From 55eb32b22a6fe2161cfc8f0941dd8182377e3e77 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Sat, 1 Aug 2026 17:58:38 +0300 Subject: [PATCH] Enhance layout and functionality by integrating TercihProfiliKapisiLazy component into layout.tsx, improving user experience with a new section in the RootLayout. Update BolumlerPage and ListemPage for better structure and readability, including adjustments to headings and content organization. Refactor UniversiteIcerik to utilize UniversiteProgramTablosu for displaying program data, and streamline UniversiteKonumHaritasi for improved rendering. Update ManuelListe components for clearer user instructions and enhanced interaction. Overall, these changes aim to improve visual clarity and user engagement across the application. --- GAMIFICATION.md | 239 ++++++++++++ src/app/bolumler/page.tsx | 70 ++-- src/app/layout.tsx | 3 + src/app/listem/listem-sekmeleri.tsx | 36 ++ src/app/listem/page.tsx | 341 ++++++++++-------- .../universite/[slug]/universite-icerik.tsx | 96 +---- src/app/universiteler/page.tsx | 98 ++--- src/components/bolum-program-listesi.tsx | 212 ++++++----- .../manuel-liste/liste-cekmecesi.tsx | 23 +- .../manuel-liste/profil-kapisi-store.ts | 190 ++++++++++ .../manuel-liste/program-ekle-butonu.tsx | 89 +++++ .../manuel-liste/secimlerim-paneli.tsx | 270 ++++++++++++++ src/components/manuel-liste/store.ts | 120 +++++- .../tercih-profili-kapisi-lazy.tsx | 8 + .../manuel-liste/tercih-profili-kapisi.tsx | 319 ++++++++++++++++ src/components/program-liste-verileri.tsx | 92 +++++ src/components/program-tablosu.tsx | 173 +-------- src/components/universite-konum-haritasi.tsx | 169 +++------ src/components/universite-program-tablosu.tsx | 127 +++++++ src/lib/sihirbaz.ts | 44 +++ src/lib/tercih-degerlendirme.ts | 127 +++++++ 21 files changed, 2137 insertions(+), 709 deletions(-) create mode 100644 GAMIFICATION.md create mode 100644 src/app/listem/listem-sekmeleri.tsx create mode 100644 src/components/manuel-liste/profil-kapisi-store.ts create mode 100644 src/components/manuel-liste/program-ekle-butonu.tsx create mode 100644 src/components/manuel-liste/secimlerim-paneli.tsx create mode 100644 src/components/manuel-liste/tercih-profili-kapisi-lazy.tsx create mode 100644 src/components/manuel-liste/tercih-profili-kapisi.tsx create mode 100644 src/components/program-liste-verileri.tsx create mode 100644 src/components/universite-program-tablosu.tsx create mode 100644 src/lib/tercih-degerlendirme.ts diff --git a/GAMIFICATION.md b/GAMIFICATION.md new file mode 100644 index 0000000..d9310c6 --- /dev/null +++ b/GAMIFICATION.md @@ -0,0 +1,239 @@ +# Kolay Tercih — Gamification ve Listeye Ekleme Kurgusu + +> Durum: Ürün kararı +> Son güncelleme: 1 Ağustos 2026 + +## Ana fikir + +Kolay Tercih'te bütün deneyim kullanıcının başarı sıralaması üzerine inşa +edilir. Amaç kullanıcıya yalnızca erişebileceği programları göstermek değil; +seçtiği programların sıralamasına, ilgi alanlarına ve önceliklerine ne kadar +uyduğunu açıklayarak tercihine ışık tutmaktır. + +Gamification; puan, rozet veya yapay rekabet üzerinden değil, kullanıcının +belirsiz bir fikirden gerekçeli ve dengeli bir tercih listesine ilerlemesi +üzerinden çalışır. + +## Değişmez ürün kuralları + +1. Başarı sıralaması bütün hesaplamaların başlangıç noktasıdır. +2. Listeye yalnızca tekil bir program eklenir. Üniversitenin veya bölümün tamamı + listeye eklenmez. +3. Program satırlarında erişilebilir bir `+` düğmesi bulunur. +4. Kullanıcı sihirbazı doldurmadan ilk programı listesine ekleyemez. +5. Sihirbaz tamamlandıktan sonra `+` ile seçilen program doğrudan listeye + eklenir; uyumsuz programlar engellenmez. +6. Sistem kullanıcının yerine karar vermez. Çelişkileri görünür kılar, nedenini + açıklar ve son kararı kullanıcıya bırakır. +7. “Garanti kazanırsın” gibi kesinlik bildiren ifadeler kullanılmaz. Risk ve + uyum değerlendirmeleri geçmiş YÖK Atlas verilerine dayalı rehberliktir. + +## Kullanıcı yolculuğu + +### 1. Sıralamayı öğren + +Kullanıcı başarı sıralamasını ve puan türünü girer. Sistem buna göre program +havuzunu, hayal/dengeli/güvenli aralıklarını ve kullanılabilir sihirbaz +seçeneklerini oluşturur. + +### 2. Niyetini öğren + +Kullanıcı ilk kez bir programdaki `+` düğmesine bastığında sihirbaz seçimleri +yoksa program hemen eklenmez. Tıklanan program “bekleyen program” olarak +korunur ve kısa sihirbaz açılır. + +Sihirbaz en az şu bilgileri toplar: + +- İlgi alanları +- Tercih edilen şehirler +- Devlet/vakıf tercihi +- Kariyer ve yaşam öncelikleri + +Sihirbaz giriş mesajı: + +> Önce seni biraz tanıyalım +> +> Sadece sıralamana uyan bir liste değil, ne istediğine de uyan bir tercih +> listesi kurmak istiyoruz. Birkaç kısa seçim yap; eklediğin programların sana +> ne kadar uyduğunu birlikte değerlendirelim. + +Kısa alternatif: + +> Tercihine ışık tutabilmemiz için önce ne istediğini öğrenelim. + +Birincil eylem: + +> Tercihlerimi belirle + +İkincil eylem: + +> Vazgeç + +Sihirbaz zorunludur; “şimdilik atla ve ekle” seçeneği bulunmaz. + +### 3. İlk programı ekle + +Sihirbaz başarıyla tamamlandığında kullanıcının daha önce `+` ile seçtiği +bekleyen program otomatik olarak listeye eklenir. Kullanıcı aynı programı +yeniden bulmak veya tekrar `+` düğmesine basmak zorunda kalmaz. + +Başarı bildirimi: + +> Program listene eklendi. Sıralamana ve tercihlerine göre değerlendirdik. + +### 4. Listeyi bilinçli biçimde geliştir + +Sihirbaz tamamlandıktan sonraki `+` tıklamaları programı doğrudan ekler. +Sistem ekleme öncesinde onay modalı göstermez ve uyumsuz seçimleri engellemez. +Her programın değerlendirmesi listede görünür. + +## Program değerlendirme modeli + +Her eklenen program iki ayrı eksende değerlendirilir: + +### Yerleşme riski + +Kullanıcının başarı sıralaması ile programın güncel ve geçmiş taban +sıralamaları karşılaştırılır. + +Örnek durumlar: + +- Hayal +- Dengeli +- Daha güvenli +- Veri yetersiz + +“Garanti” yerine mümkün olduğunda “daha güvenli” ifadesi tercih edilir. + +### Tercih uyumu + +Program, sihirbaz seçimleriyle karşılaştırılır. Uyum tek bir kapalı puan olarak +verilmek yerine anlaşılır gerekçelerle açıklanır. + +Örnek olumlu sinyaller: + +- İlgi alanlarınla uyumlu +- Tercih ettiğin şehirlerden birinde +- Devlet/vakıf tercihine uyuyor +- Kariyer önceliklerinle örtüşüyor + +Örnek çelişki sinyalleri: + +- Seçtiğin ilgi alanlarının dışında görünüyor +- Tercih ettiğin şehirlerin dışında +- Üniversite türü tercihinle uyuşmuyor +- Belirttiğin önceliklerle zayıf eşleşiyor + +Dil yargılayıcı olmamalıdır. “Sana uygun değil” yerine “seçimlerinle +karşılaştırdığımızda şu noktada ayrışıyor” denir. + +## Liste ekranı + +Liste yalnızca seçilen programları depolayan bir favoriler alanı değildir. +Kullanıcının tercih stratejisini görünür kılan karar merkezidir. + +Her satırda en az şu bilgiler bulunur: + +- Program ve üniversite +- Güncel taban sıralaması +- Kullanıcının sıralamasına göre risk durumu +- Tercihlerle uyumlu noktalar +- Varsa çelişki veya dikkat notu +- Listeden çıkarma eylemi + +Örnek uyumlu satır mesajı: + +> İlgi alanın ve devlet üniversitesi tercihinle uyumlu. Sıralama açısından +> dengeli aralıkta. + +Örnek çelişkili satır mesajı: + +> Bu program listende kalabilir; ancak seçtiğin ilgi alanlarının ve tercih +> ettiğin şehirlerin dışında görünüyor. + +Örnek risk mesajı: + +> Geçen yılın taban sıralaması senin sıralamandan daha yukarıda. Hayal +> tercihlerinden biri olarak değerlendirebilirsin. + +Birden fazla çelişki varsa sistem hepsini aynı anda bağırmaz. En önemli bir veya +iki gerekçe satırda gösterilir; ayrıntılar açılır alanda sunulur. + +## İlerleme hissi + +Deneyimin oyunlaştırma döngüsü şöyledir: + +1. **Keşfet:** Sıralamana göre programları incele. +2. **Seç:** `+` ile ilgini çeken programı listene al. +3. **Anla:** Sistem risk ve tercih uyumunu açıklar. +4. **Dengele:** Hayal, dengeli ve daha güvenli dağılımını geliştir. +5. **Tamamla:** Gerekçeli 24 tercihlik listeye ulaş. + +İlerleme göstergeleri: + +- `3 / 24 tercih` +- Hayal / dengeli / daha güvenli dağılımı +- Tercihlerle güçlü uyum gösteren program sayısı +- Dikkat edilmesi gereken seçim sayısı +- Listenin eksik kalan tarafına yönelik tek ve uygulanabilir öneri + +Örnek yönlendirmeler: + +> Listen dengeli tercihlerde güçleniyor. Birkaç daha güvenli seçenek eklemek +> riski dağıtabilir. + +> Seçtiklerinin çoğu aynı şehirde. Şehir konusunda esneksen alternatiflerini +> genişletebilirsin. + +> 24 tercihe ulaştın. Şimdi dağılımı ve dikkat notlarını birlikte gözden geçir. + +## `+` düğmesinin durumları + +- **Varsayılan:** “Listeme ekle” +- **Sihirbaz gerekli:** Tıklama sihirbaz ön bilgilendirmesini açar +- **Ekleniyor:** Düğme geçici olarak devre dışı kalır +- **Eklendi:** `+` yerine onay işareti gösterilir, erişilebilir etiketi + “Listende” olur +- **Liste dolu:** Düğme devre dışıdır ve “Listen 24 tercihe ulaştı” açıklaması + sunulur +- **Hata:** Seçim kaybolmaz; kullanıcıya yeniden deneme eylemi verilir + +İkon düğmesinin dokunma alanı en az 44×44 piksel olmalı ve yalnızca ikona bağlı +anlam bırakılmamalıdır. Görsel etiket veya erişilebilir ad bulunmalıdır. + +## Sihirbaz seçimlerini değiştirme + +Kullanıcı sihirbaz yanıtlarını daha sonra değiştirebilir. Değişiklik mevcut +programları listeden otomatik olarak çıkarmaz. Bütün satırların uyum +değerlendirmesi yeni yanıtlara göre yeniden hesaplanır ve değişen yorumlar +görünür biçimde güncellenir. + +Örnek bildirim: + +> Tercihlerin güncellendi. Listendeki programları yeni seçimlerine göre yeniden +> değerlendirdik; hiçbir programı silmedik. + +## Kaçınılacak kalıplar + +- Kullanıcıyı sihirbazı doldurmadığı için suçlayan dil +- Uyumsuz bir programın eklenmesini engellemek +- Sadece renk ile uyum veya risk anlatmak +- Kesin yerleşme vaadi vermek +- Her `+` tıklamasında modal veya onay adımı göstermek +- Rozet, puan ve seri gibi kararın ciddiyetini oyunlaştıran ödüller +- Kullanıcının sihirbazdan önce tıkladığı programı unutmak +- İlgi alanı uyumunu yerleşme ihtimaliyle aynı şeymiş gibi sunmak + +## Başarı ölçütleri + +- Sıralama sonucundan ilk `+` tıklamasına geçiş +- İlk `+` sonrasında sihirbaz tamamlama oranı +- Sihirbazdan sonra bekleyen programın başarıyla eklenme oranı +- Kullanıcı başına eklenen program sayısı +- 24 tercihe ulaşan kullanıcı oranı +- Uyarı görüldükten sonra programı koruma/çıkarma davranışı +- Sihirbaz tercihlerini güncelleme oranı +- Liste dağılımının zaman içinde daha dengeli hâle gelmesi + +Bu metrikler kullanıcıyı daha çok tıklamaya zorlamak için değil, daha bilinçli +bir tercih listesi kurup kuramadığını anlamak için kullanılmalıdır. diff --git a/src/app/bolumler/page.tsx b/src/app/bolumler/page.tsx index 813dba7..715f8e1 100644 --- a/src/app/bolumler/page.tsx +++ b/src/app/bolumler/page.tsx @@ -78,46 +78,46 @@ export default function BolumlerPage() { return ( <> -
- +
+ -

- Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026 -

-

- {sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban - puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı. - Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu - görürsün. Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 - tercihleri için rehber niteliğindedir. -

+

+ Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026 +

+

+ {sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban + puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı. + Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu + görürsün. Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 + tercihleri için rehber niteliğindedir. +

- + -
-

- Lisans Bölümleri (4 Yıllık) -

- -
+
+

+ Lisans Bölümleri (4 Yıllık) +

+ +
-
- -
+
+ +
-
-

- Önlisans Bölümleri (2 Yıllık — TYT) -

- -
-
- +
+

+ Önlisans Bölümleri (2 Yıllık — TYT) +

+ +
+
+ ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8190db3..69a9ec4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { Toaster } from "@/components/ui/sonner"; import { SiteHeader } from "@/components/site-header"; import { SiteTopBanner } from "@/components/site-top-banner"; import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy"; +import { TercihProfiliKapisiLazy } from "@/components/manuel-liste/tercih-profili-kapisi-lazy"; import { HeaderGate } from "@/components/header-gate"; import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41"; import { DevPanel } from "@/components/dev/dev-panel"; @@ -109,6 +110,8 @@ export default function RootLayout({ {/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */} + {/* İlk "+" için sıra + sihirbaz kapısı */} + {/* Dev süperadmin paneli — prod build'de hiç render edilmez */} {process.env.NODE_ENV !== "production" ? : null} diff --git a/src/app/listem/listem-sekmeleri.tsx b/src/app/listem/listem-sekmeleri.tsx new file mode 100644 index 0000000..f05ef03 --- /dev/null +++ b/src/app/listem/listem-sekmeleri.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { ListChecks, Sparkles } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { SecimlerimPaneli } from "@/components/manuel-liste/secimlerim-paneli"; + +export type ListemSekme = "secimlerim" | "ai"; + +export function ListemSekmeleri({ + varsayilan, + aiIcerik, +}: { + varsayilan: ListemSekme; + aiIcerik: React.ReactNode; +}) { + return ( + + + + + Seçtiklerim + + + + AI Listem + + + + + + + {aiIcerik} + + + ); +} diff --git a/src/app/listem/page.tsx b/src/app/listem/page.tsx index b201491..14da066 100644 --- a/src/app/listem/page.tsx +++ b/src/app/listem/page.tsx @@ -29,6 +29,7 @@ import { PagePixelDivider } 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"; export const metadata: Metadata = { title: "Listem", @@ -42,18 +43,15 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", { export default async function ListemPage({ searchParams, }: { - searchParams: Promise<{ acildi?: string }>; + searchParams: Promise<{ acildi?: string; sekme?: string }>; }) { const session = await verifySession("/listem"); - // Kullanıcı satırı, ?acildi parametresi ve rapor birbirinden bağımsız — - // seri await zinciri yerine tek turda çöz. - const [user, { acildi }, satir, sohbetGecmisi] = await Promise.all([ + const [user, params, satir, sohbetGecmisi] = await Promise.all([ getCurrentUser(), searchParams, appDb.query.reports.findFirst({ where: eq(schema.reports.userId, session.user.id), }), - // Sohbet geçmişi burada gelirse client mount sonrası /api/soru turu atmaz appDb .select({ id: schema.chatMessages.id, @@ -67,41 +65,64 @@ export default async function ListemPage({ ]); if (!user) redirect("/giris?callback=/listem"); - // ?acildi=1: ödeme dönüşü — kilidin kalktığı tek seferlik açılış deneyimi + const { acildi, sekme } = params; const kilitAcilisi = Boolean(user.hasPaket && acildi === "1"); + const raporVar = Boolean(satir?.result); - // Liste yoksa önce sihirbazdan oluşturulmalı - if (!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. + Seçtiklerim sekmesinde ise tablolardan eklediğin programlar durur. +

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

- Henüz bir listen yok +
+

+ Tercih karar merkezin

-

- Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; - sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin. +

+ Seçtiklerin ve AI listen burada yan yana — ikisi birbirinden bağımsız.

- - + +
); } - // Paketsize giden rapor burada da maskelenir (bkz. rapor-maske) const rapor = user.hasPaket - ? (satir.result as RaporSonuc) - : raporMaskele(satir.result as RaporSonuc); - const params = satir.params as RaporParams; + ? (satir!.result as RaporSonuc) + : raporMaskele(satir!.result as RaporSonuc); + const raporParams = satir!.params as RaporParams; - return ( -
+ const aiIcerik = ( + <> {kilitAcilisi ? (
Paketin aktif — 24 tercihinin tamamı, gerekçeler, riskler ve PDF - artık açık. 🎉 + artık açık.
) : null}
@@ -125,14 +146,16 @@ export default async function ListemPage({ YÖK Atlas verileri ücretsiz )} -

+

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

+

- {params.sira.toLocaleString("tr-TR")}. sıra ·{" "} - {PUAN_TURLERI[params.tur]} ·{" "} + {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."} @@ -165,16 +188,13 @@ export default async function ListemPage({ ) : null} - - {/* AI'ın genel değerlendirmesi ve uyarıları: paketliye tam, - paketsize sunucuda kırpılmış önizleme + kilit notu */} {rapor.genelDegerlendirme ? (

-

+

Danışman özeti -

+

{rapor.genelDegerlendirme}

@@ -205,51 +225,51 @@ export default async function ListemPage({
-
- -
- - - -
-

- İlk {ACIK_SATIR} tercihte AI analizini gördün -

-

- Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve - AI yorumu paketle açılır. Program adları ve taban verileri - her zaman ücretsiz. -

+
+ +
+ + + +
+

+ İlk {ACIK_SATIR} tercihte AI analizini gördün +

+

+ Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve + AI yorumu paketle açılır. Program adları ve taban + verileri her zaman ücretsiz. +

+
+
- -
- } - /> -
+ } + /> +
- {user.hasPaket ? ( - - ) : null} + {user.hasPaket ? ( + + ) : null}
@@ -260,91 +280,87 @@ export default async function ListemPage({ /> ) : (
-
-
-
- - - Tercih Dönemi Paketi - -
-

- Tablo ücretsiz. -
- Karar desteği pakette. -

-

- Sadece veriyi göstermez; hangi tercihin neden mantıklı - olduğunu sıralamana göre açıklar. -

-
- -
-
    - {[ - "24 tercihin tamamında kişisel risk analizi", - "Her program için kısa, gerekçeli AI yorumu", - "2 liste revizyonu ve paylaşılabilir PDF", - `${URUNLER.paket.credits} AI danışman sorusu`, - ].map((ozellik) => ( -
  • - - {ozellik} -
  • - ))} -
- -
-
-

Tek seferlik

-

- {PAKET_FIYATI} TL -

+
+
+
+ + + Tercih Dönemi Paketi +
- Abonelik yok +

+ Tablo ücretsiz. +
+ Karar desteği pakette. +

+

+ Sadece veriyi göstermez; hangi tercihin neden mantıklı + olduğunu sıralamana göre açıklar. +

- +
+
    + {[ + "24 tercihin tamamında kişisel risk analizi", + "Her program için kısa, gerekçeli AI yorumu", + "2 liste revizyonu ve paylaşılabilir PDF", + `${URUNLER.paket.credits} AI danışman sorusu`, + ].map((ozellik) => ( +
  • + + {ozellik} +
  • + ))} +
-
- - - iyzico güvenli ödeme - - - - Gerçek YÖK verisi - +
+
+

Tek seferlik

+

+ {PAKET_FIYATI} TL +

+
+ Abonelik yok +
+ + + +
+ + + iyzico güvenli ödeme + + + + Gerçek YÖK verisi + +
-
-
+
- {/* Deneme kredileri sohbette geçerli: AI'ı satın almadan önce - tattırır (kalan kredi biterse composer paket CTA'sına döner). */} - +
)}
- {/* Mobilde sohbet sağ rayla birlikte en alta düşer; yüzen kısayol - kullanıcıyı 24 satırı kaydırmadan danışmana ulaştırır. */} + + ); + + return ( +
+

+ Tercih karar merkezin +

+

+ Seçtiklerin ve AI listen burada yan yana — ikisi birbirinden bağımsız. +

+ +
); } diff --git a/src/app/universite/[slug]/universite-icerik.tsx b/src/app/universite/[slug]/universite-icerik.tsx index 6172ba6..ea02eed 100644 --- a/src/app/universite/[slug]/universite-icerik.tsx +++ b/src/app/universite/[slug]/universite-icerik.tsx @@ -10,26 +10,11 @@ import { SiteFooter } from "@/components/site-footer"; import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama"; import { PagePixelDivider } from "@/components/pixel-decor"; import { UniversiteKonumHaritasi } from "@/components/universite-konum-haritasi"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { UniversiteProgramTablosu } from "@/components/universite-program-tablosu"; import type { Program } from "@/lib/db"; const sayi = (n: number) => n.toLocaleString("tr-TR"); -const TUR_ETIKET: Record = { - SAYISAL: "SAY", - "EŞİT AĞIRLIK": "EA", - SÖZEL: "SÖZ", - DİL: "DİL", - TYT: "TYT", -}; - export function uniToplamSayfa(programSayisi: number): number { return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU)); } @@ -69,12 +54,16 @@ export function UniversiteIcerik({ (sayfa - 1) * SAYFA_BOYU, sayfa * SAYFA_BOYU, ); - const gruplar: { fakulte: string; programlar: Program[] }[] = []; + const gruplar: { + fakulte: string; + programlar: (Program & { bolumSlug: string | null })[]; + }[] = []; for (const p of dilim) { const anahtar = p.fakulte ?? "Diğer"; + const satir = { ...p, bolumSlug: bolumSlugFromIsim(p.isim) }; const son = gruplar[gruplar.length - 1]; - if (son && son.fakulte === anahtar) son.programlar.push(p); - else gruplar.push({ fakulte: anahtar, programlar: [p] }); + if (son && son.fakulte === anahtar) son.programlar.push(satir); + else gruplar.push({ fakulte: anahtar, programlar: [satir] }); } const ilkSayfa = sayfa === 1; @@ -178,6 +167,7 @@ export function UniversiteIcerik({

+
@@ -216,7 +206,6 @@ export function UniversiteIcerik({
-
) : ( @@ -238,10 +227,7 @@ export function UniversiteIcerik({ -
+

Üniversite programları

@@ -250,67 +236,7 @@ export function UniversiteIcerik({

- {gruplar.map(({ fakulte, programlar }, i) => ( -
-

- {fakulte} -

-
- - - - Program - Puan Türü - 2025 Puan - 2025 Sıralama - 2024 Sıralama - Kontenjan - - - - {programlar.map((p) => { - const bolumSlug = bolumSlugFromIsim(p.isim); - return ( - - - {bolumSlug ? ( - - {p.isim.trim()} - - ) : ( - - {p.isim.trim()} - - )} - - {TUR_ETIKET[p.tur] ?? p.tur} - - {p.puan2025 != null - ? p.puan2025.toFixed(2).replace(".", ",") - : "—"} - - - {p.sira2025 != null ? sayi(p.sira2025) : "—"} - - - {p.sira2024 != null ? sayi(p.sira2024) : "—"} - - - {p.kontenjan2025 != null - ? sayi(p.kontenjan2025) - : "—"} - - - ); - })} - -
-
-
- ))} +

"—": o yıl için yerleştirme verisi bulunmuyor (yeni açılan diff --git a/src/app/universiteler/page.tsx b/src/app/universiteler/page.tsx index 3e65573..d9bab3d 100644 --- a/src/app/universiteler/page.tsx +++ b/src/app/universiteler/page.tsx @@ -49,64 +49,64 @@ export default function UniversitelerPage() { return ( <> -

- +
+ -

- Üniversitelerin Taban Puanları ve Sıralamaları 2026 -

-

- {sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri. - Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı - sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler - YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için - rehber niteliğindedir. -

+

+ Üniversitelerin Taban Puanları ve Sıralamaları 2026 +

+

+ {sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri. + Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı + sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler + YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için + rehber niteliğindedir. +

- + -
-

- Devlet Üniversiteleri - - {sayi(devlet.length)} - -

- -
- -
-

- Vakıf Üniversiteleri - - {sayi(vakif.length)} - -

- -
- - {digerUniler.length > 0 ? ( -
+

- KKTC ve Yurt Dışı + Devlet Üniversiteleri - {sayi(digerUniler.length)} + {sayi(devlet.length)}

- +
- ) : null} -
- -
-
- +
+

+ Vakıf Üniversiteleri + + {sayi(vakif.length)} + +

+ +
+ + {digerUniler.length > 0 ? ( +
+

+ KKTC ve Yurt Dışı + + {sayi(digerUniler.length)} + +

+ +
+ ) : null} + +
+ +
+
+ ); } diff --git a/src/components/bolum-program-listesi.tsx b/src/components/bolum-program-listesi.tsx index 444c315..1cd304f 100644 --- a/src/components/bolum-program-listesi.tsx +++ b/src/components/bolum-program-listesi.tsx @@ -3,9 +3,21 @@ import { useMemo, useState } from "react"; import Link from "next/link"; import { ChevronDown, ChevronUp } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; import type { Program } from "@/lib/db"; import { trBaslikDuzeni } from "@/lib/slug"; +import { + ProgramSiraGecmisi, + ProgramTabanTrendi, +} from "@/components/program-liste-verileri"; +import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; const sayi = (n: number) => n.toLocaleString("tr-TR"); @@ -47,13 +59,17 @@ function SiralamaDugmesi({ @@ -85,11 +101,7 @@ export function BolumProgramListesi({ if (bDeger == null) return -1; const fark = aDeger - bDeger; - return fark === 0 - ? a.index - b.index - : yon === "artan" - ? fark - : -fark; + return fark === 0 ? a.index - b.index : yon === "artan" ? fark : -fark; }) .map(({ program }) => program); }, [aktifAlan, programlar, yon]); @@ -104,88 +116,116 @@ export function BolumProgramListesi({ } return ( -
    - {siraliProgramlar.map((p) => { - const uniAd = trBaslikDuzeni(p.universite); - const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null; - return ( -
  • -
    - {p.universiteSlug ? ( - - {uniAd} - - ) : ( -

    {uniAd}

    - )} -
    - {p.il ? {trBaslikDuzeni(p.il)} : null} - {p.il && p.unitur ? · : null} - {p.unitur ? ( - - {p.unitur === "DEVLET" - ? "Devlet" - : trBaslikDuzeni(p.unitur)} - - ) : null} -
    - {varyant ? ( -

    {varyant}

    - ) : null} -
    +
    + + + + Üniversite + + Son 5 yıl + + + + + + + + + + + + Listeye ekle + + + + + {siraliProgramlar.map((p) => { + const uniAd = trBaslikDuzeni(p.universite); + const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null; + const devlet = p.unitur === "DEVLET"; -
    -
    -
    - -
    -
    - {p.sira2025 != null ? sayi(p.sira2025) : "—"} -
    -
    -
    -
    - -
    -
    + return ( + + + {p.universiteSlug ? ( + + {uniAd} + + ) : ( + + {uniAd} + + )} + + {p.unitur ? ( + + {devlet ? "Devlet" : trBaslikDuzeni(p.unitur)} + + ) : null} + + {p.il ? trBaslikDuzeni(p.il) : null} + {varyant ? `${p.il ? " · " : ""}${varyant}` : ""} + + {p.sure ? ( + + · {p.sure} yıl + + ) : null} + + + + + + {p.puan2025 != null ? p.puan2025.toFixed(2).replace(".", ",") : "—"} -
    -
    -
    -
    - + + {p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"} + + + + + {p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"} + + + + -
    -
    - {p.kontenjan2025 != null ? sayi(p.kontenjan2025) : "—"} -
    -
    -
    - - ); - })} - + + + ); + })} +
    +
    +
    ); } diff --git a/src/components/manuel-liste/liste-cekmecesi.tsx b/src/components/manuel-liste/liste-cekmecesi.tsx index 10415f1..afc3106 100644 --- a/src/components/manuel-liste/liste-cekmecesi.tsx +++ b/src/components/manuel-liste/liste-cekmecesi.tsx @@ -178,8 +178,8 @@ function CekmeceSheet() { />

- Sıralamanla ulaşabildiğin programlardan kendi listen — tutamaktan - sürükleyip sırala, sağa kaydırıp sil. + Bölüm, üniversite veya sonuç tablolarından eklediğin programlar — + tutamaktan sürükleyip sırala, sağa kaydırıp sil.

@@ -194,8 +194,9 @@ function CekmeceSheet() { Henüz program seçmedin

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

) : ( @@ -222,15 +223,23 @@ function CekmeceSheet() { )} - {/* Alt bant: AI danışman köprüsü */} + {/* Alt bant: karar merkezi köprüsü */} + + Seçtiklerimi ve uyum notlarını aç + + AI listeni ve danışmanı aç diff --git a/src/components/manuel-liste/profil-kapisi-store.ts b/src/components/manuel-liste/profil-kapisi-store.ts new file mode 100644 index 0000000..6eaa8f4 --- /dev/null +++ b/src/components/manuel-liste/profil-kapisi-store.ts @@ -0,0 +1,190 @@ +"use client"; + +// Global "+" kapısı: profil yokken bekleyen programı tutar, modalı açar. +// Layout'ta bir kez monte edilen TercihProfiliKapisi bu store'a abone olur. + +import { useSyncExternalStore } from "react"; +import type { Program } from "@/lib/db"; +import { + tercihProfiliOku, + tercihProfiliTamamlandiMi, + type TercihProfili, +} from "@/lib/sihirbaz"; +import { + ekle, + listedeMi, + MANUEL_LISTE_MAX, + programdanManuelTercih, + riskleriYenile, + useManuelListe, + type ManuelKaynak, +} from "./store"; + +export type BekleyenProgram = { + program: Pick< + Program, + | "id" + | "isim" + | "universite" + | "il" + | "unitur" + | "tur" + | "sira2025" + | "sira2024" + >; + kaynak: ManuelKaynak; + adaySira?: number; + adayTur?: string; +}; + +type KapıDurumu = { + acik: boolean; + duzenle: boolean; + bekleyen: BekleyenProgram | null; + profil: TercihProfili | null; + profilYuklendi: boolean; +}; + +let durum: KapıDurumu = { + acik: false, + duzenle: false, + bekleyen: null, + profil: null, + profilYuklendi: false, +}; + +const dinleyiciler = new Set<() => void>(); + +function yayinla() { + for (const cb of dinleyiciler) cb(); +} + +function profiliTazele() { + durum = { + ...durum, + profil: tercihProfiliOku(), + profilYuklendi: true, + }; +} + +function yukle() { + if (durum.profilYuklendi || typeof window === "undefined") return; + profiliTazele(); + window.addEventListener("storage", (e) => { + if (e.key !== "kolaytercih.sihirbaz") return; + profiliTazele(); + yayinla(); + }); +} + +function abone(cb: () => void) { + dinleyiciler.add(cb); + if (!durum.profilYuklendi && typeof window !== "undefined") { + yukle(); + queueMicrotask(yayinla); + } + return () => { + dinleyiciler.delete(cb); + }; +} + +const SSR_DURUM: KapıDurumu = { + acik: false, + duzenle: false, + bekleyen: null, + profil: null, + profilYuklendi: false, +}; + +export function useProfilKapisi(): KapıDurumu { + return useSyncExternalStore(abone, () => durum, () => SSR_DURUM); +} + +export function useTercihProfili(): TercihProfili | null { + return useSyncExternalStore( + abone, + () => { + if (!durum.profilYuklendi) yukle(); + return durum.profil; + }, + () => null, + ); +} + +export function profilKapisiniKapat() { + durum = { ...durum, acik: false, duzenle: false, bekleyen: null }; + yayinla(); +} + +export function profilDuzenlemeyiAc() { + if (!durum.profilYuklendi) yukle(); + durum = { ...durum, acik: true, duzenle: true, bekleyen: null }; + yayinla(); +} + +export function programEkleIste(opts: { + program: BekleyenProgram["program"]; + kaynak?: ManuelKaynak; + adaySira?: number; + adayTur?: string; +}): { + eklendi: boolean; + zatenVar: boolean; + doldu: boolean; + kapiAcildi: boolean; +} { + const { program, kaynak = "sonuc", adaySira, adayTur } = opts; + + if (listedeMi(program.id)) { + return { eklendi: false, zatenVar: true, doldu: false, kapiAcildi: false }; + } + + if (!tercihProfiliTamamlandiMi()) { + durum = { + ...durum, + acik: true, + duzenle: false, + bekleyen: { program, kaynak, adaySira, adayTur }, + }; + yayinla(); + return { eklendi: false, zatenVar: false, doldu: false, kapiAcildi: true }; + } + + if (!durum.profilYuklendi) { + profiliTazele(); + } + + const profil = durum.profil ?? tercihProfiliOku(); + const tercih = programdanManuelTercih( + program, + adaySira ?? profil?.sira ?? null, + kaynak, + ); + const ok = ekle(tercih); + if (!ok) { + return { eklendi: false, zatenVar: false, doldu: true, kapiAcildi: false }; + } + return { eklendi: true, zatenVar: false, doldu: false, kapiAcildi: false }; +} + +export function profilTamamlandi(profil: TercihProfili) { + riskleriYenile(profil); + const bekleyen = durum.bekleyen; + if (bekleyen) { + ekle(programdanManuelTercih(bekleyen.program, profil.sira, bekleyen.kaynak)); + } + durum = { + ...durum, + acik: false, + duzenle: false, + bekleyen: null, + profil, + profilYuklendi: true, + }; + yayinla(); +} + +export function useListeDolu(): boolean { + const liste = useManuelListe(); + return liste.length >= MANUEL_LISTE_MAX; +} diff --git a/src/components/manuel-liste/program-ekle-butonu.tsx b/src/components/manuel-liste/program-ekle-butonu.tsx new file mode 100644 index 0000000..87f9eb1 --- /dev/null +++ b/src/components/manuel-liste/program-ekle-butonu.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Check, Plus } from "lucide-react"; +import { toast } from "sonner"; +import type { Program } from "@/lib/db"; +import { + listedeMi, + MANUEL_LISTE_MAX, + useManuelListe, + type ManuelKaynak, +} from "./store"; +import { programEkleIste } from "./profil-kapisi-store"; + +type ProgramOzet = Pick< + Program, + | "id" + | "isim" + | "universite" + | "il" + | "unitur" + | "tur" + | "sira2025" + | "sira2024" +>; + +export function ProgramEkleButonu({ + program, + kaynak = "sonuc", + adaySira, + adayTur, + className = "", +}: { + program: ProgramOzet; + kaynak?: ManuelKaynak; + adaySira?: number; + adayTur?: string; + className?: string; +}) { + const liste = useManuelListe(); + const listede = liste.some((t) => t.id === program.id) || listedeMi(program.id); + const doldu = liste.length >= MANUEL_LISTE_MAX; + + function handleClick(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + const sonuc = programEkleIste({ program, kaynak, adaySira, adayTur }); + if (sonuc.eklendi) { + toast.success("Program listene eklendi. Sıralamana ve tercihlerine göre değerlendirdik."); + } else if (sonuc.doldu) { + toast.info(`Listen ${MANUEL_LISTE_MAX} tercihe ulaştı.`); + } else if (sonuc.zatenVar) { + toast.info("Bu program zaten seçtiklerinde."); + } + // kapiAcildi: modal açılır, toast yok + } + + return ( + + ); +} diff --git a/src/components/manuel-liste/secimlerim-paneli.tsx b/src/components/manuel-liste/secimlerim-paneli.tsx new file mode 100644 index 0000000..908cf52 --- /dev/null +++ b/src/components/manuel-liste/secimlerim-paneli.tsx @@ -0,0 +1,270 @@ +"use client"; + +import Link from "next/link"; +import { + AlertTriangle, + CheckCircle2, + ListChecks, + PencilLine, + Sparkles, + X, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk"; +import { + tercihDegerlendir, + tercihListesiOzeti, +} from "@/lib/tercih-degerlendirme"; +import { + cikar, + MANUEL_LISTE_MAX, + useManuelListe, + type ManuelTercih, +} from "./store"; +import { + profilDuzenlemeyiAc, + useTercihProfili, +} from "./profil-kapisi-store"; + +const RISK_STIL: Record = { + guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" }, + "az-riskli": { nokta: "bg-amber-500", kenar: "border-l-amber-500" }, + riskli: { nokta: "bg-red-500", kenar: "border-l-red-500" }, +}; + +export function SecimlerimPaneli() { + const liste = useManuelListe(); + const profil = useTercihProfili(); + + const ozet = profil + ? tercihListesiOzeti( + liste.map((t) => ({ + isim: t.isim, + il: t.il, + unitur: t.unitur, + efektifSira: t.efektifSira, + })), + profil, + ) + : null; + + if (liste.length === 0) { + return ( +
+ +

+ Henüz program seçmedin +

+

+ Bölüm, üniversite veya sonuç sayfalarındaki tablolardan{" "} + ile program ekle. Tercihlerine göre + risk ve uyum notlarını burada göreceksin. +

+
+ + +
+
+ ); + } + + return ( +
+
+
+
+

+ + Seçtiklerim + + {liste.length}/{MANUEL_LISTE_MAX} + +

+

+ {profil + ? `${profil.sira.toLocaleString("tr-TR")}. sıra · tercihlerine göre değerlendirme` + : "Tercih profilin oluşunca uyum notları görünür."} +

+
+ {profil ? ( + + ) : null} +
+ +
+
+
+ + {ozet ? ( +
+ + + + +
+ ) : null} + + {ozet ? ( +

+ + {ozet.oneri} + {ozet.gucluUyum > 0 ? ( + + {ozet.gucluUyum} program güçlü uyum gösteriyor. + + ) : null} +

+ ) : null} +
+ +
    + {liste.map((t, i) => ( + + ))} +
+
+ ); +} + +function Istatistik({ etiket, deger }: { etiket: string; deger: number }) { + return ( +
+
+ {etiket} +
+
+ {deger} +
+
+ ); +} + +function SecimSatiri({ + tercih, + sira, +}: { + tercih: ManuelTercih; + sira: number; +}) { + const profil = useTercihProfili(); + const degerlendirme = profil + ? tercihDegerlendir( + { + isim: tercih.isim, + il: tercih.il, + unitur: tercih.unitur, + efektifSira: tercih.efektifSira, + }, + profil, + ) + : null; + const risk = degerlendirme?.risk ?? tercih.risk; + const stil = risk ? RISK_STIL[risk] : null; + + return ( +
  • +
    + + {sira} + +
    +
    +
    +

    + {tercih.isim} +

    +

    + {tercih.universite} + {tercih.il + ? ` · ${tercih.il.toLocaleLowerCase("tr-TR")}` + : ""} + {tercih.efektifSira != null + ? ` · ~${tercih.efektifSira.toLocaleString("tr-TR")}.` + : ""} +

    +
    + +
    + + {degerlendirme ? ( +
    +

    + {risk ? ( + + ) : null} + {degerlendirme.riskEtiketi} + {risk ? ( + + ({RISK_ETIKET[risk]}) + + ) : null} +

    +

    {degerlendirme.riskMetni}

    + {degerlendirme.olumlu.slice(0, 2).map((m) => ( +

    + + {m} +

    + ))} + {degerlendirme.celiskiler.slice(0, 2).map((m) => ( +

    + + {m} +

    + ))} +
    + ) : ( +

    + Tercih profilin tamamlanınca bu programı değerlendireceğiz. +

    + )} +
    +
    +
  • + ); +} diff --git a/src/components/manuel-liste/store.ts b/src/components/manuel-liste/store.ts index 12b75f6..eec6c9e 100644 --- a/src/components/manuel-liste/store.ts +++ b/src/components/manuel-liste/store.ts @@ -6,20 +6,30 @@ // (girişsiz kullanıcı da liste kurabilsin diye sunucuya yazmıyoruz). import { useSyncExternalStore } from "react"; -import type { RiskSeviyesi } from "@/lib/risk"; +import type { Program } from "@/lib/db"; +import { riskHesapla, type RiskSeviyesi } from "@/lib/risk"; +import { + tercihProfiliOku, + type TercihProfili, +} from "@/lib/sihirbaz"; export const MANUEL_LISTE_MAX = 24; const STORAGE_KEY = "kolaytercih.manuel-liste.v1"; +export type ManuelKaynak = "sonuc" | "bolum" | "universite"; + export type ManuelTercih = { /** yokatlas program id'si */ id: string; isim: string; universite: string; il: string | null; + unitur: string | null; + tur: string | null; efektifSira: number | null; - /** Ekleme anındaki aday sıralamasına göre risk (renklendirme için) */ + /** Ekleme / profil anındaki aday sıralamasına göre risk */ risk: RiskSeviyesi | null; + kaynak?: ManuelKaynak; }; // SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer @@ -43,15 +53,41 @@ function kaydet() { function dogrula(ham: unknown): ManuelTercih[] { if (!Array.isArray(ham)) return []; - return ham - .filter( - (t): t is ManuelTercih => - typeof t === "object" && - t !== null && - typeof (t as ManuelTercih).id === "string" && - typeof (t as ManuelTercih).isim === "string", - ) - .slice(0, MANUEL_LISTE_MAX); + const sonuc: ManuelTercih[] = []; + for (const t of ham) { + if (typeof t !== "object" || t === null) continue; + const kayit = t as Record; + if (typeof kayit.id !== "string" || typeof kayit.isim !== "string") continue; + const risk: RiskSeviyesi | null = + kayit.risk === "guvenli" || + kayit.risk === "az-riskli" || + kayit.risk === "riskli" + ? kayit.risk + : null; + const kaynak: ManuelKaynak | undefined = + kayit.kaynak === "sonuc" || + kayit.kaynak === "bolum" || + kayit.kaynak === "universite" + ? kayit.kaynak + : undefined; + sonuc.push({ + id: kayit.id, + isim: kayit.isim, + universite: typeof kayit.universite === "string" ? kayit.universite : "", + il: typeof kayit.il === "string" ? kayit.il : null, + unitur: typeof kayit.unitur === "string" ? kayit.unitur : null, + tur: typeof kayit.tur === "string" ? kayit.tur : null, + efektifSira: + typeof kayit.efektifSira === "number" && + Number.isFinite(kayit.efektifSira) + ? kayit.efektifSira + : null, + risk, + kaynak, + }); + if (sonuc.length >= MANUEL_LISTE_MAX) break; + } + return sonuc; } function yukle() { @@ -103,11 +139,71 @@ export function listedeMi(id: string): boolean { return liste.some((t) => t.id === id); } +export function efektifSiraAl( + p: Pick, +): number | null { + return p.sira2025 ?? p.sira2024 ?? null; +} + +/** Program satırını manuel liste kaydına çevirir. */ +export function programdanManuelTercih( + p: Pick< + Program, + | "id" + | "isim" + | "universite" + | "il" + | "unitur" + | "tur" + | "sira2025" + | "sira2024" + >, + adaySira?: number | null, + kaynak: ManuelKaynak = "sonuc", +): ManuelTercih { + const taban = efektifSiraAl(p); + return { + id: p.id, + isim: p.isim, + universite: p.universite, + il: p.il, + unitur: p.unitur ?? null, + tur: p.tur, + efektifSira: taban, + risk: + adaySira != null && Number.isFinite(adaySira) + ? riskHesapla(taban, adaySira) + : null, + kaynak, + }; +} + +/** Profil sırasına göre listedeki riskleri yeniden hesaplar (program silmez). */ +export function riskleriYenile(profil?: TercihProfili | null) { + yukle(); + const p = profil ?? tercihProfiliOku(); + if (!p) return; + liste = liste.map((t) => ({ + ...t, + risk: riskHesapla(t.efektifSira, p.sira), + })); + kaydet(); + yayinla(); +} + /** Listeye ekler; doluysa veya zaten varsa false döner. */ export function ekle(tercih: ManuelTercih): boolean { yukle(); if (liste.length >= MANUEL_LISTE_MAX || listedeMi(tercih.id)) return false; - liste = [...liste, tercih]; + const profil = tercihProfiliOku(); + const kayit: ManuelTercih = { + ...tercih, + risk: + profil != null + ? riskHesapla(tercih.efektifSira, profil.sira) + : tercih.risk, + }; + liste = [...liste, kayit]; kaydet(); yayinla(); return true; diff --git a/src/components/manuel-liste/tercih-profili-kapisi-lazy.tsx b/src/components/manuel-liste/tercih-profili-kapisi-lazy.tsx new file mode 100644 index 0000000..e7c2d4c --- /dev/null +++ b/src/components/manuel-liste/tercih-profili-kapisi-lazy.tsx @@ -0,0 +1,8 @@ +"use client"; + +import dynamic from "next/dynamic"; + +export const TercihProfiliKapisiLazy = dynamic( + () => import("./tercih-profili-kapisi").then((m) => m.TercihProfiliKapisi), + { ssr: false }, +); diff --git a/src/components/manuel-liste/tercih-profili-kapisi.tsx b/src/components/manuel-liste/tercih-profili-kapisi.tsx new file mode 100644 index 0000000..4ec99eb --- /dev/null +++ b/src/components/manuel-liste/tercih-profili-kapisi.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useState } from "react"; +import { ArrowRight, Sparkles } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { + SihirbazAdimlarLazy, + preloadSihirbazAdimlar, +} from "@/components/sihirbaz-adimlar-lazy"; +import type { SihirbazFacetleri } from "@/lib/db"; +import { + tercihProfiliYaz, + type SihirbazSecimleri, + type TercihProfili, +} from "@/lib/sihirbaz"; +import { + profilKapisiniKapat, + profilTamamlandi, + useProfilKapisi, + type BekleyenProgram, +} from "./profil-kapisi-store"; + +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; + +type Adim = "giris" | "sira" | "sihirbaz"; + +export function TercihProfiliKapisi() { + const { acik, duzenle, bekleyen, profil } = useProfilKapisi(); + const oturumAnahtari = acik + ? `${duzenle ? "d" : "n"}-${bekleyen?.program.id ?? "yok"}-${profil?.sira ?? 0}-${profil?.tur ?? ""}` + : "kapali"; + + return ( + { + if (!v) profilKapisiniKapat(); + }} + > + + + {duzenle ? "Tercihlerini düzenle" : "Tercih profili sihirbazı"} + + + Sıralamanı ve tercihlerini belirle; eklediğin programları buna göre + değerlendirelim. + + {acik ? ( + + ) : null} + + + ); +} + +function KapıIcerik({ + duzenle, + bekleyen, + profil, +}: { + duzenle: boolean; + bekleyen: BekleyenProgram | null; + profil: TercihProfili | null; +}) { + const bilinenSira = bekleyen?.adaySira ?? profil?.sira ?? null; + const bilinenTur = bekleyen?.adayTur ?? profil?.tur ?? "say"; + + const [adim, setAdim] = useState("giris"); + const [siralama, setSiralama] = useState( + bilinenSira != null ? bilinenSira.toLocaleString("tr-TR") : "", + ); + const [tur, setTur] = useState(bilinenTur); + const [sira, setSira] = useState(bilinenSira); + const [facetler, setFacetler] = useState(null); + const [yukleniyor, setYukleniyor] = useState(false); + const [hata, setHata] = useState(null); + + function siralamaDegisti(ham: string) { + setHata(null); + const rakamlar = ham.replace(/\D/g, "").slice(0, 7); + setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : ""); + } + + async function facetYukle(siraDeger: number, turDeger: string) { + setYukleniyor(true); + setHata(null); + try { + const res = await fetch( + `/api/facetler?sira=${siraDeger}&tur=${turDeger}`, + ); + if (!res.ok) throw new Error(); + const veri = (await res.json()) as { facetler: SihirbazFacetleri }; + setSira(siraDeger); + setTur(turDeger); + setFacetler(veri.facetler); + setAdim("sihirbaz"); + } catch { + setHata("Bir şeyler ters gitti, tekrar dener misin?"); + } finally { + setYukleniyor(false); + } + } + + async function giristenIlerle() { + if (sira != null) { + await facetYukle(sira, tur); + return; + } + setAdim("sira"); + preloadSihirbazAdimlar(); + } + + 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; + } + await facetYukle(value, tur); + } + + function secimleriTamamla(secimler: SihirbazSecimleri) { + if (sira == null) return; + 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) { + toast.success( + "Program listene eklendi. Sıralamana ve tercihlerine göre değerlendirdik.", + ); + } else { + toast.success("Tercihlerin kaydedildi."); + } + } + + if (adim === "giris") { + return ( +
    +
    +

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

    +

    + {duzenle + ? "Seçimlerini değiştir" + : "Tercihine ışık tutabilmemiz için önce ne istediğini öğrenelim"} +

    +

    + {duzenle + ? "İlgi alanların, şehirlerin veya önceliklerin değiştiyse güncelle. Mevcut programların listede kalır; yalnızca değerlendirmeler yenilenir." + : "Sadece sıralamana uyan bir liste değil, ne istediğine de uyan bir tercih listesi kurmak istiyoruz. Birkaç kısa seçim yap; eklediğin programların sana ne kadar uyduğunu birlikte değerlendirelim."} +

    + {bekleyen ? ( +

    + Bekleyen program:{" "} + {bekleyen.program.isim} + + {" "} + · {bekleyen.program.universite} + +

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

    + Başarı sıralaman nedir? +

    +

    + 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} +
    + +
    +
    + ); + } + + if (facetler && sira != null) { + return ( +
    + {hata ? ( +

    + {hata} +

    + ) : null} + +
    + ); + } + + return null; +} diff --git a/src/components/program-liste-verileri.tsx b/src/components/program-liste-verileri.tsx new file mode 100644 index 0000000..f8492df --- /dev/null +++ b/src/components/program-liste-verileri.tsx @@ -0,0 +1,92 @@ +import { TrendingDown, TrendingUp } from "lucide-react"; +import type { Program } from "@/lib/db"; + +export function programEtkinSira(program: Program): number | null { + return program.sira2025 ?? program.sira2024; +} + +/** 2021–2025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */ +export function ProgramSiraGecmisi({ program }: { program: Program }) { + const yillar = [ + [2021, program.sira2021], + [2022, program.sira2022], + [2023, program.sira2023], + [2024, program.sira2024], + [2025, program.sira2025], + ].filter((yil): yil is [number, number] => yil[1] != null); + + if (yillar.length < 2) return ; + + const siralar = yillar.map(([, sira]) => sira); + const min = Math.min(...siralar); + const max = Math.max(...siralar); + const genislik = 56; + const yukseklik = 18; + const bosluk = 2; + const noktalar = yillar.map(([yil, sira], index) => { + const x = + bosluk + + (index / (yillar.length - 1)) * (genislik - bosluk * 2); + const y = + max === min + ? yukseklik / 2 + : bosluk + + ((sira - min) / (max - min)) * (yukseklik - bosluk * 2); + return { x, y, yil, sira }; + }); + const son = noktalar[noktalar.length - 1]; + const ozet = yillar + .map(([yil, sira]) => `${yil}: ~${sira.toLocaleString("tr-TR")}.`) + .join("\n"); + + return ( + + {ozet} + `${nokta.x},${nokta.y}`).join(" ")} + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + + + ); +} + +/** Son iki yılın tabanı %10'dan fazla değiştiğinde yönü işaretler. */ +export function ProgramTabanTrendi({ program }: { program: Program }) { + if (program.sira2025 == null || program.sira2024 == null) return null; + + const oran = (program.sira2024 - program.sira2025) / program.sira2024; + if (Math.abs(oran) < 0.1) return null; + + const gecis = `${program.sira2024.toLocaleString("tr-TR")} → ${program.sira2025.toLocaleString("tr-TR")}`; + if (oran > 0) { + return ( + + {`Taban yükseliyor: ${gecis}`} + + ); + } + + return ( + + {`Taban geriliyor: ${gecis}`} + + ); +} diff --git a/src/components/program-tablosu.tsx b/src/components/program-tablosu.tsx index 02796a4..bf671de 100644 --- a/src/components/program-tablosu.tsx +++ b/src/components/program-tablosu.tsx @@ -9,18 +9,19 @@ import { useState } from "react"; import Link from "next/link"; import { useLinkStatus } from "next/link"; import { - Check, ChevronDown, - Plus, Rocket, Scale, ShieldCheck, - TrendingDown, - TrendingUp, } 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 { + ProgramSiraGecmisi, + ProgramTabanTrendi, + programEtkinSira, +} from "@/components/program-liste-verileri"; import { Table, TableBody, @@ -30,12 +31,7 @@ import { TableRow, } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - MANUEL_LISTE_MAX, - ekle, - useManuelListe, - type ManuelTercih, -} from "@/components/manuel-liste/store"; +import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu"; const DILIMLER: { key: DilimKey; @@ -128,22 +124,6 @@ function PuanTuruSecici({ ); } -function efektifSira(p: Program): number | null { - return p.sira2025 ?? p.sira2024; -} - -function tercihYap(p: Program, adaySira: number): ManuelTercih { - const taban = efektifSira(p); - return { - id: p.id, - isim: p.isim, - universite: p.universite, - il: p.il, - efektifSira: taban, - risk: riskHesapla(taban, adaySira), - }; -} - const BOS_EKSTRA: Record = { hayal: [], dengeli: [], @@ -167,12 +147,9 @@ export function ProgramTablosu({ adaySira: number; tur: PuanTuruKey; }) { - const liste = useManuelListe(); const [aktifDilim, setAktifDilim] = useState(() => varsayilanDilim(sonuclar), ); - const listedekiler = new Set(liste.map((t) => t.id)); - const doldu = liste.length >= MANUEL_LISTE_MAX; // "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür değişince // (puan türü seçici aynı bileşeni yeniden render eder, remount etmez) @@ -190,10 +167,6 @@ export function ProgramTablosu({ } } - function eklemeyiDene(p: Program) { - ekle(tercihYap(p, adaySira)); - } - async function dahaFazlaYukle(dilim: DilimKey, offset: number) { setYuklenen(dilim); try { @@ -300,9 +273,7 @@ export function ProgramTablosu({ key={p.id} program={p} adaySira={adaySira} - listede={listedekiler.has(p.id)} - doldu={doldu} - onEkle={eklemeyiDene} + tur={tur} /> ))} @@ -334,86 +305,6 @@ export function ProgramTablosu({ ); } -// Tabanın 2021–2025 seyri: küçük sıra = daha rekabetçi olduğundan y ekseni -// ters çevrilir; çizgi yukarı gidiyorsa bölüm yıllar içinde zorlaşıyor demektir. -function SiraGecmisi({ p }: { p: Program }) { - const yillar = [ - [2021, p.sira2021], - [2022, p.sira2022], - [2023, p.sira2023], - [2024, p.sira2024], - [2025, p.sira2025], - ].filter((y): y is [number, number] => y[1] != null); - if (yillar.length < 2) return ; - - const siralar = yillar.map(([, s]) => s); - const min = Math.min(...siralar); - const max = Math.max(...siralar); - const W = 56; - const H = 18; - const PAD = 2; - const noktalar = yillar.map(([yil, s], i) => { - const x = PAD + (i / (yillar.length - 1)) * (W - PAD * 2); - // min sıra (en rekabetçi) üstte dursun - const y = - max === min - ? H / 2 - : PAD + ((s - min) / (max - min)) * (H - PAD * 2); - return { x, y, yil, s }; - }); - const son = noktalar[noktalar.length - 1]; - const ozet = yillar - .map(([yil, s]) => `${yil}: ~${s.toLocaleString("tr-TR")}.`) - .join("\n"); - - return ( - - {ozet} - `${n.x},${n.y}`).join(" ")} - fill="none" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - - - ); -} - -// Taban sıra iki yıl üst üste %10'dan fazla oynadıysa yönünü gösterir; -// küçük sıra = daha rekabetçi taban demektir. -function TabanTrendi({ p }: { p: Program }) { - if (p.sira2025 == null || p.sira2024 == null) return null; - const oran = (p.sira2024 - p.sira2025) / p.sira2024; - if (Math.abs(oran) < 0.1) return null; - const yukseliyor = oran > 0; - const gecis = `${p.sira2024.toLocaleString("tr-TR")} → ${p.sira2025.toLocaleString("tr-TR")}`; - return yukseliyor ? ( - - {`Taban yükseliyor: ${gecis}`} - - ) : ( - - {`Taban geriliyor: ${gecis}`} - - ); -} - // /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı // kural (sondaki "(İL)" eki atılır) — client bundle'a harita verisi çekmemek // için burada tekrarlanır. Katalog haritası DB'deki tüm universite @@ -425,17 +316,13 @@ function uniSayfaSlug(ad: string): string { function ProgramSatiri({ program: p, adaySira, - listede, - doldu, - onEkle, + tur, }: { program: Program; adaySira: number; - listede: boolean; - doldu: boolean; - onEkle: (p: Program) => void; + tur: PuanTuruKey; }) { - const taban = efektifSira(p); + const taban = programEtkinSira(p); const risk = riskHesapla(taban, adaySira); const devlet = p.unitur === "DEVLET"; @@ -466,7 +353,6 @@ function ProgramSatiri({ href={`/universite/${uniSayfaSlug(p.universite)}`} className="hover:text-primary hover:underline" title={`${p.universite} taban puanları sayfası`} - // Satırdaki "+" ekleme akışıyla karışmasın: tıklama satıra yayılmaz onClick={(e) => e.stopPropagation()} > {p.universite} @@ -479,7 +365,7 @@ function ProgramSatiri({ - + - + {taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"} - + ); diff --git a/src/components/universite-konum-haritasi.tsx b/src/components/universite-konum-haritasi.tsx index 33b4f12..39f0c76 100644 --- a/src/components/universite-konum-haritasi.tsx +++ b/src/components/universite-konum-haritasi.tsx @@ -1,4 +1,3 @@ -import { siApple, siGooglemaps } from "simple-icons"; import { cities } from "turkey-map-react/lib/data"; import { HARITA_GENISLIK, @@ -7,34 +6,6 @@ import { normalizeIlAdi, uniKonum, } from "@/lib/harita"; -import { trBaslikDuzeni } from "@/lib/slug"; - -function HaritaMarkaIkonu({ marka }: { marka: string }) { - const ikon = - marka === "Google" ? siGooglemaps : marka === "Apple" ? siApple : null; - - if (ikon) { - return ( - - - - ); - } - - return ( - - Я - - ); -} export function UniversiteKonumHaritasi({ universite, @@ -44,17 +15,7 @@ export function UniversiteKonumHaritasi({ il: string | null; }) { const konum = uniKonum(universite, il); - const ilAdi = il ? trBaslikDuzeni(il) : null; - const arama = `${universite}${ilAdi ? ` ${ilAdi}` : ""}`; - const sorgu = encodeURIComponent(arama); - const haritaBaglantilari = [ - { - ad: "Google", - href: `https://www.google.com/maps/search/?api=1&query=${sorgu}`, - }, - { ad: "Yandex", href: `https://yandex.com.tr/maps/?text=${sorgu}` }, - { ad: "Apple", href: `https://maps.apple.com/?q=${sorgu}` }, - ]; + if (!konum) return null; return (
    - {konum ? ( - - ) : ( -
    + - {haritaBaglantilari.map((baglanti) => ( - - - - ))} -
    - )} + {cities.map((city) => { + const secili = il ? normalizeIlAdi(city.name) === il : false; + return ( + + {city.name} + + ); + })} + + + + + + +
    ); } diff --git a/src/components/universite-program-tablosu.tsx b/src/components/universite-program-tablosu.tsx new file mode 100644 index 0000000..25295b7 --- /dev/null +++ b/src/components/universite-program-tablosu.tsx @@ -0,0 +1,127 @@ +"use client"; + +import Link from "next/link"; +import type { Program } from "@/lib/db"; +import { + ProgramSiraGecmisi, + ProgramTabanTrendi, + programEtkinSira, +} from "@/components/program-liste-verileri"; +import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const sayi = (n: number) => n.toLocaleString("tr-TR"); + +const TUR_ETIKET: Record = { + SAYISAL: "SAY", + "EŞİT AĞIRLIK": "EA", + SÖZEL: "SÖZ", + DİL: "DİL", + TYT: "TYT", +}; + +export type UniProgramSatiri = Program & { bolumSlug: string | null }; + +export function UniversiteProgramTablosu({ + gruplar, +}: { + gruplar: { fakulte: string; programlar: UniProgramSatiri[] }[]; +}) { + return ( + <> + {gruplar.map(({ fakulte, programlar }, i) => ( +
    +

    + {fakulte} +

    +
    + + + + Program + + Son 5 yıl + + + 2025 puan + + + Kontenjan + + Taban sıra + + Listeye ekle + + + + + {programlar.map((p) => { + const taban = programEtkinSira(p); + return ( + + + {p.bolumSlug ? ( + + {p.isim.trim()} + + ) : ( + + {p.isim.trim()} + + )} + + + {TUR_ETIKET[p.tur] ?? p.tur} + + {p.sure ? ( + {p.sure} yıl + ) : null} + + + + + + + {p.puan2025 != null + ? p.puan2025.toFixed(2).replace(".", ",") + : "—"} + + + {p.kontenjan2025 != null + ? `${sayi(p.kontenjan2025)} kişi` + : "—"} + + + + + {taban != null ? `~${sayi(taban)}.` : "—"} + + + + + + + ); + })} + +
    +
    +
    + ))} + + ); +} diff --git a/src/lib/sihirbaz.ts b/src/lib/sihirbaz.ts index 9f0bb61..0a598b3 100644 --- a/src/lib/sihirbaz.ts +++ b/src/lib/sihirbaz.ts @@ -34,6 +34,12 @@ export interface SihirbazSecimleri { oncelikler: string[]; } +export interface TercihProfili { + sira: number; + tur: string; + secimler: SihirbazSecimleri; +} + const UNI_TIPLERI: UniversiteTipi[] = ["devlet", "vakif", "farketmez"]; /** @@ -90,6 +96,44 @@ export function sihirbazDogrula(girdi: unknown): SihirbazSecimleri | null { return { kategoriler, iller, universiteTipi, oncelikler }; } +/** localStorage'taki ortak sıra + sihirbaz profilini güvenle doğrular. */ +export function tercihProfiliDogrula(girdi: unknown): TercihProfili | null { + if (typeof girdi !== "object" || girdi === null) return null; + const p = girdi as Record; + const sira = typeof p.sira === "number" ? p.sira : Number(p.sira); + if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) return null; + if (typeof p.tur !== "string" || p.tur.trim().length === 0) return null; + const secimler = sihirbazDogrula(p.secimler); + if (!secimler) return null; + return { sira, tur: p.tur, secimler }; +} + +/** Client'ta SIHIRBAZ_STORAGE_KEY'den doğrulanmış profil okur. */ +export function tercihProfiliOku(): TercihProfili | null { + if (typeof window === "undefined") return null; + try { + const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY); + if (!ham) return null; + return tercihProfiliDogrula(JSON.parse(ham)); + } catch { + return null; + } +} + +/** Ortak profili yazar; hero ve AI sihirbazıyla aynı anahtarı kullanır. */ +export function tercihProfiliYaz(profil: TercihProfili): void { + if (typeof window === "undefined") return; + const dogru = tercihProfiliDogrula(profil); + if (!dogru) return; + try { + localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru)); + } catch {} +} + +export function tercihProfiliTamamlandiMi(): boolean { + return tercihProfiliOku() !== null; +} + /** Prompt'a enjekte edilecek Türkçe özet. */ export function secimOzeti(secimler: SihirbazSecimleri): string { const satirlar = [ diff --git a/src/lib/tercih-degerlendirme.ts b/src/lib/tercih-degerlendirme.ts new file mode 100644 index 0000000..fd5e86a --- /dev/null +++ b/src/lib/tercih-degerlendirme.ts @@ -0,0 +1,127 @@ +import { kategoriEslesir } from "./kategoriler"; +import { riskHesapla, type RiskSeviyesi } from "./risk"; +import type { TercihProfili } from "./sihirbaz"; + +export type DegerlendirilecekProgram = { + isim: string; + il: string | null; + unitur?: string | null; + efektifSira: number | null; +}; + +export type TercihDegerlendirmesi = { + risk: RiskSeviyesi | null; + riskEtiketi: "Hayal" | "Dengeli" | "Daha güvenli" | "Veri yetersiz"; + riskMetni: string; + olumlu: string[]; + celiskiler: string[]; +}; + +function normalize(deger: string): string { + return deger.trim().toLocaleUpperCase("tr-TR"); +} + +export function tercihDegerlendir( + program: DegerlendirilecekProgram, + profil: TercihProfili, +): TercihDegerlendirmesi { + const risk = riskHesapla(program.efektifSira, profil.sira); + const olumlu: string[] = []; + const celiskiler: string[] = []; + + if (kategoriEslesir(program.isim, profil.secimler.kategoriler)) { + olumlu.push("İlgi alanlarınla uyumlu"); + } else { + celiskiler.push("Seçtiğin ilgi alanlarının dışında görünüyor"); + } + + if (profil.secimler.iller.length > 0) { + const seciliIller = new Set(profil.secimler.iller.map(normalize)); + if (program.il && seciliIller.has(normalize(program.il))) { + olumlu.push("Tercih ettiğin şehirlerden birinde"); + } else { + celiskiler.push("Tercih ettiğin şehirlerin dışında"); + } + } + + if (profil.secimler.universiteTipi !== "farketmez" && program.unitur) { + const devlet = normalize(program.unitur) === "DEVLET"; + const uyuyor = + (profil.secimler.universiteTipi === "devlet" && devlet) || + (profil.secimler.universiteTipi === "vakif" && !devlet); + if (uyuyor) olumlu.push("Üniversite türü tercihine uyuyor"); + else celiskiler.push("Üniversite türü tercihinle uyuşmuyor"); + } + + if (profil.secimler.oncelikler.includes("Kolay yerleşme")) { + if (risk === "guvenli") { + olumlu.push("Daha güvenli seçenek önceliğini destekliyor"); + } else if (risk === "riskli") { + celiskiler.push("Kolay yerleşme önceliğine göre iddialı bir seçim"); + } + } + + const riskEtiketi = + risk === "riskli" + ? "Hayal" + : risk === "az-riskli" + ? "Dengeli" + : risk === "guvenli" + ? "Daha güvenli" + : "Veri yetersiz"; + const riskMetni = + risk === "riskli" + ? "Geçen yılın taban sıralaması senin sıralamandan daha yukarıda." + : risk === "az-riskli" + ? "Geçmiş taban sıralamasına göre dengeli aralıkta." + : risk === "guvenli" + ? "Geçmiş taban sıralamasına göre daha güvenli aralıkta." + : "Yerleşme riskini hesaplamak için yeterli taban sıra verisi yok."; + + return { + risk, + riskEtiketi, + riskMetni, + olumlu: olumlu.slice(0, 3), + celiskiler: celiskiler.slice(0, 2), + }; +} + +export function tercihListesiOzeti( + programlar: DegerlendirilecekProgram[], + profil: TercihProfili, +) { + const degerlendirmeler = programlar.map((p) => tercihDegerlendir(p, profil)); + const hayal = degerlendirmeler.filter((d) => d.risk === "riskli").length; + const dengeli = degerlendirmeler.filter( + (d) => d.risk === "az-riskli", + ).length; + const dahaGuvenli = degerlendirmeler.filter( + (d) => d.risk === "guvenli", + ).length; + const dikkat = degerlendirmeler.filter( + (d) => d.celiskiler.length > 0, + ).length; + const gucluUyum = degerlendirmeler.filter( + (d) => d.olumlu.length >= 2 && d.celiskiler.length === 0, + ).length; + + let oneri = "Yeni seçimler ekledikçe listenin dengesi burada görünecek."; + if (programlar.length > 0 && dahaGuvenli === 0) { + oneri = + "Listen iddialı seçeneklerde yoğunlaşıyor. Birkaç daha güvenli program eklemek riski dağıtabilir."; + } else if (programlar.length > 2 && dengeli === 0) { + oneri = + "Listen için dengeli aralıkta birkaç program eklemek seçeneklerini güçlendirebilir."; + } else if (dikkat > Math.max(1, programlar.length / 2)) { + oneri = + "Seçtiklerinin çoğu profilinle ayrışıyor. Dikkat notlarını birlikte gözden geçir."; + } else if (programlar.length >= 24) { + oneri = + "24 tercihe ulaştın. Şimdi risk dağılımını ve dikkat notlarını gözden geçir."; + } else if (programlar.length > 0) { + oneri = "Listen dengeleniyor; uyum ve risk dağılımını koruyarak ilerle."; + } + + return { hayal, dengeli, dahaGuvenli, dikkat, gucluUyum, oneri }; +}