213 lines
6.4 KiB
TypeScript
213 lines
6.4 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Search, Coins, Gift, Loader2 } from "lucide-react";
|
||
import Shell from "@/components/Shell";
|
||
import NumberInput, { fmtNum } from "@/components/NumberInput";
|
||
import Badge from "@/components/Badge";
|
||
import Empty from "@/components/Empty";
|
||
import { TableSkeleton } from "@/components/Skeleton";
|
||
import { api } from "@/lib/api";
|
||
import { toastError } from "@/lib/toast";
|
||
|
||
type User = {
|
||
id: number;
|
||
mobile: string;
|
||
first_name: string | null;
|
||
coins: number;
|
||
rank_points: number;
|
||
is_admin: number;
|
||
};
|
||
|
||
export default function UsersPage() {
|
||
const [q, setQ] = useState("");
|
||
const [users, setUsers] = useState<User[]>([]);
|
||
const [sel, setSel] = useState<User | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
async function load(query = "") {
|
||
setLoading(true);
|
||
try {
|
||
const r = await api.get<{ users: User[] }>(
|
||
`/users${query ? `?q=${encodeURIComponent(query)}` : ""}`,
|
||
);
|
||
setUsers(r.users || []);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
useEffect(() => {
|
||
load();
|
||
}, []);
|
||
|
||
return (
|
||
<Shell title="کاربران">
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
load(q);
|
||
}}
|
||
className="flex gap-2 mb-4"
|
||
>
|
||
<input
|
||
className="inp"
|
||
placeholder="جستجو با موبایل، نام یا شناسه…"
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
/>
|
||
<button className="btn btn-gold flex items-center gap-1">
|
||
<Search size={16} /> جستجو
|
||
</button>
|
||
</form>
|
||
|
||
{loading ? (
|
||
<TableSkeleton rows={6} cols={7} />
|
||
) : users.length === 0 ? (
|
||
<Empty
|
||
title="کاربری پیدا نشد"
|
||
hint="با موبایل، نام یا شناسه جستجو کنید."
|
||
/>
|
||
) : (
|
||
<div className="card overflow-x-auto">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>موبایل</th>
|
||
<th>نام</th>
|
||
<th>سکه</th>
|
||
<th>امتیاز رتبه</th>
|
||
<th>ادمین</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{users.map((u) => (
|
||
<tr key={u.id}>
|
||
<td className="text-[#8aa]">{u.id}</td>
|
||
<td dir="ltr">{u.mobile}</td>
|
||
<td>{u.first_name || "—"}</td>
|
||
<td className="text-[var(--gold)]" dir="ltr">
|
||
{fmtNum(u.coins)}
|
||
</td>
|
||
<td dir="ltr">{fmtNum(u.rank_points)}</td>
|
||
<td>{u.is_admin ? <Badge tone="gold">مدیر</Badge> : ""}</td>
|
||
<td>
|
||
<button className="btn btn-ghost" onClick={() => setSel(u)}>
|
||
مدیریت
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{sel && <ManageUser user={sel} onClose={() => setSel(null)} onDone={() => load(q)} />}
|
||
</Shell>
|
||
);
|
||
}
|
||
|
||
function ManageUser({
|
||
user,
|
||
onClose,
|
||
onDone,
|
||
}: {
|
||
user: User;
|
||
onClose: () => void;
|
||
onDone: () => void;
|
||
}) {
|
||
const [coins, setCoins] = useState(0);
|
||
const [tickets, setTickets] = useState(0);
|
||
const [vipDays, setVipDays] = useState(30);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function run(fn: () => Promise<unknown>) {
|
||
setBusy(true);
|
||
try {
|
||
await fn();
|
||
onDone();
|
||
} catch (e) {
|
||
toastError((e as Error).message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/60 grid place-items-center z-20 p-4" onClick={onClose}>
|
||
<div className="card p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||
<h2 className="font-bold text-lg mb-1">{user.first_name || user.mobile}</h2>
|
||
<p className="text-sm text-[#8aa] mb-4" dir="ltr">
|
||
{user.mobile} · سکه: {fmtNum(user.coins)}
|
||
</p>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="text-sm flex items-center gap-1 mb-1">
|
||
<Coins size={14} className="text-[var(--gold)]" /> تغییر سکه (+/−)
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<NumberInput value={coins} onChange={setCoins} allowNegative />
|
||
<button
|
||
className="btn btn-gold"
|
||
disabled={busy}
|
||
onClick={() =>
|
||
run(() => api.post("/users/coins", { user_id: user.id, amount: coins }))
|
||
}
|
||
>
|
||
{busy ? <Loader2 size={14} className="animate-spin" /> : "اعمال"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-sm flex items-center gap-1 mb-1">
|
||
<Gift size={14} className="text-[var(--gold)]" /> اعطای بلیط
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<NumberInput value={tickets} onChange={setTickets} />
|
||
<button
|
||
className="btn btn-ghost"
|
||
disabled={busy}
|
||
onClick={() =>
|
||
run(() =>
|
||
api.post("/users/grant", { user_id: user.id, kind: "ticket", amount: tickets }),
|
||
)
|
||
}
|
||
>
|
||
{busy ? <Loader2 size={14} className="animate-spin" /> : "اعطا"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-sm flex items-center gap-1 mb-1">
|
||
<Gift size={14} className="text-[var(--gold)]" /> اعطای VIP (روز)
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<NumberInput value={vipDays} onChange={setVipDays} />
|
||
<button
|
||
className="btn btn-ghost"
|
||
disabled={busy}
|
||
onClick={() =>
|
||
run(() =>
|
||
api.post("/users/grant", { user_id: user.id, kind: "vip", vip_days: vipDays }),
|
||
)
|
||
}
|
||
>
|
||
{busy ? <Loader2 size={14} className="animate-spin" /> : "اعطا"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button className="btn btn-ghost w-full mt-6" onClick={onClose}>
|
||
بستن
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|