Refactor UI components for consistency and clarity across the application
All checks were successful
Deploy / deploy (push) Successful in 18m31s

- Updated AGENTS.md with new guidelines for UI consistency, including badge and modal styles.
- Refactored layout.tsx to improve the placement of the progressive blur effect.
- Enhanced NotFound component text for better user guidance.
- Replaced HeroFocusButton with HeroBaslik and KapanisCta in the Home component for improved clarity.
- Added SiteFooter to multiple pages for consistent footer display.
- Removed ListemSekmeleri component and adjusted ListemPage to streamline functionality.
- Updated various components to ensure consistent styling and user experience.
This commit is contained in:
bilalgursen
2026-08-02 04:28:27 +03:00
parent 6bd210075f
commit b4effc8545
48 changed files with 2173 additions and 1529 deletions

View File

@@ -3,7 +3,27 @@
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
# Tarayıcı açma
UI değişikliklerini tarayıcıda/preview'da doğrulamaya çalışma; kullanıcı kendisi kontrol ediyor. Dev sunucusu veya browser paneli açma — sadece kod değişikliğini yap ve ne değiştiğini raporla.
# Push öncesi build zorunlu
Push emri geldiğinde önce lokalde `pnpm build` başarıyla geçmeli; geçmeden asla push etme. Kritik detay: çalışma ağacı kirliyken build, commit'lenmemiş dosyaları da gördüğü için CI'da kırılacak bir commit'i yakalayamayabilir. Bu yüzden build'i **push edilecek commit'in kendisi** üzerinde doğrula (ör. geçici `git worktree add <dir> HEAD` + `node_modules` symlink'i ile). Ayrıca kısmi commit atarken değişikliğin bağımlılık kapanışını kontrol et — bir dosyanın kullandığı prop/export başka bir commit'lenmemiş dosyadan geliyorsa onları da aynı commit'e dahil et.
# Badge/eyebrow rozetleri: tek kaynak SectionEyebrow
Site genelindeki tüm badge/rozet/eyebrow elemanları ana sayfadaki section başlıklarının üstünde duran rozetle birebir aynı olmalı. Kanonik bileşen `src/components/pixel-decor.tsx` içindeki `SectionEyebrow`dur (piksel imzalı `PixelMark` ikonu + `rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-primary`). Yeni bir badge/eyebrow gerektiğinde `SectionEyebrow` kullan (`className` prop'u kenar boşluğu için var); `@/components/ui/badge`'deki `Badge` ile ya da elle yazılmış pill span'lerle yeni badge tasarlama. İnteraktif chip/buton pill'leri (filtre çipleri, nav butonları) bu kuralın dışındadır.
# UI tutarlılığı: ortak elemanlar tek kaynaktan
Modallar arası ortak elemanlar (kapatma çarpısı, başlık düzeni vb.) görsel olarak birebir aynı olmalı. Kapatma çarpısının kanonik stili `src/components/ui/dialog.tsx` içindeki `DialogContent` varsayılan butonudur (yuvarlak, `size-9`, `border border-slate-200`, `size-4` X ikonu, hover'da `bg-slate-100`, `active:scale-[0.96]`). Yeni bir modal eklerken bu varsayılanı kullan; özel yerleşim gerekiyorsa (ör. katalog-arama'da input satırının içindeki buton) `showCloseButton={false}` ver ama **aynı class setini** kopyala — farklı boyut/renk/varyantta yeni bir çarpı tasarlama. Aynı prensip diğer tekrarlanan UI parçaları için de geçerli: bir eleman iki yerde görünüyorsa stilini tek kaynaktan al veya birebir eşleştir.
# CTA butonlarında ikon: yıldız yok, sağ ok metnin sonunda
Funnel CTA butonlarında ("Listemi oluştur", "Tercihlerimi belirle", giriş/ödeme CTA'ları vb.) `Sparkles` (yıldız) ikonu kullanılmaz. Kanonik desen: buton metni önce gelir, sonunda `<ArrowRight className="size-4" aria-hidden />` durur. Yeni bir CTA butonu eklerken bu deseni uygula; metnin başına ikon koyma. Dekoratif `Sparkles` kullanımları (boş durum başlık ikonları, bilgi satırları) bu kuralın dışındadır — kural yalnızca aksiyon butonları/CTA linkleri içindir.
# Meraklısına'daki mermaid şeması mimariyle senkron kalmalı
`/meraklisina` sayfasındaki sihirbaz akış şeması (`src/app/meraklisina/huni-semasi.tsx` içindeki `SEMA` sabiti) ürünün gerçek davranışının belgesidir, dekor değildir. Sihirbaz/listeleme mimarisinde davranış değiştiren her değişiklikte bu şema da aynı PR/commit içinde güncellenmeli: sihirbaz adımlarının sayısı-sırası-içeriği (alan → il → devlet/vakıf), aday havuzu kuralları, 24'lük liste iskeleti (hayal/dengeli/garanti dağılımı), havuz 24'ün altına düşünce filtre gevşetme sırası ve yapay zekânın havuz-dışına-çıkamama kuralı. Şemayı güncellerken çevresindeki unsurları da eşitle: bölümdeki anlatım metni (`src/app/meraklisina/page.tsx`, 4. bölüm), kabın `aria-label`'ı ve figcaption. Şema öğrenci diliyle kalmalı (SQL/prompt/teknik detay yok) ve ürünün risk renk dilini korumalı — kırmızı/sarı/yeşil her zaman metin etiketiyle birlikte, renk tek başına anlam taşımaz.
<!-- END:nextjs-agent-rules -->

106
CTA-CEKMECE-TASARIMI.md Normal file
View File

@@ -0,0 +1,106 @@
# CTA çekmecesi tasarım algısı — "Sıralamanla nereye yerleşirsin? Saniyede gör."
Bu doküman, programatik SEO sayfalarındaki funnel CTA'sının (`src/components/cta-sira-form.tsx`)
alttan açılan çekmece (drawer) tasarımının arkasındaki algıyı ve kuralları belgeler.
Yeni bir funnel giriş noktası eklerken ya da bu bileşeni değiştirirken buradaki
ilkelere uy.
## 1. Temel algı: vaadi verdiğin yerde tahsil et
Eski davranış adayı ana sayfaya (`/`) gönderiyordu: "Saniyede gör" vaadi verip
saniyeler süren bir sayfa geçişi ve bağlam kaybı yaşatıyordu. Yeni algı şu:
- **Kart vaat eder** — SEO sayfasının içinde duran kart (`bg-primary/5`,
`border-primary/20`) merak uyandırır: "Sıralamanla nereye yerleşirsin?"
- **Çekmece tahsil eder** — CTA butonu sayfadan koparmadan alttan bir çekmece
açar; sıralama girişi, sihirbaz adımları ve tamamlama hepsi aynı bağlamda akar.
- **Ödül `/sonuc`'ta** — seçimler tamamlanınca profil yazılır, çekmece kapanır
ve aday doğrudan sonuç sayfasına gider. Sayfa geçişi yalnızca ödül anında olur.
Akış: **kart (vaat) → çekmece (sıralama) → sihirbaz (kişiselleştirme) → /sonuc (ödül)**.
Profilli adayda çekmece hiç devreye girmez: kayıtlı sıralamayla doğrudan
`/sonuc` linki gösterilir — sıralamasını girmiş adaya bir daha girmesini
söylemeyiz.
## 2. Neden çekmece, modal değil?
- Çekmece **sayfanın üstüne değil, altına eklenir** algısı verir: aday hâlâ
okuduğu üniversite/bölüm sayfasının üzerindedir, içerik kaybolmaz (overlay
hafif: `bg-black/10` + blur).
- Alttan gelen yüzey mobilde başparmak menzilinde; hedef kitle (YKS adayı)
ırlıkla mobilde.
- Swipe-to-dismiss (tutamaktan) ve overlay/Escape ile kaçış ucuz: "bir bakayım"
maliyeti düşük tutulur. `handleOnly` kullanılır ki form/sihirbaz içindeki
kaydırmalar çekmeceyi yanlışlıkla kapatmasın.
- Desktop'ta çekmece tam genişliğe yayılmaz: `sm:max-w-2xl` ile ortalanır,
`sm:border-x + sm:shadow-2xl` ile sayfadan yükselen bir panel gibi durur
(liste çekmecesiyle aynı reçete).
## 3. Görsel dil: yeni hiçbir şey icat etme
Çekmecedeki her parça sitede zaten var olan kanonik kaynaktan gelir:
| Parça | Kaynak / kural |
| --- | --- |
| Eyebrow rozeti ("Saniyede gör") | `SectionEyebrow` (`pixel-decor.tsx`) — elle pill yazılmaz |
| Kapatma çarpısı | `dialog.tsx`'teki kanonik set: `size-9`, `rounded-full`, `border-slate-200`, `size-4` X, hover `bg-slate-100`, `active:scale-[0.96]` |
| CTA butonları | Metin önce, sonda `ArrowRight` (`size-4`); yıldız/Sparkles yok; `bg-orange-500 → hover:bg-orange-600` |
| Puan türü çipleri | Hero formundaki radiogroup çipleriyle birebir aynı class seti |
| Sıralama girişi | Profil kapısındaki desen: sola `Trophy` ikonu, `h-12`, `pl-11` |
| Kanıt üçlüsü | Arama funnel kartıyla aynı üçlü: 24 tercihlik plan / Risk analizi / YÖK Atlas verisi (`ListChecks`, `ChartNoAxesCombined`, `Database`) |
| Başlık/kopya tipografisi | `font-heading` bold başlık, `text-slate-600` gövde, `text-slate-900` vurgu |
Algı hedefi: çekmece açıldığında aday "yeni bir yere geldim" değil, "aynı ürünün
bir katmanı kalktı" hissetmeli. Bu yüzden çekmeceye özgü hiçbir yeni renk,
rozet, buton varyantı tasarlanmaz.
## 4. İçerik tonu
- Öğrenci diliyle, ikinci tekil: "Sıralamanı yaz", "bu sayfadan ayrılmadan gör".
- Sürtünme kaldıran güvence her zaman görünür: **"Ücretsiz, üyelik gerekmez."**
- Kanıt üçlüsü süs değil, ürün vaadinin özetidir; metin etiketleri ikonsuz da
anlamlı olmalı (renk/ikon tek başına anlam taşımaz — ürünün risk renk dili
kuralıyla aynı ilke).
- Buton etiketleri eylemin sonucunu söyler: "Programları gör" (girdi adımı),
"Ücretsiz tablonu gör" / "AI listemi kur (3 kredi)" (sihirbaz sonu, girişe göre).
## 5. Davranış ve teknik algı
- **Statik kalır:** Kart RSC sayfalarında prerender edilir; oturum bilgisi
mount'ta değil, facet isteğiyle **paralel** çözülür (hero formundaki desen) —
landing başına gereksiz auth turu atılmaz.
- **Preload:** CTA butonuna `pointerenter`/`focus` ve sıralama inputuna `focus`
geldiğinde `preloadSihirbazAdimlar()` çalışır; sihirbaz açıldığında chunk
çoktan inmiştir. "Saniyede gör" vaadi teknik olarak da tutulur.
- **İki adım, tek yüzey:** çekmece içinde `sira → sihirbaz` adımları vardır;
erişilebilir ad sabittir (sr-only `DrawerTitle`/`DrawerDescription`), görünür
başlıklar adıma göre değişir.
- **Kapanış = sıfırlama, ama nazikçe:** çekmece kapanınca adım ve facet'ler
sıfırlanır; adayın yazdığı sıralama ve puan türü **korunur** (tekrar açarsa
baştan yazmasın).
- **Tamamlanınca:** `tercihProfiliYaz` → çekmece kapanır → `sonucHref` ile
`/sonuc` (girişliye `sihirbaz: "1"`, girişsize `hazir: "1"`). Giriş duvarı
sihirbazın hemen arkasında değildir; değer önce gösterilir.
- **Lenis:** çekmece içeriği ve kaydırma kabı `data-lenis-prevent` taşır;
sayfadaki smooth-scroll çekmece içi kaydırmayı ezmez.
## 6. Erişilebilirlik
- Sıralama alanı: `inputMode="numeric"`, binlik ayraçlı biçimleme
(85000 → 85.000), hata durumunda `aria-invalid` + `aria-describedby` +
`role="alert"`.
- Puan türü seçimi `role="radiogroup"` / `role="radio"` + `aria-checked`.
- Kapatma yolları: çarpı butonu, overlay, Escape, tutamaktan swipe.
- `prefers-reduced-motion` vaul/motion tarafında zaten sadeleşir; çekmeceye
özel ek animasyon eklenmedi.
## 7. Bu deseni ne zaman kullan / kullanma
**Kullan:** İçerik sayfasında duran ve adaydan tek kritik girdi (sıralama)
isteyen her funnel girişi. Adayın okuduğu bağlam değerliyse (bölüm, üniversite,
rehber) çekmece doğru araçtır.
**Kullanma:** Zaten modal/dialog içinde olan akışlar (arama modalındaki funnel
kartı gibi — modal üstüne çekmece açılmaz, oradaki kart mevcut kapı/dialog
akışını kullanır) ve profilli kısayollar (doğrudan link yeterli).

Binary file not shown.

View File

@@ -6,6 +6,7 @@ import { getBolumBySlug, uniSlugFromAd } from "@/lib/katalog";
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
import { BolumProgramListesi } from "@/components/bolum-program-listesi";
import { SiteFooter } from "@/components/site-footer";
const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -53,7 +54,8 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
const buYol = ilkSayfa ? `/bolum/${slug}` : `/bolum/${slug}/sayfa/${sayfa}`;
return (
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
<>
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
<JsonLd
data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" },
@@ -124,6 +126,8 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
sayfa={sayfa}
toplamSayfa={toplamSayfa}
/>
</main>
</main>
<SiteFooter />
</>
);
}

View File

@@ -1,61 +1,40 @@
"use client";
import { TextLoop } from "@/components/core/text-loop";
import { TypingAnimation } from "@/components/ui/typing-animation";
// Sonuç sayfasından gelen funnel'da dönen sempatik mesajlar.
// Kısa tutulmalı — başlıkta tek satır, taşma yok.
// Hepsi aynı şablon: "Giriş yap, çünkü …"
const FUNNEL_MESAJLARI = [
"1 adım kaldı tercihine",
"Listen seni bekliyor 👀",
"24 tercih, hepsi sana göre",
"5 kredin hazır, ya sen?",
", çünkü listen seni bekliyor",
", çünkü tercihine 1 adım kaldı",
", çünkü seçimlerin hazır",
", çünkü 5 kredin yanında",
", çünkü kaçırmak istemezsin",
", çünkü listen hemen üretilsin",
];
/**
* Giriş sayfası başlığı. Kullanıcı sihirbazdan (callback /sonuc'a dönüyorsa)
* geldiyse TextLoop'la funnel mesajları döner; diğer girişlerde sabit başlık.
* geldiyse sabit "Giriş yap" + typewriter hook'ları; diğer girişlerde
* yalnız sabit başlık.
*/
export function GirisBaslik({ funnel }: { funnel: boolean }) {
if (!funnel) {
return <h1 className="font-heading text-2xl font-bold">Giriş yap</h1>;
return <h1 className="font-heading text-xl font-bold">Giriş yap</h1>;
}
return (
<h1 className="font-heading text-2xl font-bold">
<TextLoop
className="overflow-y-clip"
interval={2.5}
transition={{
type: "spring",
stiffness: 900,
damping: 80,
mass: 10,
}}
variants={{
initial: {
y: 20,
rotateX: 90,
opacity: 0,
filter: "blur(4px)",
},
animate: {
y: 0,
rotateX: 0,
opacity: 1,
filter: "blur(0px)",
},
exit: {
y: -20,
rotateX: -90,
opacity: 0,
filter: "blur(4px)",
},
}}
>
{FUNNEL_MESAJLARI.map((mesaj) => (
<span key={mesaj}>{mesaj}</span>
))}
</TextLoop>
<h1 className="flex flex-wrap items-baseline font-heading text-xl font-bold leading-tight">
<span className="shrink-0">Giriş yap</span>
<TypingAnimation
as="span"
words={FUNNEL_MESAJLARI}
loop
startOnView={false}
typeSpeed={42}
deleteSpeed={22}
pauseDelay={2400}
className="min-h-[1.25em] font-heading text-xl font-bold leading-tight tracking-[-0.02em] text-primary"
/>
</h1>
);
}

View File

@@ -5,8 +5,8 @@ import { Skeleton } from "@/components/ui/skeleton";
// içerik gelince zıplama olmaz.
export default function GirisLoading() {
return (
<div className="flex min-h-screen flex-col bg-slate-50">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{/* Başlık + alt metin */}
<Skeleton className="h-8 w-40" />

View File

@@ -27,8 +27,10 @@ export default async function GirisPage({
const funnel = callbackURL.startsWith("/sonuc");
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-5 py-16 sm:px-4">
// -mb-28: layout'taki ScrollFlow sarmalayıcısının pb-28'ini nötrler —
// kart tam dvh içinde ortalanır, sayfa scroll etmez (sığmazsa scroll açılır).
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="relative">
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<GirisBaslik funnel={funnel} />

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Parallax } from "@/components/parallax";
import { PagePixelDivider } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
export const metadata: Metadata = {
title: "Gizlilik ve KVKK",
@@ -13,6 +14,7 @@ export const metadata: Metadata = {
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function GizlilikPage() {
return (
<>
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<Parallax fromStart strength={10}>
<h1 className="font-heading text-3xl font-bold">
@@ -78,5 +80,7 @@ export default function GizlilikPage() {
</section>
</Parallax>
</main>
<SiteFooter />
</>
);
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Mail } from "lucide-react";
import { Parallax } from "@/components/parallax";
import { PagePixelDivider } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
export const metadata: Metadata = {
title: "İletişim",
@@ -12,6 +13,7 @@ export const metadata: Metadata = {
export default function IletisimPage() {
return (
<>
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<Parallax fromStart strength={10}>
<h1 className="font-heading text-3xl font-bold">İletişim</h1>
@@ -34,5 +36,7 @@ export default function IletisimPage() {
asla e-postayla paylaşma.
</p>
</main>
<SiteFooter />
</>
);
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Parallax } from "@/components/parallax";
import { PagePixelDivider } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
export const metadata: Metadata = {
title: "Kullanım ve Satış Koşulları",
@@ -13,6 +14,7 @@ export const metadata: Metadata = {
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function KosullarPage() {
return (
<>
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<Parallax fromStart strength={10}>
<h1 className="font-heading text-3xl font-bold">
@@ -85,5 +87,7 @@ export default function KosullarPage() {
</section>
</Parallax>
</main>
<SiteFooter />
</>
);
}

View File

@@ -84,24 +84,24 @@ export default function RootLayout({
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} antialiased`}
>
<body className="min-h-dvh flex flex-col bg-slate-50">
{/* Site geneli progressive blur — üst ve alt */}
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-28 print:hidden">
<ProgressiveBlur
position="top"
backgroundColor="#f8fafc"
height="112px"
blurAmount="6px"
/>
</div>
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 h-28 print:hidden">
<ProgressiveBlur
position="bottom"
backgroundColor="#f8fafc"
height="112px"
blurAmount="6px"
/>
</div>
<HeaderGate>
{/* Site geneli progressive blur — üst ve alt */}
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-28 print:hidden">
<ProgressiveBlur
position="top"
backgroundColor="#f8fafc"
height="112px"
blurAmount="6px"
/>
</div>
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 h-28 print:hidden">
<ProgressiveBlur
position="bottom"
backgroundColor="#f8fafc"
height="112px"
blurAmount="6px"
/>
</div>
<SiteTopBanner />
<SiteHeader />
</HeaderGate>

View File

@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { sonucHref } from "@/lib/sihirbaz";
/**
* /listem boş hâlinin metni + CTA'sı: sıralamasını zaten girmiş adaya
* "sıralamanı gir" demek yerine sihirbazıık hâlde sonuç sayfasına
* götürür; profil yoksa ana sayfadaki sıralama formuna yönlendirir.
*/
export function ListemBosCta() {
const profil = useTercihProfili();
return (
<>
<p className="mt-3 text-slate-600">
{profil
? "Sıralaman kayıtlı — birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin."
: "Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin."}
</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={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}>
{profil ? "AI listeni kur" : "Sıralamanı gir, AI listeni kur"}
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</>
);
}

View File

@@ -1,85 +0,0 @@
"use client";
import { useState } from "react";
import { ListChecks, Sparkles } from "lucide-react";
import { Tabs as TabsPrimitive } from "radix-ui";
import { SecimlerimPaneli } from "@/components/manuel-liste/secimlerim-paneli";
import { useManuelListe } from "@/components/manuel-liste/store";
export type ListemSekme = "secimlerim" | "ai";
export function ListemSekmeleri({
varsayilan,
aiIcerik,
aiHazir,
}: {
varsayilan: ListemSekme;
aiIcerik: React.ReactNode;
aiHazir?: boolean;
}) {
const [sekme, setSekme] = useState<ListemSekme>(varsayilan);
const liste = useManuelListe();
function degistir(deger: string) {
const yeni = deger as ListemSekme;
setSekme(yeni);
const url = new URL(window.location.href);
url.searchParams.set("sekme", yeni);
window.history.replaceState(null, "", url);
}
return (
<TabsPrimitive.Root value={sekme} onValueChange={degistir} className="mt-6">
<TabsPrimitive.List
aria-label="Liste görünümü"
className="relative mx-auto grid w-full max-w-md grid-cols-2 rounded-full border border-slate-200 bg-slate-100 p-1"
>
<span
aria-hidden
className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-white shadow-sm transition-transform duration-200 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]"
style={{
transform: sekme === "ai" ? "translateX(100%)" : "translateX(0)",
}}
/>
<TabsPrimitive.Trigger
value="secimlerim"
className="relative z-10 flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full text-sm font-semibold text-slate-500 transition-colors duration-200 hover:text-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[state=active]:text-slate-900"
>
<ListChecks className="size-4" aria-hidden />
Kendi Listem
{liste.length > 0 ? (
<span className="min-w-5 rounded-full bg-primary/10 px-1.5 py-0.5 text-center text-[11px] font-bold tabular-nums text-primary">
{liste.length}
</span>
) : null}
</TabsPrimitive.Trigger>
<TabsPrimitive.Trigger
value="ai"
className="relative z-10 flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full text-sm font-semibold text-slate-500 transition-colors duration-200 hover:text-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[state=active]:text-slate-900"
>
<Sparkles className="size-4" aria-hidden />
AI Listem
{aiHazir ? (
<span
className="size-1.5 rounded-full bg-emerald-500"
aria-hidden
title="AI listen hazır"
/>
) : null}
</TabsPrimitive.Trigger>
</TabsPrimitive.List>
<TabsPrimitive.Content
value="secimlerim"
className="mt-6 outline-none data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-200"
>
<SecimlerimPaneli />
</TabsPrimitive.Content>
<TabsPrimitive.Content
value="ai"
className="mt-6 outline-none data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-200"
>
{aiIcerik}
</TabsPrimitive.Content>
</TabsPrimitive.Root>
);
}

View File

@@ -3,11 +3,9 @@ import Link from "next/link";
import { asc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import {
AlertTriangle,
ArrowRight,
Bot,
Check,
Database,
FileText,
Lock,
MessageCircleQuestion,
@@ -22,14 +20,14 @@ import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { RaporListesi } from "@/components/rapor-listesi";
import { ViewportPortal } from "@/components/viewport-portal";
import { PagePixelDivider } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
import { SectionEyebrow } from "@/components/pixel-decor";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
import { SohbetClient, type Mesaj } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
import { ListemSekmeleri, type ListemSekme } from "./listem-sekmeleri";
import { ListemBosCta } from "./listem-bos-cta";
export const metadata: Metadata = {
title: "Listem",
@@ -43,7 +41,7 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
export default async function ListemPage({
searchParams,
}: {
searchParams: Promise<{ acildi?: string; sekme?: string }>;
searchParams: Promise<{ acildi?: string }>;
}) {
const session = await verifySession("/listem");
const [user, params, satir, sohbetGecmisi] = await Promise.all([
@@ -65,54 +63,24 @@ export default async function ListemPage({
]);
if (!user) redirect("/giris?callback=/listem");
const { acildi, sekme } = params;
const { acildi } = params;
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
const raporVar = Boolean(satir?.result);
const varsayilanSekme: ListemSekme =
sekme === "secimlerim"
? "secimlerim"
: sekme === "ai" || acildi === "1" || raporVar
? "ai"
: "secimlerim";
const aiBos = (
<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.
Kendi Listem 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 (
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
<div className="mx-auto max-w-2xl text-center">
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
Tercih karar merkezin
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
Kendi listen ve AI listen burada yan yana ikisi birbirinden
bağımsız.
</p>
<PagePixelDivider seed={71} className="mx-auto mt-6" />
</div>
<ListemSekmeleri varsayilan={varsayilanSekme} aiIcerik={aiBos} />
</main>
<>
<main className="mx-auto w-full max-w-4xl px-4 py-12">
<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 />
<h1 className="mt-4 font-heading text-2xl font-bold">
Henüz AI listen yok
</h1>
<ListemBosCta />
</section>
</main>
<SiteFooter />
</>
);
}
@@ -120,9 +88,19 @@ export default async function ListemPage({
? (satir!.result as RaporSonuc)
: raporMaskele(satir!.result as RaporSonuc);
const raporParams = satir!.params as RaporParams;
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
// /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor.
const danismanOzeti = rapor.genelDegerlendirme
? {
metin: rapor.genelDegerlendirme,
uyarilar: user.hasPaket ? rapor.uyarilar : undefined,
kilitli: !user.hasPaket,
}
: undefined;
const aiIcerik = (
return (
<>
<main className="mx-auto w-full max-w-4xl px-4 py-12">
{kilitAcilisi ? (
<div
role="status"
@@ -136,30 +114,22 @@ export default async function ListemPage({
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
{user.hasPaket ? (
<Badge variant="secondary" className="mb-3">
<ShieldCheck aria-hidden />
Paketin aktif
</Badge>
) : (
<Badge variant="secondary" className="mb-3">
<Database aria-hidden />
YÖK Atlas verileri ücretsiz
</Badge>
)}
<h2 className="max-w-2xl font-heading text-xl font-bold tracking-tight sm:text-2xl">
<SectionEyebrow className="mb-3">Paketin aktif</SectionEyebrow>
) : null}
<h1 className="max-w-2xl font-heading text-xl font-bold tracking-tight sm:text-2xl">
{user.hasPaket
? "AI tercih listen"
: "Veriyi ücretsiz incele. Kararı AI ile netleştir."}
</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
{raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ??
raporParams.tur}{" "}
·{" "}
{user.hasPaket
? "24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle aşağıda inceleyebilirsin."
: "24 programın 20242025 taban sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin."}
</p>
</h1>
{user.hasPaket ? (
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
{raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ??
raporParams.tur}{" "}
· 24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle
aşağıda inceleyebilirsin.
</p>
) : null}
</div>
{user.hasPaket ? (
<div className="flex gap-2">
@@ -189,40 +159,6 @@ export default async function ListemPage({
) : null}
</div>
{rapor.genelDegerlendirme ? (
<section className="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h3 className="flex items-center gap-2 font-heading text-base font-bold">
<Bot className="size-4 text-primary" aria-hidden />
Danışman özeti
</h3>
<p className="mt-2 text-sm leading-6 text-slate-700">
{rapor.genelDegerlendirme}
</p>
{user.hasPaket && rapor.uyarilar?.length ? (
<ul className="mt-3 flex flex-col gap-2">
{rapor.uyarilar.map((uyari) => (
<li
key={uyari}
className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
>
<AlertTriangle
className="mt-0.5 size-4 shrink-0"
aria-hidden
/>
{uyari}
</li>
))}
</ul>
) : null}
{!user.hasPaket ? (
<p className="mt-3 flex items-center gap-1.5 text-xs text-slate-500">
<Lock className="size-3.5" aria-hidden />
Değerlendirmenin tamamı ve sıralamana özel uyarılar pakette.
</p>
) : null}
</section>
) : null}
<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">
<section
@@ -277,18 +213,13 @@ export default async function ListemPage({
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
ozet={danismanOzeti}
/>
) : (
<div className="flex flex-col gap-6">
<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="flex items-center gap-2 text-primary">
<Sparkles className="size-4" aria-hidden />
<span className="text-xs font-bold uppercase tracking-wide">
Tercih Dönemi Paketi
</span>
</div>
<h3 className="mt-3 font-heading text-xl font-bold tracking-tight">
<h3 className="font-heading text-xl font-bold tracking-tight">
Tablo ücretsiz.
<br />
Karar desteği pakette.
@@ -317,14 +248,11 @@ export default async function ListemPage({
))}
</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>
<Badge variant="outline">Abonelik yok</Badge>
<div className="mt-6">
<p className="text-xs text-slate-500">Tek seferlik</p>
<p className="font-heading text-3xl font-bold">
{PAKET_FIYATI} TL
</p>
</div>
<Button
@@ -354,7 +282,8 @@ export default async function ListemPage({
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
className="h-[480px]"
ozet={danismanOzeti}
className="h-[520px]"
/>
</div>
)}
@@ -370,26 +299,8 @@ export default async function ListemPage({
Danışmana sor
</a>
</ViewportPortal>
</main>
<SiteFooter />
</>
);
return (
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
<div className="mx-auto max-w-2xl text-center">
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
Tercih karar merkezin
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
Kendi listen ve AI listen burada yan yana ikisi birbirinden
bağımsız.
</p>
<PagePixelDivider seed={71} className="mx-auto mt-6" />
</div>
<ListemSekmeleri
varsayilan={varsayilanSekme}
aiIcerik={aiIcerik}
aiHazir
/>
</main>
);
}

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowUp, Coins, Sparkles } from "lucide-react";
import { AlertTriangle, ArrowUp, Bot, Coins, Lock, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -29,6 +29,12 @@ export interface Mesaj {
content: string;
}
export interface DanismanOzeti {
metin: string;
uyarilar?: string[];
kilitli?: boolean;
}
const ORNEK_SORULAR = [
"Listemdeki kırmızı (riskli) tercihler ne kadar riskli?",
"İlk 5 tercihimin taban sıralaması trendi nasıl?",
@@ -44,12 +50,15 @@ export function SohbetClient({
baslangicKredi,
initialMesajlar,
className,
ozet,
}: {
baslangicKredi: number;
/** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
initialMesajlar?: Mesaj[];
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
className?: string;
/** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */
ozet?: DanismanOzeti;
}) {
const [mesajlar, setMesajlar] = useState<Mesaj[]>(initialMesajlar ?? []);
const [girdi, setGirdi] = useState("");
@@ -154,20 +163,73 @@ export function SohbetClient({
<div className="relative flex-1 overflow-hidden">
<ChatContainerRoot className="h-full">
<ChatContainerContent className="gap-4 p-4">
{mesajlar.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-10 text-center">
<Sparkles className="size-7 text-primary" aria-hidden />
<div>
<p className="font-heading font-bold">
Listen hakkında her şeyi sorabilirsin
</p>
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
Cevaplar senin 24&apos;lük listen, seçimlerin ve gerçek
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
{ozet ? (
<Message className="justify-start">
<div className="flex max-w-[85%] flex-col gap-2">
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
<Bot className="size-3.5 text-primary" aria-hidden />
Danışman özeti
</p>
<MessageContent className="bg-slate-100 text-slate-800">
{ozet.metin}
</MessageContent>
{ozet.uyarilar?.length ? (
<ul className="flex flex-col gap-2">
{ozet.uyarilar.map((uyari) => (
<li
key={uyari}
className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
>
<AlertTriangle
className="mt-0.5 size-4 shrink-0"
aria-hidden
/>
{uyari}
</li>
))}
</ul>
) : null}
{ozet.kilitli ? (
<p className="flex items-center gap-1.5 text-xs text-slate-500">
<Lock className="size-3.5" aria-hidden />
Değerlendirmenin tamamı ve sıralamana özel uyarılar
pakette.
</p>
) : null}
</div>
</Message>
) : null}
{mesajlar.length === 0 ? (
<div
className={cn(
"flex flex-col gap-4",
!ozet && "items-center py-10 text-center",
)}
>
{!ozet ? (
<>
<Sparkles
className="mx-auto size-7 text-primary"
aria-hidden
/>
<div>
<p className="font-heading font-bold">
Listen hakkında her şeyi sorabilirsin
</p>
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
Cevaplar senin 24&apos;lük listen, seçimlerin ve gerçek
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
</p>
</div>
</>
) : null}
{yuklendi ? (
<div className="flex max-w-lg flex-wrap justify-center gap-2">
<div
className={cn(
"flex max-w-lg flex-wrap gap-2",
!ozet && "justify-center",
)}
>
{ORNEK_SORULAR.map((s) => (
<PromptSuggestion
key={s}
@@ -181,7 +243,12 @@ export function SohbetClient({
))}
</div>
) : (
<div className="flex max-w-lg flex-wrap justify-center gap-2">
<div
className={cn(
"flex max-w-lg flex-wrap gap-2",
!ozet && "justify-center",
)}
>
{[112, 148, 96, 132].map((w, i) => (
<Skeleton
key={i}

View File

@@ -1,28 +1,25 @@
"use client";
// /meraklisina'nın canlı anlatım katmanı: kullanıcı kendi sıralamasını girer,
// dilim şeridi ve kademeli filtre hunisi o sıralamanın GERÇEK sayılarıyla
// güncellenir. Provider sayfadaki metin bölümlerini (RSC children) sarar;
// görseller context'ten okur, böylece tek girişle iki diyagram birden yaşar.
// /meraklisina'nın canlı anlatım katmanı: kullanıcının kayıtlı tercih
// profilindeki sıralama + puan türü okunur, dilim şeridi o sıralamanın GERÇEK
// sayılarıyla güncellenir. Profil yoksa sayfa SSR'daki örnek sıralamayla kalır.
// Provider sayfadaki metin bölümlerini (RSC children) sarar; görseller
// context'ten okur. (Sihirbaz hunisi statik bir şemayla anlatılır — bkz.
// huni-semasi.tsx.)
import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { PUAN_TURU_ETIKET, sonucHref } from "@/lib/sihirbaz";
import type { DilimKey, SihirbazFacetleri } from "@/lib/db";
const TUR_SECENEKLERI = [
{ 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;
export type DemoVeri = {
facetler: SihirbazFacetleri;
dilimToplam: Record<DilimKey, number>;
@@ -33,18 +30,7 @@ type DemoDurum = {
tur: string;
veri: DemoVeri;
yukleniyor: boolean;
// Huni seçimleri ve kademeli facet cevapları
kategori: string | null;
il: string | null;
tip: "devlet" | "vakif" | null;
kategoriFacet: SihirbazFacetleri | null; // ?kategori= cevabı (iller buradan)
ilFacet: SihirbazFacetleri | null; // ?kategori&il cevabı (tipler buradan)
kategoriSec: (k: string | null) => void;
ilSec: (i: string | null) => void;
tipSec: (t: "devlet" | "vakif" | null) => void;
siraDegistir: (ham: string) => void;
turDegistir: (t: string) => void;
siraMetni: string;
profilVar: boolean;
};
const DemoContext = createContext<DemoDurum | null>(null);
@@ -58,13 +44,9 @@ function useDemo(): DemoDurum {
async function facetGetir(
sira: number,
tur: string,
secim: { kategori?: string; il?: string; dilimler?: boolean },
signal: AbortSignal,
): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record<DilimKey, number> } | null> {
const params = new URLSearchParams({ sira: String(sira), tur });
if (secim.kategori) params.append("kategori", secim.kategori);
if (secim.il) params.append("il", secim.il);
if (secim.dilimler) params.set("dilimler", "1");
const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" });
try {
const res = await fetch(`/api/facetler?${params}`, { signal });
if (!res.ok) return null;
@@ -86,36 +68,19 @@ export function SiralamaDemo({
baslangicVeri: DemoVeri;
children: ReactNode;
}) {
const [siraMetni, setSiraMetni] = useState(
baslangicSira.toLocaleString("tr-TR"),
);
const [sira, setSira] = useState(baslangicSira);
const [tur, setTur] = useState("say");
const profil = useTercihProfili();
const sira = profil?.sira ?? baslangicSira;
const tur = profil?.tur ?? "say";
const [veri, setVeri] = useState(baslangicVeri);
const [yukleniyor, setYukleniyor] = useState(false);
const [kategori, setKategori] = useState<string | null>(null);
const [il, setIl] = useState<string | null>(null);
const [tip, setTip] = useState<"devlet" | "vakif" | null>(null);
const [kategoriFacet, setKategoriFacet] = useState<SihirbazFacetleri | null>(
null,
);
const [ilFacet, setIlFacet] = useState<SihirbazFacetleri | null>(null);
const zamanlayici = useRef<ReturnType<typeof setTimeout> | null>(null);
// Sıralama/tür değişti → ana veriyi tazele, huni seçimlerini sıfırla
// Profil yüklendi ve SSR örneğinden farklı → ana veriyi tazele
useEffect(() => {
if (sira === baslangicSira && tur === "say" && veri === baslangicVeri) {
return; // ilk render: sunucudan gelen veri zaten güncel
}
if (sira === baslangicSira && tur === "say") return;
const ctrl = new AbortController();
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti
setYukleniyor(true);
setKategori(null);
setIl(null);
setTip(null);
setKategoriFacet(null);
setIlFacet(null);
facetGetir(sira, tur, { dilimler: true }, ctrl.signal)
facetGetir(sira, tur, ctrl.signal)
.then((cevap) => {
if (!cevap?.dilimToplam) return;
setVeri({ facetler: cevap.facetler, dilimToplam: cevap.dilimToplam });
@@ -125,126 +90,51 @@ export function SiralamaDemo({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sira, tur]);
// Kategori seçildi → o kategorinin illerini getir
useEffect(() => {
if (!kategori) return;
const ctrl = new AbortController();
facetGetir(sira, tur, { kategori }, ctrl.signal).then((cevap) => {
if (cevap) setKategoriFacet(cevap.facetler);
});
return () => ctrl.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kategori]);
// İl seçildi → kategori+il ile tipleri getir
useEffect(() => {
if (!kategori || !il) return;
const ctrl = new AbortController();
facetGetir(sira, tur, { kategori, il }, ctrl.signal).then((cevap) => {
if (cevap) setIlFacet(cevap.facetler);
});
return () => ctrl.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kategori, il]);
function siraDegistir(ham: string) {
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
setSiraMetni(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
if (zamanlayici.current) clearTimeout(zamanlayici.current);
const deger = Number(rakamlar);
if (!deger || deger < 1 || deger > 4_000_000) return;
zamanlayici.current = setTimeout(() => setSira(deger), 450);
}
const durum: DemoDurum = {
sira,
tur,
veri,
yukleniyor,
kategori,
il,
tip,
kategoriFacet,
ilFacet,
kategoriSec: (k) => {
setKategori(k);
setIl(null);
setTip(null);
setKategoriFacet(null);
setIlFacet(null);
},
ilSec: (i) => {
setIl(i);
setTip(null);
setIlFacet(null);
},
tipSec: setTip,
siraDegistir,
turDegistir: setTur,
siraMetni,
profilVar: profil !== null,
};
return <DemoContext.Provider value={durum}>{children}</DemoContext.Provider>;
}
/** Sıralama girişi + puan türü — iki diyagramı birden süren tek kumanda. */
/** Görsellerin hangi sıralamayla hesaplandığını söyleyen bilgi kartı. */
export function DemoKumanda() {
const { siraMetni, siraDegistir, tur, turDegistir, sira, yukleniyor } =
useDemo();
const { sira, tur, yukleniyor, profilVar } = useDemo();
const turEtiket = PUAN_TURU_ETIKET[tur] ?? tur;
return (
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-5">
<label
htmlFor="demo-sira"
className="font-heading text-sm font-bold text-slate-900"
>
Kendi sıralamanı gir, aşağıdaki görseller sana göre değişsin
</label>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center">
<input
id="demo-sira"
inputMode="numeric"
autoComplete="off"
value={siraMetni}
onChange={(e) => siraDegistir(e.target.value)}
placeholder="ör. 85.000"
className="h-11 w-full rounded-xl border border-slate-200 bg-white px-4 text-base font-medium tabular-nums outline-none transition-colors focus:border-primary sm:w-44"
/>
<div
role="radiogroup"
aria-label="Puan türü"
className="flex flex-wrap gap-1.5"
>
{TUR_SECENEKLERI.map((t) => (
<button
key={t.deger}
type="button"
role="radio"
aria-checked={tur === t.deger}
onClick={() => turDegistir(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"
: "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"
}
>
{t.etiket}
</button>
))}
</div>
</div>
<p className="font-heading text-sm font-bold text-slate-900">
{profilVar
? "Görseller senin sıralamana göre hesaplandı"
: "Görseller örnek bir sıralamayla gösteriliyor"}
</p>
<p
className={`mt-2 text-xs text-slate-500 ${yukleniyor ? "animate-pulse" : ""}`}
className={`mt-2 text-sm text-slate-600 ${yukleniyor ? "animate-pulse" : ""}`}
aria-live="polite"
>
{yukleniyor
? "Hesaplanıyor…"
: `Aşağıdaki tüm sayılar ${sira.toLocaleString("tr-TR")}. sıradaki bir aday için gerçek YÖK Atlas verisidir.`}
{yukleniyor ? (
"Hesaplanıyor…"
) : (
<>
<span className="font-semibold tabular-nums text-slate-900">
{sira.toLocaleString("tr-TR")}.
</span>{" "}
sıra · {turEtiket} aşağıdaki tüm sayılar bu aday için gerçek YÖK
Atlas verisidir.
{!profilVar &&
" Ana sayfadan kendi sıralamanı girersen bu sayfa sana göre güncellenir."}
</>
)}
</p>
</div>
);
}
/** Sıralama ekseni: girilen sıraya göre dilim sınırları ve gerçek adetler. */
/** Sıralama ekseni: adayın sırasına göre dilim sınırları ve gerçek adetler. */
export function DilimSeridiCanli() {
const { sira, veri, yukleniyor } = useDemo();
const t = veri.dilimToplam;
@@ -334,307 +224,28 @@ export function DilimSeridiCanli() {
);
}
function HuniCubugu({
etiket,
adet,
oran,
}: {
etiket: string;
adet: number;
oran: number;
}) {
return (
<div className="min-w-0 flex-1">
<div
className="flex h-9 min-w-36 items-center justify-between gap-2 rounded-lg bg-primary/85 px-3 transition-[width] duration-300"
style={{ width: `${Math.min(Math.max(oran * 100, 36), 100)}%` }}
>
<span className="truncate text-xs font-medium text-white">{etiket}</span>
<span className="shrink-0 text-xs font-bold tabular-nums text-white">
{adet.toLocaleString("tr-TR")}
</span>
</div>
</div>
);
}
/**
* Sayfanın kapanış kartı: profili olmayan ziyaretçiyi ana sayfadaki sıralama
* formuna, sıralamasını zaten girmiş adayı ise doğrudan sonuç sayfasına
* gönderir — "sıralamanı gir" CTA'sı profil varken gösterilmez.
*/
export function DemoKapanisCta() {
const profil = useTercihProfili();
function SecimCipleri<T extends { anahtar: string; etiket: string; adet: number }>({
secenekler,
secili,
onSec,
}: {
secenekler: T[];
secili: string | null;
onSec: (anahtar: string | null) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{secenekler.map((s) => (
<button
key={s.anahtar}
type="button"
aria-pressed={secili === s.anahtar}
onClick={() => onSec(secili === s.anahtar ? null : s.anahtar)}
className={
secili === s.anahtar
? "cursor-pointer rounded-full border border-orange-500 bg-orange-500 px-3 py-1.5 text-xs font-medium text-white"
: "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"
}
>
{s.etiket}
<span className="ml-1 text-[10px] opacity-70 tabular-nums">
{s.adet.toLocaleString("tr-TR")}
</span>
</button>
))}
</div>
);
}
// AI listesinin dilim iskeleti: üstte hayal, ortada dengeli, sonda garanti.
// Ürünün risk renkleriyle boyanır; sayılar prompt'taki kuralın orta değeri.
const LISTE_ISKELETI: { dilim: string; adet: number; renk: string }[] = [
{ dilim: "hayal", adet: 5, renk: "bg-red-400" },
{ dilim: "dengeli", adet: 13, renk: "bg-amber-400" },
{ dilim: "garanti", adet: 6, renk: "bg-emerald-500" },
];
/** Huninin finali: kalan havuzun yapay zekâya gidişi ve 24'lük listenin
iskeleti. Havuz 24'ün altına düşerse gevşetme kuralını canlı gösterir. */
function YapayZekaAsamasi({ havuz }: { havuz: number }) {
const dar = havuz < 24;
return (
<div className="border-t border-slate-100 pt-4">
<p className="text-xs font-semibold text-slate-500">
Sonra sıra yapay zekâda
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-center">
<p className="font-heading text-base font-bold text-slate-900">
{profil
? "Kuralları öğrendin — sayılar zaten senin sıralamanla hesaplandı."
: "Kuralları öğrendin — şimdi kendi sıralamanla dene."}
</p>
<p className="mt-1.5 text-xs leading-5 text-slate-600">
Seçimlerinden geriye kalan{" "}
<span className="font-bold tabular-nums text-slate-900">
{havuz.toLocaleString("tr-TR")} programlık havuz
</span>{" "}
yapay zekâya gider; o da bu havuzdan tam 24 tercih seçer ve şu iskelete
oturtur:
</p>
{/* 24 hücrelik liste şeridi — renk + metin birlikte (renk tek başına
anlam taşımaz) */}
<div
role="img"
aria-label="24 tercihlik listenin yapısı: en üstte yaklaşık 5 hayal, ortada 13 dengeli, sonda 6 garanti tercih."
className="mt-3"
<Link
href={profil ? sonucHref(profil) : "/"}
className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-orange-600"
>
<div className="flex gap-1">
{LISTE_ISKELETI.flatMap((b) =>
Array.from({ length: b.adet }, (_, i) => (
<span
key={`${b.dilim}-${i}`}
className={`h-6 min-w-0 flex-1 rounded ${b.renk}`}
/>
)),
)}
</div>
<div className="mt-1.5 flex justify-between text-[11px] text-slate-500">
<span>
üstte 46 <span className="font-semibold text-red-600">hayal</span>
</span>
<span>
ortada 1214{" "}
<span className="font-semibold text-amber-600">dengeli</span>
</span>
<span>
sonda 57{" "}
<span className="font-semibold text-emerald-600">garanti</span>
</span>
</div>
</div>
<ul className="mt-3 space-y-1 text-xs leading-5 text-slate-600">
<li>
· Havuzda olmayan bir bölümü yazamaz listedeki her satırın kimliği
havuzla karşılaştırılır, uymayan liste yeniden ürettirilir.
</li>
<li>
· Her satıra tek cümlelik gerekçe ve bir risk notu ekler; sıraya neyi
neden koyduğu görünür.
</li>
<li>
· Önceliklerin ( garantisi, maaş, prestij) hangi programın öne
geçeceğini belirler.
</li>
<li>
· Havuz 24&apos;ün altına düşerse filtreler adım adım gevşetilir
önce il, sonra alan ve bu, listende ıkça belirtilir.
</li>
</ul>
{dar ? (
<p className="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
Bu seçimle havuz 24&apos;ün altına düştü. Gerçek sihirbaz bu durumda
filtreleri adım adım gevşetir önce il, sonra alan tercihi esnetilir
ve bunu listende ıkça belirtir; liste her zaman 24&apos;e
tamamlanır.
</p>
) : null}
</div>
);
}
/** Kademeli filtre hunisi: gerçek sihirbaz mantığının oynanabilir kopyası. */
export function HuniCanli() {
const {
veri,
yukleniyor,
kategori,
il,
tip,
kategoriFacet,
ilFacet,
kategoriSec,
ilSec,
tipSec,
} = useDemo();
const toplam = veri.facetler.toplam;
const kategoriAdet =
kategori != null
? (veri.facetler.kategoriler.find((k) => k.ad === kategori)?.adet ?? 0)
: null;
const ilAdet =
il != null && kategoriFacet
? (kategoriFacet.iller.find((i) => i.il === il)?.adet ?? 0)
: null;
const tipAdet =
tip != null && ilFacet
? (ilFacet.uniturler.find((u) => u.grup === tip)?.adet ?? 0)
: null;
const kategoriSecenekleri = veri.facetler.kategoriler.slice(0, 6).map((k) => ({
anahtar: k.ad,
etiket: k.ad,
adet: k.adet,
}));
const ilSecenekleri = (kategoriFacet?.iller ?? []).slice(0, 6).map((i) => ({
anahtar: i.il,
etiket: i.il.toLocaleLowerCase("tr-TR"),
adet: i.adet,
}));
const tipSecenekleri = (ilFacet?.uniturler ?? []).map((u) => ({
anahtar: u.grup,
etiket: u.grup === "devlet" ? "Devlet" : "Vakıf",
adet: u.adet,
}));
// Yapay zekâya giden havuz: huninin en derin (en son seçilmiş) katmanı
const havuz = tipAdet ?? ilAdet ?? kategoriAdet ?? toplam;
return (
<div
className={`mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-5 ${
yukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
{/* Huni çubukları — her seçim bir çubuk daha ekler */}
<div className="space-y-2">
<div className="flex items-center gap-3">
<span className="w-6 shrink-0" />
<HuniCubugu
etiket="Sıralamanla açılan tüm programlar"
adet={toplam}
oran={1}
/>
</div>
{kategori != null && kategoriAdet != null ? (
<div className="flex items-center gap-3">
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
1.
</span>
<HuniCubugu
etiket={`${kategori}” seçince`}
adet={kategoriAdet}
oran={toplam > 0 ? kategoriAdet / toplam : 0}
/>
</div>
) : null}
{il != null && ilAdet != null ? (
<div className="flex items-center gap-3">
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
2.
</span>
<HuniCubugu
etiket={`+ “${il.toLocaleLowerCase("tr-TR")}” seçince`}
adet={ilAdet}
oran={toplam > 0 ? ilAdet / toplam : 0}
/>
</div>
) : null}
{tip != null && tipAdet != null ? (
<div className="flex items-center gap-3">
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
3.
</span>
<HuniCubugu
etiket={`+ “${tip === "devlet" ? "Devlet" : "Vakıf"}” seçince`}
adet={tipAdet}
oran={toplam > 0 ? tipAdet / toplam : 0}
/>
</div>
) : null}
</div>
{/* Seçim adımları — sihirbazın küçük, oynanabilir hali */}
<div className="space-y-3 border-t border-slate-100 pt-4">
<div>
<p className="mb-1.5 text-xs font-semibold text-slate-500">
1. Bir alan seç
</p>
<SecimCipleri
secenekler={kategoriSecenekleri}
secili={kategori}
onSec={kategoriSec}
/>
</div>
{kategori != null ? (
<div>
<p className="mb-1.5 text-xs font-semibold text-slate-500">
2. Bir il seç{" "}
<span className="font-normal text-slate-400">
(iller artık yalnızca {kategori} programlarını sayıyor)
</span>
</p>
{ilSecenekleri.length > 0 ? (
<SecimCipleri
secenekler={ilSecenekleri}
secili={il}
onSec={ilSec}
/>
) : (
<p className="text-xs text-slate-400">Yükleniyor</p>
)}
</div>
) : null}
{kategori != null && il != null ? (
<div>
<p className="mb-1.5 text-xs font-semibold text-slate-500">
3. Üniversite tipi seç{" "}
<span className="font-normal text-slate-400">
(sayılar hem alanı hem ili hesaba katıyor)
</span>
</p>
{tipSecenekleri.length > 0 ? (
<SecimCipleri
secenekler={tipSecenekleri}
secili={tip}
onSec={(t) => tipSec(t as "devlet" | "vakif" | null)}
/>
) : (
<p className="text-xs text-slate-400">Yükleniyor</p>
)}
</div>
) : null}
</div>
<YapayZekaAsamasi havuz={havuz} />
{profil ? "Sıralamana uygun programları gör" : "Sıralamanı gir"}
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
);
}

View File

@@ -0,0 +1,54 @@
// Sihirbaz hunisinin statik anlatımı: interaktif demo yerine akışın tamamını
// tek bakışta gösteren bir mermaid şeması. Çizim, ürün genelindeki ortak
// "el çizimi" bileşeniyle yapılır (bkz. components/el-cizimi-sema.tsx).
import { ElCizimiSema } from "@/components/el-cizimi-sema";
// Renkler ürünün risk dilinden (kırmızı/sarı/yeşil) ve slate paletinden;
// renk tek başına anlam taşımaz, her düğüm metinle etiketlidir.
const SEMA = `---
config:
look: handDrawn
---
flowchart TD
T("Sıralamanla açılan tüm programlar") --> A("1. adım · Alan seçimi<br/>yalnızca ulaşabildiğin alanlar listelenir")
A --> B("2. adım · İl seçimi<br/>iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır")
B --> C("3. adım · Devlet / Vakıf<br/>sayılar hem alanı hem ili hesaba katar")
C --> H("Aday havuzu<br/>elde kalan gerçek programlar")
H --> S{"Havuzda 24'ten az program mı kaldı?"}
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir")
G --> H
S -- "hayır" --> Y("Yapay zekâ havuzdan tam 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe + risk notu ekler")
Y --> L("24 tercihlik liste")
L --> L1("üstte 46 hayal")
L --> L2("ortada 1214 dengeli")
L --> L3("sonda 57 garanti")
classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a
classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
class H,Y havuz
class G uyari
class L1 hayal
class L2 dengeli
class L3 garanti`;
export function HuniSemasi() {
return (
<ElCizimiSema
tanim={SEMA}
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, sonra yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde 24 tercih seçer."
figcaption={
<>
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
<span className="font-medium text-slate-700">
dışına asla çıkmadan
</span>{" "}
kurar.
</>
}
/>
);
}

View File

@@ -1,35 +1,37 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Parallax } from "@/components/parallax";
import { PagePixelDivider } from "@/components/pixel-decor";
import { PagePixelDivider, SectionEyebrow } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
import { dilimAra, rankWindowFacets, type DilimKey } from "@/lib/db";
import {
DemoKapanisCta,
DemoKumanda,
DilimSeridiCanli,
HuniCanli,
SiralamaDemo,
type DemoVeri,
} from "./demo";
import { HuniSemasi } from "./huni-semasi";
export const metadata: Metadata = {
title: "Meraklısına: KolayTercih nasıl çalışır?",
alternates: { canonical: "/meraklisina" },
description:
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, kendi sıralamanla deneyebileceğin canlı görsellerle.",
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, sıralamana göre hesaplanan canlı görsellerle.",
};
/**
* Algoritmanın öğrenci diline çevrilmiş anlatımı. Teknik detay (SQL, çarpan
* mutfağı) bilerek verilmez; amaç güven: veri gerçek, kurallar şeffaf.
* Görseller canlıdır: kullanıcı sıralamasını girer, dilim şeridi ve filtre
* hunisi o sıralamanın gerçek sayılarıyla güncellenir (bkz. demo.tsx).
* Dilim şeridi canlıdır: kullanıcının kayıtlı tercih profilindeki sıralama
* okunur, şerit o sıralamanın gerçek sayılarıyla güncellenir (bkz. demo.tsx);
* profil yoksa örnek sıralama gösterilir. Sihirbaz hunisi ise statik
* bir mermaid şemasıyla anlatılır (bkz. huni-semasi.tsx).
* Diyagramlar ürünün risk renk dilini kullanır (yeşil/sarı/kırmızı) ve her
* bölge metinle de etiketlenir — renk tek başına anlam taşımaz.
*/
// Demo ilk açılışta bu sıralamayla dolu gelir; SSR'da gerçek veriyle render
// edilir ki kullanıcı girmeden de sayfa anlamlı olsun.
// Profili olmayan ziyaretçi bu örnek sıralamayı görür; SSR'da gerçek veriyle
// render edilir ki profil olmadan da sayfa anlamlı olsun.
const VARSAYILAN_SIRA = 100_000;
export default function MeraklisinaPage() {
@@ -45,19 +47,18 @@ export default function MeraklisinaPage() {
};
return (
<>
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<Parallax fromStart strength={10}>
<p className="text-sm font-semibold tracking-wide text-primary">
Meraklısına
</p>
<h1 className="mt-1 font-heading text-3xl font-bold">
<SectionEyebrow>Meraklısına</SectionEyebrow>
<h1 className="mt-3 font-heading text-3xl font-bold">
Sıralamanı girince arka planda ne oluyor?
</h1>
<p className="mt-3 text-slate-600">
Sihir yok, kara kutu yok: KolayTercih&apos;in önerdiği her satır resmî
YÖK Atlas verisinden gelir ve hangi kurala göre önüne geldiği bellidir.
Bu sayfa o kuralları öğrenci diliyle anlatır üstelik görseller
canlı: kendi sıralamanı gir, sayılar sana göre değişsin.
canlı: sıralamanı girdiysen sayılar sana göre hesaplanır.
</p>
</Parallax>
@@ -139,7 +140,7 @@ export default function MeraklisinaPage() {
4. Sihirbaz süzer, yapay zekâ kurar
</h2>
<p className="mt-2">
&ldquo;Listemi oluştur&rdquo; sihirbazındaki üç adım birbirinden
&ldquo;AI listemi kur&rdquo; sihirbazındaki üç adım birbirinden
bağımsız değildir. İlk adımda yalnızca{" "}
<strong>sıralamanla ulaşabildiğin</strong>{" "}
alanlar listelenir; ikinci adımdaki iller, seçtiğin alanlardaki
@@ -148,11 +149,10 @@ export default function MeraklisinaPage() {
Adımlar bitince elde kalan gerçek programlar bir{" "}
<strong>aday havuzu</strong>{" "}
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
verilir. Aşağıda kendin dene her seçimin havuzu nasıl
daralttığını ve havuzun 24&apos;lük listeye nasıl dönüştüğünü huni
canlı gösterir.
verilir. Aşağıdaki şema akışın tamamını gösterir havuz
24&apos;ün altına düşerse filtrelerin nasıl gevşetildiği de dahil.
</p>
<HuniCanli />
<HuniSemasi />
</section>
<section>
@@ -170,20 +170,11 @@ export default function MeraklisinaPage() {
</p>
</section>
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-center">
<p className="font-heading text-base font-bold text-slate-900">
Kuralları öğrendin şimdi kendi sıralamanla dene.
</p>
<Link
href="/"
className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-orange-600"
>
Sıralamanı gir
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
<DemoKapanisCta />
</Parallax>
</SiralamaDemo>
</main>
<SiteFooter />
</>
);
}

View File

@@ -10,8 +10,8 @@ export default function NotFound() {
Böyle bir sayfa yok
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
Aradığın sayfa taşınmış ya da hiç var olmamış olabilir. Sıralamanı
girip listeni kurmaya ana sayfadan devam edebilirsin.
Aradığın sayfa taşınmış ya da hiç var olmamış olabilir. Listeni
kurmaya ana sayfadan devam edebilirsin.
</p>
<Button
asChild

View File

@@ -1,12 +1,14 @@
import Link from "next/link";
import type { Metadata } from "next";
import { eq } from "drizzle-orm";
import { CheckCircle2, Sparkles, XCircle } from "lucide-react";
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
import { verifySession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { odemeyiSonuclandir } from "@/lib/odeme";
import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
import { RaporYokCta } from "./rapor-yok-cta";
export const metadata: Metadata = {
title: "Ödeme Sonucu",
@@ -73,22 +75,24 @@ export default async function OdemeSonucPage({
</p>
<PagePixelDivider seed={79} className="mx-auto mt-5" />
<div className="mt-6 flex flex-col gap-2">
<Button
asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={listeHref ?? "/"}>
<Sparkles className="size-4" aria-hidden />
{listeHref
? "Listeni aç — kilidi kaldırdık"
: "24'lük listeni oluştur"}
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
{listeHref
? "24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık."
: "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."}
</p>
{listeHref ? (
<>
<Button
asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={listeHref}>
Listeni kilidi kaldırdık
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
24 tercihin tamamı, gerekçeler, riskler ve PDF artık ık.
</p>
</>
) : (
<RaporYokCta />
)}
</div>
</div>
) : (
@@ -118,6 +122,7 @@ export default async function OdemeSonucPage({
</div>
)}
</main>
<SiteFooter />
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { sonucHref } from "@/lib/sihirbaz";
/**
* Ödeme başarılı ama henüz AI listesi yokken gösterilen CTA: kayıtlı tercih
* profili olan adayı sihirbazıık hâlde sonuç sayfasına götürür ve
* "sıralamanı gir" metni gösterilmez; profil yoksa ana sayfadaki sıralama
* formuna yönlendirir.
*/
export function RaporYokCta() {
const profil = useTercihProfili();
return (
<>
<Button
asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}>
24&apos;lük listeni oluştur
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
{profil
? "Sıralaman kayıtlı — sonuç sayfasındaki sihirbazla listeni kur."
: "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."}
</p>
</>
);
}

View File

@@ -12,7 +12,8 @@ import {
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { HeroFocusButton } from "@/components/hero-focus-button";
import { HeroBaslik } from "@/components/hero-baslik";
import { KapanisCta } from "@/components/hero-focus-button";
import { HeroForm } from "@/components/hero-form";
import { Parallax } from "@/components/parallax";
import { Reveal } from "@/components/landing/reveal";
@@ -24,7 +25,6 @@ import {
SectionEyebrow,
} from "@/components/pixel-decor";
import { URUNLER } from "@/lib/credits";
import { Badge } from "@/components/ui/badge";
import {
Accordion,
AccordionContent,
@@ -209,13 +209,7 @@ export default function Home() {
strength={14}
className="mx-auto flex min-h-[calc(100svh-5.5rem)] max-w-6xl flex-col items-center justify-center px-4 pb-4 pt-12 text-center sm:pb-36 sm:pt-4"
>
<h1 className="max-w-3xl font-heading text-5xl font-bold tracking-[0.015em] sm:text-6xl">
Dört yıllık geleceğini <br className="hidden sm:block" />
<span className="font-bricolage italic tracking-tighter font-bold text-primary">
48 saatlik panikle
</span>{" "}
seçme
</h1>
<HeroBaslik />
{/* <p className="mt-6 max-w-2xl text-lg leading-relaxed text-slate-600">
KolayTercih, gerçek YÖK Atlas verisiyle çalışan yapay zekâ tercih
danışmanın. İnsan danışmanın binlerce lira aldığı işi — dengeli
@@ -227,10 +221,6 @@ export default function Home() {
>
<HeroForm />
</div>
<p className="mt-4 text-sm text-slate-500">
Temel arama her zaman ücretsiz · Kredi kartı gerekmez · Tam analiz
paketi tek seferlik {PAKET_FIYATI} TL
</p>
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
<Image
src="/yok-logo.svg"
@@ -530,11 +520,7 @@ export default function Home() {
<h2 className="mt-6 font-heading text-3xl font-bold sm:text-4xl">
Tercih listen, pişmanlık listesi olmasın
</h2>
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun.
Tercih dönemi bitmeden yerini al.
</p>
<HeroFocusButton />
<KapanisCta />
</div>
</div>
</Reveal>

View File

@@ -12,10 +12,10 @@ import {
import { getCurrentUser } from "@/lib/session";
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { SectionEyebrow } from "@/components/pixel-decor";
import { Parallax } from "@/components/parallax";
import { PagePixelDivider } from "@/components/pixel-decor";
import { SatinAlForm } from "./satin-al-form";
import { SiteFooter } from "@/components/site-footer";
export const metadata: Metadata = {
title: "Tercih Dönemi Paketi",
@@ -68,9 +68,7 @@ export default async function PaketPage({
</div>
) : null}
<Parallax fromStart strength={12} className="text-center">
<Badge variant="secondary" className="bg-primary/10 text-primary">
Tek seferlik ödeme · Abonelik yok
</Badge>
<SectionEyebrow>Tek seferlik ödeme · Abonelik yok</SectionEyebrow>
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
İnsan danışmanın işini,{" "}
<span className="text-primary">onda bir fiyatına</span>
@@ -81,8 +79,6 @@ export default async function PaketPage({
</p>
</Parallax>
<PagePixelDivider seed={53} className="mx-auto mt-7" />
<Parallax
strength={6}
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
@@ -192,6 +188,7 @@ export default async function PaketPage({
.
</p>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -1,6 +1,4 @@
import Link from "next/link";
import type { Metadata } from "next";
import { ArrowLeft } from "lucide-react";
import { eq } from "drizzle-orm";
import {
PUAN_TURLERI,
@@ -14,13 +12,11 @@ import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { raporMaskele } from "@/lib/rapor-maske";
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
import { FunnelBanner } from "./funnel-banner";
import type { MevcutRapor } from "./liste-paneli";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ProgramTablosu } from "@/components/program-tablosu";
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
import { PagePixelDivider } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
import { SiraGerekli } from "./sira-gerekli";
export const metadata: Metadata = {
title: "Tercih Listeni Kur",
@@ -30,14 +26,6 @@ export const metadata: Metadata = {
robots: { index: false, follow: true },
};
const TUR_LABELS: Record<PuanTuruKey, string> = {
say: "Sayısal",
ea: "Eşit Ağırlık",
soz: "Sözel",
dil: "Dil",
tyt: "TYT (Önlisans)",
};
export default async function SonucPage({
searchParams,
}: {
@@ -58,21 +46,10 @@ export default async function SonucPage({
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<h1 className="font-heading text-2xl font-bold">
Geçerli bir sıralama gerekli
</h1>
<p className="mt-3 text-slate-600">
Listeni kurabilmek için ana sayfadan YKS başarı sıralamanı gir.
</p>
<PagePixelDivider seed={59} className="mx-auto mt-6" />
<Button asChild className="mt-6 cursor-pointer">
<Link href="/">
<ArrowLeft className="size-4" aria-hidden />
Ana sayfaya dön
</Link>
</Button>
</main>
<>
<SiraGerekli />
<SiteFooter />
</>
);
}
@@ -108,51 +85,15 @@ export default async function SonucPage({
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<main className="mx-auto max-w-4xl px-4 py-12">
<div className="flex items-center justify-between gap-3">
<Badge variant="secondary" className="bg-primary/10 text-primary">
{sira.toLocaleString("tr-TR")}. sıra · {TUR_LABELS[turKey]}
</Badge>
<Button
asChild
size="sm"
variant="outline"
className="cursor-pointer rounded-full"
>
<Link href="/">
<ArrowLeft className="size-4" aria-hidden />
Yeni arama
</Link>
</Button>
</div>
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için tercih planı
</h1>
<p className="mt-2 max-w-2xl text-slate-600">
Birkaç adımda ilgi alanlarını ve şehir tercihini seç; yapay zekâ
gerçek YÖK Atlas verisiyle 24 tercihlik dengeli listeni kursun. Her
satır yerleşme riskine göre renklenir:{" "}
<span className="font-semibold text-emerald-600">yeşil güvenli</span>,{" "}
<span className="font-semibold text-amber-600">sarı az riskli</span>,{" "}
<span className="font-semibold text-red-600">kırmızı riskli</span>.
</p>
<PagePixelDivider seed={61} className="mt-7" />
{/* Sayfa hiyerarşisi: önce Türkiye haritası, sonra liste. Manuel
liste store'una abone olduğundan tablodan "+" ile eklenenler
buraya canlı yansır. */}
<div className="mt-8">
<ManuelHarita />
</div>
<ManuelHarita />
{/* AI'dan bağımsız ham tablo — haritanın hemen altında, sayfanın ana
gövdesi; puan türü seçici de listenin başında yaşar */}
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
{/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor
varsa /listem paywall'ına) iten banner — paketli kullanıcıya
satacak bir şey kalmadığından gösterilmez */}
{!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null}
{/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli
sayfanın en altında yaşar */}
<SihirbazBolumu
@@ -174,6 +115,7 @@ export default async function SonucPage({
aittir.
</p>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -3,22 +3,24 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Sparkles, X } from "lucide-react";
import { ArrowRight, X } from "lucide-react";
import { animate } from "motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ViewportPortal } from "@/components/viewport-portal";
import { PixelField } from "@/components/pixel-decor";
import type { SihirbazFacetleri } from "@/lib/db";
import {
SIHIRBAZ_AC_EVENT,
SIHIRBAZ_STORAGE_KEY,
sihirbazDogrula,
sonucHref,
tercihProfiliSil,
tercihProfiliYaz,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
import { SihirbazModal } from "./sihirbaz-modal";
import { SIHIRBAZ_AC_EVENT } from "./funnel-banner";
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
import { listeOlustur, listeRevize } from "./actions";
@@ -86,6 +88,9 @@ export function SihirbazBolumu({
const [elleKapandi, setElleKapandi] = useState(false);
const [kartGizle, setKartGizle] = useState(false);
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
// ?sihirbaz=1 fallback modalı storage okunmadan açılmasın: hero'dan gelen
// kayıtlı seçimler varken modal bir anlığına açılıp kapanıyordu.
const [depoOkundu, setDepoOkundu] = useState(false);
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
const [kredi, setKredi] = useState(baslangicKredi);
@@ -101,6 +106,9 @@ export function SihirbazBolumu({
const requestIdRef = useRef<string | null>(null);
const otomatikBasladi = useRef(false);
const bolumRef = useRef<HTMLDivElement>(null);
// Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi
// görsün diye ref'te tutulur; olay dinleyicisi bir kez bağlanır.
const bekleyenRef = useRef<BekleyenSecim | null>(null);
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
useEffect(() => {
@@ -124,18 +132,20 @@ export function SihirbazBolumu({
}
// eslint-disable-next-line react-hooks/set-state-in-effect
if (kaliciKapali) setKartGizle(true);
if (yeniBekleyen) setBekleyen(yeniBekleyen);
if (yeniBekleyen) {
setBekleyen(yeniBekleyen);
bekleyenRef.current = yeniBekleyen;
}
} catch {}
setDepoOkundu(true);
}, [sira, tur]);
// Üstteki funnel banner'ının CTA'sı sihirbaz modalını bu olayla açar
// Arama modalındaki funnel kartının CTA'sı sihirbazı bu olayla başlatır
useEffect(() => {
const ac = () => {
setElleAcik(true);
setElleKapandi(false);
};
const ac = () => sihirbaziBaslat();
window.addEventListener(SIHIRBAZ_AC_EVENT, ac);
return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
@@ -241,7 +251,7 @@ export function SihirbazBolumu({
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
tercihProfiliYaz({ sira, tur, secimler });
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
const geri = sonucHref({ sira, tur }, { sihirbaz: "1" });
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
@@ -255,6 +265,22 @@ export function SihirbazBolumu({
await uret(secimler);
}
/**
* Funnel CTA'larının (banner, büyük kart, sabit alt kart) ortak girişi:
* adımlar zaten tamamlanıp kaydedildiyse modal açılmaz — girişli
* kullanıcıda üretim doğrudan başlar, girişsiz kullanıcı girişe gider.
* Kayıtlı seçim yoksa (ya da başka sıra/türe aitse) modal açılır.
*/
function sihirbaziBaslat() {
const hazir = bekleyenRef.current;
if (hazir?.eslesir) {
void ilerle(hazir.secimler);
return;
}
setElleAcik(true);
setElleKapandi(false);
}
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
// (bir kez, modal açmadan — üretim sayfa gövdesinde oynar).
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && !rapor);
@@ -268,11 +294,15 @@ export function SihirbazBolumu({
}, [otomatikUret]);
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin.
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. Karar
// storage okunmadan verilmez: kayıtlı seçimlerle gelen kullanıcıya modal
// hiç gösterilmez, üretim doğrudan başlar.
const acik =
!elleKapandi &&
(elleAcik ||
Boolean(otomatikAc && !otomatikUret && !rapor && durum === "bos"));
Boolean(
otomatikAc && depoOkundu && !otomatikUret && !rapor && durum === "bos",
));
function acikDegisti(v: boolean) {
if (v) {
@@ -293,7 +323,7 @@ export function SihirbazBolumu({
// mount sonrası değişir ve AnimatePresence geçişi donardı.
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor);
const girisUrl = `/giris?callback=${encodeURIComponent(
`/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`,
sonucHref({ sira, tur }, { sihirbaz: "1" }),
)}`;
const panelAnahtari =
@@ -393,11 +423,17 @@ export function SihirbazBolumu({
</Button>
</section>
) : girisBekliyor ? (
<section className="mt-16 min-w-0 rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={13} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
Seçimlerin hazır AI listen bir adım uzakta
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik AI listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı listen otomatik kurulsun (üretim 3
@@ -406,34 +442,41 @@ export function SihirbazBolumu({
<Button
asChild
size="lg"
className="mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
<Link href={girisUrl}>
<Sparkles className="size-4" aria-hidden />5 deneme kredinle
AI listeni kur giriş yap
5 deneme kredinle AI listeni kur giriş yap
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="mt-3 text-xs text-white/70">
<p className="relative mt-3 text-xs text-white/70">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</section>
) : (
<section className="mt-16 min-w-0 rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
Bu listeyi 24 tercihlik plana çevirelim mi?
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={19} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
24 tercihi tek tek elemek yerine, yapay zekâ kursun
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
Birkaç adımda ilgi alanların ve şehir tercihine göre
gerekçeli, dengeli bir tercih listesi kuralım her satır için
yerleşme riski analiziyle.
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
ve garanti dilimlerine dağıtılmış 24 tercihi her satırı
yerleşme riski ve gerekçesiyle senin yerine dizer.
</p>
<Button
size="lg"
onClick={() => acikDegisti(true)}
className="mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
onClick={sihirbaziBaslat}
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
<Sparkles className="size-4" aria-hidden />
Listemi oluştur
AI listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</section>
)}
@@ -450,12 +493,12 @@ export function SihirbazBolumu({
<p className="font-heading text-sm font-bold text-slate-900">
{girisBekliyor
? "Seçimlerin kayıtlı — AI listen hazır bekliyor"
: "3 adımda 24 tercihlik listen"}
: "Tek tek ekleme, yapay zekâ kursun"}
</p>
<p className="text-xs text-slate-500">
{girisBekliyor
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
: "Sıralamana uygun bölümleri seç, yapay zekâ listeni kursun."}
: "Üç adımda seçimlerini söyle; risk analizli, gerekçeli 24 tercih hazır."}
</p>
</div>
{girisBekliyor ? (
@@ -469,7 +512,7 @@ export function SihirbazBolumu({
) : (
<Button
size="sm"
onClick={() => acikDegisti(true)}
onClick={sihirbaziBaslat}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Başla
@@ -506,7 +549,7 @@ export function SihirbazBolumu({
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
}
sonButonEtiketi={
girisliMi ? "Listemi oluştur (3 kredi)" : "Giriş yap ve listeni gör"
girisliMi ? "AI listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
}
onTamamla={ilerle}
/>

View File

@@ -0,0 +1,70 @@
"use client";
// /sonuc?sira= yokken: kayıtlı profil varsa oraya taşı; yoksa kapıyı aç.
// Sıra bilgisi localStorage'daki TercihProfili ile tüm uygulamada ortak.
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Trophy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor";
import { sonucHref } from "@/lib/sihirbaz";
import {
sonucaGitIste,
useProfilKapisi,
} from "@/components/manuel-liste/profil-kapisi-store";
export function SiraGerekli() {
const router = useRouter();
const { profil, profilYuklendi } = useProfilKapisi();
const [yonleniyor, setYonleniyor] = useState(false);
useEffect(() => {
if (!profilYuklendi) return;
if (profil) {
setYonleniyor(true);
router.replace(sonucHref(profil));
}
}, [profil, profilYuklendi, router]);
if (!profilYuklendi || yonleniyor || profil) {
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<p className="text-sm text-slate-500">Sıralaman kontrol ediliyor</p>
</main>
);
}
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<h1 className="font-heading text-2xl font-bold">
Geçerli bir sıralama gerekli
</h1>
<p className="mt-3 text-slate-600">
Listeni kurabilmek için YKS başarı sıralamanı gir. Kaydettiğin sıra tüm
uygulamada kullanılır.
</p>
<PagePixelDivider seed={59} className="mx-auto mt-6" />
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<Button
type="button"
onClick={() => {
const { href } = sonucaGitIste();
if (href) router.push(href);
}}
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Trophy className="size-4" aria-hidden />
Sıralamamı gir
</Button>
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/">
<ArrowLeft className="size-4" aria-hidden />
Ana sayfaya dön
</Link>
</Button>
</div>
</main>
);
}

View File

@@ -8,19 +8,51 @@ import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { MessageCircleMore, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Skeleton } from "@/components/ui/skeleton";
import { ViewportPortal } from "@/components/viewport-portal";
function SohbetSkeleton() {
return (
<div
aria-busy="true"
aria-live="polite"
className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl"
>
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
<div className="flex flex-1 flex-col items-center gap-4 px-4 py-10">
<Skeleton className="size-7 rounded-full" />
<Skeleton className="h-5 w-56 max-w-full" />
<div className="w-full max-w-md space-y-2">
<Skeleton className="mx-auto h-3.5 w-full" />
<Skeleton className="mx-auto h-3.5 w-4/5" />
</div>
<div className="flex max-w-lg flex-wrap justify-center gap-2">
{[112, 148, 96, 132].map((w, i) => (
<Skeleton
key={i}
className="h-7 rounded-full"
style={{ width: w }}
/>
))}
</div>
</div>
<div className="border-t border-slate-100 p-3">
<Skeleton className="h-24 w-full rounded-2xl" />
</div>
</div>
);
}
// Sohbet istemcisi (markdown zinciriyle birlikte) ağır; popup ilk açıldığında
// yüklensin — /sonuc ilk yükünü şişirmesin.
const SohbetClient = dynamic(
() => import("@/app/listem/sohbet-client").then((m) => m.SohbetClient),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center rounded-2xl border border-slate-200 bg-white text-sm text-slate-500 shadow-2xl">
Sohbet yükleniyor
</div>
),
loading: () => <SohbetSkeleton />,
},
);

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
ArrowRight,
ChartNoAxesCombined,
@@ -10,9 +10,11 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { TypingAnimation } from "@/components/ui/typing-animation";
/** Banner CTA'sının sihirbaz modalını açmak için yayınladığı olay. */
export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac";
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/lib/sihirbaz";
import {
profilToplamaAc,
useTercihProfili,
} from "@/components/manuel-liste/profil-kapisi-store";
const FUNNEL_METINLERI = [
"Sıralamana en uygun listeyi birlikte kuralım.",
@@ -47,7 +49,7 @@ const FUNNEL_METINLERI = [
"İyi bir liste, iyi bir başlangıçtır.",
];
/** Fisher-Yates ile kopya üzerinde karıştırır; her ziyarette farklı sıra. */
/** Fisher-Yates ile kopya üzerinde karıştırır; her ılışta farklı sıra. */
function karistir<T>(dizi: readonly T[]): T[] {
const kopya = [...dizi];
for (let i = kopya.length - 1; i > 0; i--) {
@@ -64,47 +66,59 @@ const KANITLAR = [
];
/**
* Puan türü badge'lerinin hemen altında yaşayan satın alım funnel banner'ı.
* Rastgele sırayla yazılıp silinen samimi ve profesyonel funnel
* metinleriyle adayı sihirbaza; rapor zaten varsa kilitli listenin
* durduğu /listem paywall'ına iter.
* Tasarım: güven veren lacivert yüzey, veri kanıtları ve turuncu CTA.
* Arama modalının ılış ekranında yaşayan satın alım funnel kartı.
* Rastgele sırayla yazılıp silinen funnel metinleriyle adayı sihirbaza
* iter: /sonuc üzerindeyse oradaki orkestratörü olayla açar, başka
* sayfadaysa profile göre /sonuc'a (sihirbaz ık) yönlendirir ya da
* sıralama kapısını açar. Modal yalnızca ıkken mount olduğundan
* karıştırma her ılışta yenilenir ve hydration sorunu yaratmaz.
*/
export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
// İlk render'da bir kez karıştırılır; SSR boş metinle başladığı için
// hydration uyuşmazlığı yaratmaz.
export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
const router = useRouter();
const pathname = usePathname();
const profil = useTercihProfili();
const [metinler] = useState(() => karistir(FUNNEL_METINLERI));
const ctaSinif =
"h-12 w-full shrink-0 cursor-pointer rounded-xl bg-orange-500 px-5 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto";
function sihirbazaGit() {
onSec();
if (pathname === "/sonuc") {
window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT));
return;
}
if (profil) {
router.push(sonucHref(profil, { sihirbaz: "1" }));
return;
}
profilToplamaAc("sonuc");
}
return (
<section
aria-label="Tercih listeni kur"
className="relative mt-6 overflow-hidden rounded-3xl border border-blue-400/20 bg-[#0b1f3a] px-5 py-6 text-white shadow-[0_20px_50px_-28px_rgba(15,71,150,0.65)] sm:px-7 sm:py-7"
className="relative mt-5 overflow-hidden rounded-2xl border border-blue-400/20 bg-[#0b1f3a] px-4 py-5 text-white shadow-[0_20px_50px_-28px_rgba(15,71,150,0.65)] sm:px-5"
>
<div
aria-hidden
className="pointer-events-none absolute -right-16 -top-24 size-64 rounded-full bg-blue-500/25 blur-3xl"
className="pointer-events-none absolute -right-12 -top-16 size-44 rounded-full bg-blue-500/25 blur-3xl"
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-20 left-1/3 size-48 rounded-full bg-cyan-400/10 blur-3xl"
className="pointer-events-none absolute -bottom-14 left-1/3 size-36 rounded-full bg-cyan-400/10 blur-3xl"
/>
<div className="relative grid items-center gap-6 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-8">
<div className="relative grid items-center gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-5">
<div className="min-w-0">
<TypingAnimation
as="h2"
as="h3"
words={metinler}
loop
typeSpeed={38}
deleteSpeed={18}
pauseDelay={2600}
className="min-h-[2.5em] max-w-2xl font-heading text-2xl font-bold leading-tight tracking-[-0.02em] sm:min-h-[1.25em] sm:text-3xl"
className="min-h-[2.75em] font-heading text-base font-bold leading-snug tracking-[-0.02em] sm:min-h-[1.4em] sm:text-lg"
/>
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-2">
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2">
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
<span
key={etiket}
@@ -117,23 +131,14 @@ export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
</div>
</div>
{hedefListem ? (
<Button asChild size="lg" className={ctaSinif}>
<Link href="/listem">
Listeni
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
) : (
<Button
size="lg"
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
className={ctaSinif}
>
Listemi oluştur
<ArrowRight className="size-4" aria-hidden />
</Button>
)}
<Button
size="lg"
onClick={sihirbazaGit}
className="h-11 w-full shrink-0 cursor-pointer rounded-xl bg-orange-500 px-5 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto"
>
AI listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</div>
</section>
);

View File

@@ -1,11 +1,161 @@
import { ArrowRight } from "lucide-react";
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowRight,
ChartNoAxesCombined,
Database,
ListChecks,
Trophy,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerTitle,
} from "@/components/ui/drawer";
import { SectionEyebrow } from "@/components/pixel-decor";
import {
SihirbazAdimlarLazy,
preloadSihirbazAdimlar,
} from "@/components/sihirbaz-adimlar-lazy";
import { authClient } from "@/lib/auth-client";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import type { SihirbazFacetleri } from "@/lib/db";
import {
sonucHref,
tercihProfiliYaz,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
const PUAN_TURLERI_SECENEK = [
{ deger: "say", etiket: "Sayısal" },
{ deger: "ea", etiket: "Eşit Ağırlık" },
{ deger: "soz", etiket: "Sözel" },
{ deger: "dil", etiket: "Dil" },
{ deger: "tyt", etiket: "TYT" },
] as const;
// Kanıt üçlüsü arama funnel kartıyla aynı dili konuşur (bkz. arama-funnel-karti)
const KANITLAR = [
{ ikon: ListChecks, etiket: "24 tercihlik plan" },
{ ikon: ChartNoAxesCombined, etiket: "Risk analizi" },
{ ikon: Database, etiket: "YÖK Atlas verisi" },
] as const;
/**
* Programatik SEO sayfalarındaki funnel girişi: JS'siz düz GET formu,
* /sonuc?sira=&tur= akışına düşer. Client bileşen DEĞİL — sayfalar tamamen
* statik prerender edilir, form tarayıcının kendi submit'iyle çalışır.
* Programatik SEO sayfalarındaki funnel girişi. Statik/SSR çıktı her zaman
* profilsiz hâldir (sayfalar prerender edilmeye devam eder); hydration
* sonrası kayıtlı tercih profili bulunursa "sıralamanı gir" yerine doğrudan
* sonuç sayfasına götüren CTA gösterilir — sıralamasını girmiş adaya bir
* daha girmesini söylemeyiz.
*
* Profilsiz CTA adayı ana sayfaya göndermez: buton alttan bir çekmece açar,
* sıralama girişi ve sihirbaz adayın bulunduğu sayfanın bağlamında akar.
* Tasarım gerekçesi için bkz. CTA-CEKMECE-TASARIMI.md.
*/
export function CtaSiraForm({ baslik }: { baslik?: string }) {
const profil = useTercihProfili();
const router = useRouter();
const [acik, setAcik] = useState(false);
const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira");
const [siralama, setSiralama] = useState("");
const [tur, setTur] = useState<string>("say");
const [sira, setSira] = useState<number | null>(null);
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
const [yukleniyor, setYukleniyor] = useState(false);
const [hata, setHata] = useState<string | null>(null);
const [girisli, setGirisli] = useState(false);
// Yazarken binlik ayracıyla biçimle (85000 → 85.000)
function siralamaDegisti(ham: string) {
setHata(null);
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
}
async function siraGonder(e: React.FormEvent) {
e.preventDefault();
const value = Number(siralama.replace(/\./g, ""));
if (!value || value < 1 || value > 4_000_000) {
setHata("Geçerli bir başarı sıralaması gir (ör. 85.000).");
return;
}
setYukleniyor(true);
try {
// Oturum bilgisi facet isteğiyle PARALEL çözülür; sayfa statik kalır
// (hero formundaki desenle aynı — bkz. hero-form.tsx).
const [res, oturum] = await Promise.all([
fetch(`/api/facetler?sira=${value}&tur=${tur}`),
authClient.getSession().catch(() => null),
]);
if (!res.ok) throw new Error();
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
setGirisli(Boolean(oturum?.data?.user));
setSira(value);
setFacetler(veri.facetler);
setAdim("sihirbaz");
} catch {
setHata("Bir şeyler ters gitti, tekrar dener misin?");
} finally {
setYukleniyor(false);
}
}
// Seçimler tamam: sakla, çekmeceyi kapat ve /sonuc'a git. Girişli
// kullanıcıda orada otomatik üretim başlar; girişsiz kullanıcı önce
// ücretsiz tabloyu görür (değer önce — giriş duvarı sihirbazın hemen
// arkasında değil).
function secimleriTamamla(secimler: SihirbazSecimleri) {
if (sira == null) return;
const yeniProfil = { sira, tur, secimler };
tercihProfiliYaz(yeniProfil);
setAcik(false);
router.push(
sonucHref(yeniProfil, girisli ? { sihirbaz: "1" } : { hazir: "1" }),
);
}
function acikDegisti(v: boolean) {
setAcik(v);
if (!v) {
// Yeniden açılışta sıfırdan başla; yazılan sıralama kaybolmasın.
setAdim("sira");
setFacetler(null);
setHata(null);
}
}
if (profil) {
return (
<section className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8">
<h2 className="font-heading text-xl font-bold text-slate-900">
{baslik ?? "Sıralamanla nereye yerleşirsin? Saniyede gör."}
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Sıralaman kayıtlı ({profil.sira.toLocaleString("tr-TR")}. sıra)
gerçek YÖK Atlas verisiyle hayal, dengeli ve garanti dilimlerindeki
programların hazır.
</p>
<Link
href={sonucHref(profil)}
className="mt-4 inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
>
Sıralamana uygun programları gör
<ArrowRight className="size-4" aria-hidden />
</Link>
</section>
);
}
return (
<section className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8">
<h2 className="font-heading text-xl font-bold text-slate-900">
@@ -16,42 +166,151 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
hayal, dengeli ve garanti dilimlerindeki programları anında listele.
Ücretsiz, üyelik gerekmez.
</p>
<form
action="/sonuc"
method="get"
className="mt-4 flex flex-col gap-3 sm:flex-row"
<Button
type="button"
onClick={() => setAcik(true)}
onPointerEnter={preloadSihirbazAdimlar}
onFocus={preloadSihirbazAdimlar}
className="mt-4 h-11 cursor-pointer bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
>
<input
type="number"
name="sira"
required
min={1}
max={4000000}
inputMode="numeric"
placeholder="Başarı sıralaman (ör. 85000)"
aria-label="YKS başarı sıralaması"
className="h-11 flex-1 rounded-lg border border-slate-300 bg-white px-4 text-sm text-slate-900 placeholder:text-slate-400 focus:border-primary focus:outline-none"
/>
<select
name="tur"
aria-label="Puan türü"
defaultValue="say"
className="h-11 rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary focus:outline-none"
Sıralamanı gir, programları gör
<ArrowRight className="size-4" aria-hidden />
</Button>
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly>
<DrawerContent
data-lenis-prevent
className="max-h-[92dvh] bg-white sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
>
<option value="say">Sayısal</option>
<option value="ea">Eşit ırlık</option>
<option value="soz">Sözel</option>
<option value="dil">Dil</option>
<option value="tyt">TYT (2 yıllık)</option>
</select>
<button
type="submit"
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-6 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Programları gör
<ArrowRight className="size-4" aria-hidden />
</button>
</form>
{/* Görünür başlıklar adıma göre değişir; erişilebilir ad sabit kalır. */}
<DrawerTitle className="sr-only">
Sıralamanla nereye yerleşirsin?
</DrawerTitle>
<DrawerDescription className="sr-only">
YKS başarı sıralamanı gir; sıralamana uygun programları bu sayfadan
ayrılmadan gör.
</DrawerDescription>
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
<div
data-lenis-prevent
className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6"
>
{adim === "sira" ? (
<div className="flex flex-col gap-5">
<div>
<SectionEyebrow>Saniyede gör</SectionEyebrow>
<h2 className="mt-3 font-heading text-2xl font-bold text-slate-900">
Sıralamanla nereye yerleşirsin?
</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-600">
Puan türünü seç, başarı sıralamanı yaz; hayal, dengeli ve
garanti dilimlerindeki programları bu sayfadan ayrılmadan
gör. Ücretsiz, üyelik gerekmez.
</p>
</div>
<form onSubmit={siraGonder} className="flex flex-col gap-3">
<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>
<label htmlFor="cta-siralama" className="sr-only">
YKS başarı sıralaması
</label>
<div className="flex flex-col gap-3 sm:flex-row">
<div className="relative sm:flex-1">
<Trophy
className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-slate-400"
aria-hidden
/>
<Input
id="cta-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}
aria-describedby={
hata ? "cta-siralama-hata" : undefined
}
className="h-12 bg-background pl-11 text-base"
/>
</div>
<Button
type="submit"
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600"
>
{yukleniyor ? "Hazırlanıyor…" : "Programları gör"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</div>
{hata ? (
<p
id="cta-siralama-hata"
role="alert"
className="text-sm font-medium text-red-600"
>
{hata}
</p>
) : null}
</form>
<ul
aria-label="Neler alacaksın"
className="flex flex-wrap gap-x-4 gap-y-2 border-t border-slate-100 pt-4"
>
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
<li
key={etiket}
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-600"
>
<Ikon className="size-3.5 text-primary" aria-hidden />
{etiket}
</li>
))}
</ul>
</div>
) : facetler && sira != null ? (
<SihirbazAdimlarLazy
key={`${sira}-${tur}`}
sira={sira}
tur={tur}
facetler={facetler}
sonButonEtiketi={
girisli ? "AI listemi kur (3 kredi)" : "Ücretsiz tablonu gör"
}
onTamamla={secimleriTamamla}
/>
) : null}
</div>
</DrawerContent>
</Drawer>
</section>
);
}

View File

@@ -5,17 +5,14 @@
// ayrıca korunur). Amaç: satın alma / giriş / kredi durumlarını tek
// tıkla taklit edip uygulamanın tüm sayfalarını gerçek akışta gezmek.
//
// Hareket dili (Apple "Designing Fluid Interfaces"): panel DEV çipinden
// büyür ve yine ona küçülür (paylaşılan layoutId ile mekânsal bağ), yay
// kritik sönümlüdür (bounce 0) — açılış ortasında kapatılırsa mevcut
// değerden geri döner. Yüzey opak kutu değil, yarı saydam malzemedir.
// Yüzey opak kutu değil, yarı saydam malzemedir.
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Wrench, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { tercihProfiliSil } from "@/lib/sihirbaz";
import {
devAnindaGiris,
devAyarla,
@@ -27,6 +24,7 @@ import {
const SAYFALAR: { ad: string; href: string }[] = [
{ ad: "Ana sayfa", href: "/" },
{ ad: "Sonuç (sırasız)", href: "/sonuc" },
{ ad: "Sonuç (85k · SAY)", href: "/sonuc?sira=85000&tur=say&sihirbaz=1" },
{ ad: "Sonuç (girişsiz hazır)", href: "/sonuc?sira=85000&tur=say&hazir=1" },
{ ad: "Listem", href: "/listem" },
@@ -34,6 +32,13 @@ const SAYFALAR: { ad: string; href: string }[] = [
{ ad: "Paket", href: "/paket" },
{ ad: "Paket (ödeme hatası)", href: "/paket?hata=token" },
{ ad: "PDF / yazdır", href: "/rapor/yazdir" },
{ ad: "Bölümler", href: "/bolumler" },
{ ad: "Bölüm (bilg. müh.)", href: "/bolum/bilgisayar-muhendisligi" },
{ ad: "Üniversiteler", href: "/universiteler" },
{ ad: "Üniversite (Boğaziçi)", href: "/universite/bogazici-universitesi" },
{ ad: "Rehber", href: "/rehber" },
{ ad: "Rehber (devlet/vakıf)", href: "/rehber/devlet-mi-vakif-mi" },
{ ad: "Meraklısına", href: "/meraklisina" },
{ ad: "Giriş", href: "/giris" },
{ ad: "Gizlilik", href: "/gizlilik" },
{ ad: "Koşullar", href: "/kosullar" },
@@ -41,10 +46,6 @@ const SAYFALAR: { ad: string; href: string }[] = [
{ ad: "404", href: "/boyle-bir-sayfa-yok" },
];
// Apple değerleri: damping 1.0 (bounce 0), response ~0.35 sn.
// Yay süreli değildir; hedef değişirse mevcut değer + hızdan devam eder.
const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const;
// Basış anında tepki (pointer-down feedback) — bırakınca değil.
const BASIS =
"active:scale-[0.97] transition-[transform,background-color,border-color,opacity] duration-150 ease-out";
@@ -60,7 +61,6 @@ export function DevPanel() {
const [email, setEmail] = useState("test@kolaytercih.dev");
const [mesgul, setMesgul] = useState(false);
const router = useRouter();
const azMotion = useReducedMotion();
const yenile = useCallback(async () => {
try {
@@ -131,50 +131,27 @@ export function DevPanel() {
const g = durum?.girisli ? durum : null;
// Azaltılmış hareket: morph yerine kısa çapraz solma (yaysız, kaymasız).
const paylasilan = azMotion ? {} : { layoutId: "dev-panel" };
const gecis = azMotion ? { duration: 0.15 } : YAY;
return (
<AnimatePresence initial={false}>
<>
{!acik ? (
<motion.button
key="cip"
{...paylasilan}
<button
type="button"
onClick={paneliAc}
title="Dev süperadmin paneli"
style={{ borderRadius: 18 }}
transition={gecis}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15, ease: "easeOut" } }}
className={`fixed bottom-20 left-4 z-50 inline-flex h-9 cursor-pointer items-center gap-1.5 px-3 text-xs font-bold tracking-wide text-white ${MALZEME} !bg-slate-900/85 print:hidden ${BASIS} hover:!bg-slate-800/85`}
>
<Wrench className="size-3.5 text-violet-300" aria-hidden />
DEV
</motion.button>
</button>
) : (
<motion.div
key="panel"
{...paylasilan}
<div
role="dialog"
aria-label="Dev süperadmin paneli"
style={{ borderRadius: 24, transformOrigin: "bottom left" }}
transition={gecis}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15, ease: "easeOut" } }}
style={{ borderRadius: 24 }}
className={`fixed bottom-20 left-4 z-50 flex max-h-[72vh] w-80 flex-col overflow-hidden ${MALZEME} print:hidden`}
>
{/* İçerik morph sırasında bozulmasın: yüzey oturduktan hemen
sonra belirir (malzeme önce, içerik sonra). */}
<motion.div
initial={azMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: azMotion ? 0 : 0.12 }}
className="flex min-h-0 flex-col"
>
<div className="flex min-h-0 flex-col">
{/* Başlık: opak şerit değil, aynı malzemenin üstünde ince
bir kenar çizgisiyle ayrışan katman. */}
<div className="flex items-center justify-between border-b border-slate-900/5 px-4 py-3">
@@ -319,6 +296,24 @@ export function DevPanel() {
gider
</span>
</button>
{/* Sıra + sihirbaz profili localStorage'ta yaşar; girişten
bağımsızdır — bu yüzden !g ile kilitlenmez. */}
<button
type="button"
disabled={mesgul}
onClick={() => {
tercihProfiliSil();
toast.success("Sıralama ve tercih profili temizlendi.");
router.refresh();
}}
className={`mt-1.5 w-full cursor-pointer rounded-xl border border-rose-600/15 bg-rose-50/80 px-2.5 py-2 text-left text-xs font-semibold text-rose-800 hover:bg-rose-100/80 disabled:cursor-default disabled:opacity-40 ${BASIS}`}
>
Sıralamayı temizle
<span className="block text-[10px] font-normal text-rose-700">
localStorage&apos;taki sıra + sihirbaz profilini siler
(navbar rozeti dahil)
</span>
</button>
</section>
{/* Sayfalar */}
@@ -339,9 +334,9 @@ export function DevPanel() {
</div>
</section>
</div>
</motion.div>
</motion.div>
</div>
</div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -2,7 +2,8 @@
import { usePathname } from "next/navigation";
// Header'ın gizleneceği rotalar (tam eşleşme veya alt yol)
// Site kromunun (banner, header, üst/alt blur) gizleneceği rotalar
// (tam eşleşme veya alt yol)
const HIDDEN_PREFIXES = ["/giris"];
export function HeaderGate({ children }: { children: React.ReactNode }) {

View File

@@ -0,0 +1,33 @@
"use client";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
// Hero başlığı: sıralama girilmemişse tanıtım metni, kayıtlı profil varsa
// aynı şablonla (vurgulu italik orta parça) aksiyon metnine dönüşür.
// SSR/ilk boyamada profil bilinmediği için varsayılan metin render edilir;
// store yüklenince client'ta aksiyon haline geçer.
export function HeroBaslik() {
const profil = useTercihProfili();
return (
<h1 className="max-w-3xl font-heading text-5xl font-bold tracking-[0.015em] sm:text-6xl">
{profil ? (
<>
Sıralaman kayıtlı, <br className="hidden sm:block" />
<span className="font-bricolage italic tracking-tighter font-bold text-primary">
tercih listen
</span>{" "}
seni bekliyor
</>
) : (
<>
Dört yıllık geleceğini <br className="hidden sm:block" />
<span className="font-bricolage italic tracking-tighter font-bold text-primary">
48 saatlik panikle
</span>{" "}
seçme
</>
)}
</h1>
);
}

View File

@@ -1,6 +1,10 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { sonucHref } from "@/lib/sihirbaz";
export function HeroFocusButton() {
function handleClick() {
@@ -24,3 +28,43 @@ export function HeroFocusButton() {
</Button>
);
}
/**
* Ana sayfanın kapanış CTA'sı: kayıtlı tercih profili olan adaya
* "sıralamanı gir" demek yerine doğrudan sonuç sayfasına götürür.
* Profil yoksa mevcut davranış — hero'daki sıralama formuna odaklanır.
*/
export function KapanisCta() {
const profil = useTercihProfili();
if (profil) {
return (
<>
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
Sıralaman kayıtlı yapay zekâ danışmanın veriyle konuşsun. Tercih
dönemi bitmeden yerini al.
</p>
<Button
asChild
size="lg"
className="mt-8 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={sonucHref(profil)}>
Üniversitelere göz at
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</>
);
}
return (
<>
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun. Tercih dönemi
bitmeden yerini al.
</p>
<HeroFocusButton />
</>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -16,8 +17,17 @@ import {
preloadSihirbazAdimlar,
} from "@/components/sihirbaz-adimlar-lazy";
import { authClient } from "@/lib/auth-client";
import {
profilDuzenlemeyiAc,
useTercihProfili,
} from "@/components/manuel-liste/profil-kapisi-store";
import type { SihirbazFacetleri } from "@/lib/db";
import { tercihProfiliYaz, type SihirbazSecimleri } from "@/lib/sihirbaz";
import {
PUAN_TURU_ETIKET,
sonucHref,
tercihProfiliYaz,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
const PUAN_TURLERI_SECENEK = [
@@ -37,15 +47,16 @@ export function HeroForm() {
const [modalAcik, setModalAcik] = useState(false);
const [hata, setHata] = useState<string | null>(null);
const [girisli, setGirisli] = useState(false);
// Daha önce sıralama girmiş ziyaretçi formu yeniden doldurmasın:
// kayıtlı profil varsa doğrudan /sonuc CTA'sı gösterilir.
const kayitliProfil = useTercihProfili();
const router = useRouter();
// Yazarken binlik ayracıyla biçimle (85000 → 85.000)
function siralamaDegisti(ham: string) {
setHata(null);
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
setSiralama(
rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "",
);
setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
}
async function handleSubmit(e: React.FormEvent) {
@@ -82,12 +93,43 @@ export function HeroForm() {
// oradan gelir (değer önce — giriş duvarı sihirbazın hemen arkasında değil).
function secimleriTamamla(secimler: SihirbazSecimleri) {
if (!sira) return;
tercihProfiliYaz({ sira, tur, secimler });
const profil = { sira, tur, secimler };
tercihProfiliYaz(profil);
setModalAcik(false);
router.push(
girisli
? `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`
: `/sonuc?sira=${sira}&tur=${tur}&hazir=1`,
sonucHref(profil, girisli ? { sihirbaz: "1" } : { hazir: "1" }),
);
}
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
// "Farklı sıralama gir" navbar'daki Değiştir ile aynı düzenleme modalını
// (TercihProfiliKapisi, layout'ta monte) açar.
if (kayitliProfil) {
return (
<div className="flex w-full max-w-md flex-col items-center gap-3">
<Button
asChild
size="lg"
className="h-12 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
>
<Link href={sonucHref(kayitliProfil)}>
Üniversitelere göz at
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-sm text-slate-600">
{kayitliProfil.sira.toLocaleString("tr-TR")} ·{" "}
{PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur} sıralamanla
devam edersin.{" "}
<button
type="button"
onClick={profilDuzenlemeyiAc}
className="cursor-pointer font-medium text-slate-900 underline underline-offset-2 hover:text-slate-700"
>
Farklı sıralama gir
</button>
</p>
</div>
);
}
@@ -144,7 +186,7 @@ export function HeroForm() {
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{yukleniyor ? "Hazırlanıyor…" : "Listemi oluştur"}
{yukleniyor ? "Hazırlanıyor…" : "AI listemi kur"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>
@@ -172,7 +214,7 @@ export function HeroForm() {
tur={tur}
facetler={facetler}
sonButonEtiketi={
girisli ? "Listemi oluştur (3 kredi)" : "Ücretsiz tablonu gör"
girisli ? "AI listemi kur (3 kredi)" : "Ücretsiz tablonu gör"
}
onTamamla={secimleriTamamla}
/>

View File

@@ -82,14 +82,11 @@ export function IlSecimHaritasi({
</svg>
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-slate-500">
<span>az</span>
<span
aria-hidden
className="h-2 w-24 rounded-full"
style={{
background:
"linear-gradient(to right, oklch(0.623 0.188 259.8 / 0.25), oklch(0.623 0.188 259.8 / 0.9))",
}}
/>
<span aria-hidden className="flex items-center gap-1">
<span className="size-2.5 rounded-full bg-primary/25" />
<span className="size-2.5 rounded-full bg-primary/55" />
<span className="size-2.5 rounded-full bg-primary/90" />
</span>
<span>çok program</span>
<span className="ml-3 inline-flex items-center gap-1.5">
<span

View File

@@ -2,13 +2,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
import { RehberKapak } from "@/components/rehber-kapak";
import { UniLogo } from "@/components/uni-logo";
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
import type { RehberOzet } from "@/lib/rehber";
import { PUAN_TURU_ETIKET } from "@/lib/sihirbaz";
import {
profilDuzenlemeyiAc,
sonucaGitIste,
useTercihProfili,
} from "@/components/manuel-liste/profil-kapisi-store";
import {
@@ -56,9 +58,36 @@ const BOS_SONUCLAR: AramaSonuclari = { bolumler: [], universiteler: [] };
const REHBER_SONUC_SINIRI = 4;
const sayi = (deger: number) => deger.toLocaleString("tr-TR");
const KESFET_TONLARI = {
slate: {
pencere: "border-slate-200/80 bg-white",
nokta: "bg-slate-300",
rozet: "border-slate-200 bg-slate-50 text-slate-600",
cizgi: "bg-slate-200/90",
},
sky: {
pencere: "border-sky-200/70 bg-white",
nokta: "bg-sky-300",
rozet: "border-sky-200 bg-sky-50 text-sky-600",
cizgi: "bg-sky-100",
},
amber: {
pencere: "border-amber-200/70 bg-white",
nokta: "bg-amber-300",
rozet: "border-amber-200 bg-amber-50 text-amber-600",
cizgi: "bg-amber-100",
},
} as const;
export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
const router = useRouter();
const profil = useTercihProfili();
const [acik, setAcik] = useState(false);
function siralamaGirVeyaSonucaGit() {
const { href } = sonucaGitIste();
if (href) router.push(href);
}
const [sorgu, setSorgu] = useState("");
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
const [yukleniyor, setYukleniyor] = useState(false);
@@ -162,11 +191,18 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
return () => gozlemci.disconnect();
}, [acik, aramaAktif, yukleniyor, sonucVar, kenarBlurGuncelle]);
// Temizlik kapanışta değil açılışta yapılır: kapanış animasyonu sırasında
// içerik "Keşfet" ekranına zıplamaz, modal her açılışta temiz başlar.
// (X / sonuç linki `kapat` ile kapatır; orada temizlik yapılmadığı için
// eski sorgu, bayat sonuçlar ve yarıda kesilen fetch'in takılı kalan
// yükleniyor iskeleti bir sonraki açılışa taşınıyordu.)
function modalDegisti(yeniAcik: boolean) {
setAcik(yeniAcik);
if (!yeniAcik) {
if (yeniAcik) {
setSorgu("");
setSonuclar(BOS_SONUCLAR);
setYukleniyor(false);
setKenarBlur({ ust: false, alt: false });
}
}
@@ -182,42 +218,42 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
<button
type="button"
onClick={profilDuzenlemeyiAc}
aria-label={`Sıralaman ${profil.sira.toLocaleString("tr-TR")} (${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}) — tercihlerini düzenle`}
className="group/profil flex cursor-pointer items-center gap-2.5 rounded-full py-1.5 pl-4 pr-1.5 transition-colors duration-200 hover:bg-slate-100"
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")})`}
className="group/profil flex cursor-pointer items-center gap-2 rounded-full py-1.5 pl-4 pr-1.5 transition-colors duration-150 hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none"
>
<Trophy className="size-4 text-orange-500" aria-hidden />
<span className="flex items-baseline gap-1.5">
<span className="font-semibold tabular-nums tracking-tight text-slate-900">
{profil.sira.toLocaleString("tr-TR")}
</span>
<span className="text-xs font-medium text-slate-400">
{PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
</span>
<Trophy className="size-4 shrink-0 text-orange-500" aria-hidden />
<span className="font-semibold tabular-nums tracking-tight text-slate-900">
{profil.sira.toLocaleString("tr-TR")}
</span>
<span className="flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-1.5 text-xs font-medium text-slate-500 transition-colors duration-200 group-hover/profil:bg-white group-hover/profil:text-slate-900">
<SquarePen className="size-3" aria-hidden />
Düzenle
<span
aria-hidden
className="flex max-w-0 items-center gap-1 overflow-hidden rounded-full bg-slate-100 px-0 py-1.5 text-xs font-medium text-slate-500 opacity-0 transition-[max-width,padding,opacity,background-color,color] duration-200 group-hover/profil:max-w-28 group-hover/profil:bg-white group-hover/profil:px-2.5 group-hover/profil:text-slate-900 group-hover/profil:opacity-100 group-focus-visible/profil:max-w-28 group-focus-visible/profil:bg-white group-focus-visible/profil:px-2.5 group-focus-visible/profil:text-slate-900 group-focus-visible/profil:opacity-100"
>
<SquarePen className="size-3 shrink-0" aria-hidden />
Değiştir
</span>
</button>
) : (
<>
<button
type="button"
onClick={siralamaGirVeyaSonucaGit}
className="flex cursor-pointer items-center gap-2 rounded-full px-4 py-1.5 text-slate-700 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
<Trophy className="size-4 text-orange-500" aria-hidden />
Sıralamanı gir
</button>
<Link
href="/#nasil-calisir"
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
Nasıl çalışır?
</Link>
<Link
href="/#karsilastirma"
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
Karşılaştır
</Link>
</>
)}
<button
type="button"
onClick={() => setAcik(true)}
onClick={() => modalDegisti(true)}
className="flex cursor-pointer items-center gap-2 rounded-full bg-slate-900 px-5 py-2 text-white transition-[background-color,transform] duration-150 hover:bg-slate-700 active:scale-[0.97]"
>
<Search className="size-4" aria-hidden />
@@ -231,14 +267,50 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
</button>
</nav>
<button
type="button"
onClick={() => setAcik(true)}
aria-label="Bölüm, üniversite veya rehber ara"
className="ml-auto flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:size-12 lg:hidden"
>
<Search className="size-4" aria-hidden />
</button>
{/* lg altında ortadaki nav gizli; sıralama + ara burada yaşar */}
<div className="ml-auto flex items-center gap-1.5 lg:hidden">
{profil ? (
<button
type="button"
onClick={profilDuzenlemeyiAc}
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")})`}
className="flex h-9 max-w-40 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-1.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:max-w-none sm:gap-2 sm:pl-3.5 sm:pr-2 sm:text-sm"
>
<Trophy
className="size-3.5 shrink-0 text-orange-500 sm:size-4"
aria-hidden
/>
<span className="truncate tabular-nums tracking-tight">
{profil.sira.toLocaleString("tr-TR")}
</span>
<span className="inline-flex items-center gap-0.5 rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 sm:text-xs">
<SquarePen className="size-2.5 sm:size-3" aria-hidden />
Değiştir
</span>
</button>
) : (
<button
type="button"
onClick={siralamaGirVeyaSonucaGit}
aria-label="Başarı sıralamanı gir"
className="flex h-9 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-2.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-3 sm:text-sm"
>
<Trophy
className="size-3.5 shrink-0 text-orange-500 sm:size-4"
aria-hidden
/>
<span className="truncate">Sıralama gir</span>
</button>
)}
<button
type="button"
onClick={() => modalDegisti(true)}
aria-label="Bölüm, üniversite veya rehber ara"
className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:size-12"
>
<Search className="size-4" aria-hidden />
</button>
</div>
<Dialog open={acik} onOpenChange={modalDegisti}>
<DialogContent
@@ -423,71 +495,127 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
function BaslangicIcerigi({ onSec }: { onSec: () => void }) {
return (
<div>
<p className="px-2 pb-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
<p className="px-1 pb-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Keşfet
</p>
<div className="grid gap-2 sm:grid-cols-3">
<div className="grid grid-cols-3 gap-2">
<KesfetLinki
href="/bolumler"
ikon={<GraduationCap className="size-5" aria-hidden />}
ikon={GraduationCap}
baslik="Tüm bölümler"
aciklama="Lisans ve önlisans"
kisaBaslik="Bölümler"
aciklama="Lisans · önlisans"
ton="slate"
onSec={onSec}
/>
<KesfetLinki
href="/universiteler"
ikon={<Building2 className="size-5" aria-hidden />}
ikon={Building2}
baslik="Üniversiteler"
aciklama="Devlet ve vakıf"
kisaBaslik="Üniversite"
aciklama="Devlet · vakıf"
ton="sky"
onSec={onSec}
/>
<KesfetLinki
href="/rehber"
ikon={<BookOpenText className="size-5" aria-hidden />}
ikon={BookOpenText}
baslik="Tercih rehberi"
aciklama="Sorularına yanıtlar"
kisaBaslik="Rehber"
aciklama="Sorularına yanıt"
ton="amber"
onSec={onSec}
/>
</div>
<div className="mt-6 rounded-xl border border-orange-100 bg-orange-50/70 p-4">
<p className="text-sm font-medium text-slate-900">
Neyi merak ediyorsun?
</p>
<p className="mt-1 text-sm leading-6 text-slate-600">
Psikoloji veya Boğaziçi gibi bir ad yaz; eşleşen sayfaları ve
temel bilgilerini burada önizle.
</p>
<div className="mt-5 flex gap-3 rounded-2xl bg-slate-50 px-3.5 py-3.5 sm:px-4">
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-white text-slate-400 shadow-[0_0_0_1px_rgba(15,23,42,0.06)]">
<Search className="size-3.5" aria-hidden />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold tracking-tight text-slate-900">
Neyi merak ediyorsun?
</p>
<p className="mt-0.5 text-xs leading-5 text-slate-500 sm:text-sm sm:leading-6">
Psikoloji veya Boğaziçi yaz; eşleşen sayfaları ve temel
bilgilerini burada önizle.
</p>
</div>
</div>
<AramaFunnelKarti onSec={onSec} />
</div>
);
}
function KesfetLinki({
href,
ikon,
ikon: Ikon,
baslik,
kisaBaslik,
aciklama,
ton,
onSec,
}: {
href: string;
ikon: React.ReactNode;
ikon: typeof GraduationCap;
baslik: string;
kisaBaslik: string;
aciklama: string;
ton: keyof typeof KESFET_TONLARI;
onSec: () => void;
}) {
const palet = KESFET_TONLARI[ton];
return (
<Link
href={href}
onClick={onSec}
className="group rounded-xl border border-slate-200 bg-white p-4 transition-[border-color,background-color,transform] duration-150 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.98]"
className="group/kesfet flex cursor-pointer flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white transition-transform duration-150 ease-out active:scale-[0.97]"
>
<span className="flex size-9 items-center justify-center rounded-lg bg-slate-100 text-slate-600 transition-colors group-hover:bg-white">
{ikon}
{/* Mini tarayıcı — sekme içinde tek büyük ikon, alta doğru maske ile solar */}
<div
aria-hidden
className="relative h-18 overflow-hidden bg-slate-50/80 sm:h-22"
>
<div
className={`absolute inset-x-2 -bottom-3 top-2 flex flex-col overflow-hidden rounded-t-xl border border-b-0 sm:inset-x-2.5 sm:top-2.5 ${palet.pencere}`}
>
<div className="flex h-3.5 shrink-0 items-center gap-1 border-b border-slate-100/90 bg-slate-50/90 px-2">
<span className={`size-1.5 rounded-full ${palet.nokta}`} />
<span className={`size-1.5 rounded-full ${palet.nokta} opacity-65`} />
<span className={`size-1.5 rounded-full ${palet.nokta} opacity-35`} />
<span className="ml-1.5 h-1.5 max-w-[55%] flex-1 rounded-full bg-white shadow-[0_0_0_1px_rgba(15,23,42,0.06)]" />
</div>
<div className="relative min-h-0 flex-1 overflow-hidden">
<div className="absolute inset-x-0 top-0 flex flex-col items-center gap-1.5 pt-2 sm:gap-2 sm:pt-2.5">
<span
className={`flex size-9 items-center justify-center rounded-xl border shadow-[0_6px_12px_-6px_rgba(15,23,42,0.18)] transition-transform duration-200 ease-out group-hover/kesfet:-translate-y-0.5 sm:size-10 ${palet.rozet}`}
>
<Ikon className="size-4.5 sm:size-5" strokeWidth={1.75} />
</span>
<span className={`h-1.5 w-2/5 rounded-full ${palet.cizgi}`} />
<span className="h-1.5 w-1/4 rounded-full bg-slate-100" />
</div>
<ProgressiveBlur
position="bottom"
backgroundColor="#ffffff"
height="45%"
blurAmount="2px"
/>
</div>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-white to-transparent" />
</div>
<span className="min-w-0 px-2.5 pb-3 pt-2 text-center sm:px-3.5 sm:pb-3.5 sm:pt-2.5 sm:text-left">
<span className="block text-[12px] font-semibold leading-tight tracking-tight text-slate-900 sm:text-sm">
<span className="sm:hidden">{kisaBaslik}</span>
<span className="hidden sm:inline">{baslik}</span>
</span>
<span className="mt-0.5 block text-[10px] leading-snug text-slate-500 sm:text-xs">
{aciklama}
</span>
</span>
<span className="mt-3 block text-sm font-semibold text-slate-900">
{baslik}
</span>
<span className="mt-0.5 block text-xs text-slate-500">{aciklama}</span>
</Link>
);
}

View File

@@ -1,28 +1,36 @@
"use client";
// Manuel 24'lük listenin alt çekmecesi (bottom sheet). Layout'ta bir kez
// Manuel 24'lük listenin alt çekmecesi (bottom drawer). Layout'ta bir kez
// monte edilir; navbar'daki Seçtiklerim butonu açar/kapatır.
//
// Motion dili (apple-design / emil-design-eng):
// - Sheet alttan spring ile yükselir; çıkış girişten hızlı, iOS drawer eğrisi.
// - Üstteki tutamak çubuğundan aşağı çekilince kapanır (offset veya hız eşiği).
// - Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
// sağa kaydırmak silme jesti — eşik aşılırsa satır uçar, altından kırmızı
// sil bandı görünür.
// - Silme/temizlemede kart yüksekliği ve kalan satırlar layout projeksiyonuyla
// yumuşakça oturur ("lap diye" kısalma yok). Projeksiyon, sheet'in giriş
// spring'i BİTTİKTEN sonra açılır (oturdu state'i): transform'lu parent
// içinde ölçüm alan satırlar o offset'i kalıcı transform olarak taşıyordu.
// - prefers-reduced-motion: sheet cross-fade; silme için X butonu her
// durumda kalır.
// Vaul drawer: tutamaktan aşağı swipe ile kapanır (handleOnly — satır
// sürükleme/silme jestleriyle çakışmasın diye). Escape ve overlay tıklaması
// da kapatır.
//
// Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
// sağa kaydırmak silme jesti. prefers-reduced-motion: satır animasyonları
// sadeleşir; X butonu her durumda kalır.
import { useEffect, useState } from "react";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { PixelField } from "@/components/pixel-decor";
import { UniLogo } from "@/components/uni-logo";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import {
ArrowRight,
GripVertical,
ListChecks,
Sparkles,
Plus,
Trash2,
X,
} from "lucide-react";
@@ -32,13 +40,15 @@ import {
Reorder,
useDragControls,
useMotionValue,
useReducedMotion,
useTransform,
type PanInfo,
} from "motion/react";
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
import { sonucaGitIste } from "./profil-kapisi-store";
import {
MANUEL_LISTE_MAX,
cekmeceAc,
cekmeceKapat,
cikar,
temizle,
@@ -48,6 +58,18 @@ import {
type ManuelTercih,
} from "./store";
// Slug kuralları katalog.ts'tekiyle aynı (bolumBazAdi/uniAdiNormalize +
// turkishSlugify); katalog haritaları DB'deki tüm isim/universite
// değerlerinden kurulduğu için bu linkler var olan sayfalara çıkar. Harita
// verisini client bundle'a çekmemek için burada tekrarlanır (bkz.
// program-tablosu'ndaki uniSayfaSlug).
function bolumHref(isim: string): string {
return `/bolum/${turkishSlugify(bolumBazAdi(isim))}`;
}
function uniHref(ad: string): string {
return `/universite/${turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim())}`;
}
// Risk renk dili ürünün geri kalanıyla birebir aynı (bkz. rapor-listesi)
const RISK_NOKTA: Record<RiskSeviyesi, string> = {
guvenli: "bg-emerald-500",
@@ -56,92 +78,47 @@ const RISK_NOKTA: Record<RiskSeviyesi, string> = {
};
export function ListeCekmecesi() {
const router = useRouter();
const acik = useCekmeceAcik();
return <AnimatePresence>{acik ? <CekmeceSheet /> : null}</AnimatePresence>;
}
function CekmeceSheet() {
const liste = useManuelListe();
const azMotion = useReducedMotion();
const sheetKontrol = useDragControls();
// Giriş spring'i bitmeden satırların layout projeksiyonu kapalı kalır —
// aksi halde transform'lu sheet içinde alınan ölçüm satırları kaydırıyor
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu
// drawer içinde erken ölçüm satırları kaydırıyordu
const [oturdu, setOturdu] = useState(false);
// Escape ile kapat — çekmece modal değil, akışı bölmeden yaşar
useEffect(() => {
const dinle = (e: KeyboardEvent) => {
if (e.key === "Escape") cekmeceKapat();
};
window.addEventListener("keydown", dinle);
return () => window.removeEventListener("keydown", dinle);
}, []);
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
const surukleyinceKapat = (_: unknown, info: PanInfo) => {
if (info.offset.y > 80 || info.velocity.y > 500) cekmeceKapat();
};
function programaEkleGit() {
cekmeceKapat();
const { href } = sonucaGitIste();
if (href) router.push(href);
}
return (
<motion.aside
initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }}
animate={azMotion ? { opacity: 1 } : { y: 0 }}
exit={
azMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
y: "calc(100% + 16px)",
transition: {
duration: 0.25,
ease: [0.32, 0.72, 0, 1],
},
}
}
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
onAnimationComplete={() => setOturdu(true)}
drag={azMotion ? false : "y"}
dragListener={false}
dragControls={sheetKontrol}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={surukleyinceKapat}
aria-label="Manuel tercih listem"
className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3"
<Drawer
open={acik}
onOpenChange={(v) => {
if (v) cekmeceAc();
else {
setOturdu(false);
cekmeceKapat();
}
}}
handleOnly
onAnimationEnd={(open) => setOturdu(open)}
>
{/* layout: satır silinince/eklenince kart yüksekliği spring ile oturur.
Doğrudan çocuklar layout="position" taşır ki ölçekleme sırasında
içerik yamulmasın (scale-correction zinciri). */}
<motion.div
layout={oturdu}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className="flex min-h-0 w-full max-w-lg flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl"
<DrawerContent
data-lenis-prevent
className="min-h-0 max-h-[92dvh] bg-white sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
>
{/* Tutamak: buradan aşağı çekilince sheet kapanır */}
<motion.div
layout="position"
onPointerDown={(e) => {
if (!azMotion) sheetKontrol.start(e);
}}
className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing"
>
<div className="h-1 w-10 rounded-full bg-slate-300" aria-hidden />
</motion.div>
{/* Başlık + doluluk */}
<motion.div
layout="position"
className="border-b border-slate-100 px-5 pb-4 pt-1"
>
<DrawerHeader className="border-b border-slate-100 text-left md:text-left">
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
<DrawerTitle className="flex items-center gap-2 font-heading text-lg font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Kendi Listem
<span className="font-heading text-sm font-bold tabular-nums text-slate-400">
{liste.length}/{MANUEL_LISTE_MAX}
</span>
</h2>
</DrawerTitle>
<div className="flex items-center gap-1">
{liste.length > 0 ? (
<button
@@ -154,14 +131,10 @@ function CekmeceSheet() {
<span className="sr-only">Listeyi temizle</span>
</button>
) : null}
<button
type="button"
onClick={cekmeceKapat}
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<DrawerClose className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</button>
</DrawerClose>
</div>
</div>
<div
@@ -178,10 +151,6 @@ function CekmeceSheet() {
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
/>
</div>
<p className="mt-2 text-xs text-slate-500">
Bölüm, üniversite veya sonuç tablolarından eklediğin programlar
tutamaktan sürükleyip sırala, sağa kaydırıp sil.
</p>
<ul
className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500"
aria-label="Yerleşme riski renkleri"
@@ -202,72 +171,93 @@ function CekmeceSheet() {
</li>
))}
</ul>
</motion.div>
</DrawerHeader>
{/* Gövde: sürüklenebilir satırlar */}
{liste.length === 0 ? (
<motion.div
layout="position"
className="flex flex-col items-center justify-center gap-2 px-8 py-12 text-center"
>
<ListChecks className="size-8 text-slate-300" aria-hidden />
<p className="font-heading text-sm font-bold text-slate-700">
Henüz program seçmedin
</p>
<p className="text-xs leading-relaxed text-slate-500">
Bölüm, üniversite veya sonuç sayfalarındaki tablolardan{" "}
<span aria-hidden></span> ile program ekle; beğendiklerin burada
birikir.
</p>
</motion.div>
) : (
<Reorder.Group
axis="y"
values={liste}
onReorder={yenidenSirala}
layout="position"
layoutScroll
as="ol"
data-lenis-prevent
className="min-h-0 flex-1 space-y-2 overflow-x-hidden overflow-y-auto px-4 py-4"
>
<AnimatePresence initial={false}>
{liste.map((t, i) => (
<CekmeceSatiri
key={t.id}
tercih={t}
sira={i + 1}
projeksiyonAcik={oturdu}
/>
))}
</AnimatePresence>
</Reorder.Group>
)}
{/* Alt bant: karar merkezi köprüsü */}
<motion.div
layout="position"
className="flex flex-col gap-2 border-t border-slate-100 px-5 py-4"
<div
data-lenis-prevent
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-4"
>
<Link
href="/listem?sekme=secimlerim"
onClick={cekmeceKapat}
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 />
Kendi listemi 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 />
AI listeni ve danışmanı
</Link>
</motion.div>
</motion.div>
</motion.aside>
{liste.length === 0 ? (
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center">
<ListChecks
className="pointer-events-none absolute top-1/2 left-1/2 size-40 -translate-x-1/2 -translate-y-[55%] text-slate-300/70 select-none [mask-image:linear-gradient(to_bottom,black_20%,transparent_85%)] [-webkit-mask-image:linear-gradient(to_bottom,black_20%,transparent_85%)]"
strokeWidth={1.25}
aria-hidden
/>
<p className="relative font-heading text-sm font-bold text-slate-700">
Henüz program seçmedin
</p>
<p className="relative text-xs leading-relaxed text-slate-500">
Sonuç sayfasından program ekleyerek listeni doldurmaya başla.
</p>
</div>
) : (
<Reorder.Group
axis="y"
values={liste}
onReorder={yenidenSirala}
layoutScroll
as="ol"
>
<AnimatePresence initial={false}>
{liste.map((t, i) => (
<CekmeceSatiri
key={t.id}
tercih={t}
sira={i + 1}
projeksiyonAcik={oturdu}
/>
))}
</AnimatePresence>
</Reorder.Group>
)}
{liste.length < MANUEL_LISTE_MAX ? (
<button
type="button"
onClick={programaEkleGit}
className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-xl border border-dashed border-slate-300 text-slate-400 transition-colors duration-200 hover:border-primary/50 hover:bg-primary/5 hover:text-primary active:scale-[0.98] ${
liste.length > 0 ? "mt-2" : "mt-1"
}`}
>
<Plus className="size-5" aria-hidden />
<span className="sr-only">Sonuç sayfasından program ekle</span>
</button>
) : null}
</div>
<DrawerFooter className="border-t border-slate-100 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{/* Ana sayfadaki final CTA panelinin kompakt hâli: koyu primary
zemin + kenarlara doğru yoğunlaşan piksel dokusu (PixelField) */}
<div className="relative isolate flex items-center justify-between gap-4 overflow-hidden rounded-2xl bg-primary px-5 py-5 text-left text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-6 -z-10 text-white"
>
<PixelField seed={8} />
</div>
<div className="min-w-0">
<p className="font-heading text-2xl font-bold">
Risk analizini biz yaptık
</p>
<p className="mt-1 truncate text-sm leading-relaxed text-white">
Her seçimin yerleşme riski hazır son kararı sen ver.
</p>
</div>
<Button
asChild
size="lg"
className="h-11 shrink-0 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem" onClick={cekmeceKapat}>
Listemde
<ArrowRight data-icon="inline-end" />
</Link>
</Button>
</div>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
@@ -311,7 +301,7 @@ function CekmeceSatiri({
exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
whileDrag={{ scale: 1.03, zIndex: 10 }}
className="relative select-none"
className="relative mb-2 select-none last:mb-0"
>
{/* Sil bandı: kart sağa kaydıkça altından görünür */}
<motion.div
@@ -348,11 +338,24 @@ function CekmeceSatiri({
</span>
<UniLogo ad={tercih.universite} boy="xs" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">
<Link
href={bolumHref(tercih.isim)}
onClick={cekmeceKapat}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{tercih.isim}
</span>
</Link>
<span className="block truncate text-xs text-slate-500">
{tercih.universite}
<Link
href={uniHref(tercih.universite)}
onClick={cekmeceKapat}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
title={`${tercih.universite} taban puanları sayfası`}
className="hover:text-primary hover:underline"
>
{tercih.universite}
</Link>
{tercih.il ? ` · ${tercih.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</span>

View File

@@ -7,6 +7,7 @@ import { useSyncExternalStore } from "react";
import type { Program } from "@/lib/db";
import {
PROFIL_DEGISTI_EVENT,
sonucHref,
tercihProfiliOku,
tercihProfiliTamamlandiMi,
type TercihProfili,
@@ -38,10 +39,14 @@ export type BekleyenProgram = {
adayTur?: string;
};
/** Kapı tamamlanınca nereye gideceği (sıra yazıldıktan sonra href üretilir). */
export type BekleyenHedef = "sonuc";
type KapıDurumu = {
acik: boolean;
duzenle: boolean;
bekleyen: BekleyenProgram | null;
bekleyenHedef: BekleyenHedef | null;
profil: TercihProfili | null;
profilYuklendi: boolean;
};
@@ -50,6 +55,7 @@ let durum: KapıDurumu = {
acik: false,
duzenle: false,
bekleyen: null,
bekleyenHedef: null,
profil: null,
profilYuklendi: false,
};
@@ -99,6 +105,7 @@ const SSR_DURUM: KapıDurumu = {
acik: false,
duzenle: false,
bekleyen: null,
bekleyenHedef: null,
profil: null,
profilYuklendi: false,
};
@@ -119,16 +126,61 @@ export function useTercihProfili(): TercihProfili | null {
}
export function profilKapisiniKapat() {
durum = { ...durum, acik: false, duzenle: false, bekleyen: null };
durum = {
...durum,
acik: false,
duzenle: false,
bekleyen: null,
bekleyenHedef: null,
};
yayinla();
}
export function profilDuzenlemeyiAc() {
if (!durum.profilYuklendi) yukle();
durum = { ...durum, acik: true, duzenle: true, bekleyen: null };
durum = {
...durum,
acik: true,
duzenle: true,
bekleyen: null,
bekleyenHedef: null,
};
yayinla();
}
/**
* Profil yokken kapıyı açar; tamamlanınca `hedef`'e yönlendirilir.
* Navbar, çekmece +, /sonuc boş hali ve listem gibi yerler bunu kullanır.
*/
export function profilToplamaAc(hedef: BekleyenHedef | null = null) {
if (!durum.profilYuklendi) yukle();
durum = {
...durum,
acik: true,
duzenle: false,
bekleyen: null,
bekleyenHedef: hedef,
};
yayinla();
}
/**
* Sonuç sayfasına gitmek isteyen her yer: profil varsa href,
* yoksa sıralama/tercih kapısını açar (tamamlanınca /sonuc'a gider).
*/
export function sonucaGitIste(): {
href: string | null;
kapiAcildi: boolean;
} {
if (!durum.profilYuklendi) yukle();
const profil = durum.profil ?? tercihProfiliOku();
if (profil) {
return { href: sonucHref(profil), kapiAcildi: false };
}
profilToplamaAc("sonuc");
return { href: null, kapiAcildi: true };
}
export function programEkleIste(opts: {
program: BekleyenProgram["program"];
kaynak?: ManuelKaynak;
@@ -152,6 +204,7 @@ export function programEkleIste(opts: {
acik: true,
duzenle: false,
bekleyen: { program, kaynak, adaySira, adayTur },
bekleyenHedef: null,
};
yayinla();
return { eklendi: false, zatenVar: false, doldu: false, kapiAcildi: true };
@@ -174,9 +227,11 @@ export function programEkleIste(opts: {
return { eklendi: true, zatenVar: false, doldu: false, kapiAcildi: false };
}
export function profilTamamlandi(profil: TercihProfili) {
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner. */
export function profilTamamlandi(profil: TercihProfili): string | null {
riskleriYenile(profil);
const bekleyen = durum.bekleyen;
const hedef = durum.bekleyenHedef;
if (bekleyen) {
ekle(programdanManuelTercih(bekleyen.program, profil.sira, bekleyen.kaynak));
}
@@ -185,10 +240,12 @@ export function profilTamamlandi(profil: TercihProfili) {
acik: false,
duzenle: false,
bekleyen: null,
bekleyenHedef: null,
profil,
profilYuklendi: true,
};
yayinla();
return hedef === "sonuc" ? sonucHref(profil) : null;
}
export function useListeDolu(): boolean {

View File

@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { UniLogo } from "@/components/uni-logo";
import {
AlertTriangle,
@@ -12,6 +13,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
import { sonucHref } from "@/lib/sihirbaz";
import {
tercihDegerlendir,
tercihListesiOzeti,
@@ -24,6 +26,7 @@ import {
} from "./store";
import {
profilDuzenlemeyiAc,
sonucaGitIste,
useTercihProfili,
} from "./profil-kapisi-store";
@@ -34,6 +37,7 @@ const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
};
export function SecimlerimPaneli() {
const router = useRouter();
const liste = useManuelListe();
const profil = useTercihProfili();
@@ -49,6 +53,11 @@ export function SecimlerimPaneli() {
)
: null;
function sonucaGit() {
const { href } = sonucaGitIste();
if (href) router.push(href);
}
if (liste.length === 0) {
return (
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center">
@@ -65,9 +74,22 @@ export function SecimlerimPaneli() {
<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>
{profil ? (
<Button
asChild
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Link href={sonucHref(profil)}>Sonuçlara git</Link>
</Button>
) : (
<Button
type="button"
onClick={sonucaGit}
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
Sıralamanı gir
</Button>
)}
</div>
</section>
);
@@ -102,7 +124,16 @@ export function SecimlerimPaneli() {
<PencilLine className="size-4" aria-hidden />
Tercihlerimi düzenle
</Button>
) : null}
) : (
<Button
type="button"
size="sm"
onClick={sonucaGit}
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
Sıralamanı gir
</Button>
)}
</div>
<div

View File

@@ -1,8 +1,12 @@
"use client";
import { useState } from "react";
import { ArrowRight, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { ArrowRight, Trophy } from "lucide-react";
import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
import { toast } from "sonner";
import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -11,6 +15,13 @@ import {
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
SihirbazAdimlarLazy,
preloadSihirbazAdimlar,
@@ -38,6 +49,8 @@ const PUAN_TURLERI_SECENEK = [
type Adim = "giris" | "sira" | "sihirbaz";
const ADIM_SIRASI: Record<Adim, number> = { giris: 0, sira: 1, sihirbaz: 2 };
export function TercihProfiliKapisi() {
const { acik, duzenle, bekleyen, profil } = useProfilKapisi();
const oturumAnahtari = acik
@@ -84,10 +97,17 @@ function KapıIcerik({
bekleyen: BekleyenProgram | null;
profil: TercihProfili | null;
}) {
const router = useRouter();
const bilinenSira = bekleyen?.adaySira ?? profil?.sira ?? null;
const bilinenTur = bekleyen?.adayTur ?? profil?.tur ?? "say";
const [adim, setAdim] = useState<Adim>("giris");
// Düzenlemede doğrudan sıralama adımı — yeni sıra + tercihler aynı oturumda
const [adim, setAdim] = useState<Adim>(duzenle ? "sira" : "giris");
// Adım geçişleri sihirbazın kendi içindeki TransitionPanel kaymasıyla oynar
const [yon, setYon] = useState(1);
const [gecisBasladi, setGecisBasladi] = useState(false);
const [panelRef, panelOlculeri] = useMeasure();
const hareketiAzalt = useReducedMotion();
const [siralama, setSiralama] = useState(
bilinenSira != null ? bilinenSira.toLocaleString("tr-TR") : "",
);
@@ -103,6 +123,12 @@ function KapıIcerik({
setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
}
function adimaGec(yeniAdim: Adim) {
setYon(ADIM_SIRASI[yeniAdim] > ADIM_SIRASI[adim] ? 1 : -1);
setGecisBasladi(true);
setAdim(yeniAdim);
}
async function facetYukle(siraDeger: number, turDeger: string) {
setYukleniyor(true);
setHata(null);
@@ -115,7 +141,7 @@ function KapıIcerik({
setSira(siraDeger);
setTur(turDeger);
setFacetler(veri.facetler);
setAdim("sihirbaz");
adimaGec("sihirbaz");
} catch {
setHata("Bir şeyler ters gitti, tekrar dener misin?");
} finally {
@@ -128,7 +154,7 @@ function KapıIcerik({
await facetYukle(sira, tur);
return;
}
setAdim("sira");
adimaGec("sira");
preloadSihirbazAdimlar();
}
@@ -147,24 +173,65 @@ function KapıIcerik({
const yeniProfil: TercihProfili = { sira, tur, secimler };
tercihProfiliYaz(yeniProfil);
const bekleyenVar = Boolean(bekleyen);
profilTamamlandi(yeniProfil);
if (duzenle) {
toast.success(
"Tercihlerin güncellendi. Listendeki programları yeni seçimlerine göre yeniden değerlendirdik; hiçbir programı silmedik.",
);
} else if (!bekleyenVar) {
const yonlendir = profilTamamlandi(yeniProfil);
if (!duzenle && !bekleyenVar) {
toast.success("Tercihlerin kaydedildi.");
}
if (yonlendir) router.push(yonlendir);
}
if (adim === "giris") {
return (
<div className="flex flex-col gap-5">
const panelYuksekligi =
gecisBasladi && panelOlculeri.height > 0 ? panelOlculeri.height : "auto";
return (
<TransitionPanel
activeIndex={ADIM_SIRASI[adim]}
custom={yon}
variants={{
enter: (gecisYonu: number) => ({
x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
opacity: 0,
height: panelYuksekligi,
position: "relative",
}),
center: {
zIndex: 1,
x: 0,
opacity: 1,
height: panelYuksekligi,
},
exit: (gecisYonu: number) => ({
zIndex: 0,
x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
opacity: 0,
position: "absolute",
top: 0,
left: 0,
width: "100%",
}),
}}
transition={
hareketiAzalt
? {
opacity: { duration: 0.12 },
x: { duration: 0 },
height: { duration: 0 },
}
: {
x: { type: "spring", stiffness: 300, damping: 30 },
height: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}
}
>
<div ref={panelRef} className="flex flex-col gap-5 px-px">
<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 ? (
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
Tercihlerini güncelle
</p>
) : null}
<h2 className="mt-2 font-heading text-2xl font-bold text-slate-900 first:mt-0">
{duzenle
? "Seçimlerini değiştir"
: "Tercihine ışık tutabilmemiz için önce ne istediğini öğrenelim"}
@@ -200,116 +267,134 @@ function KapıIcerik({
onClick={() => void giristenIlerle()}
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
{yukleniyor ? "Hazırlanıyor…" : "Tercihlerimi belirle"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</div>
</div>
);
}
if (adim === "sira") {
return (
<div className="flex flex-col gap-5">
<form
ref={panelRef}
onSubmit={siraGonder}
className="flex flex-col gap-5 px-px"
>
<div>
<h2 className="font-heading text-xl font-bold text-slate-900">
Başarı sıralaman nedir?
{duzenle ? (
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
Sıralama ve tercihler
</p>
) : null}
<h2 className="mt-2 font-heading text-xl font-bold text-slate-900 first:mt-0">
{duzenle
? "Yeni sıralaman nedir?"
: "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.
{duzenle
? "Sıralamanı veya puan türünü güncelle; sonraki adımda tercihlerini de değiştirebilirsin. Listedeki programlar silinmez."
: "Risk ve erişilebilir programlar sıralamana göre hesaplanır."}
</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"
<div className="flex flex-col gap-2">
<label
htmlFor="kapisi-siralama"
className="text-xs font-semibold uppercase tracking-wide text-slate-400"
>
{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">
Başarı sıralaması ve puan türü
</label>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative sm:flex-1">
<Trophy
className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-slate-400"
aria-hidden
/>
<Input
id="kapisi-siralama"
inputMode="numeric"
autoComplete="off"
placeholder="ör. 85.000"
value={siralama}
onChange={(e) => siralamaDegisti(e.target.value)}
onFocus={preloadSihirbazAdimlar}
aria-invalid={hata ? true : undefined}
aria-describedby={hata ? "kapisi-siralama-hata" : undefined}
className="h-12 bg-background pl-11 text-base"
/>
</div>
<Select value={tur} onValueChange={setTur}>
<SelectTrigger
aria-label="Puan türü"
className="w-full bg-background data-[size=default]:h-12 sm:w-44"
>
<SelectValue placeholder="Puan türü" />
</SelectTrigger>
<SelectContent>
{PUAN_TURLERI_SECENEK.map((t) => (
<SelectItem key={t.deger} value={t.deger}>
{t.etiket}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{hata ? (
<p
id="kapisi-siralama-hata"
role="alert"
className="text-sm font-medium text-red-600"
>
{hata}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => setAdim("giris")}
onClick={() =>
duzenle ? profilKapisiniKapat() : adimaGec("giris")
}
className="cursor-pointer"
>
Geri
{duzenle ? "Vazgeç" : "Geri"}
</Button>
<Button
type="submit"
disabled={yukleniyor}
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
{yukleniyor ? "Hazırlanıyor…" : "Tercihlere geç"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</div>
</div>
);
}
</form>
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>
<div ref={panelRef} className="px-px">
{facetler && sira != null ? (
<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>
) : 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;
</TransitionPanel>
);
}

View File

@@ -261,9 +261,20 @@ export function PixelCorner({ className }: { className?: string }) {
}
/** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */
export function SectionEyebrow({ children }: { children: ReactNode }) {
export function SectionEyebrow({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-primary">
<span
className={cn(
"inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-primary",
className,
)}
>
<PixelMark />
{children}
</span>

View File

@@ -7,7 +7,6 @@
import { useState } from "react";
import Link from "next/link";
import { useLinkStatus } from "next/link";
import {
ChevronDown,
Rocket,
@@ -16,7 +15,7 @@ import {
} from "lucide-react";
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { turkishSlugify } from "@/lib/slug";
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
@@ -70,61 +69,6 @@ const RISK_NOKTA: Record<RiskSeviyesi, string> = {
riskli: "bg-red-500",
};
// Seçicideki kısa etiketler — sayfa başlığındaki uzun hallerinden bağımsız
const TUR_ETIKETLERI: Record<PuanTuruKey, string> = {
say: "Sayısal",
ea: "Eşit Ağırlık",
soz: "Sözel",
dil: "Dil",
tyt: "TYT",
};
// useLinkStatus Link'in altındaki bir bileşende çağrılmalı; sunucu
// gidiş-dönüşü sürerken tıklanan pill hafifçe nabız atar (layout shift yok).
function SeciciEtiket({ children }: { children: React.ReactNode }) {
const { pending } = useLinkStatus();
return (
<span
className={`transition-opacity duration-150 ${
pending ? "animate-pulse opacity-60" : ""
}`}
>
{children}
</span>
);
}
function PuanTuruSecici({
sira,
aktif,
}: {
sira: number;
aktif: PuanTuruKey;
}) {
return (
<nav
aria-label="Puan türü"
className="flex w-fit max-w-full flex-wrap gap-1 rounded-full border border-slate-200 bg-slate-100 p-1"
>
{(Object.keys(TUR_ETIKETLERI) as PuanTuruKey[]).map((key) => (
<Link
key={key}
href={`/sonuc?sira=${sira}&tur=${key}`}
scroll={false}
aria-current={key === aktif ? "page" : undefined}
className={`cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-[background-color,color,transform] duration-150 active:scale-[0.97] ${
key === aktif
? "bg-white text-slate-900 shadow-sm"
: "text-slate-500 hover:text-slate-900"
}`}
>
<SeciciEtiket>{TUR_ETIKETLERI[key]}</SeciciEtiket>
</Link>
))}
</nav>
);
}
const BOS_EKSTRA: Record<DilimKey, Program[]> = {
hayal: [],
dengeli: [],
@@ -197,47 +141,23 @@ export function ProgramTablosu({
return (
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
<div>
<h2 className="font-heading text-2xl font-bold">
Sıralamanla ılan programlar
</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-500">
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
sıralamalarına göre ulaşabildiğin bölümler.{" "}
<span className="font-medium text-slate-700">
ile beğendiklerini Seçtiklerim&apos;e at.
</span>
</p>
</div>
<Tabs
value={aktifDilim}
onValueChange={(v) => setAktifDilim(v as DilimKey)}
className="mt-5"
>
{/* Kontrol çubuğu: tabloyu süzen her şey tablonun hemen üstünde —
solda dilim sekmeleri (birincil), sağda puan türü (ikincil filtre).
Dar ekranda alt alta sarar. */}
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<TabsList>
{DILIMLER.map((d) => (
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
{d.etiket}
<span className="text-xs tabular-nums text-slate-400">
{sonuclar.toplam[d.key].toLocaleString("tr-TR")}
</span>
</TabsTrigger>
))}
</TabsList>
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wide text-slate-400">
Puan türü
</span>
{/* scroll={false}: tür değişince sayfa konumu korunur */}
<PuanTuruSecici sira={adaySira} aktif={tur} />
</div>
</div>
{/* Dilim sekmeleri tablonun hemen üstünde; puan türü burada değil,
navbar'daki sıralama rozetinden değiştirilir. */}
<TabsList>
{DILIMLER.map((d) => (
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
{d.etiket}
<span className="text-xs tabular-nums text-slate-400">
{sonuclar.toplam[d.key].toLocaleString("tr-TR")}
</span>
</TabsTrigger>
))}
</TabsList>
{DILIMLER.map((d) => {
const satirlar = [...sonuclar[d.key], ...ekstra[d.key]];
@@ -314,6 +234,14 @@ function uniSayfaSlug(ad: string): string {
return turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim());
}
// /bolum/[slug] sayfa slug'ı: katalog.ts'teki bolumSlugFromIsim ile aynı kural
// (bolumBazAdi + turkishSlugify). Bölüm haritası DB'deki tüm isim
// değerlerinden kurulduğu için bu slug da her zaman var olan bir sayfaya çıkar
// (bkz. liste-cekmecesi'ndeki bolumHref).
function bolumSayfaSlug(isim: string): string {
return turkishSlugify(bolumBazAdi(isim));
}
function ProgramSatiri({
program: p,
adaySira,
@@ -341,9 +269,14 @@ function ProgramSatiri({
<span className="flex min-w-0 items-center gap-2.5">
<UniLogo ad={p.universite} boy="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">
<Link
href={`/bolum/${bolumSayfaSlug(p.isim)}`}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={`${p.isim} bölüm sayfası`}
onClick={(e) => e.stopPropagation()}
>
{p.isim}
</span>
</Link>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${

View File

@@ -5,20 +5,50 @@
// dynamic import: hem hero-form hem sonuc/sihirbaz-modal burayı kullanır.
import dynamic from "next/dynamic";
import { Skeleton } from "@/components/ui/skeleton";
function SihirbazAdimlarSkeleton() {
return (
<div
aria-busy="true"
aria-live="polite"
className="flex min-h-64 flex-col gap-5"
>
<div className="flex items-center gap-1.5">
<Skeleton className="h-1.5 w-6 rounded-full" />
<Skeleton className="size-1.5 rounded-full" />
<Skeleton className="size-1.5 rounded-full" />
<Skeleton className="ml-2 h-3 w-14" />
</div>
<div>
<Skeleton className="h-6 w-64 max-w-full" />
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
<div className="mt-4 flex flex-wrap gap-2">
{[88, 112, 96, 128, 72, 104, 120, 84, 100].map((w, i) => (
<Skeleton
key={i}
className="h-9 rounded-full"
style={{ width: w }}
/>
))}
</div>
</div>
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
<Skeleton className="h-9 w-20 rounded-lg" />
<Skeleton className="h-9 w-24 rounded-lg" />
</div>
</div>
);
}
export const SihirbazAdimlarLazy = dynamic(
() =>
import("@/components/sihirbaz-adimlar").then((m) => m.SihirbazAdimlar),
{
ssr: false,
loading: () => (
<div
aria-live="polite"
className="flex min-h-64 items-center justify-center text-sm text-slate-500"
>
Sihirbaz hazırlanıyor
</div>
),
loading: () => <SihirbazAdimlarSkeleton />,
},
);

View File

@@ -8,7 +8,7 @@
// üniversite tipi sayıları kategori+il'e göre /api/facetler'den tazelenir.
import { useEffect, useState } from "react";
import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react";
import { ArrowLeft, ArrowRight, X } from "lucide-react";
import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
import { toast } from "sonner";
@@ -17,6 +17,7 @@ import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button";
import { IlSecimHaritasi } from "@/components/il-secim-haritasi";
import { normalizeIlAdi } from "@/lib/harita";
import { trBaslikDuzeni } from "@/lib/slug";
import type { SihirbazFacetleri } from "@/lib/db";
import {
ONCELIKLER,
@@ -259,7 +260,7 @@ export function SihirbazAdimlar({
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{i.il.toLocaleLowerCase("tr-TR")}
{trBaslikDuzeni(i.il)}
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
</Cip>
))}
@@ -277,7 +278,7 @@ export function SihirbazAdimlar({
onClick={() => listeDegistir(iller, setIller, il)}
className="inline-flex cursor-pointer items-center gap-1 rounded-full bg-orange-500 px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200 hover:bg-orange-600"
>
{il.toLocaleLowerCase("tr-TR")}
{trBaslikDuzeni(il)}
<X className="size-3" aria-hidden />
</button>
))}
@@ -382,8 +383,8 @@ export function SihirbazAdimlar({
disabled={kategoriler.length === 0}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
{sonButonEtiketi}
<ArrowRight className="size-4" aria-hidden />
</Button>
)}
</div>

View File

@@ -4,7 +4,8 @@ import { getAllBolumler } from "@/lib/katalog";
/**
* Site footer'ı: yasal metin + SEO iç link kolonları. Server component —
* popüler bölümler listesi build sırasında katalogdan gelir. Layout'a değil
* sayfalara eklenir; auth/utility ekranlarında (giris, listem...) footer yok.
* sayfalara eklenir; yazdırma çıktısı (rapor/yazdir) hariç her sayfada bu
* footer kullanılır.
*/
export function SiteFooter() {
const populerBolumler = [...getAllBolumler()]

View File

@@ -69,16 +69,12 @@ function DialogContent({
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
<XIcon className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>

View File

@@ -0,0 +1,154 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DrawerHandle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Handle>) {
return (
<DrawerPrimitive.Handle
data-slot="drawer-handle"
className={cn(
"mx-auto mt-4 hidden h-1.5 w-25 shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
showSwipeHandle = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content> & {
showSwipeHandle?: boolean
}) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className
)}
{...props}
>
{showSwipeHandle ? <DrawerHandle /> : null}
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -39,6 +39,10 @@ export const SIHIRBAZ_STORAGE_KEY = "kolaytercih.sihirbaz";
// aboneler (ör. navbar sıralama rozeti) bu olayla haberdar edilir.
export const PROFIL_DEGISTI_EVENT = "kolaytercih:profil-degisti";
// Arama modalındaki funnel CTA'sı /sonuc üzerindeyken sihirbaz
// orkestratörünü (SihirbazBolumu) bu olayla açar.
export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac";
function profilDegistiginiYayinla(): void {
window.dispatchEvent(new Event(PROFIL_DEGISTI_EVENT));
}
@@ -160,6 +164,19 @@ export function tercihProfiliTamamlandiMi(): boolean {
return tercihProfiliOku() !== null;
}
/** Profildeki sıra/tur ile sonuç sayfası URL'i — tüm uygulama aynı sorguyu kullanır. */
export function sonucHref(
profil: Pick<TercihProfili, "sira" | "tur">,
ekstra?: Record<string, string>,
): string {
const q = new URLSearchParams({
sira: String(profil.sira),
tur: profil.tur,
...ekstra,
});
return `/sonuc?${q}`;
}
/** Prompt'a enjekte edilecek Türkçe özet. */
export function secimOzeti(secimler: SihirbazSecimleri): string {
const satirlar = [