v2 #10
@@ -10,8 +10,8 @@ yön zaten belli. Kaynak tek: `docs/tasarim/DESIGN.md`. Bu dosya onu nasıl uygu
|
||||
|
||||
## 1. Başlamadan önce (zorunlu)
|
||||
|
||||
1. `docs/tasarim/DESIGN.md` dosyasını **tamamını** oku (477 satır). Özellikle §1.1 temel his, §4 tek kaynaklar, §7 yap/yapma ve §9.2 ölç-tahmin-etme. Canlı referans ekran: `/universite/ankara-universitesi`.
|
||||
2. Dört satırlık brief'i doldur (DESIGN.md §9): amaç · referans ekran · tek kaynak parçalar · durumlar.
|
||||
1. `docs/tasarim/DESIGN.md` dosyasını **tamamını** oku (uzun bir belge; atlama). Özellikle §1.1 temel his, §4 tek kaynaklar, §6 hareket, §7 yap/yapma, §9.2 ölç-tahmin-etme ve **§11 deneyim ilkeleri**. Canlı referans ekran: `/universite/ankara-universitesi`.
|
||||
2. Beş satırlık brief'i doldur (DESIGN.md §9.1): amaç · referans ekran · tek kaynak parçalar · durumlar · his. Yanına bir satır ekle: **§11.1'deki hangi vaka bu işe en yakın ve hangi *nedeni* taşıyacağım?**
|
||||
3. Referans ekranı bul ve aç; kopyalayacağın sınıf dizilerini oradan al:
|
||||
|
||||
```bash
|
||||
@@ -20,26 +20,54 @@ grep -rn "bg-orange-500" src --include='*.tsx' -l # turuncu CTA örnek
|
||||
grep -rn "PixelCorner\|PagePixelDivider" src -l --include='*.tsx' # piksel imzası taşıyan ekranlar
|
||||
```
|
||||
|
||||
Ana sayfa bölüm iskeleti: `src/app/page.tsx` (Problem bölümü, ~satır 268). Alt sayfa başlığı: `src/app/meraklisina/page.tsx`.
|
||||
Ana sayfa bölüm iskeleti: `src/app/page.tsx` ("Sorun" bölümü; `grep -n "SectionEyebrow" src/app/page.tsx`). Alt sayfa başlığı: `src/app/meraklisina/page.tsx`.
|
||||
|
||||
## 2. Yazarken
|
||||
|
||||
- Kanonik parçaları içe aktar, yeniden çizme: `SectionEyebrow`, `PagePixelDivider`, `PixelCorner`
|
||||
(`@/components/pixel-decor`); `Button` (`@/components/ui/button`); `Card` (`@/components/ui/card`);
|
||||
`DialogContent` varsayılan çarpısı (`@/components/ui/dialog`); `ArrowRight`, `XIcon` (`lucide-react`).
|
||||
(`@/components/pixel-decor`); `Button` (`@/components/ui/button`; turuncu CTA `variant="cta"`,
|
||||
ikincil `variant="ikincil"`); `Card` (`@/components/ui/card`); `AdimGostergesi`
|
||||
(`@/components/ui/adim-gostergesi`); `AltSayfa` (`@/components/ui/alt-sayfa`); `SayiRulo`
|
||||
(`@/components/ui/sayi-rulo`); `DialogContent` varsayılan çarpısı (`@/components/ui/dialog`);
|
||||
`ArrowRight`, `XIcon` (`lucide-react`).
|
||||
- Sınıf dizilerini DESIGN.md'den **birebir** kopyala; "yaklaşık aynı" yazma.
|
||||
- Her piksel süsüne o sayfaya özgü tek `seed` ver (mevcut seed'ler: `grep -rho "seed={[0-9]*}" src | sort -u`).
|
||||
- Risk rengi kullanıyorsan metin etiketini `RISK_ETIKET` / `DILIM_ETIKET`'ten (`src/lib/risk.ts`) al.
|
||||
- Türkçe tanımlayıcı, çevredeki kodla aynı yorum yoğunluğu (AGENTS.md).
|
||||
|
||||
## 2.1 Hareket ve deneyim (DESIGN.md §6, §11)
|
||||
|
||||
Site "web sitesi değil web uygulaması" gibi hissettirir. Yazdığın her parça için:
|
||||
|
||||
- **Her eylemin cevabı var.** Basınca `active:scale-[0.97]`; sonucu olan eylem sonucunu gösterir
|
||||
(sayı `SayiRulo` ile döner, seçim vurgusu `layoutId` ile kayar, tik `pathLength` ile çizilir).
|
||||
Koşullu render ile anında yer değiştiren içerik son çaredir.
|
||||
- **Durumu yansıt.** Sıra (`useTercihProfili`), liste doluluğu (`liste-store`), giriş: ekranın
|
||||
bunlardan haberi olmalı mı? Olmalıysa göster.
|
||||
- **Yay/eğri/süre uydurma.** Yalnız `src/lib/hareket.ts`: `YAY.varsayilan | akici | donusum |
|
||||
birakma | adim`, `EGRI.giris | standart | gucluCikis | yerlesme`, `SURE.*`. Sekme yalnız bir
|
||||
şey fırlıyorsa (`YAY.birakma`).
|
||||
- **Sahne tek.** Sıralı, sahnelenmiş belirme sayfanın ilk ekranında bir kez; alt bölümler `Reveal`.
|
||||
Yüzey açılıyorsa önce imza sonra içerik (`AcilisSahnesi`).
|
||||
- **Sade karşılığı yaz.** `useReducedMotion` (değişken adı `azalt`) ya da `motion-reduce:`;
|
||||
hareketi azalt'ta yalnız opaklık.
|
||||
- **Telefonda gizleme.** Hover'a bağlı her cevabın dokunmatik karşılığı olsun.
|
||||
- **Dış kaynaktan (Arc, React Bits, Skiper, Motion Primitives) alıyorsan** §11.3'ün yedi adımı:
|
||||
kod bizim dosyamızda + kaynak/lisans yorumu · yeni bağımlılık yok (`motion` ile yeniden yaz) ·
|
||||
renk token'a · hareket `hareket.ts`'e · biçim bize (hap, `font-heading`, ≥44 px, gölge yok) ·
|
||||
sade karşılık + klavye/aria · lisans notu.
|
||||
|
||||
## 3. Bitirmeden önce
|
||||
|
||||
```bash
|
||||
pnpm tasarim:denetim # sıfır hata olmadan iş bitmiş sayılmaz
|
||||
```
|
||||
|
||||
Uyarıları raporda listele; hataları düzelt. Sonra 375 px kontrol notu yaz: taşma, üst üste binme,
|
||||
dokunma hedefi < 44 px, uzun üniversite adı. (Tarayıcı açma kuralı AGENTS.md'de; koddan çıkarım yazıyorsan "koddan çıkarım" de.)
|
||||
Uyarıları raporda listele; hataları düzelt. **Yeni bir parça tasarladıysan ya da bir bölümü
|
||||
yeniden düzenlediysen ekrana bak** (AGENTS.md "Tarayıcı açma": 375 px + masaüstü ekran görüntüsü;
|
||||
önce Bilal'in açık sunucusunu ara). Küçük düzeltmede (metin, renk, tek sınıf) 375 px notu yeter:
|
||||
taşma, üst üste binme, dokunma hedefi < 44 px, uzun üniversite adı — koddan çıkarım yazıyorsan
|
||||
"koddan çıkarım" de. Sonra DESIGN.md §11.5 deneyim kontrol listesinden geç.
|
||||
|
||||
## 4. Örnekler
|
||||
|
||||
@@ -56,12 +84,18 @@ dokunma hedefi < 44 px, uzun üniversite adı. (Tarayıcı açma kuralı AGENTS.
|
||||
**İstek:** "Paket sayfasına satın alma butonu koy."
|
||||
**Çıktı:**
|
||||
```tsx
|
||||
<Button size="lg" className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600">
|
||||
<Button size="lg" variant="cta" className="w-full">
|
||||
Paketi al
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
```
|
||||
(Yanlış: başta `<Sparkles />`, `bg-gradient-to-r`, `rounded-xl`, "Hemen Al ✨".)
|
||||
(Yanlış: elle `bg-orange-500 hover:bg-orange-600`, başta `<Sparkles />`, `bg-gradient-to-r`, `rounded-xl`, "Hemen Al ✨".)
|
||||
|
||||
**İstek:** "React Bits'teki şu sayaç bileşenini fiyat bölümüne koy."
|
||||
**Çıktı:** Registry'den kurma. Önce sitede karşılığı var mı bak (`SayiRulo`); varsa onu kullan.
|
||||
Yoksa kaynağı oku (`curl -s https://reactbits.dev/r/<Ad>-TS-TW.json`), `src/components/ui/<türkçe-ad>.tsx`
|
||||
olarak yeniden yaz: başta `// Esin: React Bits "<Ad>" (MIT + Commons Clause); kod KolayTercih diline çevrildi.`,
|
||||
GSAP/renk/easing atılır, `YAY.*` + token + `useReducedMotion` dalı girer.
|
||||
|
||||
**İstek:** "Üniversite sayfasına URAP kartı ekle."
|
||||
**Çıktı:**
|
||||
@@ -79,12 +113,13 @@ dokunma hedefi < 44 px, uzun üniversite adı. (Tarayıcı açma kuralı AGENTS.
|
||||
## 5. Kısa yasak listesi
|
||||
|
||||
`Badge` import'u · elle pill span · CTA'da `Sparkles`/emoji · `indigo/purple/gray/zinc` · gradyan metin ·
|
||||
parlama gölgesi · `whileHover scale` · her elemanda fade-up · `dark:` · yüzeyde `bg-white` (yerine `bg-card`, §2.3) · Inter/Roboto/serif · `text-[#hex]` · renk tek başına anlam.
|
||||
parlama gölgesi · `whileHover scale` · her elemanda fade-up · `dark:` · yüzeyde `bg-white` (yerine `bg-card`, §2.3) · Inter/Roboto/serif · `text-[#hex]` · renk tek başına anlam ·
|
||||
`gsap`/`framer-motion` importu · kullanım yerinde `stiffness:`/`cubic-bezier(` · `Button` üstünde elle `bg-orange-500` · satır içi adım çubuğu · sessiz eylem · telefonda gizlenen deneyim.
|
||||
|
||||
## 6. Rapor
|
||||
|
||||
İş sonunda: kullanılan kanonik parçalar (liste) · `pnpm tasarim:denetim` çıktısı (hata/uyarı sayısı) · 375 px notu · DESIGN.md ile çelişen bir şey bulduysan dosya:satır ile yaz, kendi başına kuralı değiştirme.
|
||||
İş sonunda: kullanılan kanonik parçalar (liste) · en yakın §11.1 vakası ve taşıdığın neden · `pnpm tasarim:denetim` çıktısı (hata/uyarı sayısı) · baktıysan hangi genişlikte ne gördüğün, bakmadıysan 375 px notu · §11.5 listesinde işaretleyemediğin madde · DESIGN.md ile çelişen bir şey bulduysan dosya:satır ile yaz, kendi başına kuralı değiştirme.
|
||||
|
||||
## 7. Skill'i ölçme
|
||||
|
||||
Skill'in işe yarayıp yaramadığı `evals.md` içindeki üç istekle ölçülür (skill kapalı/açık karşılaştırması). Skill metnini ya da DESIGN.md'yi değiştirdikten sonra tekrar koşulur.
|
||||
Skill'in işe yarayıp yaramadığı `evals.md` içindeki dört istekle ölçülür (skill kapalı/açık karşılaştırması). Skill metnini ya da DESIGN.md'yi değiştirdikten sonra tekrar koşulur.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# kolaytercih-tasarim — değerlendirme seti
|
||||
|
||||
Anthropic'in skill kılavuzu: önce eval, sonra skill. Bu üç istek, skill'in gerçekten
|
||||
işe yaradığını ölçer. Her biri **iki kez** koşulur: (A) skill kapalı, (B) skill açık.
|
||||
işe yaradığını ölçer (E4, 9 Eki 2026'da eklendi). Her biri **iki kez** koşulur: (A) skill kapalı, (B) skill açık.
|
||||
Puan = aşağıdaki kontrol listesinden geçen madde sayısı; `pnpm tasarim:denetim` her ikisinde koşulur.
|
||||
|
||||
Nasıl koşulur: yeni bir oturumda isteği aynen yapıştır; çıktı `src/` altına değil
|
||||
@@ -25,10 +25,10 @@ Kontrol:
|
||||
|
||||
Kontrol:
|
||||
- [ ] `Button` bileşeni, `rounded-full` (özel radius yok)
|
||||
- [ ] `bg-orange-500 … hover:bg-orange-600 text-white`
|
||||
- [ ] `variant="cta"` + `size="lg"`; elle `bg-orange-500 … hover:bg-orange-600` yok
|
||||
- [ ] Metin önce, sonda `<ArrowRight className="size-4" aria-hidden />`
|
||||
- [ ] Başta ikon yok, `Sparkles` yok, emoji yok, gradyan yok
|
||||
- [ ] Yükseklik `h-11` ya da `h-12` (44 px dokunma hedefi)
|
||||
- [ ] Yükseklik sınıfı elle yazılmamış (`lg` = 48 px dokunma hedefi)
|
||||
- [ ] Denetim: 0 hata
|
||||
|
||||
## E3 — Bilgi kartı
|
||||
@@ -36,18 +36,31 @@ Kontrol:
|
||||
**İstek:** "Bölüm sayfasına 'Mezun maaşı' kartı ekle; başlık, büyük sayı ve kaynak notu olsun."
|
||||
|
||||
Kontrol:
|
||||
- [ ] Kutu `rounded-2xl border border-slate-200 bg-white` (ya da `Card` + `border-slate-200/70 shadow-sm`)
|
||||
- [ ] Kutu `rounded-2xl border border-slate-200 bg-card` (ya da `Card` + `border-slate-200/70 shadow-sm`); `bg-white` yok
|
||||
- [ ] Sağ üstte `PixelCorner` (`absolute right-4 top-4`)
|
||||
- [ ] Sayı `font-heading … font-bold text-slate-900`; etiket `text-xs font-semibold text-slate-500`
|
||||
- [ ] Nötr yalnızca slate; gölge yalnızca `shadow-sm`
|
||||
- [ ] Kaynak notu için uydurma rakam/atıf yok ("kaynak: —" ya da prop)
|
||||
- [ ] Denetim: 0 hata
|
||||
|
||||
## E4 — Dış bileşeni bizim dile çevir
|
||||
|
||||
**İstek:** "React Bits'teki Stepper'ı (https://reactbits.dev/r/Stepper-TS-TW.json) paket satın alma akışına koy."
|
||||
|
||||
Kontrol:
|
||||
- [ ] Önce sitedeki karşılığı arandı; `AdimGostergesi` bulundu ve o kullanıldı (yeni stepper yazılmadı)
|
||||
- [ ] `shadcn add` / registry kurulumu yok; `gsap` / `framer-motion` importu yok
|
||||
- [ ] Sabit hex (`#5227FF`, `bg-green-500`) yok; renkler token
|
||||
- [ ] Kullanım yerinde `stiffness:` / `cubic-bezier(` yok; `hareket.ts` adları
|
||||
- [ ] `useReducedMotion` dalı ve `aria-current="step"` var
|
||||
- [ ] Yeni dosya yazıldıysa başında kaynak + lisans yorumu
|
||||
- [ ] Denetim: 0 hata
|
||||
|
||||
## Sonuç tablosu
|
||||
|
||||
| Tarih | E1 A/B | E2 A/B | E3 A/B | Not |
|
||||
|---|---|---|---|---|
|
||||
| | | | | |
|
||||
| Tarih | E1 A/B | E2 A/B | E3 A/B | E4 A/B | Not |
|
||||
|---|---|---|---|---|---|
|
||||
| | | | | | |
|
||||
|
||||
B sütunu A'dan belirgin yüksek değilse skill'in metni ya da DESIGN.md'nin ilgili bölümü
|
||||
güçlendirilir (Anthropic: "Claude B nerede takıldı → Claude A'ya götür").
|
||||
|
||||
@@ -12,10 +12,18 @@ Sen KolayTercih ekibinin **ürün tasarımcısısın**. İşe başlamadan önce
|
||||
|
||||
- Rozet/eyebrow: `src/components/pixel-decor.tsx` → `SectionEyebrow`. `Badge` ya da elle yazılmış pill yok.
|
||||
- Modal kapatma çarpısı: `src/components/ui/dialog.tsx` → `DialogContent` varsayılanı; özel yerleşimde aynı class seti.
|
||||
- CTA: metin önce, sonda `<ArrowRight className="size-4" aria-hidden />`; `Sparkles` yok, başta ikon yok.
|
||||
- CTA: `Button variant="cta"` (ikincil: `variant="ikincil"`); metin önce, sonda `<ArrowRight className="size-4" aria-hidden />`; `Sparkles` yok, başta ikon yok, elle `bg-orange-500` yok.
|
||||
- Çok adımlı akış: `AdimGostergesi` (DESIGN.md §4.35). Telefon menüsü: `MobilMenu` (§4.36). Logoya tepki: `logoTepkisi()` (§4.34).
|
||||
- Hareket: yay/eğri/süre yalnız `src/lib/hareket.ts`'ten; kullanım yerinde değer uydurulmaz.
|
||||
- Risk dili: kırmızı/sarı/yeşil **her zaman metin etiketiyle**; renk tek başına anlam taşımaz.
|
||||
- Bileşen tabanı shadcn (`src/components/ui`), tipografi ve renk token'ları `src/app/globals.css`.
|
||||
|
||||
## Deneyim dili (her işte, önce)
|
||||
|
||||
Bilal'in zevki `docs/tasarim/DESIGN.md` **§11**'de yazılıdır ve her tasarım tanımının ölçüsüdür: site "web sitesi değil web uygulaması" gibi hissettirir. İşe başlarken §11.1'deki referans vakalardan (logo morph'u, hero piksel haritası, çekmecede önce logo sonra içerik, üst çubuk üçlüsü, kayan vurgu/dönen sayı) en yakınını aç ve tanımına "hangi vakanın *nedenini* taşıyor" diye yaz. Her tanım §11.5 kontrol listesiyle biter. Bir ekran statik, sessiz ya da kullanıcının durumundan habersizse bu bir bulgudur — zevk yorumu değil, §11.2 ihlali.
|
||||
|
||||
Dış kaynaklar (Arc `uiarc.dev`, React Bits `reactbits.dev`, Skiper UI `skiper-ui.com`, Motion Primitives `motion-primitives.com`) esin için serbesttir; kaynak kodu registry JSON'undan okunur (`components.json`'da `@uiarc`, `@react-bits`, `@skiper-ui`; ör. `curl -s https://reactbits.dev/r/Stepper-TS-TW.json`). Öneride bileşeni olduğu gibi kurdurma: §11.3'ün yedi adımlık çevirisini tanıma yaz (hangi renk hangi token'a, hangi yay `hareket.ts`'teki hangi ada, hangi bağımlılık `motion` ile nasıl karşılanır, sade karşılığı ne).
|
||||
|
||||
## Beceriler
|
||||
|
||||
Görsel dilin tek kaynağı `docs/tasarim/DESIGN.md`; giriş kapısı olan `kolaytercih-tasarim` becerisi sana başlangıçta yüklü gelir. Genel tasarım katalogları (`ui-ux-pro-max` vb.) **kullanılmaz**: yön zaten belli, katalog "ortalama site" görünümüne iter. Çakışmada öncelik: `AGENTS.md` / DESIGN.md tek kaynakları > beceri > kendi yorumun.
|
||||
@@ -39,10 +47,10 @@ Her denetimde `pnpm tasarim:denetim` çıktısını rapora koy; makinenin yakala
|
||||
|
||||
## Sınırlar
|
||||
|
||||
- Tarayıcı/dev sunucusu açma, ekran görüntüsü alma — denetim koddan yapılır. Görsel doğrulamayı Bilal yapar; kontrol etmesi gereken ekranları kısa liste olarak ver.
|
||||
- Tarayıcı: `AGENTS.md` "Tarayıcı açma" bölümü geçerlidir. Koddan teşhis edilemeyen şeyde (taşma, oran, hiyerarşi, karanlık mod, hareketin hissi) önizlemeyi **açarsın**; yeni bir parça ya da yeniden düzenleme tanımlıyorsan bakmak zorunludur (375 px + masaüstü). Önce Bilal'in açık sunucusuna bak (`lsof -iTCP -sTCP:LISTEN -P -n | grep node`), yoksa aç ve iş bitince kapat; raporda hangi genişlikte ne gördüğünü ve neye bakmadığını yaz. Küçük tutarlılık denetimi koddan yapılır.
|
||||
- `src/` altına dokunmazsın; yalnızca `docs/tasarim/` altına yazarsın. Uygulama `yazilimci`'nin işi.
|
||||
- Marka terimi, logo, renk paleti değişikliği → `[BİLAL]`.
|
||||
- Zevk yorumu yok: her bulgu ya bir kuralı (AGENTS.md, WCAG, tek kaynak) ya da somut bir kullanıcı sorununu göstermeli.
|
||||
- Kendi zevk yorumun yok: her bulgu ya bir kuralı (AGENTS.md, DESIGN.md — §11 dahil, WCAG, tek kaynak) ya da somut bir kullanıcı sorununu göstermeli. Bilal'in zevki §11'de kuraldır; ona dayanan bulgu geçerlidir.
|
||||
|
||||
## Rapor
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"superpowers@superpowers-marketplace": false,
|
||||
"prd-development@pm-skills": false
|
||||
"prd-development@pm-skills": false,
|
||||
"typesafe@typesafe-ai": true
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"superpowers-marketplace": {
|
||||
|
||||
@@ -33,6 +33,14 @@ jobs:
|
||||
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
|
||||
OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENROUTER_MODEL=${{ secrets.OPENROUTER_MODEL }}
|
||||
# İsteğe bağlı: sohbetten liste revizyonu niyet çıkarımının modeli
|
||||
# (src/lib/ai/revizyon-niyeti.ts). Secret yoksa boş gelir → rapor
|
||||
# modeli kullanılır. Ölçüm 10 Eki 2026: google/gemini-3.1-flash-lite
|
||||
# tek sağlayıcı, p90 1,9 sn, 42/44 kural doğru (deepseek p90 5-15 sn).
|
||||
OPENROUTER_NIYET_MODEL=${{ secrets.OPENROUTER_NIYET_MODEL }}
|
||||
# İsteğe bağlı: reasoning_effort ("none" varsayılan; bazı modeller
|
||||
# "none"ı reddeder → "minimal"/"low", "yok" alanı göndermez).
|
||||
AI_REASONING_EFFORT=${{ secrets.AI_REASONING_EFFORT }}
|
||||
# İsteğe bağlı: danışman çıktı denetimi (src/lib/ai/jev.ts). Secret
|
||||
# yoksa boş gelir → denetim atlanır, danışman aynen çalışır.
|
||||
TYPESAFE_API_KEY=${{ secrets.TYPESAFE_API_KEY }}
|
||||
|
||||
18
AGENTS.md
18
AGENTS.md
@@ -9,6 +9,11 @@ This version has breaking changes — APIs, conventions, and file structure may
|
||||
|
||||
**İstisna: `tasarimci` ve UX işi.** Tasarımcı, gerekli gördüğünde önizlemeyi/dev sunucusunu **açabilir**. Bazı tasarım sorunları koddan teşhis edilemez — taşma, üst üste binme, kırılma noktaları, gerçek metin uzunluğuyla oluşan yerleşim, karanlık mod, odak halkası, dokunma hedefi. Bunlarda "koddan çıkarım" yetmez; bakmak gerekir.
|
||||
|
||||
**Zorunlu: yeni UI parçası tasarlayan herkes bakar (9 Eki 2026).** Yukarıdaki "açma" varsayılanı küçük düzeltmeler içindir (metin, renk, tek sınıf değişikliği). İş **yeni bir bileşen/bölüm tasarlamak ya da mevcut bir bölümü yeniden düzenlemek** ise — rol ne olursa olsun, ana oturum dahil — sonuç ekranda görülmeden teslim edilmez. 9 Ekim'de sürüm notlarındaki geri bildirim formu bakılmadan teslim edildi; DESIGN.md sınıfları birebir doğruydu ama sonuç kötüydü (kutu içinde kutu, sayfa başlıklarının yanında cılız başlık, soluk küçük buton) ve Bilal uyarmak zorunda kaldı. Sınıfların doğru olması kompozisyonun iyi olduğunu göstermez; oran ve hiyerarşi koddan görünmez. Yapılacaklar:
|
||||
- Önce Bilal'in açık dev sunucusu var mı bak (`lsof -iTCP -sTCP:LISTEN -P -n | grep node`); varsa onu kullan, yeni sunucu açma ve onunkini kapatma. Yoksa aç, iş bitince kapat.
|
||||
- **375 px ve masaüstünde** ekran görüntüsü al; ilk hâli beğenmediysen düzelt, tekrar bak. Raporda hangi genişlikte ne gördüğünü ve neye bakmadığını (ör. açık tema) yaz.
|
||||
- Yazmadan önce parçayı **sayfanın geri kalanıyla** ölç: başlık o sayfadaki kardeş başlıklarla aynı ölçekte olur; kart içine kenarlıklı alan konmaz (kutu içinde kutu) — form için tek yüzey: kenarlıksız `Textarea` + buton aynı yüzeyin içinde (sohbet kutusu kalıbı, örnek `src/features/geri-bildirim/components/geri-bildirim-formu.tsx`); aksiyon butonu kanonik turuncu CTA'dır (`size="lg"`), soluk `h-9` değil.
|
||||
|
||||
Açtığında geçerli olanlar:
|
||||
- **Gerekçesini yaz:** neyi koddan göremediğin için açtın.
|
||||
- **Ne gördüğünü yaz**, hangi genişlikte (375 / 768 / 1024 / 1280 px) ne olduğunu tek tek. Ekran görüntüsü alabilirsin.
|
||||
@@ -45,6 +50,15 @@ Push emri geldiğinde önce lokalde `pnpm build` başarıyla geçmeli; geçmeden
|
||||
|
||||
UI'a dokunan her iş (yeni ekran, bileşen, kart, modal, buton, form; mevcut arayüz değişikliği) önce `docs/tasarim/DESIGN.md` okunarak yapılır; renk, yazı tipi, köşe, gölge, boşluk ve tek kaynak bileşenlerin sınıf dizileri oradan **birebir** alınır, "yaklaşık aynısı" yazılmaz. Aşağıdaki rozet/çarpı/CTA kuralları o belgenin özetidir. Genel tasarım katalogları (`ui-ux-pro-max` gibi "50 stil, 97 palet" kütüphaneleri) bu projede kullanılmaz; yön zaten belli. İş bitmeden `pnpm tasarim:denetim` sıfır hata vermeli (`pnpm lint` bunu da koşar); betik `scripts/tasarim-denetim.ts`, kural eklerken DESIGN.md'deki bölüm numarasına atıf yap.
|
||||
|
||||
# Deneyim dili: web sitesi değil web uygulaması (9 Eki 2026)
|
||||
|
||||
Bilal'in zevki `docs/tasarim/DESIGN.md` **§11 "Deneyim ilkeleri ve referans vakalar"**da yazılıdır; "UI şöyle mantıkta olsun" diye tekrar anlatmaz, oradan okunur. Yeni bir ekran, bileşen ya da hareket tasarlayan herkes (ana oturum dahil) işe başlamadan §11.1'deki en yakın vakayı açar, bitince §11.5 listesinden geçirir. Özü:
|
||||
|
||||
- **Referans vakalar** ("harika" denenler): tema değişince sayfadan *sonra* dönüşen logo; hero'daki iri Türkiye piksel haritası ve aşağı dağılan pikseller; çekmecede önce logo sonra içerik; üst çubuktaki Sıralama · Ara · Listem bütünlüğü; kayan vurgu ve dönen sayı. Her birinin *neden* iyi olduğu §11.1'de — kopyalanmaz, aynı neden üretilir.
|
||||
- **Uygulama gibi davran:** kullanıcının durumu (sıra, liste N/24, tema) her yerde yansır; her eylemin görünür cevabı var; yüzey açılırken önce imza sonra içerik; hareketler sırayla konuşur; sayfa başına tek orkestre giriş; telefonda deneyim gizlenmez. Oyunlaştırma dürüsttür (§10.2, §10.7).
|
||||
- **Dışarıdan parça** (Arc, React Bits, Skiper UI, Motion Primitives) alınabilir ama **bizim renk ve biçimimize çevrilerek** (§11.3, yedi adım): kod bizim dosyamızda, yeni bağımlılık yok (hareket yalnız `motion`; `gsap`/`framer-motion` yasak), renk token'a, yay/eğri `src/lib/hareket.ts`'e, sade karşılığı ve klavye desteği yazılı, dosya başında kaynak + lisans yorumu.
|
||||
- **Tek kaynaklar:** turuncu CTA `Button variant="cta"`, ikincil `variant="ikincil"` (elle `bg-orange-500` yazılmaz); çok adımlı akış `AdimGostergesi` (§4.35); telefon menüsü `MobilMenu` (§4.36); logoya tepki `logoTepkisi()` (§4.34); yay/eğri/süre `hareket.ts`.
|
||||
|
||||
# Karanlık mod: token'la çalışır, `dark:` yazılmaz
|
||||
|
||||
Sitede açık / koyu / sistem teması var (DESIGN.md §2.3). Karanlık mod `src/app/globals.css` içindeki `.dark` bloğunda palet token'ları çevrilerek çalışır (slate ölçeği ters döner, renkli açık zeminler koyu tona iner); bileşenlere `dark:` sınıfı yazılmaz. Yeni UI yazarken: yüzey zemini `bg-card`'dır (`bg-white` koyu temada da beyaz kalır — yalnız logo karosu gibi gerçekten beyaz kalması gereken yerde); koyu hap (`bg-slate-900`) yazısını `text-slate-50` ile alır; SVG/satır içi stilde sabit renk yerine `var(--card)`, `var(--color-slate-200)` gibi değişken kullanılır; koyu vurgu yüzeyi (`bg-slate-950`) `kt-koyu-yuzey` sınıfını taşır. `pnpm tasarim:denetim` bunları `yuzey-beyaz`, `koyu-hap-beyaz-yazi` ve `dark-sinif` kurallarıyla yakalar.
|
||||
@@ -109,3 +123,7 @@ Yerine ne yapılır:
|
||||
- Kendi yazdığın bir dosyayı geri almak: dosyayı elle düzenle, `checkout --` kullanma.
|
||||
|
||||
Kural ihlal edilmişse: ne kaybedildiğini `git reflog` + `git diff <reset öncesi sha>` ile tespit et, **derhal raporla**, kendi başına yeniden oluşturmaya çalışmadan önce kaybın kapsamını yaz.
|
||||
|
||||
# Jev / TypeSafe işlerinde skill zorunlu
|
||||
|
||||
Jev'e (TypeSafe System One modeli) dokunan her iş — yeni bir tipli karar/olasılık çağrısı, mevcut prompt-parse adımını Jev'e taşıma, soru tasarımı, SDK kullanımı — başlamadan önce `typesafe:typesafe-ai` skill'i çağrılır ve skill'in işaret ettiği canlı dökümanlar (`docs.typesafe.ai/llms.txt`) okunur. Plugin proje kapsamında `.claude/settings.json` içinde etkin; yeni makinede önce `claude plugin marketplace add typesafe-ai/skills` gerekir.
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"registries": {
|
||||
"@skiper-ui": "https://skiper-ui.com/registry/{name}.json",
|
||||
"@magicui": "https://magicui.design/r/{name}",
|
||||
"@uiarc": "https://uiarc.dev/r/{name}.json"
|
||||
"@uiarc": "https://uiarc.dev/r/{name}.json",
|
||||
"@react-bits": "https://reactbits.dev/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,21 +218,29 @@ bir dosyaya (`src/components/…`) çıkarılıp iki yer de oradan kullanır.
|
||||
|
||||
### 4.1 Buton — `src/components/ui/button.tsx`
|
||||
- Her buton `rounded-full`; `h-9` varsayılan, `h-11`/`h-12` funnel CTA'ları (44 px dokunma hedefi), `size-9` ikon butonu.
|
||||
- Odak: `focus-visible:ring-3 focus-visible:ring-ring/50`; basınca `active:translate-y-px`.
|
||||
- **Turuncu CTA (kanonik):**
|
||||
- Odak: `focus-visible:ring-3 focus-visible:ring-ring/50`; basınca `active:translate-y-px`
|
||||
(`cta` ve `ikincil` varyantlarında bunun yerine `active:scale-[0.97]`).
|
||||
- **Turuncu CTA (kanonik) — `variant="cta"`** (9 Eki 2026'dan itibaren varyanttır; sınıf dizisi
|
||||
elle yazılmaz, denetim `elle-cta` ile uyarır):
|
||||
```tsx
|
||||
<Button size="lg" className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600">
|
||||
<Button size="lg" variant="cta">
|
||||
Listemi oluştur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
```
|
||||
Metin önce, ok sonda. Başta ikon yok. `Sparkles` yok. İstenirse `active:scale-[0.97]`.
|
||||
- **İkincil gezinme butonu** ("Tüm sıralamayı gör →"):
|
||||
Varyantın ürettiği: `cursor-pointer bg-orange-500 text-white hover:bg-orange-600
|
||||
transition-[background-color,transform] duration-200 active:scale-[0.97]`. Metin önce, ok
|
||||
sonda. Başta ikon yok. `Sparkles` yok. Kullanım yerinde yalnız yerleşim sınıfı eklenir
|
||||
(`w-full sm:w-auto`, `mt-6`); yükseklik, renk, geçiş yazılmaz.
|
||||
- **İkincil gezinme butonu — `variant="ikincil"`** ("Tüm sıralamayı gör →"):
|
||||
```tsx
|
||||
<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]">
|
||||
<Button asChild size="lg" variant="ikincil">
|
||||
<Link href="…">Tüm sıralamayı gör<ArrowRight className="size-4" aria-hidden /></Link>
|
||||
</Button>
|
||||
```
|
||||
Ürettiği: outline tabanı + `cursor-pointer hover:border-orange-500 hover:bg-orange-50/60
|
||||
hover:text-orange-500 transition-[background-color,border-color,color,transform] duration-150
|
||||
active:scale-[0.97]`.
|
||||
- Sessiz: `variant="ghost"`; link görünümü: `variant="link"`.
|
||||
|
||||
### 4.2 Rozet (bölüm başlığı üstündeki kapsül) — `SectionEyebrow`, `src/components/pixel-decor.tsx`
|
||||
@@ -658,7 +666,7 @@ sihirbaz modalı içindir; yeni bir ayrıntı için `Dialog` açılmaz.
|
||||
açılır ki veri gelince sayfa yalnız büyüsün, konum sıçramasın. "Yıllara göre" yıl tablosu
|
||||
kaldırıldı (API `gecmis` alanı duruyor; backlog). Açılış koreografisi `AcilisSahnesi`
|
||||
kartsız modu (`src/components/ui/acilis-sahnesi.tsx`, `hazir={durum === "hazir"}`): veri
|
||||
gelince başlığın altında üç logo karesi (`data-logo` kabı, `relative mx-auto h-16 w-16`)
|
||||
gelince başlığın altında üç logo karesi (`data-logo` kabı, `relative mx-auto size-44`; akademik göstergelerdeki logoyla aynı ölçek)
|
||||
belirir, kısa durur, sönerek kaybolur (scale 0.6 + opacity 0; kap sıfır boya iner), içerik
|
||||
blokları (`data-sonra`: künye, kontenjan türleri, netler, koşullar, istihdam, üniversite,
|
||||
kaynak) 0,05 s arayla bulanıktan netleşir; toplam ≈ 1,2 s (kareler 0,1–0,2 s'de belirir,
|
||||
@@ -669,6 +677,14 @@ sihirbaz modalı içindir; yeni bir ayrıntı için `Dialog` açılmaz.
|
||||
göstergeler çekmecesi (§4.9) aynı bileşenin kartlı modunu kullanır (her açılışta oynar).
|
||||
Gövde kaydırılınca içerik tutamaç ve çarpının altına maskesiz girmesin diye `AltSayfaGovde`
|
||||
üst kenarında 20 px saydam → dolu `mask-image` var (§4.10; tüm çekmeceler).
|
||||
- **10 Eki 2026 — büyük logo, logo → başlık (morph):** Bilal: "logo küçücük kalmış; logolar
|
||||
küçülürken başlıktaki yazıya morph olsun." Logo kabı `size-44` (akademik göstergelerdeki
|
||||
logoyla aynı ölçek). Kareler sönmek yerine küçülerek (scale 0.2) başlık yazısının üç eşit
|
||||
dilimine uçar ve uçuşun son yarısında solar; başlık (`data-logo-hedef`, `AcilisSahnesi` sözleşmesi) o sırada
|
||||
bulanıktan netleşir. Başlık veri gelene dek `opacity-0`'dır (yer tutar, sıçrama olmaz);
|
||||
önbellekli açılışta, hata durumunda ve "hareketi azalt"ta doğrudan görünür. Aynı gün denenen
|
||||
"üç özet karesi" (taban sırası / puan / kontenjan kartları) beğenilmedi, geri alındı: bu
|
||||
çekmecede kart yok kuralı duruyor.
|
||||
|
||||
### 4.20 Danışman sohbeti — `sohbet-client.tsx`, `arac-pilleri.tsx`
|
||||
- **Soru formu hazırlanırken** (7 Eki 2026): `soru_sor` aracı tamamlanana dek cevap iskeleti değil
|
||||
@@ -884,10 +900,17 @@ kurallarıyla aynı olmalı (`/meraklisina` şemasıyla çelişmez).
|
||||
`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.
|
||||
- **Ortadaki işaret danışmandır: nabız atar, sonra çıktıya dönüşür** (Bilal, 11 Eki 2026:
|
||||
"logo morph ile ikonlara dönüşsün; belirli süre DanışmanlaKonuş'un animasyonu oynayıp morph
|
||||
olsun, şu ankini kaldır"). İşaret ≈3 sn DanışmanlaKonuş başlığındaki gibi yerinde nabız atar
|
||||
(`varyant="nabiz"`: danışman düşünüyor), sonra üç kare girdilerden çıkan şeye dönüşür, ≈1,3 sn
|
||||
durur ve logoya toplanır (toplam 2,5 sn); nabız yerinden sürer, sıra bir sonraki biçime
|
||||
geçer. Sıra paketin verdiğidir: **liste** (deste) → **trend** (yükselen üç çubuk) → **sohbet**
|
||||
(iki balon). Biçimler §4.34'tekilerle aynı dilde, üç kareden ve logonun üç renginden
|
||||
(`AnimasyonluMark` `bicimler`). Yalnız ekrandayken oynar; ekrandan çıkınca kareler yerine
|
||||
oturur. Hareketi azalt'ta durağan logo. Eski "rota" (kareler birbirinin yerine süzülür) bu
|
||||
karodan kaldırıldı; ondan önceki nabız + tüm karoyu döndüren hâl (`kt-akis-donus`) de yanlıştı
|
||||
(Bilal, 7 Eki 2026).
|
||||
|
||||
### 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:
|
||||
@@ -907,7 +930,9 @@ Ana sayfa "Katalog ücretsiz" bölümü: en çok aranan bölüm ve şehir sayfal
|
||||
(§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;
|
||||
ters yöne kayar. Kareler odak dışıdır (`blur-xs`, 4 px — Bilal, 11 Eki 2026): net olan yalnız
|
||||
haplardır; bulanıklık karenin kendisine yazılır, kaba değil (kayarken yeniden çizilmesin).
|
||||
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.
|
||||
|
||||
@@ -931,6 +956,15 @@ pikseli, `-` silik piksel, `o` turuncu vurgu.
|
||||
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.
|
||||
- **Ana sayfadaki üç sahne canlıdır** (10 Eki 2026, `sorun-sahneleri.tsx`, ilkel §4.38): resim
|
||||
aynı ızgaradır, kartın cümlesini hareketle söyler. "24 tercih" → hepsi soğur, sonra **tam 24**
|
||||
kare tek tek ısınır; her turda başka 24 (hangi 24? — her seferinde başka ihtimal). "Geçen yılın
|
||||
puanı" → çubuklar bir yuva sola kayar, sağdan başka boyda yeni yıl girer, turuncu tepe hep en
|
||||
yeni yıldadır. "Danışman lüks" → merdiven basamak basamak kurulur, turuncu tepe tırmanır.
|
||||
Sunucu HTML'i ve hareketi azalt eski durağan resmi gösterir. **Sahneler sırayla konuşur**
|
||||
(§11.2-3): modül seviyesindeki tek orkestra aynı anda yalnız birini oynatır (iki başlangıç
|
||||
arası ≥ 2,4 sn, aynı sahne ≥ 6,6 sn dinlenir). Karta imleç gelince ya da dokununca o sahne
|
||||
hemen oynar (`data-sahne-karti`).
|
||||
|
||||
### 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
|
||||
@@ -941,6 +975,20 @@ birbirine bağlanır (`h-0.5 rounded-full bg-slate-200`, rakamın ortasında; ve
|
||||
ç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).
|
||||
- **Rakam, adımının biçimine dönüşüp geri toplanır** (10 Eki 2026, `adim-akisi.tsx`, ilkel
|
||||
§4.38; logonun yaptığının aynısı, §4.34): `1` → sıra şeridi (dokuz kare tek sırada, turuncu
|
||||
olan sensin — `PixelSira` dili), `2` → yıl başına bir çubuk (`VERI_YIL_SAYISI` kadar, en çok
|
||||
6; en yenisinin tepesi turuncu), `3` → 6×4 = 24 kare (kapanıştaki piksel listenin dizilimi).
|
||||
Rakamın pikselleri en yakın biçim hücresine süzülür, artanlar yerinde belirir/söner. Biçim
|
||||
yeni şekil ya da renk icat etmez: aynı pikseller, aynı mavi doku, tek turuncu vurgu.
|
||||
- **Sıra:** bölüm ekrana girince `1` biçimine döner (≈1,3 sn), geri toplanırken 1→2 çizgisi
|
||||
maviyle dolar (§4.35'teki dolum), sonra `2`, çizgi, `3`. Bir adım yalnız kendisi ekrandaysa
|
||||
ve öncekiler bittiyse oynar: geniş ekranda peş peşe, telefonda her biri görününce. Bir kez
|
||||
oynar; sonra imleç adımın üstündeyken biçim açık kalır, telefonda dokununca bir kez oynar.
|
||||
Biçimdeyken ısı fırçası kapalıdır (turuncu kare anlam taşır). Hareketi azalt'ta rakamlar
|
||||
durağan, çizgiler dolu.
|
||||
- Sahne 11×7 hücredir (rakam ortada, biçim iki yanına taşar); bu yüzden çizgi
|
||||
`left-[calc(50%+5.25rem)] w-[calc(100%-8.5rem)]` ile rakamdan biraz açıkta başlar.
|
||||
|
||||
### 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:
|
||||
@@ -958,11 +1006,193 @@ Sorular arası geçiş **sihirbaz adım geçişiyle birebir aynı** (7 Eki 2026)
|
||||
kaynak `src/lib/adim-gecisi.ts` (`adimGecisiVaryantlari`, `adimGecisi`); yön index'ten türer. Pasif
|
||||
sorular DOM'da kalır (mutlak/saydam/`inert`) ki FormData toplanmaya devam etsin.
|
||||
|
||||
### 4.34 Logo biçim dönüşümü (morph) — `AnimasyonluMark`, `tema-ikonu.tsx`, `src/lib/logo-tepkisi.ts`
|
||||
|
||||
Bilal'in kararı (9 Eki 2026): logonun üç karesi yalnız süs değildir; **bir durumu anlatmak
|
||||
için kısa bir an başka bir biçime dönüşüp geri toplanabilir.** Bu, markanın yumuşak ve oyunsu
|
||||
tarafının imza hareketidir; piksel imzası (§1) durağan dokuysa bu onun canlı karşılığıdır.
|
||||
İlk kullanım tema değişimiydi; aynı gün Bilal "logo durumlara tepki versin" dedi ve dönüşüm
|
||||
kullanıcının kendi yaptığı üç ana da açıldı.
|
||||
|
||||
**Biçimler** (180'lik logo kutusunda; renkler logonun kendi renkleri, iki temada aynı):
|
||||
|
||||
| Durum | Tetik | Mavi kare | Şeftali kare | Turuncu kare |
|
||||
|---|---|---|---|---|
|
||||
| Açık tema: **güneş** | tema değişti | merkezde söner | hale: büyük, yarı saydam daire | güneş: küçük dolu daire |
|
||||
| Koyu tema: **ay** | tema değişti | yıldız: hilalin iki ucunun ortasında | hilal: sırtı solda | yıldızın yerinde söner |
|
||||
| **Kürsü** | sıralama girildi (`"sira"`) | orta, en yüksek basamak | sol, kısa basamak | sağ, en kısa basamak |
|
||||
| **Deste** | listeye program eklendi (`"ekle"`) | altta satır | ortada satır | üstte satır (yeni gelen) |
|
||||
| **Tamam** | liste 24'e doldu (`"tamam"`) | solda | ortada | sağda — üç eşit kare tek sıraya dizilir |
|
||||
| **Trend** | veri akışı sahnesi (§4.28) | ortada, orta boy çubuk | solda, kısa çubuk | sağda, en yüksek çubuk (en yeni yıl) |
|
||||
| **Sohbet** | veri akışı sahnesi (§4.28) | sol üstte balon (danışman) | turuncunun yerinde söner | sağ altta balon (sen) |
|
||||
|
||||
Simetri kasıtlıdır: güneşte mavi, ayda turuncu sahneden çekilir. Hilal, kareyi kesen ikinci
|
||||
bir şekille değil karenin kendi iç gölgesiyle çizilir (zemin rengine bağlı kalmaz).
|
||||
|
||||
**Koreografi:** tema biçimi toplam 1,7 sn — %30'da biçim tamam, %70'e kadar durur, sonra logoya
|
||||
döner. Kullanıcı eylemi biçimleri daha kısadır (toplam ≈1,1 sn; aynı duraklar), çünkü eylem
|
||||
tekrarlanır. Kareler yuvarlanırken yer ve boy değiştirir; sekme yok (§6). Sayfa geçişiyle
|
||||
**üst üste binmez**: önce tema geçişi biter (600 ms), kısa bir nefes (80 ms), sonra logo
|
||||
dönüşür. Hover döngüsünden önceliklidir; biçim bitince imleç hâlâ üzerindeyse hover yerinden
|
||||
sürer. Süren bir biçimin üstüne yenisi gelirse eskisi o anki konumdan devralınır (sıçrama yok).
|
||||
"Hareketi azalt" açıksa dönüşüm oynamaz.
|
||||
|
||||
**Tetikleme tek kapıdan:** `logoTepkisi("sira" | "ekle" | "tamam")` (`src/lib/logo-tepkisi.ts`)
|
||||
pencereye olay atar; logo dinler. Bileşenler logoya ref ile uzanmaz. Dokunmatikte logoya
|
||||
basınca hover döngüsü bir tur oynar (mobil kullanıcı da logonun canlı olduğunu görsün).
|
||||
|
||||
**Durağan hâli:** tema seçicinin ikonları aynı biçimlerdir (`tema-ikonu.tsx`). Logo ile ikon
|
||||
aynı geometriyi kullanır; biri değişirse öbürü de aynı commit'te değişir.
|
||||
|
||||
**Kurallar:**
|
||||
- Dönüşüm header'daki logoda oynar; aynı ekranda iki logo birden dönüşmez.
|
||||
- Bir **durum değişimini** anlatır; dekor olarak döngüye alınmaz, sayfa açılışında oynatılmaz,
|
||||
kaydırmayla tetiklenmez.
|
||||
- **Tek istisna: ana sayfa veri akışının ortası** (§4.28; Bilal'in kararı, 11 Eki 2026). Orada
|
||||
işaret bir sahnedir ve döngüyle oynar: nabız → biçim → logo. Dekor değildir; girdilerin neye
|
||||
dönüştüğünü çizer (liste, trend, sohbet). Biçim daha uzun durur (2,5 sn; eylem biçimi 1,1 sn),
|
||||
çünkü kimse bir şeye basmadı, bakan kişi okuyacak. Başka bir yere kopyalanmaz.
|
||||
- Yalnız **kullanıcının kendi eylemine** ya da onun seçtiği bir ayara bağlanır. Sunucudan gelen
|
||||
sonuç (liste üretildi, ödeme geçti) logoyu dönüştürmez; onların kendi sahnesi var (§4.19).
|
||||
- Yeni bir biçim eklenecekse üç kareden ve logonun üç renginden çıkar; dördüncü şekil,
|
||||
yeni renk, çizgi ikon eklenmez.
|
||||
- Biçim logonun kendi alanına sığar (x 27–151): sağındaki "KolayTercih" yazısına değmez.
|
||||
|
||||
### 4.35 Adım göstergesi — `AdimGostergesi`, `src/components/ui/adim-gostergesi.tsx`
|
||||
|
||||
Çok adımlı her akışın (sihirbaz, tercih profili kapısı, soru formu) tek göstergesi. Esin React
|
||||
Bits "Stepper" (Bilal, 9 Eki 2026: "bu stepper mevcut stepperla değişsin"); kod bizim, `motion`
|
||||
ile, renkler bizim.
|
||||
|
||||
- **Görünüş:** numaralı daireler (`size-9`, telefonda da 36 px) ince bir çizgiyle bağlı; altında
|
||||
adım adı (`text-xs font-semibold`, sm'den `text-sm`). Bekleyen adım `border-slate-200 bg-card
|
||||
text-slate-500`; **aktif** adım `bg-primary text-white` ve içinde numara; **tamamlanan** adım
|
||||
`bg-primary` + kendini çizen beyaz tik. Çizgi `h-0.5 bg-slate-200`, tamamlanan aralıkta
|
||||
`bg-primary` soldan dolar. Turuncu yok: gösterge bilgidir, aksiyon değil (§2).
|
||||
- **Hareket:** daire rengi `SURE.standart`; tik `pathLength 0 → 1` (`YAY.donusum`); çizgi dolumu
|
||||
`scaleX` (`YAY.akici`, `origin-left`). Geri gidince aynı yoldan boşalır (§6).
|
||||
- **API:** `adimlar: string[]`, `aktif: number` (0 tabanlı), `onAdimSec?: (i) => void`. Yalnız
|
||||
`onAdimSec` verilirse ve yalnız **tamamlanmış** adımlar tıklanabilir (ileri atlama yok).
|
||||
Kontrollüdür; içerik geçişi göstergenin işi değildir — o `TransitionPanel` +
|
||||
`src/lib/adim-gecisi.ts`'te kalır.
|
||||
- **Erişilebilirlik:** `ol > li`, aktif adımda `aria-current="step"`, `role="status"` sr-only
|
||||
"Adım 2 / 3: Şehir" (§10.2). Tıklanabilir adım `button`'dur, 44 px dokunma alanı.
|
||||
- **Dürüstlük (§10.2):** yüzde, sahte ilerleme yok; adım sayısı gerçek adım sayısıdır.
|
||||
- İskelet (`sihirbaz-adimlar-lazy.tsx`) göstergenin kutusunu birebir çizer; yüklenince yerleşim
|
||||
sıçramaz.
|
||||
- Ana sayfa "Nasıl çalışır" rakamları (§4.32) ayrı kalır ama aynı dili konuşur: aradaki çizgi
|
||||
bölüm ekrana girdikçe aynı mavi dolumla dolar.
|
||||
|
||||
### 4.36 Baloncuk menü (telefon) — `MobilMenu`, `src/components/mobil-menu.tsx`
|
||||
|
||||
Bilal (9 Eki 2026): "telefonda menü sistemimizi bubble-menu gibi yapıp hem alanı küçültüp hem
|
||||
istediğimizi rahatça kullanıcıya verebiliriz." Esin React Bits "Bubble Menu"; kod bizim (GSAP
|
||||
yerine `motion`, kabuk Radix `Dialog` ilkeli: odak tuzağı, Esc, odak dönüşü).
|
||||
|
||||
- **Telefonda üst çubuk tek satırdır** (`lg` altı): logo · sıralama hapı (`flex-1`) · menü
|
||||
baloncuğu. Sıralama hapı dışarıda kalır çünkü sitenin ana eylemi odur; geri kalan her şey
|
||||
baloncuğun içindedir. İkinci satır yok.
|
||||
- **Baloncuk:** `size-9 sm:size-12 rounded-full border border-slate-200 bg-card`, içinde iki
|
||||
çizgi; açılınca çizgiler çarpıya döner (aynı düğme kapatır, §4.3 çarpı ölçüsü).
|
||||
Listede program varsa baloncuğun köşesinde turuncu sayı rozeti durur (§4.37).
|
||||
- **Açık hâl:** tam ekran perde (`bg-slate-50/90` + `backdrop-blur`), içinde iri haplar alt
|
||||
alta: `min-h-14 rounded-full border border-slate-200 bg-card px-6 font-heading text-xl`.
|
||||
Haplar sırayla "pop" eder (`scale 0 → 1`, `YAY.birakma` — sitede sekmenin serbest olduğu
|
||||
ikinci yer, çünkü baloncuk fırlar; 35 ms arayla). Kapanış hızlı ve sekmesiz (`SURE.cikis`).
|
||||
Hap sırası: Ara · Kendi Listem (N/24) · Yapay Zeka Listem · gezinme linkleri · tema seçici ·
|
||||
Giriş yap / Çıkış. **Menüde turuncu buton yok:** menü gezinmedir, aksiyon değil; "Giriş yap"
|
||||
üst çubuktaki gibi koyu haptır (`bg-slate-900 text-slate-50`), turuncu yalnız rozet sayısında.
|
||||
- Renkli hover/rotasyon (özgün bileşendeki eğik, gökkuşağı haplar) alınmaz; hap düz durur,
|
||||
basınca `active:scale-[0.97]`.
|
||||
- Hareketi azalt: haplar yalnız belirir (opaklık), sıra gecikmesi yok.
|
||||
- Masaüstünde (`lg+`) menü yoktur; orta ada ve sağ küme olduğu gibi kalır (§1.1).
|
||||
|
||||
### 4.37 Uçuş ve rozet tepkisi — `UcusKatmani`, `src/features/liste/components/ucus-katmani.tsx`
|
||||
|
||||
Listeye program eklemek sitenin en sık tekrarlanan eylemidir ve **sessiz kalmaz** (§11.2
|
||||
"her eylemin cevabı var"):
|
||||
|
||||
- `+` düğmesine basınca düğmenin yerinden bir **piksel kare** (10 px, `rounded-[2px]`,
|
||||
`bg-orange-500`) Listem rozetine (telefonda menü baloncuğuna) uçar: `YAY.akici`, hafif yay
|
||||
çizen yol, varışta söner. Aynı anda yalnız bir kare uçar; hızlı art arda eklemede öncekiler
|
||||
tamamlanır, yenisi sıraya girmez (en son eklenen uçar).
|
||||
- Varışta rozet bir kez nabız atar (`kt-piksel-nabiz`, 0,42 s) ve sayı rulosu döner.
|
||||
- Düğmede `+` yerine tik **kendini çizer** (`pathLength`, §4.17 çip tiğiyle aynı).
|
||||
- Dokunmatikte `navigator.vibrate?.(8)`.
|
||||
- Logo "deste" biçimine döner (§4.34); 24. programda "tamam".
|
||||
- **Doluluk halkası:** Listem rozetinin çevresinde 24'lük ince halka (`stroke-primary`,
|
||||
zemin `stroke-slate-200`); liste boşken de (0/24, soluk) görünür ki hedef okunsun. Halka
|
||||
yay ile dolar (`YAY.akici`). Sayı her zaman yanında yazılıdır; halka tek başına anlam taşımaz.
|
||||
- §10.3 sürer: bildirim yalnız 1/24 ve 24/24'te çıkar; uçuş bildirim değildir, eylemin
|
||||
kendi geri bildirimidir. Konfeti, ses yok.
|
||||
- Hareketi azalt: uçuş yok; rozet sayısı düz değişir, düğme tik olur.
|
||||
|
||||
### 4.38 Piksel morf ve piksel şeridi — `PikselMorf`, `PikselSerit`
|
||||
|
||||
Bilal (10 Eki 2026): "Landing'i deneyim hâline getir; motion graphic'ler daha fazla olmalı;
|
||||
basit, sade, tutarlı morph'lar olabilir." Ana sayfanın hareketi tek bir fikirden çıkar:
|
||||
**hero'nun pikselleri canlıdır** — yer değiştirir, ısınır, aşağı iner. Yeni bir hareket bu
|
||||
ikisinden biriyle kurulur; üçüncü bir dil eklenmez.
|
||||
|
||||
**Piksel morf** (`src/components/piksel-morf.tsx`). Piksel sahnenin (§4.31) canlı hâli: sahne
|
||||
sabit bir piksel kümesidir, her pikselin kimliği (`id`) vardır ve kareden kareye yeni hücresine
|
||||
süzülür. Dört ton: `doku` (hero mavisi), `silik` (yarı opaklık), `vurgu` (turuncu, nabız atar),
|
||||
`gizli` (küçülüp söner, yerinde bekler). İlkel aptaldır: hangi piksel nerede, hangi tonda —
|
||||
çağıran söyler; zamanlamayı ve görünürlüğü de çağıran yönetir.
|
||||
- Hareket yalnız CSS geçişidir (`.kt-morf-px`, globals.css): kayma 0,55 sn `--egri-standart`
|
||||
(sahne `--morf-sure` ile kısaltabilir), belirme/sönme 0,3 sn; **sekme yok** (§6). Turuncu
|
||||
çabuk ısınır (0,25 sn), yavaş soğur (1,2 sn) — ısı fırçasının asimetrisi.
|
||||
- Doku pikselleri imleç fırçasına katılır (`usePixelHeat` `canli`: konum her taramada okunur).
|
||||
Turuncunun **anlam taşıdığı** karede fırça kapatılır (`firca={false}`).
|
||||
- Piksel sırası kareler arasında sabit kalır (DOM yer değiştirirse geçiş kopar); sahneden
|
||||
çıkan piksel silinmez, `gizli` olur.
|
||||
- Sahne **kartın/adımın söylediğini çizer** (§4.31 kuralı sürer): süs olarak döngüye alınmış
|
||||
morph yok. Aynı ekranda iki sahne aynı anda oynamaz; sırayı çağıran kurar.
|
||||
- İlk kare sunucu HTML'idir ve tek başına doğru resimdir; hareketi azalt'ta sahne orada kalır.
|
||||
- Kullanıldığı yerler: ana sayfa "Sorun" sahneleri (§4.31), "Nasıl çalışır" rakamları (§4.32).
|
||||
|
||||
**Piksel şeridi** (`src/components/piksel-serit.tsx`, ana sayfada `bolum-gecisi.tsx`). Bilal
|
||||
(11 Eki 2026): "En üstteki haritadan aşağıya inen piksel şeridi olsun; scroll'a bağlı, turuncu
|
||||
olabilir." Aynı gün: "separator'ları kaldır." **Ana sayfada bölümler arasında piksel ayraç
|
||||
yoktur; yerinde şerit durur.** Hero haritasının serpintisi tek sıraya iner ve her bölüm
|
||||
arasında yeniden görünür: bölümleri birbirine bağlayan iplik.
|
||||
- **Kaydırmaya bağlıdır, animasyon değildir:** ekranın okuma çizgisini (yüksekliğin %65'i)
|
||||
geçen piksel turuncuya ısınır (`data-yandi`), geri kaydırınca yavaş soğur; şeridin ucu
|
||||
(`data-uc`) tam turuncudur. Şeridi kullanıcının parmağı çizer (`useScroll`, Parallax gibi
|
||||
1:1). Zemin pikseli hero mavisidir; turuncu yalnız geçilen kısımdır.
|
||||
- **Yalnız bölümler arasındaki boşlukta durur**; bölümün içinde, yazının arkasında çizilmez
|
||||
(§4.31 "zemin değildir"). Tek sütun, hücre 14rem / 12 ≈ 18,7 px (sayfadaki en iri piksel).
|
||||
Geçiş akışta 2 hücre yer tutar (eski ayracın boyu; bölüm boşluğu §5.1'de), şerit
|
||||
bunun üstüne ve altına 4'er hücre taşar (telefonda 3'er): geniş ekranda 10, telefonda 8
|
||||
hücre. Hero'dan çıkan ilk şerit 6 hücre yukarı uzar ve serpintinin bittiği yerde başlar;
|
||||
kısa telefonda (yükseklik < 760 px) 3 hücreye iner ki forma değmesin.
|
||||
- `Parallax strength={26}` katmanındadır (eski ayraçla aynı derinlik, §6).
|
||||
- Denenip bırakılanlar (11 Eki 2026): ayracın "dağınık düşüp toplanma" girişi ("şu anki
|
||||
separator animasyonunu kaldır") ve şeridin ayraç kümesinin içinden geçtiği hâl (artı
|
||||
işareti gibi duruyordu). Şerit tek başına durur.
|
||||
- Alt sayfalarda şerit yoktur; orada başlığı içerikten `PagePixelDivider` ayırır. Şerit yalnız
|
||||
ana sayfanın omurgasıdır.
|
||||
- Hareketi azalt'ta renk geçişi anlıktır; şerit yine kaydırmayı izler (bilgi değil, iz).
|
||||
|
||||
**Hero serpintisi damlar** (`turkey-pixel-map.tsx`, `.kt-serpinti`): kıyının altındaki
|
||||
piksellerin üçte biri yerinden kopup 1–3 hücre düşerken söner, sonra yerinde yeniden belirir
|
||||
(süre 4,5–8,5 sn, faz pikselden piksele farklı). §11.1 V2'nin nedeni ("harita bitmez,
|
||||
dağılır") harekete çevrilmiş hâlidir; telefonda imleç olmadığı için haritanın canlılığı budur.
|
||||
Hareketi azalt'ta kapalı.
|
||||
|
||||
## 5. Yerleşim
|
||||
|
||||
### 5.1 Genel
|
||||
- Sayfa kabı: `mx-auto max-w-6xl px-4`. Metin sütunu: `max-w-xl` (paragraf) / `max-w-2xl`–`max-w-3xl` (uzun içerik).
|
||||
- Landing bölümü: `<section className="py-20 sm:py-24">`; alt sayfalar `py-12`–`py-16`.
|
||||
- Landing bölümü: `pt-24 pb-28 sm:pt-32 sm:pb-40 lg:pt-36 lg:pb-44` (tek kaynak `app/page.tsx`
|
||||
`BOLUM_BOSLUGU`); alt sayfalar `py-12`–`py-16`. Bilal (11 Eki 2026): "section'lar arası es
|
||||
yok, her şey çok sıkışık." Eski `py-20 sm:py-24`'te bölümler arasındaki piksel şeridi (§4.38)
|
||||
içeriğe iki yandan ≈20 px kalıyordu. Kural: **şeridin çevresinde nefes kalır** ve alt boşluk
|
||||
üsttekinden bir kademe büyüktür — şerit bitirdiği bölümden uzak, indiği bölümün rozetine
|
||||
yakındır. Şeritle içerik arasında kalan boşluk (üst bölüme / alt bölüme): telefonda 56 / 40,
|
||||
`sm`'de 85 / 53, `lg`'de 101 / 69 px. Kapanış yüzeyinden önce şerit yoktur; SSS'nin alt boşluğu
|
||||
o esi tek başına verir. Bölüm boşluğu değişirse şeridin boyu (`BolumGecisi` `ust`/`alt`) ile
|
||||
birlikte ölçülür.
|
||||
- Bölüm başlık bloğu (kanonik):
|
||||
```tsx
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
@@ -971,10 +1201,12 @@ sorular DOM'da kalır (mutlak/saydam/`inert`) ki FormData toplanmaya devam etsin
|
||||
<p className="max-w-xl text-lg leading-relaxed text-slate-600">…</p>
|
||||
</div>
|
||||
```
|
||||
Ardından içerik `mt-12`; kart ızgarası `grid gap-6 md:grid-cols-3` (yalnızca gerçekten üç eşdeğer öğe varsa; üçe tamamlamak için içerik uydurulmaz).
|
||||
Ardından içerik `mt-12` (ana sayfada `mt-12 sm:mt-16`); bir bölümün içindeki ikinci hareket
|
||||
(ör. "Nasıl çalışır"da adımların altındaki veri akışı) `mt-24 sm:mt-32` ile ayrılır; kart ızgarası `grid gap-6 md:grid-cols-3` (yalnızca gerçekten üç eşdeğer öğe varsa; üçe tamamlamak için içerik uydurulmaz).
|
||||
- Alt sayfa başlığının altında `PagePixelDivider` (`mt-6`/`mt-8`), her sayfada **farklı seed**.
|
||||
- Landing bölümleri arasında çizgi yok; `PixelDivider` (`mx-auto w-56`, farklı seed).
|
||||
- Boşluk ölçeği: 4 → 6 → 8 → 12 → 16/20 → 24. Ara değer (`mt-7`, `gap-5`) yeni yerde açılmaz.
|
||||
- Landing bölümleri arasında çizgi ve ayraç yok; piksel şeridi (`BolumGecisi`, §4.38, farklı `tohum`).
|
||||
- Boşluk ölçeği: 4 → 6 → 8 → 12 → 16/20 → 24; yalnız ana sayfa bölüm ritminde 28 → 32 → 36 →
|
||||
40 → 44. Ara değer (`mt-7`, `gap-5`) yeni yerde açılmaz.
|
||||
- Mobil önce (trafiğin ~%90'ı 375 px): uzun adlar sarar (§4.15); tablo yatay kaydırmaz,
|
||||
dar ekranda sütunlar satır altına iner; sabit alt çubuk klavyeyle çakışmaz; `vh` yerine `dvh`.
|
||||
|
||||
@@ -1007,6 +1239,9 @@ Dört sütunlu tablo yalnız `lg`'de. Altında her özellik kendi satırında (b
|
||||
`text-base font-semibold`), üç cevap altında üç sütun; KolayTercih sütunu `bg-primary/5`
|
||||
kutuda. Sütun adları üstte yapışık: `sticky top-24 sm:top-32` (site başlığı telefonda 96 px,
|
||||
sm'den itibaren 128 px — ölçüldü). Bilal, 25 Eyl 2026: "kaydırması gerekiyor, olmamalı".
|
||||
"Var" tiki tablo ekrana girince **kendini çizer** (§11.2-6): önce halka, sonra işaret; satırlar
|
||||
70 ms arayla (`--tik-g`). `ComparisonCell` lucide `circle-check` geometrisini `pathLength="1"`
|
||||
ile çizer (`.kt-tik-ciz`, globals.css); hareketi azalt'ta ve JS'siz istemcide tik hazır gelir.
|
||||
|
||||
### 5.3 Verimli ve havadar
|
||||
- İlişkili öğeler yan yana dizilir (iki sütun) — tek sütunda alt alta uzayan panel yapılmaz.
|
||||
@@ -1016,7 +1251,7 @@ sm'den itibaren 128 px — ölçüldü). Bilal, 25 Eyl 2026: "kaydırması gerek
|
||||
|
||||
## 6. Derinlik, katman ve hareket
|
||||
|
||||
Derinlik gölgeyle değil **piksel katmanı ve hareket farkıyla** verilir: başlık bloğu gövdeden hafif hızlı akar (`Parallax strength={16}` başlık, `7` içerik, `26` ayraç). 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` bölümler arası piksel şeridi — §4.38). Sahne kartları (§4.31) hover'da 4 px yükselir. Modal arka planı `bg-black/10` + hafif blur. Yığın: içerik < sabit bar < çekmece/modal (`z-50`).
|
||||
|
||||
Hareket kuralları (Apple, `motion/react`):
|
||||
- Varsayılan yay: `{ type: "spring", bounce: 0, duration: 0.35 }` — sekmesiz, sakin. Sekme
|
||||
@@ -1025,6 +1260,16 @@ Hareket kuralları (Apple, `motion/react`):
|
||||
`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.
|
||||
- **Yay ve eğri kullanım yerinde uydurulmaz** (denetim: `elle-yay`). `hareket.ts`'teki adlar:
|
||||
`YAY.varsayilan / akici / donusum / birakma / adim`, `EGRI.giris / standart / gucluCikis /
|
||||
yerlesme`, `SURE.anlik / hizli / cikis / standart`. Yeni bir değer gerekiyorsa önce oraya
|
||||
adıyla ve tek cümle gerekçesiyle eklenir. CSS tarafında aynı eğriler `globals.css`'te
|
||||
`--egri-*` değişkenleridir.
|
||||
- **Sayfa başına tek orkestre giriş** (§11.2): sıralı, sahnelenmiş belirme yalnız sayfanın ilk
|
||||
ekranında bir kez; alttaki bölümler sade `Reveal` ile gelir. Çekmece ve menü kendi sahnesini
|
||||
taşır (§4.19, §4.36).
|
||||
- **Sayfa geçişi:** rotalar arası kısa çapraz solma (≈0,2 s, `EGRI.standart`); üst çubuk
|
||||
geçişe katılmaz, yerinde kalır. Hareketi azalt'ta kapalı.
|
||||
- Giriş ve çıkış **aynı yoldan** (aşağıdan geldiyse aşağı gider).
|
||||
- Basınca anında tepki: `active:scale-[0.97]` (buton), `[0.99]` (geniş satır), `[0.96]` (çarpı).
|
||||
- Hover'da ok `translate-x-0.5` kayar; kart yükselmesi yalnız landing kartında.
|
||||
@@ -1140,8 +1385,9 @@ Araştırma ve kaynaklar: `docs/tasarim/2026-09-25-samimi-arayuz-arastirmasi.md`
|
||||
|
||||
### 10.2 İlerleme
|
||||
- Sayıyla ve dürüst: "7/24", "Adım 2/3: Şehir". Yüzde, sahte hız, yapay ilerleme yok.
|
||||
- Sihirbaz adım göstergesi adlı (Alan · Şehir · Öncelik); dolum `scale-x` ile soldan,
|
||||
`duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]`, hareketi azalt'ta geçişsiz.
|
||||
- Adım göstergesi tek bileşendir: `AdimGostergesi` (§4.35) — numaralı, adlı (Alan · Şehir ·
|
||||
Öncelik), tamamlanan adımda tik. Sihirbaz, tercih profili kapısı ve soru formu aynısını
|
||||
kullanır; satır içi çubuk yazılmaz.
|
||||
- Liste çekmecesinde risk dağılımı metinle (`RiskEtiketi` + sayı). Riskleri bilinen 6+
|
||||
tercihli listede güvenli yoksa tek cümle ipucu — vaat değil, gerekçe.
|
||||
|
||||
@@ -1305,3 +1551,140 @@ satır:** başlık (`text-lg`) üstte, altında kupa+sayı ile ok **yan yana** (
|
||||
gap-5`; kupa kutusu ve ok telefonda 48 px, sm'den 56 px); lg'de tek satır (sayı · başlık · ok).
|
||||
Blok formun yüksekliğini **aşamaz** — aşarsa `overflow-hidden` kap üstten kırpar (24 Eyl 2026'da
|
||||
üç satırlı ilk sürümde başlık kesildi; ölç, §9.2).
|
||||
|
||||
## 11. Deneyim ilkeleri ve referans vakalar
|
||||
|
||||
Bilal (9 Eki 2026): "Landing'de amacım yeni gelen kişiye bir **deneyim** vermek. Sitede benim
|
||||
müdahalemle çok iyi bir deneyime dönüşmüş yerler var; bunlar gibi çalış. Web sitesi değil **web
|
||||
uygulaması** olduğumuzu aktarabilmemiz lazım. Her seferinde 'UI şöyle mantıkta olsun' demeyeyim."
|
||||
|
||||
Bu bölüm o zevkin yazılı hâlidir. §1–§10 **neyin nasıl göründüğünü** söyler; bu bölüm **neyin
|
||||
iyi hissettirdiğini ve neden** söyler. Yeni bir ekran, bileşen ya da hareket tasarlayan herkes
|
||||
(rol fark etmez) işe başlamadan §11.1'deki vakalardan en yakın olanı açıp bakar ve işi bitince
|
||||
§11.5 listesinden geçirir.
|
||||
|
||||
### 11.1 Referans vakalar (Bilal'in "harika" dedikleri)
|
||||
|
||||
Her vaka üç şey söyler: ne oluyor, **neden iyi**, nereden bakılır. Yeni iş bunları kopyalamaz;
|
||||
aynı *nedeni* kendi yerinde üretir.
|
||||
|
||||
**V1 — Tema değişince logo, sayfadan sonra dönüşür** (§4.34; `src/lib/tema-gecisi.ts`,
|
||||
`animasyonlu-mark.tsx`). Yeni tema logodan dışarı doğru açılır (600 ms), kısa bir nefes
|
||||
(80 ms), sonra logonun üç karesi güneşe ya da aya dönüşüp geri toplanır (1,7 sn).
|
||||
*Neden iyi:* iki hareket **sırayla** konuşur, üst üste binmez; ikincisi birincinin cevabıdır.
|
||||
Ayarı değiştiren kişi "site beni duydu" hissini alır. Marka (logo) işin içindedir ama araya
|
||||
girmez — bir an oynar ve çekilir.
|
||||
|
||||
**V2 — Hero'daki Türkiye piksel haritası ve aşağı sarkan pikseller**
|
||||
(`src/features/pazarlama/components/turkey-pixel-map.tsx`, `use-pixel-heat.ts`). Harita
|
||||
ekranın %82'sine kadar yayılır (`w-[min(1665px,82vw)]`); kıyı hattından aşağı 9 hücre boyunca
|
||||
seyrelen bir serpinti dökülür (kıyıda %45, dipte ≈%5 doluluk, derinlikle söner).
|
||||
*Neden iyi:* **boş alan süsle değil, şeklin kendi uzantısıyla dolar** — harita bitmez, dağılır.
|
||||
Boyutu cesurdur (küçük bir ikon değil, sahnenin kendisi) ama içerik onun önünde rahat okunur
|
||||
(ortada okunurluk perdesi). İmleç yaklaşınca ısınır: bakana cevap verir.
|
||||
|
||||
**V3 — Çekmecede önce logo, sonra içerik** (§4.19; `src/components/ui/acilis-sahnesi.tsx`).
|
||||
"Akademik göstergeler" ve "Mezun istihdamı" açılınca logonun üç karesi ortada belirir, sonra
|
||||
her biri kendi gösterge karesinin yerine ve boyuna büyür; içerik onların içinde netleşir.
|
||||
*Neden iyi:* **yükleme anı marka anına döner** — kullanıcı boş bir kutu ya da gri iskelet
|
||||
değil, tanıdığı bir şeyin açılışını izler. İçerik "bir yerden" gelir: logodan. Veri hazırsa
|
||||
sahne oynamaz (önbellekli açılışta atlanır); bekletmek için değil, beklemeyi doldurmak için var.
|
||||
|
||||
**V4 — Üst çubuktaki üçlü: Sıralamanı gir · Ara · Listem** (`katalog-arama.tsx`,
|
||||
`listem-butonu.tsx`, `user-nav.tsx`). Üç ayrı işlev tek bir adada, aynı hap diliyle durur;
|
||||
sıralama girilince hap sayıyı gösterir, liste dolunca rozet sayar.
|
||||
*Neden iyi:* **çubuk bir menü değil, kullanıcının durumudur.** "Sıram 85.000, listemde 7
|
||||
program var" bilgisi her sayfada, her an görünür. Site kullanıcıyı tanıyor gibi davranır;
|
||||
bu, "web sitesi" ile "web uygulaması" arasındaki farkın kendisidir.
|
||||
|
||||
**V5 — Kayan vurgu ve dönen sayı** (`secim-haplari.tsx`, `sayi-rulo.tsx`). Seçim değişince
|
||||
vurgu bir haptan öbürüne kayar; sayı değişince rakamlar döner.
|
||||
*Neden iyi:* **değişim gösterilir, sonuç değil.** Eski değerle yeni değer arasında bir yol
|
||||
vardır; göz neyin değiştiğini aramaz.
|
||||
|
||||
### 11.2 İlkeler
|
||||
|
||||
1. **Uygulama gibi davran.** Kullanıcının durumu (sıra, liste doluluğu, tema, giriş) her
|
||||
sayfada yansır ve bir yerde değişince her yerde değişir. Sayfa "belge" gibi değil "ekran"
|
||||
gibi kurulur: sabit üst çubuk, parmağı izleyen alt sayfa, sayfalar arası kesintisiz geçiş.
|
||||
2. **Her eylemin cevabı var.** Basılan şey basıldığını gösterir (`active:scale`), sonucu olan
|
||||
eylem sonucunu gösterir (rozet atar, sayı döner, kare uçar). Sessiz eylem yoktur. Cevap
|
||||
eylemin büyüklüğüyle orantılıdır: hap seçmek bir kayma, liste doldurmak bir an.
|
||||
3. **Önce imza, sonra içerik.** Bir yüzey açılırken (çekmece, menü, ilk ekran) önce markanın
|
||||
bir parçası belirir, içerik onun ardından ve onun içinden gelir (V3). Hareketler **sırayla**
|
||||
konuşur; aynı anda iki şey dikkat istemez (V1).
|
||||
4. **Boşluğu şeklin kendisi doldurur.** Boş alana dekor yerleştirilmez; var olan bir şekil
|
||||
uzar, dağılır, seyrelir (V2). Piksel dokusu her zaman bir şeyin devamıdır.
|
||||
5. **Cesur ölçü, sakin hareket.** İmza öğeler iridir (harita sahnenin kendisi, sayı kartın
|
||||
kahramanı, hap 48 px); hareket ise sekmesiz ve kısadır (§6). Büyük + sakin = güven.
|
||||
Küçük + hareketli = oyuncak.
|
||||
6. **Değişimi göster.** Değer değişirken eski ile yeni arasında yol çizilir (V5): kayan vurgu,
|
||||
dönen rakam, dolan çizgi, kendini çizen tik. Anında yer değiştiren içerik son çaredir.
|
||||
7. **Oyunlaştırma dürüsttür.** İlerleme gerçek sayıdır (7/24), kutlama öğrencinin kendi
|
||||
adımınadır (§10.2, §10.3). Seri, puan, kayıp korkusu, sahte aciliyet yok (§10.7). Sempatik
|
||||
= tepki veren ve tanıyan; şirin ya da gürültülü değil.
|
||||
8. **Sayfa başına tek orkestre giriş.** Sahnelenmiş, sıralı belirme yalnız ilk ekranda; her
|
||||
bölümde tekrarlanırsa gösteri olur, deneyim olmaz (§6).
|
||||
9. **Telefon önce ve eksiksiz.** Trafiğin ~%90'ı telefonda. Bir deneyim telefonda gizleniyorsa
|
||||
(harita, logo tepkisi, hover'a bağlı cevap) o deneyim yoktur; telefonda sade ama **var**
|
||||
bir karşılığı tasarlanır.
|
||||
10. **Hareketi azalt'ta da tam.** Her sahnenin sade karşılığı yazılır (§8); bilgi hiçbir
|
||||
zaman yalnız harekette durmaz.
|
||||
|
||||
### 11.3 Dışarıdan parça alma
|
||||
|
||||
Bilal'in beğendiği kaynaklar: **Arc** (`uiarc.dev` — hareket ve buton dili), **React Bits**
|
||||
(`reactbits.dev` — Stepper, Bubble Menu ve genel UI algısı), **Skiper UI** (`skiper-ui.com` —
|
||||
deneyim bileşenleri), **Motion Primitives** (`motion-primitives.com`). Bunlara bakmak serbest
|
||||
ve teşvik edilir; ama Bilal'in sözü kuraldır: "Bunlardan bir şey kullansak dahi, harika dediğim
|
||||
kısımlar gibi **bize özgü renk ve tasarım şekline dönüştürüp** kullanırız."
|
||||
|
||||
Bir dış bileşen siteye şu çeviriden geçerek girer (hepsi zorunlu):
|
||||
|
||||
1. **Kod bizim olur.** Dosya `src/components/…` altına Türkçe adla yazılır; başına kaynak ve
|
||||
lisans yorumu konur ("Esin: React Bits 'Stepper' (MIT + Commons Clause)"). Registry'den
|
||||
olduğu gibi kurulup bırakılmaz.
|
||||
2. **Yeni bağımlılık yok.** Sitede hareket için yalnız `motion` var. `gsap`, `framer-motion`,
|
||||
`three`, `ogl`, `lenis`, `matter-js` isteyen bileşen `motion` ile yeniden yazılır ya da
|
||||
alınmaz (denetim: `yabanci-hareket-paketi`). WebGL arka planlar alınmaz.
|
||||
3. **Renk token'a iner.** Sabit hex, mor/yeşil/gökkuşağı vurgular gider; slate nötr, mavi
|
||||
bilgi, turuncu aksiyon, yüzey `bg-card` (§2, §2.3). `dark:` yazılmaz.
|
||||
4. **Hareket `hareket.ts`'e bağlanır.** Bileşenin kendi süre/easing/yay değerleri atılır,
|
||||
en yakın `YAY` / `EGRI` / `SURE` adı kullanılır. Sekme yalnız bir şey fırlıyorsa.
|
||||
5. **Biçim bize döner.** Köşe `rounded-full` / `rounded-2xl`, yazı `font-heading` /
|
||||
`font-sans`, dokunma alanı ≥ 44 px, gölge yok. Uygun yerde piksel imzası eklenir (§1.2).
|
||||
6. **Sade karşılığı ve erişilebilirliği yazılır.** `useReducedMotion` dalı, klavye, odak,
|
||||
`aria-*`. Dış bileşenlerin çoğunda bunlar yoktur; biz ekleriz.
|
||||
7. **Lisans:** React Bits MIT + Commons Clause (kullanmak serbest, bileşeni satmak/yeniden
|
||||
dağıtmak yasak). Skiper ücretsiz sürüm atıf ister (dosya başı yorumu). Arc ücretsiz
|
||||
katman MIT. Motion Primitives MIT. Ücretli (Pro) katmandan hiçbir şey alınmaz.
|
||||
|
||||
Arc'tan alınan **hareket ilkeleri**dir (durum bildiren her şey sekmesiz yay, yalnız
|
||||
`transform`/`opacity`, çıkış girişten hızlı, etkileşim başına tek hareket) — bunlar zaten
|
||||
`hareket.ts`'te. Arc'ın görsel dili (Geist/Inter, 34 px köşe, "eyebrow yok") **alınmaz**;
|
||||
bizim dilimizle çelişir.
|
||||
|
||||
Kaynak kodunu okumak için shadcn MCP ya da registry JSON'u yeterlidir (`components.json`'da
|
||||
`@uiarc`, `@react-bits`, `@skiper-ui` kayıtlı); `shadcn add` ile kurmak yerine okuyup yazılır.
|
||||
|
||||
### 11.4 "Web uygulaması" kabuğu
|
||||
|
||||
- Ana ekrana eklenebilir: `src/app/manifest.ts` (`display: "standalone"`), ikonlar, tema rengi.
|
||||
- Rotalar arası çapraz solma, üst çubuk yerinde (§6).
|
||||
- Telefonda tek satır üst çubuk + baloncuk menü (§4.36).
|
||||
- Durum çubukta yaşar: sıra hapı, liste halkası (§4.37).
|
||||
- Ayrıntı her zaman alt sayfada; telefonda arama ve sıralama kapısı da alt sayfadır,
|
||||
masaüstünde ortalı pencere (§4.10).
|
||||
|
||||
### 11.5 Bitirmeden: deneyim kontrolü
|
||||
|
||||
Yeni ya da yeniden düzenlenmiş her UI parçası için, ekrana bakarak (AGENTS.md):
|
||||
|
||||
- [ ] En yakın referans vakayı (§11.1) açtım; işim onunla aynı *nedeni* taşıyor.
|
||||
- [ ] Bu ekrandaki her eylemin görünür bir cevabı var (basma, sonuç, hata).
|
||||
- [ ] Kullanıcının durumu (sıra / liste / giriş) bu ekranda yansıyor ya da gerekmiyor.
|
||||
- [ ] Aynı anda iki hareket dikkat istemiyor; sahne varsa bir tane.
|
||||
- [ ] Telefonda (375 px) deneyim gizlenmedi; sade karşılığı var.
|
||||
- [ ] Hareketi azalt'ta bilgi eksilmiyor.
|
||||
- [ ] Yay/eğri/süre `hareket.ts`'ten; turuncu CTA `variant="cta"`; yeni bağımlılık yok.
|
||||
- [ ] Dış kaynaktan aldıysam §11.3'ün yedi adımı tamam, kaynak yorumu dosyada.
|
||||
|
||||
@@ -14,6 +14,8 @@ const eslintConfig = defineConfig([
|
||||
"next-env.d.ts",
|
||||
// Paralel ajan worktree'leri (typegen çıktıları lint'e girmesin).
|
||||
".claude/worktrees/**",
|
||||
// Türkçe TTS sanal ortamı (scripts/seslendir.py); içindeki torch JS dosyaları lint'e girmesin.
|
||||
".venv-tts/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ const nextConfig: NextConfig = {
|
||||
// optimize listesinde yoktur; named import'lar doğrudan alt modüle
|
||||
// çevrilir (dev boot / build modül grafiği küçülür).
|
||||
optimizePackageImports: ["radix-ui"],
|
||||
// Rotalar arası çapraz solma: React <ViewTransition> + tarayıcının View
|
||||
// Transitions API'si (components/sayfa-gecisi.tsx, DESIGN.md §6).
|
||||
viewTransition: true,
|
||||
...(kaynakKisitli
|
||||
? {
|
||||
// Turbopack'ın Rust tarafı NODE_OPTIONS heap sınırını görmez; bellek
|
||||
|
||||
9
public/pwa-ikon-maskable.svg
Normal file
9
public/pwa-ikon-maskable.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<!-- Maskelenebilir: zemin kenara kadar dolu, logo ortadaki güvenli alanda (%60) -->
|
||||
<rect width="512" height="512" fill="#f8fafc"/>
|
||||
<g transform="translate(121 121) scale(1.5)">
|
||||
<rect x="27" y="56" width="57" height="57" rx="13" fill="#3b82f6"/>
|
||||
<rect x="91" y="29" width="56" height="56" rx="13" fill="#fdc7a7"/>
|
||||
<rect x="91" y="95" width="56" height="56" rx="13" fill="#ff5a00"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
8
public/pwa-ikon.svg
Normal file
8
public/pwa-ikon.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#f8fafc"/>
|
||||
<g transform="translate(76 76) scale(2)">
|
||||
<rect x="27" y="56" width="57" height="57" rx="13" fill="#3b82f6"/>
|
||||
<rect x="91" y="29" width="56" height="56" rx="13" fill="#fdc7a7"/>
|
||||
<rect x="91" y="95" width="56" height="56" rx="13" fill="#ff5a00"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -32,7 +32,7 @@ import Database from "better-sqlite3";
|
||||
import { argOku, evalAppDbHazirla, gitDurumu, kisalt, olcerekKos, sonucYaz, tablo, TOPLAM } from "./ortak";
|
||||
import type { RaporParams, RaporSonuc, RevizyonKurali } from "@/features/rapor/types/rapor";
|
||||
import type { KayitliMesaj } from "@/lib/ai/sohbet-gecmisi";
|
||||
import type { Anket } from "@/lib/ai/sohbet-meta";
|
||||
import type { Anket, AnketCevaplari } from "@/lib/ai/sohbet-meta";
|
||||
import type { KoddanIslem } from "@/lib/ai/sohbet-akisi";
|
||||
|
||||
// ── QA kaydından alınan geçmiş parçaları (metinler birebir) ──
|
||||
@@ -114,6 +114,17 @@ type Senaryo = {
|
||||
kural?: Partial<RevizyonKurali>;
|
||||
/** Form cevabı senaryosu: cevaplanan form (route sonAnket'i verir). */
|
||||
oncekiForm?: Anket;
|
||||
/** Form cevabının yapısal hâli (route anketCevabiDogrula'dan); varsayılan formda modelsiz yol. */
|
||||
anketCevabi?: AnketCevaplari;
|
||||
};
|
||||
/** Varsayılan form (revizyon-niyeti.ts varsayilanForm ile aynı adlar/değerler; kimlik sabit). */
|
||||
const VARSAYILAN_ANKET: Anket = {
|
||||
id: "7c1f3d2e-0000-4000-8000-varsayilan01",
|
||||
baslik: "Listeni nasıl değiştirelim?",
|
||||
sorular: [
|
||||
{ ad: "degisiklik", soru: "Ne değişsin?", coklu: true, serbest: "Başka bir şey…", secenekler: [{ deger: "il-ekle", etiket: "Başka bir il de olsun" }, { deger: "devlet", etiket: "Sadece devlet üniversiteleri" }, { deger: "vakif-olmasin", etiket: "Vakıf üniversiteleri çıksın" }, { deger: "guvenli", etiket: "Daha güvenli olsun (riski azaltalım)" }, { deger: "iddiali", etiket: "Daha iddialı olsun (hayal ağırlıklı)" }] },
|
||||
{ ad: "adet", soru: "Kaç tercih etkilensin?", zorunlu: false, secenekler: [{ deger: "5", etiket: "İlk 5" }, { deger: "10", etiket: "İlk 10" }, { deger: "24", etiket: "Listenin tamamı" }] },
|
||||
],
|
||||
};
|
||||
const SON_KURAL: RaporParams["revizyon"] = { ilkTercihler: { adet: 5, iller: ["İZMİR"] }, iller: { ekle: ["ANKARA"] }, not: "İlk 5 tercih İzmir olsun." };
|
||||
const SENARYOLAR: Senaryo[] = [
|
||||
@@ -127,6 +138,15 @@ const SENARYOLAR: Senaryo[] = [
|
||||
{ id: "r08", aciklama: "2 geçmiş, il ekleme", gecmis: G2, soru: "Kocaeli de olsun", beklenen: "revizyon", revizyon: SON_KURAL, kural: { iller: { ekle: ["KOCAELİ"] } } },
|
||||
{ id: "r09", aciklama: "2 geçmiş, belirsiz istek → form", gecmis: G2, soru: "listemi biraz değiştirmek istiyorum", beklenen: "soru", revizyon: SON_KURAL },
|
||||
{ id: "r10", aciklama: "2 geçmiş, revizyon değil (kontrol)", gecmis: G2, soru: "listemdeki 1. programın kontenjanı kaç, geçen yıl kaç kişi yerleşmiş?", beklenen: "hicbiri", revizyon: SON_KURAL },
|
||||
// 10 Eki 2026 (Bilal: paketli hesapta "liste oluşturma" isteği koşmadı; QA B1 "listemi değiştir" → diger):
|
||||
// genel istek form açmalı, "diger"e düşmemeli; "yeniden oluştur" regex kapısından geçmeli.
|
||||
{ id: "r11", aciklama: "genel istek, ne değişeceği yok → form (QA B1)", gecmis: G2, soru: "listemi değiştir", beklenen: "soru", revizyon: SON_KURAL },
|
||||
{ id: "r12", aciklama: "listeyi baştan isteme → form", gecmis: [], soru: "listemi yeniden oluştur", beklenen: "soru" },
|
||||
{ id: "r13", aciklama: "tercih güncelleme dili → form", gecmis: G2, soru: "tercihlerimi güncellemek istiyorum", beklenen: "soru", revizyon: SON_KURAL },
|
||||
{ id: "r14", aciklama: "net çoklu kural tek cümlede", gecmis: [], soru: "yeni liste yap: ilk 10 tercihim İstanbul'dan olsun, vakıf olmasın", beklenen: "revizyon", kural: { ilkTercihler: { adet: 10, iller: ["İSTANBUL"] }, universiteTipi: "devlet" } },
|
||||
// Varsayılan formun cevabı modelsiz kurala çevrilir (niyet sn ≈ 0 beklenir)
|
||||
{ id: "r15", aciklama: "varsayılan form cevabı → modelsiz kural (il serbest metin + devlet, ilk 5)", gecmis: [...G2, U("listemi değiştir"), A("Birkaç kısa soru hazırladım; cevaplarsan listeni ona göre yeniden kurarım.", { anket: VARSAYILAN_ANKET })], soru: "Form cevaplarım:\n- Ne değişsin? → Başka bir il de olsun, Sadece devlet üniversiteleri, Kocaeli\n- Kaç tercih etkilensin? → İlk 5", beklenen: "revizyon", revizyon: SON_KURAL, oncekiForm: VARSAYILAN_ANKET, anketCevabi: { degisiklik: ["il-ekle", "devlet", "Kocaeli"], adet: "5" }, kural: { ilkTercihler: { adet: 5, iller: ["KOCAELİ"], universiteTipi: "devlet" }, iller: { ekle: ["KOCAELİ"] } } },
|
||||
{ id: "r16", aciklama: "varsayılan form cevabı → modelsiz kural (güvenli, tamamı)", gecmis: [...G2, U("listemi değiştir"), A("Birkaç kısa soru hazırladım.", { anket: VARSAYILAN_ANKET })], soru: "Form cevaplarım:\n- Ne değişsin? → Daha güvenli olsun (riski azaltalım)\n- Kaç tercih etkilensin? → Listenin tamamı", beklenen: "revizyon", revizyon: SON_KURAL, oncekiForm: VARSAYILAN_ANKET, anketCevabi: { degisiklik: ["guvenli"], adet: "24" }, kural: { iskelet: "guvenli-agirlikli" } },
|
||||
];
|
||||
|
||||
/** `beklenen` kural parçası `gercek`in alt kümesi mi (diziler sırasız, büyük-küçük harf duyarsız). */
|
||||
@@ -214,7 +234,7 @@ async function main() {
|
||||
let ozet: ReturnType<typeof sahteOzet> | null = null;
|
||||
const t0 = Date.now();
|
||||
const niyetOlcum = regex
|
||||
? await olcerekKos(() => revizyonNiyetiCikar({ mesaj: s.soru, params, oncekiForm: s.oncekiForm ?? null, gozlem: (g) => (niyetSaglayici = g.saglayici) }))
|
||||
? await olcerekKos(() => revizyonNiyetiCikar({ mesaj: s.soru, params, oncekiForm: s.oncekiForm ?? null, anketCevabi: s.anketCevabi ?? null, gozlem: (g) => (niyetSaglayici = g.saglayici) }))
|
||||
: null;
|
||||
const niyetMs = Date.now() - t0;
|
||||
if (niyetOlcum) {
|
||||
|
||||
@@ -281,6 +281,35 @@ const KURALLAR: Kural[] = [
|
||||
mesaj: "Hata/bildirim metninde 'Hay aksi/Eyvah/Oops' ya da 'buraya tıkla' yok; ne oldu + ne yapılacak (DESIGN.md §10.4)",
|
||||
bul: regexKurali(/Hay aksi|Eyvah|\bOops|\bUups|[Bb]uraya tıkla|[Tt]ıklayın(?:ız)?\b/),
|
||||
},
|
||||
{
|
||||
ad: "yabanci-hareket-paketi",
|
||||
seviye: "hata",
|
||||
mesaj:
|
||||
"Hareket yalnız motion ile; gsap/framer-motion isteyen dış bileşen motion ile yeniden yazılır (DESIGN.md §11.3)",
|
||||
uiDahil: true,
|
||||
bul: regexKurali(/from\s+["'](?:gsap|@gsap\/[\w-]+|framer-motion|lenis|matter-js|ogl|three)(?:\/[\w./-]+)?["']/),
|
||||
},
|
||||
{
|
||||
ad: "elle-cta",
|
||||
seviye: "uyari",
|
||||
mesaj: 'Turuncu CTA elle yazılmaz; <Button variant="cta"> (DESIGN.md §4.1)',
|
||||
bul: (icerik) => {
|
||||
const sonuc: Array<{ index: number; parca: string }> = [];
|
||||
for (const m of icerik.matchAll(/<Button\b[^>]*?>/g)) {
|
||||
const t = /\bbg-orange-500\b/.exec(m[0]);
|
||||
if (t) sonuc.push({ index: (m.index ?? 0) + t.index, parca: t[0] });
|
||||
}
|
||||
return sonuc;
|
||||
},
|
||||
},
|
||||
{
|
||||
ad: "elle-yay",
|
||||
seviye: "uyari",
|
||||
mesaj:
|
||||
"Yay/eğri kullanım yerinde uydurulmaz; src/lib/hareket.ts'teki YAY / EGRI adları ya da --egri-* değişkenleri (DESIGN.md §6)",
|
||||
uiDahil: true,
|
||||
bul: regexKurali(/\bstiffness\s*:|cubic-bezier\(/),
|
||||
},
|
||||
{
|
||||
ad: "emoji-ikon",
|
||||
seviye: "uyari",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { kuralOzeti } from "@/lib/revizyon-kurali";
|
||||
import {
|
||||
anketCevabiDogrula,
|
||||
anketCevabiMetni,
|
||||
type AnketCevaplari,
|
||||
type RevizyonOzeti,
|
||||
type SohbetMeta,
|
||||
} from "@/lib/ai/sohbet-meta";
|
||||
@@ -138,6 +139,7 @@ export async function POST(request: NextRequest) {
|
||||
// üretilir; istemcinin yazdığı metin kullanılmaz.
|
||||
const sonAnket = sonMesaj?.role === "assistant" ? sonMesaj.meta?.anket : undefined;
|
||||
let kullaniciMeta: SohbetMeta | undefined;
|
||||
let anketCevaplari: AnketCevaplari | null = null;
|
||||
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 });
|
||||
@@ -148,6 +150,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
mesaj = anketCevabiMetni(sonAnket, cevaplar);
|
||||
kullaniciMeta = { anketCevabi: { anketId: sonAnket.id, cevaplar } };
|
||||
anketCevaplari = cevaplar;
|
||||
}
|
||||
const bedelsiz = Boolean(anketCevabiHam) || onayMesajiMi(mesaj, sonAsistan);
|
||||
|
||||
@@ -294,7 +297,7 @@ export async function POST(request: NextRequest) {
|
||||
let islem: KoddanIslem | undefined;
|
||||
if (paketli && revizyonNiyetiVar(mesaj)) {
|
||||
const t0 = Date.now();
|
||||
const niyet = await revizyonNiyetiCikar({ mesaj, params: raporParams, oncekiForm: anketCevabiHam ? sonAnket : null });
|
||||
const niyet = await revizyonNiyetiCikar({ mesaj, params: raporParams, oncekiForm: anketCevabiHam ? sonAnket : null, anketCevabi: anketCevaplari });
|
||||
console.info("[soru] revizyon niyeti:", niyet.niyet, "kural" in niyet ? "kural" : "form" in niyet ? `form (eksik: ${niyet.eksik.join(", ") || "—"})` : "neden" in niyet ? niyet.neden : "", `${Date.now() - t0} ms`);
|
||||
if (niyet.niyet === "revizyon" && "kural" in niyet) {
|
||||
const kural = niyet.kural;
|
||||
|
||||
@@ -74,6 +74,11 @@
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
/* Hareket eğrileri (DESIGN.md §6); JS karşılığı src/lib/hareket.ts EGRI */
|
||||
--egri-giris: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--egri-standart: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--egri-guclu-cikis: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--egri-yerlesme: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
@@ -484,3 +489,297 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tema geçişi (DESIGN.md §2.3, lib/tema-gecisi.ts): yeni tema header'daki
|
||||
logodan yumuşak bir halka gibi açılır. Eski görüntü yerinde durur; yeni
|
||||
görüntü radyal degrade maskesiyle üstünde belirir. Maskenin kenarı keskin
|
||||
değil: yarıçapın %55'ine kadar tam opak, oradan kenara doğru tüy gibi
|
||||
söner (--kt-tema-cekirdek; JS'teki CEKIRDEK ile aynı olmalı) — ışık
|
||||
yayılır gibi, sert çizgi yok. Yarıçap (--kt-tema-r) 0 → --kt-tema-son
|
||||
yumuşak kalkıp yumuşak durur; kısa ve hafif (600 ms) — asıl gösteri,
|
||||
geçiş bitince oynayan logo dönüşümüdür. Merkez ve son yarıçap JS'ten gelir.
|
||||
Hareket azaltılmışsa JS geçişi hiç başlatmaz, tema düz değişir. */
|
||||
@property --kt-tema-r {
|
||||
syntax: "<length>";
|
||||
inherits: true;
|
||||
initial-value: 0px;
|
||||
}
|
||||
.kt-tema-gecis {
|
||||
view-transition-name: root;
|
||||
--kt-tema-cekirdek: 0.55;
|
||||
}
|
||||
.kt-tema-gecis::view-transition-old(root),
|
||||
.kt-tema-gecis::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
.kt-tema-gecis::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
.kt-tema-gecis::view-transition-new(root) {
|
||||
z-index: 2;
|
||||
mask-image: radial-gradient(
|
||||
circle at var(--kt-tema-x) var(--kt-tema-y),
|
||||
#000 calc(var(--kt-tema-r) * var(--kt-tema-cekirdek)),
|
||||
transparent var(--kt-tema-r)
|
||||
);
|
||||
animation: kt-tema-halka 600ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
@keyframes kt-tema-halka {
|
||||
from {
|
||||
--kt-tema-r: 0px;
|
||||
}
|
||||
to {
|
||||
--kt-tema-r: var(--kt-tema-son);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sayfa geçişi (DESIGN.md §6, components/sayfa-gecisi.tsx): rota değişince
|
||||
eski içerik hızla söner, yenisi hafifçe yükselerek belirir — çıkış girişten
|
||||
hızlı. Üst çubuk kendi grubunda ve animasyonsuz: içerik değişir, çubuk
|
||||
yerinde kalır (uygulama hissi, §11.4). Tema geçişinde (`.kt-tema-gecis`)
|
||||
çubuk adını bırakır ki halka onu da kaplasın. */
|
||||
.kt-ust-cubuk {
|
||||
view-transition-name: kt-ust-cubuk;
|
||||
}
|
||||
.kt-tema-gecis .kt-ust-cubuk {
|
||||
view-transition-name: none;
|
||||
}
|
||||
::view-transition-group(kt-ust-cubuk) {
|
||||
animation: none;
|
||||
z-index: 100;
|
||||
}
|
||||
::view-transition-old(kt-ust-cubuk) {
|
||||
display: none;
|
||||
}
|
||||
::view-transition-new(kt-ust-cubuk) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-old(.kt-sayfa-cik) {
|
||||
animation: 120ms var(--egri-standart) both kt-sayfa-sol reverse;
|
||||
}
|
||||
::view-transition-new(.kt-sayfa-gir) {
|
||||
animation:
|
||||
200ms var(--egri-standart) 80ms both kt-sayfa-sol,
|
||||
280ms var(--egri-giris) both kt-sayfa-yuksel;
|
||||
}
|
||||
@keyframes kt-sayfa-sol {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes kt-sayfa-yuksel {
|
||||
from {
|
||||
translate: 0 8px;
|
||||
}
|
||||
to {
|
||||
translate: 0 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(.kt-sayfa-cik),
|
||||
::view-transition-new(.kt-sayfa-gir) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ana sayfa ilk ekranının girişi (DESIGN.md §6 "sayfa başına tek orkestre
|
||||
giriş", §11.2): önce harita belirir, sonra başlık, sonra form netleşerek
|
||||
gelir — sırayla, üst üste binmeden. `--sira` ile kademelenir. Başlık LCP
|
||||
öğesi olduğundan hiçbir kare tam saydam başlamaz (opaklık 0,35'ten). */
|
||||
.kt-hero-gir {
|
||||
animation: kt-hero-gir 0.7s var(--egri-giris) both;
|
||||
animation-delay: calc(var(--sira, 0) * 110ms + 80ms);
|
||||
}
|
||||
.kt-hero-harita {
|
||||
animation: kt-hero-harita 1.1s var(--egri-standart) both;
|
||||
}
|
||||
@keyframes kt-hero-gir {
|
||||
from {
|
||||
opacity: 0.35;
|
||||
filter: blur(10px);
|
||||
translate: 0 12px;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
translate: 0 0;
|
||||
}
|
||||
}
|
||||
@keyframes kt-hero-harita {
|
||||
from {
|
||||
opacity: 0;
|
||||
scale: 0.985;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.kt-hero-gir,
|
||||
.kt-hero-harita {
|
||||
animation: kt-unblur-sade 0.25s ease-out both;
|
||||
}
|
||||
}
|
||||
|
||||
/* "Nasıl çalışır" adımları arasındaki çizgi (DESIGN.md §4.32): adımın rakamı
|
||||
biçimine dönüşüp geri toplanınca (adim-akisi.tsx `data-dolu` yazar) soldan
|
||||
maviyle dolar. JS'siz istemcide dolu durur. */
|
||||
@media (scripting: enabled) {
|
||||
.kt-cizgi-dol {
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.6s var(--egri-guclu-cikis);
|
||||
}
|
||||
.kt-cizgi-dol[data-dolu] {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.kt-cizgi-dol {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Piksel morf (components/piksel-morf.tsx, DESIGN.md §4.38): her piksel bir
|
||||
<g>; yeri `translate` ile verilir ve kare değişince yeni hücresine süzülür.
|
||||
`gizli` küçülüp söner. Turuncu vurgu, doku karesinin üstündeki ikinci
|
||||
karedir: çabuk ısınır, yavaş soğur (ısı fırçasıyla aynı asimetri).
|
||||
Sekme yok (§6); kayma süresi sahneye göre `--morf-sure` ile ayarlanır. */
|
||||
.kt-morf-px {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
transition:
|
||||
translate var(--morf-sure, 0.55s) var(--egri-standart) var(--g, 0ms),
|
||||
opacity 0.3s var(--egri-standart) var(--g, 0ms),
|
||||
scale 0.3s var(--egri-standart) var(--g, 0ms);
|
||||
}
|
||||
.kt-morf-px[data-ton="gizli"] {
|
||||
opacity: 0;
|
||||
scale: 0.6;
|
||||
}
|
||||
.kt-morf-vurgu {
|
||||
opacity: 0;
|
||||
fill-opacity: 0.7;
|
||||
transition: opacity 1.2s var(--egri-standart);
|
||||
}
|
||||
.kt-morf-taban {
|
||||
transition: opacity 0.6s var(--egri-standart);
|
||||
}
|
||||
.kt-morf-px[data-ton="vurgu"] > .kt-morf-taban {
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s var(--egri-standart) var(--g, 0ms);
|
||||
}
|
||||
.kt-morf-px[data-ton="vurgu"] > .kt-morf-vurgu {
|
||||
opacity: 1;
|
||||
animation:
|
||||
kt-morf-isin 0.25s var(--egri-standart) var(--g, 0ms) backwards,
|
||||
kt-morf-nabiz 3.6s ease-in-out var(--n, 0s) infinite;
|
||||
}
|
||||
@keyframes kt-morf-isin {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes kt-morf-nabiz {
|
||||
50% {
|
||||
fill-opacity: 0.35;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.kt-morf-px,
|
||||
.kt-morf-taban,
|
||||
.kt-morf-vurgu {
|
||||
transition: none;
|
||||
}
|
||||
.kt-morf-px[data-ton="vurgu"] > .kt-morf-vurgu {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Kendini çizen tik (landing-parcalari.tsx, DESIGN.md §5.2.3): karşılaştırma
|
||||
tablosu ekrana girince (Reveal → .kt-reveal-in) önce halka, sonra işaret
|
||||
çizilir; satır gecikmesi `--tik-g` ile gelir. `pathLength="1"` bekler. */
|
||||
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
|
||||
.kt-reveal .kt-tik-ciz {
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
opacity: 0;
|
||||
transition:
|
||||
stroke-dashoffset 0.5s var(--egri-standart) calc(var(--tik-g, 0ms) + 250ms),
|
||||
opacity 0.01s linear calc(var(--tik-g, 0ms) + 250ms);
|
||||
}
|
||||
.kt-reveal .kt-tik-isaret {
|
||||
transition-duration: 0.3s, 0.01s;
|
||||
transition-delay:
|
||||
calc(var(--tik-g, 0ms) + 600ms),
|
||||
calc(var(--tik-g, 0ms) + 600ms);
|
||||
}
|
||||
.kt-reveal.kt-reveal-in .kt-tik-ciz {
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero haritasının serpintisi (turkey-pixel-map.tsx, DESIGN.md §11.1 V2):
|
||||
piksel bir süre yerinde durur, kopup `--d` birim (10 = bir hücre) düşerken
|
||||
söner, sonra yerinde yeniden belirir. Süre ve faz satır içi. */
|
||||
@keyframes kt-serpinti {
|
||||
0%,
|
||||
42% {
|
||||
translate: 0 0;
|
||||
opacity: 1;
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
76% {
|
||||
translate: 0 calc(var(--d, 20) * 1px);
|
||||
opacity: 0;
|
||||
animation-timing-function: step-end;
|
||||
}
|
||||
80% {
|
||||
translate: 0 0;
|
||||
opacity: 0;
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
100% {
|
||||
translate: 0 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.kt-serpinti {
|
||||
animation: kt-serpinti 6s infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.kt-serpinti {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Piksel şeridi (components/piksel-serit.tsx, DESIGN.md §4.38): kaydırmaya
|
||||
bağlı sütun. Okuma çizgisini geçen piksel `data-yandi` alır ve turuncuya
|
||||
ısınır; geri kaydırınca yavaş soğur. Şeridin ucu (`data-uc`) tam turuncu. */
|
||||
.kt-serit-px {
|
||||
fill: currentColor;
|
||||
fill-opacity: var(--op, 0.2);
|
||||
transition:
|
||||
fill 0.9s var(--egri-standart),
|
||||
fill-opacity 0.9s var(--egri-standart);
|
||||
}
|
||||
.kt-serit-px[data-yandi] {
|
||||
fill: var(--color-orange-500);
|
||||
fill-opacity: 0.62;
|
||||
transition-duration: 0.16s;
|
||||
}
|
||||
.kt-serit-px[data-uc] {
|
||||
fill-opacity: 1;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.kt-serit-px {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import Script from "next/script";
|
||||
import { Cal_Sans, Geist_Mono, Bricolage_Grotesque } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import { BildirimYigini } from "@/components/ui/bildirim";
|
||||
import { SayfaGecisi } from "@/components/sayfa-gecisi";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { SiteTopBanner } from "@/components/site-top-banner";
|
||||
import { ListeCekmecesiLazy } from "@/features/liste/components/liste-cekmecesi-lazy";
|
||||
import { UcusKatmani } from "@/features/liste/components/ucus-katmani";
|
||||
import { KaydetBannerLazy } from "@/features/liste/components/kaydet-banner-lazy";
|
||||
import { TercihProfiliKapisiLazy } from "@/features/liste/components/tercih-profili-kapisi-lazy";
|
||||
import { HeaderGate } from "@/components/header-gate";
|
||||
@@ -117,12 +119,14 @@ export default function RootLayout({
|
||||
<div
|
||||
id="icerik"
|
||||
tabIndex={-1}
|
||||
className="flex min-h-0 flex-1 flex-col pb-28 outline-none print:pb-0"
|
||||
className="flex min-h-0 flex-1 flex-col pb-28 outline-none has-[[data-site-footer]]:pb-0 print:pb-0"
|
||||
>
|
||||
{children}
|
||||
<SayfaGecisi>{children}</SayfaGecisi>
|
||||
</div>
|
||||
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
|
||||
{/* Manuel 24'lük liste çekmecesi (navbar'dan açılır) */}
|
||||
<ListeCekmecesiLazy />
|
||||
{/* "+" ile eklemede rozete uçan piksel kare (DESIGN.md §4.37) */}
|
||||
<UcusKatmani />
|
||||
{/* Girişsiz + 3 seçimli kullanıcıya "listen yalnızca bu tarayıcıda" banner'ı */}
|
||||
<KaydetBannerLazy />
|
||||
{/* İlk "+" için sıra + sihirbaz kapısı */}
|
||||
|
||||
26
src/app/manifest.ts
Normal file
26
src/app/manifest.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
// Ana ekrana eklenebilir kabuk (DESIGN.md §11.4): telefonda "Ana ekrana ekle"
|
||||
// ile site tarayıcı çubuğu olmadan, uygulama gibi açılır. Yalnız kurulum
|
||||
// kabuğudur; service worker ve çevrimdışı çalışma yok. Renkler gövde zemini
|
||||
// (bg-slate-50); koyu temada adres çubuğu rengini tema-saglayici.tsx sürer.
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "KolayTercih — YKS Tercih Danışmanı",
|
||||
short_name: "KolayTercih",
|
||||
description:
|
||||
"YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste.",
|
||||
lang: "tr",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "portrait",
|
||||
background_color: "#f8fafc",
|
||||
theme_color: "#f8fafc",
|
||||
icons: [
|
||||
{ src: "/pwa-ikon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
|
||||
{ src: "/pwa-ikon-maskable.svg", sizes: "any", type: "image/svg+xml", purpose: "maskable" },
|
||||
{ src: "/apple-icon.png", sizes: "180x180", type: "image/png", purpose: "any" },
|
||||
],
|
||||
};
|
||||
}
|
||||
237
src/app/page.tsx
237
src/app/page.tsx
@@ -10,23 +10,22 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { AdimAkisi } from "@/features/pazarlama/components/adim-akisi";
|
||||
import { BolumGecisi } from "@/features/pazarlama/components/bolum-gecisi";
|
||||
import { ComparisonCell } from "@/features/pazarlama/components/landing-parcalari";
|
||||
import { HeroBaslik } from "@/features/pazarlama/components/hero-baslik";
|
||||
import { KapanisCta } from "@/features/pazarlama/components/hero-focus-button";
|
||||
import { HeroForm } from "@/features/sihirbaz/components/hero-form";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { Reveal } from "@/features/pazarlama/components/reveal";
|
||||
import { SayanSayi } from "@/features/pazarlama/components/sayan-sayi";
|
||||
import { SorunSahnesi } from "@/features/pazarlama/components/sorun-sahneleri";
|
||||
import { SosyalKanitBandi } from "@/features/pazarlama/components/sosyal-kanit";
|
||||
import { TurkeyPixelMap } from "@/features/pazarlama/components/turkey-pixel-map";
|
||||
import { SuzulenHaplar } from "@/features/pazarlama/components/suzulen-haplar";
|
||||
import { VeriAkisi } from "@/features/pazarlama/components/veri-akisi";
|
||||
import { katalogHaplari } from "@/features/pazarlama/landing-verisi";
|
||||
import {
|
||||
PixelDivider,
|
||||
PixelRakam,
|
||||
PixelSahne,
|
||||
SectionEyebrow,
|
||||
} from "@/components/pixel-decor";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi";
|
||||
import { URUNLER } from "@/lib/credits";
|
||||
import {
|
||||
@@ -68,78 +67,57 @@ const TOPUP_FIYATI = (URUNLER.topup.amountKurus / 100).toLocaleString("tr-TR", {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
// Sorun kartlarının piksel sahneleri (DESIGN.md §4.31): hero haritasıyla aynı
|
||||
// ızgara dili — `#` doku, `-` silik, `o` turuncu vurgu. Her biri kartın
|
||||
// söylediğini çizer: binlerce programın içinde tam 24 seçim; her yıl başka
|
||||
// boyda çubuklar ve yalnız sonuncusu işaretli; basamak basamak tırmanan fiyat.
|
||||
const SAHNE_IHTIMAL = [
|
||||
"o#o#######o##o##o#######",
|
||||
"####o###o#########o###o#",
|
||||
"######o####o###o########",
|
||||
"#o##o###################",
|
||||
"######o##o####o####o##o#",
|
||||
"#o##########o####o######",
|
||||
".###..o#......####.#..o.",
|
||||
"..#...###...##......#...",
|
||||
];
|
||||
const SAHNE_YILLAR = [
|
||||
"........................",
|
||||
".................##.....",
|
||||
".........##......##.....",
|
||||
".##......##......##.....",
|
||||
".##......##..##..##..oo.",
|
||||
".##..##..##..##..##..##.",
|
||||
".##..##..##..##..##..##.",
|
||||
".##..##..##..##..##..##.",
|
||||
];
|
||||
const SAHNE_FIYAT = [
|
||||
".....................ooo",
|
||||
"..................###---",
|
||||
"...............###------",
|
||||
"............###---------",
|
||||
".........###------------",
|
||||
"......###---------------",
|
||||
"...###------------------",
|
||||
"###---------------------",
|
||||
];
|
||||
// Bölüm ritmi (DESIGN.md §5.1; Bilal, 11 Eki 2026: "section'lar arası es yok,
|
||||
// her şey çok sıkışık"). Bölümler arasında piksel şeridi durur (§4.38) ve
|
||||
// çevresinde boşluk ister: şerit üst bölümün içeriğinden uzak, alt bölümün
|
||||
// rozetine yakındır — bir bölümü bitirip sıradakine iner. Alt boşluk bu yüzden
|
||||
// üsttekinden bir kademe büyük. Şerit geçiş çizgisinin iki yanına telefonda
|
||||
// 56, sm'den itibaren 75 px taşar; kalan nefes telefonda 56/40, sm'de 85/53,
|
||||
// lg'de 101/69 px (üst bölüme/alt bölüme).
|
||||
const BOLUM_BOSLUGU = "pt-24 pb-28 sm:pt-32 sm:pb-40 lg:pt-36 lg:pb-44";
|
||||
|
||||
// Sorun kartlarının piksel sahneleri (DESIGN.md §4.31, §4.38): hero haritasıyla
|
||||
// aynı ızgara dili. Her biri kartın söylediğini hareketle çizer: binlerce
|
||||
// programın içinde ısınıp sönen tam 24 seçim; her yıl bir sola kayan, sağdan
|
||||
// başka boyda gelen çubuklar; basamak basamak tırmanan fiyat. Izgaralar
|
||||
// `sorun-sahneleri.tsx`te.
|
||||
const problems = [
|
||||
{
|
||||
sahne: SAHNE_IHTIMAL,
|
||||
sahne: "ihtimal",
|
||||
title: "24 tercih, binlerce ihtimal",
|
||||
description: `${PROGRAM_SAYISI_CUMLE} lisans ve önlisans programı arasından sana uyan 24 tanesini doğru sırayla dizmek, tablolarla boğuşarak günler alıyor.`,
|
||||
},
|
||||
{
|
||||
sahne: SAHNE_YILLAR,
|
||||
sahne: "yillar",
|
||||
title: "Geçen yılın puanı yetmiyor",
|
||||
description:
|
||||
"Taban sıralamaları her yıl kayıyor; tek yıla bakmak riski gizler.",
|
||||
},
|
||||
{
|
||||
sahne: SAHNE_FIYAT,
|
||||
sahne: "fiyat",
|
||||
title: "Danışman lüks haline geldi",
|
||||
description:
|
||||
"Armut'ta tercih danışmanlığı ilanları ortalama 1.000–3.000 TL (Eylül 2026). Ücretsiz robotların çoğu ise yalnızca liste filtreliyor; listeyi kurup gerekçesini yazmıyor.",
|
||||
},
|
||||
];
|
||||
] as const;
|
||||
|
||||
const steps = [
|
||||
{
|
||||
step: 1,
|
||||
title: "Sıralamanı ve hedeflerini anlat",
|
||||
description:
|
||||
no: 1,
|
||||
baslik: "Sıralamanı ve hedeflerini anlat",
|
||||
aciklama:
|
||||
"YKS başarı sıralamanı gir; sıralamana uygun ilgi alanı, şehir ve üniversite tercihlerini birkaç adımda işaretle.",
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: `Son ${VERI_YIL_SAYISI} yılın resmî verisi taransın`,
|
||||
description:
|
||||
no: 2,
|
||||
baslik: `Son ${VERI_YIL_SAYISI} yılın resmî verisi taransın`,
|
||||
aciklama:
|
||||
"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,
|
||||
title: "Dengeli 24'lük listeni al",
|
||||
description:
|
||||
no: 3,
|
||||
baslik: "Dengeli 24'lük listeni al",
|
||||
aciklama:
|
||||
"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;
|
||||
@@ -270,19 +248,23 @@ export default function Home() {
|
||||
aria-hidden
|
||||
// SVG altta 9 hücrelik serpinti payı taşıdığı için silüet merkezi
|
||||
// yüksekliğin %37,1'inde; -1/2 yerine bu oranla hizalanır
|
||||
className="pointer-events-none absolute left-1/2 top-[43%] -z-10 hidden -translate-x-1/2 -translate-y-[37.14%] sm:block"
|
||||
// Telefonda harita gizlenmez (DESIGN.md §11.2-9): ekrandan taşacak
|
||||
// kadar iri durur, başlığın üstündeki boşluğu doldurur ve serpinti
|
||||
// başlığa doğru dökülür.
|
||||
className="kt-hero-harita pointer-events-none absolute left-1/2 top-[27%] -z-10 -translate-x-1/2 -translate-y-[37.14%] sm:top-[43%]"
|
||||
>
|
||||
{/* Arka katman: harita scroll'un gerisinde kalır, derinlik verir */}
|
||||
<Parallax fromStart strength={-52}>
|
||||
{/* Harita orta ekranlarda nefes payı bırakır, ekranla birlikte
|
||||
ölçeklenir ve çok geniş ekranda 1665px tavanına oturur;
|
||||
mobilde tümüyle gizli. */}
|
||||
<TurkeyPixelMap className="w-[min(1665px,82vw)] max-w-none text-primary" />
|
||||
telefonda ekranın 1,7 katı (kenarlardan taşar, kırpılır). */}
|
||||
<TurkeyPixelMap className="w-[170vw] max-w-none text-primary sm:w-[min(1665px,82vw)]" />
|
||||
</Parallax>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_58%_52%_at_50%_42%,color-mix(in_oklab,var(--color-slate-50)_90%,transparent)_25%,transparent_75%)]"
|
||||
// Okunurluk perdesi: telefonda başlık daha aşağıda ve dar, perde ona göre
|
||||
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_95%_38%_at_50%_62%,color-mix(in_oklab,var(--color-slate-50)_92%,transparent)_35%,transparent_80%)] sm:bg-[radial-gradient(ellipse_58%_52%_at_50%_42%,color-mix(in_oklab,var(--color-slate-50)_90%,transparent)_25%,transparent_75%)]"
|
||||
/>
|
||||
<Parallax
|
||||
fromStart
|
||||
@@ -292,22 +274,21 @@ export default function Home() {
|
||||
<HeroBaslik />
|
||||
<div
|
||||
id="hero-form"
|
||||
className="mt-6 flex w-full scroll-mt-28 justify-center"
|
||||
className="kt-hero-gir mt-6 flex w-full scroll-mt-28 justify-center"
|
||||
style={{ "--sira": 2 } as React.CSSProperties}
|
||||
>
|
||||
<HeroForm />
|
||||
</div>
|
||||
</Parallax>
|
||||
</section>
|
||||
|
||||
{/* Hero → Problem geçişi: border yerine piksel ayraç */}
|
||||
<div aria-hidden className="mx-auto w-56">
|
||||
<Parallax strength={26}>
|
||||
<PixelDivider seed={11} />
|
||||
</Parallax>
|
||||
</div>
|
||||
{/* Hero → Problem geçişi: border ya da ayraç yerine piksel şeridi.
|
||||
Haritanın serpintisinden başlar, kaydırdıkça turuncuya ısınarak
|
||||
bölümden bölüme iner (DESIGN.md §4.38). */}
|
||||
<BolumGecisi tohum={11} ust={6} ustTelefon={6} ustKisaTelefon={3} />
|
||||
|
||||
{/* Problem */}
|
||||
<section className="py-20 sm:py-24">
|
||||
<section className={BOLUM_BOSLUGU}>
|
||||
<div className="mx-auto max-w-6xl px-4">
|
||||
{/* Başlık gövdeden hafif hızlı akar — katman derinliği */}
|
||||
<Parallax strength={16}>
|
||||
@@ -322,13 +303,18 @@ export default function Home() {
|
||||
</Reveal>
|
||||
</Parallax>
|
||||
{/* Sahne kartı (DESIGN.md §4.31): üstte hero'nun piksel diliyle
|
||||
kartın söylediğini çizen sahne, altında başlık ve metin. */}
|
||||
<Parallax strength={7} className="mt-12 grid gap-6 md:grid-cols-3">
|
||||
kartın söylediğini çizen sahne, altında başlık ve metin.
|
||||
Sahneler sırayla oynar; karta gelince o sahne hemen oynar
|
||||
(`data-sahne-karti`, sorun-sahneleri.tsx). */}
|
||||
<Parallax strength={7} className="mt-12 grid gap-6 sm:mt-16 md:grid-cols-3">
|
||||
{problems.map((p, i) => (
|
||||
<Reveal key={p.title} delay={i * 0.08} className="h-full">
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200/70 bg-card shadow-sm transition-[translate,border-color,box-shadow] duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5">
|
||||
<div
|
||||
data-sahne-karti=""
|
||||
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 motion-reduce:hover:translate-y-0 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5"
|
||||
>
|
||||
<div className="border-b border-slate-100 bg-slate-50 px-6 py-8">
|
||||
<PixelSahne izgara={p.sahne} />
|
||||
<SorunSahnesi tur={p.sahne} sira={i} />
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<h3 className="font-heading text-xl font-bold text-balance">
|
||||
@@ -346,14 +332,10 @@ export default function Home() {
|
||||
</section>
|
||||
|
||||
{/* Problem → Nasıl çalışır geçişi */}
|
||||
<div aria-hidden className="mx-auto w-56">
|
||||
<Parallax strength={26}>
|
||||
<PixelDivider seed={17} />
|
||||
</Parallax>
|
||||
</div>
|
||||
<BolumGecisi tohum={17} />
|
||||
|
||||
{/* How it works */}
|
||||
<section id="nasil-calisir" className="scroll-mt-24 py-20 sm:py-24">
|
||||
<section id="nasil-calisir" className={`scroll-mt-24 ${BOLUM_BOSLUGU}`}>
|
||||
<div className="mx-auto max-w-6xl px-4">
|
||||
<Parallax strength={16}>
|
||||
<Reveal className="flex flex-col items-center gap-4 text-center">
|
||||
@@ -362,7 +344,7 @@ export default function Home() {
|
||||
{/* Kuralların şeffaf anlatımına köprü: /meraklisina */}
|
||||
<Link
|
||||
href="/meraklisina"
|
||||
className="group inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-3.5 py-1.5 font-bricolage text-xs font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
className="group inline-flex min-h-8 items-center gap-1 rounded-full px-2 text-sm font-semibold text-slate-600 transition-colors duration-200 hover:text-orange-500"
|
||||
>
|
||||
Meraklısına
|
||||
<ArrowUpRight
|
||||
@@ -381,34 +363,10 @@ export default function Home() {
|
||||
</Reveal>
|
||||
</Parallax>
|
||||
{/* Adımlar kartsız (DESIGN.md §4.32): numara hero'nun piksel
|
||||
diliyle iri rakam, md'den itibaren rakamlar düz çizgiyle
|
||||
birbirine bağlanır. */}
|
||||
<Parallax strength={7} className="mt-12">
|
||||
<ol className="grid gap-12 md:grid-cols-3 md:gap-8">
|
||||
{steps.map((s, i) => (
|
||||
<li key={s.step}>
|
||||
<Reveal
|
||||
delay={i * 0.08}
|
||||
className="relative flex flex-col items-center text-center"
|
||||
>
|
||||
<PixelRakam rakam={s.step} className="h-24 w-auto" />
|
||||
{i < steps.length - 1 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-12 left-[calc(50%+4.5rem)] hidden h-0.5 w-[calc(100%-7rem)] -translate-y-1/2 rounded-full bg-slate-200 md:block"
|
||||
/>
|
||||
) : null}
|
||||
<h3 className="mt-6 font-heading text-xl font-bold text-balance">
|
||||
<span className="sr-only">{s.step}. adım: </span>
|
||||
{s.title}
|
||||
</h3>
|
||||
<p className="mt-2 max-w-sm text-base leading-relaxed text-slate-600">
|
||||
{s.description}
|
||||
</p>
|
||||
</Reveal>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
diliyle iri rakam; her rakam sırayla adımının biçimine dönüşüp
|
||||
geri toplanır, md'den itibaren aradaki düz çizgi dolar. */}
|
||||
<Parallax strength={7} className="mt-12 sm:mt-16">
|
||||
<AdimAkisi adimlar={steps} yilSayisi={VERI_YIL_SAYISI} />
|
||||
</Parallax>
|
||||
|
||||
{/* Veri akışı + veriye dayalı güven + dürüstlük notu (DESIGN.md
|
||||
@@ -416,10 +374,10 @@ export default function Home() {
|
||||
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">
|
||||
<Parallax strength={10} className="mt-24 sm:mt-32">
|
||||
<Reveal
|
||||
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="grid items-center gap-16 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"
|
||||
>
|
||||
<VeriAkisi className="lg:order-2" />
|
||||
|
||||
@@ -432,7 +390,7 @@ export default function Home() {
|
||||
{stat.label}
|
||||
</dt>
|
||||
<dd className="order-1 font-heading text-4xl font-bold leading-none tracking-tight text-primary sm:text-5xl">
|
||||
{stat.value}
|
||||
<SayanSayi deger={stat.value} />
|
||||
{stat.birim ? (
|
||||
<span className="ml-1.5 font-sans text-base font-semibold tracking-normal text-slate-400">
|
||||
{stat.birim}
|
||||
@@ -463,14 +421,10 @@ export default function Home() {
|
||||
</section>
|
||||
|
||||
{/* Nasıl çalışır → Karşılaştırma geçişi */}
|
||||
<div aria-hidden className="mx-auto w-56">
|
||||
<Parallax strength={26}>
|
||||
<PixelDivider seed={23} />
|
||||
</Parallax>
|
||||
</div>
|
||||
<BolumGecisi tohum={23} />
|
||||
|
||||
{/* Comparison */}
|
||||
<section id="karsilastirma" className="scroll-mt-24 py-20 sm:py-24">
|
||||
<section id="karsilastirma" className={`scroll-mt-24 ${BOLUM_BOSLUGU}`}>
|
||||
<div className="mx-auto max-w-4xl px-4">
|
||||
<Parallax strength={16}>
|
||||
<Reveal className="flex flex-col items-center gap-4 text-center">
|
||||
@@ -488,7 +442,7 @@ export default function Home() {
|
||||
üstte yapışık durur (site başlığının hemen altında: telefonda 96 px,
|
||||
sm'den itibaren 128 px — ölçüldü).
|
||||
lg'de klasik dört sütunlu tablo. */}
|
||||
<Parallax strength={7} className="mt-12">
|
||||
<Parallax strength={7} className="mt-12 sm:mt-16">
|
||||
<Reveal delay={0.08}>
|
||||
<div className="rounded-2xl border border-slate-200/70 bg-card shadow-sm lg:hidden">
|
||||
<div
|
||||
@@ -502,8 +456,12 @@ export default function Home() {
|
||||
</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{comparison.map((row) => (
|
||||
<li key={row.feature} className="px-3 py-5">
|
||||
{comparison.map((row, i) => (
|
||||
<li
|
||||
key={row.feature}
|
||||
className="px-3 py-5"
|
||||
style={{ "--tik-g": `${i * 70}ms` } as React.CSSProperties}
|
||||
>
|
||||
<p className="px-1 text-center text-base font-semibold leading-snug text-slate-900">
|
||||
{row.feature}
|
||||
</p>
|
||||
@@ -551,10 +509,11 @@ export default function Home() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{comparison.map((row) => (
|
||||
{comparison.map((row, i) => (
|
||||
<TableRow
|
||||
key={row.feature}
|
||||
className="hover:bg-transparent"
|
||||
style={{ "--tik-g": `${i * 70}ms` } as React.CSSProperties}
|
||||
>
|
||||
<TableCell className="whitespace-normal py-4 pl-5 text-base font-semibold text-slate-900">
|
||||
{row.feature}
|
||||
@@ -582,14 +541,10 @@ export default function Home() {
|
||||
</section>
|
||||
|
||||
{/* Karşılaştırma → Fiyat geçişi */}
|
||||
<div aria-hidden className="mx-auto w-56">
|
||||
<Parallax strength={26}>
|
||||
<PixelDivider seed={29} />
|
||||
</Parallax>
|
||||
</div>
|
||||
<BolumGecisi tohum={29} />
|
||||
|
||||
{/* Fiyat */}
|
||||
<section id="fiyat" className="scroll-mt-24 py-20 sm:py-24">
|
||||
<section id="fiyat" className={`scroll-mt-24 ${BOLUM_BOSLUGU}`}>
|
||||
<div className="mx-auto max-w-4xl px-4">
|
||||
<Parallax strength={16}>
|
||||
<Reveal className="flex flex-col items-center gap-4 text-center">
|
||||
@@ -610,7 +565,7 @@ export default function Home() {
|
||||
|
||||
<Parallax
|
||||
strength={7}
|
||||
className="mt-12 grid gap-6 sm:grid-cols-[1fr_auto]"
|
||||
className="mt-12 grid gap-6 sm:mt-16 sm:grid-cols-[1fr_auto]"
|
||||
>
|
||||
<Reveal
|
||||
delay={0.08}
|
||||
@@ -648,10 +603,7 @@ export default function Home() {
|
||||
<SosyalKanitBandi />
|
||||
</Suspense>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Button asChild size="lg" variant="cta" className="w-full">
|
||||
<Link href="/paket?kaynak=fiyat_paket">
|
||||
{PAKET_FIYATI} TL — Paketi incele
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
@@ -677,11 +629,7 @@ export default function Home() {
|
||||
+{URUNLER.topup.credits} soru hakkı, {TOPUP_FIYATI} TL. Pakete
|
||||
ek olarak, istediğin zaman.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="mt-4 h-11 w-full cursor-pointer"
|
||||
>
|
||||
<Button asChild variant="ikincil" className="mt-4 h-11 w-full">
|
||||
<Link href="/paket?kaynak=fiyat_kredi">
|
||||
+{URUNLER.topup.credits} kredi — {TOPUP_FIYATI} TL
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
@@ -695,12 +643,8 @@ export default function Home() {
|
||||
{/* 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">
|
||||
<BolumGecisi tohum={41} />
|
||||
<section id="katalog" className={`scroll-mt-24 ${BOLUM_BOSLUGU}`}>
|
||||
<div className="mx-auto max-w-6xl px-4">
|
||||
<Parallax strength={16}>
|
||||
<Reveal className="flex flex-col items-center gap-4 text-center">
|
||||
@@ -719,8 +663,7 @@ export default function Home() {
|
||||
<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]"
|
||||
variant="ikincil"
|
||||
>
|
||||
<Link href="/bolumler">
|
||||
Tüm bölümleri gör
|
||||
@@ -734,14 +677,10 @@ export default function Home() {
|
||||
) : null}
|
||||
|
||||
{/* Fiyat → SSS geçişi */}
|
||||
<div aria-hidden className="mx-auto w-56">
|
||||
<Parallax strength={26}>
|
||||
<PixelDivider seed={31} />
|
||||
</Parallax>
|
||||
</div>
|
||||
<BolumGecisi tohum={31} />
|
||||
|
||||
{/* FAQ */}
|
||||
<section id="sss" className="scroll-mt-24 py-20 sm:py-24">
|
||||
<section id="sss" className={`scroll-mt-24 ${BOLUM_BOSLUGU}`}>
|
||||
<div className="mx-auto max-w-3xl px-4">
|
||||
<Parallax strength={16}>
|
||||
<Reveal className="flex flex-col items-center gap-4 text-center">
|
||||
@@ -756,7 +695,7 @@ export default function Home() {
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="mt-10 rounded-2xl border border-slate-200/70 bg-card px-6 shadow-sm"
|
||||
className="mt-12 rounded-2xl border border-slate-200/70 bg-card px-6 shadow-sm sm:mt-16"
|
||||
>
|
||||
{faqs.map((faq) => (
|
||||
<AccordionItem
|
||||
@@ -779,7 +718,7 @@ export default function Home() {
|
||||
</section>
|
||||
|
||||
{/* Final CTA — tam genişlik bant yerine yüzen, köşeleri yumuşak panel */}
|
||||
<section className="px-4 pb-24 pt-6 sm:px-6">
|
||||
<section className="px-4 pb-24 pt-6 sm:px-6 sm:pb-32">
|
||||
<Parallax strength={12} className="mx-auto max-w-6xl">
|
||||
<Reveal>
|
||||
{/* Kapanış yüzeyi (DESIGN.md §10.8): koyu kart + 24'lük
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { LOGO_TEPKI_OLAYI, type LogoTepkisi } from "@/lib/logo-tepkisi";
|
||||
import { TEMA_GECIS_OLAYI } from "@/lib/tema-gecisi";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı.
|
||||
@@ -18,6 +20,11 @@ import { cn } from "@/lib/utils";
|
||||
// küçülüp büyür (danışman panel başlığının sakin, sürekli hâli; danışman
|
||||
// avatarı da cevap beklenirken böyle atar).
|
||||
//
|
||||
// `bicimler` verilirse döngü bir sahneye döner (DESIGN.md §4.28, ana sayfa
|
||||
// veri akışının ortası): varyant belirli süre oynar, sonra kareler sıradaki
|
||||
// biçime (liste, trend, sohbet) dönüşür, kısa bir an durur ve logoya
|
||||
// toplanır; döngü yerinden sürer, sıra bir sonraki biçime geçer.
|
||||
//
|
||||
// Performans notu: kareler SVG <rect> değil HTML <span>, animasyon da Web
|
||||
// Animations API ile yalnızca `translate` / `scale` / `opacity` üzerinden
|
||||
// yürür. Bu üçü compositor'da (GPU) oynar: ana iş parçacığı meşgulken
|
||||
@@ -97,6 +104,141 @@ function yer(pozisyon: number, boyut: number): string {
|
||||
return `${(x / boyut) * 100}% ${(y / boyut) * 100}%`;
|
||||
}
|
||||
|
||||
/** Karenin merkezini 180'lik kutuda (cx, cy) noktasına taşıyan `translate`. */
|
||||
function merkezde(cx: number, cy: number, boyut: number): string {
|
||||
return `${((cx - boyut / 2) / boyut) * 100}% ${((cy - boyut / 2) / boyut) * 100}%`;
|
||||
}
|
||||
|
||||
// Tema geçişi biçimi (DESIGN.md §2.3): sayfa geçişi bitince kareler
|
||||
// neye geçildiğini gösterir, kısa bir an durur ve yeniden logoya toplanır.
|
||||
// - Koyuya: şeftali kare hilale, mavi kare hilalin kucağındaki yıldıza;
|
||||
// turuncu söner.
|
||||
// - Açığa: turuncu kare güneşe, şeftali kare arkasındaki haleye; mavi söner.
|
||||
// Toplam süre ve duraklar: %30'da biçim tamam, %70'e kadar durur, sonra döner.
|
||||
const SEKIL_SURE = 1700;
|
||||
const SEKIL_DURAKLAR = [0, 0.3, 0.7, 1];
|
||||
type SekilHedefi = { translate: string; scale: string; opacity: number; borderRadius: string };
|
||||
|
||||
// Kullanıcı eylemi biçimleri (DESIGN.md §4.34): sıralama girildi → kürsü,
|
||||
// listeye eklendi → deste, liste doldu → tek sıra. Tema biçiminden kısadır,
|
||||
// çünkü eylem tekrarlanır; duraklar aynı. Kareler sırayla yola çıkar.
|
||||
const EYLEM_SURE = 1100;
|
||||
const EYLEM_GECIKME_ADIMI = 70;
|
||||
export type Bicim = "gunes" | "ay" | "kursu" | "deste" | "sira" | "trend" | "sohbet";
|
||||
const TEPKI_BICIMI: Record<LogoTepkisi, Bicim> = { sira: "kursu", ekle: "deste", tamam: "sira" };
|
||||
|
||||
// Sahne (`bicimler`): eylem biçiminden uzun durur, çünkü kimse bir şeye
|
||||
// basmadı — bakan kişi biçimi okuyacak kadar görmeli. Dönüşüm ≈0,6 sn,
|
||||
// durma ≈1,3 sn, toplanma ≈0,6 sn. Arada döngü (danışmanın nabzı) üç karenin
|
||||
// de birer kez atmasına yetecek kadar oynar.
|
||||
const SAHNE_SURE = 2500;
|
||||
const SAHNE_DURAKLAR = [0, 0.24, 0.76, 1];
|
||||
const SAHNE_DONGU_SURE = 3000;
|
||||
|
||||
/** Merkezi (cx, cy), eni `en`, boyu `boy` olan köşeli dikdörtgen hedefi. */
|
||||
function dikdortgen(cx: number, cy: number, en: number, boy: number, b: number): SekilHedefi {
|
||||
return {
|
||||
translate: merkezde(cx, cy, b),
|
||||
scale: `${en / b} ${boy / b}`,
|
||||
opacity: 1,
|
||||
// Ölçek yarıçapı da gerer; kısa kenara göre verilir ki köşe ezilmesin.
|
||||
borderRadius: `${(7 / en) * 100}% / ${(7 / boy) * 100}%`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Konuşma balonu: yuvarlak köşeli dikdörtgen, `kuyruk` yanındaki alt köşesi
|
||||
* sivri. Yarıçaplar ölçek gerdiği için her eksende ayrı verilir. */
|
||||
function balon(
|
||||
cx: number,
|
||||
cy: number,
|
||||
en: number,
|
||||
boy: number,
|
||||
b: number,
|
||||
kuyruk: "sol" | "sag",
|
||||
): SekilHedefi {
|
||||
// Köşe sırası: sol üst, sağ üst, sağ alt, sol alt.
|
||||
const r = kuyruk === "sol" ? [20, 20, 20, 4] : [20, 20, 4, 20];
|
||||
const oran = (kenar: number) => r.map((v) => `${(v / kenar) * 100}%`).join(" ");
|
||||
return {
|
||||
translate: merkezde(cx, cy, b),
|
||||
scale: `${en / b} ${boy / b}`,
|
||||
opacity: 1,
|
||||
borderRadius: `${oran(en)} / ${oran(boy)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Ay kompozisyonu kutunun merkezine (90) göre küçültülür ve logonun alanına
|
||||
// (x 27–151) oturacak kadar kaydırılır.
|
||||
const AY_OLCEK = 0.82;
|
||||
const AY_KAYMA_X = 8;
|
||||
function ay(nokta: number, kayma = AY_KAYMA_X): number {
|
||||
return 90 + (nokta - 90) * AY_OLCEK + kayma;
|
||||
}
|
||||
|
||||
/** Kare `i` için hedef biçim. Kareler sırayla mavi, şeftali, turuncu.
|
||||
* Her biçim logonun kendi alanına (x 27–151) sığar. */
|
||||
function sekilHedefi(i: number, bicim: Bicim): SekilHedefi {
|
||||
const b = KARELER[i].boyut;
|
||||
const daire = "50%";
|
||||
switch (bicim) {
|
||||
case "ay":
|
||||
return [
|
||||
// Biçim tema-ikonu.tsx'teki ay ikonuyla aynı: gövde r74 @ (76,92),
|
||||
// tek yıldız r13 @ (108,79) — hilalin iki sivri ucunun tam ortası (iki
|
||||
// dairenin kesişim kirişinin orta noktası). Turuncu kare yıldızın
|
||||
// yerinde söner: güneşte mavi, ayda turuncu sahneden çekilir. `ay`
|
||||
// kompozisyonu logonun kendi alanına sığdırır.
|
||||
{ translate: merkezde(ay(108), ay(79, 0), b), scale: String((26 * AY_OLCEK) / b), opacity: 1, borderRadius: daire }, // yıldız
|
||||
{ translate: merkezde(ay(76), ay(92, 0), b), scale: String((148 * AY_OLCEK) / b), opacity: 1, borderRadius: daire }, // hilal
|
||||
{ translate: merkezde(ay(108), ay(79, 0), b), scale: "0.2", opacity: 0, borderRadius: daire }, // söner
|
||||
][i];
|
||||
case "gunes":
|
||||
return [
|
||||
{ translate: merkezde(90, 90, b), scale: "0.3", opacity: 0, borderRadius: daire }, // söner
|
||||
{ translate: merkezde(90, 90, b), scale: "2.15", opacity: 0.45, borderRadius: daire }, // hale
|
||||
{ translate: merkezde(90, 90, b), scale: "1.1", opacity: 1, borderRadius: daire }, // güneş
|
||||
][i];
|
||||
case "kursu":
|
||||
// Üç basamak, tabanı aynı çizgide (y 150): mavi ortada en yüksek.
|
||||
return [
|
||||
dikdortgen(89, 103, 36, 94, b),
|
||||
dikdortgen(49, 119, 36, 62, b),
|
||||
dikdortgen(129, 127, 36, 46, b),
|
||||
][i];
|
||||
case "deste":
|
||||
// Üst üste üç satır: yeni gelen (turuncu) en üstte.
|
||||
return [
|
||||
dikdortgen(89, 130, 112, 30, b),
|
||||
dikdortgen(89, 90, 112, 30, b),
|
||||
dikdortgen(89, 50, 112, 30, b),
|
||||
][i];
|
||||
case "sira":
|
||||
// Liste tamam: üç kare tek sırada, eşit.
|
||||
return [
|
||||
dikdortgen(47, 90, 36, 36, b),
|
||||
dikdortgen(89, 90, 36, 36, b),
|
||||
dikdortgen(131, 90, 36, 36, b),
|
||||
][i];
|
||||
case "trend":
|
||||
// Yükselen üç çubuk, tabanı aynı çizgide (y 137; biçim kutunun
|
||||
// ortasına oturur): en yeni yıl (turuncu) sağda ve en yüksek — sorun
|
||||
// kartındaki çubuklarla aynı vurgu.
|
||||
return [
|
||||
dikdortgen(89, 102, 36, 70, b),
|
||||
dikdortgen(49, 114, 36, 46, b),
|
||||
dikdortgen(129, 90, 36, 94, b),
|
||||
][i];
|
||||
case "sohbet":
|
||||
// İki balon: solda danışman (mavi), sağ altta sen (turuncu). Şeftali
|
||||
// kare turuncunun yerinde söner — güneşte mavi, ayda turuncu gibi.
|
||||
return [
|
||||
balon(76, 65, 92, 56, b, "sol"),
|
||||
{ translate: merkezde(106, 122, b), scale: "0.2", opacity: 0, borderRadius: "50%" },
|
||||
balon(106, 122, 84, 44, b, "sag"),
|
||||
][i];
|
||||
}
|
||||
}
|
||||
|
||||
/** Kare için durak koordinatları: kendi yeri → sıradaki → ondan sonraki →
|
||||
* kendi yeri. Duraklarda bekleme için her hedef iki kez tekrarlanır
|
||||
* (KONUM_ZAMANLARI ile hizalı). */
|
||||
@@ -170,6 +312,7 @@ export function AnimasyonluMark({
|
||||
sadeceHover = false,
|
||||
oynat = true,
|
||||
varyant = "rota",
|
||||
bicimler,
|
||||
renk = "marka",
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -177,6 +320,10 @@ export function AnimasyonluMark({
|
||||
/** `sadeceHover` yokken döngü açık mı. `false` iken işaret durağandır. */
|
||||
oynat?: boolean;
|
||||
varyant?: Varyant;
|
||||
/** Sahne: döngü oynarken kareler aralıklarla sıradaki biçime dönüşüp geri
|
||||
* toplanır. Dizi sabit olmalı (her render'da yenisi verilirse sıra başa
|
||||
* döner). `sadeceHover` ile birlikte kullanılmaz. */
|
||||
bicimler?: readonly Bicim[];
|
||||
/** "marka": logodaki üç renk; "turuncu": tüm kareler sitenin CTA turuncusu
|
||||
* (orange-500) — ör. danışman panel başlığı. */
|
||||
renk?: "marka" | "turuncu";
|
||||
@@ -184,6 +331,7 @@ export function AnimasyonluMark({
|
||||
const kokRef = useRef<HTMLSpanElement>(null);
|
||||
const kontrolRef = useRef<{
|
||||
ayarla: (hedef: Varyant | null, cevik?: boolean) => void;
|
||||
sekilOynat: (bicim: Bicim, sahne?: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
// Animasyonların sahibi: bileşen yaşadıkça bir kez kurulur. Prop değişimi
|
||||
@@ -197,6 +345,8 @@ export function AnimasyonluMark({
|
||||
|
||||
let dongu: Animation[] = [];
|
||||
let donus: Animation[] = [];
|
||||
/** Tema geçişi biçimi (güneş/ay) animasyonları; bkz. `temaSekli`. */
|
||||
let sekil: Animation[] = [];
|
||||
/** Şu an dönen döngü ve istenen döngü (`null`: durağan). */
|
||||
let aktif: Varyant | null = null;
|
||||
let hedef: Varyant | null = null;
|
||||
@@ -233,14 +383,11 @@ export function AnimasyonluMark({
|
||||
);
|
||||
};
|
||||
|
||||
const ayarla = (yeni: Varyant | null, cevik = false) => {
|
||||
if (yeni === hedef) return;
|
||||
hedef = yeni;
|
||||
if (aktif !== null) yerles(cevik);
|
||||
if (yeni === null || azHareket.matches) return;
|
||||
// Kareler hâlâ yerine dönüyorsa dönüşün bitmesi beklenir; döngü hep
|
||||
// yerinden başlar, sıçrama olmaz.
|
||||
Promise.all(donus.map((a) => a.finished))
|
||||
/** `yeni` döngüsünü, süren dönüş ve tema biçimi bitince yerinden başlatır. */
|
||||
const donguyuSiraya = (yeni: Varyant) => {
|
||||
// Kareler hâlâ yerine dönüyorsa (ya da güneş/ay oynuyorsa) bitmesi
|
||||
// beklenir; döngü hep yerinden başlar, sıçrama olmaz.
|
||||
Promise.all([...donus, ...sekil].map((a) => a.finished))
|
||||
.then(() => {
|
||||
if (sokuldu || hedef !== yeni || aktif !== null) return;
|
||||
dongu = kareler.flatMap((el, i) => donguyuBaslat(el, i, yeni));
|
||||
@@ -249,12 +396,104 @@ export function AnimasyonluMark({
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const kontrol = { ayarla };
|
||||
const ayarla = (yeni: Varyant | null, cevik = false) => {
|
||||
if (yeni === hedef) return;
|
||||
hedef = yeni;
|
||||
if (aktif !== null) yerles(cevik);
|
||||
if (yeni === null || azHareket.matches) return;
|
||||
donguyuSiraya(yeni);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tema geçişi biçimi: kareler güneşe ya da aya dönüşür, durur, logoya
|
||||
* döner. Hover döngüsünden önceliklidir: döngü oynuyorsa kesilir, kareler
|
||||
* o an neredeyse oradan biçime geçer; biçim bitince imleç hâlâ
|
||||
* üzerindeyse döngü yerinden yeniden başlar. Hareket azaltılmışsa çalışmaz.
|
||||
* Hilal, kareyi kesen ikinci bir şekille değil karenin kendi iç gölgesiyle
|
||||
* çizilir (zemin şeffaf, renk iç gölgede): arkadaki zemin ne olursa olsun
|
||||
* doğru görünür. `sahne` biçimi daha uzun tutar (SAHNE_SURE).
|
||||
*/
|
||||
const sekilOynat = (hedefBicim: Bicim, sahne = false) => {
|
||||
if (azHareket.matches) return;
|
||||
const koyu = hedefBicim === "ay";
|
||||
const eylem = hedefBicim !== "ay" && hedefBicim !== "gunes";
|
||||
const duraklar = sahne ? SAHNE_DURAKLAR : SEKIL_DURAKLAR;
|
||||
// Karelerin o anki hâli: döngü ya da önceki biçim yarıdaysa oradan çıkılır.
|
||||
const anlik = kareler.map((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
translate: cs.translate,
|
||||
scale: cs.scale,
|
||||
opacity: cs.opacity,
|
||||
borderRadius: cs.borderRadius,
|
||||
};
|
||||
});
|
||||
[...dongu, ...donus, ...sekil].forEach((a) => a.cancel());
|
||||
dongu = [];
|
||||
donus = [];
|
||||
aktif = null;
|
||||
sekil = kareler.flatMap((el, i) => {
|
||||
const k = KARELER[i];
|
||||
const ev = {
|
||||
translate: yer(k.baslangic, k.boyut),
|
||||
scale: "1",
|
||||
opacity: 1,
|
||||
borderRadius: `${(13 / k.boyut) * 100}%`,
|
||||
};
|
||||
const bicim = sekilHedefi(i, hedefBicim);
|
||||
// `fill: backwards` gecikme boyunca kareyi o anki yerinde tutar.
|
||||
const secenek = eylem
|
||||
? {
|
||||
duration: sahne ? SAHNE_SURE : EYLEM_SURE,
|
||||
delay: i * EYLEM_GECIKME_ADIMI,
|
||||
fill: "backwards" as const,
|
||||
}
|
||||
: { duration: SEKIL_SURE };
|
||||
const animasyonlar = [
|
||||
el.animate(
|
||||
[
|
||||
{ ...anlik[i], easing: DONUS },
|
||||
{ ...bicim, easing: "linear" },
|
||||
{ ...bicim, easing: YERLES },
|
||||
ev,
|
||||
].map((kare, d) => ({ ...kare, offset: duraklar[d] })),
|
||||
secenek,
|
||||
),
|
||||
];
|
||||
// Hilal: yalnız şeftali kare, yalnız koyuya geçerken.
|
||||
if (koyu && i === 1) {
|
||||
const renk = getComputedStyle(el).backgroundColor;
|
||||
const w = el.offsetWidth;
|
||||
const dolu = `inset 0 0 0 ${w}px ${renk}`;
|
||||
// tema-ikonu.tsx'teki hilalle aynı kesim: gövde r74'ten, (+40,-16)
|
||||
// kaydırılmış r66 daire çıkar — sırtı sol altta.
|
||||
// İç gölgede: kaydırma = kesim dairesinin kayması, yayılma = iki
|
||||
// yarıçapın farkı (hepsi gövde çapı 148'e oranla).
|
||||
const hilal = `inset ${(40 / 148) * w}px ${(-16 / 148) * w}px 0 ${(8 / 148) * w}px ${renk}`;
|
||||
animasyonlar.push(
|
||||
el.animate(
|
||||
[
|
||||
{ boxShadow: dolu, backgroundColor: "transparent", easing: DONUS },
|
||||
{ boxShadow: hilal, backgroundColor: "transparent", easing: "linear" },
|
||||
{ boxShadow: hilal, backgroundColor: "transparent", easing: YERLES },
|
||||
{ boxShadow: dolu, backgroundColor: "transparent" },
|
||||
].map((kare, d) => ({ ...kare, offset: SEKIL_DURAKLAR[d] })),
|
||||
secenek,
|
||||
),
|
||||
);
|
||||
}
|
||||
return animasyonlar;
|
||||
});
|
||||
// İmleç hâlâ üzerindeyse hover döngüsü biçimden sonra sürer.
|
||||
if (hedef !== null) donguyuSiraya(hedef);
|
||||
};
|
||||
|
||||
const kontrol = { ayarla, sekilOynat };
|
||||
kontrolRef.current = kontrol;
|
||||
return () => {
|
||||
sokuldu = true;
|
||||
if (kontrolRef.current === kontrol) kontrolRef.current = null;
|
||||
[...dongu, ...donus].forEach((a) => a.cancel());
|
||||
[...dongu, ...donus, ...sekil].forEach((a) => a.cancel());
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -265,7 +504,17 @@ export function AnimasyonluMark({
|
||||
if (!kok) return;
|
||||
if (!sadeceHover) {
|
||||
kontrolRef.current?.ayarla(oynat ? varyant : null);
|
||||
return;
|
||||
if (!oynat || !bicimler?.length) return;
|
||||
// Sahne: döngü SAHNE_DONGU_SURE oynar, sonra sıradaki biçim; biçim
|
||||
// bitince döngü yerinden sürer (`sekilOynat` sıraya koyar). `oynat`
|
||||
// kapanınca sayaç durur, yarıdaki biçim kendi kendine logoya toplanır.
|
||||
let sira = 0;
|
||||
let sayac = window.setTimeout(function siradaki() {
|
||||
kontrolRef.current?.sekilOynat(bicimler[sira % bicimler.length], true);
|
||||
sira += 1;
|
||||
sayac = window.setTimeout(siradaki, SAHNE_SURE + SAHNE_DONGU_SURE);
|
||||
}, SAHNE_DONGU_SURE);
|
||||
return () => window.clearTimeout(sayac);
|
||||
}
|
||||
kontrolRef.current?.ayarla(null, true);
|
||||
// Hover hedefi işaretin kendisi değil, en yakın `group` ebeveyni (site
|
||||
@@ -276,11 +525,39 @@ export function AnimasyonluMark({
|
||||
const cik = () => kontrolRef.current?.ayarla(null, true);
|
||||
hoverHedefi.addEventListener("mouseenter", gir);
|
||||
hoverHedefi.addEventListener("mouseleave", cik);
|
||||
// Tema geçişi bitince (lib/tema-gecisi.ts) kareler yeni temanın
|
||||
// biçimine dönüşür: koyuya geçişte ay ve yıldızlar, açığa geçişte güneş.
|
||||
// Olay anında <html> yeni temanın sınıfını taşır.
|
||||
const temaGecti = () =>
|
||||
kontrolRef.current?.sekilOynat(
|
||||
document.documentElement.classList.contains("dark") ? "ay" : "gunes",
|
||||
);
|
||||
window.addEventListener(TEMA_GECIS_OLAYI, temaGecti);
|
||||
// Kullanıcı eylemi (lib/logo-tepkisi.ts): sıralama, ekleme, dolu liste.
|
||||
const tepki = (e: Event) => {
|
||||
const tur = (e as CustomEvent<LogoTepkisi>).detail;
|
||||
if (tur in TEPKI_BICIMI) kontrolRef.current?.sekilOynat(TEPKI_BICIMI[tur]);
|
||||
};
|
||||
window.addEventListener(LOGO_TEPKI_OLAYI, tepki);
|
||||
// Dokunmatikte hover yok: logoya basınca döngü bir tur oynar, sonra
|
||||
// kareler yerine oturur (telefonda da logonun canlı olduğu görülsün).
|
||||
let dokunmaSayaci: number | undefined;
|
||||
const dokun = (e: Event) => {
|
||||
if ((e as PointerEvent).pointerType === "mouse") return;
|
||||
window.clearTimeout(dokunmaSayaci);
|
||||
gir();
|
||||
dokunmaSayaci = window.setTimeout(cik, ROTA_SURE);
|
||||
};
|
||||
hoverHedefi.addEventListener("pointerdown", dokun);
|
||||
return () => {
|
||||
hoverHedefi.removeEventListener("mouseenter", gir);
|
||||
hoverHedefi.removeEventListener("mouseleave", cik);
|
||||
window.removeEventListener(TEMA_GECIS_OLAYI, temaGecti);
|
||||
window.removeEventListener(LOGO_TEPKI_OLAYI, tepki);
|
||||
hoverHedefi.removeEventListener("pointerdown", dokun);
|
||||
window.clearTimeout(dokunmaSayaci);
|
||||
};
|
||||
}, [oynat, sadeceHover, varyant]);
|
||||
}, [oynat, sadeceHover, varyant, bicimler]);
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
304
src/components/mobil-menu.tsx
Normal file
304
src/components/mobil-menu.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
// Telefon menüsü (DESIGN.md §4.36): üst çubuk tek satır kalsın diye sıralama
|
||||
// dışındaki her şey bu baloncuğun içinde. Açılınca tam ekran perde gelir,
|
||||
// iri haplar sırayla "pop" eder; kapanış hızlı ve sekmesiz.
|
||||
//
|
||||
// Esin: React Bits "Bubble Menu" (reactbits.dev, MIT + Commons Clause). Kod
|
||||
// KolayTercih diline çevrildi: GSAP yerine `motion`, zamanlama
|
||||
// `lib/hareket.ts`, renkler token; eğik/gökkuşağı haplar alınmadı. Kabuk
|
||||
// Radix Dialog ilkeli (odak tuzağı, Esc, odak dönüşü); hareketi azalt'ta
|
||||
// haplar yalnız belirir.
|
||||
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
BookOpen,
|
||||
Building2,
|
||||
CircleHelp,
|
||||
ClipboardList,
|
||||
Coins,
|
||||
GraduationCap,
|
||||
ListChecks,
|
||||
Search,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
|
||||
import { TemaSecici } from "@/components/tema-secici";
|
||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
cekmeceAc,
|
||||
useManuelListe,
|
||||
} from "@/features/liste/liste-store";
|
||||
import { preloadListeCekmecesi } from "@/features/liste/components/liste-cekmecesi-lazy";
|
||||
import { EGRI, SURE, YAY } from "@/lib/hareket";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Arama penceresini açtıran olay; `katalog-arama.tsx` dinler. */
|
||||
export const ARAMA_AC_OLAYI = "kt-arama-ac";
|
||||
|
||||
const HAP_ADIMI = 0.035;
|
||||
|
||||
const GEZINME: { href: string; etiket: string; ikon: LucideIcon }[] = [
|
||||
{ href: "/bolumler", etiket: "Bölümler", ikon: GraduationCap },
|
||||
{ href: "/universiteler", etiket: "Üniversiteler", ikon: Building2 },
|
||||
{ href: "/rehber", etiket: "Rehber", ikon: BookOpen },
|
||||
{ href: "/#nasil-calisir", etiket: "Nasıl çalışır?", ikon: CircleHelp },
|
||||
];
|
||||
|
||||
const IRI_HAP =
|
||||
"flex min-h-14 w-full cursor-pointer items-center gap-3 rounded-full border border-slate-200 bg-card px-6 text-left font-heading text-xl font-bold text-slate-900 outline-none transition-[background-color,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97] motion-reduce:transition-none";
|
||||
const KUCUK_HAP =
|
||||
"flex min-h-12 cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-card px-4 text-base font-semibold text-slate-700 outline-none transition-[background-color,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97] motion-reduce:transition-none";
|
||||
|
||||
export function MobilMenu({
|
||||
kredi,
|
||||
girisSlotu,
|
||||
}: {
|
||||
/** Girişli kullanıcının kredisi; girişsizse `null`. */
|
||||
kredi: number | null;
|
||||
/** "Giriş yap" ya da "Çıkış yap" hapı (sunucu tarafı karar verir). */
|
||||
girisSlotu: ReactNode;
|
||||
}) {
|
||||
const [acik, setAcik] = useState(false);
|
||||
// Kapatma düğmesi ve logo, üst çubuktaki yerlerinde durur: çubuğun o anki
|
||||
// konumu (üst bant görünürken daha aşağıda) açılırken ölçülür.
|
||||
const [ust, setUst] = useState(16);
|
||||
const tetikRef = useRef<HTMLButtonElement>(null);
|
||||
const azalt = useReducedMotion();
|
||||
const liste = useManuelListe();
|
||||
|
||||
function degistir(yeni: boolean) {
|
||||
if (yeni) {
|
||||
const r = tetikRef.current?.getBoundingClientRect();
|
||||
if (r) setUst(Math.max(8, r.top));
|
||||
}
|
||||
setAcik(yeni);
|
||||
}
|
||||
|
||||
/** Sırayla "pop": baloncuk fırlar (§4.36: sekmenin serbest olduğu yer). */
|
||||
const hap = (i: number) =>
|
||||
azalt
|
||||
? {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, transition: { duration: SURE.hizli } },
|
||||
exit: { opacity: 0, transition: { duration: SURE.anlik } },
|
||||
}
|
||||
: {
|
||||
initial: { opacity: 0, scale: 0.6 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { ...YAY.birakma, delay: 0.06 + i * HAP_ADIMI },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.92,
|
||||
transition: { duration: SURE.cikis, ease: EGRI.standart },
|
||||
},
|
||||
};
|
||||
|
||||
let sira = 0;
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={acik} onOpenChange={degistir}>
|
||||
<DialogPrimitive.Trigger asChild>
|
||||
<button
|
||||
ref={tetikRef}
|
||||
type="button"
|
||||
aria-label={
|
||||
liste.length > 0
|
||||
? `Menü — listende ${liste.length}/${MANUEL_LISTE_MAX} program`
|
||||
: "Menü"
|
||||
}
|
||||
data-ucus-hedefi="mobil"
|
||||
className="relative flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-900 outline-none transition-[background-color,transform] duration-150 hover:bg-slate-100 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.96] motion-reduce:transition-none sm:size-12"
|
||||
>
|
||||
<MenuCizgileri acik={false} />
|
||||
{liste.length > 0 ? (
|
||||
<span
|
||||
data-ucus-rozet
|
||||
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-orange-500 px-1 font-heading text-xs font-bold text-white"
|
||||
>
|
||||
<SayiRulo deger={liste.length} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</DialogPrimitive.Trigger>
|
||||
|
||||
<AnimatePresence>
|
||||
{acik ? (
|
||||
<DialogPrimitive.Portal forceMount>
|
||||
<DialogPrimitive.Overlay forceMount asChild>
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 bg-slate-50/90 supports-backdrop-filter:backdrop-blur-md"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: SURE.standart, ease: EGRI.standart } }}
|
||||
exit={{ opacity: 0, transition: { duration: SURE.cikis, ease: EGRI.standart } }}
|
||||
/>
|
||||
</DialogPrimitive.Overlay>
|
||||
<DialogPrimitive.Content
|
||||
forceMount
|
||||
aria-describedby={undefined}
|
||||
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain outline-none"
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">Menü</DialogPrimitive.Title>
|
||||
|
||||
{/* Üst sıra: çubuğun yerinde. Önce imza (logo bir tur döner),
|
||||
içerik ardından gelir (§11.2). */}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 z-10 flex items-center justify-between px-3 sm:px-4"
|
||||
style={{ top: ust }}
|
||||
>
|
||||
<motion.span
|
||||
className="flex items-center font-bricolage"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: SURE.standart } }}
|
||||
exit={{ opacity: 0, transition: { duration: SURE.anlik } }}
|
||||
>
|
||||
<AnimasyonluMark varyant="nabiz" className="size-8 sm:size-11" />
|
||||
<span className="text-lg font-light tracking-tighter sm:text-3xl">Kolay</span>
|
||||
<span className="text-lg font-semibold tracking-tighter sm:text-3xl">Tercih</span>
|
||||
</motion.span>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Menüyü kapat"
|
||||
className="pointer-events-auto flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-900 outline-none transition-[background-color,transform] duration-150 hover:bg-slate-100 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.96] motion-reduce:transition-none sm:size-12"
|
||||
>
|
||||
<MenuCizgileri acik />
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
aria-label="Menü"
|
||||
className="mx-auto flex min-h-full max-w-md flex-col gap-3 px-4 pb-10"
|
||||
style={{ paddingTop: ust + 72 }}
|
||||
>
|
||||
<motion.div {...hap(sira++)}>
|
||||
<button
|
||||
type="button"
|
||||
className={IRI_HAP}
|
||||
onClick={() => {
|
||||
setAcik(false);
|
||||
window.dispatchEvent(new Event(ARAMA_AC_OLAYI));
|
||||
}}
|
||||
>
|
||||
<Search className="size-5 shrink-0 text-primary" aria-hidden />
|
||||
Ara
|
||||
<span className="ml-auto text-sm font-medium font-sans text-slate-500">
|
||||
bölüm, üniversite, rehber
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div {...hap(sira++)}>
|
||||
<button
|
||||
type="button"
|
||||
className={IRI_HAP}
|
||||
onPointerEnter={preloadListeCekmecesi}
|
||||
onFocus={preloadListeCekmecesi}
|
||||
onClick={() => {
|
||||
setAcik(false);
|
||||
cekmeceAc();
|
||||
}}
|
||||
>
|
||||
<ListChecks className="size-5 shrink-0 text-primary" aria-hidden />
|
||||
Kendi Listem
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto flex h-7 items-center rounded-full px-2.5 font-sans text-sm font-bold tabular-nums",
|
||||
liste.length > 0
|
||||
? "bg-orange-500 text-white"
|
||||
: "bg-slate-100 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{liste.length}/{MANUEL_LISTE_MAX}
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{kredi !== null ? (
|
||||
<>
|
||||
<motion.div {...hap(sira++)}>
|
||||
<Link href="/listem" className={IRI_HAP} onClick={() => setAcik(false)}>
|
||||
<ClipboardList className="size-5 shrink-0 text-primary" aria-hidden />
|
||||
Yapay Zeka Listem
|
||||
</Link>
|
||||
</motion.div>
|
||||
<motion.div {...hap(sira++)}>
|
||||
<Link
|
||||
href="/paket?kaynak=nav"
|
||||
className={IRI_HAP}
|
||||
onClick={() => setAcik(false)}
|
||||
>
|
||||
<Coins className="size-5 shrink-0 text-amber-500" aria-hidden />
|
||||
Kredilerim
|
||||
<span className="ml-auto font-sans text-base font-bold tabular-nums text-slate-700">
|
||||
{kredi.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<motion.ul {...hap(sira++)} className="mt-2 grid grid-cols-2 gap-2">
|
||||
{GEZINME.map(({ href, etiket, ikon: Ikon }) => (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(KUCUK_HAP, "w-full")}
|
||||
onClick={() => setAcik(false)}
|
||||
>
|
||||
<Ikon className="size-4 shrink-0 text-slate-500" aria-hidden />
|
||||
<span className="truncate">{etiket}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
|
||||
<motion.div {...hap(sira++)} className="mt-4 flex justify-center">
|
||||
<TemaSecici ipucu={false} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
{...hap(sira++)}
|
||||
className="mt-auto flex justify-center pt-6"
|
||||
// Giriş/çıkış sonrası menü açık kalmasın.
|
||||
onClick={() => setAcik(false)}
|
||||
>
|
||||
{girisSlotu}
|
||||
</motion.div>
|
||||
</nav>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/** İki çizgi ↔ çarpı. Açık hâl perdede ayrı düğmede çizildiği için burada
|
||||
* geçiş yok; iki hâl aynı ölçüde (§4.3 çarpı ölçüsü). */
|
||||
function MenuCizgileri({ acik }: { acik: boolean }) {
|
||||
return (
|
||||
<span aria-hidden className="relative block size-4">
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-0 h-0.5 w-4 rounded-full bg-current",
|
||||
acik ? "top-[7px] rotate-45" : "top-1",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-0 h-0.5 w-4 rounded-full bg-current",
|
||||
acik ? "top-[7px] -rotate-45" : "top-2.5",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
145
src/components/piksel-morf.tsx
Normal file
145
src/components/piksel-morf.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
// Piksel morf (DESIGN.md §4.38): piksel sahnenin (§4.31) canlı hâli. Sahne
|
||||
// sabit bir piksel kümesidir; her pikselin kimliği vardır ve kareden kareye
|
||||
// yeni hücresine süzülür, turuncuya ısınır ya da sahneden çekilir. Logonun üç
|
||||
// karesinin yaptığını (§4.34) hero'nun ızgarasında, çok pikselle yapar.
|
||||
//
|
||||
// Bilerek aptal bir ilkel: hangi pikselin nerede, hangi tonda duracağını
|
||||
// çağıran söyler (`pikseller`), hareketin tamamı CSS geçişidir (globals.css
|
||||
// `.kt-morf-px`). Zamanlamayı, tekrarı ve görünürlüğü de çağıran yönetir.
|
||||
//
|
||||
// Doku pikselleri imleç ısı fırçasına katılır (usePixelHeat `canli`): fırça
|
||||
// konumu her taramada okur, o yüzden yer değiştiren piksel doğru ısınır.
|
||||
// Turuncu vurgu, doku pikselinin üstüne binen ikinci bir karedir — fırça
|
||||
// alttaki karenin rengini sürerken vurgu ona karışmaz; vurgu yanarken alttaki
|
||||
// kare söner (turuncu, mavinin üstünde bulanmasın).
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { PIKSEL } from "@/components/pixel-decor";
|
||||
import { pikselIsiTazele, usePixelHeat } from "@/components/use-pixel-heat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type MorfTon =
|
||||
/** Hero haritasının mavi dokusu */
|
||||
| "doku"
|
||||
/** Aynı doku yarı opaklıkta (dolgu, arka plan) */
|
||||
| "silik"
|
||||
/** Turuncu vurgu; hero'daki şehirler gibi nabız atar */
|
||||
| "vurgu"
|
||||
/** Sahnede yok: küçülüp söner, yerinde bekler */
|
||||
| "gizli";
|
||||
|
||||
export type MorfPiksel = {
|
||||
/** Kareler arası kimlik: aynı `id` aynı pikseldir, yeni yerine süzülür. */
|
||||
id: string;
|
||||
/** Hücre koordinatı (buçuklu olabilir: ortalanmış küme). */
|
||||
x: number;
|
||||
y: number;
|
||||
ton: MorfTon;
|
||||
/** Doku opaklığı (0–1). Kimliğe bağlıdır; piksel taşınınca değişmez. */
|
||||
op: number;
|
||||
/** Bu karedeki geçişin gecikmesi (ms): sırayla ısınma, sırayla kalkış. */
|
||||
gecikme?: number;
|
||||
};
|
||||
|
||||
const { hucre: CELL, nokta: DOT, bosluk: INSET } = PIKSEL;
|
||||
|
||||
export function PikselMorf({
|
||||
pikseller,
|
||||
sutun,
|
||||
satir,
|
||||
yaricap = 26,
|
||||
firca = true,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
/** Sıra kareler arasında sabit kalmalı (DOM yer değiştirirse geçiş kopar). */
|
||||
pikseller: readonly MorfPiksel[];
|
||||
sutun: number;
|
||||
satir: number;
|
||||
/** Isı fırçasının yarıçapı (viewBox birimi; hücre 10) */
|
||||
yaricap?: number;
|
||||
/** `false`: bu karede fırça kapalı — turuncunun anlam taşıdığı biçimde
|
||||
* (ör. "sen buradasın" karesi) imleç ısısı ona karışmasın. */
|
||||
firca?: boolean;
|
||||
className?: string;
|
||||
/** Süre ayarı için: `--morf-sure` (kayma), varsayılan 0,55 sn */
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
usePixelHeat(svgRef, { radius: yaricap, canli: true });
|
||||
|
||||
// Pikseller yer değiştirdi: imleç yerinde dursa da fırça yeniden baksın.
|
||||
useEffect(() => {
|
||||
pikselIsiTazele();
|
||||
}, [pikseller, firca]);
|
||||
|
||||
// Turuncu üst kare yalnız bir kez vurgu olmuş piksele eklenir ve orada
|
||||
// kalır: sönerken de DOM'da olmalı ki soğuma geçişi oynasın.
|
||||
const [vurguGordu, setVurguGordu] = useState(
|
||||
() => new Set(pikseller.filter((p) => p.ton === "vurgu").map((p) => p.id)),
|
||||
);
|
||||
if (pikseller.some((p) => p.ton === "vurgu" && !vurguGordu.has(p.id))) {
|
||||
const genis = new Set(vurguGordu);
|
||||
for (const p of pikseller) if (p.ton === "vurgu") genis.add(p.id);
|
||||
setVurguGordu(genis);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${sutun * CELL} ${satir * CELL}`}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
className={cn("h-auto w-full text-primary", className)}
|
||||
style={style}
|
||||
>
|
||||
{pikseller.map((p, i) => {
|
||||
const isinir = firca && (p.ton === "doku" || p.ton === "silik");
|
||||
const op = Math.round((p.ton === "silik" ? p.op * 0.5 : p.op) * 100) / 100;
|
||||
return (
|
||||
<g
|
||||
key={p.id}
|
||||
className="kt-morf-px"
|
||||
data-ton={p.ton}
|
||||
style={
|
||||
{
|
||||
translate: `${p.x * CELL}px ${p.y * CELL}px`,
|
||||
"--g": p.gecikme ? `${p.gecikme}ms` : undefined,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<rect
|
||||
x={INSET}
|
||||
y={INSET}
|
||||
width={DOT}
|
||||
height={DOT}
|
||||
rx={2}
|
||||
data-px={isinir ? "" : undefined}
|
||||
data-cx={p.x * CELL + CELL / 2}
|
||||
data-cy={p.y * CELL + CELL / 2}
|
||||
data-op={op}
|
||||
fill="currentColor"
|
||||
fillOpacity={op}
|
||||
className="kt-morf-taban"
|
||||
/>
|
||||
{vurguGordu.has(p.id) ? (
|
||||
<rect
|
||||
x={INSET}
|
||||
y={INSET}
|
||||
width={DOT}
|
||||
height={DOT}
|
||||
rx={2}
|
||||
className="kt-morf-vurgu fill-orange-500"
|
||||
// Nabızlar aynı anda atmasın (PixelSahne ile aynı kademe);
|
||||
// sıra pikselin dizideki yerinden gelir, kareden kareye sabit.
|
||||
style={{ "--n": `${((i * 5) % 8) * 0.45}s` } as CSSProperties}
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
75
src/components/piksel-serit.tsx
Normal file
75
src/components/piksel-serit.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
// Piksel şeridi (DESIGN.md §4.38): hero haritasından dökülen serpintinin tek
|
||||
// sıraya inmiş hâli. Dikey bir piksel sütunudur ve kaydırmaya bağlıdır:
|
||||
// ekranın okuma çizgisini (varsayılan: yüksekliğin %65'i) geçen her piksel
|
||||
// turuncuya ısınır, geri kaydırınca yavaşça soğur. Animasyon yoktur; şeridi
|
||||
// kullanıcının parmağı çizer (Parallax gibi kaydırmayla 1:1).
|
||||
//
|
||||
// Renk geçişi CSS'tir (globals.css `.kt-serit-px`): çabuk ısınma, yavaş soğuma
|
||||
// — ısı fırçasıyla aynı asimetri. Hareketi azalt'ta geçiş anlıktır.
|
||||
|
||||
import { useRef, useState, type CSSProperties } from "react";
|
||||
import { useMotionValueEvent, useScroll } from "motion/react";
|
||||
import { PIKSEL, dokuOpakligi } from "@/components/pixel-decor";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const { hucre: CELL, nokta: DOT, bosluk: INSET } = PIKSEL;
|
||||
|
||||
export function PikselSerit({
|
||||
uzunluk,
|
||||
okuma = 0.65,
|
||||
tohum = 0,
|
||||
hucreSinifi,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
/** Şeridin boyu (hücre) */
|
||||
uzunluk: number;
|
||||
/** Okuma çizgisi: ekran yüksekliğinin oranı (0 üst, 1 alt) */
|
||||
okuma?: number;
|
||||
/** Doku deseni; yan yana şeritler aynı görünmesin */
|
||||
tohum?: number;
|
||||
/** Sıra başına ek sınıf: dar boşlukta içeriğe değecek pikseli gizlemek
|
||||
* için (ör. `max-sm:hidden`). */
|
||||
hucreSinifi?: readonly (string | undefined)[];
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const kapRef = useRef<HTMLDivElement>(null);
|
||||
const [yanan, setYanan] = useState(0);
|
||||
// 0: şeridin tepesi okuma çizgisinde; 1: dibi okuma çizgisinde.
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: kapRef,
|
||||
offset: [`start ${okuma}`, `end ${okuma}`],
|
||||
});
|
||||
useMotionValueEvent(scrollYProgress, "change", (ilerleme) => {
|
||||
setYanan(Math.round(ilerleme * uzunluk));
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={kapRef} aria-hidden="true" className={className} style={style}>
|
||||
<svg
|
||||
viewBox={`0 0 ${CELL} ${uzunluk * CELL}`}
|
||||
focusable="false"
|
||||
className="block h-auto w-full text-primary"
|
||||
>
|
||||
{Array.from({ length: uzunluk }, (_, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={INSET}
|
||||
y={i * CELL + INSET}
|
||||
width={DOT}
|
||||
height={DOT}
|
||||
rx={2}
|
||||
data-yandi={i < yanan ? "" : undefined}
|
||||
// Şeridin ucu: en son yanan piksel tam turuncu
|
||||
data-uc={i === yanan - 1 ? "" : undefined}
|
||||
className={cn("kt-serit-px", hucreSinifi?.[i])}
|
||||
style={{ "--op": dokuOpakligi(tohum, i) } as CSSProperties}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,12 @@ const hash = (x: number, y: number, seed: number) => {
|
||||
return n - Math.floor(n);
|
||||
};
|
||||
|
||||
// Kendi sahnesini üreten bileşenler (PikselMorf ve sahneleri) aynı ızgarayı,
|
||||
// dokuyu ve sözde-rastgeleyi buradan alır; ölçü ikinci bir yerde yazılmaz.
|
||||
export const PIKSEL = { hucre: CELL, nokta: DOT, bosluk: INSET } as const;
|
||||
export const dokuOpakligi = opacityFor;
|
||||
export const pikselHash = hash;
|
||||
|
||||
function pixelRect(
|
||||
x: number,
|
||||
y: number,
|
||||
@@ -551,7 +557,7 @@ export function PixelSahne({
|
||||
}
|
||||
|
||||
// 5×7 dot-matrix rakamlar. Yeni bir rakam gerekirse ızgarası buraya eklenir.
|
||||
const RAKAMLAR = {
|
||||
export const RAKAMLAR = {
|
||||
1: ["..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."],
|
||||
2: [".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"],
|
||||
3: [".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."],
|
||||
|
||||
22
src/components/sayfa-gecisi.tsx
Normal file
22
src/components/sayfa-gecisi.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { ViewTransition, type ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Rotalar arası kısa çapraz solma (DESIGN.md §6, §11.4): sayfa "belge" gibi
|
||||
* bir anda değişmez, eski içerik söner yenisi belirir. Yalnız yol değişince
|
||||
* oynar (`key`): aynı sayfadaki güncellemeler (arama, sıralama, Suspense)
|
||||
* `default="none"` ile sessiz kalır. Üst çubuk geçişe katılmaz, yerinde durur
|
||||
* (globals.css `.kt-ust-cubuk`). Süre ve eğri globals.css'te
|
||||
* (`.kt-sayfa-gir` / `.kt-sayfa-cik`); hareketi azalt'ta kapalı. Tarayıcı
|
||||
* View Transitions bilmiyorsa sayfa eskisi gibi düz değişir.
|
||||
*/
|
||||
export function SayfaGecisi({ children }: { children: ReactNode }) {
|
||||
const yol = usePathname();
|
||||
return (
|
||||
<ViewTransition key={yol} enter="kt-sayfa-gir" exit="kt-sayfa-cik" default="none">
|
||||
{children}
|
||||
</ViewTransition>
|
||||
);
|
||||
}
|
||||
@@ -25,8 +25,10 @@ export function SiteFooter() {
|
||||
.sort((a, b) => b.programSayisi - a.programSayisi)
|
||||
.slice(0, 8);
|
||||
|
||||
// data-site-footer: layout'taki #icerik alt boşluğunu (pb-28) kapatır —
|
||||
// dev yazı logosu ekranın altına yapışsın.
|
||||
return (
|
||||
<footer className="border-t border-slate-200/70 pb-32 pt-12">
|
||||
<footer data-site-footer className="border-t border-slate-200/70 pt-12">
|
||||
<div className="mx-auto max-w-6xl px-4">
|
||||
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<nav aria-label="Popüler bölümler">
|
||||
@@ -212,6 +214,41 @@ export function SiteFooter() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Dev yazı logosu: header'daki "KolayTercih" (Bricolage, ince + yarı
|
||||
kalın) kenardan kenara, alttan kırpılmış ve aşağı doğru sönerek.
|
||||
SVG + textLength: yazı her genişlikte kabı tam doldurur, font
|
||||
ölçüsüne göre taşmaz. Renkler slate token'ı — koyu temada kendiliğinden
|
||||
döner. Süs: ekran okuyucuya kapalı. */}
|
||||
<svg
|
||||
viewBox="0 0 1000 135"
|
||||
aria-hidden
|
||||
className="pointer-events-none mt-10 block w-full px-4 font-bricolage select-none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="footer-yazi-logo"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="135"
|
||||
>
|
||||
<stop offset="0" style={{ stopColor: "var(--color-slate-900)" }} />
|
||||
<stop offset="0.75" style={{ stopColor: "var(--color-slate-300)" }} />
|
||||
<stop offset="1" style={{ stopColor: "var(--color-slate-200)" }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<text
|
||||
x="0"
|
||||
y="156"
|
||||
fontSize="222"
|
||||
textLength="1000"
|
||||
fill="url(#footer-yazi-logo)"
|
||||
>
|
||||
<tspan className="font-light">Kolay</tspan>
|
||||
<tspan className="font-semibold">Tercih</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getRehberAramaKayitlari } from "@/lib/rehber";
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-4 z-50 mt-4 sm:top-6 sm:mt-6 print:hidden">
|
||||
<header className="kt-ust-cubuk sticky top-4 z-50 mt-4 sm:top-6 sm:mt-6 print:hidden">
|
||||
{/* lg altında 2 satır: logo+UserNav / sıralama+ara. lg+: tek satır, ortada nav. */}
|
||||
<div className="relative mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-2 gap-y-2 px-3 sm:gap-x-3 sm:px-4 lg:h-16 lg:flex-nowrap">
|
||||
{/* Sayfa başlığı yuvası (NavBaslik): bir sayfa <html>'e
|
||||
|
||||
54
src/components/tema-ikonu.tsx
Normal file
54
src/components/tema-ikonu.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useId } from "react";
|
||||
|
||||
// Tema seçicinin ikonları (DESIGN.md §2.3): tema geçişinde header'daki logonun
|
||||
// dönüştüğü biçimlerin durağan hâli (animasyonlu-mark.tsx `temaSekli`).
|
||||
// - "light": güneş — turuncu daire, arkasında şeftali hale.
|
||||
// - "dark": ay — şeftali hilal, iki ucunun ortasında mavi bir yıldız.
|
||||
// - "system": logonun üç karesi — ikisine de dönüşebilen hâl.
|
||||
// Renkler logonun renkleridir ve iki temada aynıdır (§2.3); 180'lik kutu
|
||||
// public/kolay-tercih-mark.svg ile aynı. Dekoratiftir: anlamı yanındaki
|
||||
// etiket taşır.
|
||||
|
||||
const MAVI = "#3b82f6";
|
||||
const SEFTALI = "#fdc7a7";
|
||||
const TURUNCU = "#ff5a00";
|
||||
|
||||
export type TemaIkonuTuru = "light" | "dark" | "system";
|
||||
|
||||
export function TemaIkonu({
|
||||
tur,
|
||||
className,
|
||||
}: {
|
||||
tur: TemaIkonuTuru;
|
||||
className?: string;
|
||||
}) {
|
||||
const maskeId = useId();
|
||||
return (
|
||||
<svg viewBox="0 0 180 180" aria-hidden="true" className={className}>
|
||||
{tur === "light" ? (
|
||||
<>
|
||||
<circle cx="90" cy="90" r="84" fill={SEFTALI} opacity="0.55" />
|
||||
<circle cx="90" cy="90" r="43" fill={TURUNCU} />
|
||||
</>
|
||||
) : tur === "dark" ? (
|
||||
<>
|
||||
{/* Hilal: daireden, sağa-yukarı kaydırılmış ikinci daire çıkarılır
|
||||
(sırtı solda). */}
|
||||
<mask id={maskeId}>
|
||||
<rect width="180" height="180" fill="#fff" />
|
||||
<circle cx="116" cy="76" r="66" fill="#000" />
|
||||
</mask>
|
||||
<circle cx="76" cy="92" r="74" fill={SEFTALI} mask={`url(#${maskeId})`} />
|
||||
{/* Yıldız: hilalin iki sivri ucunun tam ortası. */}
|
||||
<circle cx="108" cy="79" r="13" fill={MAVI} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<rect x="27" y="56" width="57" height="57" rx="13" fill={MAVI} />
|
||||
<rect x="91" y="29" width="56" height="56" rx="13" fill={SEFTALI} />
|
||||
<rect x="91" y="95" width="56" height="56" rx="13" fill={TURUNCU} />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
38
src/components/tema-kisayolu.tsx
Normal file
38
src/components/tema-kisayolu.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { temayiGecisle } from "@/lib/tema-gecisi";
|
||||
|
||||
/** Tuş yazı alanında basıldıysa kısayol değildir. */
|
||||
function yaziAlanindaMi(hedef: EventTarget | null) {
|
||||
if (!(hedef instanceof HTMLElement)) return false;
|
||||
if (hedef.isContentEditable) return true;
|
||||
const etiket = hedef.tagName;
|
||||
return etiket === "INPUT" || etiket === "TEXTAREA" || etiket === "SELECT";
|
||||
}
|
||||
|
||||
/**
|
||||
* Klavye kısayolu: `D` açık ↔ koyu temayı değiştirir (ipucu footer'daki
|
||||
* TemaSecici'nin altında, Kbd ile). Değiştirici tuşla (⌘/Ctrl/Alt) ya da yazı
|
||||
* alanındayken çalışmaz; "sistem" seçiliyken o anki çözümlenmiş temanın
|
||||
* tersine, açık bir seçime geçer. Geçiş logodan yumuşak bir halka gibi yayılır.
|
||||
*/
|
||||
export function TemaKisayolu() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const dinle = (e: KeyboardEvent) => {
|
||||
if (e.key !== "d" && e.key !== "D") return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;
|
||||
if (yaziAlanindaMi(e.target)) return;
|
||||
e.preventDefault();
|
||||
const hedef = resolvedTheme === "dark" ? "light" : "dark";
|
||||
temayiGecisle(() => setTheme(hedef));
|
||||
};
|
||||
window.addEventListener("keydown", dinle);
|
||||
return () => window.removeEventListener("keydown", dinle);
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
import { TemaKisayolu } from "@/components/tema-kisayolu";
|
||||
|
||||
// Gövde zemini (bg-slate-50) — açık ve koyu temadaki karşılığı
|
||||
const ZEMIN = { light: "#f8fafc", dark: "#080e19" };
|
||||
@@ -25,7 +26,8 @@ function AdresCubuguRengi() {
|
||||
* Tema (DESIGN.md §2.3): <html>'e `dark` sınıfı yazılır, renkler
|
||||
* globals.css'teki `.dark` token eşlemesinden gelir. Varsayılan açık tema;
|
||||
* kullanıcı footer'daki TemaSecici ile koyu ya da "sistem"e geçer, seçim
|
||||
* tarayıcıda saklanır.
|
||||
* tarayıcıda saklanır; `D` tuşu açık ↔ koyu geçişi yapar (TemaKisayolu).
|
||||
* Tema değişimi logodan yumuşak bir halka gibi yayılır (lib/tema-gecisi.ts).
|
||||
*/
|
||||
export function TemaSaglayici({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -37,6 +39,7 @@ export function TemaSaglayici({ children }: { children: React.ReactNode }) {
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AdresCubuguRengi />
|
||||
<TemaKisayolu />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,24 @@
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { TemaIkonu } from "@/components/tema-ikonu";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { SecimHaplari } from "@/components/ui/secim-haplari";
|
||||
import { temayiGecisle } from "@/lib/tema-gecisi";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// İkonlar geçişte logonun dönüştüğü biçimlerin durağan hâlidir (DESIGN.md
|
||||
// §4.34): güneş, ay ve logonun kendisi. SecimHaplari ikonu bileşen olarak
|
||||
// ister; her tür için ince bir sarmalayıcı.
|
||||
type IkonProps = { className?: string };
|
||||
const Gunes = ({ className }: IkonProps) => <TemaIkonu tur="light" className={className} />;
|
||||
const Ay = ({ className }: IkonProps) => <TemaIkonu tur="dark" className={className} />;
|
||||
const Sistem = ({ className }: IkonProps) => <TemaIkonu tur="system" className={className} />;
|
||||
|
||||
const SECENEKLER = [
|
||||
{ deger: "light", etiket: "Açık", ikon: Sun },
|
||||
{ deger: "dark", etiket: "Koyu", ikon: Moon },
|
||||
{ deger: "system", etiket: "Sistem", ikon: Monitor },
|
||||
{ deger: "light", etiket: "Açık", ikon: Gunes },
|
||||
{ deger: "dark", etiket: "Koyu", ikon: Ay },
|
||||
{ deger: "system", etiket: "Sistem", ikon: Sistem },
|
||||
] as const;
|
||||
type Tema = (typeof SECENEKLER)[number]["deger"];
|
||||
|
||||
@@ -18,9 +29,18 @@ const aboneOlma = () => () => {};
|
||||
* 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,
|
||||
* hydration sonrası işaretlenir (yerleşim oynamaz). Koyu vurgu seçenekten
|
||||
* seçeneğe kayar (secim-haplari.tsx, kapsül varyantı).
|
||||
* seçeneğe kayar (secim-haplari.tsx, kapsül varyantı). Seçim logodan yumuşak
|
||||
* bir halka gibi yayılarak uygulanır, ardından logo yeni temanın biçimine
|
||||
* dönüşür (lib/tema-gecisi.ts, §4.34); `ipucu` ile altında `D` kısayolu durur.
|
||||
*/
|
||||
export function TemaSecici({ className }: { className?: string }) {
|
||||
export function TemaSecici({
|
||||
className,
|
||||
ipucu = true,
|
||||
}: {
|
||||
className?: string;
|
||||
/** Altındaki klavye kısayolu satırı; dokunmatik menüde kapatılır. */
|
||||
ipucu?: boolean;
|
||||
}) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const tarayicida = useSyncExternalStore(
|
||||
aboneOlma,
|
||||
@@ -32,14 +52,21 @@ export function TemaSecici({ className }: { className?: string }) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SecimHaplari<Tema>
|
||||
ariaLabel="Tema"
|
||||
secenekler={SECENEKLER}
|
||||
deger={secili}
|
||||
onChange={setTheme}
|
||||
varyant="kapsul"
|
||||
boyut="md"
|
||||
className={className}
|
||||
/>
|
||||
<div className={cn("flex flex-col items-center gap-2", className)}>
|
||||
<SecimHaplari<Tema>
|
||||
ariaLabel="Tema"
|
||||
secenekler={SECENEKLER}
|
||||
deger={secili}
|
||||
onChange={(deger) => temayiGecisle(() => setTheme(deger))}
|
||||
varyant="kapsul"
|
||||
boyut="md"
|
||||
/>
|
||||
{ipucu ? (
|
||||
<p className="hidden items-center gap-1.5 text-xs text-slate-500 lg:flex">
|
||||
<Kbd>D</Kbd>
|
||||
<span>ile açık / koyu arasında geç</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
// - Kartsız (program-ayrinti.tsx, §4.19 "kart yok"): `[data-kare]` yok,
|
||||
// `[data-logo]` kabındaki üç `[data-logo-kare]` belirir, kısa durur, sonra
|
||||
// sönerek kaybolur (scale 0.6 + opacity 0; Bilal, 7 Eki 2026) ve
|
||||
// `[data-sonra]` bloklar netleşerek gelir.
|
||||
// `[data-sonra]` bloklar netleşerek gelir. sahnenin kardeşi bir `[data-logo-hedef]` varsa kareler
|
||||
// sönmek yerine küçülerek o yazıya (çekmece başlığı) uçar, yazı onlardan
|
||||
// netleşir (Bilal, 10 Eki 2026).
|
||||
// "Hareketi azalt"ta logo ve büyüme yok, yalnız 0,25 s solma (§6). Veri
|
||||
// bekleyen çekmecede (`hazir` verilmiş) sahne yalnız veri ilk geldiğinde
|
||||
// (false → true) oynar; önbellekli yeniden açılışta aynı 0,25 s solma
|
||||
@@ -18,6 +20,7 @@
|
||||
|
||||
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { animate, useReducedMotion } from "motion/react";
|
||||
import { EGRI } from "@/lib/hareket";
|
||||
|
||||
// Logodaki üç kare (public/kolay-tercih-mark.svg, 180'lik viewBox): solda
|
||||
// mavi, sağ üstte şeftali, sağ altta turuncu. animasyonlu-mark.tsx ile aynı.
|
||||
@@ -37,8 +40,8 @@ const SLOT_ADIMI = 0.08;
|
||||
const KARE_SURE = 1.15;
|
||||
const KARE_ZAMANLARI = [0, 0.24, 0.52, 1];
|
||||
const ICERIK_BASI = 0.95;
|
||||
const GUCLU_CIKIS = [0.23, 1, 0.32, 1] as const;
|
||||
const YERLESME = [0.32, 0.72, 0, 1] as const;
|
||||
const GUCLU_CIKIS = EGRI.gucluCikis;
|
||||
const YERLESME = EGRI.yerlesme;
|
||||
|
||||
// Kartsız mod (saniye): logo karesi belir → dur → sön; kareler 0,05 s arayla
|
||||
// gelir, içerik blokları sönmeyle çaprazlanarak 0,05 s arayla netleşir.
|
||||
@@ -55,6 +58,22 @@ const LOGO_ZAMANLARI = [
|
||||
(LOGO_BELIRME + LOGO_DURUS) / LOGO_SURE,
|
||||
1,
|
||||
];
|
||||
// Hedefli kartsız mod: kareler sönmek yerine LOGO_UCUS süresince hedef yazıya
|
||||
// uçar; uçuşun son yarısında solar, yazı o sırada netleşir.
|
||||
const LOGO_UCUS = 0.45;
|
||||
const UCUS_SURE = LOGO_BELIRME + LOGO_DURUS + LOGO_UCUS;
|
||||
const UCUS_ZAMANLARI = [
|
||||
0,
|
||||
LOGO_BELIRME / UCUS_SURE,
|
||||
(LOGO_BELIRME + LOGO_DURUS) / UCUS_SURE,
|
||||
1,
|
||||
];
|
||||
const UCUS_SOLMA_ZAMANLARI = [
|
||||
0,
|
||||
LOGO_BELIRME / UCUS_SURE,
|
||||
(LOGO_BELIRME + LOGO_DURUS + LOGO_UCUS * 0.5) / UCUS_SURE,
|
||||
1,
|
||||
];
|
||||
const KARTSIZ_SLOT_ADIMI = 0.05;
|
||||
const KARTSIZ_ICERIK_BASI = 0.6;
|
||||
const KARTSIZ_BLOK_ADIMI = 0.05;
|
||||
@@ -63,14 +82,21 @@ const KARTSIZ_NETLESME = 0.35;
|
||||
/** Kartsız modun logo kareleri, belirme sırasıyla (`data-logo-kare` değerleri). */
|
||||
export const LOGO_SLOTLARI = SLOT_SIRASI;
|
||||
|
||||
/**
|
||||
* Kartsız modun logo kabının kenarı (px) = `size-44`. Kartlı moddaki logoyla
|
||||
* (akademik göstergeler, kare alanının yarısı, en çok 200 px) aynı ölçekte;
|
||||
* 375 px'lik telefonda da sığar.
|
||||
*/
|
||||
export const LOGO_KAP_BOYU = 176;
|
||||
|
||||
/**
|
||||
* Kartsız modun logo karesi stili: logodaki yer ve renk, kabın boyuna ölçekli
|
||||
* (`data-logo` kabı `relative mx-auto h-16 w-16` → 64/180). Kullanım:
|
||||
* `<span data-logo-kare={slot} className="absolute" style={logoKareStili(slot)} />`.
|
||||
* (`data-logo` kabı `relative mx-auto size-44` → 176/180). Kullanım:
|
||||
* `<span data-logo-kare={slot} className="absolute" style={logoKareStili(slot, LOGO_KAP_BOYU)} />`.
|
||||
* Kareler yalnız sahne sırasında görünür; sahne bitince kap sıfır boya iner,
|
||||
* "hareketi azalt"ta hiç gösterilmez.
|
||||
*/
|
||||
export function logoKareStili(slot: Slot, kapBoyu = 64) {
|
||||
export function logoKareStili(slot: Slot, kapBoyu = LOGO_KAP_BOYU) {
|
||||
const olcek = kapBoyu / 180;
|
||||
return {
|
||||
left: LOGO[slot].x * olcek,
|
||||
@@ -100,6 +126,10 @@ export function logoKareStili(slot: Slot, kapBoyu = 64) {
|
||||
* açıldı): sahne atlanır, içerik 0,25 s solmayla gelir — kareler o sırada
|
||||
* çekmece daha ekrana kaymadan oynuyor, gövde boş kalıyordu. Verilmezse
|
||||
* (içerik sunucudan hazır gelen çekmece) sahne her montajda oynar.
|
||||
*
|
||||
* `data-logo-hedef`: kartsız modda logo karelerinin küçülerek dönüşeceği yazı
|
||||
* (sahnenin kardeşi, ör. çekmece başlığı). Kareler yazının üç eşit dilimine
|
||||
* uçar; yazı sahne başında görünmez olur, kareler varırken netleşir.
|
||||
*/
|
||||
export function AcilisSahnesi({
|
||||
children,
|
||||
@@ -194,12 +224,52 @@ export function AcilisSahnesi({
|
||||
// iner ki altında boşluk kalmasın; içerik KARTSIZ_ICERIK_BASI'ndan
|
||||
// itibaren, sönmeyle çaprazlanarak gelir.
|
||||
let sonKare = 0;
|
||||
// Hedef yazının kutusu (blok değil, yazının kendisi: Range).
|
||||
const hedef =
|
||||
kap.parentElement?.querySelector<HTMLElement>("[data-logo-hedef]") ?? null;
|
||||
let hedefKutu: DOMRect | null = null;
|
||||
if (hedef) {
|
||||
const aralik = document.createRange();
|
||||
aralik.selectNodeContents(hedef);
|
||||
hedefKutu = aralik.getBoundingClientRect();
|
||||
}
|
||||
logoKareleri.forEach((el) => {
|
||||
const slot = (el.dataset.logoKare ?? "sol") as Slot;
|
||||
const gecikme = BASLANGIC + SLOT_SIRASI.indexOf(slot) * KARTSIZ_SLOT_ADIMI;
|
||||
const sira = SLOT_SIRASI.indexOf(slot);
|
||||
const gecikme = BASLANGIC + sira * KARTSIZ_SLOT_ADIMI;
|
||||
sonKare = Math.max(sonKare, gecikme);
|
||||
el.style.opacity = "0";
|
||||
dokunulan.push(el);
|
||||
if (hedefKutu) {
|
||||
// Kare ile hedef aynı çekmecede: çekmece kayarken fark değişmez.
|
||||
const k = el.getBoundingClientRect();
|
||||
const dx =
|
||||
hedefKutu.left + (hedefKutu.width * (sira + 0.5)) / 3 - (k.left + k.width / 2);
|
||||
const dy = hedefKutu.top + hedefKutu.height / 2 - (k.top + k.height / 2);
|
||||
kontroller.push(
|
||||
animate(
|
||||
el,
|
||||
{ x: [0, 0, 0, dx], y: [0, 0, 0, dy], scale: [0.4, 1, 1, 0.2] },
|
||||
{
|
||||
duration: UCUS_SURE,
|
||||
delay: gecikme,
|
||||
times: UCUS_ZAMANLARI,
|
||||
ease: [GUCLU_CIKIS, "linear", YERLESME],
|
||||
},
|
||||
),
|
||||
animate(
|
||||
el,
|
||||
{ opacity: [0, 1, 1, 0] },
|
||||
{
|
||||
duration: UCUS_SURE,
|
||||
delay: gecikme,
|
||||
times: UCUS_SOLMA_ZAMANLARI,
|
||||
ease: [GUCLU_CIKIS, "linear", "easeIn"],
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
kontroller.push(
|
||||
animate(
|
||||
el,
|
||||
@@ -218,10 +288,13 @@ export function AcilisSahnesi({
|
||||
});
|
||||
const sonmeBasi = sonKare + LOGO_SURE * LOGO_ZAMANLARI[2];
|
||||
dokunulan.push(logo!);
|
||||
if (hedef && hedefKutu) {
|
||||
netlestir(hedef, sonmeBasi + LOGO_UCUS * 0.4, 0.4);
|
||||
}
|
||||
const kapat = animate(
|
||||
logo!,
|
||||
{ height: [logo!.offsetHeight, 0] },
|
||||
{ duration: LOGO_SONME, delay: sonmeBasi, ease: YERLESME },
|
||||
{ duration: hedefKutu ? LOGO_UCUS : LOGO_SONME, delay: sonmeBasi, ease: YERLESME },
|
||||
);
|
||||
kapat.then(() => {
|
||||
logo!.style.display = "none";
|
||||
|
||||
177
src/components/ui/adim-gostergesi.tsx
Normal file
177
src/components/ui/adim-gostergesi.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
// Çok adımlı akışların tek göstergesi (DESIGN.md §4.35, §10.2): numaralı
|
||||
// daireler ince çizgiyle bağlı; aktif adım mavi dolu, tamamlanan adımda tik
|
||||
// kendini çizer, aradaki çizgi soldan dolar. Geri gidince aynı yoldan boşalır.
|
||||
//
|
||||
// Esin: React Bits "Stepper" (reactbits.dev, MIT + Commons Clause). Kod
|
||||
// KolayTercih diline çevrildi: sabit renkler token'a, zamanlama
|
||||
// `lib/hareket.ts`'e bağlandı; kontrollü hâle getirildi (içerik geçişi
|
||||
// `TransitionPanel` + `lib/adim-gecisi.ts`'te kalır), `aria-current`,
|
||||
// durum duyurusu ve hareketi azalt dalı eklendi. Turuncu yok: gösterge
|
||||
// bilgidir, aksiyon değil (§2).
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { SURE, YAY } from "@/lib/hareket";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Durum = "bekliyor" | "aktif" | "tamam";
|
||||
|
||||
export function AdimGostergesi({
|
||||
adimlar,
|
||||
aktif,
|
||||
onAdimSec,
|
||||
ariaLabel = "Adımlar",
|
||||
className,
|
||||
}: {
|
||||
/** Adım adları, sırayla. Sayı gerçek adım sayısıdır (§10.2). */
|
||||
adimlar: readonly string[];
|
||||
/** 0 tabanlı aktif adım. */
|
||||
aktif: number;
|
||||
/** Verilirse yalnız TAMAMLANMIŞ adımlar tıklanabilir olur (ileri atlama yok). */
|
||||
onAdimSec?: (adim: number) => void;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const azalt = useReducedMotion();
|
||||
const anlik = { duration: 0 };
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ol
|
||||
aria-label={ariaLabel}
|
||||
className="grid items-start"
|
||||
// Daire sütunları eşit, aralarındaki çizgiler kalan yeri paylaşır;
|
||||
// böylece ilk ve son daire kenara, adları dairenin altına oturur.
|
||||
style={{
|
||||
gridTemplateColumns: adimlar
|
||||
.map((_, i) => (i === 0 ? "auto" : "1fr auto"))
|
||||
.join(" "),
|
||||
}}
|
||||
>
|
||||
{adimlar.map((ad, i) => {
|
||||
const durum: Durum = i < aktif ? "tamam" : i === aktif ? "aktif" : "bekliyor";
|
||||
const tiklanabilir = !!onAdimSec && durum === "tamam";
|
||||
const daire = (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"relative flex size-9 items-center justify-center rounded-full border font-heading text-sm font-bold transition-colors motion-reduce:transition-none",
|
||||
durum === "bekliyor"
|
||||
? "border-slate-200 bg-card text-slate-500"
|
||||
: "border-primary bg-primary text-white",
|
||||
)}
|
||||
style={{ transitionDuration: `${SURE.standart * 1000}ms` }}
|
||||
>
|
||||
{/* Numara ile tik aynı yuvada; biri çıkarken öbürü çizilir. */}
|
||||
<motion.span
|
||||
initial={false}
|
||||
animate={{ opacity: durum === "tamam" ? 0 : 1, scale: durum === "tamam" ? 0.6 : 1 }}
|
||||
transition={azalt ? anlik : { duration: SURE.hizli }}
|
||||
>
|
||||
{i + 1}
|
||||
</motion.span>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="absolute size-4"
|
||||
>
|
||||
<motion.path
|
||||
d="M4 12.5 9.5 18 20 6.5"
|
||||
initial={false}
|
||||
animate={{
|
||||
pathLength: durum === "tamam" ? 1 : 0,
|
||||
opacity: durum === "tamam" ? 1 : 0,
|
||||
}}
|
||||
transition={azalt ? anlik : YAY.donusum}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
const etiket = (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 block text-center text-xs sm:text-sm",
|
||||
durum === "aktif"
|
||||
? "font-semibold text-slate-900"
|
||||
: durum === "tamam"
|
||||
? "font-medium text-slate-600"
|
||||
: "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{ad}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Fragment key={ad}>
|
||||
{i > 0 ? (
|
||||
// Çizgi dairenin ortasında (size-9 → 18 px); dolum transform ile.
|
||||
<li aria-hidden className="mx-2 mt-[1.0625rem] h-0.5 overflow-hidden rounded-full bg-slate-200">
|
||||
<motion.span
|
||||
className="block h-full origin-left rounded-full bg-primary"
|
||||
initial={false}
|
||||
animate={{ scaleX: i <= aktif ? 1 : 0 }}
|
||||
transition={azalt ? anlik : YAY.akici}
|
||||
/>
|
||||
</li>
|
||||
) : null}
|
||||
<li
|
||||
aria-current={durum === "aktif" ? "step" : undefined}
|
||||
className="flex min-w-0 flex-col items-center"
|
||||
>
|
||||
{tiklanabilir ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAdimSec(i)}
|
||||
aria-label={`${i + 1}. adıma dön: ${ad}`}
|
||||
className="group flex min-h-11 cursor-pointer flex-col items-center rounded-2xl outline-none transition-transform focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97] motion-reduce:transition-none"
|
||||
>
|
||||
{daire}
|
||||
{etiket}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{daire}
|
||||
{etiket}
|
||||
{durum === "tamam" ? <span className="sr-only">(tamamlandı)</span> : null}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<p role="status" className="sr-only">
|
||||
Adım {Math.min(aktif + 1, adimlar.length)} / {adimlar.length}: {adimlar[Math.min(aktif, adimlar.length - 1)]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Lazy yüklenen akışın iskeleti: göstergenin kutusunu birebir tutar (yerleşim sıçramaz). */
|
||||
export function AdimGostergesiIskeleti({ adet = 3 }: { adet?: number }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="grid items-start"
|
||||
style={{
|
||||
gridTemplateColumns: Array.from({ length: adet }, (_, i) => (i === 0 ? "auto" : "1fr auto")).join(" "),
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: adet }, (_, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? <span className="mx-2 mt-[1.0625rem] h-0.5 rounded-full bg-slate-200" /> : null}
|
||||
<span className="flex flex-col items-center">
|
||||
<span className="size-9 rounded-full bg-slate-100 motion-safe:animate-pulse" />
|
||||
<span className="mt-1.5 h-4 w-12 rounded-full bg-slate-100 motion-safe:animate-pulse sm:h-5" />
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,11 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
// DESIGN.md §4.1: funnel CTA'sının tek kaynağı. Basma tepkisi ölçekle
|
||||
// (tabandaki 1 px kayma bu iki varyantta kapalı; ikisi birlikte titrer).
|
||||
cta: "cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:not-aria-[haspopup]:translate-y-0 active:scale-[0.97] motion-reduce:active:scale-100",
|
||||
ikincil:
|
||||
"cursor-pointer border-border bg-background transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:not-aria-[haspopup]:translate-y-0 active:scale-[0.97] motion-reduce:active:scale-100",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
|
||||
@@ -61,7 +61,11 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
// Modal yüzeyi = DESIGN.md §4.4 sade kutu (rounded-2xl, slate-200
|
||||
// kenarlık, bg-card); shadcn'in ring'li rounded-xl/popover hâli
|
||||
// sitede yoktu (10 Eki 2026, Bilal: "yeni dizayn anlayışımıza uygun
|
||||
// değil"). Yazı tabanı gövde (16 px, §3); açıklama text-sm'i kendi taşır.
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-5 rounded-2xl border border-slate-200 bg-card p-6 text-slate-900 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-open:zoom-in-100 motion-reduce:data-closed:zoom-out-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -102,8 +106,10 @@ function DialogFooter({
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
// Gri bant yok: aksiyonlar yüzeyin içinde, telefonda alt alta tam
|
||||
// genişlik (ana aksiyon üstte), sm'den itibaren sağa yaslı yan yana.
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -111,7 +117,9 @@ function DialogFooter({
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="ikincil" size="lg">
|
||||
Kapat
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
@@ -125,8 +133,9 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
// Panel/kart h3 ölçeği (DESIGN.md §3): başlık her zaman bold Cal Sans
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
"font-heading text-xl leading-tight font-bold text-slate-900",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -142,7 +151,7 @@ function DialogDescription({
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
"text-sm leading-relaxed text-slate-600 *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-orange-500",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
|
||||
import { useId, type ComponentType } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { YAY as HAREKET_YAYI } from "@/lib/hareket";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const;
|
||||
const YAY = HAREKET_YAYI.varsayilan;
|
||||
|
||||
export interface SecimHapiSecenek<T extends string> {
|
||||
deger: T;
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
* modül seviyesinde TEK pointermove dinleyicisi + rAF birleştirmesi kullanılır;
|
||||
* kare başına en fazla bir tarama yapılır, imleçten uzak örnekler hiç
|
||||
* piksel iterasyonuna girmez.
|
||||
*
|
||||
* `canli`: pikselleri yer ve rol değiştiren sahneler (PikselMorf) içindir.
|
||||
* Konum ve taban opaklık mount'ta kopyalanmaz, her taramada DOM'dan okunur;
|
||||
* `data-px`'i kalkan piksel (turuncu vurgu oldu ya da gizlendi) fırçadan çıkar.
|
||||
*/
|
||||
|
||||
import { useEffect, type RefObject } from "react";
|
||||
@@ -46,6 +50,8 @@ type HeatInstance = {
|
||||
/** preserveAspectRatio "slice" mi (kabı doldurur) */
|
||||
slice: boolean;
|
||||
baseColor: string;
|
||||
/** Pikseller yer/rol değiştirir: konum ve opaklık her taramada okunur */
|
||||
canli: boolean;
|
||||
pixels: HeatTarget[];
|
||||
hot: Set<SVGRectElement>;
|
||||
controls: Map<SVGRectElement, AnimationPlaybackControls>;
|
||||
@@ -99,8 +105,17 @@ function tara() {
|
||||
const x = vb.x + (e.clientX - rect.left - ox) / scale;
|
||||
const y = vb.y + (e.clientY - rect.top - oy) / scale;
|
||||
for (const p of inst.pixels) {
|
||||
const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2;
|
||||
const isHot = d2 <= inst.radius * inst.radius;
|
||||
let { cx, cy, baseOpacity } = p;
|
||||
let katilir = true;
|
||||
if (inst.canli) {
|
||||
const veri = p.el.dataset;
|
||||
katilir = veri.px !== undefined;
|
||||
cx = Number(veri.cx);
|
||||
cy = Number(veri.cy);
|
||||
baseOpacity = Number(veri.op);
|
||||
}
|
||||
const d2 = (cx - x) ** 2 + (cy - y) ** 2;
|
||||
const isHot = katilir && d2 <= inst.radius * inst.radius;
|
||||
if (isHot && !inst.hot.has(p.el)) {
|
||||
inst.hot.add(p.el);
|
||||
calistir(
|
||||
@@ -114,7 +129,7 @@ function tara() {
|
||||
calistir(
|
||||
inst,
|
||||
p.el,
|
||||
{ fill: inst.baseColor, fillOpacity: p.baseOpacity },
|
||||
{ fill: inst.baseColor, fillOpacity: baseOpacity },
|
||||
COOL_DURATION,
|
||||
);
|
||||
}
|
||||
@@ -122,6 +137,14 @@ function tara() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pikseller yer değiştirdikten sonra (PikselMorf kare değişimi) fırçayı son
|
||||
* imleç konumuyla yeniden çalıştırır: imleç yerinde dursa da ısı güncel kalır. */
|
||||
export function pikselIsiTazele() {
|
||||
if (sonOlay && instances.size > 0 && !rafId) {
|
||||
rafId = requestAnimationFrame(tara);
|
||||
}
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
sonOlay = e;
|
||||
if (!rafId) rafId = requestAnimationFrame(tara);
|
||||
@@ -201,7 +224,9 @@ export function usePixelHeat(
|
||||
radius = 34,
|
||||
/** İmleç olmadan da rastgele pikseller seyrekçe ısınıp sönsün (hero) */
|
||||
kirpisma = false,
|
||||
}: { radius?: number; kirpisma?: boolean } = {},
|
||||
/** Pikseller yer/rol değiştiriyor (PikselMorf): konum her taramada okunur */
|
||||
canli = false,
|
||||
}: { radius?: number; kirpisma?: boolean; canli?: boolean } = {},
|
||||
) {
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
@@ -216,8 +241,9 @@ export function usePixelHeat(
|
||||
|
||||
if (!svg.viewBox.baseVal.width) return;
|
||||
|
||||
// Canlı sahnede `data-px` kareden kareye açılıp kapanır; hepsi toplanır.
|
||||
const pixels: HeatTarget[] = Array.from(
|
||||
svg.querySelectorAll<SVGRectElement>("[data-px]"),
|
||||
svg.querySelectorAll<SVGRectElement>(canli ? "[data-cx]" : "[data-px]"),
|
||||
).map((el) => ({
|
||||
el,
|
||||
cx: Number(el.dataset.cx),
|
||||
@@ -231,6 +257,7 @@ export function usePixelHeat(
|
||||
radius,
|
||||
slice: (svg.getAttribute("preserveAspectRatio") ?? "").includes("slice"),
|
||||
baseColor: getComputedStyle(svg).color,
|
||||
canli,
|
||||
pixels,
|
||||
hot: new Set(),
|
||||
controls: new Map(),
|
||||
@@ -241,5 +268,5 @@ export function usePixelHeat(
|
||||
kirpismaDur?.();
|
||||
ayril(inst);
|
||||
};
|
||||
}, [svgRef, radius, kirpisma]);
|
||||
}, [svgRef, radius, kirpisma, canli]);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
||||
import { ARAMA_AC_OLAYI } from "@/components/mobil-menu";
|
||||
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
||||
|
||||
type BolumSonucu = {
|
||||
@@ -172,6 +174,13 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
|
||||
// modal her açılıp kapandığında remove/add döngüsü dönmesin
|
||||
// (client-event-listeners)
|
||||
const kisayolTetiklendi = useEffectEvent(() => modalDegisti(!acik));
|
||||
// Telefon menüsündeki "Ara" hapı (mobil-menu.tsx) pencereyi olayla açtırır.
|
||||
const aramaIstendi = useEffectEvent(() => modalDegisti(true));
|
||||
useEffect(() => {
|
||||
window.addEventListener(ARAMA_AC_OLAYI, aramaIstendi);
|
||||
return () => window.removeEventListener(ARAMA_AC_OLAYI, aramaIstendi);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function kisayolDinle(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
@@ -279,9 +288,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
|
||||
>
|
||||
<Trophy className="size-4 shrink-0 text-orange-500" aria-hidden />
|
||||
<span className="font-semibold tracking-tight text-slate-900">
|
||||
<span className="tabular-nums">
|
||||
{profil.sira.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
<SayiRulo deger={profil.sira} className="tabular-nums" />
|
||||
<span className="font-medium text-slate-500">
|
||||
{" "}
|
||||
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
|
||||
@@ -331,58 +338,42 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* lg altında 2. satır: sıralama + ara (header flex-wrap + order-3) */}
|
||||
<div className="order-3 flex w-full basis-full items-center gap-1.5 lg:hidden">
|
||||
{profil ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={profilDuzenlemeyiAc}
|
||||
onPointerEnter={preloadTercihProfiliKapisi}
|
||||
onFocus={preloadTercihProfiliKapisi}
|
||||
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
|
||||
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-card py-1 pl-2.5 pr-1.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-2 sm:text-sm"
|
||||
>
|
||||
<Trophy
|
||||
className="size-3.5 shrink-0 text-orange-500 sm:size-4"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate tracking-tight">
|
||||
<span className="tabular-nums">
|
||||
{profil.sira.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
<span className="font-medium text-slate-500">
|
||||
{" "}
|
||||
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
|
||||
</span>
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">
|
||||
<SquarePen className="size-3" aria-hidden />
|
||||
Değiştir
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={siralamaGirVeyaSonucaGit}
|
||||
onPointerEnter={preloadTercihProfiliKapisi}
|
||||
onFocus={preloadTercihProfiliKapisi}
|
||||
aria-label="Başarı sıralamanı gir"
|
||||
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-card py-1 pl-2.5 pr-2.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-3 sm:text-sm"
|
||||
>
|
||||
<Trophy
|
||||
className="size-3.5 shrink-0 text-orange-500 sm:size-4"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">Sıralama gir</span>
|
||||
</button>
|
||||
)}
|
||||
{/* lg altı: tek satırın ortasındaki sıralama hapı (DESIGN.md §4.36).
|
||||
Arama ve geri kalan her şey sağdaki baloncuk menüde. Sayfa başlığı
|
||||
çubuğa çıkınca (NavBaslik) hap yalnız kupaya iner, başlığa yer açar. */}
|
||||
<div className="order-2 flex shrink-0 items-center lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => modalDegisti(true)}
|
||||
aria-label="Bölüm, üniversite veya rehber ara"
|
||||
className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-700 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:size-12"
|
||||
onClick={profil ? profilDuzenlemeyiAc : siralamaGirVeyaSonucaGit}
|
||||
onPointerEnter={preloadTercihProfiliKapisi}
|
||||
onFocus={preloadTercihProfiliKapisi}
|
||||
aria-label={
|
||||
profil
|
||||
? `Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`
|
||||
: "Başarı sıralamanı gir"
|
||||
}
|
||||
className="flex h-9 max-w-40 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-card px-2.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] motion-reduce:transition-none sm:h-12 sm:max-w-none sm:gap-2 sm:px-4 sm:text-sm"
|
||||
>
|
||||
<Search className="size-4" aria-hidden />
|
||||
<Trophy className="size-3.5 shrink-0 text-orange-500 sm:size-4" aria-hidden />
|
||||
<span className="truncate tracking-tight in-data-[nav-baslik]:hidden">
|
||||
{profil ? (
|
||||
<>
|
||||
<SayiRulo deger={profil.sira} className="tabular-nums" />
|
||||
<span className="hidden font-medium text-slate-500 sm:inline">
|
||||
{" "}
|
||||
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"Sıralama gir"
|
||||
)}
|
||||
</span>
|
||||
{profil ? (
|
||||
<SquarePen
|
||||
className="size-3 shrink-0 text-slate-400 in-data-[nav-baslik]:hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
AcilisSahnesi,
|
||||
LOGO_SLOTLARI,
|
||||
logoKareStili,
|
||||
LOGO_KAP_BOYU,
|
||||
} from "@/components/ui/acilis-sahnesi";
|
||||
import {
|
||||
Table,
|
||||
@@ -183,7 +184,15 @@ export function ProgramAyrintiButonu({
|
||||
<span className="sr-only">Kapat</span>
|
||||
</AltSayfaKapat>
|
||||
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
|
||||
<AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
|
||||
{/* Başlık veri gelene dek görünmez: logo kareleri küçülüp buraya
|
||||
uçar, yazı onlardan netleşir (AcilisSahnesi `data-logo-hedef`). */}
|
||||
<AltSayfaBaslik
|
||||
data-logo-hedef
|
||||
className={cn(
|
||||
"px-10 text-center font-heading text-2xl font-bold text-slate-900",
|
||||
(durum === "bekliyor" || durum === "yukleniyor") && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{isim}
|
||||
</AltSayfaBaslik>
|
||||
<AltSayfaAciklama className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
|
||||
@@ -226,14 +235,20 @@ function AyrintiIcerik({ durum, veri }: { durum: Durum; veri: ProgramAyrintisi |
|
||||
</p>
|
||||
);
|
||||
}
|
||||
// Sıra = AcilisSahnesi'nin `data-sonra` sırası: logo söner, bloklar
|
||||
// yukarıdan aşağıya 0,05 s arayla netleşir.
|
||||
// Sıra = AcilisSahnesi'nin `data-sonra` sırası: logo küçülerek başlığa
|
||||
// dönüşür, bloklar yukarıdan aşağıya 0,05 s arayla netleşir.
|
||||
return (
|
||||
<>
|
||||
{/* Logo kareleri: sahne sırasında görünür, sönünce kap sıfır boya iner. */}
|
||||
<div data-logo className="relative mx-auto h-16 w-16" aria-hidden>
|
||||
{/* Logo kareleri: sahne sırasında görünür, küçülerek başlığa uçar; kap
|
||||
sıfır boya iner. */}
|
||||
<div data-logo className="relative mx-auto size-44" aria-hidden>
|
||||
{LOGO_SLOTLARI.map((slot) => (
|
||||
<span key={slot} data-logo-kare={slot} className="absolute" style={logoKareStili(slot)} />
|
||||
<span
|
||||
key={slot}
|
||||
data-logo-kare={slot}
|
||||
className="absolute"
|
||||
style={logoKareStili(slot, LOGO_KAP_BOYU)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Kunye veri={veri} />
|
||||
|
||||
@@ -33,7 +33,7 @@ import { Button } from "@/components/ui/button";
|
||||
*/
|
||||
const AUTH_GUVENLI_YOL = /^\/(?!\/|\\)[\w\-./+@]*$/;
|
||||
|
||||
export function GirisLinki() {
|
||||
export function GirisLinki({ menu = false }: { /** Telefon menüsündeki iri hap (DESIGN.md §4.36). */ menu?: boolean }) {
|
||||
const pathname = usePathname();
|
||||
// /giris kendine callback veremez (döngü); pathname yoksa sade hedef.
|
||||
const href =
|
||||
@@ -44,7 +44,11 @@ export function GirisLinki() {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="h-9 cursor-pointer rounded-full bg-slate-900 px-3.5 text-sm font-medium text-slate-50 transition-colors duration-200 hover:bg-slate-700 sm:h-12 sm:px-7 sm:text-base"
|
||||
className={
|
||||
menu
|
||||
? "h-14 w-full max-w-xs cursor-pointer rounded-full bg-slate-900 px-8 font-heading text-xl font-bold text-slate-50 transition-[background-color,transform] duration-200 hover:bg-slate-700 active:scale-[0.97]"
|
||||
: "h-9 cursor-pointer rounded-full bg-slate-900 px-3.5 text-sm font-medium text-slate-50 transition-colors duration-200 hover:bg-slate-700 sm:h-12 sm:px-7 sm:text-base"
|
||||
}
|
||||
>
|
||||
<Link href={href}>Giriş yap</Link>
|
||||
</Button>
|
||||
|
||||
@@ -5,8 +5,23 @@ import { LogOut } from "lucide-react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SignOutButton() {
|
||||
export function SignOutButton({ menu = false }: { /** Telefon menüsündeki yazılı hap (DESIGN.md §4.36). */ menu?: boolean }) {
|
||||
const router = useRouter();
|
||||
if (menu) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-12 cursor-pointer rounded-full border border-slate-200 bg-card px-6 text-base font-semibold text-slate-700 transition-[background-color,transform] duration-200 hover:bg-slate-50 active:scale-[0.97]"
|
||||
onClick={async () => {
|
||||
await authClient.signOut();
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<LogOut className="size-4" aria-hidden />
|
||||
Çıkış yap
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ListeSenkron } from "@/features/liste/components/liste-senkron";
|
||||
import { RybbitIdentify } from "@/components/rybbit-identify";
|
||||
import { GirisLinki } from "./giris-linki";
|
||||
import { SignOutButton } from "./sign-out-button";
|
||||
import { MobilMenu } from "@/components/mobil-menu";
|
||||
|
||||
/**
|
||||
* Suspense fallback'i: girişsiz görünümle aynı yükseklik/genişlikte sessiz
|
||||
@@ -15,10 +16,11 @@ import { SignOutButton } from "./sign-out-button";
|
||||
export function UserNavFallback() {
|
||||
return (
|
||||
<div
|
||||
className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2"
|
||||
className="order-3 flex shrink-0 items-center gap-1.5 sm:gap-2"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="h-9 w-16 rounded-full bg-slate-900/10 sm:h-12 sm:w-28" />
|
||||
{/* lg altı: menü baloncuğunun yeri; lg+: "Giriş yap" hapının yeri */}
|
||||
<div className="size-9 rounded-full bg-slate-900/10 sm:size-12 lg:h-12 lg:w-28" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,19 +31,29 @@ export async function UserNav() {
|
||||
// Manuel liste localStorage'ta yaşadığı için Listem girişsiz de çalışır
|
||||
if (!u) {
|
||||
return (
|
||||
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||
<div className="order-3 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||
<RybbitIdentify kullaniciId={null} />
|
||||
<ListeSenkron userId={null} />
|
||||
<ListemButonu />
|
||||
<GirisLinki />
|
||||
{/* lg altında her şey baloncuk menüde (DESIGN.md §4.36) */}
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<ListemButonu />
|
||||
<GirisLinki />
|
||||
</div>
|
||||
<div className="lg:hidden">
|
||||
<MobilMenu kredi={null} girisSlotu={<GirisLinki menu />} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||
<div className="order-3 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||
<RybbitIdentify kullaniciId={u.id} />
|
||||
<ListeSenkron userId={u.id} />
|
||||
<div className="lg:hidden">
|
||||
<MobilMenu kredi={u.creditBalance} girisSlotu={<SignOutButton menu />} />
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<ListemButonu />
|
||||
{/* Yapay Zeka raporunun kalıcı kapısı: girişli kullanıcı /listem'i
|
||||
navbar'dan bulamıyordu (çekmece butonu manuel liste boşken hiç
|
||||
@@ -79,6 +91,7 @@ export async function UserNav() {
|
||||
<span className="hidden md:inline lg:hidden">kredi</span>
|
||||
</Link>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ export function preloadListeCekmecesi() {
|
||||
export function ListeCekmecesiLazy() {
|
||||
const acik = useCekmeceAcik();
|
||||
const liste = useManuelListe();
|
||||
// "+" uçuş animasyon katmanı da çekmece modülünde yaşadığı için liste
|
||||
// doluysa (çekmece kapalı olsa da) mount kalır; yalnızca boş+kapalı durumda
|
||||
// atlanır — ilk ziyaretin en yaygın hâli.
|
||||
// Liste doluysa çekmece kapalıyken de mount kalır (açılış gecikmesin);
|
||||
// yalnız boş+kapalı durumda atlanır — ilk ziyaretin en yaygın hâli.
|
||||
// ("+" uçuşu ayrı katmanda: ucus-katmani.tsx.)
|
||||
return acik || liste.length > 0 ? <ListeCekmecesiIc /> : null;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,17 @@ import {
|
||||
* Liste localStorage'da yaşadığı için hidrasyondan önce yer tutucu çizilir,
|
||||
* satır zıplamasın.
|
||||
*/
|
||||
export function ListeDurumuSatiri() {
|
||||
export function ListeDurumuSatiri({
|
||||
varyant = "sayfa",
|
||||
}: {
|
||||
/** "hero": ana sayfada yalnız liste doluyken görünür, her genişlikte ortalı. */
|
||||
varyant?: "sayfa" | "hero";
|
||||
}) {
|
||||
const hidre = useListeHidreMi();
|
||||
const liste = useManuelListe();
|
||||
const hero = varyant === "hero";
|
||||
|
||||
if (hero && (!hidre || liste.length === 0)) return null;
|
||||
if (!hidre) return <div className="mt-5 h-11" aria-hidden />;
|
||||
|
||||
if (liste.length === 0) {
|
||||
@@ -36,7 +43,7 @@ export function ListeDurumuSatiri() {
|
||||
|
||||
const kalan = MANUEL_LISTE_MAX - liste.length;
|
||||
return (
|
||||
<div className="mt-5 flex justify-center lg:justify-start">
|
||||
<div className={hero ? "mt-4 flex justify-center" : "mt-5 flex justify-center lg:justify-start"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cekmeceAc}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
// taşır, tıklanınca çekmeceyi açar/kapatır. ("Listem" adı Yapay Zeka raporunun
|
||||
// yaşadığı /listem sayfasına aittir — isim çakışmasın.)
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { YAY } from "@/lib/hareket";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
@@ -18,9 +21,10 @@ export function ListemButonu() {
|
||||
const liste = useManuelListe();
|
||||
const acik = useCekmeceAcik();
|
||||
|
||||
// Liste boşken buton gürültü: tabloya (+) ulaşmamış ziyaretçiye "0/24"
|
||||
// bir şey ifade etmez. İlk program eklenince belirir, liste boşalınca gider.
|
||||
if (liste.length === 0) return null;
|
||||
const azalt = useReducedMotion();
|
||||
// Boşken de görünür (DESIGN.md §4.37): halka boş, sayı soluk — hedef (24)
|
||||
// ilk bakışta okunur ve ilk eklemede uçan karenin konacağı yer hazırdır.
|
||||
const dolu = liste.length > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -31,6 +35,7 @@ export function ListemButonu() {
|
||||
aria-expanded={acik}
|
||||
aria-label={`Kendi listem — ${liste.length}/${MANUEL_LISTE_MAX} program`}
|
||||
title="Tablodan seçtiğin programlar"
|
||||
data-ucus-hedefi="masaustu"
|
||||
className="relative inline-flex h-9 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-card px-3 text-xs font-semibold text-slate-700 transition-[color,background-color,border-color,transform] duration-200 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.97] sm:h-12 sm:gap-2 sm:px-5 sm:text-sm"
|
||||
>
|
||||
<ListChecks className="size-3.5 text-primary sm:size-4" aria-hidden />
|
||||
@@ -38,13 +43,35 @@ export function ListemButonu() {
|
||||
hiçbir kırılma noktasında etikete yer yok — ikon + rozet + yukarıdaki
|
||||
aria-label anlamı taşıyor. UserNav'daki pill'le aynı desen. */}
|
||||
<span className="hidden">Kendi Listem</span>
|
||||
{liste.length > 0 ? (
|
||||
// Rozet yerinde durur, rakam rulo gibi döner (eklemede yukarı,
|
||||
// çıkarmada aşağı) — Arc "Number field" rulosu, bkz. sayi-rulo.tsx.
|
||||
<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">
|
||||
{/* Rozet yerinde durur, rakam rulo gibi döner (sayi-rulo.tsx). Çevresinde
|
||||
24'lük doluluk halkası: yay ile dolar; sayı her zaman yazılı, halka
|
||||
tek başına anlam taşımaz. */}
|
||||
<span data-ucus-rozet className="relative flex size-8 items-center justify-center">
|
||||
<svg viewBox="0 0 32 32" aria-hidden className="absolute inset-0 size-full -rotate-90">
|
||||
<circle cx="16" cy="16" r="14.5" fill="none" strokeWidth="2.5" className="stroke-slate-200" />
|
||||
<motion.circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="14.5"
|
||||
fill="none"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
className="stroke-primary"
|
||||
pathLength={1}
|
||||
initial={false}
|
||||
animate={{ pathLength: liste.length / MANUEL_LISTE_MAX, opacity: dolu ? 1 : 0 }}
|
||||
transition={azalt ? { duration: 0 } : YAY.akici}
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-[22px] items-center justify-center rounded-full font-heading text-xs font-bold transition-colors duration-200",
|
||||
dolu ? "bg-orange-500 text-white" : "bg-slate-100 text-slate-500",
|
||||
)}
|
||||
>
|
||||
<SayiRulo deger={liste.length} />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { SATIR_DUGME_BOYU } from "@/components/liste-satiri";
|
||||
import { ArrowRight, Check, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { ArrowRight, Plus } from "lucide-react";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { YAY } from "@/lib/hareket";
|
||||
import { logoTepkisi } from "@/lib/logo-tepkisi";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
cekmeceAc,
|
||||
@@ -18,6 +22,7 @@ import {
|
||||
programEkleIste,
|
||||
} from "../hooks/use-tercih-profili";
|
||||
import { preloadTercihProfiliKapisi } from "./tercih-profili-kapisi-lazy";
|
||||
import { ucusBaslat } from "./ucus-katmani";
|
||||
|
||||
type ProgramOzet = Pick<
|
||||
Program,
|
||||
@@ -51,10 +56,15 @@ export function ProgramEkleButonu({
|
||||
// taramanın listedeMi ile ikinci kez yapılması vardı).
|
||||
const listede = useListedeMi(program.id);
|
||||
const doldu = useListeDoluMu();
|
||||
const azalt = useReducedMotion();
|
||||
// Tik yalnız bu düğmeyle eklenince kendini çizer; sayfa açılışında zaten
|
||||
// listede olan satırlar çizim oynatmaz.
|
||||
const [yeniEklendi, setYeniEklendi] = useState(false);
|
||||
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const dugme = e.currentTarget;
|
||||
const sonuc = programEkleIste({ program, kaynak, adaySira, adayTur });
|
||||
if (sonuc.doldu) {
|
||||
// Ne olduğunu ve ne yapılacağını söyle (DESIGN.md §10)
|
||||
@@ -75,6 +85,12 @@ export function ProgramEkleButonu({
|
||||
// olur, navbar sayacı artar). Profilsiz eklemede aşağıdaki toast
|
||||
// zaten açıklama taşıyor.
|
||||
const adet = listeOku().length;
|
||||
// Eylemin kendi cevabı (§4.37): kare rozete uçar, logo döner, parmak
|
||||
// hafifçe titrer. Bildirim değil; her eklemede olur.
|
||||
setYeniEklendi(true);
|
||||
ucusBaslat(dugme);
|
||||
logoTepkisi(adet === MANUEL_LISTE_MAX ? "tamam" : "ekle");
|
||||
navigator.vibrate?.(8);
|
||||
if (!sonuc.profilsiz && adet === 1) {
|
||||
toast.success(`İlk programın listende · 1/${MANUEL_LISTE_MAX}`, {
|
||||
description: "Beğendiklerini ekledikçe sırasını çekmeceden değiştirebilirsin.",
|
||||
@@ -139,7 +155,23 @@ export function ProgramEkleButonu({
|
||||
} ${className}`}
|
||||
>
|
||||
{listede ? (
|
||||
<Check className="size-4" aria-hidden />
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4"
|
||||
aria-hidden
|
||||
>
|
||||
<motion.path
|
||||
d="M4 12.5 9.5 18 20 6.5"
|
||||
initial={yeniEklendi && !azalt ? { pathLength: 0 } : false}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={YAY.donusum}
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<Plus className="size-4" aria-hidden />
|
||||
)}
|
||||
|
||||
@@ -94,12 +94,15 @@ const BOS_EKSTRA: Record<DilimKey, ProgramListeSatiri[]> = {
|
||||
garanti: [],
|
||||
};
|
||||
|
||||
// Dengeli ve hayal dilimleri tamamen boşsa (sıralama çok gerideyse) boş bir
|
||||
// "dengeli" sekmesi yerine dolu olan "garanti" ile açılır.
|
||||
// Açılış sekmesi hiçbir zaman boş bir dilim olmaz (Bilal, 10 Eki 2026:
|
||||
// "dengeli 0 dahi olsa tab aktif oluyor"): önce dengeli, o boşsa güvenli,
|
||||
// o da boşsa hayal. Üçü de boşsa dengeli kalır — tek boş mesaj orada okunur.
|
||||
function varsayilanDilim(sonuclar: RankResultsListe): DilimKey {
|
||||
return sonuclar.toplam.dengeli === 0 && sonuclar.toplam.hayal === 0
|
||||
? "garanti"
|
||||
: "dengeli";
|
||||
const { toplam } = sonuclar;
|
||||
if (toplam.dengeli > 0) return "dengeli";
|
||||
if (toplam.garanti > 0) return "garanti";
|
||||
if (toplam.hayal > 0) return "hayal";
|
||||
return "dengeli";
|
||||
}
|
||||
|
||||
export function ProgramTablosu({
|
||||
@@ -133,9 +136,10 @@ export function ProgramTablosu({
|
||||
if (veriAnahtari !== oncekiAnahtar) {
|
||||
setOncekiAnahtar(veriAnahtari);
|
||||
setEkstra(BOS_EKSTRA);
|
||||
// Yeni sıra/türde dengeli+hayal boş kaldıysa boş sekmede bırakma
|
||||
if (varsayilanDilim(sonuclar) === "garanti" && aktifDilim !== "garanti") {
|
||||
setAktifDilim("garanti");
|
||||
// Yeni sıra/tür/filtrede açık sekme boşaldıysa dolu olana geç; kullanıcı
|
||||
// bilerek dolu bir sekmedeyse (0 değilse) seçimi korunur.
|
||||
if (sonuclar.toplam[aktifDilim] === 0) {
|
||||
setAktifDilim(varsayilanDilim(sonuclar));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { logoTepkisi } from "@/lib/logo-tepkisi";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Trophy } from "lucide-react";
|
||||
@@ -7,6 +8,7 @@ import { useReducedMotion } from "motion/react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import { TransitionPanel } from "@/components/core/transition-panel";
|
||||
import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -230,6 +232,8 @@ function KapıIcerik({
|
||||
if (sira == null) return;
|
||||
const yeniProfil: TercihProfili = { sira, tur, secimler };
|
||||
tercihProfiliYaz(yeniProfil);
|
||||
// Üst çubuktaki logo kürsüye döner (DESIGN.md §4.34).
|
||||
logoTepkisi("sira");
|
||||
const bekleyenVar = Boolean(bekleyen);
|
||||
const yonlendir = profilTamamlandi(yeniProfil, girisli);
|
||||
// "sihirbaz" hedefinde akış /sonuc'ta devam eder (üretim ya da giriş
|
||||
@@ -255,42 +259,11 @@ function KapıIcerik({
|
||||
<TransitionPanel
|
||||
activeIndex={ADIM_SIRASI[adim]}
|
||||
custom={yon}
|
||||
variants={{
|
||||
enter: (gecisYonu: number) => ({
|
||||
x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
|
||||
opacity: 0,
|
||||
height: panelYuksekligi,
|
||||
position: "relative",
|
||||
}),
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
height: panelYuksekligi,
|
||||
},
|
||||
exit: (gecisYonu: number) => ({
|
||||
zIndex: 0,
|
||||
x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
}),
|
||||
}}
|
||||
transition={
|
||||
hareketiAzalt
|
||||
? {
|
||||
opacity: { duration: 0.12 },
|
||||
x: { duration: 0 },
|
||||
height: { duration: 0 },
|
||||
}
|
||||
: {
|
||||
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||
height: { type: "spring", stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 },
|
||||
}
|
||||
}
|
||||
variants={adimGecisiVaryantlari({
|
||||
hareketiAzalt: !!hareketiAzalt,
|
||||
yukseklik: panelYuksekligi,
|
||||
})}
|
||||
transition={adimGecisi(!!hareketiAzalt)}
|
||||
>
|
||||
{/* p-1: geçiş paneli taşmayı kırpar; alanların 3 px odak halkası
|
||||
(Input/Select/Button ring-3) sığsın (Bilal, 24 Eyl 2026). */}
|
||||
@@ -330,7 +303,7 @@ function KapıIcerik({
|
||||
type="button"
|
||||
disabled={yukleniyor}
|
||||
onClick={() => void giristenIlerle()}
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
variant="cta"
|
||||
>
|
||||
{yukleniyor ? "Hazırlanıyor…" : "Tercihlerimi belirle"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
@@ -423,7 +396,7 @@ function KapıIcerik({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={yukleniyor}
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
variant="cta"
|
||||
>
|
||||
{yukleniyor ? "Hazırlanıyor…" : "Tercihlere geç"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
|
||||
111
src/features/liste/components/ucus-katmani.tsx
Normal file
111
src/features/liste/components/ucus-katmani.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
// Listeye ekleme uçuşu (DESIGN.md §4.37, §11.2 "her eylemin cevabı var"):
|
||||
// "+" düğmesine basılınca düğmenin yerinden bir piksel kare, üst çubuktaki
|
||||
// Listem rozetine (telefonda menü baloncuğuna) uçar; varışta rozet bir kez
|
||||
// nabız atar. Katman tektir ve layout'ta durur; düğmeler `ucusBaslat` ile
|
||||
// pencereye olay atar. Hareketi azalt'ta uçuş yok (rozet sayısı yine değişir).
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { animate } from "motion/react";
|
||||
import { EGRI } from "@/lib/hareket";
|
||||
|
||||
const UCUS_OLAYI = "kt-ucus";
|
||||
const KARE = 10;
|
||||
const UCUS_SURE = 0.55;
|
||||
|
||||
/** "+" düğmesi çağırır: uçuş `kaynak` öğesinin ortasından başlar. */
|
||||
export function ucusBaslat(kaynak: Element) {
|
||||
if (typeof window === "undefined") return;
|
||||
const r = kaynak.getBoundingClientRect();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(UCUS_OLAYI, {
|
||||
detail: { x: r.left + r.width / 2, y: r.top + r.height / 2 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Ekranda görünen hedef: masaüstünde Listem düğmesi, telefonda menü baloncuğu. */
|
||||
function hedefBul(): HTMLElement | null {
|
||||
for (const el of document.querySelectorAll<HTMLElement>("[data-ucus-hedefi]")) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rozetNabzi(hedef: HTMLElement) {
|
||||
const rozet = hedef.querySelector<HTMLElement>("[data-ucus-rozet]") ?? hedef;
|
||||
rozet.classList.remove("kt-piksel-nabiz");
|
||||
// Yeniden tetiklemek için yerleşimi oku (animasyon sıfırlanır).
|
||||
void rozet.offsetWidth;
|
||||
rozet.classList.add("kt-piksel-nabiz");
|
||||
rozet.addEventListener(
|
||||
"animationend",
|
||||
() => rozet.classList.remove("kt-piksel-nabiz"),
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function UcusKatmani() {
|
||||
const kareRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let surenUcus: { stop: () => void } | null = null;
|
||||
const azalt = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
|
||||
function uc(e: Event) {
|
||||
const { x, y } = (e as CustomEvent<{ x: number; y: number }>).detail;
|
||||
const kare = kareRef.current;
|
||||
// Rozet sayısı store'dan bir kare sonra güncellenir; hedef o zaman aranır
|
||||
// (ilk eklemede rozet yeni belirir).
|
||||
requestAnimationFrame(() => {
|
||||
const hedef = hedefBul();
|
||||
if (!hedef) return;
|
||||
if (azalt.matches || !kare) {
|
||||
return;
|
||||
}
|
||||
const h = hedef.getBoundingClientRect();
|
||||
const hx = h.left + h.width / 2;
|
||||
const hy = h.top + h.height / 2;
|
||||
// Aynı anda tek kare: süren uçuş kesilir, yenisi kendi yerinden çıkar.
|
||||
surenUcus?.stop();
|
||||
kare.style.left = `${x - KARE / 2}px`;
|
||||
kare.style.top = `${y - KARE / 2}px`;
|
||||
// Yatay düz, dikey gecikmeli: iki eğrinin farkı yayı çizer.
|
||||
surenUcus = animate(
|
||||
kare,
|
||||
{
|
||||
x: [0, hx - x],
|
||||
y: [0, hy - y],
|
||||
opacity: [0, 1, 1, 0],
|
||||
scale: [0.6, 1.4, 1, 0.5],
|
||||
},
|
||||
{
|
||||
duration: UCUS_SURE,
|
||||
x: { duration: UCUS_SURE, ease: EGRI.standart },
|
||||
y: { duration: UCUS_SURE, ease: [0.5, 0, 0.75, 0] },
|
||||
opacity: { duration: UCUS_SURE, times: [0, 0.12, 0.85, 1], ease: "linear" },
|
||||
scale: { duration: UCUS_SURE, times: [0, 0.15, 0.8, 1], ease: "easeOut" },
|
||||
onComplete: () => rozetNabzi(hedef),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener(UCUS_OLAYI, uc);
|
||||
return () => {
|
||||
window.removeEventListener(UCUS_OLAYI, uc);
|
||||
surenUcus?.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={kareRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed z-[70] block rounded-[2px] bg-orange-500 opacity-0 print:hidden"
|
||||
style={{ width: KARE, height: KARE, left: 0, top: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
277
src/features/pazarlama/components/adim-akisi.tsx
Normal file
277
src/features/pazarlama/components/adim-akisi.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
// Ana sayfa "Nasıl çalışır"ın üç adımı (DESIGN.md §4.32). Adım numarası
|
||||
// hero'nun piksel diliyle yazılmış iri rakamdır; bölüm ekrana girince her
|
||||
// rakam sırayla, kısa bir an o adımın biçimine dönüşüp geri toplanır
|
||||
// (logonun yaptığının aynısı, §4.34):
|
||||
// 1 → sıra şeridi (herkes tek sırada, turuncu kare sensin)
|
||||
// 2 → yıl yıl çubuklar (taranan veri; en yenisinin tepesi turuncu)
|
||||
// 3 → 6×4 = 24 kare (listen)
|
||||
// Rakam geri toplanınca sıradaki adıma giden çizgi dolar; hareketler sırayla
|
||||
// konuşur (§11.2-3). Sonrasında imleçle adıma gelince biçim yeniden açılır,
|
||||
// telefonda dokununca bir kez oynar. Hareketi azalt'ta rakamlar durağandır.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { PikselMorf, type MorfPiksel } from "@/components/piksel-morf";
|
||||
import { RAKAMLAR, dokuOpakligi } from "@/components/pixel-decor";
|
||||
import { Reveal } from "@/features/pazarlama/components/reveal";
|
||||
|
||||
export type Adim = {
|
||||
no: keyof typeof RAKAMLAR;
|
||||
baslik: string;
|
||||
aciklama: string;
|
||||
};
|
||||
|
||||
// Sahne: rakam (5×7) ortada, biçim iki yanına taşabilir.
|
||||
const SUTUN = 11;
|
||||
const SATIR = 7;
|
||||
const RAKAM_X = (SUTUN - 5) / 2;
|
||||
|
||||
/** Biçimde kalma (dönüşüm dahil) ve rakama dönüş süreleri (ms). */
|
||||
const BICIM_TUT = 1300;
|
||||
const DONUS = 650;
|
||||
const ILK_BEKLEME = 350;
|
||||
|
||||
type Hucre = { x: number; y: number; vurgu?: boolean };
|
||||
|
||||
function rakamHucreleri(no: Adim["no"]): Hucre[] {
|
||||
return RAKAMLAR[no].flatMap((satir, y) =>
|
||||
[...satir].flatMap((c, x) => (c === "#" ? [{ x: x + RAKAM_X, y }] : [])),
|
||||
);
|
||||
}
|
||||
|
||||
function bicimHucreleri(no: Adim["no"], yilSayisi: number): Hucre[] {
|
||||
if (no === 1) {
|
||||
// Sıra şeridi (PixelSira dili): dokuz kare tek sırada, biri sen
|
||||
return Array.from({ length: 9 }, (_, i) => ({
|
||||
x: 1 + i,
|
||||
y: 3,
|
||||
vurgu: i === 2,
|
||||
}));
|
||||
}
|
||||
if (no === 2) {
|
||||
// Yıl başına bir çubuk; sahne 6 çubuk alır
|
||||
const n = Math.min(Math.max(yilSayisi, 3), 6);
|
||||
const boylar = [3, 2, 4, 3, 5, 4].slice(-n);
|
||||
const sol = (SUTUN - (2 * n - 1)) / 2;
|
||||
return boylar.flatMap((boy, b) =>
|
||||
Array.from({ length: boy }, (_, r) => ({
|
||||
x: sol + b * 2,
|
||||
y: SATIR - 1 - r,
|
||||
vurgu: b === n - 1 && r === boy - 1,
|
||||
})),
|
||||
);
|
||||
}
|
||||
// 24'lük liste: 6×4, kapanıştaki piksel listeyle aynı dizilim
|
||||
return Array.from({ length: 24 }, (_, i) => ({
|
||||
x: (SUTUN - 6) / 2 + (i % 6),
|
||||
y: (SATIR - 4) / 2 + Math.floor(i / 6),
|
||||
}));
|
||||
}
|
||||
|
||||
type Eslesme = { rakam?: Hucre; bicim?: Hucre; op: number };
|
||||
|
||||
/** Rakam pikselleri en yakın biçim hücresine gider; artanlar yerinde belirir/söner. */
|
||||
function esle(rakam: Hucre[], bicim: Hucre[]): Eslesme[] {
|
||||
const ciftler = rakam
|
||||
.flatMap((r, ri) =>
|
||||
bicim.map((b, bi) => ({ ri, bi, d: (r.x - b.x) ** 2 + (r.y - b.y) ** 2 })),
|
||||
)
|
||||
.sort((a, b) => a.d - b.d);
|
||||
const rakamdan = new Map<number, number>();
|
||||
const bicimden = new Set<number>();
|
||||
for (const c of ciftler) {
|
||||
if (rakamdan.has(c.ri) || bicimden.has(c.bi)) continue;
|
||||
rakamdan.set(c.ri, c.bi);
|
||||
bicimden.add(c.bi);
|
||||
}
|
||||
// "Dolgun" ton (PixelSahne ile aynı): okunması gereken şekil
|
||||
const dolgun = (h: Hucre) =>
|
||||
0.4 + dokuOpakligi(Math.abs(Math.round(h.x - RAKAM_X)), Math.round(h.y)) * 2;
|
||||
return [
|
||||
...rakam.map((r, ri) => {
|
||||
const bi = rakamdan.get(ri);
|
||||
return { rakam: r, bicim: bi === undefined ? undefined : bicim[bi], op: dolgun(r) };
|
||||
}),
|
||||
...bicim
|
||||
.filter((_, bi) => !bicimden.has(bi))
|
||||
.map((b) => ({ bicim: b, op: dolgun(b) })),
|
||||
];
|
||||
}
|
||||
|
||||
function RakamMorfu({
|
||||
no,
|
||||
yilSayisi,
|
||||
bicimde,
|
||||
}: {
|
||||
no: Adim["no"];
|
||||
yilSayisi: number;
|
||||
bicimde: boolean;
|
||||
}) {
|
||||
const eslesmeler = useMemo(
|
||||
() => esle(rakamHucreleri(no), bicimHucreleri(no, yilSayisi)),
|
||||
[no, yilSayisi],
|
||||
);
|
||||
const pikseller = useMemo<MorfPiksel[]>(
|
||||
() =>
|
||||
eslesmeler.map((e, i) => {
|
||||
const hedef = bicimde ? e.bicim : e.rakam;
|
||||
// Bu hâlde yeri olmayan piksel öbür hâldeki yerinde gizli bekler
|
||||
const yer = hedef ?? e.bicim ?? e.rakam!;
|
||||
return {
|
||||
id: String(i),
|
||||
x: yer.x,
|
||||
y: yer.y,
|
||||
op: e.op,
|
||||
ton: !hedef ? "gizli" : bicimde && hedef.vurgu ? "vurgu" : "doku",
|
||||
// Kareler sırayla yola çıkar (logo biçimlerindeki gibi)
|
||||
gecikme: i * 14,
|
||||
};
|
||||
}),
|
||||
[eslesmeler, bicimde],
|
||||
);
|
||||
return (
|
||||
<PikselMorf
|
||||
pikseller={pikseller}
|
||||
sutun={SUTUN}
|
||||
satir={SATIR}
|
||||
yaricap={22}
|
||||
// Biçimde turuncu kare anlam taşır ("sen", "bu yıl"): fırça karışmasın
|
||||
firca={!bicimde}
|
||||
className="h-24 w-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdimAkisi({
|
||||
adimlar,
|
||||
yilSayisi,
|
||||
}: {
|
||||
adimlar: readonly Adim[];
|
||||
/** "Son N yıl": ikinci adımın biçimindeki çubuk sayısı */
|
||||
yilSayisi: number;
|
||||
}) {
|
||||
const azalt = useReducedMotion();
|
||||
const ogeler = useRef<(HTMLLIElement | null)[]>([]);
|
||||
/** Şu an biçimini gösteren adım */
|
||||
const [bicimde, setBicimde] = useState<number | null>(null);
|
||||
/** Dolan çizgi sayısı (adım i ile i+1 arası: i < dolan) */
|
||||
const [dolan, setDolan] = useState(0);
|
||||
// Açılış sırası: kaç adım oynadı, şu an oynuyor mu
|
||||
const oynanan = useRef(0);
|
||||
const mesgul = useRef(false);
|
||||
const gorunur = useRef<boolean[]>([]);
|
||||
const zamanlayicilar = useRef<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (azalt || typeof IntersectionObserver === "undefined") return;
|
||||
const bekle = (ms: number, is: () => void) => {
|
||||
zamanlayicilar.current.push(window.setTimeout(is, ms));
|
||||
};
|
||||
// Adım, kendisi ekrandaysa ve öncekiler bittiyse oynar: geniş ekranda
|
||||
// 1 → 2 → 3 peş peşe, telefonda her biri görününce.
|
||||
const ilerlet = () => {
|
||||
const i = oynanan.current;
|
||||
if (mesgul.current || i >= adimlar.length || !gorunur.current[i]) return;
|
||||
mesgul.current = true;
|
||||
bekle(i === 0 ? ILK_BEKLEME : 0, () => {
|
||||
setBicimde(i);
|
||||
bekle(BICIM_TUT, () => {
|
||||
setBicimde(null);
|
||||
setDolan(i + 1);
|
||||
bekle(DONUS, () => {
|
||||
oynanan.current = i + 1;
|
||||
mesgul.current = false;
|
||||
ilerlet();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
const io = new IntersectionObserver(
|
||||
(kayitlar) => {
|
||||
for (const k of kayitlar) {
|
||||
const i = ogeler.current.indexOf(k.target as HTMLLIElement);
|
||||
if (i >= 0) gorunur.current[i] = k.isIntersecting;
|
||||
}
|
||||
ilerlet();
|
||||
},
|
||||
// Rakam ekranın alt kenarından biraz içeri girince
|
||||
{ rootMargin: "0px 0px -120px 0px" },
|
||||
);
|
||||
ogeler.current.forEach((el) => el && io.observe(el));
|
||||
const bekleyenler = zamanlayicilar.current;
|
||||
return () => {
|
||||
io.disconnect();
|
||||
bekleyenler.forEach((z) => window.clearTimeout(z));
|
||||
bekleyenler.length = 0;
|
||||
mesgul.current = false;
|
||||
};
|
||||
}, [azalt, adimlar.length]);
|
||||
|
||||
// Açılışı bitmiş adım bakana cevap verir: imleç üstündeyken biçim açık
|
||||
// kalır, dokunmada bir kez oynar. Açılış sürerken karışmaz.
|
||||
const serbest = (i: number) => !azalt && !mesgul.current && oynanan.current > i;
|
||||
const dokun = (i: number) => {
|
||||
if (!serbest(i)) return;
|
||||
mesgul.current = true;
|
||||
setBicimde(i);
|
||||
zamanlayicilar.current.push(
|
||||
window.setTimeout(() => {
|
||||
setBicimde(null);
|
||||
mesgul.current = false;
|
||||
}, BICIM_TUT),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ol className="grid gap-12 md:grid-cols-3 md:gap-8">
|
||||
{adimlar.map((s, i) => (
|
||||
<li
|
||||
key={s.no}
|
||||
ref={(el) => {
|
||||
ogeler.current[i] = el;
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType === "mouse" && serbest(i)) setBicimde(i);
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === "mouse" && !mesgul.current) {
|
||||
setBicimde((b) => (b === i ? null : b));
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.pointerType !== "mouse") dokun(i);
|
||||
}}
|
||||
>
|
||||
<Reveal
|
||||
delay={i * 0.08}
|
||||
className="relative flex flex-col items-center text-center"
|
||||
>
|
||||
<RakamMorfu no={s.no} yilSayisi={yilSayisi} bicimde={bicimde === i} />
|
||||
{i < adimlar.length - 1 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-12 left-[calc(50%+5.25rem)] hidden h-0.5 w-[calc(100%-8.5rem)] -translate-y-1/2 overflow-hidden rounded-full bg-slate-200 md:block"
|
||||
>
|
||||
{/* Adım göstergesiyle aynı mavi dolum (§4.35): rakam biçiminden
|
||||
dönünce sıradaki adıma doğru dolar. */}
|
||||
<span
|
||||
data-dolu={dolan > i ? "" : undefined}
|
||||
className="kt-cizgi-dol block h-full rounded-full bg-primary"
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
<h3 className="mt-6 font-heading text-xl font-bold text-balance">
|
||||
<span className="sr-only">{s.no}. adım: </span>
|
||||
{s.baslik}
|
||||
</h3>
|
||||
<p className="mt-2 max-w-sm text-base leading-relaxed text-slate-600">
|
||||
{s.aciklama}
|
||||
</p>
|
||||
</Reveal>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
61
src/features/pazarlama/components/bolum-gecisi.tsx
Normal file
61
src/features/pazarlama/components/bolum-gecisi.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// Ana sayfa bölüm geçişi (DESIGN.md §4.38): bölümler arasında piksel ayraç
|
||||
// yerine tek bir piksel şeridi durur. Şerit hero haritasının serpintisinden
|
||||
// başlar ve her bölüm arasında yeniden görünür; kaydırdıkça turuncuya ısınarak
|
||||
// aşağı iner — sayfanın bölümlerini birbirine bağlayan iplik. Bölümlerin
|
||||
// içinde çizilmez (yazının arkasına piksel serilmez, §4.31), yalnız aradaki
|
||||
// boşlukta durur.
|
||||
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PikselSerit } from "@/components/piksel-serit";
|
||||
|
||||
/** Geçişin akışta kapladığı boy (hücre): eski ayracın yüksekliği, bölümler
|
||||
* arasındaki mesafe değişmesin. Şerit bunun üstüne ve altına taşar. */
|
||||
const ORTA = 2;
|
||||
|
||||
export function BolumGecisi({
|
||||
tohum,
|
||||
ust = 4,
|
||||
alt = 4,
|
||||
ustTelefon = 3,
|
||||
altTelefon = 3,
|
||||
ustKisaTelefon = ustTelefon,
|
||||
}: {
|
||||
/** Şeridin doku deseni; sayfadaki her geçişte farklı */
|
||||
tohum: number;
|
||||
/** Şeridin geçiş çizgisinin üstünde ve altında kalan boyu (hücre ≈ 18,7 px) */
|
||||
ust?: number;
|
||||
alt?: number;
|
||||
/** Telefonda bölüm boşluğu dardır (üstte 112, altta 96 px; app/page.tsx
|
||||
* `BOLUM_BOSLUGU`): şerit daha kısa çizilir. */
|
||||
ustTelefon?: number;
|
||||
altTelefon?: number;
|
||||
/** Kısa telefonda (yükseklik < 760 px) üstte kalan boy. Hero'nun altındaki
|
||||
* boşluk ekran boyuna bağlıdır; şerit forma değmesin. */
|
||||
ustKisaTelefon?: number;
|
||||
}) {
|
||||
const uzunluk = ust + ORTA + alt;
|
||||
const hucreSinifi = Array.from({ length: uzunluk }, (_, i) => {
|
||||
const ustten = ust - i; // orta kısmın kaç hücre üstünde (1 = hemen üstü)
|
||||
const alttan = i - (ust + ORTA) + 1;
|
||||
if (ustten > ustTelefon || alttan > altTelefon) return "max-sm:hidden";
|
||||
if (ustten > ustKisaTelefon) {
|
||||
return "max-sm:[@media(max-height:759px)]:hidden";
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return (
|
||||
// Bir hücre = 14rem / 12 (hero'dan sonraki en iri piksel; eski ayraçla aynı)
|
||||
<div aria-hidden className="mx-auto h-[calc(14rem/6)] w-[calc(14rem/12)]">
|
||||
<Parallax strength={26} className="relative">
|
||||
<PikselSerit
|
||||
uzunluk={uzunluk}
|
||||
tohum={tohum}
|
||||
hucreSinifi={hucreSinifi}
|
||||
className="pointer-events-none absolute inset-x-0"
|
||||
style={{ top: `calc(${-ust} * 14rem / 12)` }}
|
||||
/>
|
||||
</Parallax>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,9 @@ export function HeroBaslik() {
|
||||
const profil = useTercihProfili();
|
||||
|
||||
return (
|
||||
<h1 className="max-w-3xl font-heading text-5xl font-bold tracking-[0.015em] sm:text-6xl">
|
||||
// Sayfanın tek orkestre girişi (DESIGN.md §6, §11.2): başlık, ardından
|
||||
// form netleşerek gelir (globals.css `.kt-hero-gir`).
|
||||
<h1 className="kt-hero-gir max-w-3xl font-heading text-5xl font-bold tracking-[0.015em] sm:text-6xl">
|
||||
{profil ? (
|
||||
<>
|
||||
Sıralaman kayıtlı, <br className="hidden sm:block" />
|
||||
|
||||
@@ -22,7 +22,8 @@ export function HeroFocusButton() {
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={handleClick}
|
||||
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
variant="cta"
|
||||
className="mt-8"
|
||||
>
|
||||
Ücretsiz dene
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
@@ -47,7 +48,8 @@ export function KapanisCta() {
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
variant="cta"
|
||||
className="mt-8"
|
||||
>
|
||||
<Link href={sonucHref()}>
|
||||
Üniversitelere göz at
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CheckCircle2, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
// Ana sayfadaki karşılaştırma tablosunun hücresi: metin değeri düz yazılır,
|
||||
// boolean değer Var/Yok ikonuna çevrilir.
|
||||
// boolean değer Var/Yok ikonuna çevrilir. "Var" tiki tablo ekrana girince
|
||||
// kendini çizer (DESIGN.md §5.2.3, §11.2-6; globals.css `.kt-tik-ciz`):
|
||||
// lucide `circle-check` ile aynı geometri, iki parça `pathLength="1"`.
|
||||
export function ComparisonCell({
|
||||
value,
|
||||
vurgu = false,
|
||||
@@ -20,7 +22,24 @@ export function ComparisonCell({
|
||||
);
|
||||
}
|
||||
return value ? (
|
||||
<CheckCircle2 className="mx-auto size-6 text-primary" aria-label="Var" />
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label="Var"
|
||||
className="mx-auto size-6 text-primary"
|
||||
>
|
||||
<circle cx={12} cy={12} r={10} pathLength={1} className="kt-tik-ciz" />
|
||||
<path
|
||||
d="m9 12 2 2 4-4"
|
||||
pathLength={1}
|
||||
className="kt-tik-ciz kt-tik-isaret"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<X className="mx-auto size-5 text-slate-300" aria-label="Yok" />
|
||||
);
|
||||
|
||||
61
src/features/pazarlama/components/sayan-sayi.tsx
Normal file
61
src/features/pazarlama/components/sayan-sayi.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
// Landing'deki iri sayılar ekrana girince rulo gibi dönerek yerine oturur
|
||||
// (DESIGN.md §11.2 "değişimi göster"; rulo: ui/sayi-rulo.tsx). Sunucu HTML'i
|
||||
// ve ilk boyama gerçek değeri taşır; sayı ekran dışındayken sessizce biraz
|
||||
// geriye alınır, görünüme girince gerçek değere döner. Hareketi azalt'ta ve
|
||||
// zaten ekrandayken hiç oynamaz.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
||||
|
||||
/** "20.000+", "%100", "5" → önek, sayı, sonek. Sayı yoksa `null`. */
|
||||
function ayir(metin: string) {
|
||||
const m = /^(\D*)([\d.]+)(\D*)$/.exec(metin);
|
||||
if (!m) return null;
|
||||
const sayi = Number(m[2].replace(/\./g, ""));
|
||||
return Number.isFinite(sayi) ? { onek: m[1], sayi, sonek: m[3] } : null;
|
||||
}
|
||||
|
||||
export function SayanSayi({ deger }: { deger: string }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const parca = ayir(deger);
|
||||
const hedef = parca?.sayi ?? 0;
|
||||
const [gosterilen, setGosterilen] = useState(hedef);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || !parca || typeof IntersectionObserver === "undefined") return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
let ilk = true;
|
||||
const io = new IntersectionObserver(
|
||||
([giris]) => {
|
||||
if (ilk) {
|
||||
ilk = false;
|
||||
// İlk ölçümde zaten ekrandaysa dokunma; değilse geriye al ve bekle.
|
||||
if (giris.isIntersecting) return io.disconnect();
|
||||
setGosterilen(Math.floor(hedef * 0.9));
|
||||
return;
|
||||
}
|
||||
if (giris.isIntersecting) {
|
||||
setGosterilen(hedef);
|
||||
io.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -80px 0px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
// `parca` her render'da yeni nesne; hedef sayı değişmedikçe yeniden kurma.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hedef]);
|
||||
|
||||
if (!parca) return <>{deger}</>;
|
||||
return (
|
||||
<span ref={ref}>
|
||||
{parca.onek}
|
||||
<SayiRulo deger={gosterilen} />
|
||||
{parca.sonek}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
328
src/features/pazarlama/components/sorun-sahneleri.tsx
Normal file
328
src/features/pazarlama/components/sorun-sahneleri.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
// Ana sayfa "Sorun" kartlarının canlı sahneleri (DESIGN.md §4.31, §4.38).
|
||||
// Üç sahne de eskiden durağan duran ızgaranın aynısıdır; artık kartın
|
||||
// söylediğini hareketle söyler:
|
||||
// ihtimal → binlerce karenin içinde 24'ü ısınır, söner, başka 24'ü ısınır
|
||||
// (hangi 24? — her seferinde başka bir ihtimal)
|
||||
// yillar → çubuklar her yıl bir sola kayar, sağdan başka boyda yeni yıl girer
|
||||
// fiyat → merdiven basamak basamak kurulur, turuncu tepe yukarı tırmanır
|
||||
//
|
||||
// Sahneler sırayla konuşur (§11.2-3): modül seviyesindeki tek orkestra aynı
|
||||
// anda yalnız bir sahneyi oynatır. İmleçle karta gelmek ya da karta dokunmak
|
||||
// o sahneyi hemen oynatır (bakana cevap). Sunucu HTML'i ve hareketi azalt
|
||||
// eski durağan resmi gösterir.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import {
|
||||
PikselMorf,
|
||||
type MorfPiksel,
|
||||
} from "@/components/piksel-morf";
|
||||
import { dokuOpakligi, pikselHash } from "@/components/pixel-decor";
|
||||
|
||||
export type SorunSahnesiTuru = "ihtimal" | "yillar" | "fiyat";
|
||||
|
||||
const SUTUN = 24;
|
||||
const SATIR = 8;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Orkestra: aynı anda tek sahne */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** İki sahnenin söze başlaması arasındaki en kısa süre. */
|
||||
const SOZ_ARASI = 2400;
|
||||
/** Bir sahne oynadıktan sonra en az bu kadar durur. */
|
||||
const DINLENME = 6600;
|
||||
/** Elle (imleç/dokunma) tetiklemede aynı sahnenin tekrar aralığı. */
|
||||
const ELLE_ARASI = 2600;
|
||||
const TIK = 600;
|
||||
|
||||
type Kayit = {
|
||||
sira: number;
|
||||
gorunur: boolean;
|
||||
sonBaslangic: number;
|
||||
oynat: () => void;
|
||||
};
|
||||
|
||||
const kayitlar = new Set<Kayit>();
|
||||
let sayac = 0;
|
||||
let sonSoz = -Infinity;
|
||||
let siradaki = 0;
|
||||
|
||||
function sozVer(k: Kayit, simdi: number) {
|
||||
k.sonBaslangic = simdi;
|
||||
sonSoz = simdi;
|
||||
k.oynat();
|
||||
}
|
||||
|
||||
function tik() {
|
||||
if (document.hidden) return;
|
||||
const simdi = performance.now();
|
||||
if (simdi - sonSoz < SOZ_ARASI) return;
|
||||
const adaylar = [...kayitlar]
|
||||
.filter((k) => k.gorunur && simdi - k.sonBaslangic >= DINLENME)
|
||||
.sort((a, b) => a.sira - b.sira);
|
||||
if (adaylar.length === 0) return;
|
||||
const secilen = adaylar.find((k) => k.sira >= siradaki) ?? adaylar[0];
|
||||
siradaki = secilen.sira + 1;
|
||||
sozVer(secilen, simdi);
|
||||
}
|
||||
|
||||
function saatiAyarla() {
|
||||
const gorunenVar = [...kayitlar].some((k) => k.gorunur);
|
||||
if (gorunenVar && !sayac) sayac = window.setInterval(tik, TIK);
|
||||
if (!gorunenVar && sayac) {
|
||||
window.clearInterval(sayac);
|
||||
sayac = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sahne 1 — 24 tercih, binlerce ihtimal */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// Doku alanı (`o` eski durağan resimdeki 24 turuncu kare: ilk seçim).
|
||||
const 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 ihtimalHucreleri = IHTIMAL.flatMap((satir, y) =>
|
||||
[...satir].flatMap((c, x) => (c === "." ? [] : [{ x, y, ilk: c === "o" }])),
|
||||
);
|
||||
|
||||
/** Her biri tam 24 hücrelik seçimler; sıra, karelerin ısınma sırasıdır. */
|
||||
const SECIMLER: string[][] = [
|
||||
ihtimalHucreleri.filter((h) => h.ilk).map((h) => `${h.x}-${h.y}`),
|
||||
...[3, 8].map((tohum) =>
|
||||
ihtimalHucreleri
|
||||
// Alt iki seyrek satır seçime girmez: 24 kare alanın içinde kalsın
|
||||
.filter((h) => h.y < 6)
|
||||
.map((h) => ({ h, r: pikselHash(h.x, h.y, tohum) }))
|
||||
.sort((a, b) => a.r - b.r)
|
||||
.slice(0, 24)
|
||||
.map(({ h }) => `${h.x}-${h.y}`),
|
||||
),
|
||||
];
|
||||
|
||||
/** `adim` 0: hepsi soğur (binlerce ihtimal). `adim` 1: yeni 24 tek tek ısınır. */
|
||||
function ihtimalSahnesi(dongu: number, adim: number): MorfPiksel[] {
|
||||
const secim = adim === 0 ? [] : SECIMLER[dongu % SECIMLER.length];
|
||||
const sira = new Map(secim.map((id, i) => [id, i]));
|
||||
return ihtimalHucreleri.map((h) => {
|
||||
const id = `${h.x}-${h.y}`;
|
||||
const s = sira.get(id);
|
||||
return {
|
||||
id,
|
||||
x: h.x,
|
||||
y: h.y,
|
||||
op: dokuOpakligi(h.x, h.y),
|
||||
ton: s === undefined ? "doku" : "vurgu",
|
||||
// İlk boyamada (dongu 0) gecikme yok: resim hazır gelir
|
||||
gecikme: s !== undefined && dongu > 0 ? s * 42 : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sahne 2 — Geçen yılın puanı yetmiyor */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// Yıl yıl çubuk boyları; ilk altısı eski durağan resim (5·3·6·4·7·4).
|
||||
const BOYLAR = [5, 3, 6, 4, 7, 4, 6, 3, 5, 2, 7];
|
||||
const GORUNEN = 6;
|
||||
// Görünen altı yuva + solda çıkan + sağda sırasını bekleyen = 8 çubuk döner.
|
||||
const CUBUK = GORUNEN + 2;
|
||||
const EN_BOY = 7;
|
||||
|
||||
function yillarSahnesi(dongu: number): MorfPiksel[] {
|
||||
const pikseller: MorfPiksel[] = [];
|
||||
for (let b = 0; b < CUBUK; b++) {
|
||||
// Bu çubuğun şu an taşıdığı yıl: dongu-1 … dongu+6 aralığında, b ile denk
|
||||
const yil = dongu - 1 + ((((b - (dongu - 1)) % CUBUK) + CUBUK) % CUBUK);
|
||||
const yuva = yil - dongu; // -1 (çıktı) … 6 (bekliyor)
|
||||
const sahnede = yuva >= 0 && yuva < GORUNEN;
|
||||
const boy = BOYLAR[((yil % BOYLAR.length) + BOYLAR.length) % BOYLAR.length];
|
||||
for (let c = 0; c < 2; c++) {
|
||||
for (let r = 0; r < EN_BOY; r++) {
|
||||
pikseller.push({
|
||||
id: `${b}-${c}-${r}`,
|
||||
x: 1 + yuva * 4 + c,
|
||||
y: SATIR - 1 - r,
|
||||
op: dokuOpakligi(b * 2 + c, r),
|
||||
ton:
|
||||
!sahnede || r >= boy
|
||||
? "gizli"
|
||||
: // Yalnız en yeni yılın tepesi turuncu (bu yılın tabanı)
|
||||
yuva === GORUNEN - 1 && r === boy - 1
|
||||
? "vurgu"
|
||||
: "doku",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return pikseller;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sahne 3 — Danışman lüks haline geldi */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const BASAMAK = 8;
|
||||
|
||||
/** `adim`: turuncu tepenin durduğu basamak (0 en alt, 7 en üst = eski resim). */
|
||||
function fiyatSahnesi(adim: number): MorfPiksel[] {
|
||||
const pikseller: MorfPiksel[] = [];
|
||||
for (let k = 0; k < BASAMAK; k++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const x = k * 3 + c;
|
||||
const ustY = SATIR - 1 - k;
|
||||
// Basamağın üstü: tepe buradan geçtikten sonra dolu doku
|
||||
pikseller.push({
|
||||
id: `u-${k}-${c}`,
|
||||
x,
|
||||
y: ustY,
|
||||
op: dokuOpakligi(x, ustY),
|
||||
ton: k < adim ? "doku" : "gizli",
|
||||
});
|
||||
// Basamağın altındaki silik dolgu
|
||||
for (let r = 1; r <= k; r++) {
|
||||
pikseller.push({
|
||||
id: `d-${k}-${c}-${r}`,
|
||||
x,
|
||||
y: ustY + r,
|
||||
op: dokuOpakligi(x, ustY + r),
|
||||
ton: k <= adim ? "silik" : "gizli",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Turuncu tepe: üç piksel, basamaktan basamağa süzülür
|
||||
for (let c = 0; c < 3; c++) {
|
||||
pikseller.push({
|
||||
id: `tepe-${c}`,
|
||||
x: adim * 3 + c,
|
||||
y: SATIR - 1 - adim,
|
||||
op: 0.3,
|
||||
ton: "vurgu",
|
||||
});
|
||||
}
|
||||
return pikseller;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const SAHNE: Record<
|
||||
SorunSahnesiTuru,
|
||||
{
|
||||
adimSayisi: number;
|
||||
adimMs: number;
|
||||
/** Kayma süresi (sn); verilmezse ilkelin varsayılanı */
|
||||
sure?: number;
|
||||
ciz: (dongu: number, adim: number) => MorfPiksel[];
|
||||
}
|
||||
> = {
|
||||
// Önce hepsi soğur (vurgunun soğuması 1,2 sn), sonra yeni 24 ısınır
|
||||
ihtimal: { adimSayisi: 2, adimMs: 1100, ciz: ihtimalSahnesi },
|
||||
yillar: { adimSayisi: 1, adimMs: 0, ciz: (dongu) => yillarSahnesi(dongu) },
|
||||
fiyat: {
|
||||
adimSayisi: BASAMAK,
|
||||
adimMs: 300,
|
||||
sure: 0.3,
|
||||
ciz: (_dongu, adim) => fiyatSahnesi(adim),
|
||||
},
|
||||
};
|
||||
|
||||
export function SorunSahnesi({
|
||||
tur,
|
||||
sira,
|
||||
}: {
|
||||
tur: SorunSahnesiTuru;
|
||||
/** Kartın sırası (0, 1, 2): sahneler bu sırayla konuşur. */
|
||||
sira: number;
|
||||
}) {
|
||||
const azalt = useReducedMotion();
|
||||
const kapRef = useRef<HTMLDivElement>(null);
|
||||
const sahne = SAHNE[tur];
|
||||
// Başlangıç (sunucu HTML'i, hareketi azalt): eski durağan resim = son adım.
|
||||
const [an, setAn] = useState({ dongu: 0, adim: sahne.adimSayisi - 1 });
|
||||
|
||||
useEffect(() => {
|
||||
const kap = kapRef.current;
|
||||
if (azalt || !kap || typeof IntersectionObserver === "undefined") return;
|
||||
|
||||
let zamanlayicilar: number[] = [];
|
||||
const kayit: Kayit = {
|
||||
sira,
|
||||
gorunur: false,
|
||||
sonBaslangic: -Infinity,
|
||||
oynat: () => {
|
||||
zamanlayicilar.forEach((z) => window.clearTimeout(z));
|
||||
zamanlayicilar = [];
|
||||
setAn((o) => ({ dongu: o.dongu + 1, adim: 0 }));
|
||||
for (let a = 1; a < sahne.adimSayisi; a++) {
|
||||
zamanlayicilar.push(
|
||||
window.setTimeout(
|
||||
() => setAn((o) => ({ ...o, adim: a })),
|
||||
a * sahne.adimMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
kayitlar.add(kayit);
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
([k]) => {
|
||||
kayit.gorunur = k.isIntersecting;
|
||||
saatiAyarla();
|
||||
},
|
||||
{ threshold: 0.6 },
|
||||
);
|
||||
io.observe(kap);
|
||||
|
||||
// Bakana cevap: karta imleç gelince ya da dokununca sahne hemen oynar.
|
||||
const kart = kap.closest<HTMLElement>("[data-sahne-karti]") ?? kap;
|
||||
const elle = () => {
|
||||
const simdi = performance.now();
|
||||
if (simdi - kayit.sonBaslangic < ELLE_ARASI) return;
|
||||
sozVer(kayit, simdi);
|
||||
};
|
||||
kart.addEventListener("pointerenter", elle);
|
||||
|
||||
return () => {
|
||||
kart.removeEventListener("pointerenter", elle);
|
||||
io.disconnect();
|
||||
zamanlayicilar.forEach((z) => window.clearTimeout(z));
|
||||
kayitlar.delete(kayit);
|
||||
saatiAyarla();
|
||||
};
|
||||
}, [azalt, sira, sahne]);
|
||||
|
||||
const pikseller = useMemo(
|
||||
() => sahne.ciz(an.dongu, an.adim),
|
||||
[sahne, an.dongu, an.adim],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={kapRef}>
|
||||
<PikselMorf
|
||||
pikseller={pikseller}
|
||||
sutun={SUTUN}
|
||||
satir={SATIR}
|
||||
style={
|
||||
sahne.sure
|
||||
? ({ "--morf-sure": `${sahne.sure}s` } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,10 +118,12 @@ function ArkaKare({
|
||||
// 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);
|
||||
// Odak dışı: kareler bulanık durur, net olan yalnız haplardır. Bulanıklık
|
||||
// karenin kendisindedir (kapta değil) — kayarken yeniden çizilmez.
|
||||
return (
|
||||
<motion.span
|
||||
className={cn(
|
||||
"absolute rounded",
|
||||
"absolute rounded blur-xs",
|
||||
kare.sicak ? "bg-orange-500/70" : "bg-primary/15",
|
||||
)}
|
||||
style={{ left: `${kare.x}%`, top: `${kare.y}%`, width: kare.boy, height: kare.boy, x, y }}
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
*
|
||||
* İmleç gelmese de rastgele pikseller tek tük, seyrek aralıklarla turuncuya
|
||||
* ısınıp söner (usePixelHeat `kirpisma`); "hareketi azalt"ta kapalıdır.
|
||||
*
|
||||
* Kıyının altındaki serpinti canlıdır: piksellerin bir kısmı yerinden kopup
|
||||
* aşağı düşer ve yeniden belirir (CSS, `.kt-serpinti`). Telefonda imleç
|
||||
* olmadığı için haritanın hareketi budur.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
@@ -137,8 +141,16 @@ const MAP_MARKUP = (() => {
|
||||
const op =
|
||||
Math.round(opacityFor(x, y) * (1 - d / (SCATTER_DEPTH + 1)) * 100) /
|
||||
100;
|
||||
// Serpintinin üçte biri damlar (globals.css `.kt-serpinti`): piksel
|
||||
// yerinden kopup 1–3 hücre düşerken söner, sonra yerinde yeniden
|
||||
// belirir. Harita bitmez, dökülür (DESIGN.md §11.1 V2); süre ve faz
|
||||
// pikselden piksele farklı ki yağmur gibi sıralı düşmesin.
|
||||
const damla =
|
||||
hash(x, y, 57) > 0.67
|
||||
? ` class="kt-serpinti" style="--d:${10 + Math.round(hash(x, y, 61) * 20)};animation-duration:${(4.5 + hash(x, y, 67) * 4).toFixed(2)}s;animation-delay:-${(hash(x, y, 71) * 8).toFixed(2)}s"`
|
||||
: "";
|
||||
parts.push(
|
||||
`<rect data-px data-cx="${x * CELL + CELL / 2}" data-cy="${y * CELL + CELL / 2}" data-op="${op}" ${rectAttrs(x, y)} fill="currentColor" fill-opacity="${op}"/>`,
|
||||
`<rect data-px data-cx="${x * CELL + CELL / 2}" data-cy="${y * CELL + CELL / 2}" data-op="${op}" ${rectAttrs(x, y)} fill="currentColor" fill-opacity="${op}"${damla}/>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
//
|
||||
// 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").
|
||||
// dizilir (yerleşim app/page.tsx'te).
|
||||
//
|
||||
// Ortadaki işaret danışmanın kendisidir (Bilal, 11 Eki 2026): bir süre
|
||||
// DanışmanlaKonuş başlığındaki gibi nabız atar (danışman düşünüyor), sonra
|
||||
// üç kare girdilerden çıkan şeye dönüşür, durur ve logoya toplanır — sırayla
|
||||
// liste, trend çubukları, sohbet balonları (`AnimasyonluMark` `bicimler`,
|
||||
// §4.34). Eski "rota" (kareler birbirinin yerine süzülür) kaldırıldı.
|
||||
//
|
||||
// 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
|
||||
@@ -18,9 +22,14 @@
|
||||
|
||||
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 { AnimasyonluMark, type Bicim } from "@/components/animasyonlu-mark";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Girdilerden çıkanlar, paketin verdiği sırayla: 24'lük liste, her tercihin
|
||||
// trend analizi, danışmana soru. Bileşen dışında sabit: dizi değişirse sıra
|
||||
// başa döner (animasyonlu-mark.tsx).
|
||||
const CIKTILAR: readonly Bicim[] = ["deste", "trend", "sohbet"];
|
||||
|
||||
// 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" },
|
||||
@@ -40,9 +49,15 @@ const IC_BOSLUK = 15.5;
|
||||
/** Akan karenin kenarı (hero noktasının viewBox karşılığı). */
|
||||
const KARE = 2.4;
|
||||
|
||||
// Üç ondalığa yuvarlanır: sunucu (Node) ile tarayıcının sin/cos'u son
|
||||
// basamakta ayrışıyor, yuvarlanmazsa hidrasyon uyuşmazlığı çıkıyor.
|
||||
const yuvarla = (v: number) => Math.round(v * 1000) / 1000;
|
||||
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 };
|
||||
return {
|
||||
x: yuvarla(50 + Math.cos(rad) * r),
|
||||
y: yuvarla(50 + Math.sin(rad) * r),
|
||||
};
|
||||
};
|
||||
|
||||
export function VeriAkisi({ className }: { className?: string }) {
|
||||
@@ -143,9 +158,14 @@ export function VeriAkisi({ className }: { className?: string }) {
|
||||
})}
|
||||
|
||||
<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" />
|
||||
{/* Danışmanın nabzı, aralıklarla çıktıya dönüşür (liste → trend →
|
||||
sohbet) ve logoya toplanır; yalnız ekrandayken oynar. */}
|
||||
<AnimasyonluMark
|
||||
oynat={oynuyor}
|
||||
varyant="nabiz"
|
||||
bicimler={CIKTILAR}
|
||||
className="size-14"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption className="mt-8 text-center text-sm leading-relaxed text-slate-500">
|
||||
|
||||
@@ -274,11 +274,7 @@ export function ListeUretici({
|
||||
? "Liste üretimi 3 kredi. +30 kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin."
|
||||
: "Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam liste, PDF ve revizyon hakları birlikte gelir."}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Button asChild size="lg" variant="cta" className="mt-6">
|
||||
<Link href="/paket?kaynak=kredi_bitti">
|
||||
{hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
@@ -301,8 +297,9 @@ export function ListeUretici({
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="cta"
|
||||
onClick={() => profil && void uret(profil)}
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
className="mt-6"
|
||||
>
|
||||
Tekrar dene
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
@@ -354,9 +351,10 @@ function SunucuYokEkrani() {
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="cta"
|
||||
onClick={() => void dene()}
|
||||
disabled={deniyor}
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
className="mt-6"
|
||||
>
|
||||
{deniyor ? "Kontrol ediliyor…" : "Listemi aç"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
@@ -381,11 +379,7 @@ function SuruyorEkrani() {
|
||||
Bunu başka bir sekmede başlatmışsın. İki kez kurmayalım — kredin bu
|
||||
denemede harcanmadı. Hazır olunca listende görünecek.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Button asChild size="lg" variant="cta" className="mt-6">
|
||||
<Link href="/listem">
|
||||
Listeme git
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
@@ -417,9 +411,10 @@ function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
|
||||
tıkalı olduğundan router.refresh burada işe yaramaz. replace:
|
||||
geri tuşu ?uret=1'e dönüp üretimi yeniden başlatmasın. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ikincil"
|
||||
size="lg"
|
||||
onClick={() => window.location.replace("/listem")}
|
||||
className="mt-2 cursor-pointer"
|
||||
className="mt-2"
|
||||
>
|
||||
Listemi kontrol et
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
useTercihProfili,
|
||||
} from "@/features/liste/hooks/use-tercih-profili";
|
||||
|
||||
// Her iki dalın butonu da aynı CTA: sınıf seti tek yerde dursun.
|
||||
const CTA_SINIF = "mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600";
|
||||
// Her iki dalın butonu da kanonik CTA (`variant="cta"`, §4.1); burada yalnız yerleşim.
|
||||
const CTA_SINIF = "mt-6";
|
||||
|
||||
/**
|
||||
* /listem boş hâlinin metni + CTA'sı: seçimleri zaten hazır olan adayı
|
||||
@@ -31,7 +31,7 @@ export function ListemBosCta() {
|
||||
</p>
|
||||
<PagePixelDivider seed={67} className="mx-auto mt-6" />
|
||||
{profil ? (
|
||||
<Button asChild size="lg" className={CTA_SINIF}>
|
||||
<Button asChild size="lg" variant="cta" className={CTA_SINIF}>
|
||||
<Link href="/listem?uret=1&kaynak=bos_durum">
|
||||
Yapay Zeka listeni kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
@@ -41,6 +41,7 @@ export function ListemBosCta() {
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="cta"
|
||||
className={CTA_SINIF}
|
||||
onClick={() => profilToplamaAc("sihirbaz", true, "listem_bos")}
|
||||
>
|
||||
|
||||
@@ -58,9 +58,9 @@ const SUTUN_ARALIGI = 24;
|
||||
const kolonTabani = () =>
|
||||
window.matchMedia("(min-width: 1280px)").matches ? 440 : 380;
|
||||
|
||||
// Başlık satırındaki ikincil düğmeler: 44 px, hover tek turuncu (§4.1, §2.1)
|
||||
const IKINCIL_BUTON =
|
||||
"h-11 cursor-pointer px-4 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]";
|
||||
// Başlık satırındaki ikincil düğmeler: `variant="ikincil"` (hover ve basma
|
||||
// tepkisi varyanttan, §4.1); burada yalnız 44 px boy ve yatay boşluk kalır.
|
||||
const IKINCIL_BUTON = "h-11 px-4";
|
||||
|
||||
export function ListemGovde({
|
||||
rapor,
|
||||
@@ -293,7 +293,10 @@ export function ListemGovde({
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{/* `shrink-0` yok: üç düğme 375 px'te 343 px'lik sütuna sığmıyor,
|
||||
grup küçülemeyince PDF sağdan taşıyordu (10 Eki 2026, ölçüldü);
|
||||
grup daralır, düğmeler alt satıra sarar. */}
|
||||
<div className="flex max-w-full flex-wrap gap-2">
|
||||
{/* Tercih düzenleme her kullanıcıya açık: kapı (layout'ta monte
|
||||
TercihProfiliKapisi) profili günceller, /listem'e döner.
|
||||
Profil kayıtlı listeden ayrıldığı anda bu buton "Tercihlerin
|
||||
@@ -316,7 +319,7 @@ export function ListemGovde({
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ikincil"
|
||||
onClick={profilDuzenlemeyiAc}
|
||||
className={IKINCIL_BUTON}
|
||||
>
|
||||
@@ -332,7 +335,7 @@ export function ListemGovde({
|
||||
yeniden kurar. Sayı sınırı yok (7 Eki 2026). */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ikincil"
|
||||
onClick={() => sohbetiOdakla("Listemde şunu değiştir: ")}
|
||||
className={IKINCIL_BUTON}
|
||||
>
|
||||
@@ -341,7 +344,7 @@ export function ListemGovde({
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
variant="ikincil"
|
||||
className={IKINCIL_BUTON}
|
||||
>
|
||||
<Link href="/rapor/yazdir" target="_blank">
|
||||
@@ -372,8 +375,10 @@ export function ListemGovde({
|
||||
className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
|
||||
<Lock className="size-4" aria-hidden />
|
||||
{/* §4.4 ikon kutusu (fark modalındaki kutuyla aynı dil);
|
||||
orange-100/700 §2.1'in "tek turuncu" kuralı dışındaydı */}
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Lock className="size-5 text-primary" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-heading font-bold text-slate-900">
|
||||
@@ -389,11 +394,13 @@ export function ListemGovde({
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
size="lg"
|
||||
variant="cta"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
>
|
||||
<Link href="/paket?kaynak=kilit">
|
||||
Analizleri aç — {paketFiyati} TL
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -489,7 +496,7 @@ export function ListemGovde({
|
||||
{[
|
||||
"24 tercihin tamamında kişisel risk analizi",
|
||||
"Her program için kısa, gerekçeli Yapay Zeka yorumu",
|
||||
"2 liste revizyonu ve paylaşılabilir PDF",
|
||||
"Sınırsız liste revizyonu ve paylaşılabilir PDF",
|
||||
`${paketKredi} Yapay Zeka danışman sorusu`,
|
||||
].map((ozellik) => (
|
||||
<li key={ozellik} className="flex items-start gap-2.5">
|
||||
@@ -509,14 +516,10 @@ export function ListemGovde({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-5 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<Button asChild size="lg" variant="cta" className="mt-5 w-full">
|
||||
<Link href="/paket?kaynak=kilit">
|
||||
Kişisel analizi aç
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Check } from "lucide-react";
|
||||
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
|
||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
||||
import {
|
||||
PROFIL_CEREZ_ADI,
|
||||
@@ -69,7 +70,9 @@ export async function ListemIcerik({
|
||||
if (!raporVar) {
|
||||
const bosDurum = (
|
||||
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
|
||||
<Sparkles className="size-10 text-primary" aria-hidden />
|
||||
{/* Boş durumun tepesinde yıldız değil marka işareti (§7 "AI izi";
|
||||
§11.2 "önce imza, sonra içerik"); durağan. */}
|
||||
<AnimasyonluMark oynat={false} className="size-10" />
|
||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
||||
Henüz Yapay Zeka listen yok
|
||||
</h1>
|
||||
@@ -148,7 +151,7 @@ export async function ListemIcerik({
|
||||
role="status"
|
||||
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
|
||||
>
|
||||
<Sparkles className="size-4 shrink-0" aria-hidden />
|
||||
<Check className="size-4 shrink-0" aria-hidden />
|
||||
Paketin aktif — listendeki tercihlerin tamamı, gerekçeler, riskler ve
|
||||
PDF artık açık.
|
||||
</div>
|
||||
|
||||
@@ -82,6 +82,7 @@ export function RaporSatirDetayi({
|
||||
riskNotu,
|
||||
trendOzeti,
|
||||
oneCikan,
|
||||
sikistir,
|
||||
}: {
|
||||
dilim: string;
|
||||
risk: RiskSeviyesi;
|
||||
@@ -91,11 +92,18 @@ export function RaporSatirDetayi({
|
||||
trendOzeti: string;
|
||||
/** Koddan hesaplanan "öne çıkan" veriler (uyum puanı); yoksa satır çizilmez */
|
||||
oneCikan?: string[];
|
||||
/** Sıkı ölçü (tadımlık, dar sütun): metin bir kademe küçük, kutu dolgusu
|
||||
* bir kademe az. Aynı iskelet, aynı etiketler — yalnız ölçü değişir. */
|
||||
sikistir?: boolean;
|
||||
}) {
|
||||
const stil = RISK_STIL[risk];
|
||||
// Risk etiketi yalnızca dilimin varsayılanından farklıysa yazılır; yoksa
|
||||
// üçüncü dilimde başlık "Güvenli · Güvenli" olurdu (bkz. risk.ts).
|
||||
const farkliRisk = dilimdenFarkliRiskEtiketi(dilim, risk);
|
||||
const kutu = cn(
|
||||
"rounded-xl border border-slate-200 bg-card",
|
||||
sikistir ? "p-3" : "p-4",
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-slate-500">
|
||||
@@ -108,8 +116,13 @@ export function RaporSatirDetayi({
|
||||
<span>· {UNITUR_ETIKET[unitur]?.cip ?? unitur}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="grid gap-3 text-base leading-relaxed sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-slate-200 bg-card p-4">
|
||||
<dl
|
||||
className={cn(
|
||||
"grid leading-relaxed sm:grid-cols-2",
|
||||
sikistir ? "gap-2 text-sm" : "gap-3 text-base",
|
||||
)}
|
||||
>
|
||||
<div className={kutu}>
|
||||
<dt className="text-sm font-semibold text-slate-900">
|
||||
Neden listede?
|
||||
</dt>
|
||||
@@ -121,7 +134,7 @@ export function RaporSatirDetayi({
|
||||
</dd>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-card p-4">
|
||||
<div className={kutu}>
|
||||
<dt className="text-sm font-semibold text-slate-900">
|
||||
Risk ve Yapay Zeka yorumu
|
||||
</dt>
|
||||
|
||||
@@ -5,20 +5,21 @@ import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, type ChatTransport, type UIMessage } from "ai";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { animate, useReducedMotion } from "motion/react";
|
||||
import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Coins,
|
||||
Info,
|
||||
Lock,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { YAY } from "@/lib/hareket";
|
||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
||||
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
|
||||
import { AracPilleri, aracPilleri } from "./arac-pilleri";
|
||||
@@ -168,8 +169,10 @@ function cevapAnahtari(mesajlar: UIMessage[], i: number): string {
|
||||
|
||||
// Araç adı → öğrenci dili eşlemesi ve "…bakıyorum" hapları arac-pilleri.tsx'te.
|
||||
|
||||
/** Panelin boyut geçişi: sekmesiz yay (DESIGN.md §6); listem-govde ızgarası aynı yayı kullanır. */
|
||||
export const SOHBET_GENISLEME_GECISI = { type: "spring", bounce: 0, duration: 0.35 } as const;
|
||||
/** Panelin boyut geçişi: sihirbaz adım paneliyle AYNI yay (`YAY.adim`,
|
||||
* adim-gecisi.ts; Bilal, 10 Eki 2026: "danışmanla konuş'taki motion wizard
|
||||
* ile aynı olsun"). listem-govde ızgarası ve sayfa kaydırması da bunu kullanır. */
|
||||
export const SOHBET_GENISLEME_GECISI = YAY.adim;
|
||||
|
||||
/**
|
||||
* Genişletilmiş panel üstten `top-28` (7rem; listem sütununun sticky ofseti)
|
||||
@@ -198,6 +201,28 @@ const GENIS_EN_AZ_PX = 380;
|
||||
/** /dev/sohbet gibi sticky'siz yerlerde de aynı hesap; sabit yükseklik istenirse `genisYukseklik`. */
|
||||
const GENIS_YUKSEKLIK = "";
|
||||
|
||||
/**
|
||||
* Panel başlığındaki ikon düğmeleri (genişlet / bilgi / temizle): görünür
|
||||
* 32 px (paneldeki h-8 çip ve araç haplarıyla aynı boy), dokunma alanı
|
||||
* görünmez taşmayla 44 px (§8) — eski `size-7` hem 44'ün altındaydı hem ikonu
|
||||
* çip boyutunda bırakıyordu. 36 px (`size-9`) denendi (10 Eki 2026): 375 px'te
|
||||
* başlık "DanışmanlaK…" diye kırpılıyordu; 32 px'te üç düğme + kredi hapı +
|
||||
* başlık yan yana sığıyor.
|
||||
*/
|
||||
const BASLIK_DUGMESI =
|
||||
"relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,color,transform] duration-150 before:absolute before:-inset-1.5 before:content-[''] hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96] motion-reduce:active:scale-100";
|
||||
|
||||
/**
|
||||
* Kredi hapı: üst çubuktaki kredi hapının sınıf dizisi (user-nav.tsx; §11.1 V4
|
||||
* "çubuk kullanıcının durumudur" — aynı durum burada da aynı biçimde durur),
|
||||
* yalnız boy panelin ölçeğinde (h-8, başlık düğmeleri ve çiplerle aynı).
|
||||
* Eski amber hap bu üründe risk dilinin "az riskli"
|
||||
* tonuydu (tercih-degisti-modali.tsx notu), kredi için yanlış sinyaldi.
|
||||
* "kredi" sözcüğü yalnız sm'den itibaren: 375 px'te başlık kırpılmasın.
|
||||
*/
|
||||
const KREDI_HAPI =
|
||||
"inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-card px-2.5 text-xs font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 sm:px-3";
|
||||
|
||||
type Red = { status: number; error?: string };
|
||||
|
||||
/**
|
||||
@@ -725,31 +750,35 @@ export function SohbetClient({
|
||||
Temizle butonu ikon-only — metinli hâli başlıkla yarışıp taşıyordu. */}
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3">
|
||||
<DanismanBaslik />
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={genisligiDegistir}
|
||||
aria-pressed={genis}
|
||||
aria-label={genis ? "Sohbeti daralt" : "Sohbeti genişlet"}
|
||||
title={genis ? "Daralt" : "Genişlet"}
|
||||
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
|
||||
className={BASLIK_DUGMESI}
|
||||
>
|
||||
{genis ? (
|
||||
<Minimize2 className="size-3.5" aria-hidden />
|
||||
<Minimize2 className="size-4" aria-hidden />
|
||||
) : (
|
||||
<Maximize2 className="size-3.5" aria-hidden />
|
||||
<Maximize2 className="size-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
{bildikleri?.length ? (
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Telefonda (sm altı) gizli: ipucu hover/odakla açılır,
|
||||
dokunmatikte zaten açılmıyordu (ölü düğme) ve 375 px'te
|
||||
başlığı kırpıyordu. Dokunmayla açılan karşılığı (popover)
|
||||
ayrı iş — raporda. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Danışmanın bildikleri"
|
||||
className="inline-flex size-7 cursor-help items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-800"
|
||||
className={cn(BASLIK_DUGMESI, "cursor-help max-sm:hidden")}
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden />
|
||||
<Info className="size-4" aria-hidden />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
@@ -778,19 +807,25 @@ export function SohbetClient({
|
||||
onClick={() => setTemizleAcik(true)}
|
||||
disabled={bekliyor || temizleniyor}
|
||||
aria-label="Sohbeti temizle"
|
||||
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className={cn(
|
||||
BASLIK_DUGMESI,
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
title="Sohbet geçmişini sil"
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden />
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
<Link
|
||||
href="/paket?kaynak=danisman_rozet"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-100"
|
||||
className={KREDI_HAPI}
|
||||
title="Kredilerin — yüklemek için tıkla"
|
||||
>
|
||||
<Coins className="size-3.5" aria-hidden />
|
||||
<SayiRulo deger={kredi} /> kredi
|
||||
<Coins className="size-3.5 text-amber-500" aria-hidden />
|
||||
<span className="tabular-nums">
|
||||
<SayiRulo deger={kredi} />
|
||||
</span>
|
||||
<span className="hidden sm:inline">kredi</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -804,26 +839,39 @@ export function SohbetClient({
|
||||
<span className="text-xs font-semibold text-slate-500">
|
||||
Kaydettiklerim
|
||||
</span>
|
||||
{profil.map((s) => (
|
||||
// Etkileşimli çip (§4.17 kalıbı, kompakt h-8): rozet değil.
|
||||
<span
|
||||
key={s.alan}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-full border border-slate-200 bg-card pr-1 pl-3 text-xs font-semibold text-slate-700"
|
||||
>
|
||||
{s.metin}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => profilAlaniSil(s.alan)}
|
||||
aria-label={`Sil: ${s.metin}`}
|
||||
className="inline-flex size-6 cursor-pointer items-center justify-center rounded-full text-slate-500 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-orange-500 active:scale-[0.96]"
|
||||
{/* Çip silinince yerinden küçülerek söner, komşular kayarak yerini
|
||||
kapatır (§11.2 "değişimi göster"; anında kaybolan çip sessiz
|
||||
eylemdi). Hareketi azalt'ta yalnız opaklık. */}
|
||||
<AnimatePresence initial={false}>
|
||||
{profil.map((s) => (
|
||||
// Etkileşimli çip (§4.17 kalıbı, kompakt h-8): rozet değil.
|
||||
<motion.span
|
||||
key={s.alan}
|
||||
layout={!hareketAz}
|
||||
initial={{ opacity: 0, scale: hareketAz ? 1 : 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: hareketAz ? 1 : 0.9 }}
|
||||
transition={YAY.varsayilan}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-full border border-slate-200 bg-card pr-1 pl-3 text-xs font-semibold text-slate-700"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{s.metin}
|
||||
{/* Görünür 24 px, dokunma alanı taşmayla 44 px (§8) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => profilAlaniSil(s.alan)}
|
||||
aria-label={`Sil: ${s.metin}`}
|
||||
className="relative inline-flex size-6 cursor-pointer items-center justify-center rounded-full text-slate-500 transition-[background-color,color,transform] duration-150 before:absolute before:-inset-2.5 before:content-[''] hover:bg-slate-100 hover:text-orange-500 active:scale-[0.96] motion-reduce:active:scale-100"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Onay modalı: yüzey ve başlık DialogContent/DialogTitle varsayılanı
|
||||
(§4.4 sade kutu, §3 h3); aksiyonlar tek kaynak varyantlar (§4.1). */}
|
||||
<Dialog open={temizleAcik} onOpenChange={setTemizleAcik}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -836,15 +884,16 @@ export function SohbetClient({
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
variant="ikincil"
|
||||
size="lg"
|
||||
onClick={() => setTemizleAcik(false)}
|
||||
disabled={temizleniyor}
|
||||
>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
variant="cta"
|
||||
size="lg"
|
||||
onClick={sohbetiTemizle}
|
||||
disabled={temizleniyor}
|
||||
>
|
||||
@@ -920,9 +969,12 @@ export function SohbetClient({
|
||||
>
|
||||
{!ozet ? (
|
||||
<>
|
||||
<Sparkles
|
||||
className="mx-auto size-7 text-primary"
|
||||
aria-hidden
|
||||
{/* Boş durumda yıldız değil marka işareti: önce imza,
|
||||
sonra içerik (§11.2); durağan — hareket "düşünüyor" demek. */}
|
||||
<AnimasyonluMark
|
||||
oynat={false}
|
||||
renk="turuncu"
|
||||
className="mx-auto size-8"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-heading font-bold">
|
||||
@@ -1058,16 +1110,17 @@ export function SohbetClient({
|
||||
|
||||
<div className="border-t border-slate-100 p-3">
|
||||
{krediYok ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||||
<p className="text-sm font-medium text-amber-800">
|
||||
// Uyarı kutusu (§2 tablo: amber bilgilendirme notu) + kanonik CTA;
|
||||
// eski h-7 buton 44 px'in altındaydı (§8).
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4">
|
||||
<p className="text-sm font-medium text-amber-900">
|
||||
Kredin bitti — sohbete devam etmek için kredi yükle.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket?kaynak=danisman_kredi_bitti">Kredi yükle</Link>
|
||||
<Button asChild size="lg" variant="cta" className="w-full">
|
||||
<Link href="/paket?kaynak=danisman_kredi_bitti">
|
||||
Kredi yükle
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1102,9 +1155,9 @@ export function SohbetClient({
|
||||
<PromptInputAction tooltip="Gönder (Enter)">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="cta"
|
||||
onClick={() => gonder(girdi)}
|
||||
disabled={bekliyor || !girdi.trim()}
|
||||
className="size-9 cursor-pointer rounded-full bg-orange-500 text-white hover:bg-orange-600"
|
||||
aria-label="Gönder"
|
||||
>
|
||||
{bekliyor ? <DotsLoader size="sm" /> : <ArrowUp className="size-4" aria-hidden />}
|
||||
|
||||
@@ -7,10 +7,17 @@
|
||||
// SSR "genel" satırı render eder (prop), mount sonrası sihirbaz kategorisi
|
||||
// varsa /api/tadimlik'ten kategoriye özel satır çekilip yerinde değiştirilir
|
||||
// (seçim sunucuda kalır). Havuzda içerik yoksa hiç render edilmez.
|
||||
//
|
||||
// Yerleşim (10 Eki 2026, Bilal: "tadımlık çok büyük yer kaplıyor, pazarlama
|
||||
// tarafı eksik"): tek kart, iki sütun — solda öğrencinin kendi sırasına bağlı
|
||||
// başlık + üç kanıt satırı + CTA (PikselCtaYuzeyi'nin "metin solda, kreatif
|
||||
// sağda" kurgusu), sağda örnek satır. Telefonda sıra: başlık → örnek → CTA.
|
||||
// Kutu içinde kutu yok: örnek alanı kartın kendi bandı (border + bg-slate-50),
|
||||
// ayrı yuvarlatılmış iç kutu değil. Ölçüm 1280 px: 599 → ~370 px.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { ArrowRight, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
@@ -23,9 +30,10 @@ import {
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { dilimdenRisk, riskHesapla } from "@/lib/risk";
|
||||
import { DILIM_ETIKET, dilimdenRisk, riskHesapla } from "@/lib/risk";
|
||||
import { ilSayfaHref, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { VERI_ARALIGI, VERI_YIL_SAYISI } from "@/lib/veri-yillari";
|
||||
import { TurCipi } from "@/components/tur-cipi";
|
||||
import {
|
||||
SATIR_ALT_AD,
|
||||
@@ -47,6 +55,16 @@ function tipeUyar(program: TadimlikSatir["program"], tip: TadimlikTip): boolean
|
||||
return program.unitur != null && program.unitur !== "DEVLET";
|
||||
}
|
||||
|
||||
// Üç kanıt satırı: marka mesaj mimarisinin üç sütunu (docs/ekip-raporlari/
|
||||
// 2026-10-07/founder-arastirma-06 §2: veri, yöntem açıklığı, dürüst vaat).
|
||||
// Rakamlar tek kaynaktan: dilim adları risk.ts, veri yılları veri-yillari.ts.
|
||||
// Vaat cümlesi yok (§10, §7). Her satır 18 rem sütunda tek satıra sığacak
|
||||
// kadar kısa tutulur (sütun 21 rem, ölçüm 1280 px) — uzatınca kart yeniden uzar.
|
||||
const KANITLAR: readonly string[] = [
|
||||
`3 risk dilimi: ${Object.values(DILIM_ETIKET).join(" · ")}`,
|
||||
`${VERI_ARALIGI} YÖK Atlas verisi, ${VERI_YIL_SAYISI} yıl`,
|
||||
];
|
||||
|
||||
export function TadimlikSatiri({
|
||||
sira,
|
||||
tur,
|
||||
@@ -58,6 +76,12 @@ export function TadimlikSatiri({
|
||||
ilk: TadimlikSatir | null;
|
||||
}) {
|
||||
const [satir, setSatir] = useState<TadimlikSatir | null>(ilk);
|
||||
// Gösterilen satır sihirbaz seçimine (alan / devlet-vakıf) göre mi seçildi?
|
||||
// Etiket bunu söyler (DESIGN.md §11.2 md. 1: kullanıcının durumu yansır).
|
||||
const [secimeOzel, setSecimeOzel] = useState(false);
|
||||
// Satır mount sonrası değiştiyse yeni satır blur çözülerek belirir
|
||||
// (§11.2 md. 6: değişimi göster); ilk boyada sahne yok (§11.2 md. 8).
|
||||
const [degisti, setDegisti] = useState(false);
|
||||
const yuklenenAnahtar = useRef(`${GENEL}|${GENEL}`);
|
||||
|
||||
// Sihirbaz kategorisi ve devlet/vakıf tercihi yalnızca localStorage'da
|
||||
@@ -78,6 +102,8 @@ export function TadimlikSatiri({
|
||||
yuklenenAnahtar.current = anahtar;
|
||||
if (slug === GENEL && tip === GENEL) {
|
||||
setSatir(ilk);
|
||||
setSecimeOzel(false);
|
||||
setDegisti(true);
|
||||
return;
|
||||
}
|
||||
// SSR "genel" satırı seçilen tipe uymuyorsa fetch bitene kadar gizle
|
||||
@@ -94,6 +120,8 @@ export function TadimlikSatiri({
|
||||
if (yuklenenAnahtar.current !== anahtar) return;
|
||||
if (veri?.tadimlik) {
|
||||
setSatir(veri.tadimlik);
|
||||
setSecimeOzel(true);
|
||||
setDegisti(true);
|
||||
} else if (tip !== GENEL) {
|
||||
// Uygun tipte satır yok: yanlış tip göstermektense gizle
|
||||
setSatir(null);
|
||||
@@ -128,97 +156,139 @@ export function TadimlikSatiri({
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(listemUretHref("tadimlik"))}`;
|
||||
|
||||
return (
|
||||
// /sonuc'ta artık ilk öğe sonuç başlığı (h1); tadımlık onun altında
|
||||
// (listesi olanda haritanın ve tablonun altında) mt-12 ile durur.
|
||||
// Tadımlığın render edilmediği durumlarda (girişli kullanıcı, boş havuz)
|
||||
// bu bileşen hiç dönmez.
|
||||
<section ref={gorunumRef} className="mt-12 min-w-0">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow>
|
||||
<h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl">
|
||||
Yapay zekâ listendeki her satırı böyle anlatır
|
||||
</h2>
|
||||
{/* "ilk üçü" = rapor-maske.ts içindeki ACIK_SATIR (3). O dosya
|
||||
`server-only`, bu dosya "use client" — sabit buraya import
|
||||
edilemiyor, sayı elle yazıldı. ACIK_SATIR değişirse bu cümle de
|
||||
aynı commit'te değişmeli. Cümle kasten "her satır bu formatta
|
||||
kurulur"u sınırlıyor: maske girişsiz/paketsiz kullanıcıya 3 satır
|
||||
açıyor, kalan 21'in gerekçe/risk/trend metni maskeli geliyor. */}
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
Sıralamana uygun bir programın gerekçesi, yerleşme riski ve taban
|
||||
trendi — 24 tercihlik listenin her satırı bu formatta kurulur, ilk
|
||||
üçü hemen açılır.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-slate-200 bg-card p-4 shadow-sm sm:p-6">
|
||||
{/* Rapor listesinin satırıyla aynı düzen (§4.15): ad sarar,
|
||||
üniversite adı altında, taban sıra sağda iri. */}
|
||||
<div className="flex items-start gap-3">
|
||||
<UniLogo ad={p.universite} boy="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={SATIR_AD_DUZ}>{p.isim}</span>
|
||||
<span className={SATIR_ALT_GRUP}>
|
||||
<span className={SATIR_ALT_AD}>
|
||||
{trBaslikDuzeni(p.universite)}
|
||||
</span>
|
||||
<span className={SATIR_META}>
|
||||
<TurCipi unitur={p.unitur} />
|
||||
{p.il ? (
|
||||
<Link
|
||||
href={ilSayfaHref(p.il)}
|
||||
className={cn("shrink-0", SATIR_IKINCIL_LINK)}
|
||||
title={`${trBaslikDuzeni(p.il)} üniversiteleri`}
|
||||
>
|
||||
{trBaslikDuzeni(p.il)}
|
||||
</Link>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden text-right md:inline-flex">
|
||||
<ProgramSiraGecmisi program={seri} />
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 text-base font-semibold tabular-nums text-slate-900">
|
||||
<ProgramTabanTrendi program={seri} />
|
||||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-xl bg-slate-50 px-4 py-5">
|
||||
<RaporSatirDetayi
|
||||
dilim={satir.dilim}
|
||||
risk={risk}
|
||||
unitur={p.unitur}
|
||||
gerekce={satir.gerekce}
|
||||
riskNotu={satir.riskNotu}
|
||||
trendOzeti={satir.trendOzeti}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-start gap-3 border-t border-slate-100 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-slate-600">
|
||||
Bu, 24 satırlık Yapay Zeka listenden yalnızca bir örnek.
|
||||
<span className="block text-xs text-slate-500">
|
||||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede ·
|
||||
Giriş sonrası liste hemen üretilir
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() => olay("giris_cta_tiklandi", { kaynak: "tadimlik" })}
|
||||
// /sonuc'ta ilk öğe sonuç başlığı (h1); tadımlık onun altında (listesi
|
||||
// olanda haritanın ve tablonun altında) durur. Tadımlığın render
|
||||
// edilmediği durumlarda (girişli kullanıcı, boş havuz) bileşen hiç dönmez.
|
||||
// data-tadimlik: QA/ölçüm kancası (yükseklik ölçümü), stil taşımaz.
|
||||
<section
|
||||
ref={gorunumRef}
|
||||
data-tadimlik
|
||||
aria-labelledby="tadimlik-baslik"
|
||||
className="mt-10 min-w-0"
|
||||
>
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm">
|
||||
<div className="grid lg:grid-cols-[minmax(0,21rem)_minmax(0,1fr)] lg:grid-rows-[1fr_auto]">
|
||||
{/* Sol üst: öğrencinin sırasına bağlı başlık + üç kanıt.
|
||||
Sayı kahraman (§1.1): sıra başlıkta mavi vurguyla. */}
|
||||
<div className="p-5 lg:pb-0">
|
||||
<SectionEyebrow>Yapay zekâ listesi</SectionEyebrow>
|
||||
<h2
|
||||
id="tadimlik-baslik"
|
||||
className="mt-4 font-heading text-2xl font-bold leading-tight text-balance text-slate-900"
|
||||
>
|
||||
{/* Buton metni listenin tamamını vaat etmez: maske girişsiz
|
||||
kullanıcıya 24 satırdan yalnız ilk birkaçının analizini
|
||||
açıyor (rapor-maske.ts), gerisi teslim edilmiyor. */}
|
||||
Kendi listemi oluştur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="text-primary">{sira.toLocaleString("tr-TR")}.</span>{" "}
|
||||
sıra için 24 tercih, her satırı böyle gerekçeli
|
||||
</h2>
|
||||
<ul className="mt-4 space-y-2 text-sm leading-snug text-slate-600">
|
||||
{KANITLAR.map((kanit) => (
|
||||
<li key={kanit} className="flex items-start gap-2">
|
||||
<Check className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden />
|
||||
<span>{kanit}</span>
|
||||
</li>
|
||||
))}
|
||||
{/* Üçüncü kanıt: dürüst vaat + yöntem açıklığı tek satırda;
|
||||
link ne açtığını söyler (§10.4). */}
|
||||
<li className="flex items-start gap-2">
|
||||
<Check className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden />
|
||||
<span>
|
||||
Garanti, komisyon, reklam yok;{" "}
|
||||
{/* nowrap: telefonda satır sonunda "açık" tek başına kalmasın */}
|
||||
<Link
|
||||
href="/meraklisina"
|
||||
className="whitespace-nowrap text-primary underline"
|
||||
>
|
||||
yöntem açık
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Sağ: gerçek rapor satırı — kartın kendi bandı (§4.15 satır +
|
||||
RaporSatirDetayi), yuvarlatılmış iç kutu değil. lg'de iki
|
||||
satır boyu, sol sütunun yanında. */}
|
||||
<div className="border-t border-slate-200 bg-slate-50 p-4 sm:p-5 lg:row-span-2 lg:border-t-0 lg:border-l">
|
||||
<p className="text-xs font-semibold text-slate-500">
|
||||
{secimeOzel
|
||||
? "Sırana ve seçimlerine uygun örnek satır"
|
||||
: "Sırana uygun örnek satır"}
|
||||
</p>
|
||||
<div
|
||||
key={`${p.universite}|${p.isim}`}
|
||||
className={cn("mt-3", degisti && "kt-unblur")}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Telefonda logo gizli — rapor listesiyle aynı karar (§4.15:
|
||||
logo adı üç satıra sıkıştırıyordu). */}
|
||||
<UniLogo ad={p.universite} boy="sm" className="hidden sm:flex" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={SATIR_AD_DUZ}>{p.isim}</span>
|
||||
<span className={SATIR_ALT_GRUP}>
|
||||
<span className={SATIR_ALT_AD}>
|
||||
{trBaslikDuzeni(p.universite)}
|
||||
</span>
|
||||
<span className={SATIR_META}>
|
||||
<TurCipi unitur={p.unitur} />
|
||||
{p.il ? (
|
||||
<Link
|
||||
href={ilSayfaHref(p.il)}
|
||||
className={cn("shrink-0", SATIR_IKINCIL_LINK)}
|
||||
title={`${trBaslikDuzeni(p.il)} üniversiteleri`}
|
||||
>
|
||||
{trBaslikDuzeni(p.il)}
|
||||
</Link>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden text-right md:inline-flex">
|
||||
<ProgramSiraGecmisi program={seri} />
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 text-base font-semibold tabular-nums text-slate-900">
|
||||
<ProgramTabanTrendi program={seri} />
|
||||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<RaporSatirDetayi
|
||||
dilim={satir.dilim}
|
||||
risk={risk}
|
||||
unitur={p.unitur}
|
||||
gerekce={satir.gerekce}
|
||||
riskNotu={satir.riskNotu}
|
||||
trendOzeti={satir.trendOzeti}
|
||||
sikistir
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sol alt: CTA. Telefonda örneğin altına iner (önce kanıt, sonra
|
||||
eylem); lg'de sol sütunun dibinde, örnekle aynı hizada biter. */}
|
||||
<div className="border-t border-slate-200 p-5 lg:border-t-0 lg:pt-4">
|
||||
<Button asChild size="lg" variant="cta" className="w-full">
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() => olay("giris_cta_tiklandi", { kaynak: "tadimlik" })}
|
||||
>
|
||||
{/* Buton metni listenin tamamını vaat etmez: maske girişsiz
|
||||
kullanıcıya 24 satırdan yalnız ilk birkaçının analizini
|
||||
açıyor (rapor-maske.ts), gerisi teslim edilmiyor. */}
|
||||
Kendi listemi oluştur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
{/* "İlk 3 satır" = rapor-maske.ts içindeki ACIK_SATIR (3). O dosya
|
||||
`server-only`, bu dosya "use client" — sabit buraya import
|
||||
edilemiyor, sayı elle yazıldı. ACIK_SATIR değişirse bu cümle de
|
||||
aynı commit'te değişmeli. Cümle kasten sınırlıyor: maske
|
||||
girişsiz/paketsiz kullanıcıya 3 satır açıyor, kalan 21'in
|
||||
gerekçe/risk/trend metni maskeli geliyor. */}
|
||||
<p className="mt-3 text-center text-xs leading-relaxed text-slate-500">
|
||||
Kredi kartı gerekmez · 30 saniyede giriş · İlk 3 satır hemen açık
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -161,7 +161,7 @@ export function TercihDegistiModali({
|
||||
<Check className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<DialogTitle className="text-lg">Listen güncellendi</DialogTitle>
|
||||
<DialogTitle>Listen güncellendi</DialogTitle>
|
||||
<DialogDescription>
|
||||
Yeni tercihlerine göre yeniden kuruldu — arkadaki liste birkaç
|
||||
saniye içinde tazelenir.
|
||||
@@ -169,8 +169,10 @@ export function TercihDegistiModali({
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="cta"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="cursor-pointer self-start transition-[background-color,transform] duration-200 active:scale-[0.97]"
|
||||
className="self-start"
|
||||
>
|
||||
Listeme dön
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
@@ -183,9 +185,7 @@ export function TercihDegistiModali({
|
||||
<SlidersHorizontal className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<DialogTitle className="text-lg">
|
||||
Tercihlerin değişmiş
|
||||
</DialogTitle>
|
||||
<DialogTitle>Tercihlerin değişmiş</DialogTitle>
|
||||
{/* Hangi alanların değiştiği hemen altındaki diff
|
||||
tablosunda zaten duruyor; açıklama yalnızca sonucu ve
|
||||
bedeli söylüyor. */}
|
||||
@@ -265,27 +265,31 @@ export function TercihDegistiModali({
|
||||
Güncelleme {raporKredi} kredi; bakiyen {kredi} kredi.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{/* Telefonda alt alta tam genişlik (ana aksiyon üstte), sm'den
|
||||
itibaren sağa yaslı; 44 px dokunma hedefi (§8). */}
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
variant="ikincil"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="cursor-pointer transition-[background-color,transform] duration-200 active:scale-[0.97]"
|
||||
>
|
||||
Şimdilik kalsın
|
||||
</Button>
|
||||
{durum.ad === "hata" ? (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="cta"
|
||||
onClick={() => void guncelle()}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
Tekrar dene
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
</Button>
|
||||
) : krediYeter ? (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="cta"
|
||||
onClick={() => void guncelle()}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
{ucretsiz
|
||||
? "Listemi güncelle"
|
||||
@@ -293,10 +297,7 @@ export function TercihDegistiModali({
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<Button asChild size="lg" variant="cta">
|
||||
<Link href="/paket?kaynak=tercih_degisti">
|
||||
{hasPaket
|
||||
? "Kredi yükle ve güncelle"
|
||||
@@ -345,10 +346,7 @@ function KrediYetersiz({
|
||||
? `Güncelleme ${raporKredi} kredi ve bakiyen yetmedi. Kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin.`
|
||||
: `Güncelleme ${raporKredi} kredi ve bakiyen yetmedi. Tercih Dönemi Paketi'yle kredilerin, tam liste, PDF ve revizyon hakları birlikte gelir.`}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<Button asChild size="lg" variant="cta" className="w-full sm:w-auto">
|
||||
<Link href="/paket?kaynak=tercih_degisti_yetersiz">
|
||||
{hasPaket ? "Kredi yükle" : "Paketi aktive et"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { logoTepkisi } from "@/lib/logo-tepkisi";
|
||||
import { useId, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -211,6 +212,8 @@ export function CtaSiraForm({
|
||||
if (sira == null) return;
|
||||
const yeniProfil = { sira, tur, secimler };
|
||||
tercihProfiliYaz(yeniProfil);
|
||||
// Üst çubuktaki logo kürsüye döner (DESIGN.md §4.34).
|
||||
logoTepkisi("sira");
|
||||
setAcik(false);
|
||||
router.push(sonucHref(girisli ? { sihirbaz: "1", kaynak: "cta" } : { hazir: "1" }));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { logoTepkisi } from "@/lib/logo-tepkisi";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { ArrowRight, Trophy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
||||
import { SecimHaplari } from "@/components/ui/secim-haplari";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,7 +24,8 @@ import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
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 { ListeDurumuSatiri } from "@/features/liste/components/liste-durumu-satiri";
|
||||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
|
||||
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
|
||||
@@ -99,6 +102,8 @@ export function HeroForm() {
|
||||
if (!sira) return;
|
||||
const profil = { sira, tur, secimler };
|
||||
tercihProfiliYaz(profil);
|
||||
// Üst çubuktaki logo kürsüye döner (DESIGN.md §4.34).
|
||||
logoTepkisi("sira");
|
||||
setModalAcik(false);
|
||||
router.push(sonucHref(girisli ? { sihirbaz: "1", kaynak: "hero" } : { hazir: "1" }));
|
||||
}
|
||||
@@ -108,16 +113,27 @@ export function HeroForm() {
|
||||
if (kayitliProfil) {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center">
|
||||
{/* Üst çubuktaki sıralama hapının iri hâli (DESIGN.md §1.1 "sayı
|
||||
kahramandır", §11.2 "durumu yansıt"): site seni tanıyor. */}
|
||||
<p className="mb-5 flex items-center gap-2.5 font-heading text-3xl leading-none font-bold tracking-tight text-slate-900 sm:text-4xl">
|
||||
<Trophy className="size-6 shrink-0 text-orange-500 sm:size-7" aria-hidden />
|
||||
<SayiRulo deger={kayitliProfil.sira} />
|
||||
<span className="font-sans text-base font-semibold tracking-normal text-slate-500">
|
||||
· {PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
|
||||
variant="cta"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Link href={sonucHref()}>
|
||||
Üniversitelere göz at
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<ListeDurumuSatiri varyant="hero" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -161,9 +177,9 @@ export function HeroForm() {
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={yukleniyor}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
variant="cta"
|
||||
>
|
||||
Yapay Zeka listemi kur
|
||||
{yukleniyor ? "Hazırlanıyor…" : "Yapay Zeka listemi kur"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// dynamic import: hem hero-form hem sonuc/sihirbaz-modal burayı kullanır.
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { AdimGostergesiIskeleti } from "@/components/ui/adim-gostergesi";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
function SihirbazAdimlarSkeleton() {
|
||||
@@ -14,11 +15,8 @@ function SihirbazAdimlarSkeleton() {
|
||||
aria-live="polite"
|
||||
className="flex min-h-64 flex-col gap-5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-1.5 w-6 rounded-full" />
|
||||
<Skeleton className="size-1.5 rounded-full" />
|
||||
<Skeleton className="size-1.5 rounded-full" />
|
||||
<Skeleton className="ml-2 h-3 w-14" />
|
||||
<div className="pr-12">
|
||||
<AdimGostergesiIskeleti adet={3} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -36,8 +34,8 @@ function SihirbazAdimlarSkeleton() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
|
||||
<Skeleton className="h-9 w-20 rounded-lg" />
|
||||
<Skeleton className="h-9 w-24 rounded-lg" />
|
||||
<Skeleton className="h-11 w-20 rounded-full" />
|
||||
<Skeleton className="h-11 w-24 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { toast } from "@/components/ui/bildirim";
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
import { TransitionPanel } from "@/components/core/transition-panel";
|
||||
import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi";
|
||||
import { AdimGostergesi } from "@/components/ui/adim-gostergesi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IlSecimHaritasi } from "./il-secim-haritasi";
|
||||
import { normalizeIlAdi } from "@/lib/harita";
|
||||
@@ -214,41 +215,16 @@ export function SihirbazAdimlar({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Adım göstergesi (DESIGN.md §10): üç parçalı çubuk + adların
|
||||
kendisi; öğrenci nerede olduğunu ve ne kaldığını görür. Değişim
|
||||
ekran okuyucuya duyurulur (WCAG 4.1.3). */}
|
||||
<div>
|
||||
<ol className="grid grid-cols-3 gap-2" aria-label="Sihirbaz adımları">
|
||||
{ADIM_ADLARI.map((ad, i) => (
|
||||
<li key={ad} className="flex flex-col gap-1.5">
|
||||
{/* Dolum transform ile (yalnız transform/opaklık canlandırılır);
|
||||
soldan dolar, güçlü ease-out 300 ms. */}
|
||||
<span className="h-1.5 overflow-hidden rounded-full bg-slate-200" aria-hidden>
|
||||
<span
|
||||
className={`block h-full origin-left rounded-full bg-primary transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none ${
|
||||
i <= adim ? "scale-x-100" : "scale-x-0"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
i === adim
|
||||
? "font-semibold text-slate-900"
|
||||
: i < adim
|
||||
? "font-medium text-slate-600"
|
||||
: "text-slate-400"
|
||||
}`}
|
||||
aria-current={i === adim ? "step" : undefined}
|
||||
>
|
||||
{ad}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p role="status" className="sr-only">
|
||||
Adım {adim + 1} / 3: {ADIM_ADLARI[adim]}
|
||||
</p>
|
||||
</div>
|
||||
{/* Adım göstergesi (DESIGN.md §4.35, §10.2): numaralı, adlı; tamamlanan
|
||||
adıma dokunarak geri dönülür. Değişimi ekran okuyucuya kendisi duyurur. */}
|
||||
<AdimGostergesi
|
||||
adimlar={ADIM_ADLARI}
|
||||
aktif={adim}
|
||||
onAdimSec={adimaGit}
|
||||
ariaLabel="Sihirbaz adımları"
|
||||
// Sağ üstteki kapatma çarpısına (size-9) yer bırakır
|
||||
className="pr-12"
|
||||
/>
|
||||
|
||||
{/* Geçiş sabitleri soru formuyla ortak (lib/adim-gecisi.ts) */}
|
||||
<TransitionPanel
|
||||
@@ -536,7 +512,8 @@ export function SihirbazAdimlar({
|
||||
type="button"
|
||||
onClick={() => adimaGit(adim + 1)}
|
||||
disabled={!adimGecerli}
|
||||
className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"
|
||||
variant="cta"
|
||||
className="h-11 px-5"
|
||||
>
|
||||
Devam
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
@@ -555,7 +532,8 @@ export function SihirbazAdimlar({
|
||||
onTamamla({ kategoriler, iller, universiteTipi, oncelikler, ozelDurum });
|
||||
}}
|
||||
disabled={kategoriler.length === 0}
|
||||
className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"
|
||||
variant="cta"
|
||||
className="h-11 px-5"
|
||||
>
|
||||
{sonButonEtiketi}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { logoTepkisi } from "@/lib/logo-tepkisi";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -182,6 +183,8 @@ export function SihirbazBolumu({
|
||||
*/
|
||||
async function ilerle(secimler: SihirbazSecimleri) {
|
||||
tercihProfiliYaz({ sira, tur, secimler });
|
||||
// Üst çubuktaki logo kürsüye döner (DESIGN.md §4.34).
|
||||
logoTepkisi("sira");
|
||||
await modalCikisiniOynat();
|
||||
setElleAcik(false);
|
||||
if (!girisliMi) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// yalnız 0,12 s'lik çapraz solma.
|
||||
|
||||
import type { Transition, Variants } from "motion/react";
|
||||
import { YAY } from "@/lib/hareket";
|
||||
|
||||
/**
|
||||
* enter / center / exit varyantları; `custom` geçiş yönüdür (1 ileri, -1
|
||||
@@ -58,8 +59,8 @@ export function adimGecisi(hareketiAzalt: boolean): Transition {
|
||||
height: { duration: 0 },
|
||||
}
|
||||
: {
|
||||
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||
height: { type: "spring", stiffness: 300, damping: 30 },
|
||||
x: YAY.adim,
|
||||
height: YAY.adim,
|
||||
opacity: { duration: 0.2 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -511,7 +511,11 @@ export const LISTE_REVIZE_PARAMETRELERI = {
|
||||
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"] },
|
||||
universiteTipi: {
|
||||
type: "string",
|
||||
enum: ["devlet", "vakif"],
|
||||
description: "Yalnız ilk N tercihe özgü tip kısıtı ('ilk 5 devlet olsun, gerisi fark etmez'). 'Vakıf olmasın / sadece devlet' gibi genel istek buraya değil üst düzey universiteTipi'ne yazılır.",
|
||||
},
|
||||
},
|
||||
required: ["adet"],
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -81,6 +81,20 @@ const URETIM_ZAMAN_ASIMI_MS = 60_000;
|
||||
* Kalanlar arasında yönlendirme OpenRouter'da kalır.
|
||||
*/
|
||||
const DISLAMA_SURESI_MS = 6 * 60 * 60_000;
|
||||
|
||||
/**
|
||||
* Düşünme (reasoning) ayarı. Varsayılan "none": görev küçük, hibrit model
|
||||
* düşünmeye bütçe yemesin. Bazı modeller/uç noktalar "none"ı 400 ile
|
||||
* reddediyor ("Reasoning is mandatory", ölçüm 10 Eki 2026: glm-5.3-flash,
|
||||
* gpt-5-nano); model değişiminde AI_REASONING_EFFORT ile "minimal"/"low"
|
||||
* verilir, "yok" alanı hiç göndermez.
|
||||
*/
|
||||
function dusunmeAyari(): { reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" } {
|
||||
const v = process.env.AI_REASONING_EFFORT?.trim();
|
||||
if (v === "yok") return {};
|
||||
if (v === "minimal" || v === "low" || v === "medium" || v === "high") return { reasoning_effort: v };
|
||||
return { reasoning_effort: "none" };
|
||||
}
|
||||
const globalForCagri = globalThis as unknown as {
|
||||
__aiDislananSaglayicilar?: Map<string, number>;
|
||||
};
|
||||
@@ -162,7 +176,7 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
|
||||
// Görev küçük (havuzdan seçim + tek cümlelik gerekce) ve şemaya
|
||||
// uymayan çıktıyı doğrulama döngüsü zaten yakalıyor; hibrit
|
||||
// modellerde düşünme bütçe/süre yemesin.
|
||||
reasoning_effort: "none",
|
||||
...dusunmeAyari(),
|
||||
messages: [
|
||||
{ role: "system", content: opts.system },
|
||||
{ role: "user", content: opts.kullanici },
|
||||
|
||||
@@ -36,7 +36,7 @@ import type { RaporParams, RevizyonKurali } from "@/features/rapor/types/rapor";
|
||||
import { LISTE_REVIZE_PARAMETRELERI, SORU_SOR_PARAMETRELERI, revizyonKuraliKur } from "./araclar";
|
||||
import { yapilandirilmisUret, type CagriGozlemi } from "./cagri";
|
||||
import { niyetModeli } from "./client";
|
||||
import { anketDogrula, anketModelMetni, type Anket } from "./sohbet-meta";
|
||||
import { anketDogrula, anketModelMetni, type Anket, type AnketCevaplari } from "./sohbet-meta";
|
||||
|
||||
export type NiyetSonucu =
|
||||
/** İstek net: kural doğrulandı, liste koddan kurulacak. */
|
||||
@@ -95,19 +95,20 @@ const NIYET_TEL_SEMASI: Record<string, unknown> = {
|
||||
const SISTEM = `Sen KolayTercih'in sunucu tarafı NİYET ÇÖZÜMLEYİCİSİSİN. Adayın (YKS öğrencisi) sohbet mesajını okur, 24'lük tercih listesinde DEĞİŞİKLİK isteyip istemediğine karar verir ve istiyorsa isteği yapısal kurala çevirirsin. Yalnız şemaya uyan JSON döndür; açıklama yazma.
|
||||
|
||||
NİYET:
|
||||
- "revizyon": aday listenin değişmesini istiyor (il, ilgi alanı, devlet/vakıf, dilim dağılımı, "ilk N tercih", bir üniversite/bölümü çıkarma, program ekleme). "Form cevaplarım:" ile başlayan mesaj HER ZAMAN revizyondur.
|
||||
- "revizyon": aday listenin değişmesini istiyor (il, ilgi alanı, devlet/vakıf, dilim dağılımı, "ilk N tercih", bir üniversite/bölümü çıkarma, program ekleme). "Form cevaplarım:" ile başlayan mesaj HER ZAMAN revizyondur. NE değişeceğini söylemeyen genel istekler de revizyondur ("listemi değiştir", "listemi güncelle", "listemi yeniden oluştur", "yeni liste istiyorum", "listeyi baştan kur", "tercihlerimi güncellemek istiyorum") — bunlarda kural null, form dolu; "diger" DEĞİL.
|
||||
- "soru": listeyle ya da programlarla ilgili bilgi/soru; değişiklik istemiyor ("1. programın kontenjanı kaç?").
|
||||
- "diger": sohbet, teşekkür, listeyle ilgisiz konu, not alma isteği.
|
||||
|
||||
KURAL (niyet=revizyon ve istek NET ise; aksi hâlde null). Karşılıklar:
|
||||
- "ilk N tercih X ilinden/ilinde olsun", "ilk N'i X'e göre güncelle" → ilkTercihler {adet: N, iller: [X]}; X sihirbaz illerinde yoksa ayrıca iller.ekle: [X].
|
||||
- "sadece X" → iller.yalniz; "X de olsun / X ekle" → iller.ekle; "X olmasın" → iller.cikar.
|
||||
- "sadece devlet / vakıf olmasın" → universiteTipi "devlet"; "sadece vakıf" → "vakif"; "fark etmez" → "farketmez".
|
||||
- "sadece devlet / vakıf olmasın" → universiteTipi "devlet"; "sadece vakıf" → "vakif"; "fark etmez" → "farketmez". Tip kısıtı aksi açıkça söylenmedikçe TÜM LİSTEYE uygulanır: üst düzey universiteTipi'ne yaz, ilkTercihler.universiteTipi'ne DEĞİL ("ilk 10 İstanbul olsun, sadece devlet" → ilkTercihler {adet: 10, iller: [İstanbul]} + universiteTipi "devlet"). ilkTercihler.universiteTipi yalnız aday "ilk N'i devlet olsun, gerisi fark etmez" gibi açıkça ilk N ile sınırlarsa.
|
||||
- "daha güvenli / garantili olsun" → iskelet "guvenli-agirlikli"; "daha iddialı / hayal ağırlıklı" → "hayal-agirlikli"; "standart kalsın" → "standart".
|
||||
- "X üniversitesi / Y bölümü olmasın, çıkar" → cikar.universiteler / cikar.bolumler (ad parçası yeter).
|
||||
- Program ekleme program kimliği ister; kimlik bilmiyorsan ekle verme, eksik'e "program kimliği" yaz.
|
||||
- "not": adayın isteğinin bir cümlelik özeti (Türkçe).
|
||||
- Kurala YALNIZ adayın bu mesajda istediği değişiklikleri yaz; istemediği alanı verme. "farketmez" ve "standart" yalnız aday açıkça "fark etmez / standart kalsın" derse yazılır; uygulanmış kurallar zaten geçerlidir, onları tekrar yazma ya da sıfırlama.
|
||||
- Bir mesajda birden çok istek varsa HEPSİNİ kurala yaz ("ilk 10 İstanbul olsun, vakıf olmasın" → ilkTercihler + universiteTipi "devlet"). "ilk N tercih X'ten" yalnız X demek DEĞİLDİR: iller.yalniz yazma, gerekirse iller.ekle.
|
||||
- Kurala YALNIZ adayın bu mesajda istediği değişiklikleri yaz; istemediği alanı verme. "farketmez" ve "standart" yalnız aday açıkça "fark etmez / standart kalsın" derse yazılır; uygulanmış kurallar zaten geçerlidir, onları tekrar yazma ya da sıfırlama. Aday ne değişeceğini söylemediyse kural UYDURMA (ör. "universiteTipi: farketmez" ile boş kural) — kural null, form dolu.
|
||||
İl adlarını Türkçe ve tam yaz (İstanbul, İzmir, Kahramanmaraş). İlgi alanı adları TAM OLARAK şunlardan: ${KATEGORILER.join(" | ")}.
|
||||
Form cevabı geldiğinde cevapları forma göre kurala çevir; "standart kalsın", "fark etmez", "yok" gibi cevaplar kural üretmez (o alanı verme); hiç kural çıkmıyorsa kural null, eksik ["istek"].
|
||||
|
||||
@@ -125,9 +126,19 @@ export async function revizyonNiyetiCikar(opts: {
|
||||
params: RaporParams;
|
||||
/** Mesaj "Form cevaplarım:" ise cevaplanan form (sohbet-meta.anketModelMetni ile dökülür). */
|
||||
oncekiForm?: Anket | null;
|
||||
/** Form cevabının yapısal hâli (route anketCevabiDogrula'dan); varsayılan formda model çağrılmadan kurala çevrilir. */
|
||||
anketCevabi?: AnketCevaplari | null;
|
||||
gozlem?: (g: CagriGozlemi) => void;
|
||||
}): Promise<NiyetSonucu> {
|
||||
const p = opts.params;
|
||||
|
||||
// Varsayılan formun cevabı modelsiz okunur (10 Eki 2026): seçenek değerleri
|
||||
// bizim, karşılıkları belli. Serbest metin il adıysa o da kodla çözülür;
|
||||
// çözülemeyen cevap (bölüm/üniversite adı gibi) modele düşer.
|
||||
if (opts.oncekiForm && opts.anketCevabi && varsayilanFormMu(opts.oncekiForm)) {
|
||||
const kural = varsayilanFormKurali(opts.anketCevabi);
|
||||
if (kural) return { niyet: "revizyon", kural };
|
||||
}
|
||||
const durum = [
|
||||
`Başarı sırası ${p.sira.toLocaleString("tr-TR")}, puan türü ${PUAN_TURLERI[p.tur]}.`,
|
||||
`Sihirbaz illeri: ${p.iller?.length ? p.iller.join(", ") : p.il ? p.il : "(seçilmedi — tüm iller)"}.`,
|
||||
@@ -161,15 +172,24 @@ export async function revizyonNiyetiCikar(opts: {
|
||||
} catch (err) {
|
||||
const neden = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[revizyon-niyeti] çıkarım başarısız:", neden.slice(0, 120));
|
||||
return { niyet: "belirsiz", neden: "çağrı başarısız" };
|
||||
return cikarimsiz(opts, "çağrı başarısız");
|
||||
}
|
||||
if (!ham) return { niyet: "belirsiz", neden: "şema dışı çıktı" };
|
||||
if (!ham) return cikarimsiz(opts, "şema dışı çıktı");
|
||||
if (ham.niyet !== "revizyon") return { niyet: ham.niyet };
|
||||
|
||||
if (ham.kural && typeof ham.kural === "object") {
|
||||
const k = revizyonKuraliKur(ham.kural);
|
||||
if (k.ok) return { niyet: "revizyon", kural: k.kural };
|
||||
console.info("[revizyon-niyeti] kural geçersiz, forma düşülüyor:", k.hata.slice(0, 120));
|
||||
if (k.ok) {
|
||||
// Etkisiz kural (10 Eki 2026 eval r12: "listemi yeniden oluştur" →
|
||||
// model `universiteTipi: farketmez` uydurdu, liste "değişmeden" yeniden
|
||||
// kuruldu): zaten geçerli olanı tekrar eden alanlar atılır; geriye bir
|
||||
// şey kalmıyorsa istek belirsizdir, forma düşülür.
|
||||
const etkili = etkisizAlanlariAt(k.kural, p);
|
||||
if (etkili) return { niyet: "revizyon", kural: tipKuraliniTamamla(opts.mesaj, etkili) };
|
||||
console.info("[revizyon-niyeti] kural etkisiz (uygulanmışı tekrarlıyor), forma düşülüyor");
|
||||
} else {
|
||||
console.info("[revizyon-niyeti] kural geçersiz, forma düşülüyor:", k.hata.slice(0, 120));
|
||||
}
|
||||
}
|
||||
const eksik = (ham.eksik ?? []).map((e) => e.replace(/\s+/g, " ").trim().slice(0, 60)).filter(Boolean).slice(0, 5);
|
||||
if (ham.form && typeof ham.form === "object") {
|
||||
@@ -180,6 +200,118 @@ export async function revizyonNiyetiCikar(opts: {
|
||||
return { niyet: "revizyon", form: varsayilanForm(p), eksik };
|
||||
}
|
||||
|
||||
/**
|
||||
* Çıkarım yapılamadı (zaman aşımı / sağlayıcı hatası / şema dışı). Düz
|
||||
* mesajda regex zaten "liste değişsin" dedi: aday "okuyamadım" yerine
|
||||
* varsayılan formu görür, cevabı modelsiz kurala çevrilir (10 Eki 2026 eval
|
||||
* r09#2: 20 sn zaman aşımı → "okuyamadım" + iade; QA B3 bimodal gecikme).
|
||||
* Form cevabında ikinci bir form açılmaz; route "belirsiz"i iade ile karşılar.
|
||||
*/
|
||||
function cikarimsiz(opts: { oncekiForm?: Anket | null; params: RaporParams }, neden: string): NiyetSonucu {
|
||||
if (opts.oncekiForm) return { niyet: "belirsiz", neden };
|
||||
return { niyet: "revizyon", form: varsayilanForm(opts.params), eksik: ["istek"] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uygulanmış kuralları ya da sihirbaz seçimini tekrar eden alanları atar
|
||||
* (`universiteTipi: farketmez` zaten fark etmezken, `iskelet: standart`
|
||||
* zaten standartken). Geriye kural kalmazsa null.
|
||||
*/
|
||||
function etkisizAlanlariAt(kural: RevizyonKurali, p: RaporParams): RevizyonKurali | null {
|
||||
const sonuc: RevizyonKurali = { ...kural };
|
||||
const mevcutTip = p.revizyon?.universiteTipi ?? p.universiteTipi ?? "farketmez";
|
||||
if (sonuc.universiteTipi === "farketmez" && mevcutTip === "farketmez") delete sonuc.universiteTipi;
|
||||
const mevcutIskelet = p.revizyon?.iskelet ?? "standart";
|
||||
if (sonuc.iskelet === "standart" && mevcutIskelet === "standart") delete sonuc.iskelet;
|
||||
const { not: _not, ...kalan } = sonuc;
|
||||
void _not;
|
||||
return Object.keys(kalan).length ? sonuc : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mesajda açıkça yazan üniversite tipi isteği kuralda yoksa kod ekler
|
||||
* (10 Eki 2026 eval r14: "ilk 10 İstanbul olsun, vakıf olmasın" → model 4/4
|
||||
* koşuda universiteTipi'ni düşürdü; "not"a yazdı, kurala yazmadı). Yalnız
|
||||
* tek anlamlı kalıplar; mesajda kalıp yoksa ("ilk N'i devlet yap" gibi
|
||||
* kısmi istek) modelin yazdığı ilkTercihler.universiteTipi olduğu gibi kalır.
|
||||
*/
|
||||
const DEVLET_KALIBI = /vak[ıi]f(?:lar)?(?:\p{L}*)?\s+(?:olmasın|çıksın|çıkar|istemiyorum|kalmasın)|sadece devlet|yaln[ıi]z(?:ca)? devlet|devlet olsun/u;
|
||||
const VAKIF_KALIBI = /sadece vak[ıi]f|yaln[ıi]z(?:ca)? vak[ıi]f|devlet(?:ler)?\s+(?:olmasın|çıksın|çıkar)/u;
|
||||
function tipKuraliniTamamla(mesaj: string, kural: RevizyonKurali): RevizyonKurali {
|
||||
if (kural.universiteTipi) return kural;
|
||||
// "vakıf olmasın" gibi ayrı bir cümle listenin TAMAMI içindir; model tipi
|
||||
// yalnız ilkTercihler içine yazdıysa (gemini-3.1-flash-lite, 10 Eki 2026,
|
||||
// r14 4/4) üst düzeye de taşınır. ilkTercihler'deki kopya zararsızdır
|
||||
// (zaten aynı tipe kısıtlı havuzdan seçilir).
|
||||
const m = mesaj.toLocaleLowerCase("tr-TR");
|
||||
if (DEVLET_KALIBI.test(m)) return { ...kural, universiteTipi: "devlet" };
|
||||
if (VAKIF_KALIBI.test(m)) return { ...kural, universiteTipi: "vakif" };
|
||||
return kural;
|
||||
}
|
||||
|
||||
const VARSAYILAN_FORM_BASLIK = "Listeni nasıl değiştirelim?";
|
||||
const VARSAYILAN_DEGERLER = new Set(["il-ekle", "devlet", "vakif-olmasin", "guvenli", "iddiali"]);
|
||||
|
||||
/** Cevaplanan form bizim varsayılan formumuz mu (soru adları + başlık). */
|
||||
export function varsayilanFormMu(anket: Pick<Anket, "baslik" | "sorular">): boolean {
|
||||
return (
|
||||
anket.baslik === VARSAYILAN_FORM_BASLIK &&
|
||||
anket.sorular.length === 2 &&
|
||||
anket.sorular[0]?.ad === "degisiklik" &&
|
||||
anket.sorular[1]?.ad === "adet"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Varsayılan formun cevabını modelsiz kurala çevirir. Serbest metin yalnız
|
||||
* bilinen bir il adıysa çözülür (revizyonKuraliKur il doğrulaması); başka
|
||||
* serbest metin, çelişen seçim (güvenli + iddialı) ya da il'siz "başka il"
|
||||
* cevabı null döner → model yolu. Adet 24'ten küçükse il/tip ölçütü yalnız
|
||||
* "ilk N"e uygulanır; iskelet her zaman listenin tamamınadır.
|
||||
*/
|
||||
export function varsayilanFormKurali(cevaplar: AnketCevaplari): RevizyonKurali | null {
|
||||
const ham = cevaplar.degisiklik;
|
||||
const secimler = (Array.isArray(ham) ? ham : ham ? [ham] : []).map((s) => s.trim()).filter(Boolean);
|
||||
if (!secimler.length) return null;
|
||||
const serbest = secimler.filter((s) => !VARSAYILAN_DEGERLER.has(s));
|
||||
if (serbest.length > 1) return null;
|
||||
const secili = new Set(secimler.filter((s) => VARSAYILAN_DEGERLER.has(s)));
|
||||
if (secili.has("guvenli") && secili.has("iddiali")) return null;
|
||||
|
||||
let il: string | undefined;
|
||||
if (serbest.length === 1) {
|
||||
const dene = revizyonKuraliKur({ iller: { ekle: [serbest[0]] } });
|
||||
if (!dene.ok || !dene.kural.iller?.ekle?.[0]) return null;
|
||||
il = dene.kural.iller.ekle[0];
|
||||
}
|
||||
if (secili.has("il-ekle") && !il) return null;
|
||||
|
||||
const adetHam = Array.isArray(cevaplar.adet) ? cevaplar.adet[0] : cevaplar.adet;
|
||||
const adet = adetHam ? Math.round(Number(adetHam)) : Number.NaN;
|
||||
const kismi = Number.isFinite(adet) && adet >= 1 && adet < 24;
|
||||
const tip = secili.has("devlet") || secili.has("vakif-olmasin") ? ("devlet" as const) : undefined;
|
||||
|
||||
const arg: Record<string, unknown> = {};
|
||||
if (secili.has("guvenli")) arg.iskelet = "guvenli-agirlikli";
|
||||
if (secili.has("iddiali")) arg.iskelet = "hayal-agirlikli";
|
||||
if (kismi && (il || tip)) {
|
||||
arg.ilkTercihler = { adet, ...(il ? { iller: [il] } : {}), ...(tip ? { universiteTipi: tip } : {}) };
|
||||
if (il) arg.iller = { ekle: [il] };
|
||||
} else {
|
||||
if (il) arg.iller = { ekle: [il] };
|
||||
if (tip) arg.universiteTipi = tip;
|
||||
}
|
||||
const ozet = [
|
||||
il ? `${il} de olsun` : null,
|
||||
tip ? "vakıf olmasın" : null,
|
||||
secili.has("guvenli") ? "daha güvenli" : secili.has("iddiali") ? "daha iddialı" : null,
|
||||
kismi ? `ilk ${adet} tercih` : null,
|
||||
].filter(Boolean);
|
||||
arg.not = `Form cevabı: ${ozet.join(", ")}.`;
|
||||
const k = revizyonKuraliKur(arg);
|
||||
return k.ok ? k.kural : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modelin formu gelmez ya da şemaya uymazsa açılan deterministik form:
|
||||
* ne değişsin (çoklu + serbest) ve kaç tercih etkilensin.
|
||||
@@ -187,7 +319,7 @@ export async function revizyonNiyetiCikar(opts: {
|
||||
export function varsayilanForm(p: RaporParams): Omit<Anket, "id"> {
|
||||
const iller = p.iller?.length ? p.iller : p.il ? [p.il] : [];
|
||||
return {
|
||||
baslik: "Listeni nasıl değiştirelim?",
|
||||
baslik: VARSAYILAN_FORM_BASLIK,
|
||||
sorular: [
|
||||
{
|
||||
ad: "degisiklik",
|
||||
|
||||
@@ -64,6 +64,11 @@ const REVIZYON_NIYETI: RegExp[] = [
|
||||
/(?<!\p{L})(?:olsun|olmasın|kalsın|kalmasın)(?!\p{L})/u,
|
||||
// "listeye ekle", "X'i çıkar / kaldır"
|
||||
/(?<!\p{L})(?:ekle|çıkar|kaldır)(?:yelim|elim|alım|ın|in|sene|sana)?(?!\p{L})/u,
|
||||
// Listeyi BAŞTAN isteyen kalıplar (10 Eki 2026, Bilal: paketli hesapta
|
||||
// "liste oluşturma" isteği koşmadı): "listemi yeniden oluştur", "yeni liste
|
||||
// yap/hazırla", "listeyi baştan/sıfırdan kur", "listemi tekrar kur". Yalnız
|
||||
// "liste" sözcüğüyle birlikte; "oluştur/kur/yap" tek başına çok genel.
|
||||
/list\p{L}*.*(?<!\p{L})(?:oluştur|hazırla|baştan|sıfırdan|yeniden|tekrar|kur)\p{L}*|(?<!\p{L})(?:oluştur|hazırla|baştan|sıfırdan|yeniden|tekrar)\p{L}*.*list\p{L}*|yeni (?:bir )?liste/u,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,15 +12,22 @@ export const YAY = {
|
||||
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 },
|
||||
/** Adım paneli kayması ve boyu (sihirbaz, soru formu, profil kapısı); `adim-gecisi.ts`. */
|
||||
adim: { type: "spring", stiffness: 300, damping: 30 },
|
||||
} as const;
|
||||
|
||||
type Egri = [number, number, number, number];
|
||||
|
||||
export const EGRI: { giris: Egri; standart: Egri } = {
|
||||
// CSS karşılıkları globals.css'te `--egri-*` (aynı sayılar; biri değişirse öbürü de).
|
||||
export const EGRI: { giris: Egri; standart: Egri; gucluCikis: Egri; yerlesme: 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],
|
||||
/** Sahne açılışı ve piksel nabzı: belirgin fırlayıp uzun süzülür (açılış sahnesi, dolum). */
|
||||
gucluCikis: [0.23, 1, 0.32, 1],
|
||||
/** Yerine oturan/kapanan öğe: geç hızlanır, sert durmaz (logo kabının kapanışı). */
|
||||
yerlesme: [0.32, 0.72, 0, 1],
|
||||
};
|
||||
|
||||
export const SURE = { anlik: 0.12, hizli: 0.16, cikis: 0.18, standart: 0.24 } as const;
|
||||
|
||||
18
src/lib/logo-tepkisi.ts
Normal file
18
src/lib/logo-tepkisi.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Logoya tepki (DESIGN.md §4.34): kullanıcının kendi yaptığı bir eylem
|
||||
// header'daki logoyu kısa bir an başka bir biçime dönüştürür. Bileşenler
|
||||
// logoya ref ile uzanmaz; buradan pencereye olay atılır, logo dinler
|
||||
// (animasyonlu-mark.tsx). Tema değişimi ayrı kapıdan gelir (lib/tema-gecisi.ts),
|
||||
// çünkü önce sayfa geçişinin bitmesini bekler.
|
||||
//
|
||||
// - "sira": sıralama girildi/değişti → kürsü
|
||||
// - "ekle": listeye program eklendi → deste
|
||||
// - "tamam": liste 24'e doldu → üç kare tek sıra
|
||||
|
||||
export type LogoTepkisi = "sira" | "ekle" | "tamam";
|
||||
|
||||
export const LOGO_TEPKI_OLAYI = "kt-logo-tepki";
|
||||
|
||||
export function logoTepkisi(tur: LogoTepkisi) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent<LogoTepkisi>(LOGO_TEPKI_OLAYI, { detail: tur }));
|
||||
}
|
||||
100
src/lib/tema-gecisi.ts
Normal file
100
src/lib/tema-gecisi.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Tema geçişi (DESIGN.md §2.3): açık ↔ koyu değişimi ekranı bir anda
|
||||
// çevirmez; yeni tema header'daki KolayTercih işaretinden yumuşak bir halka
|
||||
// gibi açılır — kenarı keskin değil, geniş ve tüylü; ışık yayılır gibi.
|
||||
// Geçiş kısa ve hafiftir; BİTİNCE logonun kareleri yeni temanın biçimine
|
||||
// dönüşür (koyuya: ay ve yıldız; açığa: güneş), kısa bir an durur ve
|
||||
// yeniden logoya toplanır.
|
||||
// Markanın yumuşak, oyunsu diline uysun diye sert kenar, piksel ya da ikinci
|
||||
// bir şekil yok (9 Eki 2026).
|
||||
//
|
||||
// Nasıl: View Transitions API eski/yeni görüntü çiftini verir; yeni görüntü
|
||||
// radyal degrade maskesiyle açılır. Yarıçapı CSS sürer (globals.css
|
||||
// `.kt-tema-gecis`), buradan yalnız merkez ve son yarıçap verilir. Tarayıcı
|
||||
// View Transitions bilmiyorsa ya da hareket azaltılmışsa tema düz değişir.
|
||||
|
||||
/** Geçiş sırasında <html>'de duran sınıf (globals.css). */
|
||||
const GECIS_SINIFI = "kt-tema-gecis";
|
||||
|
||||
/** Geçiş bitince pencereye atılan olay; header'daki logo bunu dinleyip
|
||||
* karelerini güneşe ya da aya dönüştürür (animasyonlu-mark.tsx). */
|
||||
export const TEMA_GECIS_OLAYI = "kt-tema-gecis";
|
||||
|
||||
// Maskenin tam opak çekirdeği yarıçapın bu oranında biter, kalanı tüydür
|
||||
// (globals.css'teki --kt-tema-cekirdek ile aynı olmalı): son yarıçap, çekirdek
|
||||
// en uzak köşeyi de örtecek kadar büyük seçilir.
|
||||
const CEKIRDEK = 0.55;
|
||||
|
||||
/** Sayfa geçişi ile logo dönüşümü arasındaki kısa nefes (ms). */
|
||||
const LOGO_NEFESI = 80;
|
||||
|
||||
let suruyor = false;
|
||||
|
||||
/**
|
||||
* `uygula` temayı değiştiren çağrıdır (ör. `setTheme("dark")`). Geçiş
|
||||
* sürerken gelen istek yok sayılır: art arda D'ye basınca ekrandaki görüntü
|
||||
* ile tema durumu birbirine girmesin.
|
||||
*/
|
||||
export function temayiGecisle(uygula: () => void) {
|
||||
if (typeof document === "undefined") {
|
||||
uygula();
|
||||
return;
|
||||
}
|
||||
const azHareket = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (azHareket) {
|
||||
uygula();
|
||||
return;
|
||||
}
|
||||
if (!document.startViewTransition) {
|
||||
// Sayfa geçişi yok ama logo yine dönüşür; temanın sınıfı <html>'e
|
||||
// yazılsın diye kısa bir an beklenir.
|
||||
uygula();
|
||||
window.setTimeout(
|
||||
() => window.dispatchEvent(new Event(TEMA_GECIS_OLAYI)),
|
||||
LOGO_NEFESI,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (suruyor) return;
|
||||
|
||||
const kok = document.documentElement;
|
||||
// Halkanın merkezi header'daki logo; krom gizli sayfalarda sol üst köşe.
|
||||
const logo = document
|
||||
.querySelector('header a[aria-label="KolayTercih"]')
|
||||
?.getBoundingClientRect();
|
||||
const gorunur = logo && logo.width > 0 && logo.bottom > 0;
|
||||
const x = gorunur ? logo.left + logo.height / 2 : 24;
|
||||
const y = gorunur ? logo.top + logo.height / 2 : 24;
|
||||
const enUzak = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y),
|
||||
);
|
||||
|
||||
kok.style.setProperty("--kt-tema-x", `${x}px`);
|
||||
kok.style.setProperty("--kt-tema-y", `${y}px`);
|
||||
kok.style.setProperty("--kt-tema-son", `${Math.ceil(enUzak / CEKIRDEK)}px`);
|
||||
kok.classList.add(GECIS_SINIFI);
|
||||
suruyor = true;
|
||||
|
||||
const gecis = document.startViewTransition(uygula);
|
||||
// Sekme gizliyken / yeniden boyutlanırken tarayıcı geçişi iptal eder:
|
||||
// `ready` reddedilir, tema yine uygulanmıştır; konsola hata düşmesin.
|
||||
gecis.ready.catch(() => {});
|
||||
gecis.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
kok.classList.remove(GECIS_SINIFI);
|
||||
kok.style.removeProperty("--kt-tema-x");
|
||||
kok.style.removeProperty("--kt-tema-y");
|
||||
kok.style.removeProperty("--kt-tema-son");
|
||||
suruyor = false;
|
||||
// Sıra: önce sayfa geçişi biter, sonra logo dönüşür — ikisi üst üste
|
||||
// binmez, göz önce temaya sonra logoya gider. Kısa nefes (LOGO_NEFESI):
|
||||
// geçiş iptal olduysa (sekme gizli vb.) `finished` hemen döner ve
|
||||
// temanın sınıfı <html>'e henüz yazılmamış olabilir; logo yanlış
|
||||
// biçime dönüşmesin.
|
||||
window.setTimeout(
|
||||
() => window.dispatchEvent(new Event(TEMA_GECIS_OLAYI)),
|
||||
LOGO_NEFESI,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user