From b4effc85451dba404142e938715406feb75041a3 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Sun, 2 Aug 2026 04:28:27 +0300 Subject: [PATCH] Refactor UI components for consistency and clarity across the application - Updated AGENTS.md with new guidelines for UI consistency, including badge and modal styles. - Refactored layout.tsx to improve the placement of the progressive blur effect. - Enhanced NotFound component text for better user guidance. - Replaced HeroFocusButton with HeroBaslik and KapanisCta in the Home component for improved clarity. - Added SiteFooter to multiple pages for consistent footer display. - Removed ListemSekmeleri component and adjusted ListemPage to streamline functionality. - Updated various components to ensure consistent styling and user experience. --- AGENTS.md | 20 + CTA-CEKMECE-TASARIMI.md | 106 ++++ data/app.db | Bin 155648 -> 155648 bytes src/app/bolum/[slug]/bolum-icerik.tsx | 8 +- src/app/giris/giris-baslik.tsx | 67 +-- src/app/giris/loading.tsx | 4 +- src/app/giris/page.tsx | 6 +- src/app/gizlilik/page.tsx | 4 + src/app/iletisim/page.tsx | 4 + src/app/kosullar/page.tsx | 4 + src/app/layout.tsx | 34 +- src/app/listem/listem-bos-cta.tsx | 38 ++ src/app/listem/listem-sekmeleri.tsx | 85 --- src/app/listem/page.tsx | 193 ++----- src/app/listem/sohbet-client.tsx | 93 +++- src/app/meraklisina/demo.tsx | 509 +++--------------- src/app/meraklisina/huni-semasi.tsx | 54 ++ src/app/meraklisina/page.tsx | 53 +- src/app/not-found.tsx | 4 +- src/app/odeme/sonuc/page.tsx | 39 +- src/app/odeme/sonuc/rapor-yok-cta.tsx | 36 ++ src/app/page.tsx | 22 +- src/app/paket/page.tsx | 11 +- src/app/sonuc/page.tsx | 76 +-- src/app/sonuc/sihirbaz-cagri-karti.tsx | 111 ++-- src/app/sonuc/sira-gerekli.tsx | 70 +++ src/app/sonuc/sohbet-popup.tsx | 42 +- .../arama-funnel-karti.tsx} | 83 +-- src/components/cta-sira-form.tsx | 335 ++++++++++-- src/components/dev/dev-panel.tsx | 79 ++- src/components/header-gate.tsx | 3 +- src/components/hero-baslik.tsx | 33 ++ src/components/hero-focus-button.tsx | 44 ++ src/components/hero-form.tsx | 62 ++- src/components/il-secim-haritasi.tsx | 13 +- src/components/katalog-arama.tsx | 238 ++++++-- .../manuel-liste/liste-cekmecesi.tsx | 331 ++++++------ .../manuel-liste/profil-kapisi-store.ts | 63 ++- .../manuel-liste/secimlerim-paneli.tsx | 39 +- .../manuel-liste/tercih-profili-kapisi.tsx | 301 +++++++---- src/components/pixel-decor.tsx | 15 +- src/components/program-tablosu.tsx | 125 +---- src/components/sihirbaz-adimlar-lazy.tsx | 46 +- src/components/sihirbaz-adimlar.tsx | 9 +- src/components/site-footer.tsx | 3 +- src/components/ui/dialog.tsx | 16 +- src/components/ui/drawer.tsx | 154 ++++++ src/lib/sihirbaz.ts | 17 + 48 files changed, 2173 insertions(+), 1529 deletions(-) create mode 100644 CTA-CEKMECE-TASARIMI.md create mode 100644 src/app/listem/listem-bos-cta.tsx delete mode 100644 src/app/listem/listem-sekmeleri.tsx create mode 100644 src/app/meraklisina/huni-semasi.tsx create mode 100644 src/app/odeme/sonuc/rapor-yok-cta.tsx create mode 100644 src/app/sonuc/sira-gerekli.tsx rename src/{app/sonuc/funnel-banner.tsx => components/arama-funnel-karti.tsx} (54%) create mode 100644 src/components/hero-baslik.tsx create mode 100644 src/components/ui/drawer.tsx diff --git a/AGENTS.md b/AGENTS.md index 322f6fb..41258a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,27 @@ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. +# Tarayıcı açma + +UI değişikliklerini tarayıcıda/preview'da doğrulamaya çalışma; kullanıcı kendisi kontrol ediyor. Dev sunucusu veya browser paneli açma — sadece kod değişikliğini yap ve ne değiştiğini raporla. + # Push öncesi build zorunlu Push emri geldiğinde önce lokalde `pnpm build` başarıyla geçmeli; geçmeden asla push etme. Kritik detay: çalışma ağacı kirliyken build, commit'lenmemiş dosyaları da gördüğü için CI'da kırılacak bir commit'i yakalayamayabilir. Bu yüzden build'i **push edilecek commit'in kendisi** üzerinde doğrula (ör. geçici `git worktree add HEAD` + `node_modules` symlink'i ile). Ayrıca kısmi commit atarken değişikliğin bağımlılık kapanışını kontrol et — bir dosyanın kullandığı prop/export başka bir commit'lenmemiş dosyadan geliyorsa onları da aynı commit'e dahil et. + +# Badge/eyebrow rozetleri: tek kaynak SectionEyebrow + +Site genelindeki tüm badge/rozet/eyebrow elemanları ana sayfadaki section başlıklarının üstünde duran rozetle birebir aynı olmalı. Kanonik bileşen `src/components/pixel-decor.tsx` içindeki `SectionEyebrow`dur (piksel imzalı `PixelMark` ikonu + `rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-primary`). Yeni bir badge/eyebrow gerektiğinde `SectionEyebrow` kullan (`className` prop'u kenar boşluğu için var); `@/components/ui/badge`'deki `Badge` ile ya da elle yazılmış pill span'lerle yeni badge tasarlama. İnteraktif chip/buton pill'leri (filtre çipleri, nav butonları) bu kuralın dışındadır. + +# UI tutarlılığı: ortak elemanlar tek kaynaktan + +Modallar arası ortak elemanlar (kapatma çarpısı, başlık düzeni vb.) görsel olarak birebir aynı olmalı. Kapatma çarpısının kanonik stili `src/components/ui/dialog.tsx` içindeki `DialogContent` varsayılan butonudur (yuvarlak, `size-9`, `border border-slate-200`, `size-4` X ikonu, hover'da `bg-slate-100`, `active:scale-[0.96]`). Yeni bir modal eklerken bu varsayılanı kullan; özel yerleşim gerekiyorsa (ör. katalog-arama'da input satırının içindeki buton) `showCloseButton={false}` ver ama **aynı class setini** kopyala — farklı boyut/renk/varyantta yeni bir çarpı tasarlama. Aynı prensip diğer tekrarlanan UI parçaları için de geçerli: bir eleman iki yerde görünüyorsa stilini tek kaynaktan al veya birebir eşleştir. + +# CTA butonlarında ikon: yıldız yok, sağ ok metnin sonunda + +Funnel CTA butonlarında ("Listemi oluştur", "Tercihlerimi belirle", giriş/ödeme CTA'ları vb.) `Sparkles` (yıldız) ikonu kullanılmaz. Kanonik desen: buton metni önce gelir, sonunda `` durur. Yeni bir CTA butonu eklerken bu deseni uygula; metnin başına ikon koyma. Dekoratif `Sparkles` kullanımları (boş durum başlık ikonları, bilgi satırları) bu kuralın dışındadır — kural yalnızca aksiyon butonları/CTA linkleri içindir. + +# Meraklısına'daki mermaid şeması mimariyle senkron kalmalı + +`/meraklisina` sayfasındaki sihirbaz akış şeması (`src/app/meraklisina/huni-semasi.tsx` içindeki `SEMA` sabiti) ürünün gerçek davranışının belgesidir, dekor değildir. Sihirbaz/listeleme mimarisinde davranış değiştiren her değişiklikte bu şema da aynı PR/commit içinde güncellenmeli: sihirbaz adımlarının sayısı-sırası-içeriği (alan → il → devlet/vakıf), aday havuzu kuralları, 24'lük liste iskeleti (hayal/dengeli/garanti dağılımı), havuz 24'ün altına düşünce filtre gevşetme sırası ve yapay zekânın havuz-dışına-çıkamama kuralı. Şemayı güncellerken çevresindeki unsurları da eşitle: bölümdeki anlatım metni (`src/app/meraklisina/page.tsx`, 4. bölüm), kabın `aria-label`'ı ve figcaption. Şema öğrenci diliyle kalmalı (SQL/prompt/teknik detay yok) ve ürünün risk renk dilini korumalı — kırmızı/sarı/yeşil her zaman metin etiketiyle birlikte, renk tek başına anlam taşımaz. diff --git a/CTA-CEKMECE-TASARIMI.md b/CTA-CEKMECE-TASARIMI.md new file mode 100644 index 0000000..e1c8bad --- /dev/null +++ b/CTA-CEKMECE-TASARIMI.md @@ -0,0 +1,106 @@ +# CTA çekmecesi tasarım algısı — "Sıralamanla nereye yerleşirsin? Saniyede gör." + +Bu doküman, programatik SEO sayfalarındaki funnel CTA'sının (`src/components/cta-sira-form.tsx`) +alttan açılan çekmece (drawer) tasarımının arkasındaki algıyı ve kuralları belgeler. +Yeni bir funnel giriş noktası eklerken ya da bu bileşeni değiştirirken buradaki +ilkelere uy. + +## 1. Temel algı: vaadi verdiğin yerde tahsil et + +Eski davranış adayı ana sayfaya (`/`) gönderiyordu: "Saniyede gör" vaadi verip +saniyeler süren bir sayfa geçişi ve bağlam kaybı yaşatıyordu. Yeni algı şu: + +- **Kart vaat eder** — SEO sayfasının içinde duran kart (`bg-primary/5`, + `border-primary/20`) merak uyandırır: "Sıralamanla nereye yerleşirsin?" +- **Çekmece tahsil eder** — CTA butonu sayfadan koparmadan alttan bir çekmece + açar; sıralama girişi, sihirbaz adımları ve tamamlama hepsi aynı bağlamda akar. +- **Ödül `/sonuc`'ta** — seçimler tamamlanınca profil yazılır, çekmece kapanır + ve aday doğrudan sonuç sayfasına gider. Sayfa geçişi yalnızca ödül anında olur. + +Akış: **kart (vaat) → çekmece (sıralama) → sihirbaz (kişiselleştirme) → /sonuc (ödül)**. + +Profilli adayda çekmece hiç devreye girmez: kayıtlı sıralamayla doğrudan +`/sonuc` linki gösterilir — sıralamasını girmiş adaya bir daha girmesini +söylemeyiz. + +## 2. Neden çekmece, modal değil? + +- Çekmece **sayfanın üstüne değil, altına eklenir** algısı verir: aday hâlâ + okuduğu üniversite/bölüm sayfasının üzerindedir, içerik kaybolmaz (overlay + hafif: `bg-black/10` + blur). +- Alttan gelen yüzey mobilde başparmak menzilinde; hedef kitle (YKS adayı) + ağırlıkla mobilde. +- Swipe-to-dismiss (tutamaktan) ve overlay/Escape ile kaçış ucuz: "bir bakayım" + maliyeti düşük tutulur. `handleOnly` kullanılır ki form/sihirbaz içindeki + kaydırmalar çekmeceyi yanlışlıkla kapatmasın. +- Desktop'ta çekmece tam genişliğe yayılmaz: `sm:max-w-2xl` ile ortalanır, + `sm:border-x + sm:shadow-2xl` ile sayfadan yükselen bir panel gibi durur + (liste çekmecesiyle aynı reçete). + +## 3. Görsel dil: yeni hiçbir şey icat etme + +Çekmecedeki her parça sitede zaten var olan kanonik kaynaktan gelir: + +| Parça | Kaynak / kural | +| --- | --- | +| Eyebrow rozeti ("Saniyede gör") | `SectionEyebrow` (`pixel-decor.tsx`) — elle pill yazılmaz | +| Kapatma çarpısı | `dialog.tsx`'teki kanonik set: `size-9`, `rounded-full`, `border-slate-200`, `size-4` X, hover `bg-slate-100`, `active:scale-[0.96]` | +| CTA butonları | Metin önce, sonda `ArrowRight` (`size-4`); yıldız/Sparkles yok; `bg-orange-500 → hover:bg-orange-600` | +| Puan türü çipleri | Hero formundaki radiogroup çipleriyle birebir aynı class seti | +| Sıralama girişi | Profil kapısındaki desen: sola `Trophy` ikonu, `h-12`, `pl-11` | +| Kanıt üçlüsü | Arama funnel kartıyla aynı üçlü: 24 tercihlik plan / Risk analizi / YÖK Atlas verisi (`ListChecks`, `ChartNoAxesCombined`, `Database`) | +| Başlık/kopya tipografisi | `font-heading` bold başlık, `text-slate-600` gövde, `text-slate-900` vurgu | + +Algı hedefi: çekmece açıldığında aday "yeni bir yere geldim" değil, "aynı ürünün +bir katmanı kalktı" hissetmeli. Bu yüzden çekmeceye özgü hiçbir yeni renk, +rozet, buton varyantı tasarlanmaz. + +## 4. İçerik tonu + +- Öğrenci diliyle, ikinci tekil: "Sıralamanı yaz", "bu sayfadan ayrılmadan gör". +- Sürtünme kaldıran güvence her zaman görünür: **"Ücretsiz, üyelik gerekmez."** +- Kanıt üçlüsü süs değil, ürün vaadinin özetidir; metin etiketleri ikonsuz da + anlamlı olmalı (renk/ikon tek başına anlam taşımaz — ürünün risk renk dili + kuralıyla aynı ilke). +- Buton etiketleri eylemin sonucunu söyler: "Programları gör" (girdi adımı), + "Ücretsiz tablonu gör" / "AI listemi kur (3 kredi)" (sihirbaz sonu, girişe göre). + +## 5. Davranış ve teknik algı + +- **Statik kalır:** Kart RSC sayfalarında prerender edilir; oturum bilgisi + mount'ta değil, facet isteğiyle **paralel** çözülür (hero formundaki desen) — + landing başına gereksiz auth turu atılmaz. +- **Preload:** CTA butonuna `pointerenter`/`focus` ve sıralama inputuna `focus` + geldiğinde `preloadSihirbazAdimlar()` çalışır; sihirbaz açıldığında chunk + çoktan inmiştir. "Saniyede gör" vaadi teknik olarak da tutulur. +- **İki adım, tek yüzey:** çekmece içinde `sira → sihirbaz` adımları vardır; + erişilebilir ad sabittir (sr-only `DrawerTitle`/`DrawerDescription`), görünür + başlıklar adıma göre değişir. +- **Kapanış = sıfırlama, ama nazikçe:** çekmece kapanınca adım ve facet'ler + sıfırlanır; adayın yazdığı sıralama ve puan türü **korunur** (tekrar açarsa + baştan yazmasın). +- **Tamamlanınca:** `tercihProfiliYaz` → çekmece kapanır → `sonucHref` ile + `/sonuc` (girişliye `sihirbaz: "1"`, girişsize `hazir: "1"`). Giriş duvarı + sihirbazın hemen arkasında değildir; değer önce gösterilir. +- **Lenis:** çekmece içeriği ve kaydırma kabı `data-lenis-prevent` taşır; + sayfadaki smooth-scroll çekmece içi kaydırmayı ezmez. + +## 6. Erişilebilirlik + +- Sıralama alanı: `inputMode="numeric"`, binlik ayraçlı biçimleme + (85000 → 85.000), hata durumunda `aria-invalid` + `aria-describedby` + + `role="alert"`. +- Puan türü seçimi `role="radiogroup"` / `role="radio"` + `aria-checked`. +- Kapatma yolları: çarpı butonu, overlay, Escape, tutamaktan swipe. +- `prefers-reduced-motion` vaul/motion tarafında zaten sadeleşir; çekmeceye + özel ek animasyon eklenmedi. + +## 7. Bu deseni ne zaman kullan / kullanma + +**Kullan:** İçerik sayfasında duran ve adaydan tek kritik girdi (sıralama) +isteyen her funnel girişi. Adayın okuduğu bağlam değerliyse (bölüm, üniversite, +rehber) çekmece doğru araçtır. + +**Kullanma:** Zaten modal/dialog içinde olan akışlar (arama modalındaki funnel +kartı gibi — modal üstüne çekmece açılmaz, oradaki kart mevcut kapı/dialog +akışını kullanır) ve profilli kısayollar (doğrudan link yeterli). diff --git a/data/app.db b/data/app.db index 92847bb6f6d4980dfc30b9705169df830e5d2378..abb867e365c4a46ffb566f553ce2a854438b4801 100644 GIT binary patch delta 2342 zcmai#ORU^h9mcP3ubL)l?`?sALRuxdrCp#-*k*Bd=Y0D9pT6Ju z-+grX?xV}^-2iUA|1t&K`o%YXa&qjKS}K~%8S6LN;l zCA<{$X26ORvJlEYU z-Ghc923Ho|rNuGG_D#yy$9)5l`!ii;rQtw>_)rm;IRXvV_;%yQttQK8Iqv!(M1T)p zxbYX@mD*5)ScTQeViBN9yvb}Pc^sGiw;cP!cbd)|#+ciOj9JGqKezNv3+H8x2>|LxY*w5dwp;u~WCYCHNGk z7?s?iEv_F*CDRez9V>u#ch}9*-m=EWOc5KC7(NqmwH7C6xt5VoDY+`dM{30?YU3I3 zhR!FQ0Z}75(MEX^cmXNEib^t_?Ubp>5l^tHUF^(>o%ceqphoF3>yN9@>SJR&()JNH zIOb0y6a)3F^>fypDrCp%jvywS)d)^(w`WD4prHb4!a+jhAtJ#75yL$?CkdHOq)}7m z^kOzxOnqaf6!vyE(nxz1y1IVGo1*b zVn1HXa;m6wqBMX8o2>Dp_0Rqhl;!#NZ-&o*_~c&i@KXlx^M8J(ICI4|vh%1M1WU#B z0}b=ILfFjp;S!nGGuWIIGiFD4Tfr406XE1>wXD^p(z8i5ll!ZzujLNfLx<*E#oOU{ zn6-vxIYeWv8r4L*e%_I->2@##InSv;&>Iq6h0&aL-}TWJkONuU_F`-yBryIB6&nceyWBzl#iyp9jFhM;&y3zFwU`o4l}5yjQSI6hgYKvYwDdO zWqAB429CeS!2Ul5RUAzN*o#p%@G`bhrqd4N?-?YJJqA1A{I}QYmwT<55V0H2ASDTm zJrAiu8>YKnsYd+J`TU-QNNV@LOTi^7VpAN(D>fo1db{TV8{J4}Vl(i=Sc2+a)@8%A<7ka8nW9dj zNWG|sVnOO?ZuQS7CZIhvFSHrWWJ8A|kS?toy?EPabZb3!@MwaF@QRqF)uI^5;-rT` zsV4Tj6QSwYQymtXC0?LI2P8HMZSn@)SW~py8ORb9nfB$*-XgvQahecj7%Blw7oIOp{l+pL@i{hPq)gGXNk ze)#N%PonjQPcEz*z>kkkj{kM=?D6A+ljP+7$;HX57cbrd{&swH^816=9x&H|TVHqnetAmSJh5aA5Umydp(06znEY zMV7JdwacZZItGapUg>JroRK`g(Y0K7 zneZhvCyO~ktzuDHk^C4g7gjvOl}%31i^#J5C|6iH>F@FDRbzC?U)0#|-{3k9zH=ADmgmyh_p{rxAv(Z~Bw&Y!>Y+5Yi+@9aO%b#8D! zIXvTUq^B!79~K!7LZaQOB5Y70?vQMA5}Wvg#2ogfmToQj6K#X2d0lST%>mW(FePu> zs+K1y0%tMHwa1MLv|t~Dud*KXMw!h&!a}$Y>tL4-0ZL7 zPCYqw7S>GBR==>h*mO#83Sm94o^6x@Y=G@~Njt{CbcZf2$`y5f z{pRPut?_ycwure5mqT@u2!6cOCB7U(^kzOezxL8|z$u0oe1)e}Fo8{!+V#hTc}XjH zOlxu~_`0QG(`Z}akdqS&Ay3M3i>w1^PT6&@q z0s{M#==PG)Ce2$?YoPj5AuJ6lL&$2Ac_SX`81RTwf_}5@G7%i1iy6|p~!%z_;Qk=sS|mrcITOu7!!P_&WYg|EHktk?0`N4H-&`{FzIu6%ac3va!#x3?!gbNHwAvqx8{ zh+8VD`3{6K3IinO!Rs5)0iuV6Heg5K2A2|EtwfLopRapZQ zqj@v1C<#K=1`&@f$x)}WGd|Z|+<9H_o(yqf;Z9YjpuA|qS2o0Zc)>~XW+A-ZD3>|v zkc-A#WbBe3GqfrqZG=U-sXk3SkOZ3$)pN$0+w2jdxabdO3B*rmke+#nKjDIS$+Sv3 zHwzJ?Ns1*@gr%$gTo5NjbJ;!R#7Q`F*lIR4jiuheJ$KoFk(IKb*(RzIf!Go18*&}a zhtQ(l=Wxv}gmKZ0v=Rn;@+c@GZ_cHUUw9?EMUPi1!LCbqp-ym?NwSJ zzKyL=WW5vn-X}Y;&py2U>dtF)AKIROaeM#o*G~ShcX;@8?_0n4W^wZ5vT@Ws<_@3k zKJLFBUOc@H`~Wz3c=gip#hW*P7l7f>F?Ybd{cgb>9Kg9HIvQ>I(g19Y&C2nM)^y>+ zbQ3~^4e5A=f4xTvbhVND6k-d8subkzm0^$`V8}SPP@~xPZv%I?kG~5%`UbT1LTGzo z+ys7gP^A0V#Z9tWgDEU zvc-rq(q*$=)>Gh+<972saJ;E8^e}(kDJx>MQV>+?NUTpx3n!OzE5@uCHzR{sic&@g z>I#W5E=5Kwj9P0`gyiiX-JJ)z>)-wU-R-+P@a>abZ^9qm^TIb@c(#7~0QgJo&i_gB MUpYQ`ANbQh0XfgZX#fBK diff --git a/src/app/bolum/[slug]/bolum-icerik.tsx b/src/app/bolum/[slug]/bolum-icerik.tsx index 9280391..b2e2aa1 100644 --- a/src/app/bolum/[slug]/bolum-icerik.tsx +++ b/src/app/bolum/[slug]/bolum-icerik.tsx @@ -6,6 +6,7 @@ import { getBolumBySlug, uniSlugFromAd } from "@/lib/katalog"; import { JsonLd, breadcrumbJsonLd } from "@/lib/seo"; import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama"; import { BolumProgramListesi } from "@/components/bolum-program-listesi"; +import { SiteFooter } from "@/components/site-footer"; const sayi = (n: number) => n.toLocaleString("tr-TR"); @@ -53,7 +54,8 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) { const buYol = ilkSayfa ? `/bolum/${slug}` : `/bolum/${slug}/sayfa/${sayfa}`; return ( -
+ <> +
-
+
+ + ); } diff --git a/src/app/giris/giris-baslik.tsx b/src/app/giris/giris-baslik.tsx index 6fbe372..78c9741 100644 --- a/src/app/giris/giris-baslik.tsx +++ b/src/app/giris/giris-baslik.tsx @@ -1,61 +1,40 @@ "use client"; -import { TextLoop } from "@/components/core/text-loop"; +import { TypingAnimation } from "@/components/ui/typing-animation"; -// Sonuç sayfasından gelen funnel'da dönen sempatik mesajlar. -// Kısa tutulmalı — başlıkta tek satır, taşma yok. +// Hepsi aynı şablon: "Giriş yap, çünkü …" const FUNNEL_MESAJLARI = [ - "1 adım kaldı tercihine", - "Listen seni bekliyor 👀", - "24 tercih, hepsi sana göre", - "5 kredin hazır, ya sen?", + ", çünkü listen seni bekliyor", + ", çünkü tercihine 1 adım kaldı", + ", çünkü seçimlerin hazır", + ", çünkü 5 kredin yanında", + ", çünkü kaçırmak istemezsin", + ", çünkü listen hemen üretilsin", ]; /** * Giriş sayfası başlığı. Kullanıcı sihirbazdan (callback /sonuc'a dönüyorsa) - * geldiyse TextLoop'la funnel mesajları döner; diğer girişlerde sabit başlık. + * geldiyse sabit "Giriş yap" + typewriter hook'ları; diğer girişlerde + * yalnız sabit başlık. */ export function GirisBaslik({ funnel }: { funnel: boolean }) { if (!funnel) { - return

