generated from fahricansecer/boilerplate-fe
main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
This commit is contained in:
184
src/app/[locale]/(dashboard)/dashboard/admin/plans/page.tsx
Normal file
184
src/app/[locale]/(dashboard)/dashboard/admin/plans/page.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { LayoutGrid, Save, Loader2, CheckCircle2, RefreshCw } from "lucide-react";
|
||||
import { useAdminPlans, useAdminUpdatePlan } from "@/hooks/use-api";
|
||||
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 14 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||
};
|
||||
|
||||
type PlanField = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "number" | "text" | "toggle";
|
||||
suffix?: string;
|
||||
};
|
||||
|
||||
const PLAN_FIELDS: PlanField[] = [
|
||||
{ key: "displayName", label: "Görünen Ad", type: "text" },
|
||||
{ key: "monthlyPrice", label: "Aylık Fiyat", type: "number", suffix: "$¢ (cent)" },
|
||||
{ key: "yearlyPrice", label: "Yıllık Fiyat", type: "number", suffix: "$¢ (cent)" },
|
||||
{ key: "monthlyCredits", label: "Aylık Kredi", type: "number" },
|
||||
{ key: "maxDuration", label: "Max Süre", type: "number", suffix: "sn" },
|
||||
{ key: "maxResolution", label: "Max Çözünürlük", type: "text" },
|
||||
{ key: "maxProjects", label: "Max Proje", type: "number" },
|
||||
{ key: "isActive", label: "Aktif", type: "toggle" },
|
||||
];
|
||||
|
||||
export default function AdminPlansPage() {
|
||||
const { data, isLoading, refetch } = useAdminPlans();
|
||||
const updatePlan = useAdminUpdatePlan();
|
||||
const [savedIds, setSavedIds] = useState<Set<string>>(new Set());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [edits, setEdits] = useState<Record<string, any>>({});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawPlans = (data as any)?.data ?? data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const plans = (Array.isArray(rawPlans) ? rawPlans : []) as any[];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getValue(plan: any, key: string) {
|
||||
return edits[plan.id]?.[key] !== undefined ? edits[plan.id][key] : plan[key];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function setEdit(planId: string, key: string, value: any) {
|
||||
setEdits((prev) => ({ ...prev, [planId]: { ...prev[planId], [key]: value } }));
|
||||
}
|
||||
|
||||
function savePlan(planId: string) {
|
||||
if (!edits[planId]) return;
|
||||
updatePlan.mutate(
|
||||
{ id: planId, data: edits[planId] },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSavedIds((prev) => new Set([...prev, planId]));
|
||||
setTimeout(() => setSavedIds((prev) => { const n = new Set(prev); n.delete(planId); return n; }), 2000);
|
||||
setEdits((prev) => { const n = { ...prev }; delete n[planId]; return n; });
|
||||
refetch();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const PLAN_COLORS: Record<string, string> = {
|
||||
free: "from-gray-500/10 to-gray-600/5 border-gray-500/20",
|
||||
pro: "from-violet-500/10 to-violet-600/5 border-violet-500/20",
|
||||
business: "from-amber-500/10 to-amber-600/5 border-amber-500/20",
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{ show: { transition: { staggerChildren: 0.07 } } }}
|
||||
className="space-y-6 max-w-7xl mx-auto"
|
||||
>
|
||||
<motion.div variants={fadeUp} className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold flex items-center gap-2">
|
||||
<LayoutGrid size={22} className="text-amber-400" /> Plan Yönetimi
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||
Abonelik planlarını düzenle ve fiyatları güncelle
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => refetch()} className="btn-ghost text-sm flex items-center gap-2">
|
||||
<RefreshCw size={14} /> Yenile
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 size={28} className="animate-spin text-amber-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{plans.map((plan) => {
|
||||
const colorKey = plan.name?.toLowerCase() ?? "free";
|
||||
const gradient = PLAN_COLORS[colorKey] ?? PLAN_COLORS.free;
|
||||
const hasEdits = !!edits[plan.id];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={plan.id}
|
||||
variants={fadeUp}
|
||||
className={`card-surface p-5 bg-gradient-to-br ${gradient} space-y-4`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-[family-name:var(--font-display)] text-xl font-bold capitalize">
|
||||
{plan.name}
|
||||
</h2>
|
||||
<span className="text-xs text-[var(--color-text-ghost)]">
|
||||
{plan._count?.subscriptions ?? 0} abone
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{PLAN_FIELDS.map((field) => {
|
||||
const val = getValue(plan, field.key);
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label className="text-[10px] text-[var(--color-text-muted)] mb-1 block">
|
||||
{field.label} {field.suffix && <span className="text-[var(--color-text-ghost)]">({field.suffix})</span>}
|
||||
</label>
|
||||
{field.type === "toggle" ? (
|
||||
<button
|
||||
onClick={() => setEdit(plan.id, field.key, !val)}
|
||||
className={`flex items-center gap-2 text-sm px-3 py-1.5 rounded-xl ${
|
||||
val ? "bg-emerald-500/10 text-emerald-400" : "bg-rose-500/10 text-rose-400"
|
||||
}`}
|
||||
>
|
||||
{val ? "✓ Aktif" : "✗ Pasif"}
|
||||
</button>
|
||||
) : field.type === "number" ? (
|
||||
<input
|
||||
type="number"
|
||||
value={val ?? ""}
|
||||
onChange={(e) => setEdit(plan.id, field.key, Number(e.target.value))}
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={val ?? ""}
|
||||
onChange={(e) => setEdit(plan.id, field.key, e.target.value)}
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => savePlan(plan.id)}
|
||||
disabled={!hasEdits || updatePlan.isPending}
|
||||
className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||
savedIds.has(plan.id)
|
||||
? "bg-emerald-500/10 text-emerald-400"
|
||||
: hasEdits
|
||||
? "btn-primary"
|
||||
: "opacity-40 cursor-not-allowed bg-[var(--color-bg-elevated)] text-[var(--color-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{savedIds.has(plan.id) ? (
|
||||
<><CheckCircle2 size={14} /> Kaydedildi</>
|
||||
) : updatePlan.isPending ? (
|
||||
<><Loader2 size={14} className="animate-spin" /> Kaydediliyor…</>
|
||||
) : (
|
||||
<><Save size={14} /> Değişiklikleri Kaydet</>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user