chore: merge admin repo history into monorepo at admin/
git-subtree-dir: admin git-subtree-mainline:35f5084005git-subtree-split:9f1eccd2f7
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user