Giriş yap

; + return

Giriş yap

; } return ( -

- - {FUNNEL_MESAJLARI.map((mesaj) => ( - {mesaj} - ))} - +

+ Giriş yap +

); } diff --git a/src/app/giris/loading.tsx b/src/app/giris/loading.tsx index b1a2221..0b363f7 100644 --- a/src/app/giris/loading.tsx +++ b/src/app/giris/loading.tsx @@ -5,8 +5,8 @@ import { Skeleton } from "@/components/ui/skeleton"; // içerik gelince zıplama olmaz. export default function GirisLoading() { return ( -
-
+
+
{/* Başlık + alt metin */} diff --git a/src/app/giris/page.tsx b/src/app/giris/page.tsx index 2a2b657..dd77761 100644 --- a/src/app/giris/page.tsx +++ b/src/app/giris/page.tsx @@ -27,8 +27,10 @@ export default async function GirisPage({ const funnel = callbackURL.startsWith("/sonuc"); return ( -
-
+ // -mb-28: layout'taki ScrollFlow sarmalayıcısının pb-28'ini nötrler — + // kart tam dvh içinde ortalanır, sayfa scroll etmez (sığmazsa scroll açılır). +
+
diff --git a/src/app/gizlilik/page.tsx b/src/app/gizlilik/page.tsx index fcf44f4..d67d183 100644 --- a/src/app/gizlilik/page.tsx +++ b/src/app/gizlilik/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { Parallax } from "@/components/parallax"; import { PagePixelDivider } from "@/components/pixel-decor"; +import { SiteFooter } from "@/components/site-footer"; export const metadata: Metadata = { title: "Gizlilik ve KVKK", @@ -13,6 +14,7 @@ export const metadata: Metadata = { // NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir. export default function GizlilikPage() { return ( + <>

@@ -78,5 +80,7 @@ export default function GizlilikPage() {

+ + ); } diff --git a/src/app/iletisim/page.tsx b/src/app/iletisim/page.tsx index 21c34d1..ec7b7cb 100644 --- a/src/app/iletisim/page.tsx +++ b/src/app/iletisim/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Mail } from "lucide-react"; import { Parallax } from "@/components/parallax"; import { PagePixelDivider } from "@/components/pixel-decor"; +import { SiteFooter } from "@/components/site-footer"; export const metadata: Metadata = { title: "İletişim", @@ -12,6 +13,7 @@ export const metadata: Metadata = { export default function IletisimPage() { return ( + <>

İletişim

@@ -34,5 +36,7 @@ export default function IletisimPage() { asla e-postayla paylaşma.

+ + ); } diff --git a/src/app/kosullar/page.tsx b/src/app/kosullar/page.tsx index ae9aec8..b3f8eab 100644 --- a/src/app/kosullar/page.tsx +++ b/src/app/kosullar/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { Parallax } from "@/components/parallax"; import { PagePixelDivider } from "@/components/pixel-decor"; +import { SiteFooter } from "@/components/site-footer"; export const metadata: Metadata = { title: "Kullanım ve Satış Koşulları", @@ -13,6 +14,7 @@ export const metadata: Metadata = { // NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir. export default function KosullarPage() { return ( + <>

@@ -85,5 +87,7 @@ export default function KosullarPage() {

+ + ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 69a9ec4..13d0e20 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -84,24 +84,24 @@ export default function RootLayout({ className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} antialiased`} > - {/* Site geneli progressive blur — üst ve alt */} -
- -
-
- -
+ {/* Site geneli progressive blur — üst ve alt */} +
+ +
+
+ +
diff --git a/src/app/listem/listem-bos-cta.tsx b/src/app/listem/listem-bos-cta.tsx new file mode 100644 index 0000000..41b8fd8 --- /dev/null +++ b/src/app/listem/listem-bos-cta.tsx @@ -0,0 +1,38 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { PagePixelDivider } from "@/components/pixel-decor"; +import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store"; +import { sonucHref } from "@/lib/sihirbaz"; + +/** + * /listem boş hâlinin metni + CTA'sı: sıralamasını zaten girmiş adaya + * "sıralamanı gir" demek yerine sihirbazı açık hâlde sonuç sayfasına + * götürür; profil yoksa ana sayfadaki sıralama formuna yönlendirir. + */ +export function ListemBosCta() { + const profil = useTercihProfili(); + + return ( + <> +

+ {profil + ? "Sıralaman kayıtlı — birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin." + : "Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin."} +

+ + + + ); +} diff --git a/src/app/listem/listem-sekmeleri.tsx b/src/app/listem/listem-sekmeleri.tsx deleted file mode 100644 index 6f15506..0000000 --- a/src/app/listem/listem-sekmeleri.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { ListChecks, Sparkles } from "lucide-react"; -import { Tabs as TabsPrimitive } from "radix-ui"; -import { SecimlerimPaneli } from "@/components/manuel-liste/secimlerim-paneli"; -import { useManuelListe } from "@/components/manuel-liste/store"; - -export type ListemSekme = "secimlerim" | "ai"; - -export function ListemSekmeleri({ - varsayilan, - aiIcerik, - aiHazir, -}: { - varsayilan: ListemSekme; - aiIcerik: React.ReactNode; - aiHazir?: boolean; -}) { - const [sekme, setSekme] = useState(varsayilan); - const liste = useManuelListe(); - - function degistir(deger: string) { - const yeni = deger as ListemSekme; - setSekme(yeni); - const url = new URL(window.location.href); - url.searchParams.set("sekme", yeni); - window.history.replaceState(null, "", url); - } - - return ( - - - - - - Kendi Listem - {liste.length > 0 ? ( - - {liste.length} - - ) : null} - - - - AI Listem - {aiHazir ? ( - - ) : null} - - - - - - - {aiIcerik} - - - ); -} diff --git a/src/app/listem/page.tsx b/src/app/listem/page.tsx index 73e4509..04c09a5 100644 --- a/src/app/listem/page.tsx +++ b/src/app/listem/page.tsx @@ -3,11 +3,9 @@ import Link from "next/link"; import { asc, eq } from "drizzle-orm"; import { redirect } from "next/navigation"; import { - AlertTriangle, ArrowRight, Bot, Check, - Database, FileText, Lock, MessageCircleQuestion, @@ -22,14 +20,14 @@ import { URUNLER, MAX_REVIZYON } from "@/lib/credits"; import type { RaporSonuc } from "@/lib/ai/rapor"; import type { RaporParams } from "@/lib/rapor-havuzu"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; import { RaporListesi } from "@/components/rapor-listesi"; import { ViewportPortal } from "@/components/viewport-portal"; -import { PagePixelDivider } from "@/components/pixel-decor"; +import { SiteFooter } from "@/components/site-footer"; +import { SectionEyebrow } from "@/components/pixel-decor"; import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske"; import { SohbetClient, type Mesaj } from "./sohbet-client"; import { RevizyonKutusu } from "./revizyon-kutusu"; -import { ListemSekmeleri, type ListemSekme } from "./listem-sekmeleri"; +import { ListemBosCta } from "./listem-bos-cta"; export const metadata: Metadata = { title: "Listem", @@ -43,7 +41,7 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", { export default async function ListemPage({ searchParams, }: { - searchParams: Promise<{ acildi?: string; sekme?: string }>; + searchParams: Promise<{ acildi?: string }>; }) { const session = await verifySession("/listem"); const [user, params, satir, sohbetGecmisi] = await Promise.all([ @@ -65,54 +63,24 @@ export default async function ListemPage({ ]); if (!user) redirect("/giris?callback=/listem"); - const { acildi, sekme } = params; + const { acildi } = params; const kilitAcilisi = Boolean(user.hasPaket && acildi === "1"); const raporVar = Boolean(satir?.result); - const varsayilanSekme: ListemSekme = - sekme === "secimlerim" - ? "secimlerim" - : sekme === "ai" || acildi === "1" || raporVar - ? "ai" - : "secimlerim"; - - const aiBos = ( -
- -

- Henüz AI listen yok -

-

- Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; - sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin. - Kendi Listem sekmesinde ise tablolardan eklediğin programlar durur. -

- - -
- ); - if (!raporVar) { return ( -
-
-

- Tercih karar merkezin -

-

- Kendi listen ve AI listen burada yan yana — ikisi birbirinden - bağımsız. -

- -
- -
+ <> +
+
+ +

+ Henüz AI listen yok +

+ +
+
+ + ); } @@ -120,9 +88,19 @@ export default async function ListemPage({ ? (satir!.result as RaporSonuc) : raporMaskele(satir!.result as RaporSonuc); const raporParams = satir!.params as RaporParams; + // Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet + // /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor. + const danismanOzeti = rapor.genelDegerlendirme + ? { + metin: rapor.genelDegerlendirme, + uyarilar: user.hasPaket ? rapor.uyarilar : undefined, + kilitli: !user.hasPaket, + } + : undefined; - const aiIcerik = ( + return ( <> +
{kilitAcilisi ? (
{user.hasPaket ? ( - - - Paketin aktif - - ) : ( - - - YÖK Atlas verileri ücretsiz - - )} -

+ Paketin aktif + ) : null} +

{user.hasPaket ? "AI tercih listen" : "Veriyi ücretsiz incele. Kararı AI ile netleştir."} -

-

- {raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "} - {PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ?? - raporParams.tur}{" "} - ·{" "} - {user.hasPaket - ? "24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle aşağıda inceleyebilirsin." - : "24 programın 2024–2025 taban sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin."} -

+ + {user.hasPaket ? ( +

+ {raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "} + {PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ?? + raporParams.tur}{" "} + · 24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle + aşağıda inceleyebilirsin. +

+ ) : null}
{user.hasPaket ? (
@@ -189,40 +159,6 @@ export default async function ListemPage({ ) : null}
- {rapor.genelDegerlendirme ? ( -
-

- - Danışman özeti -

-

- {rapor.genelDegerlendirme} -

- {user.hasPaket && rapor.uyarilar?.length ? ( -
    - {rapor.uyarilar.map((uyari) => ( -
  • - - {uyari} -
  • - ))} -
- ) : null} - {!user.hasPaket ? ( -

- - Değerlendirmenin tamamı ve sıralamana özel uyarılar pakette. -

- ) : null} -
- ) : null} -
) : (
-
- - - Tercih Dönemi Paketi - -
-

+

Tablo ücretsiz.
Karar desteği pakette. @@ -317,14 +248,11 @@ export default async function ListemPage({ ))} -
-
-

Tek seferlik

-

- {PAKET_FIYATI} TL -

-
- Abonelik yok +
+

Tek seferlik

+

+ {PAKET_FIYATI} TL +

)} @@ -370,26 +299,8 @@ export default async function ListemPage({ Danışmana sor +

+ ); - - return ( -
-
-

- Tercih karar merkezin -

-

- Kendi listen ve AI listen burada yan yana — ikisi birbirinden - bağımsız. -

- -
- -
- ); } diff --git a/src/app/listem/sohbet-client.tsx b/src/app/listem/sohbet-client.tsx index 70e06e7..476d1a2 100644 --- a/src/app/listem/sohbet-client.tsx +++ b/src/app/listem/sohbet-client.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { ArrowUp, Coins, Sparkles } from "lucide-react"; +import { AlertTriangle, ArrowUp, Bot, Coins, Lock, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -29,6 +29,12 @@ export interface Mesaj { content: string; } +export interface DanismanOzeti { + metin: string; + uyarilar?: string[]; + kilitli?: boolean; +} + const ORNEK_SORULAR = [ "Listemdeki kırmızı (riskli) tercihler ne kadar riskli?", "İlk 5 tercihimin taban sıralaması trendi nasıl?", @@ -44,12 +50,15 @@ export function SohbetClient({ baslangicKredi, initialMesajlar, className, + ozet, }: { baslangicKredi: number; /** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */ initialMesajlar?: Mesaj[]; /** Boyutlandırma override'ı (ör. alt popup'ta h-full) */ className?: string; + /** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */ + ozet?: DanismanOzeti; }) { const [mesajlar, setMesajlar] = useState(initialMesajlar ?? []); const [girdi, setGirdi] = useState(""); @@ -154,20 +163,73 @@ export function SohbetClient({
- {mesajlar.length === 0 ? ( -
- -
-

- Listen hakkında her şeyi sorabilirsin -

-

- Cevaplar senin 24'lük listen, seçimlerin ve gerçek - YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi. + {ozet ? ( + +

+

+ + Danışman özeti

+ + {ozet.metin} + + {ozet.uyarilar?.length ? ( +
    + {ozet.uyarilar.map((uyari) => ( +
  • + + {uyari} +
  • + ))} +
+ ) : null} + {ozet.kilitli ? ( +

+ + Değerlendirmenin tamamı ve sıralamana özel uyarılar + pakette. +

+ ) : null}
+ + ) : null} + {mesajlar.length === 0 ? ( +
+ {!ozet ? ( + <> + +
+

+ Listen hakkında her şeyi sorabilirsin +

+

+ Cevaplar senin 24'lük listen, seçimlerin ve gerçek + YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi. +

+
+ + ) : null} {yuklendi ? ( -
+
{ORNEK_SORULAR.map((s) => ( ) : ( -
+
{[112, 148, 96, 132].map((w, i) => ( ; @@ -33,18 +30,7 @@ type DemoDurum = { tur: string; veri: DemoVeri; yukleniyor: boolean; - // Huni seçimleri ve kademeli facet cevapları - kategori: string | null; - il: string | null; - tip: "devlet" | "vakif" | null; - kategoriFacet: SihirbazFacetleri | null; // ?kategori= cevabı (iller buradan) - ilFacet: SihirbazFacetleri | null; // ?kategori&il cevabı (tipler buradan) - kategoriSec: (k: string | null) => void; - ilSec: (i: string | null) => void; - tipSec: (t: "devlet" | "vakif" | null) => void; - siraDegistir: (ham: string) => void; - turDegistir: (t: string) => void; - siraMetni: string; + profilVar: boolean; }; const DemoContext = createContext(null); @@ -58,13 +44,9 @@ function useDemo(): DemoDurum { async function facetGetir( sira: number, tur: string, - secim: { kategori?: string; il?: string; dilimler?: boolean }, signal: AbortSignal, ): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record } | null> { - const params = new URLSearchParams({ sira: String(sira), tur }); - if (secim.kategori) params.append("kategori", secim.kategori); - if (secim.il) params.append("il", secim.il); - if (secim.dilimler) params.set("dilimler", "1"); + const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" }); try { const res = await fetch(`/api/facetler?${params}`, { signal }); if (!res.ok) return null; @@ -86,36 +68,19 @@ export function SiralamaDemo({ baslangicVeri: DemoVeri; children: ReactNode; }) { - const [siraMetni, setSiraMetni] = useState( - baslangicSira.toLocaleString("tr-TR"), - ); - const [sira, setSira] = useState(baslangicSira); - const [tur, setTur] = useState("say"); + const profil = useTercihProfili(); + const sira = profil?.sira ?? baslangicSira; + const tur = profil?.tur ?? "say"; const [veri, setVeri] = useState(baslangicVeri); const [yukleniyor, setYukleniyor] = useState(false); - const [kategori, setKategori] = useState(null); - const [il, setIl] = useState(null); - const [tip, setTip] = useState<"devlet" | "vakif" | null>(null); - const [kategoriFacet, setKategoriFacet] = useState( - null, - ); - const [ilFacet, setIlFacet] = useState(null); - const zamanlayici = useRef | null>(null); - // Sıralama/tür değişti → ana veriyi tazele, huni seçimlerini sıfırla + // Profil yüklendi ve SSR örneğinden farklı → ana veriyi tazele useEffect(() => { - if (sira === baslangicSira && tur === "say" && veri === baslangicVeri) { - return; // ilk render: sunucudan gelen veri zaten güncel - } + if (sira === baslangicSira && tur === "say") return; const ctrl = new AbortController(); // eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti setYukleniyor(true); - setKategori(null); - setIl(null); - setTip(null); - setKategoriFacet(null); - setIlFacet(null); - facetGetir(sira, tur, { dilimler: true }, ctrl.signal) + facetGetir(sira, tur, ctrl.signal) .then((cevap) => { if (!cevap?.dilimToplam) return; setVeri({ facetler: cevap.facetler, dilimToplam: cevap.dilimToplam }); @@ -125,126 +90,51 @@ export function SiralamaDemo({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [sira, tur]); - // Kategori seçildi → o kategorinin illerini getir - useEffect(() => { - if (!kategori) return; - const ctrl = new AbortController(); - facetGetir(sira, tur, { kategori }, ctrl.signal).then((cevap) => { - if (cevap) setKategoriFacet(cevap.facetler); - }); - return () => ctrl.abort(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kategori]); - - // İl seçildi → kategori+il ile tipleri getir - useEffect(() => { - if (!kategori || !il) return; - const ctrl = new AbortController(); - facetGetir(sira, tur, { kategori, il }, ctrl.signal).then((cevap) => { - if (cevap) setIlFacet(cevap.facetler); - }); - return () => ctrl.abort(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kategori, il]); - - function siraDegistir(ham: string) { - const rakamlar = ham.replace(/\D/g, "").slice(0, 7); - setSiraMetni(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : ""); - if (zamanlayici.current) clearTimeout(zamanlayici.current); - const deger = Number(rakamlar); - if (!deger || deger < 1 || deger > 4_000_000) return; - zamanlayici.current = setTimeout(() => setSira(deger), 450); - } - const durum: DemoDurum = { sira, tur, veri, yukleniyor, - kategori, - il, - tip, - kategoriFacet, - ilFacet, - kategoriSec: (k) => { - setKategori(k); - setIl(null); - setTip(null); - setKategoriFacet(null); - setIlFacet(null); - }, - ilSec: (i) => { - setIl(i); - setTip(null); - setIlFacet(null); - }, - tipSec: setTip, - siraDegistir, - turDegistir: setTur, - siraMetni, + profilVar: profil !== null, }; return {children}; } -/** Sıralama girişi + puan türü — iki diyagramı birden süren tek kumanda. */ +/** Görsellerin hangi sıralamayla hesaplandığını söyleyen bilgi kartı. */ export function DemoKumanda() { - const { siraMetni, siraDegistir, tur, turDegistir, sira, yukleniyor } = - useDemo(); + const { sira, tur, yukleniyor, profilVar } = useDemo(); + const turEtiket = PUAN_TURU_ETIKET[tur] ?? tur; return (
- -
- siraDegistir(e.target.value)} - placeholder="ör. 85.000" - className="h-11 w-full rounded-xl border border-slate-200 bg-white px-4 text-base font-medium tabular-nums outline-none transition-colors focus:border-primary sm:w-44" - /> -
- {TUR_SECENEKLERI.map((t) => ( - - ))} -
-
+

+ {profilVar + ? "Görseller senin sıralamana göre hesaplandı" + : "Görseller örnek bir sıralamayla gösteriliyor"} +

- {yukleniyor - ? "Hesaplanıyor…" - : `Aşağıdaki tüm sayılar ${sira.toLocaleString("tr-TR")}. sıradaki bir aday için gerçek YÖK Atlas verisidir.`} + {yukleniyor ? ( + "Hesaplanıyor…" + ) : ( + <> + + {sira.toLocaleString("tr-TR")}. + {" "} + sıra · {turEtiket} — aşağıdaki tüm sayılar bu aday için gerçek YÖK + Atlas verisidir. + {!profilVar && + " Ana sayfadan kendi sıralamanı girersen bu sayfa sana göre güncellenir."} + + )}

); } -/** Sıralama ekseni: girilen sıraya göre dilim sınırları ve gerçek adetler. */ +/** Sıralama ekseni: adayın sırasına göre dilim sınırları ve gerçek adetler. */ export function DilimSeridiCanli() { const { sira, veri, yukleniyor } = useDemo(); const t = veri.dilimToplam; @@ -334,307 +224,28 @@ export function DilimSeridiCanli() { ); } -function HuniCubugu({ - etiket, - adet, - oran, -}: { - etiket: string; - adet: number; - oran: number; -}) { - return ( -
-
- {etiket} - - {adet.toLocaleString("tr-TR")} - -
-
- ); -} +/** + * Sayfanın kapanış kartı: profili olmayan ziyaretçiyi ana sayfadaki sıralama + * formuna, sıralamasını zaten girmiş adayı ise doğrudan sonuç sayfasına + * gönderir — "sıralamanı gir" CTA'sı profil varken gösterilmez. + */ +export function DemoKapanisCta() { + const profil = useTercihProfili(); -function SecimCipleri({ - secenekler, - secili, - onSec, -}: { - secenekler: T[]; - secili: string | null; - onSec: (anahtar: string | null) => void; -}) { return ( -
- {secenekler.map((s) => ( - - ))} -
- ); -} - -// AI listesinin dilim iskeleti: üstte hayal, ortada dengeli, sonda garanti. -// Ürünün risk renkleriyle boyanır; sayılar prompt'taki kuralın orta değeri. -const LISTE_ISKELETI: { dilim: string; adet: number; renk: string }[] = [ - { dilim: "hayal", adet: 5, renk: "bg-red-400" }, - { dilim: "dengeli", adet: 13, renk: "bg-amber-400" }, - { dilim: "garanti", adet: 6, renk: "bg-emerald-500" }, -]; - -/** Huninin finali: kalan havuzun yapay zekâya gidişi ve 24'lük listenin - iskeleti. Havuz 24'ün altına düşerse gevşetme kuralını canlı gösterir. */ -function YapayZekaAsamasi({ havuz }: { havuz: number }) { - const dar = havuz < 24; - return ( -
-

- Sonra sıra yapay zekâda +

+

+ {profil + ? "Kuralları öğrendin — sayılar zaten senin sıralamanla hesaplandı." + : "Kuralları öğrendin — şimdi kendi sıralamanla dene."}

-

- Seçimlerinden geriye kalan{" "} - - {havuz.toLocaleString("tr-TR")} programlık havuz - {" "} - yapay zekâya gider; o da bu havuzdan tam 24 tercih seçer ve şu iskelete - oturtur: -

- - {/* 24 hücrelik liste şeridi — renk + metin birlikte (renk tek başına - anlam taşımaz) */} -
-
- {LISTE_ISKELETI.flatMap((b) => - Array.from({ length: b.adet }, (_, i) => ( - - )), - )} -
-
- - üstte 4–6 hayal - - - ortada 12–14{" "} - dengeli - - - sonda 5–7{" "} - garanti - -
-
- -
    -
  • - · Havuzda olmayan bir bölümü yazamaz — listedeki her satırın kimliği - havuzla karşılaştırılır, uymayan liste yeniden ürettirilir. -
  • -
  • - · Her satıra tek cümlelik gerekçe ve bir risk notu ekler; sıraya neyi - neden koyduğu görünür. -
  • -
  • - · Önceliklerin (iş garantisi, maaş, prestij…) hangi programın öne - geçeceğini belirler. -
  • -
  • - · Havuz 24'ün altına düşerse filtreler adım adım gevşetilir — - önce il, sonra alan — ve bu, listende açıkça belirtilir. -
  • -
- - {dar ? ( -

- Bu seçimle havuz 24'ün altına düştü. Gerçek sihirbaz bu durumda - filtreleri adım adım gevşetir — önce il, sonra alan tercihi esnetilir - — ve bunu listende açıkça belirtir; liste her zaman 24'e - tamamlanır. -

- ) : null} -
- ); -} - -/** Kademeli filtre hunisi: gerçek sihirbaz mantığının oynanabilir kopyası. */ -export function HuniCanli() { - const { - veri, - yukleniyor, - kategori, - il, - tip, - kategoriFacet, - ilFacet, - kategoriSec, - ilSec, - tipSec, - } = useDemo(); - - const toplam = veri.facetler.toplam; - const kategoriAdet = - kategori != null - ? (veri.facetler.kategoriler.find((k) => k.ad === kategori)?.adet ?? 0) - : null; - const ilAdet = - il != null && kategoriFacet - ? (kategoriFacet.iller.find((i) => i.il === il)?.adet ?? 0) - : null; - const tipAdet = - tip != null && ilFacet - ? (ilFacet.uniturler.find((u) => u.grup === tip)?.adet ?? 0) - : null; - - const kategoriSecenekleri = veri.facetler.kategoriler.slice(0, 6).map((k) => ({ - anahtar: k.ad, - etiket: k.ad, - adet: k.adet, - })); - const ilSecenekleri = (kategoriFacet?.iller ?? []).slice(0, 6).map((i) => ({ - anahtar: i.il, - etiket: i.il.toLocaleLowerCase("tr-TR"), - adet: i.adet, - })); - const tipSecenekleri = (ilFacet?.uniturler ?? []).map((u) => ({ - anahtar: u.grup, - etiket: u.grup === "devlet" ? "Devlet" : "Vakıf", - adet: u.adet, - })); - - // Yapay zekâya giden havuz: huninin en derin (en son seçilmiş) katmanı - const havuz = tipAdet ?? ilAdet ?? kategoriAdet ?? toplam; - - return ( -
- {/* Huni çubukları — her seçim bir çubuk daha ekler */} -
-
- - -
- {kategori != null && kategoriAdet != null ? ( -
- - 1. - - 0 ? kategoriAdet / toplam : 0} - /> -
- ) : null} - {il != null && ilAdet != null ? ( -
- - 2. - - 0 ? ilAdet / toplam : 0} - /> -
- ) : null} - {tip != null && tipAdet != null ? ( -
- - 3. - - 0 ? tipAdet / toplam : 0} - /> -
- ) : null} -
- - {/* Seçim adımları — sihirbazın küçük, oynanabilir hali */} -
-
-

- 1. Bir alan seç -

- -
- {kategori != null ? ( -
-

- 2. Bir il seç{" "} - - (iller artık yalnızca “{kategori}” programlarını sayıyor) - -

- {ilSecenekleri.length > 0 ? ( - - ) : ( -

Yükleniyor…

- )} -
- ) : null} - {kategori != null && il != null ? ( -
-

- 3. Üniversite tipi seç{" "} - - (sayılar hem alanı hem ili hesaba katıyor) - -

- {tipSecenekleri.length > 0 ? ( - tipSec(t as "devlet" | "vakif" | null)} - /> - ) : ( -

Yükleniyor…

- )} -
- ) : null} -
- - + {profil ? "Sıralamana uygun programları gör" : "Sıralamanı gir"} + +
); } diff --git a/src/app/meraklisina/huni-semasi.tsx b/src/app/meraklisina/huni-semasi.tsx new file mode 100644 index 0000000..31c3f94 --- /dev/null +++ b/src/app/meraklisina/huni-semasi.tsx @@ -0,0 +1,54 @@ +// Sihirbaz hunisinin statik anlatımı: interaktif demo yerine akışın tamamını +// tek bakışta gösteren bir mermaid şeması. Çizim, ürün genelindeki ortak +// "el çizimi" bileşeniyle yapılır (bkz. components/el-cizimi-sema.tsx). + +import { ElCizimiSema } from "@/components/el-cizimi-sema"; + +// Renkler ürünün risk dilinden (kırmızı/sarı/yeşil) ve slate paletinden; +// renk tek başına anlam taşımaz, her düğüm metinle etiketlidir. +const SEMA = `--- +config: + look: handDrawn +--- +flowchart TD + T("Sıralamanla açılan tüm programlar") --> A("1. adım · Alan seçimi
yalnızca ulaşabildiğin alanlar listelenir") + A --> B("2. adım · İl seçimi
iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır") + B --> C("3. adım · Devlet / Vakıf
sayılar hem alanı hem ili hesaba katar") + C --> H("Aday havuzu
elde kalan gerçek programlar") + H --> S{"Havuzda 24'ten az program mı kaldı?"} + S -- "evet" --> G("Filtreler adım adım gevşetilir
önce il, sonra alan — listede açıkça belirtilir") + G --> H + S -- "hayır" --> Y("Yapay zekâ havuzdan tam 24 tercih seçer
havuzda olmayan bölümü yazamaz, her satıra gerekçe + risk notu ekler") + Y --> L("24 tercihlik liste") + L --> L1("üstte 4–6 hayal") + L --> L2("ortada 12–14 dengeli") + L --> L3("sonda 5–7 garanti") + + classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a + classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e + classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b + classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e + classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46 + class H,Y havuz + class G uyari + class L1 hayal + class L2 dengeli + class L3 garanti`; + +export function HuniSemasi() { + return ( + + Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "} + + dışına asla çıkmadan + {" "} + kurar. + + } + /> + ); +} diff --git a/src/app/meraklisina/page.tsx b/src/app/meraklisina/page.tsx index 27af064..8ba2f7b 100644 --- a/src/app/meraklisina/page.tsx +++ b/src/app/meraklisina/page.tsx @@ -1,35 +1,37 @@ import type { Metadata } from "next"; -import Link from "next/link"; -import { ArrowRight } from "lucide-react"; import { Parallax } from "@/components/parallax"; -import { PagePixelDivider } from "@/components/pixel-decor"; +import { PagePixelDivider, SectionEyebrow } from "@/components/pixel-decor"; +import { SiteFooter } from "@/components/site-footer"; import { dilimAra, rankWindowFacets, type DilimKey } from "@/lib/db"; import { + DemoKapanisCta, DemoKumanda, DilimSeridiCanli, - HuniCanli, SiralamaDemo, type DemoVeri, } from "./demo"; +import { HuniSemasi } from "./huni-semasi"; export const metadata: Metadata = { title: "Meraklısına: KolayTercih nasıl çalışır?", alternates: { canonical: "/meraklisina" }, description: - "Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, kendi sıralamanla deneyebileceğin canlı görsellerle.", + "Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, sıralamana göre hesaplanan canlı görsellerle.", }; /** * Algoritmanın öğrenci diline çevrilmiş anlatımı. Teknik detay (SQL, çarpan * mutfağı) bilerek verilmez; amaç güven: veri gerçek, kurallar şeffaf. - * Görseller canlıdır: kullanıcı sıralamasını girer, dilim şeridi ve filtre - * hunisi o sıralamanın gerçek sayılarıyla güncellenir (bkz. demo.tsx). + * Dilim şeridi canlıdır: kullanıcının kayıtlı tercih profilindeki sıralama + * okunur, şerit o sıralamanın gerçek sayılarıyla güncellenir (bkz. demo.tsx); + * profil yoksa örnek sıralama gösterilir. Sihirbaz hunisi ise statik + * bir mermaid şemasıyla anlatılır (bkz. huni-semasi.tsx). * Diyagramlar ürünün risk renk dilini kullanır (yeşil/sarı/kırmızı) ve her * bölge metinle de etiketlenir — renk tek başına anlam taşımaz. */ -// Demo ilk açılışta bu sıralamayla dolu gelir; SSR'da gerçek veriyle render -// edilir ki kullanıcı girmeden de sayfa anlamlı olsun. +// Profili olmayan ziyaretçi bu örnek sıralamayı görür; SSR'da gerçek veriyle +// render edilir ki profil olmadan da sayfa anlamlı olsun. const VARSAYILAN_SIRA = 100_000; export default function MeraklisinaPage() { @@ -45,19 +47,18 @@ export default function MeraklisinaPage() { }; return ( + <>
-

- Meraklısına -

-

+ Meraklısına +

Sıralamanı girince arka planda ne oluyor?

Sihir yok, kara kutu yok: KolayTercih'in önerdiği her satır resmî YÖK Atlas verisinden gelir ve hangi kurala göre önüne geldiği bellidir. Bu sayfa o kuralları öğrenci diliyle anlatır — üstelik görseller - canlı: kendi sıralamanı gir, sayılar sana göre değişsin. + canlı: sıralamanı girdiysen sayılar sana göre hesaplanır.

@@ -139,7 +140,7 @@ export default function MeraklisinaPage() { 4. Sihirbaz süzer, yapay zekâ kurar

- “Listemi oluştur” sihirbazındaki üç adım birbirinden + “AI listemi kur” sihirbazındaki üç adım birbirinden bağımsız değildir. İlk adımda yalnızca{" "} sıralamanla ulaşabildiğin{" "} alanlar listelenir; ikinci adımdaki iller, seçtiğin alanlardaki @@ -148,11 +149,10 @@ export default function MeraklisinaPage() { Adımlar bitince elde kalan gerçek programlar bir{" "} aday havuzu{" "} olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz - verilir. Aşağıda kendin dene — her seçimin havuzu nasıl - daralttığını ve havuzun 24'lük listeye nasıl dönüştüğünü huni - canlı gösterir. + verilir. Aşağıdaki şema akışın tamamını gösterir — havuz + 24'ün altına düşerse filtrelerin nasıl gevşetildiği de dahil.

- +
@@ -170,20 +170,11 @@ export default function MeraklisinaPage() {

-
-

- Kuralları öğrendin — şimdi kendi sıralamanla dene. -

- - Sıralamanı gir - - -
+
+ + ); } diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index a1ce824..2c6af1f 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -10,8 +10,8 @@ export default function NotFound() { Böyle bir sayfa yok

- Aradığın sayfa taşınmış ya da hiç var olmamış olabilir. Sıralamanı - girip listeni kurmaya ana sayfadan devam edebilirsin. + Aradığın sayfa taşınmış ya da hiç var olmamış olabilir. Listeni + kurmaya ana sayfadan devam edebilirsin.

-

- {listeHref - ? "24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık." - : "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."} -

+ {listeHref ? ( + <> + +

+ 24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık. +

+ + ) : ( + + )}
) : ( @@ -118,6 +122,7 @@ export default async function OdemeSonucPage({
)}
+
); } diff --git a/src/app/odeme/sonuc/rapor-yok-cta.tsx b/src/app/odeme/sonuc/rapor-yok-cta.tsx new file mode 100644 index 0000000..aaab72d --- /dev/null +++ b/src/app/odeme/sonuc/rapor-yok-cta.tsx @@ -0,0 +1,36 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store"; +import { sonucHref } from "@/lib/sihirbaz"; + +/** + * Ödeme başarılı ama henüz AI listesi yokken gösterilen CTA: kayıtlı tercih + * profili olan adayı sihirbazı açık hâlde sonuç sayfasına götürür ve + * "sıralamanı gir" metni gösterilmez; profil yoksa ana sayfadaki sıralama + * formuna yönlendirir. + */ +export function RaporYokCta() { + const profil = useTercihProfili(); + + return ( + <> + +

+ {profil + ? "Sıralaman kayıtlı — sonuç sayfasındaki sihirbazla listeni kur." + : "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."} +

+ + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 6fb4ba1..82eed20 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,7 +12,8 @@ import { } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -import { HeroFocusButton } from "@/components/hero-focus-button"; +import { HeroBaslik } from "@/components/hero-baslik"; +import { KapanisCta } from "@/components/hero-focus-button"; import { HeroForm } from "@/components/hero-form"; import { Parallax } from "@/components/parallax"; import { Reveal } from "@/components/landing/reveal"; @@ -24,7 +25,6 @@ import { SectionEyebrow, } from "@/components/pixel-decor"; import { URUNLER } from "@/lib/credits"; -import { Badge } from "@/components/ui/badge"; import { Accordion, AccordionContent, @@ -209,13 +209,7 @@ export default function Home() { strength={14} className="mx-auto flex min-h-[calc(100svh-5.5rem)] max-w-6xl flex-col items-center justify-center px-4 pb-4 pt-12 text-center sm:pb-36 sm:pt-4" > -

- Dört yıllık geleceğini
- - 48 saatlik panikle - {" "} - seçme -

+ {/*

KolayTercih, gerçek YÖK Atlas verisiyle çalışan yapay zekâ tercih danışmanın. İnsan danışmanın binlerce lira aldığı işi — dengeli @@ -227,10 +221,6 @@ export default function Home() { >

-

- Temel arama her zaman ücretsiz · Kredi kartı gerekmez · Tam analiz - paketi tek seferlik {PAKET_FIYATI} TL -

Tercih listen, pişmanlık listesi olmasın -

- Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun. - Tercih dönemi bitmeden yerini al. -

- +
diff --git a/src/app/paket/page.tsx b/src/app/paket/page.tsx index f67ae1e..806085e 100644 --- a/src/app/paket/page.tsx +++ b/src/app/paket/page.tsx @@ -12,10 +12,10 @@ import { import { getCurrentUser } from "@/lib/session"; import { URUNLER, DENEME_KREDISI } from "@/lib/credits"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; +import { SectionEyebrow } from "@/components/pixel-decor"; import { Parallax } from "@/components/parallax"; -import { PagePixelDivider } from "@/components/pixel-decor"; import { SatinAlForm } from "./satin-al-form"; +import { SiteFooter } from "@/components/site-footer"; export const metadata: Metadata = { title: "Tercih Dönemi Paketi", @@ -68,9 +68,7 @@ export default async function PaketPage({
) : null} - - Tek seferlik ödeme · Abonelik yok - + Tek seferlik ödeme · Abonelik yok

İnsan danışmanın işini,{" "} onda bir fiyatına @@ -81,8 +79,6 @@ export default async function PaketPage({

- -

+
); } diff --git a/src/app/sonuc/page.tsx b/src/app/sonuc/page.tsx index 87efaf7..94e839c 100644 --- a/src/app/sonuc/page.tsx +++ b/src/app/sonuc/page.tsx @@ -1,6 +1,4 @@ -import Link from "next/link"; import type { Metadata } from "next"; -import { ArrowLeft } from "lucide-react"; import { eq } from "drizzle-orm"; import { PUAN_TURLERI, @@ -14,13 +12,11 @@ import type { RaporSonuc } from "@/lib/ai/rapor"; import type { RaporParams } from "@/lib/rapor-havuzu"; import { raporMaskele } from "@/lib/rapor-maske"; import { SihirbazBolumu } from "./sihirbaz-cagri-karti"; -import { FunnelBanner } from "./funnel-banner"; import type { MevcutRapor } from "./liste-paneli"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { ProgramTablosu } from "@/components/program-tablosu"; import { ManuelHarita } from "@/components/manuel-liste/manuel-harita"; -import { PagePixelDivider } from "@/components/pixel-decor"; +import { SiteFooter } from "@/components/site-footer"; +import { SiraGerekli } from "./sira-gerekli"; export const metadata: Metadata = { title: "Tercih Listeni Kur", @@ -30,14 +26,6 @@ export const metadata: Metadata = { robots: { index: false, follow: true }, }; -const TUR_LABELS: Record = { - say: "Sayısal", - ea: "Eşit Ağırlık", - soz: "Sözel", - dil: "Dil", - tyt: "TYT (Önlisans)", -}; - export default async function SonucPage({ searchParams, }: { @@ -58,21 +46,10 @@ export default async function SonucPage({ if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) { return ( -
-

- Geçerli bir sıralama gerekli -

-

- Listeni kurabilmek için ana sayfadan YKS başarı sıralamanı gir. -

- - -
+ <> + + + ); } @@ -108,51 +85,15 @@ export default async function SonucPage({ return (
-
- - {sira.toLocaleString("tr-TR")}. sıra · {TUR_LABELS[turKey]} - - -
-

- {sira.toLocaleString("tr-TR")}. sıradaki bir aday için tercih planı -

-

- Birkaç adımda ilgi alanlarını ve şehir tercihini seç; yapay zekâ - gerçek YÖK Atlas verisiyle 24 tercihlik dengeli listeni kursun. Her - satır yerleşme riskine göre renklenir:{" "} - yeşil güvenli,{" "} - sarı az riskli,{" "} - kırmızı riskli. -

- - {/* Sayfa hiyerarşisi: önce Türkiye haritası, sonra liste. Manuel liste store'una abone olduğundan tablodan "+" ile eklenenler buraya canlı yansır. */} -
- -
+ {/* AI'dan bağımsız ham tablo — haritanın hemen altında, sayfanın ana - gövdesi; puan türü seçici de listenin başında yaşar */} + gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */} - {/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor - varsa /listem paywall'ına) iten banner — paketli kullanıcıya - satacak bir şey kalmadığından gösterilmez */} - {!hasPaket ? : null} - {/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli sayfanın en altında yaşar */}
+
); } diff --git a/src/app/sonuc/sihirbaz-cagri-karti.tsx b/src/app/sonuc/sihirbaz-cagri-karti.tsx index 9b858e6..be20050 100644 --- a/src/app/sonuc/sihirbaz-cagri-karti.tsx +++ b/src/app/sonuc/sihirbaz-cagri-karti.tsx @@ -3,22 +3,24 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Sparkles, X } from "lucide-react"; +import { ArrowRight, X } from "lucide-react"; import { animate } from "motion"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { ViewportPortal } from "@/components/viewport-portal"; +import { PixelField } from "@/components/pixel-decor"; import type { SihirbazFacetleri } from "@/lib/db"; import { + SIHIRBAZ_AC_EVENT, SIHIRBAZ_STORAGE_KEY, sihirbazDogrula, + sonucHref, tercihProfiliSil, tercihProfiliYaz, type SihirbazSecimleri, } from "@/lib/sihirbaz"; import { SihirbazModal } from "./sihirbaz-modal"; -import { SIHIRBAZ_AC_EVENT } from "./funnel-banner"; import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli"; import { listeOlustur, listeRevize } from "./actions"; @@ -86,6 +88,9 @@ export function SihirbazBolumu({ const [elleKapandi, setElleKapandi] = useState(false); const [kartGizle, setKartGizle] = useState(false); const [bekleyen, setBekleyen] = useState(null); + // ?sihirbaz=1 fallback modalı storage okunmadan açılmasın: hero'dan gelen + // kayıtlı seçimler varken modal bir anlığına açılıp kapanıyordu. + const [depoOkundu, setDepoOkundu] = useState(false); const [rapor, setRapor] = useState(mevcutRapor); const [kredi, setKredi] = useState(baslangicKredi); @@ -101,6 +106,9 @@ export function SihirbazBolumu({ const requestIdRef = useRef(null); const otomatikBasladi = useRef(false); const bolumRef = useRef(null); + // Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi + // görsün diye ref'te tutulur; olay dinleyicisi bir kez bağlanır. + const bekleyenRef = useRef(null); // Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku useEffect(() => { @@ -124,18 +132,20 @@ export function SihirbazBolumu({ } // eslint-disable-next-line react-hooks/set-state-in-effect if (kaliciKapali) setKartGizle(true); - if (yeniBekleyen) setBekleyen(yeniBekleyen); + if (yeniBekleyen) { + setBekleyen(yeniBekleyen); + bekleyenRef.current = yeniBekleyen; + } } catch {} + setDepoOkundu(true); }, [sira, tur]); - // Üstteki funnel banner'ının CTA'sı sihirbaz modalını bu olayla açar + // Arama modalındaki funnel kartının CTA'sı sihirbazı bu olayla başlatır useEffect(() => { - const ac = () => { - setElleAcik(true); - setElleKapandi(false); - }; + const ac = () => sihirbaziBaslat(); window.addEventListener(SIHIRBAZ_AC_EVENT, ac); return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /** @@ -241,7 +251,7 @@ export function SihirbazBolumu({ function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) { tercihProfiliYaz({ sira, tur, secimler }); - const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`; + const geri = sonucHref({ sira, tur }, { sihirbaz: "1" }); router.push(`/giris?callback=${encodeURIComponent(geri)}`); } @@ -255,6 +265,22 @@ export function SihirbazBolumu({ await uret(secimler); } + /** + * Funnel CTA'larının (banner, büyük kart, sabit alt kart) ortak girişi: + * adımlar zaten tamamlanıp kaydedildiyse modal açılmaz — girişli + * kullanıcıda üretim doğrudan başlar, girişsiz kullanıcı girişe gider. + * Kayıtlı seçim yoksa (ya da başka sıra/türe aitse) modal açılır. + */ + function sihirbaziBaslat() { + const hazir = bekleyenRef.current; + if (hazir?.eslesir) { + void ilerle(hazir.secimler); + return; + } + setElleAcik(true); + setElleKapandi(false); + } + // Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla // (bir kez, modal açmadan — üretim sayfa gövdesinde oynar). const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && !rapor); @@ -268,11 +294,15 @@ export function SihirbazBolumu({ }, [otomatikUret]); // ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/ - // eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. + // eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. Karar + // storage okunmadan verilmez: kayıtlı seçimlerle gelen kullanıcıya modal + // hiç gösterilmez, üretim doğrudan başlar. const acik = !elleKapandi && (elleAcik || - Boolean(otomatikAc && !otomatikUret && !rapor && durum === "bos")); + Boolean( + otomatikAc && depoOkundu && !otomatikUret && !rapor && durum === "bos", + )); function acikDegisti(v: boolean) { if (v) { @@ -293,7 +323,7 @@ export function SihirbazBolumu({ // mount sonrası değişir ve AnimatePresence geçişi donardı. const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor); const girisUrl = `/giris?callback=${encodeURIComponent( - `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`, + sonucHref({ sira, tur }, { sihirbaz: "1" }), )}`; const panelAnahtari = @@ -393,11 +423,17 @@ export function SihirbazBolumu({ ) : girisBekliyor ? ( -
-

+
+
+ +
+

Seçimlerin hazır — AI listen bir adım uzakta

-

+

Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24 tercihlik AI listeni de kurabilirsin: ücretsiz giriş yap, seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3 @@ -406,34 +442,41 @@ export function SihirbazBolumu({ -

+

Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede

) : ( -
-

- Bu listeyi 24 tercihlik plana çevirelim mi? +
+
+ +
+

+ 24 tercihi tek tek elemek yerine, yapay zekâ kursun

-

- Birkaç adımda ilgi alanların ve şehir tercihine göre - gerekçeli, dengeli bir tercih listesi kuralım — her satır için - yerleşme riski analiziyle. +

+ Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi + alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli + ve garanti dilimlerine dağıtılmış 24 tercihi — her satırı + yerleşme riski ve gerekçesiyle — senin yerine dizer.

)} @@ -450,12 +493,12 @@ export function SihirbazBolumu({

{girisBekliyor ? "Seçimlerin kayıtlı — AI listen hazır bekliyor" - : "3 adımda 24 tercihlik listen"} + : "Tek tek ekleme, yapay zekâ kursun"}

{girisBekliyor ? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun." - : "Sıralamana uygun bölümleri seç, yapay zekâ listeni kursun."} + : "Üç adımda seçimlerini söyle; risk analizli, gerekçeli 24 tercih hazır."}

{girisBekliyor ? ( @@ -469,7 +512,7 @@ export function SihirbazBolumu({ ) : ( + + + + ); +} diff --git a/src/app/sonuc/sohbet-popup.tsx b/src/app/sonuc/sohbet-popup.tsx index 3b77eee..5e90895 100644 --- a/src/app/sonuc/sohbet-popup.tsx +++ b/src/app/sonuc/sohbet-popup.tsx @@ -8,19 +8,51 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; import { MessageCircleMore, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { Skeleton } from "@/components/ui/skeleton"; import { ViewportPortal } from "@/components/viewport-portal"; +function SohbetSkeleton() { + return ( +
+
+ + +
+
+ + +
+ + +
+
+ {[112, 148, 96, 132].map((w, i) => ( + + ))} +
+
+
+ +
+
+ ); +} + // Sohbet istemcisi (markdown zinciriyle birlikte) ağır; popup ilk açıldığında // yüklensin — /sonuc ilk yükünü şişirmesin. const SohbetClient = dynamic( () => import("@/app/listem/sohbet-client").then((m) => m.SohbetClient), { ssr: false, - loading: () => ( -
- Sohbet yükleniyor… -
- ), + loading: () => , }, ); diff --git a/src/app/sonuc/funnel-banner.tsx b/src/components/arama-funnel-karti.tsx similarity index 54% rename from src/app/sonuc/funnel-banner.tsx rename to src/components/arama-funnel-karti.tsx index 08ac17f..aaa0bdc 100644 --- a/src/app/sonuc/funnel-banner.tsx +++ b/src/components/arama-funnel-karti.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; import { ArrowRight, ChartNoAxesCombined, @@ -10,9 +10,11 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { TypingAnimation } from "@/components/ui/typing-animation"; - -/** Banner CTA'sının sihirbaz modalını açmak için yayınladığı olay. */ -export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac"; +import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/lib/sihirbaz"; +import { + profilToplamaAc, + useTercihProfili, +} from "@/components/manuel-liste/profil-kapisi-store"; const FUNNEL_METINLERI = [ "Sıralamana en uygun listeyi birlikte kuralım.", @@ -47,7 +49,7 @@ const FUNNEL_METINLERI = [ "İyi bir liste, iyi bir başlangıçtır.", ]; -/** Fisher-Yates ile kopya üzerinde karıştırır; her ziyarette farklı sıra. */ +/** Fisher-Yates ile kopya üzerinde karıştırır; her açılışta farklı sıra. */ function karistir(dizi: readonly T[]): T[] { const kopya = [...dizi]; for (let i = kopya.length - 1; i > 0; i--) { @@ -64,47 +66,59 @@ const KANITLAR = [ ]; /** - * Puan türü badge'lerinin hemen altında yaşayan satın alım funnel banner'ı. - * Rastgele sırayla yazılıp silinen samimi ve profesyonel funnel - * metinleriyle adayı sihirbaza; rapor zaten varsa kilitli listenin - * durduğu /listem paywall'ına iter. - * Tasarım: güven veren lacivert yüzey, veri kanıtları ve turuncu CTA. + * Arama modalının açılış ekranında yaşayan satın alım funnel kartı. + * Rastgele sırayla yazılıp silinen funnel metinleriyle adayı sihirbaza + * iter: /sonuc üzerindeyse oradaki orkestratörü olayla açar, başka + * sayfadaysa profile göre /sonuc'a (sihirbaz açık) yönlendirir ya da + * sıralama kapısını açar. Modal yalnızca açıkken mount olduğundan + * karıştırma her açılışta yenilenir ve hydration sorunu yaratmaz. */ -export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) { - // İlk render'da bir kez karıştırılır; SSR boş metinle başladığı için - // hydration uyuşmazlığı yaratmaz. +export function AramaFunnelKarti({ onSec }: { onSec: () => void }) { + const router = useRouter(); + const pathname = usePathname(); + const profil = useTercihProfili(); const [metinler] = useState(() => karistir(FUNNEL_METINLERI)); - const ctaSinif = - "h-12 w-full shrink-0 cursor-pointer rounded-xl bg-orange-500 px-5 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto"; + function sihirbazaGit() { + onSec(); + if (pathname === "/sonuc") { + window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT)); + return; + } + if (profil) { + router.push(sonucHref(profil, { sihirbaz: "1" })); + return; + } + profilToplamaAc("sonuc"); + } return (
-
+
-
+
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
- {hedefListem ? ( - - ) : ( - - )} +
); diff --git a/src/components/cta-sira-form.tsx b/src/components/cta-sira-form.tsx index ac47797..c0dde05 100644 --- a/src/components/cta-sira-form.tsx +++ b/src/components/cta-sira-form.tsx @@ -1,11 +1,161 @@ -import { ArrowRight } from "lucide-react"; +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + ArrowRight, + ChartNoAxesCombined, + Database, + ListChecks, + Trophy, + X, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerTitle, +} from "@/components/ui/drawer"; +import { SectionEyebrow } from "@/components/pixel-decor"; +import { + SihirbazAdimlarLazy, + preloadSihirbazAdimlar, +} from "@/components/sihirbaz-adimlar-lazy"; +import { authClient } from "@/lib/auth-client"; +import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store"; +import type { SihirbazFacetleri } from "@/lib/db"; +import { + sonucHref, + tercihProfiliYaz, + type SihirbazSecimleri, +} from "@/lib/sihirbaz"; + +// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada. +const PUAN_TURLERI_SECENEK = [ + { deger: "say", etiket: "Sayısal" }, + { deger: "ea", etiket: "Eşit Ağırlık" }, + { deger: "soz", etiket: "Sözel" }, + { deger: "dil", etiket: "Dil" }, + { deger: "tyt", etiket: "TYT" }, +] as const; + +// Kanıt üçlüsü arama funnel kartıyla aynı dili konuşur (bkz. arama-funnel-karti) +const KANITLAR = [ + { ikon: ListChecks, etiket: "24 tercihlik plan" }, + { ikon: ChartNoAxesCombined, etiket: "Risk analizi" }, + { ikon: Database, etiket: "YÖK Atlas verisi" }, +] as const; /** - * Programatik SEO sayfalarındaki funnel girişi: JS'siz düz GET formu, - * /sonuc?sira=&tur= akışına düşer. Client bileşen DEĞİL — sayfalar tamamen - * statik prerender edilir, form tarayıcının kendi submit'iyle çalışır. + * Programatik SEO sayfalarındaki funnel girişi. Statik/SSR çıktı her zaman + * profilsiz hâldir (sayfalar prerender edilmeye devam eder); hydration + * sonrası kayıtlı tercih profili bulunursa "sıralamanı gir" yerine doğrudan + * sonuç sayfasına götüren CTA gösterilir — sıralamasını girmiş adaya bir + * daha girmesini söylemeyiz. + * + * Profilsiz CTA adayı ana sayfaya göndermez: buton alttan bir çekmece açar, + * sıralama girişi ve sihirbaz adayın bulunduğu sayfanın bağlamında akar. + * Tasarım gerekçesi için bkz. CTA-CEKMECE-TASARIMI.md. */ export function CtaSiraForm({ baslik }: { baslik?: string }) { + const profil = useTercihProfili(); + const router = useRouter(); + + const [acik, setAcik] = useState(false); + const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira"); + const [siralama, setSiralama] = useState(""); + const [tur, setTur] = useState("say"); + const [sira, setSira] = useState(null); + const [facetler, setFacetler] = useState(null); + const [yukleniyor, setYukleniyor] = useState(false); + const [hata, setHata] = useState(null); + const [girisli, setGirisli] = useState(false); + + // Yazarken binlik ayracıyla biçimle (85000 → 85.000) + function siralamaDegisti(ham: string) { + setHata(null); + const rakamlar = ham.replace(/\D/g, "").slice(0, 7); + setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : ""); + } + + async function siraGonder(e: React.FormEvent) { + e.preventDefault(); + const value = Number(siralama.replace(/\./g, "")); + if (!value || value < 1 || value > 4_000_000) { + setHata("Geçerli bir başarı sıralaması gir (ör. 85.000)."); + return; + } + setYukleniyor(true); + try { + // Oturum bilgisi facet isteğiyle PARALEL çözülür; sayfa statik kalır + // (hero formundaki desenle aynı — bkz. hero-form.tsx). + const [res, oturum] = await Promise.all([ + fetch(`/api/facetler?sira=${value}&tur=${tur}`), + authClient.getSession().catch(() => null), + ]); + if (!res.ok) throw new Error(); + const veri = (await res.json()) as { facetler: SihirbazFacetleri }; + setGirisli(Boolean(oturum?.data?.user)); + setSira(value); + setFacetler(veri.facetler); + setAdim("sihirbaz"); + } catch { + setHata("Bir şeyler ters gitti, tekrar dener misin?"); + } finally { + setYukleniyor(false); + } + } + + // Seçimler tamam: sakla, çekmeceyi kapat ve /sonuc'a git. Girişli + // kullanıcıda orada otomatik üretim başlar; girişsiz kullanıcı önce + // ücretsiz tabloyu görür (değer önce — giriş duvarı sihirbazın hemen + // arkasında değil). + function secimleriTamamla(secimler: SihirbazSecimleri) { + if (sira == null) return; + const yeniProfil = { sira, tur, secimler }; + tercihProfiliYaz(yeniProfil); + setAcik(false); + router.push( + sonucHref(yeniProfil, girisli ? { sihirbaz: "1" } : { hazir: "1" }), + ); + } + + function acikDegisti(v: boolean) { + setAcik(v); + if (!v) { + // Yeniden açılışta sıfırdan başla; yazılan sıralama kaybolmasın. + setAdim("sira"); + setFacetler(null); + setHata(null); + } + } + + if (profil) { + return ( +
+

+ {baslik ?? "Sıralamanla nereye yerleşirsin? Saniyede gör."} +

+

+ Sıralaman kayıtlı ({profil.sira.toLocaleString("tr-TR")}. sıra) — + gerçek YÖK Atlas verisiyle hayal, dengeli ve garanti dilimlerindeki + programların hazır. +

+ + Sıralamana uygun programları gör + + +
+ ); + } + return (

@@ -16,42 +166,151 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) { hayal, dengeli ve garanti dilimlerindeki programları anında listele. Ücretsiz, üyelik gerekmez.

-
setAcik(true)} + onPointerEnter={preloadSihirbazAdimlar} + onFocus={preloadSihirbazAdimlar} + className="mt-4 h-11 cursor-pointer bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600" > - - - -
+ {/* Görünür başlıklar adıma göre değişir; erişilebilir ad sabit kalır. */} + + Sıralamanla nereye yerleşirsin? + + + YKS başarı sıralamanı gir; sıralamana uygun programları bu sayfadan + ayrılmadan gör. + + + + Kapat + + +
+ {adim === "sira" ? ( +
+
+ Saniyede gör +

+ Sıralamanla nereye yerleşirsin? +

+

+ Puan türünü seç, başarı sıralamanı yaz; hayal, dengeli ve + garanti dilimlerindeki programları bu sayfadan ayrılmadan + gör. Ücretsiz, üyelik gerekmez. +

+
+ +
+
+ {PUAN_TURLERI_SECENEK.map((t) => ( + + ))} +
+ + +
+
+ + siralamaDegisti(e.target.value)} + onFocus={preloadSihirbazAdimlar} + aria-invalid={hata ? true : undefined} + aria-describedby={ + hata ? "cta-siralama-hata" : undefined + } + className="h-12 bg-background pl-11 text-base" + /> +
+ +
+ {hata ? ( + + ) : null} +
+ +
    + {KANITLAR.map(({ ikon: Ikon, etiket }) => ( +
  • + + {etiket} +
  • + ))} +
+
+ ) : facetler && sira != null ? ( + + ) : null} +
+ +

); } diff --git a/src/components/dev/dev-panel.tsx b/src/components/dev/dev-panel.tsx index e5dec28..e37d8b3 100644 --- a/src/components/dev/dev-panel.tsx +++ b/src/components/dev/dev-panel.tsx @@ -5,17 +5,14 @@ // ayrıca korunur). Amaç: satın alma / giriş / kredi durumlarını tek // tıkla taklit edip uygulamanın tüm sayfalarını gerçek akışta gezmek. // -// Hareket dili (Apple "Designing Fluid Interfaces"): panel DEV çipinden -// büyür ve yine ona küçülür (paylaşılan layoutId ile mekânsal bağ), yay -// kritik sönümlüdür (bounce 0) — açılış ortasında kapatılırsa mevcut -// değerden geri döner. Yüzey opak kutu değil, yarı saydam malzemedir. +// Yüzey opak kutu değil, yarı saydam malzemedir. import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Wrench, X } from "lucide-react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { authClient } from "@/lib/auth-client"; +import { tercihProfiliSil } from "@/lib/sihirbaz"; import { devAnindaGiris, devAyarla, @@ -27,6 +24,7 @@ import { const SAYFALAR: { ad: string; href: string }[] = [ { ad: "Ana sayfa", href: "/" }, + { ad: "Sonuç (sırasız)", href: "/sonuc" }, { ad: "Sonuç (85k · SAY)", href: "/sonuc?sira=85000&tur=say&sihirbaz=1" }, { ad: "Sonuç (girişsiz hazır)", href: "/sonuc?sira=85000&tur=say&hazir=1" }, { ad: "Listem", href: "/listem" }, @@ -34,6 +32,13 @@ const SAYFALAR: { ad: string; href: string }[] = [ { ad: "Paket", href: "/paket" }, { ad: "Paket (ödeme hatası)", href: "/paket?hata=token" }, { ad: "PDF / yazdır", href: "/rapor/yazdir" }, + { ad: "Bölümler", href: "/bolumler" }, + { ad: "Bölüm (bilg. müh.)", href: "/bolum/bilgisayar-muhendisligi" }, + { ad: "Üniversiteler", href: "/universiteler" }, + { ad: "Üniversite (Boğaziçi)", href: "/universite/bogazici-universitesi" }, + { ad: "Rehber", href: "/rehber" }, + { ad: "Rehber (devlet/vakıf)", href: "/rehber/devlet-mi-vakif-mi" }, + { ad: "Meraklısına", href: "/meraklisina" }, { ad: "Giriş", href: "/giris" }, { ad: "Gizlilik", href: "/gizlilik" }, { ad: "Koşullar", href: "/kosullar" }, @@ -41,10 +46,6 @@ const SAYFALAR: { ad: string; href: string }[] = [ { ad: "404", href: "/boyle-bir-sayfa-yok" }, ]; -// Apple değerleri: damping 1.0 (bounce 0), response ~0.35 sn. -// Yay süreli değildir; hedef değişirse mevcut değer + hızdan devam eder. -const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const; - // Basış anında tepki (pointer-down feedback) — bırakınca değil. const BASIS = "active:scale-[0.97] transition-[transform,background-color,border-color,opacity] duration-150 ease-out"; @@ -60,7 +61,6 @@ export function DevPanel() { const [email, setEmail] = useState("test@kolaytercih.dev"); const [mesgul, setMesgul] = useState(false); const router = useRouter(); - const azMotion = useReducedMotion(); const yenile = useCallback(async () => { try { @@ -131,50 +131,27 @@ export function DevPanel() { const g = durum?.girisli ? durum : null; - // Azaltılmış hareket: morph yerine kısa çapraz solma (yaysız, kaymasız). - const paylasilan = azMotion ? {} : { layoutId: "dev-panel" }; - const gecis = azMotion ? { duration: 0.15 } : YAY; - return ( - + <> {!acik ? ( - DEV - + ) : ( - - {/* İçerik morph sırasında bozulmasın: yüzey oturduktan hemen - sonra belirir (malzeme önce, içerik sonra). */} - +
{/* Başlık: opak şerit değil, aynı malzemenin üstünde ince bir kenar çizgisiyle ayrışan katman. */}
@@ -319,6 +296,24 @@ export function DevPanel() { gider + {/* Sıra + sihirbaz profili localStorage'ta yaşar; girişten + bağımsızdır — bu yüzden !g ile kilitlenmez. */} +

{/* Sayfalar */} @@ -339,9 +334,9 @@ export function DevPanel() {

- - + + )} - + ); } diff --git a/src/components/header-gate.tsx b/src/components/header-gate.tsx index 29f7abc..81f8d4b 100644 --- a/src/components/header-gate.tsx +++ b/src/components/header-gate.tsx @@ -2,7 +2,8 @@ import { usePathname } from "next/navigation"; -// Header'ın gizleneceği rotalar (tam eşleşme veya alt yol) +// Site kromunun (banner, header, üst/alt blur) gizleneceği rotalar +// (tam eşleşme veya alt yol) const HIDDEN_PREFIXES = ["/giris"]; export function HeaderGate({ children }: { children: React.ReactNode }) { diff --git a/src/components/hero-baslik.tsx b/src/components/hero-baslik.tsx new file mode 100644 index 0000000..35dae0b --- /dev/null +++ b/src/components/hero-baslik.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store"; + +// Hero başlığı: sıralama girilmemişse tanıtım metni, kayıtlı profil varsa +// aynı şablonla (vurgulu italik orta parça) aksiyon metnine dönüşür. +// SSR/ilk boyamada profil bilinmediği için varsayılan metin render edilir; +// store yüklenince client'ta aksiyon haline geçer. +export function HeroBaslik() { + const profil = useTercihProfili(); + + return ( +

+ {profil ? ( + <> + Sıralaman kayıtlı,
+ + tercih listen + {" "} + seni bekliyor + + ) : ( + <> + Dört yıllık geleceğini
+ + 48 saatlik panikle + {" "} + seçme + + )} +

+ ); +} diff --git a/src/components/hero-focus-button.tsx b/src/components/hero-focus-button.tsx index d8b94f9..5b2ddbd 100644 --- a/src/components/hero-focus-button.tsx +++ b/src/components/hero-focus-button.tsx @@ -1,6 +1,10 @@ "use client"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store"; +import { sonucHref } from "@/lib/sihirbaz"; export function HeroFocusButton() { function handleClick() { @@ -24,3 +28,43 @@ export function HeroFocusButton() { ); } + +/** + * Ana sayfanın kapanış CTA'sı: kayıtlı tercih profili olan adaya + * "sıralamanı gir" demek yerine doğrudan sonuç sayfasına götürür. + * Profil yoksa mevcut davranış — hero'daki sıralama formuna odaklanır. + */ +export function KapanisCta() { + const profil = useTercihProfili(); + + if (profil) { + return ( + <> +

