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