first commit
This commit is contained in:
@@ -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 = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user