+ Sıralaman kayıtlı — yapay zekâ danışmanın veriyle konuşsun. Tercih + dönemi bitmeden yerini al. +

+ + + ); + } + + return ( + <> +

+ Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun. Tercih dönemi + bitmeden yerini al. +

+ + + ); +} diff --git a/src/components/hero-form.tsx b/src/components/hero-form.tsx index 05a1b58..73df09c 100644 --- a/src/components/hero-form.tsx +++ b/src/components/hero-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { ArrowRight } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -16,8 +17,17 @@ import { preloadSihirbazAdimlar, } from "@/components/sihirbaz-adimlar-lazy"; import { authClient } from "@/lib/auth-client"; +import { + profilDuzenlemeyiAc, + useTercihProfili, +} from "@/components/manuel-liste/profil-kapisi-store"; import type { SihirbazFacetleri } from "@/lib/db"; -import { tercihProfiliYaz, type SihirbazSecimleri } from "@/lib/sihirbaz"; +import { + PUAN_TURU_ETIKET, + sonucHref, + tercihProfiliYaz, + type SihirbazSecimleri, +} from "@/lib/sihirbaz"; // lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada. const PUAN_TURLERI_SECENEK = [ @@ -37,15 +47,16 @@ export function HeroForm() { const [modalAcik, setModalAcik] = useState(false); const [hata, setHata] = useState(null); const [girisli, setGirisli] = useState(false); + // Daha önce sıralama girmiş ziyaretçi formu yeniden doldurmasın: + // kayıtlı profil varsa doğrudan /sonuc CTA'sı gösterilir. + const kayitliProfil = useTercihProfili(); const router = useRouter(); // Yazarken binlik ayracıyla biçimle (85000 → 85.000) function siralamaDegisti(ham: string) { setHata(null); const rakamlar = ham.replace(/\D/g, "").slice(0, 7); - setSiralama( - rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "", - ); + setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : ""); } async function handleSubmit(e: React.FormEvent) { @@ -82,12 +93,43 @@ export function HeroForm() { // oradan gelir (değer önce — giriş duvarı sihirbazın hemen arkasında değil). function secimleriTamamla(secimler: SihirbazSecimleri) { if (!sira) return; - tercihProfiliYaz({ sira, tur, secimler }); + const profil = { sira, tur, secimler }; + tercihProfiliYaz(profil); setModalAcik(false); router.push( - girisli - ? `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1` - : `/sonuc?sira=${sira}&tur=${tur}&hazir=1`, + sonucHref(profil, girisli ? { sihirbaz: "1" } : { hazir: "1" }), + ); + } + + // Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür. + // "Farklı sıralama gir" navbar'daki Değiştir ile aynı düzenleme modalını + // (TercihProfiliKapisi, layout'ta monte) açar. + if (kayitliProfil) { + return ( +
+ +

