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.
All checks were successful
Deploy / deploy (push) Successful in 7m25s

This commit is contained in:
bilalgursen
2026-08-01 17:58:38 +03:00
parent 8cf0c3267d
commit 55eb32b22a
21 changed files with 2137 additions and 709 deletions

239
GAMIFICATION.md Normal file
View File

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

View File

@@ -78,46 +78,46 @@ export default function BolumlerPage() {
return ( return (
<> <>
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16"> <main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
<JsonLd <JsonLd
data={breadcrumbJsonLd([ data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" }, { name: "Ana Sayfa", path: "/" },
{ name: "Bölümler", path: "/bolumler" }, { name: "Bölümler", path: "/bolumler" },
])} ])}
/> />
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl"> <h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026 Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026
</h1> </h1>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600"> <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban {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ı. 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 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 görürsün. Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026
tercihleri için rehber niteliğindedir. tercihleri için rehber niteliğindedir.
</p> </p>
<PagePixelDivider seed={29} className="mt-8" /> <PagePixelDivider seed={29} className="mt-8" />
<section className="mt-8"> <section className="mt-8">
<h2 className="font-heading text-2xl font-bold text-slate-900"> <h2 className="font-heading text-2xl font-bold text-slate-900">
Lisans Bölümleri (4 Yıllık) Lisans Bölümleri (4 Yıllık)
</h2> </h2>
<KategoriGruplari bolumler={lisans} seviye="lisans" /> <KategoriGruplari bolumler={lisans} seviye="lisans" />
</section> </section>
<div className="mt-12"> <div className="mt-12">
<CtaSiraForm /> <CtaSiraForm />
</div> </div>
<section className="mt-12"> <section className="mt-12">
<h2 className="font-heading text-2xl font-bold text-slate-900"> <h2 className="font-heading text-2xl font-bold text-slate-900">
Önlisans Bölümleri (2 Yıllık TYT) Önlisans Bölümleri (2 Yıllık TYT)
</h2> </h2>
<KategoriGruplari bolumler={onlisans} seviye="onlisans" /> <KategoriGruplari bolumler={onlisans} seviye="onlisans" />
</section> </section>
</main> </main>
<SiteFooter /> <SiteFooter />
</> </>
); );
} }

View File

