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
+6
View File
@@ -0,0 +1,6 @@
/node_modules
/.next
/out
next-env.d.ts
*.tsbuildinfo
.env*.local
+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>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
const TONES = {
gold: "badge-gold",
blue: "badge-blue",
purple: "badge-purple",
green: "badge-green",
red: "badge-red",
gray: "badge-gray",
} as const;
export type BadgeTone = keyof typeof TONES;
export default function Badge({
tone = "gray",
children,
}: {
tone?: BadgeTone;
children: React.ReactNode;
}) {
return <span className={`badge ${TONES[tone]}`}>{children}</span>;
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import { useState } from "react";
import { Save, Trash2, Plus, Loader2 } from "lucide-react";
import NumberInput from "./NumberInput";
import { toastError, toastSuccess } from "@/lib/toast";
export type Col = {
key: string;
label: string;
type?: "text" | "number" | "bool";
readonly?: boolean;
};
type Row = Record<string, unknown>;
// سلولِ ورودی — در سطحِ ماژول تعریف شده تا با هر کلید دوباره mount نشود (وگرنه
// اینپوت پس از یک حرف فوکوس را از دست می‌دهد).
function Cell({
row,
col,
onChange,
}: {
row: Row;
col: Col;
onChange: (v: unknown) => void;
}) {
const v = row[col.key];
if (col.type === "bool") {
return (
<input
type="checkbox"
checked={!!v && v !== 0}
disabled={col.readonly}
onChange={(e) => onChange(e.target.checked)}
/>
);
}
if (col.type === "number") {
// ورودیِ عددیِ گروه‌بندی‌شده (جداکننده‌ی هزارگان) و قابلِ تایپ با کیبورد.
return (
<NumberInput
style={{ minWidth: 90 }}
value={Number(v ?? 0)}
disabled={col.readonly}
onChange={onChange}
/>
);
}
return (
<input
className="inp"
style={{ minWidth: 110 }}
type="text"
value={v === null || v === undefined ? "" : String(v)}
readOnly={col.readonly}
onChange={(e) => onChange(e.target.value)}
/>
);
}
export default function EditableTable({
cols,
rows,
onSave,
onDelete,
newTemplate,
extra,
preview,
}: {
cols: Col[];
rows: Row[];
onSave: (row: Row) => Promise<void>;
onDelete: (id: string) => Promise<void>;
newTemplate: Row;
/** ستون/دکمه‌ی اضافی برای هر ردیف (مثلاً آپلود تصویر). */
extra?: (row: Row) => React.ReactNode;
/** پیش‌نمایشِ تصویرِ ابتدای ردیف (مثلاً فرش یا کارت). */
preview?: (row: Row) => React.ReactNode;
}) {
const [draft, setDraft] = useState<Row>({ ...newTemplate });
const [busy, setBusy] = useState<string | null>(null);
// نسخه‌ی محلیِ قابلِ‌ویرایش هر ردیف.
const [edit, setEdit] = useState<Record<string, Row>>({});
const rowState = (r: Row) => edit[String(r.id)] ?? r;
const patch = (id: string, key: string, val: unknown) =>
setEdit((e) => ({ ...e, [id]: { ...(e[id] ?? rows.find((r) => String(r.id) === id)!), [key]: val } }));
// پیش از ذخیره، هر ستون را به نوعِ اعلام‌شده تبدیل کن تا سرور عددِ رشته‌ای نگیرد
// (وگرنه 400: bad request). اعداد → number، بولین‌ها → boolean.
const coerce = (row: Row): Row => {
const out: Row = { ...row };
for (const c of cols) {
if (c.type === "number") out[c.key] = Number(out[c.key] ?? 0) || 0;
else if (c.type === "bool") out[c.key] = !!out[c.key] && out[c.key] !== 0;
}
return out;
};
return (
<div className="card overflow-x-auto">
<table>
<thead>
<tr>
{preview && <th>پیشنمایش</th>}
{cols.map((c) => (
<th key={c.key}>{c.label}</th>
))}
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const id = String(r.id);
const rs = rowState(r);
return (
<tr key={id}>
{preview && <td>{preview(r)}</td>}
{cols.map((c) => (
<td key={c.key}>
<Cell row={rs} col={c} onChange={(v) => patch(id, c.key, v)} />
</td>
))}
<td className="flex items-center gap-1">
<button
className="btn btn-gold"
disabled={busy === id}
onClick={async () => {
setBusy(id);
try {
await onSave(coerce(rs));
toastSuccess("ذخیره شد");
} catch (e) {
toastError((e as Error).message);
} finally {
setBusy(null);
}
}}
>
{busy === id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
</button>
<button className="btn btn-danger" onClick={() => onDelete(id)}>
<Trash2 size={14} />
</button>
{extra?.(r)}
</td>
</tr>
);
})}
{/* ردیفِ افزودن */}
<tr style={{ background: "rgba(233,185,73,.06)" }}>
{preview && <td />}
{cols.map((c) => (
<td key={c.key}>
<Cell
row={draft}
col={{ ...c, readonly: false }}
onChange={(v) => setDraft((d) => ({ ...d, [c.key]: v }))}
/>
</td>
))}
<td>
<button
className="btn btn-ghost flex items-center gap-1"
disabled={busy === "__new__"}
onClick={async () => {
setBusy("__new__");
try {
await onSave(coerce(draft));
setDraft({ ...newTemplate });
toastSuccess("افزوده شد");
} catch (e) {
toastError((e as Error).message);
} finally {
setBusy(null);
}
}}
>
{busy === "__new__" ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Plus size={14} />
)}{" "}
افزودن
</button>
</td>
</tr>
</tbody>
</table>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { Inbox } from "lucide-react";
export default function Empty({
title = "چیزی پیدا نشد",
hint,
}: {
title?: string;
hint?: string;
}) {
return (
<div className="card flex flex-col items-center justify-center gap-3 py-14 text-center">
<div className="w-14 h-14 rounded-2xl grid place-items-center bg-[rgba(255,255,255,.05)]">
<Inbox size={26} className="text-[#5a7aa8]" />
</div>
<div className="font-bold text-[#a9c2e4]">{title}</div>
{hint && <div className="text-xs text-[#6f90bd]">{hint}</div>}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
/** عددِ گروه‌بندی‌شده با جداکننده‌ی هزارگان (مثلِ 12,500). */
export function fmtNum(n: number | null | undefined): string {
const v = Number(n ?? 0);
return Number.isFinite(v) ? v.toLocaleString("en-US") : "0";
}
/**
* ورودیِ عددی با جداکننده‌ی هزارگان که با کیبورد هم قابلِ تایپ است.
* برخلافِ <input type="number"> که جداکننده را نمی‌پذیرد، این یک ورودیِ متنی است
* که هنگامِ تایپ زنده گروه‌بندی می‌کند و مقدارِ عددی را به onChange می‌دهد.
*/
export default function NumberInput({
value,
onChange,
allowNegative = false,
className = "inp",
style,
disabled,
}: {
value: number;
onChange: (n: number) => void;
allowNegative?: boolean;
className?: string;
style?: React.CSSProperties;
disabled?: boolean;
}) {
const show = (n: number) => n.toLocaleString("en-US");
const [text, setText] = useState(show(value));
// اگر مقدار از بیرون عوض شد (مثلاً ریست فرم)، متن را هم‌گام کن.
useEffect(() => {
const cur = parseInt(text.replace(/[^\d-]/g, "") || "0", 10) || 0;
if (cur !== value) setText(show(value));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<input
className={className}
style={style}
type="text"
inputMode={allowNegative ? "text" : "numeric"}
dir="ltr"
value={text}
disabled={disabled}
onChange={(e) => {
let raw = e.target.value.replace(/[^\d-]/g, "");
if (!allowNegative) raw = raw.replace(/-/g, "");
raw = raw.replace(/(?!^)-/g, ""); // فقط منفیِ ابتدایی
const n = raw === "" || raw === "-" ? 0 : parseInt(raw, 10);
const grouped =
raw === "" ? "" : raw === "-" ? "-" : (n || 0).toLocaleString("en-US");
setText(grouped);
onChange(Number.isNaN(n) ? 0 : n);
}}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { fmtNum } from "@/components/NumberInput";
// نمودارِ درآمدِ ۱۴ روزِ گذشته — فقط CSS (بدون کتابخانه). میله‌های طلایی با
// نسبتِ به بیشترین مقدار؛ برچسبِ روز زیرِ هر میله و tooltipِ بومی روی هرکدام.
export default function RevenueChart({
days,
}: {
days: { date: string; revenue: number; count: number }[];
}) {
if (!days.length) return null;
const max = Math.max(...days.map((d) => d.revenue), 1);
return (
<div dir="ltr" role="img" aria-label="نمودار درآمدِ ۱۴ روز گذشته">
<div className="flex items-end gap-1.5 h-28">
{days.map((d) => {
const pct = Math.round((d.revenue / max) * 100);
return (
<div
key={d.date}
title={`${d.date} · ${fmtNum(d.revenue)} تومان · ${d.count} خرید`}
className="flex-1 rounded-t bg-gradient-to-t from-[var(--gold-dark)] to-[var(--gold)] transition hover:brightness-110"
style={{ height: `${Math.max(pct, d.revenue > 0 ? 3 : 0)}%` }}
/>
);
})}
</div>
<div className="flex gap-1.5 mt-1.5">
{days.map((d) => (
<span
key={d.date}
className="flex-1 text-center text-[9px] text-[#6f90bd] leading-none"
>
{d.date.slice(5)}
</span>
))}
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
LayoutDashboard,
Trophy,
ShoppingBag,
Users,
ReceiptText,
Image as ImageIcon,
Frame as FrameIcon,
Smile,
Medal,
MessageSquare,
LogOut,
Menu,
X,
Loader2,
} from "lucide-react";
import { clearAuth, isAuthed } from "@/lib/api";
const NAV = [
{ href: "/", label: "داشبورد", icon: LayoutDashboard },
{ href: "/tournaments", label: "تورنومنت‌ها", icon: Trophy },
{ href: "/shop", label: "فروشگاه", icon: ShoppingBag },
{ href: "/users", label: "کاربران", icon: Users },
{ href: "/transactions", label: "تراکنش‌ها", icon: ReceiptText },
{ href: "/carpets", label: "فرش‌ها", icon: ImageIcon },
{ href: "/frames", label: "قاب‌ها", icon: FrameIcon },
{ href: "/avatars", label: "شخصیت‌ها", icon: Smile },
{ href: "/rank-tiers", label: "رتبه‌ها", icon: Medal },
{ href: "/chat", label: "چت", icon: MessageSquare },
];
export default function Shell({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const [ready, setReady] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!isAuthed()) {
router.replace("/login");
} else {
setReady(true);
}
}, [router]);
// با تغییرِ مسیر، منوی موبایل را ببند.
useEffect(() => {
setOpen(false);
}, [pathname]);
if (!ready) {
return (
<div className="min-h-screen grid place-items-center">
<div className="flex flex-col items-center gap-3 text-[var(--gold)]">
<Loader2 size={28} className="animate-spin" />
<span className="text-sm">در حال بارگذاری</span>
</div>
</div>
);
}
// basePath را حذف و اسلشِ انتهایی را نرمال می‌کنیم تا مسیرِ فعال (مثلِ
// /transactions و /transactions/ برابر) درست تشخیص داده شود.
const cur = (pathname.replace(/^\/panel/, "") || "/").replace(/\/+$/, "") || "/";
const aside = (
<aside className="w-56 shrink-0 border-l border-[#16305a] bg-[var(--bg)] flex flex-col h-full">
<div className="px-5 py-5 flex items-center justify-between border-b border-[#16305a]">
<div className="flex items-center gap-2">
<Trophy className="text-[var(--gold)]" size={22} />
<span className="font-bold text-[var(--gold)]">حکمشو</span>
</div>
<button
className="lg:hidden text-[#a9c2e4] hover:text-[var(--gold)]"
onClick={() => setOpen(false)}
aria-label="بستن منو"
>
<X size={20} />
</button>
</div>
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
{NAV.map((n) => {
const active = cur === n.href;
const Icon = n.icon;
return (
<Link
key={n.href}
href={n.href}
className={`relative flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition ${
active
? "bg-[var(--panel)] text-[var(--gold)] font-bold"
: "text-[#a9c2e4] hover:bg-[var(--panel-2)]"
}`}
>
{active && (
<span className="absolute right-0 top-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-[var(--gold)]" />
)}
<Icon size={18} />
{n.label}
</Link>
);
})}
</nav>
<button
onClick={() => {
clearAuth();
router.replace("/login");
}}
className="m-3 flex items-center gap-2 px-3 py-2.5 rounded-xl text-sm text-[#f0a0a0] hover:bg-[var(--panel-2)]"
>
<LogOut size={18} /> خروج
</button>
</aside>
);
return (
<div className="min-h-screen flex">
{/* دسکتاپ: سایدبارِ ثابت کنارِ محتوا */}
<div className="hidden lg:block shrink-0">{aside}</div>
{/* موبایل: منوی کشویی با پس‌زمینه‌ی تیره */}
{open && (
<div
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
onClick={() => setOpen(false)}
aria-hidden
/>
)}
<div
className={`fixed inset-y-0 right-0 z-40 w-56 transition-transform duration-200 lg:hidden ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
{aside}
</div>
<main className="flex-1 min-w-0">
<header className="px-6 py-4 border-b border-[#16305a] bg-[var(--bg)]/60 backdrop-blur sticky top-0 z-10 flex items-center gap-3">
<button
className="lg:hidden text-[#a9c2e4] hover:text-[var(--gold)]"
onClick={() => setOpen(true)}
aria-label="باز کردن منو"
>
<Menu size={22} />
</button>
<div>
<h1 className="text-lg font-bold">{title}</h1>
{subtitle && <p className="text-xs text-[#8ab4e8] mt-0.5">{subtitle}</p>}
</div>
</header>
<div className="p-6">{children}</div>
</main>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`skeleton ${className}`} />;
}
// اسکلتِ جدولِ در حالِ لود — با سطر/ستونِ دلخواه.
export function TableSkeleton({
rows = 5,
cols = 5,
}: {
rows?: number;
cols?: number;
}) {
return (
<div className="card p-2 overflow-hidden">
<table>
<thead>
<tr>
{Array.from({ length: cols }).map((_, i) => (
<th key={i}>
<Skeleton className="h-3 w-16" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, r) => (
<tr key={r}>
{Array.from({ length: cols }).map((_, c) => (
<td key={c}>
<Skeleton className="h-3.5 w-24" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
// کارتِ آماریِ سربرگ — مقدارِ عددی LTR، واحد (مثلاً «تومان») جدا و RTL.
export default function StatCard({
label,
value,
suffix,
icon,
color = "var(--gold)",
sub,
loading,
}: {
label: string;
value?: React.ReactNode;
suffix?: string;
icon?: React.ReactNode;
color?: string;
sub?: React.ReactNode;
loading?: boolean;
}) {
return (
<div className="card p-4 flex flex-col gap-2 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-[#8ab4e8]">{label}</span>
{icon && <span style={{ color }}>{icon}</span>}
</div>
{loading ? (
<div className="skeleton h-7 w-28" />
) : (
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-xl font-bold" style={{ color }}>
<span dir="ltr">{value ?? "—"}</span>
</span>
{suffix && <span className="text-[11px] text-[#8ab4e8]">{suffix}</span>}
</div>
)}
{sub && <div className="text-[11px] text-[#6f90bd]">{sub}</div>}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
import { ImageOff } from "lucide-react";
/** پیش‌نمایشِ تصویر با جایگزینِ خطا. اگر src بارگذاری نشود، آیکنِ «بدون تصویر». */
export default function Thumb({
src,
alt = "",
size = 44,
rounded = 8,
}: {
src: string;
alt?: string;
size?: number;
rounded?: number;
}) {
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div
className="grid place-items-center text-[#4a6ea5] bg-[var(--panel-2)]"
style={{ width: size, height: size, borderRadius: rounded }}
>
<ImageOff size={size * 0.45} />
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
onError={() => setFailed(true)}
style={{
width: size,
height: size,
borderRadius: rounded,
objectFit: "cover",
border: "1px solid #1e3a63",
}}
/>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
import { subscribe, ToastItem } from "@/lib/toast";
const STYLE: Record<
ToastItem["type"],
{ border: string; icon: React.ReactNode }
> = {
error: { border: "#e5484d", icon: <AlertCircle size={18} color="#ff8a8d" /> },
success: { border: "#3fa34d", icon: <CheckCircle2 size={18} color="#7be58a" /> },
info: { border: "#e9b949", icon: <Info size={18} color="#e9b949" /> },
};
export default function Toaster() {
const [items, setItems] = useState<ToastItem[]>([]);
useEffect(() => subscribe(setItems), []);
return (
<div
style={{
position: "fixed",
bottom: 20,
left: "50%",
transform: "translateX(-50%)",
zIndex: 9999,
display: "flex",
flexDirection: "column",
gap: 10,
alignItems: "center",
pointerEvents: "none",
}}
>
{items.map((t) => (
<div
key={t.id}
style={{
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 10,
maxWidth: "min(90vw, 460px)",
padding: "12px 16px",
borderRadius: 12,
background: "linear-gradient(180deg, #17345c, #0c2848)",
border: `1px solid ${STYLE[t.type].border}`,
boxShadow: "0 8px 24px rgba(0,0,0,.5)",
color: "#eaf0f8",
fontSize: 14,
animation: "toastIn .18s ease-out",
}}
>
{STYLE[t.type].icon}
<span style={{ flex: 1 }}>{t.msg}</span>
</div>
))}
<style>{`@keyframes toastIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}`}</style>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
// کلاینتِ APIِ پنل. احرازِ هویت با Basic Auth (همان کاربر/پسوردِ پنل ادمین)
// که پس از ورود در localStorage نگه‌داری می‌شود و در هر درخواست فرستاده می‌شود.
// همان مبدأ (nginx، /admin/api → بک‌اند). برای توسعه‌ی محلی با
// NEXT_PUBLIC_API_URL قابلِ override است.
const BASE =
process.env.NEXT_PUBLIC_API_URL || "https://hakem.approagency.ir/admin/api";
// مبدأِ فایل‌های استاتیک (کارت/فرش): همان دامنه بدونِ /admin/api.
export const ASSET_BASE = BASE.replace(/\/admin\/api\/?$/, "");
const AUTH_KEY = "hakem_admin_auth";
export function saveAuth(user: string, pass: string) {
if (typeof window !== "undefined") {
localStorage.setItem(AUTH_KEY, btoa(`${user}:${pass}`));
}
}
export function clearAuth() {
if (typeof window !== "undefined") localStorage.removeItem(AUTH_KEY);
}
export function getAuth(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(AUTH_KEY);
}
export function isAuthed(): boolean {
return !!getAuth();
}
class ApiError extends Error {
status: number;
constructor(status: number, msg: string) {
super(msg);
this.status = status;
}
}
async function request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const auth = getAuth();
const headers: Record<string, string> = { Accept: "application/json" };
if (auth) headers["Authorization"] = `Basic ${auth}`;
const opts: RequestInit = { method, headers };
if (body !== undefined) {
if (body instanceof FormData) {
opts.body = body;
} else {
headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
}
const res = await fetch(`${BASE}${path}`, opts);
if (res.status === 401) {
clearAuth();
if (typeof window !== "undefined") window.location.href = "/panel/login/";
throw new ApiError(401, "unauthorized");
}
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new ApiError(res.status, (e as { error?: string }).error || "خطا");
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(p: string) => request<T>("GET", p),
post: <T>(p: string, body?: unknown) => request<T>("POST", p, body),
put: <T>(p: string, body?: unknown) => request<T>("PUT", p, body),
del: <T>(p: string) => request<T>("DELETE", p),
// ورود: اعتبارسنجی با فراخوانی /me.
async login(user: string, pass: string) {
const prev = getAuth();
saveAuth(user, pass);
try {
await request("GET", "/me");
return true;
} catch {
if (prev) localStorage.setItem(AUTH_KEY, prev);
else clearAuth();
return false;
}
},
};
+27
View File
@@ -0,0 +1,27 @@
import type { BadgeTone } from "@/components/Badge";
// برچسب‌ها و تن‌هایِ نشانکیِ مشترک بین صفحه‌ی تراکنش‌ها و داشبورد.
export const KIND_LABEL: Record<string, string> = {
coin: "سکه",
ticket: "بلیط",
booster: "بوستر",
vip: "VIP",
};
export const KIND_TONE: Record<string, BadgeTone> = {
coin: "gold",
ticket: "blue",
booster: "purple",
vip: "green",
};
export const STATUS_LABEL: Record<string, string> = {
verified: "پرداخت‌شده",
pending: "در انتظار",
};
export const STATUS_TONE: Record<string, BadgeTone> = {
verified: "green",
pending: "gray",
};
export const STORE_LABEL: Record<string, string> = {
bazaar: "بازار",
myket: "مایکت",
};
+37
View File
@@ -0,0 +1,37 @@
"use client";
// سیستمِ توستِ سبک (بدونِ وابستگی): یک pub/sub ساده که پیام‌ها را به Toaster می‌دهد.
export type ToastType = "error" | "success" | "info";
export type ToastItem = { id: number; msg: string; type: ToastType };
type Listener = (items: ToastItem[]) => void;
let items: ToastItem[] = [];
let seq = 1;
const listeners = new Set<Listener>();
function emit() {
for (const l of listeners) l(items);
}
/** یک توست نشان می‌دهد (پیش‌فرض: خطا). پس از چند ثانیه خودش محو می‌شود. */
export function toast(msg: string, type: ToastType = "error") {
const id = seq++;
items = [...items, { id, msg, type }];
emit();
setTimeout(() => {
items = items.filter((t) => t.id !== id);
emit();
}, type === "error" ? 5000 : 3000);
}
export const toastError = (msg: string) => toast(msg, "error");
export const toastSuccess = (msg: string) => toast(msg, "success");
export function subscribe(l: Listener) {
listeners.add(l);
l(items);
return () => {
listeners.delete(l);
};
}
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
// خروجیِ ایستا (static export) که nginx زیرِ مسیرِ /panel/ سرو می‌کند.
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
basePath: "/panel",
images: { unoptimized: true },
};
export default nextConfig;
+1911
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "hakemsho-admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"lucide-react": "^0.552.0",
"next": "16.0.1",
"react": "19.2.0",
"react-dom": "19.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"tailwindcss": "^4.2.0",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};
export default config;
Binary file not shown.
Binary file not shown.
Binary file not shown.
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# استقرارِ پنلِ ادمینِ حکم‌شو: build ایستا + آپلود به سرور (همان سرورِ بک‌اند).
# nginx فایل‌ها را زیرِ https://hakem.approagency.ir/panel/ سرو می‌کند.
set -euo pipefail
HOST="${HAKEM_HOST:-ubuntu@37.32.20.103}"
KEY="${HAKEM_KEY:-$HOME/.ssh/hakem_deploy}"
REMOTE_DIR="/var/www/hakem-panel/panel" # با root در nginx: /panel → این پوشه
cd "$(dirname "$0")/.."
echo "==> build (static export → ./out)"
npm run build
if [ ! -d out ]; then
echo "build produced no ./out" >&2
exit 1
fi
echo "==> upload to $HOST:$REMOTE_DIR"
# محتوای پوشه را جایگزین می‌کنیم (tar pipe روی یک اتصالِ SSH).
tar -C out -czf - . | ssh -i "$KEY" -o StrictHostKeyChecking=no "$HOST" "
set -e
sudo mkdir -p '$REMOTE_DIR'
sudo find '$REMOTE_DIR' -mindepth 1 -delete
sudo tar -C '$REMOTE_DIR' -xzf -
sudo chown -R www-data:www-data /var/www/hakem-panel
"
echo "==> done: https://hakem.approagency.ir/panel/"
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+204
View File
File diff suppressed because one or more lines are too long