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>
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
"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 "paid" sipariş oluşturup /odeme/sonuc'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>
|
||
);
|
||
}
|