@@ -5,6 +5,7 @@ import { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { SiteTopBanner } from "@/components/site-top-banner"; import { SiteTopBanner } from "@/components/site-top-banner";
import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy"; 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 { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41"; import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
import { DevPanel } from "@/components/dev/dev-panel"; import { DevPanel } from "@/components/dev/dev-panel";
@@ -109,6 +110,8 @@ export default function RootLayout({
</ScrollFlow> </ScrollFlow>
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */} {/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
<ListeCekmecesiLazy /> <ListeCekmecesiLazy />
{/* İlk "+" için sıra + sihirbaz kapısı */}
<TercihProfiliKapisiLazy />
<Toaster position="top-center" /> <Toaster position="top-center" />
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */} {/* Dev süperadmin paneli — prod build'de hiç render edilmez */}
{process.env.NODE_ENV !== "production" ? <DevPanel /> : null} {process.env.NODE_ENV !== "production" ? <DevPanel /> : null}

View File

@@ -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 (
<Tabs defaultValue={varsayilan} className="mt-2 gap-6">
<TabsList variant="line" className="h-auto w-full justify-start gap-1">
<TabsTrigger value="secimlerim" className="cursor-pointer px-3 py-2.5">
<ListChecks className="size-4" aria-hidden />
Seçtiklerim
</TabsTrigger>
<TabsTrigger value="ai" className="cursor-pointer px-3 py-2.5">
<Sparkles className="size-4" aria-hidden />
AI Listem
</TabsTrigger>
</TabsList>
<TabsContent value="secimlerim" className="mt-0">
<SecimlerimPaneli />
</TabsContent>
<TabsContent value="ai" className="mt-0">
{aiIcerik}
</TabsContent>
</Tabs>
);
}

View File

@@ -29,6 +29,7 @@ import { PagePixelDivider } from "@/components/pixel-decor";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske"; import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
import { SohbetClient, type Mesaj } from "./sohbet-client"; import { SohbetClient, type Mesaj } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu"; import { RevizyonKutusu } from "./revizyon-kutusu";
import { ListemSekmeleri, type ListemSekme } from "./listem-sekmeleri";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Listem", title: "Listem",
@@ -42,18 +43,15 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
export default async function ListemPage({ export default async function ListemPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ acildi?: string }>; searchParams: Promise<{ acildi?: string; sekme?: string }>;
}) { }) {
const session = await verifySession("/listem"); const session = await verifySession("/listem");
// Kullanıcı satırı, ?acildi parametresi ve rapor birbirinden bağımsız — const [user, params, satir, sohbetGecmisi] = await Promise.all([
// seri await zinciri yerine tek turda çöz.
const [user, { acildi }, satir, sohbetGecmisi] = await Promise.all([
getCurrentUser(), getCurrentUser(),
searchParams, searchParams,
appDb.query.reports.findFirst({ appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id), where: eq(schema.reports.userId, session.user.id),
}), }),
// Sohbet geçmişi burada gelirse client mount sonrası /api/soru turu atmaz
appDb appDb
.select({ .select({
id: schema.chatMessages.id, id: schema.chatMessages.id,
@@ -67,41 +65,64 @@ export default async function ListemPage({
]); ]);
if (!user) redirect("/giris?callback=/listem"); 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 kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
const raporVar = Boolean(satir?.result);
// Liste yoksa önce sihirbazdan oluşturulmalı const varsayilanSekme: ListemSekme =
if (!satir?.result) { sekme === "secimlerim"
? "secimlerim"
: sekme === "ai" || acildi === "1" || raporVar
? "ai"
: "secimlerim";
const aiBos = (
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
<Sparkles className="size-10 text-primary" aria-hidden />
<h2 className="mt-4 font-heading text-2xl font-bold">
Henüz AI listen yok
</h2>
<p className="mt-3 text-slate-600">
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.
</p>
<PagePixelDivider seed={67} className="mx-auto mt-6" />
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
>
<Link href="/">Sıralamanı gir, AI listeni kur</Link>
</Button>
</section>
);
if (!raporVar) {
return ( return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center"> <main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
<Sparkles className="size-10 text-primary" aria-hidden /> <h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
<h1 className="mt-4 font-heading text-2xl font-bold"> Tercih karar merkezin
Henüz bir listen yok
</h1> </h1>
<p className="mt-3 text-slate-600"> <p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; Seçtiklerin ve AI listen burada yan yana ikisi birbirinden bağımsız.
sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.
</p> </p>
<PagePixelDivider seed={67} className="mx-auto mt-6" /> <PagePixelDivider seed={71} className="mt-6" />
<Button <ListemSekmeleri
asChild varsayilan={varsayilanSekme}
size="lg" aiIcerik={aiBos}
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600" />
>
<Link href="/">Sıralamanı gir, listeni kur</Link>
</Button>
</main> </main>
); );
} }
// Paketsize giden rapor burada da maskelenir (bkz. rapor-maske)
const rapor = user.hasPaket const rapor = user.hasPaket
? (satir.result as RaporSonuc) ? (satir!.result as RaporSonuc)
: raporMaskele(satir.result as RaporSonuc); : raporMaskele(satir!.result as RaporSonuc);
const params = satir.params as RaporParams; const raporParams = satir!.params as RaporParams;
return ( const aiIcerik = (
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10"> <>
{kilitAcilisi ? ( {kilitAcilisi ? (
<div <div
role="status" role="status"
@@ -109,7 +130,7 @@ export default async function ListemPage({
> >
<Sparkles className="size-4 shrink-0" aria-hidden /> <Sparkles className="size-4 shrink-0" aria-hidden />
Paketin aktif 24 tercihinin tamamı, gerekçeler, riskler ve PDF Paketin aktif 24 tercihinin tamamı, gerekçeler, riskler ve PDF
artık ık. 🎉 artık ık.
</div> </div>
) : null} ) : null}
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-end justify-between gap-4">
@@ -125,14 +146,16 @@ export default async function ListemPage({
YÖK Atlas verileri ücretsiz YÖK Atlas verileri ücretsiz
</Badge> </Badge>
)} )}
<h1 className="max-w-2xl font-heading text-2xl font-bold tracking-tight sm:text-3xl"> <h2 className="max-w-2xl font-heading text-xl font-bold tracking-tight sm:text-2xl">
{user.hasPaket {user.hasPaket
? "Tercih karar merkezin" ? "AI tercih listen"
: "Veriyi ücretsiz incele. Kararı AI ile netleştir."} : "Veriyi ücretsiz incele. Kararı AI ile netleştir."}
</h1> </h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600"> <p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
{params.sira.toLocaleString("tr-TR")}. sıra ·{" "} {raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[params.tur]} ·{" "} {PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ??
raporParams.tur}{" "}
·{" "}
{user.hasPaket {user.hasPaket
? "24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle aşağıda inceleyebilirsin." ? "24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle aşağıda inceleyebilirsin."
: "24 programın 20242025 taban sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin."} : "24 programın 20242025 taban sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin."}
@@ -165,16 +188,13 @@ export default async function ListemPage({
</div> </div>
) : null} ) : null}
</div> </div>
<PagePixelDivider seed={71} className="mt-6" />
{/* AI'ın genel değerlendirmesi ve uyarıları: paketliye tam,
paketsize sunucuda kırpılmış önizleme + kilit notu */}
{rapor.genelDegerlendirme ? ( {rapor.genelDegerlendirme ? (
<section className="mt-6 rounded-2xl border border-slate-200 bg-white p-5"> <section className="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h2 className="flex items-center gap-2 font-heading text-base font-bold"> <h3 className="flex items-center gap-2 font-heading text-base font-bold">
<Bot className="size-4 text-primary" aria-hidden /> <Bot className="size-4 text-primary" aria-hidden />
Danışman özeti Danışman özeti
</h2> </h3>
<p className="mt-2 text-sm leading-6 text-slate-700"> <p className="mt-2 text-sm leading-6 text-slate-700">
{rapor.genelDegerlendirme} {rapor.genelDegerlendirme}
</p> </p>
@@ -205,51 +225,51 @@ export default async function ListemPage({
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]"> <div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex min-w-0 flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
<section <section
className={`min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5${ className={`min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5${
kilitAcilisi ? " kt-unblur" : "" kilitAcilisi ? " kt-unblur" : ""
}`} }`}
> >
<RaporListesi <RaporListesi
rapor={rapor} rapor={rapor}
adaySira={params.sira} adaySira={raporParams.sira}
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR} kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
kilitOverlay={ kilitOverlay={
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-3"> <div className="flex gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700"> <span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
<Lock className="size-4" aria-hidden /> <Lock className="size-4" aria-hidden />
</span> </span>
<div> <div>
<p className="font-heading font-bold text-slate-900"> <p className="font-heading font-bold text-slate-900">
İlk {ACIK_SATIR} tercihte AI analizini gördün İlk {ACIK_SATIR} tercihte AI analizini gördün
</p> </p>
<p className="mt-1 max-w-xl text-sm text-slate-600"> <p className="mt-1 max-w-xl text-sm text-slate-600">
Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve
AI yorumu paketle ılır. Program adları ve taban verileri AI yorumu paketle ılır. Program adları ve taban
her zaman ücretsiz. verileri her zaman ücretsiz.
</p> </p>
</div>
</div> </div>
<Button
asChild
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
>
<Link href="/paket">
Analizleri {PAKET_FIYATI} TL
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
</div> </div>
<Button }
asChild />
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto" </section>
>
<Link href="/paket">
Analizleri {PAKET_FIYATI} TL
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
</div>
}
/>
</section>
{user.hasPaket ? ( {user.hasPaket ? (
<RevizyonKutusu <RevizyonKutusu
kalanHak={Math.max(0, MAX_REVIZYON - (satir.revisionCount ?? 0))} kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
/> />
) : null} ) : null}
</div> </div>
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28"> <div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
@@ -260,91 +280,87 @@ export default async function ListemPage({
/> />
) : ( ) : (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"> <section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 p-6"> <div className="border-b border-slate-100 p-6">
<div className="flex items-center gap-2 text-primary"> <div className="flex items-center gap-2 text-primary">
<Sparkles className="size-4" aria-hidden /> <Sparkles className="size-4" aria-hidden />
<span className="text-xs font-bold uppercase tracking-wide"> <span className="text-xs font-bold uppercase tracking-wide">
Tercih Dönemi Paketi Tercih Dönemi Paketi
</span> </span>
</div>
<h2 className="mt-3 font-heading text-xl font-bold tracking-tight">
Tablo ücretsiz.
<br />
Karar desteği pakette.
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Sadece veriyi göstermez; hangi tercihin neden mantıklı
olduğunu sıralamana göre ıklar.
</p>
</div>
<div className="p-6">
<ul className="flex flex-col gap-3 text-sm">
{[
"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) => (
<li key={ozellik} className="flex items-start gap-2.5">
<Check
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
<span>{ozellik}</span>
</li>
))}
</ul>
<div className="mt-6 flex items-end justify-between">
<div>
<p className="text-xs text-slate-500">Tek seferlik</p>
<p className="font-heading text-3xl font-bold">
{PAKET_FIYATI} TL
</p>
</div> </div>
<Badge variant="outline">Abonelik yok</Badge> <h3 className="mt-3 font-heading text-xl font-bold tracking-tight">
Tablo ücretsiz.
<br />
Karar desteği pakette.
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
Sadece veriyi göstermez; hangi tercihin neden mantıklı
olduğunu sıralamana göre ıklar.
</p>
</div> </div>
<Button <div className="p-6">
asChild <ul className="flex flex-col gap-3 text-sm">
size="lg" {[
className="mt-5 h-12 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600" "24 tercihin tamamında kişisel risk analizi",
> "Her program için kısa, gerekçeli AI yorumu",
<Link href="/paket"> "2 liste revizyonu ve paylaşılabilir PDF",
Kişisel analizi `${URUNLER.paket.credits} AI danışman sorusu`,
<ArrowRight data-icon="inline-end" aria-hidden /> ].map((ozellik) => (
</Link> <li key={ozellik} className="flex items-start gap-2.5">
</Button> <Check
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
<span>{ozellik}</span>
</li>
))}
</ul>
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500"> <div className="mt-6 flex items-end justify-between">
<span className="inline-flex items-center gap-1"> <div>
<ShieldCheck className="size-3.5" aria-hidden /> <p className="text-xs text-slate-500">Tek seferlik</p>
iyzico güvenli ödeme <p className="font-heading text-3xl font-bold">
</span> {PAKET_FIYATI} TL
<span className="inline-flex items-center gap-1"> </p>
<Bot className="size-3.5" aria-hidden /> </div>
Gerçek YÖK verisi <Badge variant="outline">Abonelik yok</Badge>
</span> </div>
<Button
asChild
size="lg"
className="mt-5 h-12 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
<Link href="/paket">
Kişisel analizi
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1">
<ShieldCheck className="size-3.5" aria-hidden />
iyzico güvenli ödeme
</span>
<span className="inline-flex items-center gap-1">
<Bot className="size-3.5" aria-hidden />
Gerçek YÖK verisi
</span>
</div>
</div> </div>
</div> </section>
</section>
{/* Deneme kredileri sohbette geçerli: AI'ı satın almadan önce <SohbetClient
tattırır (kalan kredi biterse composer paket CTA'sına döner). */} baslangicKredi={user.creditBalance}
<SohbetClient initialMesajlar={sohbetGecmisi as Mesaj[]}
baslangicKredi={user.creditBalance} className="h-[480px]"
initialMesajlar={sohbetGecmisi as Mesaj[]} />
className="h-[480px]"
/>
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* 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. */}
<ViewportPortal> <ViewportPortal>
<a <a
href="#sohbet" href="#sohbet"
@@ -354,6 +370,19 @@ export default async function ListemPage({
Danışmana sor Danışmana sor
</a> </a>
</ViewportPortal> </ViewportPortal>
</>
);
return (
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
Tercih karar merkezin
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
Seçtiklerin ve AI listen burada yan yana ikisi birbirinden bağımsız.
</p>
<PagePixelDivider seed={71} className="mt-6" />
<ListemSekmeleri varsayilan={varsayilanSekme} aiIcerik={aiIcerik} />
</main> </main>
); );
} }

View File

@@ -10,26 +10,11 @@ import { SiteFooter } from "@/components/site-footer";
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama"; import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
import { PagePixelDivider } from "@/components/pixel-decor"; import { PagePixelDivider } from "@/components/pixel-decor";
import { UniversiteKonumHaritasi } from "@/components/universite-konum-haritasi"; import { UniversiteKonumHaritasi } from "@/components/universite-konum-haritasi";
import { import { UniversiteProgramTablosu } from "@/components/universite-program-tablosu";
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Program } from "@/lib/db"; import type { Program } from "@/lib/db";
const sayi = (n: number) => n.toLocaleString("tr-TR"); const sayi = (n: number) => n.toLocaleString("tr-TR");
const TUR_ETIKET: Record<string, string> = {
SAYISAL: "SAY",
"EŞİT AĞIRLIK": "EA",
SÖZEL: "SÖZ",
DİL: "DİL",
TYT: "TYT",
};
export function uniToplamSayfa(programSayisi: number): number { export function uniToplamSayfa(programSayisi: number): number {
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU)); return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
} }
@@ -69,12 +54,16 @@ export function UniversiteIcerik({
(sayfa - 1) * SAYFA_BOYU, (sayfa - 1) * SAYFA_BOYU,
sayfa * 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) { for (const p of dilim) {
const anahtar = p.fakulte ?? "Diğer"; const anahtar = p.fakulte ?? "Diğer";
const satir = { ...p, bolumSlug: bolumSlugFromIsim(p.isim) };
const son = gruplar[gruplar.length - 1]; const son = gruplar[gruplar.length - 1];
if (son && son.fakulte === anahtar) son.programlar.push(p); if (son && son.fakulte === anahtar) son.programlar.push(satir);
else gruplar.push({ fakulte: anahtar, programlar: [p] }); else gruplar.push({ fakulte: anahtar, programlar: [satir] });
} }
const ilkSayfa = sayfa === 1; const ilkSayfa = sayfa === 1;
@@ -178,6 +167,7 @@ export function UniversiteIcerik({
</p> </p>
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2"> <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
<UniversiteKonumHaritasi universite={uni.ad} il={uni.il} />
<dl className="contents"> <dl className="contents">
<div className="rounded-xl border border-slate-200 bg-white p-4"> <div className="rounded-xl border border-slate-200 bg-white p-4">
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500"> <dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
@@ -216,7 +206,6 @@ export function UniversiteIcerik({
</dd> </dd>
</div> </div>
</dl> </dl>
<UniversiteKonumHaritasi universite={uni.ad} il={uni.il} />
</div> </div>
</> </>
) : ( ) : (
@@ -238,10 +227,7 @@ export function UniversiteIcerik({
<PagePixelDivider seed={57} className="mt-8" /> <PagePixelDivider seed={57} className="mt-8" />
<div <div id="programlar" className="mt-8">
id="programlar"
className="mt-8"
>
<h2 className="font-heading text-xl font-bold text-slate-900"> <h2 className="font-heading text-xl font-bold text-slate-900">
Üniversite programları Üniversite programları
</h2> </h2>
@@ -250,67 +236,7 @@ export function UniversiteIcerik({
</p> </p>
</div> </div>
{gruplar.map(({ fakulte, programlar }, i) => ( <UniversiteProgramTablosu gruplar={gruplar} />
<section key={`${fakulte}-${i}`} className="mt-6">
<h2 className="mb-3 font-heading text-xl font-bold text-slate-900">
{fakulte}
</h2>
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Program</TableHead>
<TableHead>Puan Türü</TableHead>
<TableHead className="text-right">2025 Puan</TableHead>
<TableHead className="text-right">2025 Sıralama</TableHead>
<TableHead className="text-right">2024 Sıralama</TableHead>
<TableHead className="text-right">Kontenjan</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{programlar.map((p) => {
const bolumSlug = bolumSlugFromIsim(p.isim);
return (
<TableRow key={p.id}>
<TableCell className="max-w-72">
{bolumSlug ? (
<Link
href={`/bolum/${bolumSlug}`}
className="font-medium text-slate-900 hover:text-primary hover:underline"
>
{p.isim.trim()}
</Link>
) : (
<span className="font-medium">
{p.isim.trim()}
</span>
)}
</TableCell>
<TableCell>{TUR_ETIKET[p.tur] ?? p.tur}</TableCell>
<TableCell className="text-right tabular-nums">
{p.puan2025 != null
? p.puan2025.toFixed(2).replace(".", ",")
: "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{p.sira2025 != null ? sayi(p.sira2025) : "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{p.sira2024 != null ? sayi(p.sira2024) : "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{p.kontenjan2025 != null
? sayi(p.kontenjan2025)
: "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</section>
))}
<p className="mt-3 text-xs leading-5 text-slate-500"> <p className="mt-3 text-xs leading-5 text-slate-500">
&quot;&quot;: o yıl için yerleştirme verisi bulunmuyor (yeni ılan &quot;&quot;: o yıl için yerleştirme verisi bulunmuyor (yeni ılan

View File

@@ -49,64 +49,64 @@ export default function UniversitelerPage() {
return ( return (
<> <>
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16"> <main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
<JsonLd <JsonLd
data={breadcrumbJsonLd([ data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" }, { name: "Ana Sayfa", path: "/" },
{ name: "Üniversiteler", path: "/universiteler" }, { name: "Üniversiteler", path: "/universiteler" },
])} ])}
/> />
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl"> <h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
Üniversitelerin Taban Puanları ve Sıralamaları 2026 Üniversitelerin Taban Puanları ve Sıralamaları 2026
</h1> </h1>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600"> <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri. {sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri.
Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı Ü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 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 YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için
rehber niteliğindedir. rehber niteliğindedir.
</p> </p>
<PagePixelDivider seed={63} className="mt-8" /> <PagePixelDivider seed={63} className="mt-8" />
<section className="mt-8"> <section className="mt-8">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Devlet Üniversiteleri
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(devlet.length)}
</span>
</h2>
<UniLinkleri uniler={devlet} />
</section>
<section className="mt-10">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Vakıf Üniversiteleri
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(vakif.length)}
</span>
</h2>
<UniLinkleri uniler={vakif} />
</section>
{digerUniler.length > 0 ? (
<section className="mt-10">
<h2 className="font-heading text-2xl font-bold text-slate-900"> <h2 className="font-heading text-2xl font-bold text-slate-900">
KKTC ve Yurt Dışı Devlet Üniversiteleri
<span className="ml-2 text-sm font-normal text-slate-400"> <span className="ml-2 text-sm font-normal text-slate-400">
{sayi(digerUniler.length)} {sayi(devlet.length)}
</span> </span>
</h2> </h2>
<UniLinkleri uniler={digerUniler} /> <UniLinkleri uniler={devlet} />
</section> </section>
) : null}
<div className="mt-12"> <section className="mt-10">
<CtaSiraForm /> <h2 className="font-heading text-2xl font-bold text-slate-900">
</div> Vakıf Üniversiteleri
</main> <span className="ml-2 text-sm font-normal text-slate-400">
<SiteFooter /> {sayi(vakif.length)}
</span>
</h2>
<UniLinkleri uniler={vakif} />
</section>
{digerUniler.length > 0 ? (
<section className="mt-10">
<h2 className="font-heading text-2xl font-bold text-slate-900">
KKTC ve Yurt Dışı
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(digerUniler.length)}
</span>
</h2>
<UniLinkleri uniler={digerUniler} />
</section>
) : null}
<div className="mt-12">
<CtaSiraForm />
</div>
</main>
<SiteFooter />
</> </>
); );
} }

View File

@@ -3,9 +3,21 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { Program } from "@/lib/db"; import type { Program } from "@/lib/db";
import { trBaslikDuzeni } from "@/lib/slug"; 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"); const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -47,13 +59,17 @@ function SiralamaDugmesi({
<span className="flex flex-col -space-y-1" aria-hidden> <span className="flex flex-col -space-y-1" aria-hidden>
<ChevronUp <ChevronUp
className={`size-3 ${ className={`size-3 ${
aktif && yon === "artan" ? "text-primary" : "text-muted-foreground/40" aktif && yon === "artan"
? "text-primary"
: "text-muted-foreground/40"
}`} }`}
strokeWidth={aktif && yon === "artan" ? 3 : 2} strokeWidth={aktif && yon === "artan" ? 3 : 2}
/> />
<ChevronDown <ChevronDown
className={`size-3 ${ className={`size-3 ${
aktif && yon === "azalan" ? "text-primary" : "text-muted-foreground/40" aktif && yon === "azalan"
? "text-primary"
: "text-muted-foreground/40"
}`} }`}
strokeWidth={aktif && yon === "azalan" ? 3 : 2} strokeWidth={aktif && yon === "azalan" ? 3 : 2}
/> />
@@ -85,11 +101,7 @@ export function BolumProgramListesi({
if (bDeger == null) return -1; if (bDeger == null) return -1;
const fark = aDeger - bDeger; const fark = aDeger - bDeger;
return fark === 0 return fark === 0 ? a.index - b.index : yon === "artan" ? fark : -fark;
? a.index - b.index
: yon === "artan"
? fark
: -fark;
}) })
.map(({ program }) => program); .map(({ program }) => program);
}, [aktifAlan, programlar, yon]); }, [aktifAlan, programlar, yon]);
@@ -104,88 +116,116 @@ export function BolumProgramListesi({
} }
return ( return (
<ul className="divide-y divide-border overflow-hidden rounded-2xl border bg-card"> <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
{siraliProgramlar.map((p) => { <Table>
const uniAd = trBaslikDuzeni(p.universite); <TableHeader>
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null; <TableRow>
return ( <TableHead>Üniversite</TableHead>
<li <TableHead className="hidden text-right md:table-cell">
key={p.id} Son 5 yıl
className="grid gap-4 px-4 py-5 sm:px-5 lg:grid-cols-[minmax(0,1fr)_8rem_8rem_7rem] lg:items-center" </TableHead>
> <TableHead className="hidden text-right lg:table-cell">
<div className="min-w-0"> <SiralamaDugmesi
{p.universiteSlug ? ( alan="puan2025"
<Link aktifAlan={aktifAlan}
href={`/universite/${p.universiteSlug}`} yon={yon}
className="font-semibold text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onSirala={sirala}
> />
{uniAd} </TableHead>
</Link> <TableHead className="hidden text-right sm:table-cell">
) : ( <SiralamaDugmesi
<p className="font-semibold text-foreground">{uniAd}</p> alan="kontenjan2025"
)} aktifAlan={aktifAlan}
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground"> yon={yon}
{p.il ? <span>{trBaslikDuzeni(p.il)}</span> : null} onSirala={sirala}
{p.il && p.unitur ? <span aria-hidden>·</span> : null} />
{p.unitur ? ( </TableHead>
<Badge variant="secondary" className="font-normal"> <TableHead className="text-right">
{p.unitur === "DEVLET" <SiralamaDugmesi
? "Devlet" alan="sira2025"
: trBaslikDuzeni(p.unitur)} aktifAlan={aktifAlan}
</Badge> yon={yon}
) : null} onSirala={sirala}
</div> />
{varyant ? ( </TableHead>
<p className="mt-2 text-sm text-muted-foreground">{varyant}</p> <TableHead className="w-14 text-right">
) : null} <span className="sr-only">Listeye ekle</span>
</div> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{siraliProgramlar.map((p) => {
const uniAd = trBaslikDuzeni(p.universite);
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
const devlet = p.unitur === "DEVLET";
<dl className="grid grid-cols-3 gap-3 lg:contents"> return (
<div className="lg:text-right"> <TableRow key={p.id}>
<dt> <TableCell className="max-w-0 w-full">
<SiralamaDugmesi {p.universiteSlug ? (
alan="sira2025" <Link
aktifAlan={aktifAlan} href={`/universite/${p.universiteSlug}`}
yon={yon} className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onSirala={sirala} >
/> {uniAd}
</dt> </Link>
<dd className="mt-1 font-semibold tabular-nums text-foreground"> ) : (
{p.sira2025 != null ? sayi(p.sira2025) : "—"} <span className="block truncate text-sm font-semibold">
</dd> {uniAd}
</div> </span>
<div className="lg:text-right"> )}
<dt> <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-slate-500">
<SiralamaDugmesi {p.unitur ? (
alan="puan2025" <span
aktifAlan={aktifAlan} className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
yon={yon} devlet
onSirala={sirala} ? "bg-slate-100 text-slate-600"
/> : "bg-violet-50 text-violet-600"
</dt> }`}
<dd className="mt-1 font-medium tabular-nums text-foreground"> >
{devlet ? "Devlet" : trBaslikDuzeni(p.unitur)}
</span>
) : null}
<span className="truncate">
{p.il ? trBaslikDuzeni(p.il) : null}
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">
· {p.sure} yıl
</span>
) : null}
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={p} />
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
{p.puan2025 != null {p.puan2025 != null
? p.puan2025.toFixed(2).replace(".", ",") ? p.puan2025.toFixed(2).replace(".", ",")
: "—"} : "—"}
</dd> </TableCell>
</div> <TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
<div className="lg:text-right"> {p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
<dt> </TableCell>
<SiralamaDugmesi <TableCell className="text-right text-xs tabular-nums text-slate-500">
alan="kontenjan2025" <span className="inline-flex items-center gap-1">
aktifAlan={aktifAlan} <ProgramTabanTrendi program={p} />
yon={yon} {p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
onSirala={sirala} </span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="bolum"
className="size-8 min-h-8 min-w-8"
/> />
</dt> </TableCell>
<dd className="mt-1 font-medium tabular-nums text-foreground"> </TableRow>
{p.kontenjan2025 != null ? sayi(p.kontenjan2025) : "—"} );
</dd> })}
</div> </TableBody>
</dl> </Table>
</li> </div>
);
})}
</ul>
); );
} }

View File

@@ -178,8 +178,8 @@ function CekmeceSheet() {
/> />
</div> </div>
<p className="mt-2 text-xs text-slate-500"> <p className="mt-2 text-xs text-slate-500">
Sıralamanla ulaşabildiğin programlardan kendi listen tutamaktan Bölüm, üniversite veya sonuç tablolarından eklediğin programlar
sürükleyip sırala, sağa kaydırıp sil. tutamaktan sürükleyip sırala, sağa kaydırıp sil.
</p> </p>
</motion.div> </motion.div>
@@ -194,8 +194,9 @@ function CekmeceSheet() {
Henüz program seçmedin Henüz program seçmedin
</p> </p>
<p className="text-xs leading-relaxed text-slate-500"> <p className="text-xs leading-relaxed text-slate-500">
Sonuç sayfasındaki tablodan <span aria-hidden></span> ile Bölüm, üniversite veya sonuç sayfalarındaki tablolardan{" "}
program ekle; beğendiklerin burada birikir. <span aria-hidden></span> ile program ekle; beğendiklerin burada
birikir.
</p> </p>
</motion.div> </motion.div>
) : ( ) : (
@@ -222,15 +223,23 @@ function CekmeceSheet() {
</Reorder.Group> </Reorder.Group>
)} )}
{/* Alt bant: AI danışman köprüsü */} {/* Alt bant: karar merkezi köprüsü */}
<motion.div <motion.div
layout="position" layout="position"
className="border-t border-slate-100 px-5 py-4" className="flex flex-col gap-2 border-t border-slate-100 px-5 py-4"
> >
<Link <Link
href="/listem" href="/listem?sekme=secimlerim"
onClick={cekmeceKapat} onClick={cekmeceKapat}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80" className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<ListChecks className="size-4" aria-hidden />
Seçtiklerimi ve uyum notlarını
</Link>
<Link
href="/listem?sekme=ai"
onClick={cekmeceKapat}
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 transition-colors duration-200 hover:text-primary"
> >
<Sparkles className="size-4" aria-hidden /> <Sparkles className="size-4" aria-hidden />
AI listeni ve danışmanı AI listeni ve danışmanı

View File

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

View File

@@ -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 (
<button
type="button"
disabled={listede || doldu}
onClick={handleClick}
title={
listede
? "Zaten seçtiklerinde"
: doldu
? `Seçtiklerin dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
: "Seçtiklerime ekle"
}
className={`inline-flex size-11 min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-full border transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.92] disabled:cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
listede
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-slate-200 bg-white text-slate-500 hover:border-primary hover:bg-primary/5 hover:text-primary disabled:opacity-40"
} ${className}`}
>
{listede ? (
<Check className="size-4" aria-hidden />
) : (
<Plus className="size-4" aria-hidden />
)}
<span className="sr-only">
{listede
? `${program.isim} listende`
: doldu
? `Listen ${MANUEL_LISTE_MAX} tercihe ulaştı`
: `${program.isim} seçtiklerine ekle`}
</span>
</button>
);
}

View File

@@ -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<RiskSeviyesi, { nokta: string; kenar: string }> = {
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 (
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center">
<ListChecks className="mx-auto size-10 text-slate-300" aria-hidden />
<h2 className="mt-4 font-heading text-xl font-bold">
Henüz program seçmedin
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
Bölüm, üniversite veya sonuç sayfalarındaki tablolardan{" "}
<span aria-hidden></span> ile program ekle. Tercihlerine göre
risk ve uyum notlarını burada göreceksin.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/bolumler">Bölümlere göz at</Link>
</Button>
<Button asChild className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600">
<Link href="/">Sıralamanı gir</Link>
</Button>
</div>
</section>
);
}
return (
<section className="flex flex-col gap-6">
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 font-heading text-xl font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Seçtiklerim
<span className="text-sm font-bold tabular-nums text-slate-400">
{liste.length}/{MANUEL_LISTE_MAX}
</span>
</h2>
<p className="mt-1 text-sm text-slate-600">
{profil
? `${profil.sira.toLocaleString("tr-TR")}. sıra · tercihlerine göre değerlendirme`
: "Tercih profilin oluşunca uyum notları görünür."}
</p>
</div>
{profil ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={profilDuzenlemeyiAc}
className="cursor-pointer"
>
<PencilLine className="size-4" aria-hidden />
Tercihlerimi düzenle
</Button>
) : null}
</div>
<div
className="mt-4 h-1.5 overflow-hidden rounded-full bg-slate-100"
role="progressbar"
aria-valuenow={liste.length}
aria-valuemin={0}
aria-valuemax={MANUEL_LISTE_MAX}
aria-label="Liste doluluğu"
>
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{
width: `${Math.round((liste.length / MANUEL_LISTE_MAX) * 100)}%`,
}}
/>
</div>
{ozet ? (
<dl className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Istatistik etiket="Hayal" deger={ozet.hayal} />
<Istatistik etiket="Dengeli" deger={ozet.dengeli} />
<Istatistik etiket="Daha güvenli" deger={ozet.dahaGuvenli} />
<Istatistik etiket="Dikkat" deger={ozet.dikkat} />
</dl>
) : null}
{ozet ? (
<p className="mt-4 flex items-start gap-2 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2.5 text-sm text-slate-700">
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden />
{ozet.oneri}
{ozet.gucluUyum > 0 ? (
<span className="sr-only">
{ozet.gucluUyum} program güçlü uyum gösteriyor.
</span>
) : null}
</p>
) : null}
</div>
<ol className="flex flex-col gap-3">
{liste.map((t, i) => (
<SecimSatiri key={t.id} tercih={t} sira={i + 1} />
))}
</ol>
</section>
);
}
function Istatistik({ etiket, deger }: { etiket: string; deger: number }) {
return (
<div className="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<dt className="text-[11px] font-medium uppercase tracking-wide text-slate-500">
{etiket}
</dt>
<dd className="mt-0.5 font-heading text-xl font-bold tabular-nums text-slate-900">
{deger}
</dd>
</div>
);
}
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 (
<li
className={`rounded-2xl border border-l-4 border-slate-200 bg-white p-4 ${
stil?.kenar ?? "border-l-slate-300"
}`}
>
<div className="flex items-start gap-3">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold tabular-nums">
{sira}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-slate-900">
{tercih.isim}
</p>
<p className="truncate text-xs text-slate-500">
{tercih.universite}
{tercih.il
? ` · ${tercih.il.toLocaleLowerCase("tr-TR")}`
: ""}
{tercih.efektifSira != null
? ` · ~${tercih.efektifSira.toLocaleString("tr-TR")}.`
: ""}
</p>
</div>
<button
type="button"
onClick={() => cikar(tercih.id)}
className="shrink-0 cursor-pointer rounded-full p-2 text-slate-300 transition-colors hover:bg-slate-100 hover:text-red-500"
>
<X className="size-4" aria-hidden />
<span className="sr-only">{tercih.isim} listeden çıkar</span>
</button>
</div>
{degerlendirme ? (
<div className="mt-3 flex flex-col gap-2">
<p className="flex items-center gap-2 text-xs font-medium text-slate-700">
{risk ? (
<span
className={`size-2 rounded-full ${RISK_STIL[risk].nokta}`}
aria-hidden
/>
) : null}
{degerlendirme.riskEtiketi}
{risk ? (
<span className="font-normal text-slate-500">
({RISK_ETIKET[risk]})
</span>
) : null}
</p>
<p className="text-sm text-slate-600">{degerlendirme.riskMetni}</p>
{degerlendirme.olumlu.slice(0, 2).map((m) => (
<p
key={m}
className="flex items-start gap-1.5 text-sm text-emerald-800"
>
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{m}
</p>
))}
{degerlendirme.celiskiler.slice(0, 2).map((m) => (
<p
key={m}
className="flex items-start gap-1.5 text-sm text-amber-900"
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{m}
</p>
))}
</div>
) : (
<p className="mt-2 text-sm text-slate-500">
Tercih profilin tamamlanınca bu programı değerlendireceğiz.
</p>
)}
</div>
</div>
</li>
);
}

View File

@@ -6,20 +6,30 @@
// (girişsiz kullanıcı da liste kurabilsin diye sunucuya yazmıyoruz). // (girişsiz kullanıcı da liste kurabilsin diye sunucuya yazmıyoruz).
import { useSyncExternalStore } from "react"; 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; export const MANUEL_LISTE_MAX = 24;
const STORAGE_KEY = "kolaytercih.manuel-liste.v1"; const STORAGE_KEY = "kolaytercih.manuel-liste.v1";
export type ManuelKaynak = "sonuc" | "bolum" | "universite";
export type ManuelTercih = { export type ManuelTercih = {
/** yokatlas program id'si */ /** yokatlas program id'si */
id: string; id: string;
isim: string; isim: string;
universite: string; universite: string;
il: string | null; il: string | null;
unitur: string | null;
tur: string | null;
efektifSira: number | 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; risk: RiskSeviyesi | null;
kaynak?: ManuelKaynak;
}; };
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer // 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[] { function dogrula(ham: unknown): ManuelTercih[] {
if (!Array.isArray(ham)) return []; if (!Array.isArray(ham)) return [];
return ham const sonuc: ManuelTercih[] = [];
.filter( for (const t of ham) {
(t): t is ManuelTercih => if (typeof t !== "object" || t === null) continue;
typeof t === "object" && const kayit = t as Record<string, unknown>;
t !== null && if (typeof kayit.id !== "string" || typeof kayit.isim !== "string") continue;
typeof (t as ManuelTercih).id === "string" && const risk: RiskSeviyesi | null =
typeof (t as ManuelTercih).isim === "string", kayit.risk === "guvenli" ||
) kayit.risk === "az-riskli" ||
.slice(0, MANUEL_LISTE_MAX); 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() { function yukle() {
@@ -103,11 +139,71 @@ export function listedeMi(id: string): boolean {
return liste.some((t) => t.id === id); return liste.some((t) => t.id === id);
} }
export function efektifSiraAl(
p: Pick<Program, "sira2025" | "sira2024">,
): 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. */ /** Listeye ekler; doluysa veya zaten varsa false döner. */
export function ekle(tercih: ManuelTercih): boolean { export function ekle(tercih: ManuelTercih): boolean {
yukle(); yukle();
if (liste.length >= MANUEL_LISTE_MAX || listedeMi(tercih.id)) return false; 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(); kaydet();
yayinla(); yayinla();
return true; return true;

View File

@@ -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 },
);

View File

@@ -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 (
<Dialog
open={acik}
onOpenChange={(v) => {
if (!v) profilKapisiniKapat();
}}
>
<DialogContent
data-tercih-profili-kapisi
className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl"
>
<DialogTitle className="sr-only">
{duzenle ? "Tercihlerini düzenle" : "Tercih profili sihirbazı"}
</DialogTitle>
<DialogDescription className="sr-only">
Sıralamanı ve tercihlerini belirle; eklediğin programları buna göre
değerlendirelim.
</DialogDescription>
{acik ? (
<KapıIcerik
key={oturumAnahtari}
duzenle={duzenle}
bekleyen={bekleyen}
profil={profil}
/>
) : null}
</DialogContent>
</Dialog>
);
}
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<Adim>("giris");
const [siralama, setSiralama] = useState(
bilinenSira != null ? bilinenSira.toLocaleString("tr-TR") : "",
);
const [tur, setTur] = useState(bilinenTur);
const [sira, setSira] = useState<number | null>(bilinenSira);
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
const [yukleniyor, setYukleniyor] = useState(false);
const [hata, setHata] = useState<string | null>(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 (
<div className="flex flex-col gap-5">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
{duzenle ? "Tercihlerini güncelle" : "Önce seni biraz tanıyalım"}
</p>
<h2 className="mt-2 font-heading text-2xl font-bold text-slate-900">
{duzenle
? "Seçimlerini değiştir"
: "Tercihine ışık tutabilmemiz için önce ne istediğini öğrenelim"}
</h2>
<p className="mt-3 max-w-xl text-sm leading-6 text-slate-600">
{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."}
</p>
{bekleyen ? (
<p className="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
Bekleyen program:{" "}
<span className="font-semibold">{bekleyen.program.isim}</span>
<span className="text-slate-500">
{" "}
· {bekleyen.program.universite}
</span>
</p>
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={profilKapisiniKapat}
className="cursor-pointer"
>
Vazgeç
</Button>
<Button
type="button"
disabled={yukleniyor}
onClick={() => void giristenIlerle()}
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
{yukleniyor ? "Hazırlanıyor…" : "Tercihlerimi belirle"}
</Button>
</div>
</div>
);
}
if (adim === "sira") {
return (
<div className="flex flex-col gap-5">
<div>
<h2 className="font-heading text-xl font-bold text-slate-900">
Başarı sıralaman nedir?
</h2>
<p className="mt-1 text-sm text-slate-500">
Risk ve erişilebilir programlar sıralamana göre hesaplanır.
</p>
</div>
<div
role="radiogroup"
aria-label="Puan türü"
className="flex flex-wrap gap-1.5"
>
{PUAN_TURLERI_SECENEK.map((t) => (
<button
key={t.deger}
type="button"
role="radio"
aria-checked={tur === t.deger}
onClick={() => setTur(t.deger)}
className={
tur === t.deger
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{t.etiket}
</button>
))}
</div>
<form onSubmit={siraGonder} className="flex flex-col gap-3 sm:flex-row">
<label htmlFor="kapisi-siralama" className="sr-only">
YKS başarı sıralaması
</label>
<Input
id="kapisi-siralama"
inputMode="numeric"
autoComplete="off"
placeholder="YKS başarı sıralaman (ör. 85.000)"
value={siralama}
onChange={(e) => siralamaDegisti(e.target.value)}
onFocus={preloadSihirbazAdimlar}
aria-invalid={hata ? true : undefined}
className="h-12 bg-background sm:flex-1"
/>
<Button
type="submit"
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
{yukleniyor ? "Hazırlanıyor…" : "Devam"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>
{hata ? (
<p role="alert" className="text-sm font-medium text-red-600">
{hata}
</p>
) : null}
<div className="flex justify-start border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => setAdim("giris")}
className="cursor-pointer"
>
Geri
</Button>
</div>
</div>
);
}
if (facetler && sira != null) {
return (
<div className="flex flex-col gap-4">
{hata ? (
<p role="alert" className="text-sm font-medium text-red-600">
{hata}
</p>
) : null}
<SihirbazAdimlarLazy
key={`${sira}-${tur}-${duzenle ? "duzenle" : "yeni"}`}
sira={sira}
tur={tur}
facetler={facetler}
baslangicSecimler={duzenle ? profil?.secimler : null}
sonButonEtiketi={
duzenle
? "Tercihlerimi güncelle"
: bekleyen
? "Kaydet ve programı ekle"
: "Tercihlerimi kaydet"
}
onTamamla={secimleriTamamla}
/>
</div>
);
}
return null;
}

View File

@@ -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;
}
/** 20212025 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 <span className="text-slate-400"></span>;
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 (
<svg
viewBox={`0 0 ${genislik} ${yukseklik}`}
className="inline-block h-4.5 w-14 text-slate-300"
role="img"
aria-label={`Taban sıra geçmişi. ${ozet.replaceAll("\n", " ")}`}
>
<title>{ozet}</title>
<polyline
points={noktalar.map((nokta) => `${nokta.x},${nokta.y}`).join(" ")}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx={son.x} cy={son.y} r="2" className="fill-slate-500" />
</svg>
);
}
/** 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 (
<TrendingUp
className="size-3 shrink-0 text-amber-500"
aria-label={`Taban yükseliyor: ${gecis}`}
role="img"
>
<title>{`Taban yükseliyor: ${gecis}`}</title>
</TrendingUp>
);
}
return (
<TrendingDown
className="size-3 shrink-0 text-emerald-600"
aria-label={`Taban geriliyor: ${gecis}`}
role="img"
>
<title>{`Taban geriliyor: ${gecis}`}</title>
</TrendingDown>
);
}

View File

@@ -9,18 +9,19 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useLinkStatus } from "next/link"; import { useLinkStatus } from "next/link";
import { import {
Check,
ChevronDown, ChevronDown,
Plus,
Rocket, Rocket,
Scale, Scale,
ShieldCheck, ShieldCheck,
TrendingDown,
TrendingUp,
} from "lucide-react"; } from "lucide-react";
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db"; import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk"; import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { turkishSlugify } from "@/lib/slug"; import { turkishSlugify } from "@/lib/slug";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
programEtkinSira,
} from "@/components/program-liste-verileri";
import { import {
Table, Table,
TableBody, TableBody,
@@ -30,12 +31,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
MANUEL_LISTE_MAX,
ekle,
useManuelListe,
type ManuelTercih,
} from "@/components/manuel-liste/store";
const DILIMLER: { const DILIMLER: {
key: DilimKey; 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<DilimKey, Program[]> = { const BOS_EKSTRA: Record<DilimKey, Program[]> = {
hayal: [], hayal: [],
dengeli: [], dengeli: [],
@@ -167,12 +147,9 @@ export function ProgramTablosu({
adaySira: number; adaySira: number;
tur: PuanTuruKey; tur: PuanTuruKey;
}) { }) {
const liste = useManuelListe();
const [aktifDilim, setAktifDilim] = useState<DilimKey>(() => const [aktifDilim, setAktifDilim] = useState<DilimKey>(() =>
varsayilanDilim(sonuclar), 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 // "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) // (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) { async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
setYuklenen(dilim); setYuklenen(dilim);
try { try {
@@ -300,9 +273,7 @@ export function ProgramTablosu({
key={p.id} key={p.id}
program={p} program={p}
adaySira={adaySira} adaySira={adaySira}
listede={listedekiler.has(p.id)} tur={tur}
doldu={doldu}
onEkle={eklemeyiDene}
/> />
))} ))}
</TableBody> </TableBody>
@@ -334,86 +305,6 @@ export function ProgramTablosu({
); );
} }
// Tabanın 20212025 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 <span className="text-slate-400"></span>;
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 (
<svg
viewBox={`0 0 ${W} ${H}`}
className="inline-block h-[18px] w-14 text-slate-300"
role="img"
aria-label={`Taban sıra geçmişi. ${ozet.replaceAll("\n", " ")}`}
>
<title>{ozet}</title>
<polyline
points={noktalar.map((n) => `${n.x},${n.y}`).join(" ")}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx={son.x} cy={son.y} r="2" className="fill-slate-500" />
</svg>
);
}
// 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 ? (
<TrendingUp
className="size-3 shrink-0 text-amber-500"
aria-label={`Taban yükseliyor: ${gecis}`}
role="img"
>
<title>{`Taban yükseliyor: ${gecis}`}</title>
</TrendingUp>
) : (
<TrendingDown
className="size-3 shrink-0 text-emerald-600"
aria-label={`Taban geriliyor: ${gecis}`}
role="img"
>
<title>{`Taban geriliyor: ${gecis}`}</title>
</TrendingDown>
);
}
// /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı // /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı
// kural (sondaki "(İL)" eki atılır) — client bundle'a harita verisi çekmemek // 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 // için burada tekrarlanır. Katalog haritası DB'deki tüm universite
@@ -425,17 +316,13 @@ function uniSayfaSlug(ad: string): string {
function ProgramSatiri({ function ProgramSatiri({
program: p, program: p,
adaySira, adaySira,
listede, tur,
doldu,
onEkle,
}: { }: {
program: Program; program: Program;
adaySira: number; adaySira: number;
listede: boolean; tur: PuanTuruKey;
doldu: boolean;
onEkle: (p: Program) => void;
}) { }) {
const taban = efektifSira(p); const taban = programEtkinSira(p);
const risk = riskHesapla(taban, adaySira); const risk = riskHesapla(taban, adaySira);
const devlet = p.unitur === "DEVLET"; const devlet = p.unitur === "DEVLET";
@@ -466,7 +353,6 @@ function ProgramSatiri({
href={`/universite/${uniSayfaSlug(p.universite)}`} href={`/universite/${uniSayfaSlug(p.universite)}`}
className="hover:text-primary hover:underline" className="hover:text-primary hover:underline"
title={`${p.universite} taban puanları sayfası`} title={`${p.universite} taban puanları sayfası`}
// Satırdaki "+" ekleme akışıyla karışmasın: tıklama satıra yayılmaz
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{p.universite} {p.universite}
@@ -479,7 +365,7 @@ function ProgramSatiri({
</span> </span>
</TableCell> </TableCell>
<TableCell className="hidden text-right text-xs md:table-cell"> <TableCell className="hidden text-right text-xs md:table-cell">
<SiraGecmisi p={p} /> <ProgramSiraGecmisi program={p} />
</TableCell> </TableCell>
<TableCell <TableCell
className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell" className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell"
@@ -504,39 +390,18 @@ function ProgramSatiri({
</TableCell> </TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500"> <TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<TabanTrendi p={p} /> <ProgramTabanTrendi program={p} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"} {taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span> </span>
</TableCell> </TableCell>
<TableCell className="pr-4 text-right"> <TableCell className="pr-4 text-right">
<button <ProgramEkleButonu
type="button" program={p}
disabled={listede || doldu} kaynak="sonuc"
onClick={() => onEkle(p)} adaySira={adaySira}
title={ adayTur={tur}
listede className="size-8 min-h-8 min-w-8"
? "Zaten seçtiklerinde" />
: doldu
? `Seçtiklerin dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
: "Seçtiklerime ekle"
}
className={`inline-flex size-8 cursor-pointer items-center justify-center rounded-full border transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.92] disabled:cursor-default ${
listede
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-slate-200 bg-white text-slate-500 hover:border-primary hover:bg-primary/5 hover:text-primary disabled:opacity-40"
}`}
>
{listede ? (
<Check className="size-4" aria-hidden />
) : (
<Plus className="size-4" aria-hidden />
)}
<span className="sr-only">
{listede
? `${p.isim} seçtiklerinde`
: `${p.isim} seçtiklerine ekle`}
</span>
</button>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );

View File

@@ -1,4 +1,3 @@
import { siApple, siGooglemaps } from "simple-icons";
import { cities } from "turkey-map-react/lib/data"; import { cities } from "turkey-map-react/lib/data";
import { import {
HARITA_GENISLIK, HARITA_GENISLIK,
@@ -7,34 +6,6 @@ import {
normalizeIlAdi, normalizeIlAdi,
uniKonum, uniKonum,
} from "@/lib/harita"; } 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 (
<svg
viewBox="0 0 24 24"
className="size-4"
style={{ color: `#${ikon.hex}` }}
aria-hidden
>
<path d={ikon.path} fill="currentColor" />
</svg>
);
}
return (
<span
className="flex size-4 items-center justify-center rounded-sm bg-[#fc3f1d] text-[11px] font-bold leading-none text-white"
aria-hidden
>
Я
</span>
);
}
export function UniversiteKonumHaritasi({ export function UniversiteKonumHaritasi({
universite, universite,
@@ -44,17 +15,7 @@ export function UniversiteKonumHaritasi({
il: string | null; il: string | null;
}) { }) {
const konum = uniKonum(universite, il); const konum = uniKonum(universite, il);
const ilAdi = il ? trBaslikDuzeni(il) : null; if (!konum) return 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}` },
];
return ( return (
<section <section
@@ -65,92 +26,50 @@ export function UniversiteKonumHaritasi({
{universite} kampüs konumu {universite} kampüs konumu
</h2> </h2>
{konum ? ( <div className="relative h-full min-h-44 w-full">
<div className="flex h-full min-h-44 items-start gap-2"> <svg
<div className="relative h-full min-h-44 min-w-0 flex-1"> viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
<svg className="absolute inset-0 h-full w-full"
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`} role="img"
className="absolute inset-0 h-full w-full" aria-label={`${universite} konumunun Türkiye haritasındaki görünümü`}
role="img"
aria-label={`${universite} konumunun Türkiye haritasındaki görünümü`}
>
{cities.map((city) => {
const secili = il ? normalizeIlAdi(city.name) === il : false;
return (
<path
key={city.id}
d={city.path}
fill={
secili
? "oklch(0.809 0.105 251.8)"
: "oklch(0.929 0.013 255.5)"
}
stroke="white"
strokeWidth={secili ? 1.8 : 1}
>
<title>{city.name}</title>
</path>
);
})}
<g aria-hidden>
<circle
cx={konum.x}
cy={konum.y}
r={18}
fill="oklch(0.623 0.188 259.8 / 18%)"
/>
<circle
cx={konum.x}
cy={konum.y}
r={9}
fill="oklch(0.623 0.188 259.8)"
stroke="white"
strokeWidth={3}
/>
<circle cx={konum.x} cy={konum.y} r={2.5} fill="white" />
</g>
</svg>
</div>
<div
className="flex shrink-0 flex-col items-end gap-1"
aria-label="Harita uygulamaları"
>
{haritaBaglantilari.map((baglanti) => (
<a
key={baglanti.ad}
href={baglanti.href}
target="_blank"
rel="noreferrer"
title={`${baglanti.ad} Haritalar'da aç`}
aria-label={`${universite} konumunu ${baglanti.ad} Haritalar'da aç`}
className="inline-flex size-11 items-center justify-center rounded-full bg-white/95 shadow-sm backdrop-blur transition-colors duration-200 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<HaritaMarkaIkonu marka={baglanti.ad} />
</a>
))}
</div>
</div>
) : (
<div
className="flex h-full min-h-44 flex-col items-end justify-center gap-1"
aria-label="Harita uygulamaları"
> >
{haritaBaglantilari.map((baglanti) => ( {cities.map((city) => {
<a const secili = il ? normalizeIlAdi(city.name) === il : false;
key={baglanti.ad} return (
href={baglanti.href} <path
target="_blank" key={city.id}
rel="noreferrer" d={city.path}
title={`${baglanti.ad} Haritalar'da aç`} fill={
aria-label={`${universite} konumunu ${baglanti.ad} Haritalar'da aç`} secili
className="inline-flex size-11 items-center justify-center rounded-full bg-white shadow-sm transition-colors duration-200 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" ? "oklch(0.809 0.105 251.8)"
> : "oklch(0.929 0.013 255.5)"
<HaritaMarkaIkonu marka={baglanti.ad} /> }
</a> stroke="white"
))} strokeWidth={secili ? 1.8 : 1}
</div> >
)} <title>{city.name}</title>
</path>
);
})}
<g aria-hidden>
<circle
cx={konum.x}
cy={konum.y}
r={18}
fill="oklch(0.623 0.188 259.8 / 18%)"
/>
<circle
cx={konum.x}
cy={konum.y}
r={9}
fill="oklch(0.623 0.188 259.8)"
stroke="white"
strokeWidth={3}
/>
<circle cx={konum.x} cy={konum.y} r={2.5} fill="white" />
</g>
</svg>
</div>
</section> </section>
); );
} }

View File

@@ -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<string, string> = {
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) => (
<section key={`${fakulte}-${i}`} className="mt-6">
<h2 className="mb-3 font-heading text-xl font-bold text-slate-900">
{fakulte}
</h2>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Program</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead className="hidden text-right lg:table-cell">
2025 puan
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
Kontenjan
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-14 text-right">
<span className="sr-only">Listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{programlar.map((p) => {
const taban = programEtkinSira(p);
return (
<TableRow key={p.id}>
<TableCell className="max-w-0 w-full">
{p.bolumSlug ? (
<Link
href={`/bolum/${p.bolumSlug}`}
className="block truncate text-sm font-semibold text-slate-900 hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{p.isim.trim()}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{p.isim.trim()}
</span>
)}
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<span className="shrink-0 rounded bg-slate-100 px-1 py-px text-[10px] font-medium leading-4 text-slate-600">
{TUR_ETIKET[p.tur] ?? p.tur}
</span>
{p.sure ? (
<span className="shrink-0">{p.sure} yıl</span>
) : null}
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={p} />
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
{p.puan2025 != null
? p.puan2025.toFixed(2).replace(".", ",")
: "—"}
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
{p.kontenjan2025 != null
? `${sayi(p.kontenjan2025)} kişi`
: "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<ProgramTabanTrendi program={p} />
{taban != null ? `~${sayi(taban)}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="universite"
className="size-8 min-h-8 min-w-8"
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</section>
))}
</>
);
}

View File

@@ -34,6 +34,12 @@ export interface SihirbazSecimleri {
oncelikler: string[]; oncelikler: string[];
} }
export interface TercihProfili {
sira: number;
tur: string;
secimler: SihirbazSecimleri;
}
const UNI_TIPLERI: UniversiteTipi[] = ["devlet", "vakif", "farketmez"]; const UNI_TIPLERI: UniversiteTipi[] = ["devlet", "vakif", "farketmez"];
/** /**
@@ -90,6 +96,44 @@ export function sihirbazDogrula(girdi: unknown): SihirbazSecimleri | null {
return { kategoriler, iller, universiteTipi, oncelikler }; 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<string, unknown>;
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. */ /** Prompt'a enjekte edilecek Türkçe özet. */
export function secimOzeti(secimler: SihirbazSecimleri): string { export function secimOzeti(secimler: SihirbazSecimleri): string {
const satirlar = [ const satirlar = [

View File

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