first commit

This commit is contained in:
2026-08-07 09:40:16 +03:30
commit 0c51b30059
37 changed files with 5146 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
"use client";
const TONES = {
gold: "badge-gold",
blue: "badge-blue",
purple: "badge-purple",
green: "badge-green",
red: "badge-red",
gray: "badge-gray",
} as const;
export type BadgeTone = keyof typeof TONES;
export default function Badge({
tone = "gray",
children,
}: {
tone?: BadgeTone;
children: React.ReactNode;
}) {
return <span className={`badge ${TONES[tone]}`}>{children}</span>;
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import { useState } from "react";
import { Save, Trash2, Plus, Loader2 } from "lucide-react";
import NumberInput from "./NumberInput";
import { toastError, toastSuccess } from "@/lib/toast";
export type Col = {
key: string;
label: string;
type?: "text" | "number" | "bool";
readonly?: boolean;
};
type Row = Record<string, unknown>;
// سلولِ ورودی — در سطحِ ماژول تعریف شده تا با هر کلید دوباره mount نشود (وگرنه
// اینپوت پس از یک حرف فوکوس را از دست می‌دهد).
function Cell({
row,
col,
onChange,
}: {
row: Row;
col: Col;
onChange: (v: unknown) => void;
}) {
const v = row[col.key];
if (col.type === "bool") {
return (
<input
type="checkbox"
checked={!!v && v !== 0}
disabled={col.readonly}
onChange={(e) => onChange(e.target.checked)}
/>
);
}
if (col.type === "number") {
// ورودیِ عددیِ گروه‌بندی‌شده (جداکننده‌ی هزارگان) و قابلِ تایپ با کیبورد.
return (
<NumberInput
style={{ minWidth: 90 }}
value={Number(v ?? 0)}
disabled={col.readonly}
onChange={onChange}
/>
);
}
return (
<input
className="inp"
style={{ minWidth: 110 }}
type="text"
value={v === null || v === undefined ? "" : String(v)}
readOnly={col.readonly}
onChange={(e) => onChange(e.target.value)}
/>
);
}
export default function EditableTable({
cols,
rows,
onSave,
onDelete,
newTemplate,
extra,
preview,
}: {
cols: Col[];
rows: Row[];
onSave: (row: Row) => Promise<void>;
onDelete: (id: string) => Promise<void>;
newTemplate: Row;
/** ستون/دکمه‌ی اضافی برای هر ردیف (مثلاً آپلود تصویر). */
extra?: (row: Row) => React.ReactNode;
/** پیش‌نمایشِ تصویرِ ابتدای ردیف (مثلاً فرش یا کارت). */
preview?: (row: Row) => React.ReactNode;
}) {
const [draft, setDraft] = useState<Row>({ ...newTemplate });
const [busy, setBusy] = useState<string | null>(null);
// نسخه‌ی محلیِ قابلِ‌ویرایش هر ردیف.
const [edit, setEdit] = useState<Record<string, Row>>({});
const rowState = (r: Row) => edit[String(r.id)] ?? r;
const patch = (id: string, key: string, val: unknown) =>
setEdit((e) => ({ ...e, [id]: { ...(e[id] ?? rows.find((r) => String(r.id) === id)!), [key]: val } }));
// پیش از ذخیره، هر ستون را به نوعِ اعلام‌شده تبدیل کن تا سرور عددِ رشته‌ای نگیرد
// (وگرنه 400: bad request). اعداد → number، بولین‌ها → boolean.
const coerce = (row: Row): Row => {
const out: Row = { ...row };
for (const c of cols) {
if (c.type === "number") out[c.key] = Number(out[c.key] ?? 0) || 0;
else if (c.type === "bool") out[c.key] = !!out[c.key] && out[c.key] !== 0;
}
return out;
};
return (
<div className="card overflow-x-auto">
<table>
<thead>
<tr>
{preview && <th>پیشنمایش</th>}
{cols.map((c) => (
<th key={c.key}>{c.label}</th>
))}
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const id = String(r.id);
const rs = rowState(r);
return (
<tr key={id}>
{preview && <td>{preview(r)}</td>}
{cols.map((c) => (
<td key={c.key}>
<Cell row={rs} col={c} onChange={(v) => patch(id, c.key, v)} />
</td>
))}
<td className="flex items-center gap-1">
<button
className="btn btn-gold"
disabled={busy === id}
onClick={async () => {
setBusy(id);
try {
await onSave(coerce(rs));
toastSuccess("ذخیره شد");
} catch (e) {
toastError((e as Error).message);
} finally {
setBusy(null);
}
}}
>
{busy === id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
</button>
<button className="btn btn-danger" onClick={() => onDelete(id)}>
<Trash2 size={14} />
</button>
{extra?.(r)}
</td>
</tr>
);
})}
{/* ردیفِ افزودن */}
<tr style={{ background: "rgba(233,185,73,.06)" }}>
{preview && <td />}
{cols.map((c) => (
<td key={c.key}>
<Cell
row={draft}
col={{ ...c, readonly: false }}
onChange={(v) => setDraft((d) => ({ ...d, [c.key]: v }))}
/>
</td>
))}
<td>
<button
className="btn btn-ghost flex items-center gap-1"
disabled={busy === "__new__"}
onClick={async () => {
setBusy("__new__");
try {
await onSave(coerce(draft));
setDraft({ ...newTemplate });
toastSuccess("افزوده شد");
} catch (e) {
toastError((e as Error).message);
} finally {
setBusy(null);
}
}}
>
{busy === "__new__" ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Plus size={14} />
)}{" "}
افزودن
</button>
</td>
</tr>
</tbody>
</table>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { Inbox } from "lucide-react";
export default function Empty({
title = "چیزی پیدا نشد",
hint,
}: {
title?: string;
hint?: string;
}) {
return (
<div className="card flex flex-col items-center justify-center gap-3 py-14 text-center">
<div className="w-14 h-14 rounded-2xl grid place-items-center bg-[rgba(255,255,255,.05)]">
<Inbox size={26} className="text-[#5a7aa8]" />
</div>
<div className="font-bold text-[#a9c2e4]">{title}</div>
{hint && <div className="text-xs text-[#6f90bd]">{hint}</div>}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
/** عددِ گروه‌بندی‌شده با جداکننده‌ی هزارگان (مثلِ 12,500). */
export function fmtNum(n: number | null | undefined): string {
const v = Number(n ?? 0);
return Number.isFinite(v) ? v.toLocaleString("en-US") : "0";
}
/**
* ورودیِ عددی با جداکننده‌ی هزارگان که با کیبورد هم قابلِ تایپ است.
* برخلافِ <input type="number"> که جداکننده را نمی‌پذیرد، این یک ورودیِ متنی است
* که هنگامِ تایپ زنده گروه‌بندی می‌کند و مقدارِ عددی را به onChange می‌دهد.
*/
export default function NumberInput({
value,
onChange,
allowNegative = false,
className = "inp",
style,
disabled,
}: {
value: number;
onChange: (n: number) => void;
allowNegative?: boolean;
className?: string;
style?: React.CSSProperties;
disabled?: boolean;
}) {
const show = (n: number) => n.toLocaleString("en-US");
const [text, setText] = useState(show(value));
// اگر مقدار از بیرون عوض شد (مثلاً ریست فرم)، متن را هم‌گام کن.
useEffect(() => {
const cur = parseInt(text.replace(/[^\d-]/g, "") || "0", 10) || 0;
if (cur !== value) setText(show(value));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<input
className={className}
style={style}
type="text"
inputMode={allowNegative ? "text" : "numeric"}
dir="ltr"
value={text}
disabled={disabled}
onChange={(e) => {
let raw = e.target.value.replace(/[^\d-]/g, "");
if (!allowNegative) raw = raw.replace(/-/g, "");
raw = raw.replace(/(?!^)-/g, ""); // فقط منفیِ ابتدایی
const n = raw === "" || raw === "-" ? 0 : parseInt(raw, 10);
const grouped =
raw === "" ? "" : raw === "-" ? "-" : (n || 0).toLocaleString("en-US");
setText(grouped);
onChange(Number.isNaN(n) ? 0 : n);
}}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { fmtNum } from "@/components/NumberInput";
// نمودارِ درآمدِ ۱۴ روزِ گذشته — فقط CSS (بدون کتابخانه). میله‌های طلایی با
// نسبتِ به بیشترین مقدار؛ برچسبِ روز زیرِ هر میله و tooltipِ بومی روی هرکدام.
export default function RevenueChart({
days,
}: {
days: { date: string; revenue: number; count: number }[];
}) {
if (!days.length) return null;
const max = Math.max(...days.map((d) => d.revenue), 1);
return (
<div dir="ltr" role="img" aria-label="نمودار درآمدِ ۱۴ روز گذشته">
<div className="flex items-end gap-1.5 h-28">
{days.map((d) => {
const pct = Math.round((d.revenue / max) * 100);
return (
<div
key={d.date}
title={`${d.date} · ${fmtNum(d.revenue)} تومان · ${d.count} خرید`}
className="flex-1 rounded-t bg-gradient-to-t from-[var(--gold-dark)] to-[var(--gold)] transition hover:brightness-110"
style={{ height: `${Math.max(pct, d.revenue > 0 ? 3 : 0)}%` }}
/>
);
})}
</div>
<div className="flex gap-1.5 mt-1.5">
{days.map((d) => (
<span
key={d.date}
className="flex-1 text-center text-[9px] text-[#6f90bd] leading-none"
>
{d.date.slice(5)}
</span>
))}
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
LayoutDashboard,
Trophy,
ShoppingBag,
Users,
ReceiptText,
Image as ImageIcon,
Frame as FrameIcon,
Smile,
Medal,
MessageSquare,
LogOut,
Menu,
X,
Loader2,
} from "lucide-react";
import { clearAuth, isAuthed } from "@/lib/api";
const NAV = [
{ href: "/", label: "داشبورد", icon: LayoutDashboard },
{ href: "/tournaments", label: "تورنومنت‌ها", icon: Trophy },
{ href: "/shop", label: "فروشگاه", icon: ShoppingBag },
{ href: "/users", label: "کاربران", icon: Users },
{ href: "/transactions", label: "تراکنش‌ها", icon: ReceiptText },
{ href: "/carpets", label: "فرش‌ها", icon: ImageIcon },
{ href: "/frames", label: "قاب‌ها", icon: FrameIcon },
{ href: "/avatars", label: "شخصیت‌ها", icon: Smile },
{ href: "/rank-tiers", label: "رتبه‌ها", icon: Medal },
{ href: "/chat", label: "چت", icon: MessageSquare },
];
export default function Shell({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const [ready, setReady] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!isAuthed()) {
router.replace("/login");
} else {
setReady(true);
}
}, [router]);
// با تغییرِ مسیر، منوی موبایل را ببند.
useEffect(() => {
setOpen(false);
}, [pathname]);
if (!ready) {
return (
<div className="min-h-screen grid place-items-center">
<div className="flex flex-col items-center gap-3 text-[var(--gold)]">
<Loader2 size={28} className="animate-spin" />
<span className="text-sm">در حال بارگذاری</span>
</div>
</div>
);
}
// basePath را حذف و اسلشِ انتهایی را نرمال می‌کنیم تا مسیرِ فعال (مثلِ
// /transactions و /transactions/ برابر) درست تشخیص داده شود.
const cur = (pathname.replace(/^\/panel/, "") || "/").replace(/\/+$/, "") || "/";
const aside = (
<aside className="w-56 shrink-0 border-l border-[#16305a] bg-[var(--bg)] flex flex-col h-full">
<div className="px-5 py-5 flex items-center justify-between border-b border-[#16305a]">
<div className="flex items-center gap-2">
<Trophy className="text-[var(--gold)]" size={22} />
<span className="font-bold text-[var(--gold)]">حکمشو</span>
</div>
<button
className="lg:hidden text-[#a9c2e4] hover:text-[var(--gold)]"
onClick={() => setOpen(false)}
aria-label="بستن منو"
>
<X size={20} />
</button>
</div>
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
{NAV.map((n) => {
const active = cur === n.href;
const Icon = n.icon;
return (
<Link
key={n.href}
href={n.href}
className={`relative flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition ${
active
? "bg-[var(--panel)] text-[var(--gold)] font-bold"
: "text-[#a9c2e4] hover:bg-[var(--panel-2)]"
}`}
>
{active && (
<span className="absolute right-0 top-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-[var(--gold)]" />
)}
<Icon size={18} />
{n.label}
</Link>
);
})}
</nav>
<button
onClick={() => {
clearAuth();
router.replace("/login");
}}
className="m-3 flex items-center gap-2 px-3 py-2.5 rounded-xl text-sm text-[#f0a0a0] hover:bg-[var(--panel-2)]"
>
<LogOut size={18} /> خروج
</button>
</aside>
);
return (
<div className="min-h-screen flex">
{/* دسکتاپ: سایدبارِ ثابت کنارِ محتوا */}
<div className="hidden lg:block shrink-0">{aside}</div>
{/* موبایل: منوی کشویی با پس‌زمینه‌ی تیره */}
{open && (
<div
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
onClick={() => setOpen(false)}
aria-hidden
/>
)}
<div
className={`fixed inset-y-0 right-0 z-40 w-56 transition-transform duration-200 lg:hidden ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
{aside}
</div>
<main className="flex-1 min-w-0">
<header className="px-6 py-4 border-b border-[#16305a] bg-[var(--bg)]/60 backdrop-blur sticky top-0 z-10 flex items-center gap-3">
<button
className="lg:hidden text-[#a9c2e4] hover:text-[var(--gold)]"
onClick={() => setOpen(true)}
aria-label="باز کردن منو"
>
<Menu size={22} />
</button>
<div>
<h1 className="text-lg font-bold">{title}</h1>
{subtitle && <p className="text-xs text-[#8ab4e8] mt-0.5">{subtitle}</p>}
</div>
</header>
<div className="p-6">{children}</div>
</main>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`skeleton ${className}`} />;
}
// اسکلتِ جدولِ در حالِ لود — با سطر/ستونِ دلخواه.
export function TableSkeleton({
rows = 5,
cols = 5,
}: {
rows?: number;
cols?: number;
}) {
return (
<div className="card p-2 overflow-hidden">
<table>
<thead>
<tr>
{Array.from({ length: cols }).map((_, i) => (
<th key={i}>
<Skeleton className="h-3 w-16" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, r) => (
<tr key={r}>
{Array.from({ length: cols }).map((_, c) => (
<td key={c}>
<Skeleton className="h-3.5 w-24" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
// کارتِ آماریِ سربرگ — مقدارِ عددی LTR، واحد (مثلاً «تومان») جدا و RTL.
export default function StatCard({
label,
value,
suffix,
icon,
color = "var(--gold)",
sub,
loading,
}: {
label: string;
value?: React.ReactNode;
suffix?: string;
icon?: React.ReactNode;
color?: string;
sub?: React.ReactNode;
loading?: boolean;
}) {
return (
<div className="card p-4 flex flex-col gap-2 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-[#8ab4e8]">{label}</span>
{icon && <span style={{ color }}>{icon}</span>}
</div>
{loading ? (
<div className="skeleton h-7 w-28" />
) : (
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-xl font-bold" style={{ color }}>
<span dir="ltr">{value ?? "—"}</span>
</span>
{suffix && <span className="text-[11px] text-[#8ab4e8]">{suffix}</span>}
</div>
)}
{sub && <div className="text-[11px] text-[#6f90bd]">{sub}</div>}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
import { ImageOff } from "lucide-react";
/** پیش‌نمایشِ تصویر با جایگزینِ خطا. اگر src بارگذاری نشود، آیکنِ «بدون تصویر». */
export default function Thumb({
src,
alt = "",
size = 44,
rounded = 8,
}: {
src: string;
alt?: string;
size?: number;
rounded?: number;
}) {
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div
className="grid place-items-center text-[#4a6ea5] bg-[var(--panel-2)]"
style={{ width: size, height: size, borderRadius: rounded }}
>
<ImageOff size={size * 0.45} />
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
onError={() => setFailed(true)}
style={{
width: size,
height: size,
borderRadius: rounded,
objectFit: "cover",
border: "1px solid #1e3a63",
}}
/>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
import { subscribe, ToastItem } from "@/lib/toast";
const STYLE: Record<
ToastItem["type"],
{ border: string; icon: React.ReactNode }
> = {
error: { border: "#e5484d", icon: <AlertCircle size={18} color="#ff8a8d" /> },
success: { border: "#3fa34d", icon: <CheckCircle2 size={18} color="#7be58a" /> },
info: { border: "#e9b949", icon: <Info size={18} color="#e9b949" /> },
};
export default function Toaster() {
const [items, setItems] = useState<ToastItem[]>([]);
useEffect(() => subscribe(setItems), []);
return (
<div
style={{
position: "fixed",
bottom: 20,
left: "50%",
transform: "translateX(-50%)",
zIndex: 9999,
display: "flex",
flexDirection: "column",
gap: 10,
alignItems: "center",
pointerEvents: "none",
}}
>
{items.map((t) => (
<div
key={t.id}
style={{
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 10,
maxWidth: "min(90vw, 460px)",
padding: "12px 16px",
borderRadius: 12,
background: "linear-gradient(180deg, #17345c, #0c2848)",
border: `1px solid ${STYLE[t.type].border}`,
boxShadow: "0 8px 24px rgba(0,0,0,.5)",
color: "#eaf0f8",
fontSize: 14,
animation: "toastIn .18s ease-out",
}}
>
{STYLE[t.type].icon}
<span style={{ flex: 1 }}>{t.msg}</span>
</div>
))}
<style>{`@keyframes toastIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}`}</style>
</div>
);
}