+ {kayitliProfil.sira.toLocaleString("tr-TR")} ·{" "} + {PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur} sıralamanla + devam edersin.{" "} + +

+
); } @@ -144,7 +186,7 @@ export function HeroForm() { disabled={yukleniyor} className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" > - {yukleniyor ? "Hazırlanıyor…" : "Listemi oluştur"} + {yukleniyor ? "Hazırlanıyor…" : "AI listemi kur"} @@ -172,7 +214,7 @@ export function HeroForm() { tur={tur} facetler={facetler} sonButonEtiketi={ - girisli ? "Listemi oluştur (3 kredi)" : "Ücretsiz tablonu gör" + girisli ? "AI listemi kur (3 kredi)" : "Ücretsiz tablonu gör" } onTamamla={secimleriTamamla} /> diff --git a/src/components/il-secim-haritasi.tsx b/src/components/il-secim-haritasi.tsx index 19d325c..daabe4c 100644 --- a/src/components/il-secim-haritasi.tsx +++ b/src/components/il-secim-haritasi.tsx @@ -82,14 +82,11 @@ export function IlSecimHaritasi({
az - + + + + + çok program deger.toLocaleString("tr-TR"); +const KESFET_TONLARI = { + slate: { + pencere: "border-slate-200/80 bg-white", + nokta: "bg-slate-300", + rozet: "border-slate-200 bg-slate-50 text-slate-600", + cizgi: "bg-slate-200/90", + }, + sky: { + pencere: "border-sky-200/70 bg-white", + nokta: "bg-sky-300", + rozet: "border-sky-200 bg-sky-50 text-sky-600", + cizgi: "bg-sky-100", + }, + amber: { + pencere: "border-amber-200/70 bg-white", + nokta: "bg-amber-300", + rozet: "border-amber-200 bg-amber-50 text-amber-600", + cizgi: "bg-amber-100", + }, +} as const; + export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) { + const router = useRouter(); const profil = useTercihProfili(); const [acik, setAcik] = useState(false); + + function siralamaGirVeyaSonucaGit() { + const { href } = sonucaGitIste(); + if (href) router.push(href); + } const [sorgu, setSorgu] = useState(""); const [sonuclar, setSonuclar] = useState(BOS_SONUCLAR); const [yukleniyor, setYukleniyor] = useState(false); @@ -162,11 +191,18 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) { return () => gozlemci.disconnect(); }, [acik, aramaAktif, yukleniyor, sonucVar, kenarBlurGuncelle]); + // Temizlik kapanışta değil açılışta yapılır: kapanış animasyonu sırasında + // içerik "Keşfet" ekranına zıplamaz, modal her açılışta temiz başlar. + // (X / sonuç linki `kapat` ile kapatır; orada temizlik yapılmadığı için + // eski sorgu, bayat sonuçlar ve yarıda kesilen fetch'in takılı kalan + // yükleniyor iskeleti bir sonraki açılışa taşınıyordu.) function modalDegisti(yeniAcik: boolean) { setAcik(yeniAcik); - if (!yeniAcik) { + if (yeniAcik) { setSorgu(""); setSonuclar(BOS_SONUCLAR); + setYukleniyor(false); + setKenarBlur({ ust: false, alt: false }); } } @@ -182,42 +218,42 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) { ) : ( <> + Nasıl çalışır? - - Karşılaştır - )} - + {/* lg altında ortadaki nav gizli; sıralama + ara burada yaşar */} +
+ {profil ? ( + + ) : ( + + )} + +
void }) { return (
-

+

Keşfet

-
+
} + ikon={GraduationCap} baslik="Tüm bölümler" - aciklama="Lisans ve önlisans" + kisaBaslik="Bölümler" + aciklama="Lisans · önlisans" + ton="slate" onSec={onSec} /> } + ikon={Building2} baslik="Üniversiteler" - aciklama="Devlet ve vakıf" + kisaBaslik="Üniversite" + aciklama="Devlet · vakıf" + ton="sky" onSec={onSec} /> } + ikon={BookOpenText} baslik="Tercih rehberi" - aciklama="Sorularına yanıtlar" + kisaBaslik="Rehber" + aciklama="Sorularına yanıt" + ton="amber" onSec={onSec} />
-
-

