first commit

This commit is contained in:
2026-08-07 09:40:16 +03:30
commit 0c51b30059
37 changed files with 5146 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
"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>
);
}