Dev paneli Apple hareket diliyle yenilendi; animasyon ve UX düzeltmeleri
All checks were successful
Deploy / deploy (push) Successful in 9m29s

- Dev paneli: DEV çipinden layoutId morph ile büyüyüp küçülür (kritik sönümlü
  spring), yarı saydam malzeme (backdrop-blur + saturate), basışta anında
  scale geri bildirimi, Escape ile kapanış, reduced-motion/transparency desteği
- TypingAnimation ve TextLoop: prefers-reduced-motion'da sonsuz döngü durur,
  ilk metin sabit gösterilir
- kt-unblur: reduced-motion'da blur çözülmesi yerine kısa yumuşak belirme
- Sihirbaz: reduced-motion'da adım geçişinde height/x anında oturur;
  adım noktalarında transition-all yerine kapsamlı geçiş özellikleri
- Sihirbaz il adımındaki arama kutusu kaldırıldı; tüm iller program sayısına
  göre sıralı çip olarak listelenir
- /giris: spinner yerine kartın iskeletini gösteren route-level Skeleton

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-07-26 15:26:40 +03:00
parent 7288c926d0
commit 40a1c8ef8e
7 changed files with 338 additions and 224 deletions

Binary file not shown.

39
src/app/giris/loading.tsx Normal file
View File

@@ -0,0 +1,39 @@
import { Skeleton } from "@/components/ui/skeleton";
// /giris açılırken spinner yerine sayfanın kendi iskeleti: kart, başlık,
// Google butonu, ayraç, e-posta alanı ve gönder butonu aynı yerde belirir —
// içerik gelince zıplama olmaz.
export default function GirisLoading() {
return (
<div className="flex min-h-screen flex-col bg-slate-50">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{/* Başlık + alt metin */}
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-1.5 h-4 w-2/3" />
{/* Google ile devam et */}
<Skeleton className="mt-6 h-11 w-full rounded-lg" />
{/* "veya" ayracı */}
<div className="mt-4 flex items-center gap-3">
<Skeleton className="h-px flex-1" />
<Skeleton className="h-3 w-8" />
<Skeleton className="h-px flex-1" />
</div>
{/* E-posta alanı + gönder butonu */}
<Skeleton className="mt-4 h-11 w-full rounded-lg" />
<Skeleton className="mt-3 h-11 w-full rounded-lg" />
</div>
{/* Kart altındaki yasal metin */}
<div className="mt-6 flex flex-col items-center gap-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</main>
</div>
);
}

View File

