Refactor UI components for consistency and clarity across the application
All checks were successful
Deploy / deploy (push) Successful in 18m31s
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:
145
src/components/arama-funnel-karti.tsx
Normal file
145
src/components/arama-funnel-karti.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 Ağı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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
33
src/components/hero-baslik.tsx
Normal file
33
src/components/hero-baslik.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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ı aç
|
||||
</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ı aç
|
||||
</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 aç
|
||||
<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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 açı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'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 ${
|
||||
|
||||
@@ -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 />,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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>
|
||||
|
||||
154
src/components/ui/drawer.tsx
Normal file
154
src/components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user