chore: merge admin repo history into monorepo at admin/

git-subtree-dir: admin
git-subtree-mainline: 35f5084005
git-subtree-split: 9f1eccd2f7
This commit is contained in:
2026-09-27 10:30:47 +03:30
49 changed files with 11446 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Database, Download, HardDrive, RefreshCw } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { useToast } from "@/components/ui/Toast";
import { formatApiError } from "@/lib/hooks";
import { BackupInfo, listBackups, createBackup, downloadBackup } from "@/lib/api";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function BackupsPage() {
const toast = useToast();
const [backups, setBackups] = useState<BackupInfo[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setBackups(await listBackups());
setError(null);
} catch (err) {
setError(formatApiError(err));
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [load]);
async function handleCreate() {
setCreating(true);
try {
await createBackup();
toast.showSuccess("بک‌آپ جدید با موفقیت ساخته شد");
await load();
} catch (err) {
toast.showError(formatApiError(err));
} finally {
setCreating(false);
}
}
async function handleDownload(filename: string) {
setDownloadingFile(filename);
try {
await downloadBackup(filename);
} catch (err) {
toast.showError(formatApiError(err));
} finally {
setDownloadingFile(null);
}
}
if (!backups && !error) return <FullScreenLoader label="لیست بک‌آپ‌ها رو میاریم..." />;
return (
<div className="flex flex-col gap-6 max-w-3xl">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
بک‌آپ دیتابیس 💾
</h1>
<p className="text-[14px] text-(--color-content-secondary) mt-1">
یک نسخه‌ی کامل و فشرده از دیتابیس بگیر یا نسخه‌های قبلی رو دانلود کن.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="tertiary" size="sm" icon={<RefreshCw size={14} />} onClick={load}>
تازه‌سازی
</Button>
<Button size="sm" icon={<Database size={15} />} isLoading={creating} onClick={handleCreate}>
ساخت بک‌آپ جدید
</Button>
</div>
</div>
{error && <ErrorBanner message={error} />}
<Card padded={false} className="overflow-hidden">
{!backups || backups.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-14 text-center">
<HardDrive size={28} className="text-(--color-content-tertiary)" />
<p className="text-[14px] font-bold text-(--color-content-primary)">
هنوز هیچ بک‌آپی گرفته نشده
</p>
<p className="text-[13px] text-(--color-content-tertiary)">
با دکمه‌ی «ساخت بک‌آپ جدید» اولین نسخه رو بگیر.
</p>
</div>
) : (
<div className="divide-y divide-(--color-border-subtle)">
{backups.map((b) => (
<div key={b.filename} className="flex items-center justify-between px-5 py-4 gap-3">
<div className="min-w-0">
<p dir="ltr" className="font-bold text-[13px] text-(--color-content-primary) truncate">
{b.filename}
</p>
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
{new Date(b.created_at).toLocaleString("fa-IR")} · {formatSize(b.size_bytes)}
</p>
</div>
<Button
variant="tertiary"
size="sm"
icon={<Download size={14} />}
isLoading={downloadingFile === b.filename}
onClick={() => handleDownload(b.filename)}
className="shrink-0"
>
دانلود
</Button>
</div>
))}
</div>
)}
</Card>
</div>
);
}
@@ -0,0 +1,401 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { ChevronRight, Plus, Pencil, Trash2, Settings2 } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { TextField } from "@/components/ui/TextField";
import { Switch } from "@/components/ui/Controls";
import { Modal } from "@/components/ui/Modal";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { useToast } from "@/components/ui/Toast";
import { formatApiError } from "@/lib/hooks";
import {
ALL_CATEGORIES,
CategoryKey,
Category,
Option,
getCategory,
updateCategory,
listOptions,
createOption,
updateOption,
deleteOption,
} from "@/lib/api";
export default function CategoryDetailPage() {
const params = useParams<{ key: string }>();
const router = useRouter();
const key = params.key as CategoryKey;
const meta = ALL_CATEGORIES.find((c) => c.key === key);
const toast = useToast();
const [category, setCategory] = useState<Category | null>(null);
const [options, setOptions] = useState<Option[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [optionModal, setOptionModal] = useState<{ mode: "create" | "edit"; option?: Option } | null>(
null
);
const [deleteTarget, setDeleteTarget] = useState<Option | null>(null);
const load = useCallback(async () => {
setError(null);
try {
const [cat, opts] = await Promise.all([getCategory(key), listOptions(key)]);
setCategory(cat);
setOptions(opts);
} catch (err) {
setError(formatApiError(err));
}
}, [key]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [load]);
if (!meta) {
return <ErrorBanner message="دسته‌ی نامعتبر" />;
}
if (error && !category) {
return (
<div className="max-w-2xl">
<ErrorBanner message={error} />
</div>
);
}
if (!category || !options) return <FullScreenLoader />;
return (
<div className="flex flex-col gap-6 max-w-3xl">
<button
onClick={() => router.push("/categories")}
className="flex items-center gap-1 text-[13px] font-semibold text-(--color-content-secondary) hover:text-(--color-brand-600) w-fit"
>
<ChevronRight size={16} />
بازگشت به دسته‌بندی‌ها
</button>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[24px] flex items-center gap-2">
<span>{meta.emoji}</span> {category.title}
</h1>
<div className="flex items-center gap-2 mt-2">
<Badge tone="brand">
حداقل انتخاب: {category.min_select}
</Badge>
<Badge tone="neutral">
حداکثر انتخاب: {category.max_select === 0 ? "نامحدود" : category.max_select}
</Badge>
</div>
</div>
<Button variant="tertiary" size="sm" icon={<Settings2 size={15} />} onClick={() => setSettingsOpen(true)}>
تنظیمات دسته
</Button>
</div>
<div className="flex items-center justify-between">
<h2 className="text-[14px] font-bold text-(--color-content-secondary)">
گزینه‌ها ({options.length})
</h2>
<Button size="sm" icon={<Plus size={16} />} onClick={() => setOptionModal({ mode: "create" })}>
افزودن گزینه
</Button>
</div>
<Card padded={false} className="overflow-hidden">
{options.length === 0 ? (
<p className="text-[13px] text-(--color-content-tertiary) text-center py-10">
هنوز گزینه‌ای اضافه نشده.
</p>
) : (
<table className="w-full text-[13px]">
<thead>
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
<th className="text-right font-semibold px-4 py-3">عنوان</th>
<th className="text-right font-semibold px-4 py-3 w-20">ترتیب</th>
<th className="text-right font-semibold px-4 py-3 w-24">فعال</th>
<th className="text-right font-semibold px-4 py-3 w-24">عملیات</th>
</tr>
</thead>
<tbody>
{[...options]
.sort((a, b) => a.sort_order - b.sort_order)
.map((opt) => (
<OptionRow
key={opt.id}
option={opt}
onToggleActive={async (next) => {
try {
await updateOption(opt.id, { is_active: next });
setOptions((prev) =>
prev!.map((o) => (o.id === opt.id ? { ...o, is_active: next } : o))
);
} catch (err) {
toast.showError(formatApiError(err));
}
}}
onEdit={() => setOptionModal({ mode: "edit", option: opt })}
onDelete={() => setDeleteTarget(opt)}
/>
))}
</tbody>
</table>
)}
</Card>
<CategorySettingsModal
open={settingsOpen}
category={category}
onClose={() => setSettingsOpen(false)}
onSaved={(updated) => {
setCategory(updated);
setSettingsOpen(false);
toast.showSuccess("تنظیمات دسته به‌روزرسانی شد");
}}
/>
<OptionFormModal
open={!!optionModal}
mode={optionModal?.mode ?? "create"}
option={optionModal?.option}
categoryKey={key}
onClose={() => setOptionModal(null)}
onSaved={(opt, mode) => {
if (mode === "create") {
setOptions((prev) => [...(prev ?? []), opt]);
toast.showSuccess("گزینه اضافه شد");
} else {
setOptions((prev) => prev!.map((o) => (o.id === opt.id ? opt : o)));
toast.showSuccess("گزینه ویرایش شد");
}
setOptionModal(null);
}}
/>
<ConfirmDialog
open={!!deleteTarget}
title="حذف گزینه؟"
description={`گزینه‌ی «${deleteTarget?.title}» برای همیشه حذف می‌شه. کاربرهایی که قبلاً این رو انتخاب کرده بودن، همچنان انتخابشون توی سابقه می‌مونه ولی این گزینه دیگه قابل انتخاب نیست.`}
onCancel={() => setDeleteTarget(null)}
onConfirm={async () => {
if (!deleteTarget) return;
try {
await deleteOption(deleteTarget.id);
setOptions((prev) => prev!.filter((o) => o.id !== deleteTarget.id));
toast.showSuccess("گزینه حذف شد");
} catch (err) {
toast.showError(formatApiError(err));
} finally {
setDeleteTarget(null);
}
}}
/>
</div>
);
}
function OptionRow({
option,
onToggleActive,
onEdit,
onDelete,
}: {
option: Option;
onToggleActive: (next: boolean) => void;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<tr className="border-b border-(--color-border-subtle) last:border-0">
<td className="px-4 py-3 font-medium text-(--color-content-primary)">{option.title}</td>
<td className="px-4 py-3 text-(--color-content-tertiary)">{option.sort_order}</td>
<td className="px-4 py-3">
<Switch defaultChecked={option.is_active} onChange={onToggleActive} />
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={onEdit}
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
>
<Pencil size={13} />
</button>
<button
onClick={onDelete}
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-red-50 text-(--color-status-error)"
>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
);
}
function CategorySettingsModal({
open,
category,
onClose,
onSaved,
}: {
open: boolean;
category: Category;
onClose: () => void;
onSaved: (c: Category) => void;
}) {
const [title, setTitle] = useState(category.title);
const [minSelect, setMinSelect] = useState(String(category.min_select));
const [maxSelect, setMaxSelect] = useState(String(category.max_select));
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTitle(category.title);
setMinSelect(String(category.min_select));
setMaxSelect(String(category.max_select));
}, [category]);
async function handleSave() {
setLoading(true);
setError(null);
try {
const updated = await updateCategory(category.key, {
title,
min_select: parseInt(minSelect, 10),
max_select: parseInt(maxSelect, 10),
});
onSaved(updated);
} catch (err) {
setError(formatApiError(err));
} finally {
setLoading(false);
}
}
return (
<Modal open={open} onClose={onClose} title="تنظیمات دسته">
<div className="flex flex-col gap-4">
<TextField label="عنوان دسته" value={title} onChange={(e) => setTitle(e.target.value)} />
<div className="grid grid-cols-2 gap-3">
<TextField
label="حداقل انتخاب"
type="number"
min={0}
value={minSelect}
onChange={(e) => setMinSelect(e.target.value)}
/>
<TextField
label="حداکثر انتخاب"
type="number"
min={0}
helperText="۰ یعنی نامحدود"
value={maxSelect}
onChange={(e) => setMaxSelect(e.target.value)}
/>
</div>
<ErrorBanner message={error} />
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={onClose}>
انصراف
</Button>
<Button size="sm" isLoading={loading} onClick={handleSave}>
ذخیره
</Button>
</div>
</div>
</Modal>
);
}
function OptionFormModal({
open,
mode,
option,
categoryKey,
onClose,
onSaved,
}: {
open: boolean;
mode: "create" | "edit";
option?: Option;
categoryKey: CategoryKey;
onClose: () => void;
onSaved: (opt: Option, mode: "create" | "edit") => void;
}) {
const [title, setTitle] = useState(option?.title ?? "");
const [sortOrder, setSortOrder] = useState(String(option?.sort_order ?? 0));
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTitle(option?.title ?? "");
setSortOrder(String(option?.sort_order ?? 0));
setError(null);
}, [option, open]);
async function handleSave() {
if (!title.trim()) return;
setLoading(true);
setError(null);
try {
if (mode === "create") {
const created = await createOption(categoryKey, {
title: title.trim(),
sort_order: parseInt(sortOrder, 10) || 0,
});
onSaved(created, "create");
} else if (option) {
const updated = await updateOption(option.id, {
title: title.trim(),
sort_order: parseInt(sortOrder, 10) || 0,
});
onSaved(updated, "edit");
}
} catch (err) {
setError(formatApiError(err));
} finally {
setLoading(false);
}
}
return (
<Modal open={open} onClose={onClose} title={mode === "create" ? "افزودن گزینه" : "ویرایش گزینه"}>
<div className="flex flex-col gap-4">
<TextField
label="عنوان"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
autoFocus
/>
<TextField
label="ترتیب نمایش"
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
/>
<ErrorBanner message={error} />
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={onClose}>
انصراف
</Button>
<Button size="sm" isLoading={loading} disabled={!title.trim()} onClick={handleSave}>
ذخیره
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { ALL_CATEGORIES } from "@/lib/api";
export default function CategoriesIndexPage() {
return (
<div className="flex flex-col gap-6 max-w-5xl">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
دسته‌بندی‌ها و گزینه‌ها 🗂️
</h1>
<p className="text-[14px] text-(--color-content-secondary) mt-1">
هر دسته یه لیست انتخابیه که توی مراحل آنبوردینگ به کاربر نشون داده می‌شه.
روی هرکدوم بزن تا گزینه‌هاش رو مدیریت کنی.
</p>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{ALL_CATEGORIES.map((c) => (
<Link key={c.key} href={`/categories/${c.key}`}>
<Card hoverable className="flex items-center justify-between h-full">
<div>
<p className="text-[24px] mb-1">{c.emoji}</p>
<p className="font-bold text-[15px] text-(--color-content-primary)">
{c.fallbackTitle}
</p>
<p className="text-[11px] font-mono text-(--color-content-tertiary) mt-0.5">
{c.key}
</p>
</div>
<ArrowLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
</Card>
</Link>
))}
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { ReactNode, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ShieldAlert } from "lucide-react";
import { useAuth } from "@/lib/auth-context";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { Sidebar } from "@/components/Sidebar";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
export default function DashboardLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const { status, logout } = useAuth();
useEffect(() => {
if (status === "guest") router.replace("/login");
}, [status, router]);
if (status === "loading" || status === "guest") return <FullScreenLoader />;
if (status === "not-admin") {
return (
<div className="min-h-dvh flex items-center justify-center px-4">
<Card className="w-full max-w-sm flex flex-col items-center gap-4 !p-8 text-center">
<div className="w-14 h-14 rounded-(--radius-lg) bg-red-50 flex items-center justify-center">
<ShieldAlert size={26} className="text-(--color-status-error)" />
</div>
<h2 className="font-[var(--font-display)] font-extrabold text-[18px]">
دسترسی نداری
</h2>
<p className="text-[13px] text-(--color-content-secondary) leading-6">
حساب واردشده دسترسی ادمین نداره.
</p>
<Button
variant="tertiary"
onClick={() => {
logout();
router.push("/login");
}}
>
خروج و ورود با حساب دیگه
</Button>
</Card>
</div>
);
}
return (
<div className="flex min-h-dvh">
<Sidebar />
<main className="flex-1 min-w-0 p-8">{children}</main>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, HeartHandshake } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { formatApiError } from "@/lib/hooks";
import { AdminMatchView, listAllMatches } from "@/lib/api";
const PAGE_SIZE = 20;
export default function MatchesPage() {
const [items, setItems] = useState<AdminMatchView[] | null>(null);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (p: number) => {
setError(null);
try {
const res = await listAllMatches(p, PAGE_SIZE);
setItems(res.items);
setTotal(res.total);
} catch (err) {
setError(formatApiError(err));
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load(page);
}, [page, load]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div className="flex flex-col gap-6 max-w-3xl">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
رفقا (مچ‌ها) 💞
</h1>
<p className="text-[14px] text-(--color-content-secondary) mt-1">
همه‌ی جفت‌هایی که با هم مچ شدن، جدیدترین اول.
</p>
</div>
{error && <ErrorBanner message={error} />}
{!items ? (
<FullScreenLoader />
) : items.length === 0 ? (
<Card>
<EmptyState
icon={<HeartHandshake size={32} />}
title="هنوز هیچ مچی اتفاق نیفتاده"
description="وقتی دو نفر همدیگه رو لایک کنن، اینجا نشون داده می‌شن."
/>
</Card>
) : (
<>
<Card padded={false} className="overflow-hidden">
{items.map((m, i) => (
<div
key={m.id}
className={`flex items-center gap-4 px-4 py-3.5 ${
i !== items.length - 1 ? "border-b border-(--color-border-subtle)" : ""
}`}
>
<div className="flex items-center -space-x-3 space-x-reverse shrink-0">
<Avatar name={m.user_one_name || m.user_one_phone} />
<Avatar name={m.user_two_name || m.user_two_phone} />
</div>
<div className="flex-1 min-w-0 grid grid-cols-2 gap-3">
<div className="min-w-0">
<p className="font-bold text-[13.5px] text-(--color-content-primary) truncate">
{m.user_one_name || "بدون نام"}
</p>
<p dir="ltr" className="text-[11.5px] text-(--color-content-tertiary) text-right">
{m.user_one_phone}
</p>
</div>
<div className="min-w-0">
<p className="font-bold text-[13.5px] text-(--color-content-primary) truncate">
{m.user_two_name || "بدون نام"}
</p>
<p dir="ltr" className="text-[11.5px] text-(--color-content-tertiary) text-right">
{m.user_two_phone}
</p>
</div>
</div>
<p className="text-[11.5px] text-(--color-content-tertiary) shrink-0">
{new Date(m.created_at).toLocaleDateString("fa-IR")}
</p>
</div>
))}
</Card>
<div className="flex items-center justify-between">
<p className="text-[12px] text-(--color-content-tertiary)">
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} مچ
</p>
<div className="flex items-center gap-2">
<Button
variant="tertiary"
size="sm"
icon={<ChevronRight size={14} />}
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
قبلی
</Button>
<Button
variant="tertiary"
size="sm"
icon={<ChevronLeft size={14} />}
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
بعدی
</Button>
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,203 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Activity, AlertTriangle, Clock, Gauge, RefreshCw, Zap } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { formatApiError } from "@/lib/hooks";
import {
MonitoringSummary,
RequestLogEntry,
getMonitoringSummary,
getRecentRequests,
} from "@/lib/api";
const POLL_MS = 8_000;
function StatCard({
icon,
label,
value,
tone = "neutral",
}: {
icon: React.ReactNode;
label: string;
value: string;
tone?: "neutral" | "warning" | "error";
}) {
const toneClasses =
tone === "error"
? "text-(--color-status-error)"
: tone === "warning"
? "text-amber-600"
: "text-(--color-brand-600)";
return (
<Card className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-(--radius-md) bg-(--color-bg-secondary) flex items-center justify-center shrink-0 ${toneClasses}`}>
{icon}
</div>
<div>
<p className="text-[12px] text-(--color-content-tertiary)">{label}</p>
<p className="text-[19px] font-extrabold text-(--color-content-primary)">{value}</p>
</div>
</Card>
);
}
function statusTone(code: number): "success" | "warning" | "error" | "neutral" {
if (code >= 500) return "error";
if (code >= 400) return "warning";
if (code >= 200 && code < 300) return "success";
return "neutral";
}
function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h} ساعت و ${m} دقیقه`;
return `${m} دقیقه`;
}
export default function MonitoringPage() {
const [summary, setSummary] = useState<MonitoringSummary | null>(null);
const [requests, setRequests] = useState<RequestLogEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const [s, r] = await Promise.all([getMonitoringSummary(), getRecentRequests(50)]);
setSummary(s);
setRequests(r);
setError(null);
} catch (err) {
setError(formatApiError(err));
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
const interval = setInterval(load, POLL_MS);
return () => clearInterval(interval);
}, [load]);
if (!summary || !requests) {
if (error) {
return (
<div className="max-w-2xl">
<ErrorBanner message={error} />
</div>
);
}
return <FullScreenLoader label="آمار سرور رو میاریم..." />;
}
return (
<div className="flex flex-col gap-6 max-w-5xl">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
مانیتورینگ سرور 📡
</h1>
<p className="text-[14px] text-(--color-content-secondary) mt-1">
وضعیت زنده‌ی بک‌اند — هر {POLL_MS / 1000} ثانیه خودکار به‌روز می‌شه.
</p>
</div>
<Button variant="tertiary" size="sm" icon={<RefreshCw size={14} />} onClick={load}>
تازه‌سازی
</Button>
</div>
{error && <ErrorBanner message={error} />}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<StatCard
icon={<Activity size={18} />}
label="کل درخواست‌ها (از زمان اجرا)"
value={summary.total_requests.toLocaleString("fa-IR")}
/>
<StatCard
icon={<Zap size={18} />}
label="درخواست در دقیقه‌ی اخیر"
value={summary.requests_last_min.toLocaleString("fa-IR")}
/>
<StatCard
icon={<Clock size={18} />}
label="زمان پاسخ میانگین"
value={`${summary.avg_latency_ms.toFixed(1)} ms`}
/>
<StatCard
icon={<Gauge size={18} />}
label="زمان پاسخ p95"
value={`${summary.p95_latency_ms.toFixed(1)} ms`}
/>
<StatCard
icon={<AlertTriangle size={18} />}
label="نرخ خطا"
value={`${summary.error_rate_pct.toFixed(1)}%`}
tone={summary.error_rate_pct > 5 ? "error" : summary.error_rate_pct > 0 ? "warning" : "neutral"}
/>
<StatCard
icon={<Activity size={18} />}
label="مدت زمان روشن بودن سرور"
value={formatUptime(summary.uptime_seconds)}
/>
</div>
<Card padded={false} className="overflow-hidden">
<div className="px-5 py-4 border-b border-(--color-border-subtle)">
<h2 className="font-bold text-[15px]">آخرین درخواست‌ها</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
<th className="text-right font-semibold px-5 py-2.5">متد</th>
<th className="text-right font-semibold px-5 py-2.5">مسیر</th>
<th className="text-right font-semibold px-5 py-2.5">وضعیت</th>
<th className="text-right font-semibold px-5 py-2.5">مدت</th>
<th className="text-right font-semibold px-5 py-2.5">IP</th>
<th className="text-right font-semibold px-5 py-2.5">زمان</th>
</tr>
</thead>
<tbody>
{requests.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-(--color-content-tertiary) py-8">
هنوز درخواستی ثبت نشده
</td>
</tr>
) : (
requests.map((r, i) => (
<tr key={i} className="border-b border-(--color-border-subtle) last:border-0">
<td className="px-5 py-2.5 font-bold" dir="ltr">
{r.method}
</td>
<td className="px-5 py-2.5 font-mono text-[12px] text-(--color-content-secondary)" dir="ltr">
{r.path || "—"}
</td>
<td className="px-5 py-2.5">
<Badge tone={statusTone(r.status_code)}>{r.status_code}</Badge>
</td>
<td className="px-5 py-2.5" dir="ltr">
{r.duration_ms.toFixed(1)} ms
</td>
<td className="px-5 py-2.5 text-(--color-content-tertiary)" dir="ltr">
{r.client_ip}
</td>
<td className="px-5 py-2.5 text-(--color-content-tertiary)">
{new Date(r.time).toLocaleTimeString("fa-IR")}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, UserCheck } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ALL_CATEGORIES, listOptions, listQuestions, listPendingUsers } from "@/lib/api";
interface Stat {
key: string;
label: string;
emoji: string;
count: number;
href: string;
}
export default function DashboardHomePage() {
const [stats, setStats] = useState<Stat[] | null>(null);
const [questionsCount, setQuestionsCount] = useState<number | null>(null);
const [pendingCount, setPendingCount] = useState<number | null>(null);
useEffect(() => {
listPendingUsers()
.then((users) => setPendingCount(users.length))
.catch(() => setPendingCount(0));
Promise.all(
ALL_CATEGORIES.map(async (c) => {
try {
const options = await listOptions(c.key);
return {
key: c.key,
label: c.fallbackTitle,
emoji: c.emoji,
count: options.length,
href: `/categories/${c.key}`,
};
} catch {
return { key: c.key, label: c.fallbackTitle, emoji: c.emoji, count: 0, href: `/categories/${c.key}` };
}
})
).then(setStats);
listQuestions()
.then((qs) => setQuestionsCount(qs.length))
.catch(() => setQuestionsCount(0));
}, []);
if (!stats) return <FullScreenLoader label="آمار رو میاریم..." />;
return (
<div className="flex flex-col gap-8 max-w-5xl">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
سلام ادمین جان 👋
</h1>
<p className="text-[14px] text-(--color-content-secondary) mt-1">
از اینجا می‌تونی لیست‌های انتخابی و سوالات پروفایل اپ دلدل رو مدیریت کنی.
</p>
</div>
<Link href="/users">
<Card
hoverable
className="flex items-center justify-between max-w-sm !border-(--color-brand-200) bg-(--color-brand-50)"
>
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-(--radius-md) bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-sm) shrink-0">
<UserCheck size={19} className="text-white" />
</div>
<div>
<p className="font-bold text-[15px]">کاربران در انتظار تایید</p>
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
{pendingCount === null ? "…" : `${pendingCount} نفر منتظرن`}
</p>
</div>
</div>
{!!pendingCount && (
<Badge tone="warning" className="shrink-0">
{pendingCount}
</Badge>
)}
</Card>
</Link>
<div>
<h2 className="text-[13px] font-bold text-(--color-content-secondary) mb-3">
دسته‌بندی‌ها
</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{stats.map((s) => (
<Link key={s.key} href={s.href}>
<Card hoverable className="flex items-center justify-between h-full">
<div>
<p className="text-[24px] mb-1">{s.emoji}</p>
<p className="font-bold text-[15px] text-(--color-content-primary)">
{s.label}
</p>
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
{s.count} گزینه
</p>
</div>
<ArrowLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
</Card>
</Link>
))}
</div>
</div>
<div>
<h2 className="text-[13px] font-bold text-(--color-content-secondary) mb-3">
سوالات پروفایل
</h2>
<Link href="/questions">
<Card hoverable className="flex items-center justify-between max-w-sm">
<div>
<p className="text-[24px] mb-1">💬</p>
<p className="font-bold text-[15px]">سوالات پروفایل</p>
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
{questionsCount ?? "…"} سوال
</p>
</div>
<ArrowLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
</Card>
</Link>
</div>
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, ImageOff, Trash2, Flag } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { formatApiError } from "@/lib/hooks";
import { AdminPhotoView, listAllPhotos, deletePhotoAdmin, reportPhoto, photoUrl } from "@/lib/api";
const PAGE_SIZE = 24;
export default function PhotosModerationPage() {
const toast = useToast();
const [items, setItems] = useState<AdminPhotoView[] | null>(null);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [error, setError] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<AdminPhotoView | null>(null);
const [deleting, setDeleting] = useState(false);
const [pendingReport, setPendingReport] = useState<AdminPhotoView | null>(null);
const [reportReason, setReportReason] = useState("");
const [reporting, setReporting] = useState(false);
const load = useCallback(async (p: number) => {
setError(null);
try {
const res = await listAllPhotos(p, PAGE_SIZE);
setItems(res.items);
setTotal(res.total);
} catch (err) {
setError(formatApiError(err));
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load(page);
}, [page, load]);
async function handleDelete() {
if (!pendingDelete) return;
setDeleting(true);
try {
await deletePhotoAdmin(pendingDelete.id);
toast.showSuccess("عکس حذف شد");
setPendingDelete(null);
load(page);
} catch (err) {
toast.showError(formatApiError(err));
} finally {
setDeleting(false);
}
}
async function handleReport() {
if (!pendingReport || reportReason.trim().length < 3) return;
setReporting(true);
try {
await reportPhoto(pendingReport.id, reportReason.trim());
toast.showSuccess("گزارش ثبت شد");
setPendingReport(null);
setReportReason("");
} catch (err) {
toast.showError(formatApiError(err));
} finally {
setReporting(false);
}
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
نظارت بر عکس‌ها 🖼️
</h1>
<p className="text-[14px] text-(--color-content-secondary) mt-1">
آخرین عکس‌های آپلودشده‌ی همه‌ی کاربران، جدیدترین اول.
</p>
</div>
{error && <ErrorBanner message={error} />}
{!items ? (
<FullScreenLoader />
) : items.length === 0 ? (
<Card>
<EmptyState
icon={<ImageOff size={32} />}
title="هنوز عکسی آپلود نشده"
description="عکس‌های آپلودشده‌ی کاربران اینجا برای نظارت نشون داده می‌شن."
/>
</Card>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3">
{items.map((p) => (
<div
key={p.id}
className="group relative aspect-square rounded-(--radius-md) overflow-hidden bg-(--color-bg-tertiary) border border-(--color-border-subtle)"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photoUrl(p.url)}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 pt-6">
<p className="text-[11px] font-bold text-white truncate">
{p.owner_name || "بدون نام"}
</p>
<p dir="ltr" className="text-[10px] text-white/80 truncate">
{p.owner_phone}
</p>
</div>
<div className="absolute top-1.5 left-1.5 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setPendingReport(p)}
className="w-7 h-7 rounded-full bg-black/50 hover:bg-amber-600 flex items-center justify-center text-white"
aria-label="گزارش عکس"
>
<Flag size={12} />
</button>
<button
onClick={() => setPendingDelete(p)}
className="w-7 h-7 rounded-full bg-black/50 hover:bg-(--color-status-error) flex items-center justify-center text-white"
aria-label="حذف عکس"
>
<Trash2 size={13} />
</button>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between">
<p className="text-[12px] text-(--color-content-tertiary)">
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} عکس
</p>
<div className="flex items-center gap-2">
<Button
variant="tertiary"
size="sm"
icon={<ChevronRight size={14} />}
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
قبلی
</Button>
<Button
variant="tertiary"
size="sm"
icon={<ChevronLeft size={14} />}
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
بعدی
</Button>
</div>
</div>
</>
)}
<ConfirmDialog
open={!!pendingDelete}
title="حذف عکس"
description="این عکس برای همیشه حذف می‌شه و از پروفایل کاربر هم پاک می‌شه. مطمئنی؟"
loading={deleting}
onConfirm={handleDelete}
onCancel={() => setPendingDelete(null)}
/>
<Modal
open={!!pendingReport}
onClose={() => setPendingReport(null)}
title="گزارش عکس"
>
<div className="flex flex-col gap-4">
<p className="text-[13px] text-(--color-content-secondary)">
یک یادداشت نظارتی روی صاحب این عکس ثبت می‌شه (مستقل از حذف کردن).
</p>
<textarea
value={reportReason}
onChange={(e) => setReportReason(e.target.value)}
rows={3}
placeholder="دلیل گزارش..."
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setPendingReport(null)}>
انصراف
</Button>
<Button
size="sm"
isLoading={reporting}
disabled={reportReason.trim().length < 3}
onClick={handleReport}
>
ثبت گزارش
</Button>
</div>
</div>
</Modal>
</div>
);
}
@@ -0,0 +1,253 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/TextField";
import { Switch } from "@/components/ui/Controls";
import { Modal } from "@/components/ui/Modal";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { useToast } from "@/components/ui/Toast";
import { formatApiError } from "@/lib/hooks";
import {
ProfileQuestion,
listQuestions,
createQuestion,
updateQuestion,
deleteQuestion,
} from "@/lib/api";
export default function QuestionsPage() {
const toast = useToast();
const [questions, setQuestions] = useState<ProfileQuestion[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [formModal, setFormModal] = useState<{ mode: "create" | "edit"; q?: ProfileQuestion } | null>(
null
);
const [deleteTarget, setDeleteTarget] = useState<ProfileQuestion | null>(null);
const load = useCallback(async () => {
setError(null);
try {
setQuestions(await listQuestions());
} catch (err) {
setError(formatApiError(err));
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [load]);
if (error && !questions) {
return (
<div className="max-w-2xl">
<ErrorBanner message={error} />
</div>
);
}
if (!questions) return <FullScreenLoader />;
return (
<div className="flex flex-col gap-6 max-w-3xl">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
سوالات پروفایل 💬
</h1>
<p className="text-[14px] text-(--color-content-secondary) mt-1">
کاربر توی مرحله‌ی سوم آنبوردینگ دقیقاً ۳ تا از این سوالات رو انتخاب و
جواب می‌ده.
</p>
</div>
<Button size="sm" icon={<Plus size={16} />} onClick={() => setFormModal({ mode: "create" })}>
افزودن سوال
</Button>
</div>
<Card padded={false} className="overflow-hidden">
{questions.length === 0 ? (
<p className="text-[13px] text-(--color-content-tertiary) text-center py-10">
هنوز سوالی اضافه نشده.
</p>
) : (
<table className="w-full text-[13px]">
<thead>
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
<th className="text-right font-semibold px-4 py-3">متن سوال</th>
<th className="text-right font-semibold px-4 py-3 w-20">ترتیب</th>
<th className="text-right font-semibold px-4 py-3 w-24">فعال</th>
<th className="text-right font-semibold px-4 py-3 w-24">عملیات</th>
</tr>
</thead>
<tbody>
{[...questions]
.sort((a, b) => a.sort_order - b.sort_order)
.map((q) => (
<tr key={q.id} className="border-b border-(--color-border-subtle) last:border-0">
<td className="px-4 py-3 font-medium text-(--color-content-primary)">
{q.text}
</td>
<td className="px-4 py-3 text-(--color-content-tertiary)">{q.sort_order}</td>
<td className="px-4 py-3">
<Switch
defaultChecked={q.is_active}
onChange={async (next) => {
try {
await updateQuestion(q.id, { is_active: next });
setQuestions((prev) =>
prev!.map((x) => (x.id === q.id ? { ...x, is_active: next } : x))
);
} catch (err) {
toast.showError(formatApiError(err));
}
}}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={() => setFormModal({ mode: "edit", q })}
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
>
<Pencil size={13} />
</button>
<button
onClick={() => setDeleteTarget(q)}
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-red-50 text-(--color-status-error)"
>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
<QuestionFormModal
open={!!formModal}
mode={formModal?.mode ?? "create"}
question={formModal?.q}
onClose={() => setFormModal(null)}
onSaved={(q, mode) => {
if (mode === "create") {
setQuestions((prev) => [...(prev ?? []), q]);
toast.showSuccess("سوال اضافه شد");
} else {
setQuestions((prev) => prev!.map((x) => (x.id === q.id ? q : x)));
toast.showSuccess("سوال ویرایش شد");
}
setFormModal(null);
}}
/>
<ConfirmDialog
open={!!deleteTarget}
title="حذف سوال؟"
description="این سوال از لیست سوالات قابل‌انتخاب حذف می‌شه. پاسخ‌هایی که کاربرها قبلاً بهش دادن دست‌نخورده می‌مونه."
onCancel={() => setDeleteTarget(null)}
onConfirm={async () => {
if (!deleteTarget) return;
try {
await deleteQuestion(deleteTarget.id);
setQuestions((prev) => prev!.filter((x) => x.id !== deleteTarget.id));
toast.showSuccess("سوال حذف شد");
} catch (err) {
toast.showError(formatApiError(err));
} finally {
setDeleteTarget(null);
}
}}
/>
</div>
);
}
function QuestionFormModal({
open,
mode,
question,
onClose,
onSaved,
}: {
open: boolean;
mode: "create" | "edit";
question?: ProfileQuestion;
onClose: () => void;
onSaved: (q: ProfileQuestion, mode: "create" | "edit") => void;
}) {
const [text, setText] = useState(question?.text ?? "");
const [sortOrder, setSortOrder] = useState(String(question?.sort_order ?? 0));
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setText(question?.text ?? "");
setSortOrder(String(question?.sort_order ?? 0));
setError(null);
}, [question, open]);
async function handleSave() {
if (text.trim().length < 3) return;
setLoading(true);
setError(null);
try {
if (mode === "create") {
const created = await createQuestion({ text: text.trim(), sort_order: parseInt(sortOrder, 10) || 0 });
onSaved(created, "create");
} else if (question) {
const updated = await updateQuestion(question.id, {
text: text.trim(),
sort_order: parseInt(sortOrder, 10) || 0,
});
onSaved(updated, "edit");
}
} catch (err) {
setError(formatApiError(err));
} finally {
setLoading(false);
}
}
return (
<Modal open={open} onClose={onClose} title={mode === "create" ? "افزودن سوال" : "ویرایش سوال"}>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
متن سوال
</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={3}
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
autoFocus
/>
</div>
<TextField
label="ترتیب نمایش"
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
/>
<ErrorBanner message={error} />
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={onClose}>
انصراف
</Button>
<Button size="sm" isLoading={loading} disabled={text.trim().length < 3} onClick={handleSave}>
ذخیره
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,451 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import {
ChevronRight,
CheckCircle2,
XCircle,
Ruler,
Weight,
GraduationCap,
Briefcase,
Heart,
Sparkles,
Search,
Check,
Flag,
Activity,
Images,
User as UserIcon,
} from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
import { Badge } from "@/components/ui/Badge";
import { Tag } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { useToast } from "@/components/ui/Toast";
import { UserModerationActions } from "@/components/UserModerationActions";
import { formatApiError } from "@/lib/hooks";
import { calcAge } from "@/lib/utils";
import {
FullProfile,
ApprovalStatus,
Report,
RequestLogEntry,
getUserDetail,
approveUser,
rejectUser,
listUserReports,
getRecentRequests,
photoUrl,
} from "@/lib/api";
const statusMeta: Record<ApprovalStatus, { label: string; tone: "warning" | "success" | "error" }> = {
pending: { label: "در انتظار بررسی", tone: "warning" },
approved: { label: "تایید شده", tone: "success" },
rejected: { label: "رد شده", tone: "error" },
};
function selectionTitles(profile: FullProfile, key: string): string[] {
return profile.selections.find((s) => s.category_key === key)?.titles ?? [];
}
function SectionCard({
icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<Card className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-(--color-content-secondary)">
{icon}
<span className="text-[13px] font-bold">{title}</span>
</div>
{children}
</Card>
);
}
export default function UserReviewPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const toast = useToast();
const id = Number(params.id);
const [profile, setProfile] = useState<FullProfile | null>(null);
const [reports, setReports] = useState<Report[]>([]);
const [activity, setActivity] = useState<RequestLogEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [acting, setActing] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const load = useCallback(async () => {
setError(null);
try {
setProfile(await getUserDetail(id));
} catch (err) {
setError(formatApiError(err));
}
listUserReports(id).then(setReports).catch(() => setReports([]));
getRecentRequests(15, id).then(setActivity).catch(() => setActivity([]));
}, [id]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [load]);
async function handleApprove() {
setActing(true);
try {
await approveUser(id);
toast.showSuccess("کاربر تایید شد");
router.push("/users");
} catch (err) {
toast.showError(formatApiError(err));
} finally {
setActing(false);
}
}
if (error && !profile) {
return (
<div className="max-w-2xl">
<ErrorBanner message={error} />
</div>
);
}
if (!profile) return <FullScreenLoader />;
const { user } = profile;
const age = calcAge(user.birth_date);
const education = selectionTitles(profile, "education_level")[0];
const fieldOfStudy = selectionTitles(profile, "field_of_study")[0];
const occupations = selectionTitles(profile, "occupation");
const importance = selectionTitles(profile, "important_in_relationship");
const interests = selectionTitles(profile, "interest");
const lookingFor = selectionTitles(profile, "looking_for")[0];
const status = statusMeta[user.approval_status];
return (
<div className="flex flex-col gap-6 max-w-3xl pb-20">
<button
onClick={() => router.push("/users")}
className="flex items-center gap-1 text-[13px] font-semibold text-(--color-content-secondary) hover:text-(--color-brand-600) w-fit"
>
<ChevronRight size={16} />
بازگشت به لیست
</button>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex items-center gap-4">
<Avatar name={user.first_name || user.phone} size="lg" />
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[22px]">
{user.first_name || "بدون نام"}
{age ? `، ${age}` : ""}
</h1>
<p dir="ltr" className="text-[13px] text-(--color-content-tertiary)">
{user.phone}
</p>
<div className="flex items-center gap-2 mt-2">
<Badge tone={status.tone}>{status.label}</Badge>
{user.gender && (
<Badge tone="neutral">{user.gender === "male" ? "مرد" : "زن"}</Badge>
)}
{user.state === "blocked" && <Badge tone="error">بلاک</Badge>}
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<UserModerationActions userId={id} state={user.state} onChanged={load} />
<Button
variant="tertiary"
icon={<XCircle size={16} />}
className="!text-(--color-status-error) !border-red-200"
onClick={() => setRejectOpen(true)}
disabled={acting}
>
رد کردن
</Button>
<Button icon={<CheckCircle2 size={16} />} isLoading={acting} onClick={handleApprove}>
تایید کاربر
</Button>
</div>
</div>
{user.approval_status === "rejected" && user.rejection_reason && (
<ErrorBanner message={`دلیل رد قبلی: ${user.rejection_reason}`} />
)}
{user.state === "blocked" && user.blocked_reason && (
<ErrorBanner message={`دلیل بلاک: ${user.blocked_reason}`} />
)}
<SectionCard icon={<Images size={15} />} title={`عکس‌ها (${profile.photos.length})`}>
{profile.photos.length === 0 ? (
<div className="flex items-center gap-2 text-(--color-content-tertiary) text-[13px]">
<UserIcon size={15} />
<span>این کاربر هنوز عکسی آپلود نکرده.</span>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2.5">
{profile.photos.map((p) => (
<a
key={p.id}
href={photoUrl(p.url)}
target="_blank"
rel="noopener noreferrer"
className="relative aspect-square rounded-(--radius-md) overflow-hidden bg-(--color-bg-tertiary) block"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photoUrl(p.url)} alt="" className="w-full h-full object-cover" />
</a>
))}
</div>
)}
</SectionCard>
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
<div className="flex gap-4 text-[14px]">
<span className="flex items-center gap-1.5">
<Ruler size={14} className="text-(--color-brand-500)" />
{user.height_cm ?? "—"} سانتی‌متر
</span>
<span className="flex items-center gap-1.5">
<Weight size={14} className="text-(--color-brand-500)" />
{user.weight_kg ?? "—"} کیلوگرم
</span>
</div>
</SectionCard>
<SectionCard icon={<GraduationCap size={15} />} title="تحصیلات">
<p className="text-[14px] text-(--color-content-primary)">
{education ?? "—"} {fieldOfStudy ? `· ${fieldOfStudy}` : ""}
</p>
</SectionCard>
<SectionCard icon={<Briefcase size={15} />} title="فعالیت‌ها">
<div className="flex flex-wrap gap-1.5">
{occupations.length ? (
occupations.map((t) => <Tag key={t} label={t} color="sky" />)
) : (
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
)}
</div>
</SectionCard>
<SectionCard icon={<Heart size={15} />} title="توی رابطه براش مهمه">
<div className="flex flex-wrap gap-1.5">
{importance.length ? (
importance.map((t) => <Tag key={t} label={t} color="pink" />)
) : (
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
)}
</div>
</SectionCard>
<SectionCard icon={<Sparkles size={15} />} title="علاقه‌مندی‌ها">
<div className="flex flex-wrap gap-1.5">
{interests.length ? (
interests.map((t) => <Tag key={t} label={t} color="yellow" />)
) : (
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
)}
</div>
</SectionCard>
<SectionCard icon={<Search size={15} />} title="دنبال چی می‌گرده">
<p className="text-[14px] text-(--color-content-primary)">{lookingFor ?? "—"}</p>
</SectionCard>
{profile.profile_answers.length > 0 && (
<SectionCard icon={<Sparkles size={15} />} title="یکم از خودش">
<div className="flex flex-col gap-3">
{profile.profile_answers.map((a) => (
<div key={a.id}>
<p className="text-[12px] font-bold text-(--color-content-tertiary) mb-0.5">
{a.question?.text}
</p>
<p className="text-[14px] text-(--color-content-primary)">{a.answer}</p>
</div>
))}
</div>
</SectionCard>
)}
{reports.length > 0 && (
<SectionCard icon={<Flag size={15} />} title={`سابقه‌ی گزارش‌ها (${reports.length})`}>
<div className="flex flex-col gap-3">
{reports.map((r) => (
<div key={r.id} className="flex flex-col gap-0.5">
<p className="text-[14px] text-(--color-content-primary)">{r.reason}</p>
<p className="text-[12px] text-(--color-content-tertiary)">
{new Date(r.created_at).toLocaleString("fa-IR")}
</p>
</div>
))}
</div>
</SectionCard>
)}
<SectionCard icon={<Activity size={15} />} title="فعالیت اخیر">
{activity.length === 0 ? (
<p className="text-[13px] text-(--color-content-tertiary)">
درخواستی از این کاربر در حافظه‌ی اخیر سرور ثبت نشده.
</p>
) : (
<div className="flex flex-col gap-1.5">
{activity.map((a, i) => (
<div key={i} className="flex items-center justify-between text-[12.5px]">
<span dir="ltr" className="font-mono text-(--color-content-secondary) truncate">
{a.method} {a.path}
</span>
<span className="text-(--color-content-tertiary) shrink-0 pr-2">
{new Date(a.time).toLocaleTimeString("fa-IR")}
</span>
</div>
))}
</div>
)}
</SectionCard>
<RejectModal
open={rejectOpen}
onClose={() => setRejectOpen(false)}
onConfirm={async (reason) => {
setActing(true);
try {
await rejectUser(id, reason);
toast.showSuccess("کاربر رد شد");
setRejectOpen(false);
router.push("/users");
} catch (err) {
toast.showError(formatApiError(err));
} finally {
setActing(false);
}
}}
loading={acting}
/>
</div>
);
}
// دلایل رایج رد پروفایل — ادمین تیک می‌زنه، متن نهایی خودکار برای کاربر ساخته می‌شه
const COMMON_REASONS = [
"عکس پروفایل نداره یا واضح نیست",
"اطلاعات پروفایل ناقصه",
"پروفایل به نظر جعلی یا ربات میاد",
"محتوای عکس‌ها نامناسبه",
"توی پروفایل شماره تماس یا لینک گذاشته",
];
function RejectModal({
open,
onClose,
onConfirm,
loading,
}: {
open: boolean;
onClose: () => void;
onConfirm: (reason: string) => void;
loading: boolean;
}) {
const [selected, setSelected] = useState<string[]>([]);
const [extra, setExtra] = useState("");
useEffect(() => {
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelected([]);
setExtra("");
}
}, [open]);
function toggle(text: string) {
setSelected((prev) =>
prev.includes(text) ? prev.filter((t) => t !== text) : [...prev, text]
);
}
const finalReason = [...selected, extra.trim()].filter(Boolean).join("؛ ");
const valid = finalReason.length >= 3;
return (
<Modal open={open} onClose={onClose} title="رد کردن کاربر">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
دلیل رد (برای کاربر نمایش داده می‌شه)
</label>
<div className="flex flex-col gap-2">
{COMMON_REASONS.map((text) => {
const checked = selected.includes(text);
return (
<button
key={text}
type="button"
onClick={() => toggle(text)}
className={`flex items-center gap-2.5 rounded-(--radius-sm) border-2 px-3.5 py-2.5 text-right text-[13px] font-medium transition-colors ${
checked
? "border-(--color-brand-500) bg-(--color-surface-interactive) text-(--color-brand-700)"
: "border-(--color-border-default) text-(--color-content-secondary)"
}`}
>
<span
className={`h-5 w-5 rounded-(--radius-xs) flex items-center justify-center shrink-0 border-2 ${
checked
? "bg-(--color-brand-500) border-(--color-brand-500)"
: "border-(--color-border-default)"
}`}
>
{checked && <Check size={13} className="text-white" strokeWidth={3} />}
</span>
{text}
</button>
);
})}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
توضیح تکمیلی (اختیاری)
</label>
<textarea
value={extra}
onChange={(e) => setExtra(e.target.value)}
rows={2}
placeholder="اگه دلیل دیگه‌ای هست بنویس..."
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={onClose}>
انصراف
</Button>
<Button
size="sm"
isLoading={loading}
disabled={!valid}
className="!bg-(--color-status-error) !shadow-none hover:!bg-red-700"
onClick={() => onConfirm(finalReason)}
>
رد کردن کاربر
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,169 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ChevronLeft, ChevronRight, Search, Users as UsersIcon } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { UserModerationActions } from "@/components/UserModerationActions";
import { formatApiError } from "@/lib/hooks";
import { calcAge } from "@/lib/utils";
import { AdminUserListItem, listAllUsers } from "@/lib/api";
const PAGE_SIZE = 20;
const approvalToneMap = {
pending: "warning",
approved: "success",
rejected: "error",
} as const;
const approvalLabelMap = {
pending: "در انتظار",
approved: "تاییدشده",
rejected: "ردشده",
};
export default function AllUsersPage() {
const [items, setItems] = useState<AdminUserListItem[] | null>(null);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (p: number, s: string) => {
setError(null);
try {
const res = await listAllUsers(s, p, PAGE_SIZE);
setItems(res.items);
setTotal(res.total);
} catch (err) {
setError(formatApiError(err));
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load(page, search);
// search دونه‌شده جدا (effect پایین) هندل می‌شه؛ اینجا فقط تغییر page مهمه
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, load]);
useEffect(() => {
const t = setTimeout(() => {
setPage(1);
load(1, search);
}, 350);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div className="flex flex-col gap-6 max-w-4xl">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
همه‌ی کاربران 👥
</h1>
<p className="text-[14px] text-(--color-content-secondary) mt-1">
جست‌وجو بر اساس شماره یا اسم؛ بلاک/آنبلاک و گزارش کاربران از همین‌جا.
</p>
</div>
<div className="relative max-w-sm">
<Search size={16} className="absolute right-3.5 top-1/2 -translate-y-1/2 text-(--color-content-tertiary)" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جست‌وجو با شماره یا اسم..."
className="w-full h-11 rounded-(--radius-md) border-2 border-(--color-border-default) bg-(--color-surface-default) pr-10 pl-4 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100)"
/>
</div>
{error && <ErrorBanner message={error} />}
{!items ? (
<FullScreenLoader />
) : items.length === 0 ? (
<Card>
<EmptyState icon={<UsersIcon size={32} />} title="کاربری پیدا نشد" description="جست‌وجوت رو تغییر بده." />
</Card>
) : (
<>
<Card padded={false} className="overflow-hidden">
{items.map((u, i) => (
<div
key={u.id}
className={`flex items-center gap-3 px-4 py-3.5 ${
i !== items.length - 1 ? "border-b border-(--color-border-subtle)" : ""
}`}
>
<Link href={`/users/${u.id}`} className="flex items-center gap-3 flex-1 min-w-0">
<Avatar name={u.first_name || u.phone} />
<div className="flex-1 min-w-0">
<p className="font-bold text-[14px] text-(--color-content-primary) truncate">
{u.first_name || "بدون نام"}
{calcAge(u.birth_date) ? `، ${calcAge(u.birth_date)} سال` : ""}
</p>
<p dir="ltr" className="text-[12px] text-(--color-content-tertiary) text-right">
{u.phone}
</p>
</div>
</Link>
<div className="flex items-center gap-1.5 shrink-0">
{u.is_onboarded && (
<Badge tone={approvalToneMap[u.approval_status]}>
{approvalLabelMap[u.approval_status]}
</Badge>
)}
{u.state === "blocked" && <Badge tone="error">بلاک</Badge>}
{u.report_count > 0 && (
<Badge tone="warning">{u.report_count} گزارش</Badge>
)}
</div>
<UserModerationActions
userId={u.id}
state={u.state}
onChanged={() => load(page, search)}
/>
</div>
))}
</Card>
<div className="flex items-center justify-between">
<p className="text-[12px] text-(--color-content-tertiary)">
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} کاربر
</p>
<div className="flex items-center gap-2">
<Button
variant="tertiary"
size="sm"
icon={<ChevronRight size={14} />}
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
قبلی
</Button>
<Button
variant="tertiary"
size="sm"
icon={<ChevronLeft size={14} />}
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
بعدی
</Button>
</div>
</div>
</>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import { ChevronLeft, UserCheck } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { formatApiError } from "@/lib/hooks";
import { calcAge } from "@/lib/utils";
import { PendingUserView, listPendingUsers, photoUrl } from "@/lib/api";
export default function PendingUsersPage() {
const [users, setUsers] = useState<PendingUserView[] | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setError(null);
try {
setUsers(await listPendingUsers());
} catch (err) {
setError(formatApiError(err));
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [load]);
if (error && !users) {
return (
<div className="max-w-2xl">
<ErrorBanner message={error} />
</div>
);
}
if (!users) return <FullScreenLoader />;
return (
<div className="flex flex-col gap-6 max-w-3xl">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
کاربران در انتظار تایید ✅
</h1>
<p className="text-[14px] text-(--color-content-secondary) mt-1">
این کاربرها آنبوردینگ رو کامل کردن و منتظرن پروفایلشون بررسی بشه. قدیمی‌ترین
درخواست بالاتره.
</p>
</div>
{users.length === 0 ? (
<Card>
<EmptyState
icon={<UserCheck size={32} />}
title="کسی منتظر بررسی نیست"
description="هر وقت کاربر جدیدی آنبوردینگ رو تموم کنه، اینجا برای بررسی نشون داده می‌شه."
/>
</Card>
) : (
<Card padded={false} className="overflow-hidden">
{users.map((u, i) => (
<Link
key={u.id}
href={`/users/${u.id}`}
className={`flex items-center gap-3 px-4 py-3.5 hover:bg-(--color-bg-secondary) transition-colors ${
i !== users.length - 1 ? "border-b border-(--color-border-subtle)" : ""
}`}
>
{u.photos.length > 0 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photoUrl(u.photos[0])}
alt=""
className="w-11 h-11 rounded-(--radius-md) object-cover shrink-0"
/>
) : (
<Avatar name={u.first_name || u.phone} />
)}
<div className="flex-1 min-w-0">
<p className="font-bold text-[14px] text-(--color-content-primary) truncate">
{u.first_name || "بدون نام"}
{calcAge(u.birth_date) ? `، ${calcAge(u.birth_date)} سال` : ""}
</p>
<p dir="ltr" className="text-[12px] text-(--color-content-tertiary) text-right">
{u.phone}
</p>
</div>
{u.photos.length === 0 && (
<Badge tone="warning" className="shrink-0">
بدون عکس
</Badge>
)}
{u.gender && (
<Badge tone="neutral" className="shrink-0">
{u.gender === "male" ? "مرد" : "زن"}
</Badge>
)}
<ChevronLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
</Link>
))}
</Card>
)}
</div>
);
}