- Neyi merak ediyorsun? -

-

- “Psikoloji” veya “Boğaziçi” gibi bir ad yaz; eşleşen sayfaları ve - temel bilgilerini burada önizle. -

+
+ + + +
+

+ Neyi merak ediyorsun? +

+

+ “Psikoloji” veya “Boğaziçi” yaz; eşleşen sayfaları ve temel + bilgilerini burada önizle. +

+
+
); } function KesfetLinki({ href, - ikon, + ikon: Ikon, baslik, + kisaBaslik, aciklama, + ton, onSec, }: { href: string; - ikon: React.ReactNode; + ikon: typeof GraduationCap; baslik: string; + kisaBaslik: string; aciklama: string; + ton: keyof typeof KESFET_TONLARI; onSec: () => void; }) { + const palet = KESFET_TONLARI[ton]; + return ( - - {ikon} + {/* Mini tarayıcı — sekme içinde tek büyük ikon, alta doğru maske ile solar */} +
+
+
+ + + + +
+
+
+ + + + + +
+ +
+
+ +
+
+ + + + {kisaBaslik} + {baslik} + + + {aciklama} + - - {baslik} - - {aciklama} ); } diff --git a/src/components/manuel-liste/liste-cekmecesi.tsx b/src/components/manuel-liste/liste-cekmecesi.tsx index 6c75255..6b25fec 100644 --- a/src/components/manuel-liste/liste-cekmecesi.tsx +++ b/src/components/manuel-liste/liste-cekmecesi.tsx @@ -1,28 +1,36 @@ "use client"; -// Manuel 24'lük listenin alt çekmecesi (bottom sheet). Layout'ta bir kez +// Manuel 24'lük listenin alt çekmecesi (bottom drawer). Layout'ta bir kez // monte edilir; navbar'daki Seçtiklerim butonu açar/kapatır. // -// Motion dili (apple-design / emil-design-eng): -// - Sheet alttan spring ile yükselir; çıkış girişten hızlı, iOS drawer eğrisi. -// - Üstteki tutamak çubuğundan aşağı çekilince kapanır (offset veya hız eşiği). -// - Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi -// sağa kaydırmak silme jesti — eşik aşılırsa satır uçar, altından kırmızı -// sil bandı görünür. -// - Silme/temizlemede kart yüksekliği ve kalan satırlar layout projeksiyonuyla -// yumuşakça oturur ("lap diye" kısalma yok). Projeksiyon, sheet'in giriş -// spring'i BİTTİKTEN sonra açılır (oturdu state'i): transform'lu parent -// içinde ölçüm alan satırlar o offset'i kalıcı transform olarak taşıyordu. -// - prefers-reduced-motion: sheet cross-fade; silme için X butonu her -// durumda kalır. +// Vaul drawer: tutamaktan aşağı swipe ile kapanır (handleOnly — satır +// sürükleme/silme jestleriyle çakışmasın diye). Escape ve overlay tıklaması +// da kapatır. +// +// Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi +// sağa kaydırmak silme jesti. prefers-reduced-motion: satır animasyonları +// sadeleşir; X butonu her durumda kalır. -import { useEffect, useState } from "react"; +import { useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { PixelField } from "@/components/pixel-decor"; import { UniLogo } from "@/components/uni-logo"; +import { Button } from "@/components/ui/button"; import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { + ArrowRight, GripVertical, ListChecks, - Sparkles, + Plus, Trash2, X, } from "lucide-react"; @@ -32,13 +40,15 @@ import { Reorder, useDragControls, useMotionValue, - useReducedMotion, useTransform, type PanInfo, } from "motion/react"; import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk"; +import { bolumBazAdi, turkishSlugify } from "@/lib/slug"; +import { sonucaGitIste } from "./profil-kapisi-store"; import { MANUEL_LISTE_MAX, + cekmeceAc, cekmeceKapat, cikar, temizle, @@ -48,6 +58,18 @@ import { type ManuelTercih, } from "./store"; +// Slug kuralları katalog.ts'tekiyle aynı (bolumBazAdi/uniAdiNormalize + +// turkishSlugify); katalog haritaları DB'deki tüm isim/universite +// değerlerinden kurulduğu için bu linkler var olan sayfalara çıkar. Harita +// verisini client bundle'a çekmemek için burada tekrarlanır (bkz. +// program-tablosu'ndaki uniSayfaSlug). +function bolumHref(isim: string): string { + return `/bolum/${turkishSlugify(bolumBazAdi(isim))}`; +} +function uniHref(ad: string): string { + return `/universite/${turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim())}`; +} + // Risk renk dili ürünün geri kalanıyla birebir aynı (bkz. rapor-listesi) const RISK_NOKTA: Record = { guvenli: "bg-emerald-500", @@ -56,92 +78,47 @@ const RISK_NOKTA: Record = { }; export function ListeCekmecesi() { + const router = useRouter(); const acik = useCekmeceAcik(); - - return {acik ? : null}; -} - -function CekmeceSheet() { const liste = useManuelListe(); - const azMotion = useReducedMotion(); - const sheetKontrol = useDragControls(); - // Giriş spring'i bitmeden satırların layout projeksiyonu kapalı kalır — - // aksi halde transform'lu sheet içinde alınan ölçüm satırları kaydırıyor + // Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu + // drawer içinde erken ölçüm satırları kaydırıyordu const [oturdu, setOturdu] = useState(false); - // Escape ile kapat — çekmece modal değil, akışı bölmeden yaşar - useEffect(() => { - const dinle = (e: KeyboardEvent) => { - if (e.key === "Escape") cekmeceKapat(); - }; - window.addEventListener("keydown", dinle); - return () => window.removeEventListener("keydown", dinle); - }, []); - const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100); - const surukleyinceKapat = (_: unknown, info: PanInfo) => { - if (info.offset.y > 80 || info.velocity.y > 500) cekmeceKapat(); - }; + function programaEkleGit() { + cekmeceKapat(); + const { href } = sonucaGitIste(); + if (href) router.push(href); + } return ( - setOturdu(true)} - drag={azMotion ? false : "y"} - dragListener={false} - dragControls={sheetKontrol} - dragConstraints={{ top: 0, bottom: 0 }} - dragElastic={{ top: 0, bottom: 0.6 }} - onDragEnd={surukleyinceKapat} - aria-label="Manuel tercih listem" - className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3" + { + if (v) cekmeceAc(); + else { + setOturdu(false); + cekmeceKapat(); + } + }} + handleOnly + onAnimationEnd={(open) => setOturdu(open)} > - {/* layout: satır silinince/eklenince kart yüksekliği spring ile oturur. - Doğrudan çocuklar layout="position" taşır ki ölçekleme sırasında - içerik yamulmasın (scale-correction zinciri). */} - - {/* Tutamak: buradan aşağı çekilince sheet kapanır */} - { - if (!azMotion) sheetKontrol.start(e); - }} - className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing" - > -
- - - {/* Başlık + doluluk */} - +
-