@@ -144,6 +144,23 @@
animation: kt-unblur 0.9s ease-out both; animation: kt-unblur 0.9s ease-out both;
} }
/* Hareket azaltılmışsa blur çözülmesi yerine kısa, yumuşak bir belirme:
kavramayı sağlayan geri bildirim kalır, gösterişli efekt düşer. */
@media (prefers-reduced-motion: reduce) {
.kt-unblur {
animation: kt-unblur-sade 0.25s ease-out both;
}
}
@keyframes kt-unblur-sade {
from {
opacity: 0.6;
}
to {
opacity: 1;
}
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;

View File

@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
import { import {
AnimatePresence, AnimatePresence,
motion, motion,
useReducedMotion,
type Transition, type Transition,
type Variants, type Variants,
} from "motion/react"; } from "motion/react";
@@ -36,8 +37,12 @@ export function TextLoop({
}: TextLoopProps) { }: TextLoopProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const items = Children.toArray(children); const items = Children.toArray(children);
// Sürekli dönen dekoratif metin: hareket azaltılsın diyen kullanıcıda
// döngü hiç başlamaz, ilk metin sabit kalır.
const azMotion = useReducedMotion();
useEffect(() => { useEffect(() => {
if (azMotion) return;
const intervalMs = interval * 1000; const intervalMs = interval * 1000;
const timer = setInterval(() => { const timer = setInterval(() => {
setCurrentIndex((current) => { setCurrentIndex((current) => {
@@ -47,7 +52,7 @@ export function TextLoop({
}); });
}, intervalMs); }, intervalMs);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [items.length, interval, onIndexChange]); }, [azMotion, items.length, interval, onIndexChange]);
return ( return (
<div className={cn("relative inline-block whitespace-nowrap", className)}> <div className={cn("relative inline-block whitespace-nowrap", className)}>

View File

@@ -4,10 +4,16 @@
// (layout.tsx NODE_ENV kontrolüyle monte eder; aksiyonlar da sunucuda // (layout.tsx NODE_ENV kontrolüyle monte eder; aksiyonlar da sunucuda
// ayrıca korunur). Amaç: satın alma / giriş / kredi durumlarını tek // 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. // 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.
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2, Wrench, X } from "lucide-react"; import { Loader2, Wrench, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { import {
@@ -35,12 +41,26 @@ const SAYFALAR: { ad: string; href: string }[] = [
{ ad: "404", href: "/boyle-bir-sayfa-yok" }, { 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";
// Yarı saydam malzeme; şeffaflık azaltılsın diyen kullanıcıya opak yüzey.
const MALZEME =
"border border-white/60 bg-white/80 shadow-2xl shadow-slate-900/20 backdrop-blur-2xl backdrop-saturate-150 " +
"[@media(prefers-reduced-transparency:reduce)]:bg-white [@media(prefers-reduced-transparency:reduce)]:backdrop-blur-none";
export function DevPanel() { export function DevPanel() {
const [acik, setAcik] = useState(false); const [acik, setAcik] = useState(false);
const [durum, setDurum] = useState<DevDurum | null>(null); const [durum, setDurum] = useState<DevDurum | null>(null);
const [email, setEmail] = useState("test@kolaytercih.dev"); const [email, setEmail] = useState("test@kolaytercih.dev");
const [mesgul, setMesgul] = useState(false); const [mesgul, setMesgul] = useState(false);
const router = useRouter(); const router = useRouter();
const azMotion = useReducedMotion();
const yenile = useCallback(async () => { const yenile = useCallback(async () => {
try { try {
@@ -55,6 +75,16 @@ export function DevPanel() {
void yenile(); void yenile();
} }
// Yol bulma: panelden çıkış her an mümkün — Escape da kapatır.
useEffect(() => {
if (!acik) return;
const f = (e: KeyboardEvent) => {
if (e.key === "Escape") setAcik(false);
};
window.addEventListener("keydown", f);
return () => window.removeEventListener("keydown", f);
}, [acik]);
// Her aksiyonu tek kalıptan geçir: meşgul kilidi + durum tazeleme + // Her aksiyonu tek kalıptan geçir: meşgul kilidi + durum tazeleme +
// server component'lerin (header'daki kredi rozeti dahil) yenilenmesi // server component'lerin (header'daki kredi rozeti dahil) yenilenmesi
async function calistir(fn: () => Promise<unknown>) { async function calistir(fn: () => Promise<unknown>) {
@@ -99,175 +129,216 @@ export function DevPanel() {
} }
} }
if (!acik) {
return (
<button
type="button"
onClick={paneliAc}
title="Dev süperadmin paneli"
className="fixed bottom-20 left-4 z-50 inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full border border-violet-300 bg-violet-600 px-3 text-xs font-bold text-white shadow-lg transition-transform duration-150 hover:bg-violet-700 active:scale-95 print:hidden"
>
<Wrench className="size-3.5" aria-hidden />
DEV
</button>
);
}
const g = durum?.girisli ? durum : null; const g = durum?.girisli ? durum : null;
return ( // Azaltılmış hareket: morph yerine kısa çapraz solma (yaysız, kaymasız).
<div className="fixed bottom-20 left-4 z-50 flex max-h-[72vh] w-80 flex-col overflow-hidden rounded-2xl border border-violet-200 bg-white shadow-2xl print:hidden"> const paylasilan = azMotion ? {} : { layoutId: "dev-panel" };
<div className="flex items-center justify-between border-b border-slate-100 bg-violet-600 px-4 py-2.5 text-white"> const gecis = azMotion ? { duration: 0.15 } : YAY;
<p className="flex items-center gap-1.5 text-sm font-bold">
<Wrench className="size-4" aria-hidden />
Dev süperadmin
{mesgul ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden />
) : null}
</p>
<button
type="button"
onClick={() => setAcik(false)}
className="cursor-pointer rounded-full p-1 hover:bg-white/20"
aria-label="Kapat"
>
<X className="size-4" aria-hidden />
</button>
</div>
<div className="flex flex-col gap-4 overflow-y-auto p-4 text-sm"> return (
{/* Oturum */} <AnimatePresence initial={false}>
<section> {!acik ? (
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400"> <motion.button
Oturum key="cip"
</p> {...paylasilan}
{g ? ( type="button"
<div className="mt-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs"> onClick={paneliAc}
<p className="font-semibold text-slate-800">{g.email}</p> title="Dev süperadmin paneli"
<p className="mt-0.5 text-slate-500"> style={{ borderRadius: 18 }}
{g.kredi} kredi · {g.hasPaket ? "paketli" : "paketsiz"} ·{" "} transition={gecis}
{g.raporVar initial={{ opacity: 0 }}
? `rapor var (${g.sira?.toLocaleString("tr-TR")} · ${g.tur})` animate={{ opacity: 1 }}
: "rapor yok"} 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>
) : (
<motion.div
key="panel"
{...paylasilan}
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" } }}
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"
>
{/* 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">
<p className="flex items-center gap-2 font-heading text-sm font-bold tracking-[-0.01em] text-slate-900">
<span className="flex size-6 items-center justify-center rounded-lg bg-violet-600 text-white shadow-sm">
<Wrench className="size-3.5" aria-hidden />
</span>
Dev süperadmin
{mesgul ? (
<Loader2
className="size-3.5 animate-spin text-slate-400"
aria-hidden
/>
) : null}
</p> </p>
<button <button
type="button" type="button"
disabled={mesgul} onClick={() => setAcik(false)}
onClick={() => className={`cursor-pointer rounded-full p-1.5 text-slate-500 hover:bg-slate-900/5 hover:text-slate-700 ${BASIS}`}
calistir(async () => { aria-label="Kapat"
await authClient.signOut();
})
}
className="mt-2 cursor-pointer text-violet-700 underline-offset-2 hover:underline"
> >
Çıkış yap <X className="size-4" aria-hidden />
</button> </button>
</div> </div>
) : (
<p className="mt-2 text-xs text-slate-500">Girişsiz geziyorsun.</p>
)}
<div className="mt-2 flex gap-1.5">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="deneme@ornek.dev"
className="h-9 min-w-0 flex-1 rounded-lg border border-slate-200 px-2.5 text-xs outline-none focus-visible:border-violet-400"
/>
<button
type="button"
disabled={mesgul}
onClick={anindaGiris}
className="h-9 shrink-0 cursor-pointer rounded-lg bg-violet-600 px-3 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-50"
>
Anında gir
</button>
</div>
<p className="mt-1 text-[10px] text-slate-400">
Yeni e-posta = 5 kredili yeni kullanıcı (magic link atlanır).
</p>
</section>
{/* Senaryolar */} <div className="flex flex-col gap-4 overflow-y-auto p-4 text-sm">
<section> {/* Oturum */}
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400"> <section>
Senaryo <p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
</p> Oturum
<div className="mt-2 grid grid-cols-2 gap-1.5"> </p>
{( {g ? (
[ <div className="mt-2 rounded-xl border border-slate-900/5 bg-white/60 px-3 py-2 text-xs">
{ <p className="font-semibold text-slate-800">{g.email}</p>
ad: "Yeni kullanıcı", <p className="mt-0.5 tabular-nums text-slate-500">
aciklama: "5 kredi · paketsiz · raporsuz", {g.kredi} kredi · {g.hasPaket ? "paketli" : "paketsiz"} ·{" "}
fn: async () => { {g.raporVar
await devAyarla({ kredi: 5, hasPaket: false }); ? `rapor var (${g.sira?.toLocaleString("tr-TR")} · ${g.tur})`
await devRaporSil(); : "rapor yok"}
}, </p>
}, <button
{ type="button"
ad: "Paketli", disabled={mesgul}
aciklama: "60 kredi · paket aktif", onClick={() =>
fn: () => devAyarla({ kredi: 60, hasPaket: true }), calistir(async () => {
}, await authClient.signOut();
{ })
ad: "Kredisi bitmiş", }
aciklama: "0 kredi", className="mt-2 cursor-pointer font-medium text-violet-700 underline-offset-2 hover:underline"
fn: () => devAyarla({ kredi: 0 }), >
}, Çıkış yap
{ </button>
ad: "Raporu sil", </div>
aciklama: "boş durumlar", ) : (
fn: () => devRaporSil(), <p className="mt-2 text-xs text-slate-500">
}, Girişsiz geziyorsun.
] as const </p>
).map((s) => ( )}
<button <div className="mt-2 flex gap-1.5">
key={s.ad} <input
type="button" type="email"
disabled={mesgul || !g} value={email}
onClick={() => calistir(s.fn)} onChange={(e) => setEmail(e.target.value)}
className="cursor-pointer rounded-lg border border-slate-200 px-2.5 py-2 text-left text-xs hover:border-violet-300 hover:bg-violet-50 disabled:cursor-default disabled:opacity-40" placeholder="deneme@ornek.dev"
> className="h-9 min-w-0 flex-1 rounded-lg border border-slate-900/10 bg-white/70 px-2.5 text-xs outline-none transition-colors duration-150 focus-visible:border-violet-400"
<span className="block font-semibold text-slate-800"> />
{s.ad} <button
</span> type="button"
<span className="text-[10px] text-slate-500"> disabled={mesgul}
{s.aciklama} onClick={anindaGiris}
</span> className={`h-9 shrink-0 cursor-pointer rounded-lg bg-violet-600 px-3 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-50 ${BASIS}`}
</button> >
))} Anında gir
</div> </button>
<button </div>
type="button" <p className="mt-1 text-[10px] text-slate-400">
disabled={mesgul || !g} Yeni e-posta = 5 kredili yeni kullanıcı (magic link atlanır).
onClick={odemeEkrani} </p>
className="mt-1.5 w-full cursor-pointer rounded-lg border border-emerald-200 bg-emerald-50 px-2.5 py-2 text-left text-xs font-semibold text-emerald-800 hover:bg-emerald-100 disabled:cursor-default disabled:opacity-40" </section>
>
Ödeme başarı ekranı 🎉
<span className="block text-[10px] font-normal text-emerald-700">
sahte &quot;paid&quot; sipariş oluşturup /odeme/sonuc&apos;a gider
</span>
</button>
</section>
{/* Sayfalar */} {/* Senaryolar */}
<section> <section>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400"> <p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
Sayfalar Senaryo
</p> </p>
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 grid grid-cols-2 gap-1.5">
{SAYFALAR.map((s) => ( {(
<a [
key={s.href} {
href={s.href} ad: "Yeni kullanıcı",
className="cursor-pointer rounded-full border border-slate-200 px-2.5 py-1 text-[11px] text-slate-700 hover:border-violet-300 hover:bg-violet-50" aciklama: "5 kredi · paketsiz · raporsuz",
> fn: async () => {
{s.ad} await devAyarla({ kredi: 5, hasPaket: false });
</a> await devRaporSil();
))} },
</div> },
</section> {
</div> ad: "Paketli",
</div> aciklama: "60 kredi · paket aktif",
fn: () => devAyarla({ kredi: 60, hasPaket: true }),
},
{
ad: "Kredisi bitmiş",
aciklama: "0 kredi",
fn: () => devAyarla({ kredi: 0 }),
},
{
ad: "Raporu sil",
aciklama: "boş durumlar",
fn: () => devRaporSil(),
},
] as const
).map((s) => (
<button
key={s.ad}
type="button"
disabled={mesgul || !g}
onClick={() => calistir(s.fn)}
className={`cursor-pointer rounded-xl border border-slate-900/10 bg-white/60 px-2.5 py-2 text-left text-xs hover:border-violet-300 hover:bg-violet-50/80 disabled:cursor-default disabled:opacity-40 ${BASIS}`}
>
<span className="block font-semibold text-slate-800">
{s.ad}
</span>
<span className="text-[10px] tabular-nums text-slate-500">
{s.aciklama}
</span>
</button>
))}
</div>
<button
type="button"
disabled={mesgul || !g}
onClick={odemeEkrani}
className={`mt-1.5 w-full cursor-pointer rounded-xl border border-emerald-600/15 bg-emerald-50/80 px-2.5 py-2 text-left text-xs font-semibold text-emerald-800 hover:bg-emerald-100/80 disabled:cursor-default disabled:opacity-40 ${BASIS}`}
>
Ödeme başarı ekranı 🎉
<span className="block text-[10px] font-normal text-emerald-700">
sahte &quot;paid&quot; sipariş oluşturup /odeme/sonuc&apos;a
gider
</span>
</button>
</section>
{/* Sayfalar */}
<section>
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
Sayfalar
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{SAYFALAR.map((s) => (
<a
key={s.href}
href={s.href}
className={`cursor-pointer rounded-full border border-slate-900/10 bg-white/60 px-2.5 py-1 text-[11px] text-slate-700 hover:border-violet-300 hover:bg-violet-50/80 ${BASIS}`}
>
{s.ad}
</a>
))}
</div>
</section>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
); );
} }

View File

@@ -63,13 +63,13 @@ export function SihirbazAdimlar({
}) { }) {
const [adim, setAdim] = useState(0); const [adim, setAdim] = useState(0);
const [yon, setYon] = useState(1); const [yon, setYon] = useState(1);
const [gecisBasladi, setGecisBasladi] = useState(false);
const [panelRef, panelOlculeri] = useMeasure(); const [panelRef, panelOlculeri] = useMeasure();
const hareketiAzalt = useReducedMotion(); const hareketiAzalt = useReducedMotion();
const [kategoriler, setKategoriler] = useState<string[]>( const [kategoriler, setKategoriler] = useState<string[]>(
baslangicSecimler?.kategoriler ?? [], baslangicSecimler?.kategoriler ?? [],
); );
const [iller, setIller] = useState<string[]>(baslangicSecimler?.iller ?? []); const [iller, setIller] = useState<string[]>(baslangicSecimler?.iller ?? []);
const [ilArama, setIlArama] = useState("");
const [universiteTipi, setUniversiteTipi] = useState<UniversiteTipi>( const [universiteTipi, setUniversiteTipi] = useState<UniversiteTipi>(
baslangicSecimler?.universiteTipi ?? "farketmez", baslangicSecimler?.universiteTipi ?? "farketmez",
); );
@@ -98,9 +98,15 @@ export function SihirbazAdimlar({
function adimaGit(yeniAdim: number) { function adimaGit(yeniAdim: number) {
setYon(yeniAdim > adim ? 1 : -1); setYon(yeniAdim > adim ? 1 : -1);
setGecisBasladi(true);
setAdim(yeniAdim); setAdim(yeniAdim);
} }
const panelYuksekligi =
gecisBasladi && panelOlculeri.height > 0
? panelOlculeri.height
: "auto";
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -109,10 +115,10 @@ export function SihirbazAdimlar({
key={i} key={i}
className={ className={
i === adim i === adim
? "h-1.5 w-6 rounded-full bg-primary transition-all" ? "h-1.5 w-6 rounded-full bg-primary transition-[width,background-color] duration-200 ease-out"
: i < adim : i < adim
? "h-1.5 w-3 rounded-full bg-primary/50 transition-all" ? "h-1.5 w-3 rounded-full bg-primary/50 transition-[width,background-color] duration-200 ease-out"
: "h-1.5 w-1.5 rounded-full bg-slate-200 transition-all" : "h-1.5 w-1.5 rounded-full bg-slate-200 transition-[width,background-color] duration-200 ease-out"
} }
/> />
))} ))}
@@ -128,16 +134,14 @@ export function SihirbazAdimlar({
enter: (gecisYonu: number) => ({ enter: (gecisYonu: number) => ({
x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%", x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
opacity: 0, opacity: 0,
height: height: panelYuksekligi,
panelOlculeri.height > 0 ? panelOlculeri.height : "auto",
position: "relative", position: "relative",
}), }),
center: { center: {
zIndex: 1, zIndex: 1,
x: 0, x: 0,
opacity: 1, opacity: 1,
height: height: panelYuksekligi,
panelOlculeri.height > 0 ? panelOlculeri.height : "auto",
}, },
exit: (gecisYonu: number) => ({ exit: (gecisYonu: number) => ({
zIndex: 0, zIndex: 0,
@@ -151,7 +155,13 @@ export function SihirbazAdimlar({
}} }}
transition={ transition={
hareketiAzalt hareketiAzalt
? { opacity: { duration: 0.12 } } ? // Azaltılmış harekette yalnızca kısa çapraz solma; height/x
// varsayılan spring'e düşüp oynamasın diye açıkça sıfırlanır.
{
opacity: { duration: 0.12 },
x: { duration: 0 },
height: { duration: 0 },
}
: { : {
x: { type: "spring", stiffness: 300, damping: 30 }, x: { type: "spring", stiffness: 300, damping: 30 },
height: { type: "spring", stiffness: 300, damping: 30 }, height: { type: "spring", stiffness: 300, damping: 30 },
@@ -159,7 +169,7 @@ export function SihirbazAdimlar({
} }
} }
> >
<div ref={panelRef}> <div ref={panelRef} className="px-px pb-2">
<h3 className="font-heading text-lg font-bold"> <h3 className="font-heading text-lg font-bold">
Hangi alanlara ilgi duyuyorsun? Hangi alanlara ilgi duyuyorsun?
</h3> </h3>
@@ -187,56 +197,23 @@ export function SihirbazAdimlar({
En fazla 5 il seçebilirsin. İstersen boş bırak, fark etmez. En fazla 5 il seçebilirsin. İstersen boş bırak, fark etmez.
</p> </p>
{/* Birincil seçim yolu: arama + çipler (haritadaki küçük iller {/* Çipler birincil seçim yolu (haritadaki küçük iller mobilde
mobilde güvenilir dokunma hedefi değil) */} güvenilir dokunma hedefi değil); tüm iller program sayısına
<input göre sıralı listelenir. */}
type="search" <div className="mt-4 flex flex-wrap gap-2">
value={ilArama} {[...facetler.iller]
onChange={(e) => setIlArama(e.target.value)} .sort((a, b) => b.adet - a.adet)
placeholder="İl ara (ör. İzmir)" .map((i) => (
aria-label="İl ara" <Cip
className="mt-4 h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" key={i.il}
/> secili={iller.includes(i.il)}
{(() => { onClick={() => listeDegistir(iller, setIller, i.il, 5)}
const q = ilArama.trim().toLocaleLowerCase("tr-TR"); >
const sirali = [...facetler.iller].sort((a, b) => b.adet - a.adet); {i.il.toLocaleLowerCase("tr-TR")}
const eslesen = q <span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
? sirali.filter((i) => </Cip>
i.il.toLocaleLowerCase("tr-TR").includes(q), ))}
) </div>
: sirali.slice(0, 12);
const kalan = q ? 0 : facetler.iller.length - eslesen.length;
return (
<div className="mt-3">
<div className="flex flex-wrap gap-2">
{eslesen.map((i) => (
<Cip
key={i.il}
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{i.il.toLocaleLowerCase("tr-TR")}
<span className="ml-1.5 text-xs opacity-70">
{i.adet}
</span>
</Cip>
))}
{eslesen.length === 0 ? (
<p className="text-sm text-slate-500">
Bu sıralamada &quot;{ilArama.trim()}&quot; için program
bulunamadı.
</p>
) : null}
</div>
{kalan > 0 ? (
<p className="mt-2 text-xs text-slate-400">
En çok programı olan 12 il gösteriliyor diğer {kalan} il
için arama yaz.
</p>
) : null}
</div>
);
})()}
{iller.length > 0 ? ( {iller.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">

View File

@@ -12,6 +12,7 @@ import {
import { import {
motion, motion,
useInView, useInView,
useReducedMotion,
type DOMMotionComponents, type DOMMotionComponents,
type HTMLMotionProps, type HTMLMotionProps,
type MotionProps, type MotionProps,
@@ -84,6 +85,9 @@ export function TypingAnimation({
const [currentWordIndex, setCurrentWordIndex] = useState(0) const [currentWordIndex, setCurrentWordIndex] = useState(0)
const [currentCharIndex, setCurrentCharIndex] = useState(0) const [currentCharIndex, setCurrentCharIndex] = useState(0)
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing") const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
// Sürekli yazıp silen döngü, hareket azaltılsın diyen kullanıcı için
// rahatsız edici: ilk metin tam ve sabit gösterilir, imleç yanmaz.
const azMotion = useReducedMotion()
const elementRef = useRef<HTMLElement | null>(null) const elementRef = useRef<HTMLElement | null>(null)
const isInView = useInView(elementRef as RefObject<Element>, { const isInView = useInView(elementRef as RefObject<Element>, {
amount: 0.3, amount: 0.3,
@@ -117,7 +121,7 @@ export function TypingAnimation({
useEffect(() => { useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null let timeout: ReturnType<typeof setTimeout> | null = null
if (shouldStart && wordsToAnimate.length > 0) { if (!azMotion && shouldStart && wordsToAnimate.length > 0) {
const timeoutDelay = const timeoutDelay =
delay > 0 && displayedText === "" delay > 0 && displayedText === ""
? delay ? delay
@@ -175,6 +179,7 @@ export function TypingAnimation({
} }
} }
}, [ }, [
azMotion,
shouldStart, shouldStart,
phase, phase,
currentCharIndex, currentCharIndex,
@@ -225,8 +230,8 @@ export function TypingAnimation({
)} )}
{...props} {...props}
> >
{displayedText} {azMotion ? (wordsToAnimate[0] ?? "") : displayedText}
{shouldShowCursor && ( {shouldShowCursor && !azMotion && (
<span <span
className={cn("inline-block", blinkCursor && "animate-blink-cursor")} className={cn("inline-block", blinkCursor && "animate-blink-cursor")}
> >