Files
kolaytercih/src/components/dev/dev-panel.tsx
bilalgursen 40a1c8ef8e
All checks were successful
Deploy / deploy (push) Successful in 9m29s
Dev paneli Apple hareket diliyle yenilendi; animasyon ve UX düzeltmeleri
- 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>
2026-07-26 15:26:40 +03:00

345 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// Dev süperadmin paneli: yalnızca geliştirme ortamında render edilir
// (layout.tsx NODE_ENV kontrolüyle monte eder; aksiyonlar da sunucuda
// 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.
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Wrench, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import {
devAnindaGiris,
devAyarla,
devDurum,
devRaporSil,
devSahteOdeme,
type DevDurum,
} from "@/lib/dev/actions";
const SAYFALAR: { ad: string; href: string }[] = [
{ ad: "Ana sayfa", href: "/" },
{ 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" },
{ ad: "Listem (kilit açılışı)", href: "/listem?acildi=1" },
{ ad: "Paket", href: "/paket" },
{ ad: "Paket (ödeme hatası)", href: "/paket?hata=token" },
{ ad: "PDF / yazdır", href: "/rapor/yazdir" },
{ ad: "Giriş", href: "/giris" },
{ ad: "Gizlilik", href: "/gizlilik" },
{ ad: "Koşullar", href: "/kosullar" },
{ ad: "İletişim", href: "/iletisim" },
{ 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() {
const [acik, setAcik] = useState(false);
const [durum, setDurum] = useState<DevDurum | null>(null);
const [email, setEmail] = useState("test@kolaytercih.dev");
const [mesgul, setMesgul] = useState(false);
const router = useRouter();
const azMotion = useReducedMotion();
const yenile = useCallback(async () => {
try {
setDurum(await devDurum());
} catch {
setDurum(null);
}
}, []);
function paneliAc() {
setAcik(true);
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 +
// server component'lerin (header'daki kredi rozeti dahil) yenilenmesi
async function calistir(fn: () => Promise<unknown>) {
setMesgul(true);
try {
await fn();
await yenile();
router.refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Dev aksiyonu başarısız.");
} finally {
setMesgul(false);
}
}
async function anindaGiris() {
setMesgul(true);
try {
const sonuc = await devAnindaGiris(email);
if (!sonuc.ok) {
toast.error(sonuc.error);
return;
}
// Verify URL'i oturumu kurup callback'e yönlendirir
window.location.assign(sonuc.url);
} finally {
setMesgul(false);
}
}
async function odemeEkrani() {
setMesgul(true);
try {
const sonuc = await devSahteOdeme();
if (!sonuc.ok) {
toast.error(sonuc.error);
return;
}
router.push(`/odeme/sonuc?siparis=${sonuc.siparis}`);
} finally {
setMesgul(false);
}
}
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}
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>
) : (
<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>
<button
type="button"
onClick={() => setAcik(false)}
className={`cursor-pointer rounded-full p-1.5 text-slate-500 hover:bg-slate-900/5 hover:text-slate-700 ${BASIS}`}
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">
{/* Oturum */}
<section>
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
Oturum
</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>
<p className="mt-0.5 tabular-nums text-slate-500">
{g.kredi} kredi · {g.hasPaket ? "paketli" : "paketsiz"} ·{" "}
{g.raporVar
? `rapor var (${g.sira?.toLocaleString("tr-TR")} · ${g.tur})`
: "rapor yok"}
</p>
<button
type="button"
disabled={mesgul}
onClick={() =>
calistir(async () => {
await authClient.signOut();
})
}
className="mt-2 cursor-pointer font-medium text-violet-700 underline-offset-2 hover:underline"
>
Çıkış yap
</button>
</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-900/10 bg-white/70 px-2.5 text-xs outline-none transition-colors duration-150 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 ${BASIS}`}
>
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 */}
<section>
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
Senaryo
</p>
<div className="mt-2 grid grid-cols-2 gap-1.5">
{(
[
{
ad: "Yeni kullanıcı",
aciklama: "5 kredi · paketsiz · raporsuz",
fn: async () => {
await devAyarla({ kredi: 5, hasPaket: false });
await devRaporSil();
},
},
{
ad: "Paketli",
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>
);
}