+ Kendi Listem {liste.length}/{MANUEL_LISTE_MAX} -

+
{liste.length > 0 ? ( ) : null} - +
-

- Bölüm, üniversite veya sonuç tablolarından eklediğin programlar — - tutamaktan sürükleyip sırala, sağa kaydırıp sil. -

    ))}
-
+ - {/* Gövde: sürüklenebilir satırlar */} - {liste.length === 0 ? ( - - -

- Henüz program seçmedin -

-

- Bölüm, üniversite veya sonuç sayfalarındaki tablolardan{" "} - ile program ekle; beğendiklerin burada - birikir. -

-
- ) : ( - - - {liste.map((t, i) => ( - - ))} - - - )} - - {/* Alt bant: karar merkezi köprüsü */} - - - - Kendi listemi ve uyum notlarını aç - - - - AI listeni ve danışmanı aç - - - - + {liste.length === 0 ? ( +
+ +

+ Henüz program seçmedin +

+

+ Sonuç sayfasından program ekleyerek listeni doldurmaya başla. +

+
+ ) : ( + + + {liste.map((t, i) => ( + + ))} + + + )} + + {liste.length < MANUEL_LISTE_MAX ? ( + + ) : null} +
+ + + {/* Ana sayfadaki final CTA panelinin kompakt hâli: koyu primary + zemin + kenarlara doğru yoğunlaşan piksel dokusu (PixelField) */} +
+
+ +
+
+

