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
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Upload, Loader2 } from "lucide-react";
import Shell from "@/components/Shell";
import EditableTable, { Col } from "@/components/EditableTable";
import { TableSkeleton } from "@/components/Skeleton";
import Thumb from "@/components/Thumb";
import { api, getAuth, ASSET_BASE } from "@/lib/api";
import { toastError, toastSuccess } from "@/lib/toast";
type Row = Record<string, unknown>;
const COLS: Col[] = [
{ key: "id", label: "شناسه" },
{ key: "title", label: "عنوان" },
{ key: "price_coins", label: "قیمت (سکه)", type: "number" },
{ key: "vip", label: "VIP رایگان", type: "bool" },
{ key: "sort", label: "ترتیب", type: "number" },
];
const API =
process.env.NEXT_PUBLIC_API_URL || "https://hakem.approagency.ir/admin/api";
export default function AvatarsPage() {
const [rows, setRows] = useState<Row[]>([]);
const [ver, setVer] = useState(1); // تازه‌سازیِ کشِ تصویر پس از آپلود
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const r = await api.get<{ avatars: Row[] }>("/avatars");
setRows(r.avatars || []);
setVer((v) => v + 1);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
return (
<Shell title="شخصیت‌ها" subtitle="آواتارهای قابلِ خریدِ بازیکن">
<p className="text-sm text-[#8aa] mb-4">
شخصیتِ آواتار که کاربر میخرد و در کلِ بازی (سرِ میز، پروفایل و لابی) دیده
میشود. ابتدا ردیف را با شناسه و قیمت بسازید، سپس یک تصویرِ{" "}
<code>png</code> یا <code>svg</code> (ترجیحاً مربع و با پسزمینهی شفاف)
آپلود کنید.
</p>
{loading ? (
<TableSkeleton rows={5} cols={COLS.length} />
) : (
<EditableTable
cols={COLS}
rows={rows}
newTemplate={{ id: "", title: "", price_coins: 0, vip: false, sort: 0 }}
onSave={async (row) => {
await api.post("/avatars", row);
await load();
}}
onDelete={async (id) => {
if (!confirm("حذف شخصیت؟")) return;
await api.del(`/avatars?id=${encodeURIComponent(id)}`);
await load();
}}
preview={(row) => (
<Thumb
src={`${ASSET_BASE}/avatars/${row.id}.${row.img_ext || "png"}?v=${ver}`}
size={48}
/>
)}
extra={(row) => <UploadBtn id={String(row.id)} onDone={load} />}
/>
)}
</Shell>
);
}
function UploadBtn({ id, onDone }: { id: string; onDone: () => void }) {
const ref = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
async function upload(file: File) {
setBusy(true);
try {
const fd = new FormData();
fd.append("image", file);
const auth = getAuth();
const res = await fetch(`${API}/avatars/${encodeURIComponent(id)}/upload`, {
method: "POST",
headers: auth ? { Authorization: `Basic ${auth}` } : {},
body: fd,
});
if (!res.ok) throw new Error("آپلود ناموفق بود");
toastSuccess("تصویر آپلود شد");
onDone();
} catch (e) {
toastError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<>
<button
className="btn btn-ghost flex items-center gap-1"
disabled={busy}
onClick={() => ref.current?.click()}
title="آپلود تصویر"
>
{busy ? (
<>
<Loader2 size={14} className="animate-spin" /> در حال آپلود
</>
) : (
<Upload size={14} />
)}
</button>
<input
ref={ref}
type="file"
accept="image/png,image/svg+xml,.svg"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload(f);
e.target.value = "";
}}
/>
</>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Upload, Loader2 } from "lucide-react";
import Shell from "@/components/Shell";
import EditableTable, { Col } from "@/components/EditableTable";
import Thumb from "@/components/Thumb";
import { api, getAuth, ASSET_BASE } from "@/lib/api";
import { toastError, toastSuccess } from "@/lib/toast";
type Row = Record<string, unknown>;
const COLS: Col[] = [
{ key: "id", label: "شناسه" },
{ key: "title", label: "عنوان" },
{ key: "price_coins", label: "قیمت (سکه)", type: "number" },
{ key: "vip", label: "VIP رایگان", type: "bool" },
{ key: "sort", label: "ترتیب", type: "number" },
];
const API =
process.env.NEXT_PUBLIC_API_URL || "https://hakem.approagency.ir/admin/api";
export default function CarpetsPage() {
const [rows, setRows] = useState<Row[]>([]);
const [ver, setVer] = useState(1); // برای تازه‌سازیِ کشِ تصویر پس از آپلود
async function load() {
const r = await api.get<{ carpets: Row[] }>("/carpets");
setRows(r.carpets || []);
setVer((v) => v + 1);
}
useEffect(() => {
load();
}, []);
return (
<Shell title="فرش‌ها">
<p className="text-sm text-[#8aa] mb-4">
هر فرش بهجای میزِ بازی استفاده میشود. پس از ساختِ ردیف، تصویرِ jpg آپلود کنید.
شناسهٔ <code>classic</code> یعنی میزِ پیشفرضِ بدونِ تصویر.
</p>
<EditableTable
cols={COLS}
rows={rows}
newTemplate={{ id: "", title: "", price_coins: 0, vip: false, sort: 0 }}
onSave={async (row) => {
await api.post("/carpets", row);
await load();
}}
onDelete={async (id) => {
if (!confirm("حذف فرش؟")) return;
await api.del(`/carpets?id=${encodeURIComponent(id)}`);
await load();
}}
preview={(row) =>
String(row.id) === "classic" ? (
<span className="text-[#8aa] text-xs">پیشفرض</span>
) : (
<Thumb src={`${ASSET_BASE}/carpets/${row.id}.jpg?v=${ver}`} size={48} />
)
}
extra={(row) => <UploadBtn id={String(row.id)} onDone={load} />}
/>
</Shell>
);
}
function UploadBtn({ id, onDone }: { id: string; onDone: () => void }) {
const ref = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
async function upload(file: File) {
setBusy(true);
try {
const fd = new FormData();
fd.append("image", file);
const auth = getAuth();
const res = await fetch(`${API}/carpets/${encodeURIComponent(id)}/upload`, {
method: "POST",
headers: auth ? { Authorization: `Basic ${auth}` } : {},
body: fd,
});
if (!res.ok) throw new Error("آپلود ناموفق بود");
toastSuccess("تصویر آپلود شد");
onDone();
} catch (e) {
toastError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<>
<button
className="btn btn-ghost flex items-center gap-1"
disabled={busy}
onClick={() => ref.current?.click()}
title="آپلود تصویر"
>
{busy ? (
<>
<Loader2 size={14} className="animate-spin" /> در حال آپلود
</>
) : (
<Upload size={14} />
)}
</button>
<input
ref={ref}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload(f);
e.target.value = "";
}}
/>
</>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useEffect, useState } from "react";
import { Trash2, Plus } from "lucide-react";
import Shell from "@/components/Shell";
import EditableTable, { Col } from "@/components/EditableTable";
import { api } from "@/lib/api";
type Row = Record<string, unknown>;
const PACK_COLS: Col[] = [
{ key: "id", label: "شناسه" },
{ key: "title", label: "عنوان" },
{ key: "kind", label: "نوع (text/emoji)" },
{ key: "price_coins", label: "قیمت (سکه)", type: "number" },
{ key: "vip", label: "VIP رایگان", type: "bool" },
{ key: "sort", label: "ترتیب", type: "number" },
];
export default function ChatPage() {
const [packs, setPacks] = useState<Row[]>([]);
const [messages, setMessages] = useState<Row[]>([]);
const [sel, setSel] = useState<string>("");
const [newMsg, setNewMsg] = useState("");
async function load() {
const r = await api.get<{ packs: Row[]; messages: Row[] }>("/chat");
setPacks(r.packs || []);
setMessages(r.messages || []);
if (!sel && r.packs?.length) setSel(String(r.packs[0].id));
}
useEffect(() => {
load();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const packMsgs = messages.filter((m) => String(m.pack_id) === sel);
async function addMsg() {
if (!sel || !newMsg.trim()) return;
await api.post("/chat/message", { pack_id: sel, body: newMsg.trim(), sort: packMsgs.length });
setNewMsg("");
await load();
}
return (
<Shell title="چت">
<h2 className="font-bold mb-3">بستهها</h2>
<EditableTable
cols={PACK_COLS}
rows={packs}
newTemplate={{ id: "", title: "", kind: "text", price_coins: 0, vip: false, sort: 0 }}
onSave={async (row) => {
await api.post("/chat/pack", row);
await load();
}}
onDelete={async (id) => {
if (!confirm("حذف بسته و پیام‌هایش؟")) return;
await api.del(`/chat/pack?id=${encodeURIComponent(id)}`);
await load();
}}
/>
<div className="flex items-center gap-2 mt-6 mb-3">
<h2 className="font-bold">پیامها</h2>
<select
className="inp w-auto"
value={sel}
onChange={(e) => setSel(e.target.value)}
>
{packs.map((p) => (
<option key={String(p.id)} value={String(p.id)}>
{String(p.title)}
</option>
))}
</select>
</div>
<div className="card p-4">
<div className="flex gap-2 mb-4">
<input
className="inp"
placeholder="پیام یا شکلکِ جدید…"
value={newMsg}
onChange={(e) => setNewMsg(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addMsg()}
/>
<button className="btn btn-gold flex items-center gap-1" onClick={addMsg}>
<Plus size={16} /> افزودن
</button>
</div>
<div className="flex flex-wrap gap-2">
{packMsgs.map((m) => (
<span
key={String(m.id)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[var(--panel-2)] border border-[#16305a]"
>
{String(m.body)}
<button
onClick={async () => {
await api.del(`/chat/message?id=${m.id}`);
await load();
}}
className="text-red-400"
>
<Trash2 size={13} />
</button>
</span>
))}
{packMsgs.length === 0 && (
<span className="text-[#8aa] text-sm">پیامی نیست</span>
)}
</div>
</div>
</Shell>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useEffect, useState } from "react";
import Shell from "@/components/Shell";
import EditableTable, { Col } from "@/components/EditableTable";
import { api } from "@/lib/api";
type Row = Record<string, unknown>;
const COLS: Col[] = [
{ key: "id", label: "شناسه" },
{ key: "title", label: "عنوان" },
{ key: "price_coins", label: "قیمت (سکه)", type: "number" },
{ key: "vip", label: "VIP رایگان", type: "bool" },
{ key: "c1", label: "رنگ ۱ (hex)" },
{ key: "c2", label: "رنگ ۲ (hex)" },
{ key: "sort", label: "ترتیب", type: "number" },
{ key: "enabled", label: "فعال", type: "bool" },
];
const hex = (s: unknown) => {
const v = String(s || "").replace("#", "").trim();
return /^[0-9a-fA-F]{6}$/.test(v) ? `#${v}` : null;
};
function FramePreview({ row }: { row: Record<string, unknown> }) {
const a = hex(row.c1);
const b = hex(row.c2);
if (!a || !b) {
return <span className="text-[#8aa] text-xs">رتبه</span>;
}
return (
<div
style={{
width: 34,
height: 34,
borderRadius: "50%",
background: `linear-gradient(160deg, ${a}, ${b})`,
border: "1px solid #1e3a63",
}}
/>
);
}
export default function FramesPage() {
const [rows, setRows] = useState<Row[]>([]);
async function load() {
const r = await api.get<{ frames: Row[] }>("/frames");
setRows(r.frames || []);
}
useEffect(() => {
load();
}, []);
return (
<Shell title="قاب‌ها">
<p className="text-sm text-[#8aa] mb-4">
قابِ آواتار (کازمتیک). رنگِ گرادیان را با دو کدِ hex (مثلِ <code>F3D27A</code>) تعیین
کنید؛ قابِ جدید بدونِ بهروزرسانیِ اپ در بازی دیده میشود. شناسهٔ <code>none</code> = قابِ رتبه.
</p>
<EditableTable
cols={COLS}
rows={rows}
newTemplate={{
id: "",
title: "",
price_coins: 0,
vip: false,
c1: "",
c2: "",
sort: 0,
enabled: true,
}}
onSave={async (row) => {
await api.post("/frames", row);
await load();
}}
onDelete={async (id) => {
if (!confirm("حذف قاب؟")) return;
await api.del(`/frames?id=${encodeURIComponent(id)}`);
await load();
}}
preview={(row) => <FramePreview row={row} />}
/>
</Shell>
);
}
+188
View File
@@ -0,0 +1,188 @@
@import "tailwindcss";
/* فونتِ دانا — همان فونتِ اپلیکیشن. basePath=/panel پس مسیرِ public با /panel آغاز می‌شود. */
@font-face {
font-family: "Dana";
src: url("/panel/fonts/dana_regular.ttf") format("truetype");
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "Dana";
src: url("/panel/fonts/dana_medium.ttf") format("truetype");
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: "Dana";
src: url("/panel/fonts/dana_bold.ttf") format("truetype");
font-weight: 700;
font-display: swap;
}
:root {
--bg: #0e2347;
--bg-dark: #081428;
--panel: #17345c;
--panel-2: #0c2848;
--gold: #e9b949;
--gold-dark: #b8860b;
--line: #1e5fa8;
}
html,
body {
background: var(--bg-dark);
color: #e8eef7;
font-family: "Dana", -apple-system, "Segoe UI", Tahoma, sans-serif;
}
/* اسکرول‌بار تیره */
::-webkit-scrollbar {
width: 9px;
height: 9px;
}
::-webkit-scrollbar-thumb {
background: #1e3a63;
border-radius: 6px;
}
.card {
background: linear-gradient(160deg, var(--panel), var(--panel-2));
border: 1px solid #1e3a63;
border-radius: 16px;
}
.btn {
border-radius: 10px;
padding: 8px 14px;
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: filter 0.15s;
}
.btn:hover {
filter: brightness(1.1);
}
.btn:disabled {
opacity: 0.5;
cursor: default;
}
.btn-gold {
background: linear-gradient(180deg, var(--gold), var(--gold-dark));
color: #04101f;
}
.btn-ghost {
background: transparent;
border: 1px solid var(--line);
color: #cfe0f5;
}
.btn-danger {
background: #7f1d1d;
color: #fecaca;
}
.inp {
background: #0b1c39;
border: 1px solid #1e3a63;
border-radius: 8px;
padding: 7px 10px;
font-size: 13px;
color: #e8eef7;
width: 100%;
}
.inp:focus {
outline: none;
border-color: var(--gold);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
padding: 8px 10px;
text-align: right;
border-bottom: 1px solid #16305a;
white-space: nowrap;
}
th {
color: #8ab4e8;
font-weight: 700;
}
/* نشانک‌ها — تن‌های وضعیت/نوع */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
line-height: 1.6;
white-space: nowrap;
}
.badge::before {
content: "";
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
flex: none;
}
.badge-gold { background: rgba(233, 185, 73, 0.14); color: #e9b949; }
.badge-blue { background: rgba(97, 165, 255, 0.14); color: #7bb3ff; }
.badge-purple{ background: rgba(168, 130, 255, 0.14); color: #bda6ff; }
.badge-green { background: rgba(74, 222, 128, 0.12); color: #6ee7a0; }
.badge-red { background: rgba(248, 113, 113, 0.12); color: #fca5a5; }
.badge-gray { background: rgba(148, 163, 184, 0.14); color: #cbd5e1; }
/* اسکلتِ لودینگ */
.skeleton {
position: relative;
overflow: hidden;
background: #163a63;
border-radius: 6px;
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.06), transparent);
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
100% { transform: translateX(100%); }
}
/* حلقه‌ی فوکوسِ طلایی برای دسترسی‌پذیری */
:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
border-radius: 4px;
}
/* نوار ابزارِ فیلترها — در موبایل جستجو تمام‌عرض می‌شود */
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.toolbar .inp,
.toolbar select {
width: auto;
min-width: 130px;
}
@media (max-width: 640px) {
.toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar .inp,
.toolbar select,
.toolbar button {
width: 100%;
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import "./globals.css";
import Toaster from "@/components/Toaster";
export const metadata: Metadata = {
title: "پنل مدیریت حکم‌شو",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="fa" dir="rtl">
<body>
{children}
<Toaster />
</body>
</html>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Trophy } from "lucide-react";
import { api } from "@/lib/api";
export default function LoginPage() {
const router = useRouter();
const [user, setUser] = useState("");
const [pass, setPass] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setErr("");
const ok = await api.login(user.trim(), pass);
setBusy(false);
if (ok) router.replace("/");
else setErr("نام کاربری یا رمز اشتباه است");
}
return (
<div className="min-h-screen grid place-items-center p-6">
<form
onSubmit={submit}
className="card w-full max-w-sm p-7 flex flex-col gap-4"
>
<div className="flex flex-col items-center gap-2 mb-2">
<Trophy className="text-[var(--gold)]" size={38} />
<h1 className="text-xl font-bold text-[var(--gold)]">
پنل مدیریت حکمشو
</h1>
</div>
<label className="text-sm">
نام کاربری
<input
className="inp mt-1"
value={user}
onChange={(e) => setUser(e.target.value)}
autoFocus
/>
</label>
<label className="text-sm">
رمز عبور
<input
className="inp mt-1"
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
/>
</label>
{err && <p className="text-red-400 text-sm">{err}</p>}
<button className="btn btn-gold mt-2" disabled={busy}>
{busy ? "…" : "ورود"}
</button>
</form>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
Users,
ShoppingBag,
Gamepad2,
Coins,
Trophy,
RotateCcw,
Wallet,
} from "lucide-react";
import Shell from "@/components/Shell";
import StatCard from "@/components/StatCard";
import Badge from "@/components/Badge";
import RevenueChart from "@/components/RevenueChart";
import { fmtNum } from "@/components/NumberInput";
import { api } from "@/lib/api";
import { KIND_LABEL, KIND_TONE } from "@/lib/labels";
type Recent = {
id: number;
mobile: string;
name: string;
kind: string;
product_id: string;
product_title: string;
price_toman: number;
created_local: string;
};
type Dash = {
users: number;
purchases: number;
games: number;
coins: number;
tournaments: number;
season: number;
revenue: number;
revenue_today: { count: number; revenue: number };
revenue_14d: { date: string; revenue: number; count: number }[];
recent: Recent[];
};
export default function DashboardPage() {
const [d, setD] = useState<Dash | null>(null);
const [err, setErr] = useState("");
useEffect(() => {
api
.get<Dash>("/dashboard")
.then(setD)
.catch((e) => setErr(e.message));
}, []);
async function resetSeason() {
if (!confirm("فصلِ رتبه‌بندی صفر شود؟ این کار برگشت‌ناپذیر است.")) return;
await api.post("/season/reset");
const nd = await api.get<Dash>("/dashboard");
setD(nd);
}
const cards = [
{ label: "درآمد کل", value: d ? fmtNum(d.revenue) : undefined, suffix: "تومان", icon: Wallet, c: "var(--gold)" },
{ label: "کاربران", value: d ? fmtNum(d.users) : undefined, icon: Users, c: "#60a5fa" },
{ label: "خریدها", value: d ? fmtNum(d.purchases) : undefined, icon: ShoppingBag, c: "#c084fc" },
{ label: "بازی‌ها", value: d ? fmtNum(d.games) : undefined, icon: Gamepad2, c: "#4ade80" },
{ label: "کل سکه‌ها", value: d ? fmtNum(d.coins) : undefined, icon: Coins, c: "var(--gold)" },
{ label: "تورنومنت‌ها", value: d ? fmtNum(d.tournaments) : undefined, icon: Trophy, c: "#fbbf24" },
];
return (
<Shell title="داشبورد">
{err && <p className="text-red-400 mb-4">{err}</p>}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
{cards.map((c) => (
<StatCard
key={c.label}
label={c.label}
value={c.value}
suffix={c.suffix}
icon={<c.icon size={18} />}
color={c.c}
loading={!d && !err}
/>
))}
</div>
<div className="card p-5 mt-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<div>
<div className="font-bold">درآمدِ ۱۴ روزِ گذشته</div>
<div className="text-xs text-[#8ab4e8]">بر اساسِ پرداختهای تأییدشده</div>
</div>
{d && (
<div className="text-sm">
<span dir="ltr" className="font-bold text-[var(--gold)]">
{fmtNum(d.revenue_today.revenue)}
</span>
<span className="text-[#8ab4e8] mr-1">تومان امروز</span>
</div>
)}
</div>
{d ? (
<RevenueChart days={d.revenue_14d} />
) : (
<div className="skeleton h-28 w-full" />
)}
</div>
<div className="card mt-6 overflow-hidden">
<div className="flex items-center justify-between px-5 pt-4 pb-2">
<div className="font-bold">آخرین خریدها</div>
<Link href="/transactions" className="text-xs text-[var(--gold)] hover:underline">
همهی تراکنشها
</Link>
</div>
<table>
<thead>
<tr>
<th>کاربر</th>
<th>محصول</th>
<th>نوع</th>
<th>مبلغ</th>
<th>زمان (تهران)</th>
</tr>
</thead>
<tbody>
{d?.recent.map((r) => (
<tr key={r.id}>
<td>
<div className="font-bold">{r.name || "—"}</div>
<div className="text-[11px] text-[#6f90bd]" dir="ltr">
{r.mobile}
</div>
</td>
<td className="font-bold">{r.product_title || r.product_id}</td>
<td>
<Badge tone={KIND_TONE[r.kind] ?? "gray"}>
{KIND_LABEL[r.kind] ?? (r.kind || "نامشخص")}
</Badge>
</td>
<td>
<span dir="ltr" className="font-bold text-[var(--gold)]">
{fmtNum(r.price_toman)}
</span>{" "}
<span className="text-[11px] text-[#8ab4e8]">تومان</span>
</td>
<td className="text-[#a9c2e4]" dir="ltr">
{r.created_local}
</td>
</tr>
))}
{d && d.recent.length === 0 && (
<tr>
<td colSpan={5} className="text-center text-[#8aa] py-8">
هنوز خریدی ثبت نشده
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="card p-5 mt-6 flex items-center justify-between flex-wrap gap-3">
<div>
<div className="font-bold">فصل رتبهبندی: {d?.season ?? "—"}</div>
<div className="text-sm text-[#8ab4e8]">
با ریست، امتیازِ رتبهی همه صفر و فصلِ جدید آغاز میشود.
</div>
</div>
<button className="btn btn-danger flex items-center gap-2" onClick={resetSeason}>
<RotateCcw size={16} /> ریست فصل
</button>
</div>
</Shell>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useEffect, useState } from "react";
import Shell from "@/components/Shell";
import { TableSkeleton } from "@/components/Skeleton";
import { api } from "@/lib/api";
import { toastError } from "@/lib/toast";
type Tier = {
id: string;
label: string;
c1: string;
c2: string;
sort: number;
};
const hex = (s: string) => {
const v = String(s || "").replace("#", "").trim();
return /^[0-9a-fA-F]{6}$/.test(v) ? `#${v}` : null;
};
function Swatch({ c1, c2 }: { c1: string; c2: string }) {
const a = hex(c1);
const b = hex(c2);
return (
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
background:
a && b ? `linear-gradient(160deg, ${a}, ${b})` : "#16305a",
border: "1px solid #1e3a63",
}}
/>
);
}
export default function RankTiersPage() {
const [tiers, setTiers] = useState<Tier[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const r = await api.get<{ rank_tiers: Tier[] }>("/rank-tiers");
setTiers(r.rank_tiers || []);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
const patch = (id: string, key: keyof Tier, val: string | number) =>
setTiers((ts) => ts.map((t) => (t.id === id ? { ...t, [key]: val } : t)));
async function save(t: Tier) {
setBusy(t.id);
try {
await api.post("/rank-tiers", t);
} catch (e) {
toastError((e as Error).message);
} finally {
setBusy(null);
}
}
return (
<Shell title="رتبه‌ها">
<p className="text-sm text-[#8aa] mb-4">
رنگ و برچسبِ رتبهها (برنز تا پادشاه). اینها در حلقهی آواتار و نشانِ
رتبهی داخلِ بازی دیده میشوند و بدونِ بهروزرسانیِ اپ اعمال میشوند.
رنگ را با کدِ hex (مثلِ <code>FFD54F</code>) وارد کنید.
</p>
{loading ? (
<TableSkeleton rows={5} cols={7} />
) : (
<div className="card overflow-x-auto">
<table>
<thead>
<tr>
<th>پیشنمایش</th>
<th>شناسه</th>
<th>برچسب</th>
<th>رنگ ۱ (hex)</th>
<th>رنگ ۲ (hex)</th>
<th>ترتیب</th>
<th></th>
</tr>
</thead>
<tbody>
{tiers.map((t) => (
<tr key={t.id}>
<td>
<Swatch c1={t.c1} c2={t.c2} />
</td>
<td className="text-[#8aa]">{t.id}</td>
<td>
<input
className="inp"
style={{ minWidth: 90 }}
value={t.label}
onChange={(e) => patch(t.id, "label", e.target.value)}
/>
</td>
<td>
<input
className="inp"
style={{ minWidth: 90 }}
value={t.c1}
onChange={(e) => patch(t.id, "c1", e.target.value)}
/>
</td>
<td>
<input
className="inp"
style={{ minWidth: 90 }}
value={t.c2}
onChange={(e) => patch(t.id, "c2", e.target.value)}
/>
</td>
<td>
<input
className="inp"
style={{ minWidth: 60 }}
type="number"
value={t.sort}
onChange={(e) => patch(t.id, "sort", +e.target.value)}
/>
</td>
<td>
<button
className="btn btn-gold"
disabled={busy === t.id}
onClick={() => save(t)}
>
ذخیره
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Shell>
);
}
+227
View File
@@ -0,0 +1,227 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Upload } from "lucide-react";
import Shell from "@/components/Shell";
import EditableTable, { Col } from "@/components/EditableTable";
import { TableSkeleton } from "@/components/Skeleton";
import Thumb from "@/components/Thumb";
import { api, getAuth, ASSET_BASE } from "@/lib/api";
import { toastError } from "@/lib/toast";
type Catalog = Record<string, Record<string, unknown>[]>;
const TABS: { key: string; label: string; cols: Col[]; tpl: Record<string, unknown> }[] = [
{
key: "coin",
label: "سکه",
cols: [
{ key: "id", label: "شناسه" },
{ key: "title", label: "عنوان" },
{ key: "coins", label: "سکه", type: "number" },
{ key: "vip_days", label: "VIP روز", type: "number" },
{ key: "price_toman", label: "قیمت", type: "number" },
{ key: "bonus_pct", label: "٪هدیه", type: "number" },
{ key: "sku", label: "SKU", readonly: true },
{ key: "sort", label: "ترتیب", type: "number" },
{ key: "enabled", label: "فعال", type: "bool" },
],
tpl: { id: "", title: "", coins: 0, vip_days: 0, price_toman: 0, bonus_pct: 0, sort: 0, enabled: true },
},
{
key: "ticket",
label: "بلیط",
cols: [
{ key: "id", label: "شناسه" },
{ key: "title", label: "عنوان" },
{ key: "tickets", label: "بلیط", type: "number" },
{ key: "price_toman", label: "قیمت", type: "number" },
{ key: "sku", label: "SKU", readonly: true },
{ key: "sort", label: "ترتیب", type: "number" },
{ key: "enabled", label: "فعال", type: "bool" },
],
tpl: { id: "", title: "", tickets: 0, price_toman: 0, sort: 0, enabled: true },
},
{
key: "card",
label: "کارت",
cols: [
{ key: "id", label: "شناسه" },
{ key: "title", label: "عنوان" },
{ key: "price_coins", label: "قیمت (سکه)", type: "number" },
{ key: "sort", label: "ترتیب", type: "number" },
{ key: "enabled", label: "فعال", type: "bool" },
],
tpl: { id: "", title: "", price_coins: 0, sort: 0, enabled: true },
},
{
key: "booster",
label: "تجهیزات",
cols: [
{ key: "id", label: "شناسه" },
{ key: "title", label: "عنوان" },
{ key: "multiplier", label: "ضریب", type: "number" },
{ key: "hours", label: "ساعت", type: "number" },
{ key: "price_toman", label: "قیمت", type: "number" },
{ key: "sku", label: "SKU", readonly: true },
{ key: "sort", label: "ترتیب", type: "number" },
{ key: "enabled", label: "فعال", type: "bool" },
],
tpl: { id: "", title: "", multiplier: 2, hours: 24, price_toman: 0, sort: 0, enabled: true },
},
{
key: "vip",
label: "VIP",
cols: [
{ key: "id", label: "شناسه" },
{ key: "title", label: "عنوان" },
{ key: "months", label: "ماه", type: "number" },
{ key: "price_toman", label: "قیمت", type: "number" },
{ key: "sku", label: "SKU", readonly: true },
{ key: "sort", label: "ترتیب", type: "number" },
{ key: "enabled", label: "فعال", type: "bool" },
],
tpl: { id: "", title: "", months: 1, price_toman: 0, sort: 0, enabled: true },
},
{
key: "tier",
label: "میزها",
cols: [
{ key: "id", label: "شناسه" },
{ key: "title", label: "عنوان" },
{ key: "hands", label: "دست", type: "number" },
{ key: "entry", label: "ورودی", type: "number" },
{ key: "prize", label: "جایزه", type: "number" },
{ key: "xp", label: "XP", type: "number" },
{ key: "trophy", label: "جام", type: "number" },
{ key: "rank_reward", label: "رتبه", type: "number" },
{ key: "sort", label: "ترتیب", type: "number" },
{ key: "enabled", label: "فعال", type: "bool" },
],
tpl: { id: "", title: "", hands: 7, entry: 0, prize: 0, xp: 0, trophy: 0, rank_reward: 0, sort: 0, enabled: true },
},
];
export default function ShopPage() {
const [cat, setCat] = useState<Catalog>({});
const [tab, setTab] = useState("coin");
const [ver, setVer] = useState(1);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
setCat(await api.get<Catalog>("/catalog"));
setVer((v) => v + 1);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
const active = TABS.find((t) => t.key === tab)!;
const isCard = tab === "card";
return (
<Shell title="فروشگاه" subtitle="کاتالوگ سکه، بلیط، کارت، تجهیزات، VIP و میزها">
<div className="flex gap-2 mb-4 flex-wrap">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`btn ${tab === t.key ? "btn-gold" : "btn-ghost"}`}
>
{t.label}
</button>
))}
</div>
{loading ? (
<TableSkeleton rows={5} cols={active.cols.length} />
) : (
<EditableTable
key={tab}
cols={active.cols}
rows={cat[tab] || []}
newTemplate={active.tpl}
onSave={async (row) => {
await api.post(`/catalog/${tab}`, row);
await load();
}}
onDelete={async (id) => {
if (!confirm("حذف آیتم؟")) return;
await api.del(`/catalog/${tab}?id=${encodeURIComponent(id)}`);
await load();
}}
preview={
isCard
? (row) =>
String(row.id) === "simple" ? (
<span className="text-[#8aa] text-xs">پیشفرض</span>
) : (
<Thumb src={`${ASSET_BASE}/cards/${row.id}/AS.png?v=${ver}`} size={44} rounded={6} />
)
: undefined
}
extra={isCard ? (row) => <DeckUpload id={String(row.id)} onDone={load} /> : undefined}
/>
)}
<p className="text-xs text-[#8aa] mt-3">
{isCard
? "برای هر اسکینِ کارت، یک فایلِ zip از تصاویرِ کارت‌ها (مثلِ AS.png، KH.png، back.jpg) آپلود کنید."
: "SKU برای سکه/بلیط/تجهیزات/VIP هنگام ساخت خودکار تولید می‌شود؛ همان را در پنل مایکت/کافه‌بازار ثبت کنید."}
</p>
</Shell>
);
}
// آپلودِ zipِ اسکینِ کارت (به /catalog/card/{id}/upload، فیلد "deck").
function DeckUpload({ id, onDone }: { id: string; onDone: () => void }) {
const ref = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
async function upload(file: File) {
setBusy(true);
try {
const fd = new FormData();
fd.append("deck", file);
const auth = getAuth();
const res = await fetch(`${ASSET_BASE}/admin/api/catalog/card/${encodeURIComponent(id)}/upload`, {
method: "POST",
headers: auth ? { Authorization: `Basic ${auth}` } : {},
body: fd,
});
if (!res.ok) throw new Error("آپلودِ zip ناموفق بود");
onDone();
} catch (e) {
toastError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<>
<button
className="btn btn-ghost"
disabled={busy}
onClick={() => ref.current?.click()}
title="آپلود zip کارت‌ها"
>
<Upload size={14} />
</button>
<input
ref={ref}
type="file"
accept=".zip"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload(f);
e.target.value = "";
}}
/>
</>
);
}
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useEffect, useState } from "react";
import { Trash2, Plus, Pencil, X } from "lucide-react";
import Shell from "@/components/Shell";
import Badge, { type BadgeTone } from "@/components/Badge";
import Empty from "@/components/Empty";
import { TableSkeleton } from "@/components/Skeleton";
import { api } from "@/lib/api";
import { toastError } from "@/lib/toast";
type Tournament = {
id: number;
title: string;
description: string;
entry_fee: number;
prizes: number[];
prize_pool: number;
status: string;
players: number;
starts_local: string;
ends_local: string;
};
const STATUS: Record<string, { t: string; tone: BadgeTone }> = {
active: { t: "در حال برگزاری", tone: "green" },
upcoming: { t: "به‌زودی", tone: "gold" },
ended: { t: "پایان‌یافته", tone: "gray" },
};
const EMPTY = {
title: "",
description: "",
entry_fee: 0,
prizes: "",
starts_at: "",
ends_at: "",
};
// "2026-07-07 13:00" → "2026-07-07T13:00" (ورودیِ datetime-local)
const toLocalInput = (s: string) => (s || "").replace(" ", "T");
export default function TournamentsPage() {
const [list, setList] = useState<Tournament[]>([]);
const [form, setForm] = useState({ ...EMPTY });
const [editingId, setEditingId] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const r = await api.get<{ tournaments: Tournament[] }>("/tournaments");
setList(r.tournaments || []);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
function startEdit(t: Tournament) {
setEditingId(t.id);
setForm({
title: t.title,
description: t.description || "",
entry_fee: t.entry_fee,
prizes: (t.prizes || []).join(","),
starts_at: toLocalInput(t.starts_local),
ends_at: toLocalInput(t.ends_local),
});
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
}
function cancelEdit() {
setEditingId(null);
setForm({ ...EMPTY });
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
try {
const payload = {
title: form.title,
description: form.description,
entry_fee: Number(form.entry_fee),
prizes: form.prizes
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n)),
starts_at: form.starts_at,
ends_at: form.ends_at,
};
if (editingId) {
await api.put("/tournaments", { id: editingId, ...payload });
} else {
await api.post("/tournaments", payload);
}
cancelEdit();
await load();
} catch (e) {
toastError((e as Error).message);
} finally {
setBusy(false);
}
}
async function remove(id: number) {
if (!confirm("حذف تورنومنت؟")) return;
await api.del(`/tournaments?id=${id}`);
if (editingId === id) cancelEdit();
await load();
}
return (
<Shell title="تورنومنت‌ها">
{loading ? (
<TableSkeleton rows={5} cols={9} />
) : list.length === 0 ? (
<Empty
title="تورنومنتی وجود ندارد"
hint="با فرمِ پایینِ صفحه، اولین تورنومنت را بسازید."
/>
) : (
<div className="card overflow-x-auto mb-6">
<table>
<thead>
<tr>
<th>#</th>
<th>عنوان</th>
<th>ورودی</th>
<th>جوایز</th>
<th>وضعیت</th>
<th>بازیکنان</th>
<th>شروع (تهران)</th>
<th>پایان (تهران)</th>
<th></th>
</tr>
</thead>
<tbody>
{list.map((t) => {
const s = STATUS[t.status] || STATUS.ended;
return (
<tr key={t.id} className={editingId === t.id ? "bg-[rgba(233,185,73,.08)]" : ""}>
<td className="text-[#8aa]">{t.id}</td>
<td className="font-bold">{t.title}</td>
<td>{t.entry_fee}</td>
<td className="text-[var(--gold)]">{(t.prizes || []).join("، ")}</td>
<td>
<Badge tone={s.tone}>{s.t}</Badge>
</td>
<td>{t.players}</td>
<td dir="ltr" className="text-xs">{t.starts_local}</td>
<td dir="ltr" className="text-xs">{t.ends_local}</td>
<td className="flex gap-1">
<button className="btn btn-ghost" onClick={() => startEdit(t)} title="ویرایش">
<Pencil size={14} />
</button>
<button className="btn btn-danger" onClick={() => remove(t.id)} title="حذف">
<Trash2 size={14} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<form onSubmit={submit} className="card p-5">
<h2 className="font-bold mb-4 flex items-center gap-2">
{editingId ? (
<>
<Pencil size={18} className="text-[var(--gold)]" /> ویرایش تورنومنت #{editingId}
<button
type="button"
onClick={cancelEdit}
className="btn btn-ghost mr-auto flex items-center gap-1"
>
<X size={14} /> لغو
</button>
</>
) : (
<>
<Plus size={18} className="text-[var(--gold)]" /> افزودن تورنومنت
</>
)}
</h2>
<div className="grid md:grid-cols-2 gap-4">
<label className="text-sm">
عنوان
<input
className="inp mt-1"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
required
/>
</label>
<label className="text-sm">
ورودی (سکه)
<input
className="inp mt-1"
type="number"
value={form.entry_fee}
onChange={(e) => setForm({ ...form, entry_fee: +e.target.value })}
/>
</label>
<label className="text-sm md:col-span-2">
توضیح
<input
className="inp mt-1"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</label>
<label className="text-sm md:col-span-2">
جوایز (سکه، با کاما نفر اول، دوم، )
<input
className="inp mt-1"
placeholder="5000,3000,1000"
value={form.prizes}
onChange={(e) => setForm({ ...form, prizes: e.target.value })}
/>
</label>
<label className="text-sm">
شروع (تهران)
<input
className="inp mt-1"
type="datetime-local"
value={form.starts_at}
onChange={(e) => setForm({ ...form, starts_at: e.target.value })}
required
/>
</label>
<label className="text-sm">
پایان (تهران)
<input
className="inp mt-1"
type="datetime-local"
value={form.ends_at}
onChange={(e) => setForm({ ...form, ends_at: e.target.value })}
required
/>
</label>
</div>
<p className="text-xs text-[#8aa] mt-3">
برای فعالشدنِ فوری، زمانِ شروع را کمی قبل از الان بگذارید. برد ۱۰۰ و شرکت ۲۵ امتیاز.
</p>
<button className="btn btn-gold mt-4" disabled={busy}>
{busy ? "…" : editingId ? "ذخیره تغییرات" : "ساخت تورنومنت"}
</button>
</form>
</Shell>
);
}
+301
View File
@@ -0,0 +1,301 @@
"use client";
import { useEffect, useState } from "react";
import {
Search,
Wallet,
Sun,
CalendarDays,
ShoppingBag,
Users,
ChevronRight,
ChevronLeft,
} from "lucide-react";
import Shell from "@/components/Shell";
import StatCard from "@/components/StatCard";
import Badge from "@/components/Badge";
import { TableSkeleton } from "@/components/Skeleton";
import Empty from "@/components/Empty";
import { fmtNum } from "@/components/NumberInput";
import { api } from "@/lib/api";
import { KIND_LABEL, KIND_TONE, STATUS_LABEL, STATUS_TONE, STORE_LABEL } from "@/lib/labels";
type Purchase = {
id: number;
user_id: number;
mobile: string;
name: string;
store: string;
kind: string;
product_id: string;
coins: number;
tickets: number;
vip_days: number;
price_toman: number;
status: string;
product_title: string;
created_local: string;
};
type Stats = {
total_revenue: number;
verified_count: number;
unique_buyers: number;
avg_revenue: number;
today: { count: number; revenue: number };
month: { count: number; revenue: number };
};
type Resp = {
purchases: Purchase[];
stats: Stats;
total: number;
page: number;
page_size: number;
pages: number;
};
function received(p: Purchase): string {
const parts: string[] = [];
if (p.coins) parts.push(`${fmtNum(p.coins)} سکه`);
if (p.tickets) parts.push(`${fmtNum(p.tickets)} بلیط`);
if (p.vip_days) parts.push(`${fmtNum(p.vip_days)} روز VIP`);
return parts.join("، ") || "—";
}
export default function TransactionsPage() {
const [q, setQ] = useState("");
const [qApplied, setQApplied] = useState("");
const [store, setStore] = useState("");
const [kind, setKind] = useState("");
const [status, setStatus] = useState("verified");
const [page, setPage] = useState(1);
const [data, setData] = useState<Resp | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
useEffect(() => {
let alive = true;
setLoading(true);
setErr("");
const params = new URLSearchParams();
if (qApplied) params.set("q", qApplied);
if (store) params.set("store", store);
if (kind) params.set("kind", kind);
if (status) params.set("status", status);
params.set("page", String(page));
api
.get<Resp>(`/purchases?${params.toString()}`)
.then((r) => {
if (alive) setData(r);
})
.catch((e) => {
if (alive) setErr(e.message);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [qApplied, store, kind, status, page]);
const s = data?.stats;
return (
<Shell title="تراکنش‌ها" subtitle="خریدهای کاربران از بازار و مایکت">
{err && <p className="text-red-400 mb-4">{err}</p>}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4 mb-6">
<StatCard
label="درآمد کل"
value={s ? fmtNum(s.total_revenue) : undefined}
suffix="تومان"
icon={<Wallet size={18} />}
color="var(--gold)"
loading={loading}
/>
<StatCard
label="درآمد امروز"
value={s ? fmtNum(s.today.revenue) : undefined}
suffix="تومان"
icon={<Sun size={18} />}
color="#4ade80"
sub={s ? `${fmtNum(s.today.count)} خرید` : undefined}
loading={loading}
/>
<StatCard
label="درآمد این ماه"
value={s ? fmtNum(s.month.revenue) : undefined}
suffix="تومان"
icon={<CalendarDays size={18} />}
color="#60a5fa"
loading={loading}
/>
<StatCard
label="خریدها"
value={s ? fmtNum(s.verified_count) : undefined}
suffix="خرید"
icon={<ShoppingBag size={18} />}
color="#c084fc"
sub={s ? `میانگین ${fmtNum(s.avg_revenue)} تومان` : undefined}
loading={loading}
/>
<StatCard
label="خریداران"
value={s ? fmtNum(s.unique_buyers) : undefined}
suffix="نفر"
icon={<Users size={18} />}
color="#fbbf24"
loading={loading}
/>
</div>
<div className="toolbar mb-4">
<form
className="flex gap-2 flex-1 min-w-0"
onSubmit={(e) => {
e.preventDefault();
setQApplied(q.trim());
setPage(1);
}}
>
<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>
<select
className="inp"
value={store}
onChange={(e) => {
setStore(e.target.value);
setPage(1);
}}
>
<option value="">همه فروشگاهها</option>
<option value="bazaar">بازار</option>
<option value="myket">مایکت</option>
</select>
<select
className="inp"
value={kind}
onChange={(e) => {
setKind(e.target.value);
setPage(1);
}}
>
<option value="">همه انواع</option>
<option value="coin">سکه</option>
<option value="ticket">بلیط</option>
<option value="booster">بوستر</option>
<option value="vip">VIP</option>
</select>
<select
className="inp"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
>
<option value="">همه وضعیتها</option>
<option value="verified">پرداختشده</option>
<option value="pending">در انتظار</option>
</select>
</div>
{loading ? (
<TableSkeleton rows={6} cols={8} />
) : !data || data.purchases.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>
<th>وضعیت</th>
</tr>
</thead>
<tbody>
{data.purchases.map((p) => (
<tr key={p.id}>
<td>
<div className="font-bold">{p.name || "—"}</div>
<div className="text-[11px] text-[#6f90bd]" dir="ltr">
{p.mobile} · {p.user_id}
</div>
</td>
<td>
<div className="font-bold">{p.product_title || p.product_id}</div>
<div className="text-[11px] text-[#6f90bd]" dir="ltr">
{p.product_id}
</div>
</td>
<td>
<Badge tone={KIND_TONE[p.kind] ?? "gray"}>
{KIND_LABEL[p.kind] ?? (p.kind || "نامشخص")}
</Badge>
</td>
<td>
<span dir="ltr" className="font-bold text-[var(--gold)]">
{fmtNum(p.price_toman)}
</span>{" "}
<span className="text-[11px] text-[#8ab4e8]">تومان</span>
</td>
<td className="text-[#a9c2e4]">{received(p)}</td>
<td>{STORE_LABEL[p.store] ?? p.store}</td>
<td className="text-[#a9c2e4]" dir="ltr">
{p.created_local}
</td>
<td>
<Badge tone={STATUS_TONE[p.status] ?? "gray"}>
{STATUS_LABEL[p.status] ?? p.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between mt-4">
<button
className="btn btn-ghost flex items-center gap-1"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronRight size={16} /> قبلی
</button>
<span className="text-sm text-[#8ab4e8]">
صفحه {fmtNum(data.page)} از {fmtNum(data.pages)} {fmtNum(data.total)} تراکنش
</span>
<button
className="btn btn-ghost flex items-center gap-1"
disabled={page >= data.pages}
onClick={() => setPage((p) => p + 1)}
>
بعدی <ChevronLeft size={16} />
</button>
</div>
</>
)}
</Shell>
);
}
+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>
);
}