first commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
/node_modules
|
||||||
|
/.next
|
||||||
|
/out
|
||||||
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env*.local
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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: "مایکت",
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Generated
+1911
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
Executable
+29
@@ -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/"
|
||||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user