+ Risk analizini biz yaptık +

+

+ Her seçimin yerleşme riski hazır — son kararı sen ver. +

+
+ +
+
+ +
); } @@ -311,7 +301,7 @@ function CekmeceSatiri({ exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }} transition={{ type: "spring", duration: 0.4, bounce: 0 }} whileDrag={{ scale: 1.03, zIndex: 10 }} - className="relative select-none" + className="relative mb-2 select-none last:mb-0" > {/* Sil bandı: kart sağa kaydıkça altından görünür */} - + e.stopPropagation()} // kaydırmayı tetiklemesin + className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > {tercih.isim} - + - {tercih.universite} + e.stopPropagation()} // kaydırmayı tetiklemesin + title={`${tercih.universite} taban puanları sayfası`} + className="hover:text-primary hover:underline" + > + {tercih.universite} + {tercih.il ? ` · ${tercih.il.toLocaleLowerCase("tr-TR")}` : ""} diff --git a/src/components/manuel-liste/profil-kapisi-store.ts b/src/components/manuel-liste/profil-kapisi-store.ts index 3716c08..f2ac608 100644 --- a/src/components/manuel-liste/profil-kapisi-store.ts +++ b/src/components/manuel-liste/profil-kapisi-store.ts @@ -7,6 +7,7 @@ import { useSyncExternalStore } from "react"; import type { Program } from "@/lib/db"; import { PROFIL_DEGISTI_EVENT, + sonucHref, tercihProfiliOku, tercihProfiliTamamlandiMi, type TercihProfili, @@ -38,10 +39,14 @@ export type BekleyenProgram = { adayTur?: string; }; +/** Kapı tamamlanınca nereye gideceği (sıra yazıldıktan sonra href üretilir). */ +export type BekleyenHedef = "sonuc"; + type KapıDurumu = { acik: boolean; duzenle: boolean; bekleyen: BekleyenProgram | null; + bekleyenHedef: BekleyenHedef | null; profil: TercihProfili | null; profilYuklendi: boolean; }; @@ -50,6 +55,7 @@ let durum: KapıDurumu = { acik: false, duzenle: false, bekleyen: null, + bekleyenHedef: null, profil: null, profilYuklendi: false, }; @@ -99,6 +105,7 @@ const SSR_DURUM: KapıDurumu = { acik: false, duzenle: false, bekleyen: null, + bekleyenHedef: null, profil: null, profilYuklendi: false, }; @@ -119,16 +126,61 @@ export function useTercihProfili(): TercihProfili | null { } export function profilKapisiniKapat() { - durum = { ...durum, acik: false, duzenle: false, bekleyen: null }; + durum = { + ...durum, + acik: false, + duzenle: false, + bekleyen: null, + bekleyenHedef: null, + }; yayinla(); } export function profilDuzenlemeyiAc() { if (!durum.profilYuklendi) yukle(); - durum = { ...durum, acik: true, duzenle: true, bekleyen: null }; + durum = { + ...durum, + acik: true, + duzenle: true, + bekleyen: null, + bekleyenHedef: null, + }; yayinla(); } +/** + * Profil yokken kapıyı açar; tamamlanınca `hedef`'e yönlendirilir. + * Navbar, çekmece +, /sonuc boş hali ve listem gibi yerler bunu kullanır. + */ +export function profilToplamaAc(hedef: BekleyenHedef | null = null) { + if (!durum.profilYuklendi) yukle(); + durum = { + ...durum, + acik: true, + duzenle: false, + bekleyen: null, + bekleyenHedef: hedef, + }; + yayinla(); +} + +/** + * Sonuç sayfasına gitmek isteyen her yer: profil varsa href, + * yoksa sıralama/tercih kapısını açar (tamamlanınca /sonuc'a gider). + */ +export function sonucaGitIste(): { + href: string | null; + kapiAcildi: boolean; +} { + if (!durum.profilYuklendi) yukle(); + const profil = durum.profil ?? tercihProfiliOku(); + if (profil) { + return { href: sonucHref(profil), kapiAcildi: false }; + } + profilToplamaAc("sonuc"); + return { href: null, kapiAcildi: true }; +} + export function programEkleIste(opts: { program: BekleyenProgram["program"]; kaynak?: ManuelKaynak; @@ -152,6 +204,7 @@ export function programEkleIste(opts: { acik: true, duzenle: false, bekleyen: { program, kaynak, adaySira, adayTur }, + bekleyenHedef: null, }; yayinla(); return { eklendi: false, zatenVar: false, doldu: false, kapiAcildi: true }; @@ -174,9 +227,11 @@ export function programEkleIste(opts: { return { eklendi: true, zatenVar: false, doldu: false, kapiAcildi: false }; } -export function profilTamamlandi(profil: TercihProfili) { +/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner. */ +export function profilTamamlandi(profil: TercihProfili): string | null { riskleriYenile(profil); const bekleyen = durum.bekleyen; + const hedef = durum.bekleyenHedef; if (bekleyen) { ekle(programdanManuelTercih(bekleyen.program, profil.sira, bekleyen.kaynak)); } @@ -185,10 +240,12 @@ export function profilTamamlandi(profil: TercihProfili) { acik: false, duzenle: false, bekleyen: null, + bekleyenHedef: null, profil, profilYuklendi: true, }; yayinla(); + return hedef === "sonuc" ? sonucHref(profil) : null; } export function useListeDolu(): boolean { diff --git a/src/components/manuel-liste/secimlerim-paneli.tsx b/src/components/manuel-liste/secimlerim-paneli.tsx index 65bc95d..481c2cd 100644 --- a/src/components/manuel-liste/secimlerim-paneli.tsx +++ b/src/components/manuel-liste/secimlerim-paneli.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { UniLogo } from "@/components/uni-logo"; import { AlertTriangle, @@ -12,6 +13,7 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk"; +import { sonucHref } from "@/lib/sihirbaz"; import { tercihDegerlendir, tercihListesiOzeti, @@ -24,6 +26,7 @@ import { } from "./store"; import { profilDuzenlemeyiAc, + sonucaGitIste, useTercihProfili, } from "./profil-kapisi-store"; @@ -34,6 +37,7 @@ const RISK_STIL: Record = { }; export function SecimlerimPaneli() { + const router = useRouter(); const liste = useManuelListe(); const profil = useTercihProfili(); @@ -49,6 +53,11 @@ export function SecimlerimPaneli() { ) : null; + function sonucaGit() { + const { href } = sonucaGitIste(); + if (href) router.push(href); + } + if (liste.length === 0) { return (
@@ -65,9 +74,22 @@ export function SecimlerimPaneli() { - + {profil ? ( + + ) : ( + + )}
); @@ -102,7 +124,16 @@ export function SecimlerimPaneli() { Tercihlerimi düzenle - ) : null} + ) : ( + + )}
= { giris: 0, sira: 1, sihirbaz: 2 }; + export function TercihProfiliKapisi() { const { acik, duzenle, bekleyen, profil } = useProfilKapisi(); const oturumAnahtari = acik @@ -84,10 +97,17 @@ function KapıIcerik({ bekleyen: BekleyenProgram | null; profil: TercihProfili | null; }) { + const router = useRouter(); const bilinenSira = bekleyen?.adaySira ?? profil?.sira ?? null; const bilinenTur = bekleyen?.adayTur ?? profil?.tur ?? "say"; - const [adim, setAdim] = useState("giris"); + // Düzenlemede doğrudan sıralama adımı — yeni sıra + tercihler aynı oturumda + const [adim, setAdim] = useState(duzenle ? "sira" : "giris"); + // Adım geçişleri sihirbazın kendi içindeki TransitionPanel kaymasıyla oynar + const [yon, setYon] = useState(1); + const [gecisBasladi, setGecisBasladi] = useState(false); + const [panelRef, panelOlculeri] = useMeasure(); + const hareketiAzalt = useReducedMotion(); const [siralama, setSiralama] = useState( bilinenSira != null ? bilinenSira.toLocaleString("tr-TR") : "", ); @@ -103,6 +123,12 @@ function KapıIcerik({ setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : ""); } + function adimaGec(yeniAdim: Adim) { + setYon(ADIM_SIRASI[yeniAdim] > ADIM_SIRASI[adim] ? 1 : -1); + setGecisBasladi(true); + setAdim(yeniAdim); + } + async function facetYukle(siraDeger: number, turDeger: string) { setYukleniyor(true); setHata(null); @@ -115,7 +141,7 @@ function KapıIcerik({ setSira(siraDeger); setTur(turDeger); setFacetler(veri.facetler); - setAdim("sihirbaz"); + adimaGec("sihirbaz"); } catch { setHata("Bir şeyler ters gitti, tekrar dener misin?"); } finally { @@ -128,7 +154,7 @@ function KapıIcerik({ await facetYukle(sira, tur); return; } - setAdim("sira"); + adimaGec("sira"); preloadSihirbazAdimlar(); } @@ -147,24 +173,65 @@ function KapıIcerik({ const yeniProfil: TercihProfili = { sira, tur, secimler }; tercihProfiliYaz(yeniProfil); const bekleyenVar = Boolean(bekleyen); - profilTamamlandi(yeniProfil); - if (duzenle) { - toast.success( - "Tercihlerin güncellendi. Listendeki programları yeni seçimlerine göre yeniden değerlendirdik; hiçbir programı silmedik.", - ); - } else if (!bekleyenVar) { + const yonlendir = profilTamamlandi(yeniProfil); + if (!duzenle && !bekleyenVar) { toast.success("Tercihlerin kaydedildi."); } + if (yonlendir) router.push(yonlendir); } - if (adim === "giris") { - return ( -
+ const panelYuksekligi = + gecisBasladi && panelOlculeri.height > 0 ? panelOlculeri.height : "auto"; + + return ( + ({ + 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 }, + } + } + > +
-

- {duzenle ? "Tercihlerini güncelle" : "Önce seni biraz tanıyalım"} -

-

+ {duzenle ? ( +

+ Tercihlerini güncelle +

+ ) : null} +

{duzenle ? "Seçimlerini değiştir" : "Tercihine ışık tutabilmemiz için önce ne istediğini öğrenelim"} @@ -200,116 +267,134 @@ function KapıIcerik({ onClick={() => void giristenIlerle()} className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600" > - {yukleniyor ? "Hazırlanıyor…" : "Tercihlerimi belirle"} +

- ); - } - if (adim === "sira") { - return ( -
+
-

- Başarı sıralaman nedir? + {duzenle ? ( +

+ Sıralama ve tercihler +

+ ) : null} +

+ {duzenle + ? "Yeni sıralaman nedir?" + : "Başarı sıralaman nedir?"}

- Risk ve erişilebilir programlar sıralamana göre hesaplanır. + {duzenle + ? "Sıralamanı veya puan türünü güncelle; sonraki adımda tercihlerini de değiştirebilirsin. Listedeki programlar silinmez." + : "Risk ve erişilebilir programlar sıralamana göre hesaplanır."}

-
- {PUAN_TURLERI_SECENEK.map((t) => ( - - ))} -
- - - siralamaDegisti(e.target.value)} - onFocus={preloadSihirbazAdimlar} - aria-invalid={hata ? true : undefined} - className="h-12 bg-background sm:flex-1" - /> - -
- {hata ? ( -

- {hata} -

- ) : null} -
+ Başarı sıralaması ve puan türü + +
+
+ + siralamaDegisti(e.target.value)} + onFocus={preloadSihirbazAdimlar} + aria-invalid={hata ? true : undefined} + aria-describedby={hata ? "kapisi-siralama-hata" : undefined} + className="h-12 bg-background pl-11 text-base" + /> +
+ +
+ {hata ? ( + + ) : null} +
+
+
-
- ); - } + - if (facetler && sira != null) { - return ( -
- {hata ? ( -

- {hata} -

+
+ {facetler && sira != null ? ( +
+ {hata ? ( +

+ {hata} +

+ ) : null} + +
) : null} -
- ); - } - - return null; + + ); } diff --git a/src/components/pixel-decor.tsx b/src/components/pixel-decor.tsx index a7b0f9f..26cd9e9 100644 --- a/src/components/pixel-decor.tsx +++ b/src/components/pixel-decor.tsx @@ -261,9 +261,20 @@ export function PixelCorner({ className }: { className?: string }) { } /** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */ -export function SectionEyebrow({ children }: { children: ReactNode }) { +export function SectionEyebrow({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { return ( - + {children} diff --git a/src/components/program-tablosu.tsx b/src/components/program-tablosu.tsx index cfeaaf1..f9e18b1 100644 --- a/src/components/program-tablosu.tsx +++ b/src/components/program-tablosu.tsx @@ -7,7 +7,6 @@ import { useState } from "react"; import Link from "next/link"; -import { useLinkStatus } from "next/link"; import { ChevronDown, Rocket, @@ -16,7 +15,7 @@ import { } from "lucide-react"; import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db"; import { riskHesapla, type RiskSeviyesi } from "@/lib/risk"; -import { turkishSlugify } from "@/lib/slug"; +import { bolumBazAdi, turkishSlugify } from "@/lib/slug"; import { ProgramSiraGecmisi, ProgramTabanTrendi, @@ -70,61 +69,6 @@ const RISK_NOKTA: Record = { riskli: "bg-red-500", }; -// Seçicideki kısa etiketler — sayfa başlığındaki uzun hallerinden bağımsız -const TUR_ETIKETLERI: Record = { - say: "Sayısal", - ea: "Eşit Ağırlık", - soz: "Sözel", - dil: "Dil", - tyt: "TYT", -}; - -// useLinkStatus Link'in altındaki bir bileşende çağrılmalı; sunucu -// gidiş-dönüşü sürerken tıklanan pill hafifçe nabız atar (layout shift yok). -function SeciciEtiket({ children }: { children: React.ReactNode }) { - const { pending } = useLinkStatus(); - return ( - - {children} - - ); -} - -function PuanTuruSecici({ - sira, - aktif, -}: { - sira: number; - aktif: PuanTuruKey; -}) { - return ( - - ); -} - const BOS_EKSTRA: Record = { hayal: [], dengeli: [], @@ -197,47 +141,23 @@ export function ProgramTablosu({ return (
-
-

- Sıralamanla açılan programlar -

-

- Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban - sıralamalarına göre ulaşabildiğin bölümler.{" "} - - + ile beğendiklerini Seçtiklerim'e at. - -

-
- setAktifDilim(v as DilimKey)} - className="mt-5" > - {/* Kontrol çubuğu: tabloyu süzen her şey tablonun hemen üstünde — - solda dilim sekmeleri (birincil), sağda puan türü (ikincil filtre). - Dar ekranda alt alta sarar. */} -
- - {DILIMLER.map((d) => ( - - - {d.etiket} - - {sonuclar.toplam[d.key].toLocaleString("tr-TR")} - - - ))} - -
- - Puan türü - - {/* scroll={false}: tür değişince sayfa konumu korunur */} - -
-
+ {/* Dilim sekmeleri tablonun hemen üstünde; puan türü burada değil, + navbar'daki sıralama rozetinden değiştirilir. */} + + {DILIMLER.map((d) => ( + + + {d.etiket} + + {sonuclar.toplam[d.key].toLocaleString("tr-TR")} + + + ))} + {DILIMLER.map((d) => { const satirlar = [...sonuclar[d.key], ...ekstra[d.key]]; @@ -314,6 +234,14 @@ function uniSayfaSlug(ad: string): string { return turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim()); } +// /bolum/[slug] sayfa slug'ı: katalog.ts'teki bolumSlugFromIsim ile aynı kural +// (bolumBazAdi + turkishSlugify). Bölüm haritası DB'deki tüm isim +// değerlerinden kurulduğu için bu slug da her zaman var olan bir sayfaya çıkar +// (bkz. liste-cekmecesi'ndeki bolumHref). +function bolumSayfaSlug(isim: string): string { + return turkishSlugify(bolumBazAdi(isim)); +} + function ProgramSatiri({ program: p, adaySira, @@ -341,9 +269,14 @@ function ProgramSatiri({ - + e.stopPropagation()} + > {p.isim} - + +
+ + + + +
+ +
+ + +
+ {[88, 112, 96, 128, 72, 104, 120, 84, 100].map((w, i) => ( + + ))} +
+
+ +
+ + +
+
+ ); +} export const SihirbazAdimlarLazy = dynamic( () => import("@/components/sihirbaz-adimlar").then((m) => m.SihirbazAdimlar), { ssr: false, - loading: () => ( -
- Sihirbaz hazırlanıyor… -
- ), + loading: () => , }, ); diff --git a/src/components/sihirbaz-adimlar.tsx b/src/components/sihirbaz-adimlar.tsx index 0b370ea..cb47a6a 100644 --- a/src/components/sihirbaz-adimlar.tsx +++ b/src/components/sihirbaz-adimlar.tsx @@ -8,7 +8,7 @@ // üniversite tipi sayıları kategori+il'e göre /api/facetler'den tazelenir. import { useEffect, useState } from "react"; -import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react"; +import { ArrowLeft, ArrowRight, X } from "lucide-react"; import { useReducedMotion } from "motion/react"; import useMeasure from "react-use-measure"; import { toast } from "sonner"; @@ -17,6 +17,7 @@ import { TransitionPanel } from "@/components/core/transition-panel"; import { Button } from "@/components/ui/button"; import { IlSecimHaritasi } from "@/components/il-secim-haritasi"; import { normalizeIlAdi } from "@/lib/harita"; +import { trBaslikDuzeni } from "@/lib/slug"; import type { SihirbazFacetleri } from "@/lib/db"; import { ONCELIKLER, @@ -259,7 +260,7 @@ export function SihirbazAdimlar({ secili={iller.includes(i.il)} onClick={() => listeDegistir(iller, setIller, i.il, 5)} > - {i.il.toLocaleLowerCase("tr-TR")} + {trBaslikDuzeni(i.il)} {i.adet} ))} @@ -277,7 +278,7 @@ export function SihirbazAdimlar({ onClick={() => listeDegistir(iller, setIller, il)} className="inline-flex cursor-pointer items-center gap-1 rounded-full bg-orange-500 px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200 hover:bg-orange-600" > - {il.toLocaleLowerCase("tr-TR")} + {trBaslikDuzeni(il)} ))} @@ -382,8 +383,8 @@ export function SihirbazAdimlar({ disabled={kategoriler.length === 0} className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" > - {sonButonEtiketi} + )}
diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx index 2a76af4..756e46f 100644 --- a/src/components/site-footer.tsx +++ b/src/components/site-footer.tsx @@ -4,7 +4,8 @@ import { getAllBolumler } from "@/lib/katalog"; /** * Site footer'ı: yasal metin + SEO iç link kolonları. Server component — * popüler bölümler listesi build sırasında katalogdan gelir. Layout'a değil - * sayfalara eklenir; auth/utility ekranlarında (giris, listem...) footer yok. + * sayfalara eklenir; yazdırma çıktısı (rapor/yazdir) hariç her sayfada bu + * footer kullanılır. */ export function SiteFooter() { const populerBolumler = [...getAllBolumler()] diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index f9f90ba..41fe86a 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -69,16 +69,12 @@ function DialogContent({ > {children} {showCloseButton && ( - - + + + Kapat )} diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 0000000..ef49bac --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -0,0 +1,154 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +function Drawer({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerHandle({ + className, + ...props +}: React.ComponentProps) { + return ( +