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

@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import {
ArrowRight,
ChartNoAxesCombined,
Database,
ListChecks,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { TypingAnimation } from "@/components/ui/typing-animation";
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.",
"Geleceğin 24 satırlık bir plana sığar.",
"Gerçek YÖK Atlas verisiyle güvenle seç.",
"Her tercihinin bir nedeni olsun.",
"Riskleri biz hesaplayalım, kararı sen ver.",
"Sana uygun bölümler bir adım uzağında.",
"Dengeli bir liste, rahat bir tercih dönemi.",
"Tercihlerini şansa bırakma.",
"Doğru strateji, doğru yerleşme.",
"Hayalindeki bölüm için doğru plan.",
"Verilerle konuşan bir tercih listesi.",
"Üç adımda sana özel 24 tercih.",
"Tercih dönemini stressiz geçir.",
"Geçen yılın verisi, senin avantajın.",
"Güvenli ve iddialı tercihleri dengele.",
"Sıralaman ne söylüyor? Birlikte bakalım.",
"Her satırı senin için hesaplıyoruz.",
"Akıllı tercih, içi rahat bir bekleyiş.",
"Yerleşme ihtimalini görerek seç.",
"Sana özel liste birkaç dakikada hazır.",
"İlgi alanların ve şehrin, tek listede.",
"Boş kalan tercih, kaçan fırsattır.",
"Uzman işi bir liste, senin onayınla.",
"Kararını veriye dayandır.",
"Doğru tercih, doğru bilgiyle başlar.",
"Listeni bugün kur, için rahat olsun.",
"Yerleşme riskin renklerle önünde.",
"En önemli 24 kararını birlikte verelim.",
"Senin sıralaman, sana özel plan.",
"İyi bir liste, iyi bir başlangıçtır.",
];
/** Fisher-Yates ile kopya üzerinde karıştırır; her açılışta farklı sıra. */
function karistir<T>(dizi: readonly T[]): T[] {
const kopya = [...dizi];
for (let i = kopya.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[kopya[i], kopya[j]] = [kopya[j], kopya[i]];
}
return kopya;
}
const KANITLAR = [
{ ikon: ListChecks, etiket: "24 tercih" },
{ ikon: ChartNoAxesCombined, etiket: "Risk analizi" },
{ ikon: Database, etiket: "YÖK Atlas verisi" },
];
/**
* Arama modalının açılış ekranında yaşayan satın alım funnel kartı.
* Rastgele sırayla yazılıp silinen funnel metinleriyle adayı sihirbaza
* iter: /sonuc üzerindeyse oradaki orkestratörü olayla açar, başka
* sayfadaysa profile göre /sonuc'a (sihirbaz açık) yönlendirir ya da
* sıralama kapısını açar. Modal yalnızca açıkken mount olduğundan
* karıştırma her açılışta yenilenir ve hydration sorunu yaratmaz.
*/
export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
const router = useRouter();
const pathname = usePathname();
const profil = useTercihProfili();
const [metinler] = useState(() => karistir(FUNNEL_METINLERI));
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-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-12 -top-16 size-44 rounded-full bg-blue-500/25 blur-3xl"
/>
<div
aria-hidden
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-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-5">
<div className="min-w-0">
<TypingAnimation
as="h3"
words={metinler}
loop
typeSpeed={38}
deleteSpeed={18}
pauseDelay={2600}
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-3 flex flex-wrap gap-x-4 gap-y-2">
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
<span
key={etiket}
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-200"
>
<Ikon className="size-3.5 text-cyan-300" aria-hidden />
{etiket}
</span>
))}
</div>
</div>
<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,
}