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,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dating-admin",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3001
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
!.env.local.example
|
||||
npm-debug.log*
|
||||
uploads
|
||||
@@ -0,0 +1,2 @@
|
||||
# آدرس بکاند Go (بدون اسلش آخر)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1
|
||||
@@ -0,0 +1,46 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env*.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# فایلهای آپلودشدهی بکاند (اگه به اشتباه اینجا کنار بکاند اجرا بشه)
|
||||
/uploads/
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
*.zip
|
||||
@@ -0,0 +1,34 @@
|
||||
# ---- وابستگیها ----
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# ---- build ----
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# NEXT_PUBLIC_* موقع build توی باندل کلاینت جاسازی میشه، پس باید همینجا
|
||||
# بهعنوان build arg پاس داده بشه (نه فقط env زمان اجرا)
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- اجرا (ایمیج نهایی سبک، فقط خروجی standalone) ----
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,70 @@
|
||||
# پنل ادمین دلدل
|
||||
|
||||
پنل مدیریت دسکتاپمحور (Next.js + Basis Design System) برای همون بکاند
|
||||
Go/Gin/GORM اپ دیتینگ. با این پنل میتونی بدون لمس کد، تمام لیستهای
|
||||
انتخابی و سوالات پروفایل اپ رو مدیریت کنی.
|
||||
|
||||
## ویژگیها
|
||||
|
||||
- **ورود امن:** همون فلوی OTP اپ اصلی، ولی فقط کاربرهایی که `is_admin=true`
|
||||
دارن اجازهی ورود میگیرن (پیام «دسترسی نداری» برای بقیه).
|
||||
- **مدیریت ۶ دستهی انتخابی:** سطح تحصیلات، رشته تحصیلی، فعالیتها، اهمیت در
|
||||
رابطه، علاقهمندیها، دنبال چی میگردی — برای هر کدوم:
|
||||
- ویرایش عنوان و حداقل/حداکثر تعداد انتخاب مجاز
|
||||
- افزودن/ویرایش/حذف گزینه
|
||||
- فعال/غیرفعال کردن آنی با یک کلیک (گزینهی غیرفعال فوراً از دید کاربر مخفی میشه)
|
||||
- **مدیریت سوالات پروفایل:** افزودن/ویرایش/حذف/فعالسازی سوالاتی که کاربر
|
||||
توی مرحلهی سوم آنبوردینگ ۳ تاشو انتخاب میکنه.
|
||||
- فیدبک فوری با Toast بعد از هر عملیات، تایید قبل از حذف.
|
||||
|
||||
## ✅ تست شده
|
||||
|
||||
قبل از تحویل، یک اسکریپت Node که دقیقاً همون درخواستهای `src/lib/api.ts`
|
||||
رو میفرسته، روی یک نمونهی واقعی از بکاند اجرا شد: ورود ادمین، GET/PATCH
|
||||
دسته، CRUD کامل گزینه، toggle فعال/غیرفعال (با تایید اینکه گزینهی
|
||||
غیرفعال از API عمومی کاربر واقعاً حذف میشه)، CRUD کامل سوالات، و مهمتر
|
||||
از همه — تست کردیم که یک کاربر عادی (غیرادمین) با کد ۴۰۳ از تمام
|
||||
`/admin/*` endpoint ها بلاک میشه. همهی ۱۴ چک پاس شد.
|
||||
|
||||
## راهاندازی
|
||||
|
||||
```bash
|
||||
cp .env.local.example .env.local
|
||||
# NEXT_PUBLIC_API_URL رو به آدرس بکاندت تنظیم کن
|
||||
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`http://localhost:3000` رو باز کن، با شمارهی موبایلِ یک کاربر ادمین
|
||||
(همونی که با `ADMIN_BOOTSTRAP_PHONE` در بکاند ساخته شده، یا هر کاربری که
|
||||
دستی `is_admin=true` شده) وارد شو.
|
||||
|
||||
## ساختار پروژه
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── login/, verify/ ← ورود OTP (مثل اپ اصلی)
|
||||
│ └── (dashboard)/
|
||||
│ ├── layout.tsx ← گارد ادمین + سایدبار
|
||||
│ ├── page.tsx ← داشبورد با آمار سریع
|
||||
│ ├── categories/page.tsx ← لیست ۶ دسته
|
||||
│ ├── categories/[key]/ ← ویرایش تنظیمات دسته + CRUD گزینهها
|
||||
│ └── questions/page.tsx ← CRUD سوالات پروفایل
|
||||
├── components/
|
||||
│ ├── ui/ ← دیزاینسیستم مشترک + Modal/Toast/ConfirmDialog جدید
|
||||
│ └── Sidebar.tsx
|
||||
└── lib/
|
||||
├── api.ts ← کلاینت تایپشدهی endpoint های /admin/*
|
||||
├── auth-context.tsx ← ورود + چک is_admin
|
||||
└── hooks.ts
|
||||
```
|
||||
|
||||
## نکتهی امنیتی
|
||||
|
||||
این پنل فقط روی احراز هویت سمت بکاند (`middleware.AdminRequired`) تکیه
|
||||
میکنه — یعنی حتی اگه کسی مستقیم به route های این پنل دسترسی پیدا کنه،
|
||||
بدون توکن معتبرِ یک کاربر `is_admin=true`، هیچ عملیاتی از سمت بکاند رد
|
||||
نمیشه (این رو با تست ۴۰۳ بالا تایید کردیم). با این حال توصیه میشه این
|
||||
پنل رو پشت یک دامنهی جدا یا حداقل VPN/IP-allowlist در پروداکشن قرار بدی.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// برای build خروجی مستقل (بدون نیاز به node_modules کامل) — لازم برای ایمیج Docker سبک
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6822
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "dating-app-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/baloo-2": "^5.3.0",
|
||||
"@fontsource-variable/vazirmatn": "^5.3.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.45.0",
|
||||
"next": "16.3.5",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.5",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,188 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ============================================================
|
||||
BASIS DESIGN SYSTEM — نسخهی شاد، گرد و گوگولیمگولیِ Atlas
|
||||
همون اصول ساختاری (نقشمحور بودن توکنها، مقیاس فاصلهگذاری،
|
||||
سلسلهمراتب تایپوگرافی) حفظ شده؛ چیزی که عوض شده «شخصیت»
|
||||
بصریه: گوشههای گرد بهجای تیز، قرمز پرانرژیتر، سایههای
|
||||
نرم رنگی بهجای بردر مسطح.
|
||||
============================================================ */
|
||||
:root {
|
||||
/* -------- brand / accent -------- */
|
||||
--color-brand-50: #fff1ee;
|
||||
--color-brand-100: #ffe1da;
|
||||
--color-brand-200: #ffc2b3;
|
||||
--color-brand-300: #ff9c85;
|
||||
--color-brand-400: #ff6f56;
|
||||
--color-brand-500: #ff4433; /* قرمز شاد اصلی */
|
||||
--color-brand-600: #f22c1f;
|
||||
--color-brand-700: #d6210f;
|
||||
--color-brand-800: #a91a0d;
|
||||
--color-brand-900: #7c1409;
|
||||
--color-brand-onPrimary: #fffaf8;
|
||||
|
||||
/* -------- accent دوم برای شادی بیشتر (نقطهها/برچسبها) -------- */
|
||||
--color-pop-yellow: #ffc93c;
|
||||
--color-pop-pink: #ff8fb1;
|
||||
--color-pop-mint: #3ddc97;
|
||||
--color-pop-sky: #4fb4ff;
|
||||
|
||||
/* -------- neutral warm (پسزمینهی گرم، نه خاکستری سرد) -------- */
|
||||
--color-bg-primary: #fff8f5;
|
||||
--color-bg-secondary: #fff1ec;
|
||||
--color-bg-tertiary: #ffe4da;
|
||||
|
||||
--color-surface-default: #ffffff;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-surface-interactive: #fff1ec;
|
||||
|
||||
--color-content-primary: #2b1a15;
|
||||
--color-content-secondary: #7a5c52;
|
||||
--color-content-tertiary: #a68c82;
|
||||
--color-content-disabled: #d8c6bf;
|
||||
--color-content-onColor: #fffaf8;
|
||||
|
||||
--color-border-subtle: #ffe4da;
|
||||
--color-border-default: #ffd0c0;
|
||||
--color-border-interactive: var(--color-brand-500);
|
||||
|
||||
--color-status-success: #21a179;
|
||||
--color-status-warning: #d98a12;
|
||||
--color-status-error: #e0301e;
|
||||
--color-status-info: #3d8fd6;
|
||||
|
||||
/* -------- radius: همهچیز گرد و نرم -------- */
|
||||
--radius-xs: 10px;
|
||||
--radius-sm: 14px;
|
||||
--radius-md: 20px;
|
||||
--radius-lg: 28px;
|
||||
--radius-xl: 36px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* -------- سایههای نرم و رنگی (بهجای بردر مسطح Atlas) -------- */
|
||||
--shadow-cute-sm: 0 2px 8px -2px rgba(255, 68, 51, 0.16), 0 1px 2px rgba(43, 26, 21, 0.06);
|
||||
--shadow-cute-md: 0 10px 24px -8px rgba(255, 68, 51, 0.22), 0 2px 6px rgba(43, 26, 21, 0.06);
|
||||
--shadow-cute-lg: 0 20px 45px -12px rgba(255, 68, 51, 0.28), 0 4px 10px rgba(43, 26, 21, 0.08);
|
||||
--shadow-pop-yellow: 0 8px 20px -6px rgba(255, 201, 60, 0.45);
|
||||
--shadow-pop-mint: 0 8px 20px -6px rgba(61, 220, 151, 0.4);
|
||||
|
||||
--background: var(--color-bg-primary);
|
||||
--foreground: var(--color-content-primary);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-brand-50: var(--color-brand-50);
|
||||
--color-brand-100: var(--color-brand-100);
|
||||
--color-brand-200: var(--color-brand-200);
|
||||
--color-brand-300: var(--color-brand-300);
|
||||
--color-brand-400: var(--color-brand-400);
|
||||
--color-brand-500: var(--color-brand-500);
|
||||
--color-brand-600: var(--color-brand-600);
|
||||
--color-brand-700: var(--color-brand-700);
|
||||
--color-brand-800: var(--color-brand-800);
|
||||
--color-brand-900: var(--color-brand-900);
|
||||
|
||||
--color-pop-yellow: var(--color-pop-yellow);
|
||||
--color-pop-pink: var(--color-pop-pink);
|
||||
--color-pop-mint: var(--color-pop-mint);
|
||||
--color-pop-sky: var(--color-pop-sky);
|
||||
|
||||
--color-bg-primary: var(--color-bg-primary);
|
||||
--color-bg-secondary: var(--color-bg-secondary);
|
||||
--color-bg-tertiary: var(--color-bg-tertiary);
|
||||
|
||||
--color-surface-default: var(--color-surface-default);
|
||||
--color-surface-elevated: var(--color-surface-elevated);
|
||||
--color-surface-interactive: var(--color-surface-interactive);
|
||||
|
||||
--color-content-primary: var(--color-content-primary);
|
||||
--color-content-secondary: var(--color-content-secondary);
|
||||
--color-content-tertiary: var(--color-content-tertiary);
|
||||
--color-content-disabled: var(--color-content-disabled);
|
||||
--color-content-onColor: var(--color-content-onColor);
|
||||
|
||||
--color-border-subtle: var(--color-border-subtle);
|
||||
--color-border-default: var(--color-border-default);
|
||||
--color-border-interactive: var(--color-border-interactive);
|
||||
|
||||
--color-status-success: var(--color-status-success);
|
||||
--color-status-warning: var(--color-status-warning);
|
||||
--color-status-error: var(--color-status-error);
|
||||
--color-status-info: var(--color-status-info);
|
||||
|
||||
--radius-xs: var(--radius-xs);
|
||||
--radius-sm: var(--radius-sm);
|
||||
--radius-md: var(--radius-md);
|
||||
--radius-lg: var(--radius-lg);
|
||||
--radius-xl: var(--radius-xl);
|
||||
--radius-full: var(--radius-full);
|
||||
|
||||
--shadow-cute-sm: var(--shadow-cute-sm);
|
||||
--shadow-cute-md: var(--shadow-cute-md);
|
||||
--shadow-cute-lg: var(--shadow-cute-lg);
|
||||
--shadow-pop-yellow: var(--shadow-pop-yellow);
|
||||
--shadow-pop-mint: var(--shadow-pop-mint);
|
||||
|
||||
--font-sans: "Vazirmatn Variable", "Baloo 2 Variable", system-ui, sans-serif;
|
||||
--font-display: "Baloo 2 Variable", "Vazirmatn Variable", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-brand-200);
|
||||
color: var(--color-brand-900);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-brand-200);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
transform: scale(0.9) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.02) translateY(-2px);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import "@fontsource-variable/vazirmatn";
|
||||
import "@fontsource-variable/baloo-2";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { ToastProvider } from "@/components/ui/Toast";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "پنل ادمین دلدل",
|
||||
description: "مدیریت گزینهها، دستهبندیها و سوالات پروفایل اپ دلدل",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className="h-full antialiased">
|
||||
<body className="min-h-dvh bg-(--color-bg-secondary) text-(--color-content-primary)">
|
||||
<AuthProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextField } from "@/components/ui/TextField";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { requestOtp } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
const PHONE_REGEX = /^09\d{9}$/;
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { status } = useAuth();
|
||||
const [phone, setPhone] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") router.replace("/");
|
||||
}, [status, router]);
|
||||
|
||||
const isValid = PHONE_REGEX.test(phone);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await requestOtp(phone);
|
||||
router.push(`/verify?phone=${phone}`);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
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-6 !p-8">
|
||||
<div className="w-16 h-16 rounded-(--radius-lg) bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-md)">
|
||||
<ShieldCheck size={28} className="text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[22px]">
|
||||
پنل ادمین دلدل
|
||||
</h1>
|
||||
<p className="text-[13px] text-(--color-content-secondary) mt-1">
|
||||
فقط برای مدیران سیستم
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<TextField
|
||||
label="شماره موبایل ادمین"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
placeholder="09xxxxxxxxx"
|
||||
dir="ltr"
|
||||
className="text-center tracking-widest"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, "").slice(0, 11))}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||
/>
|
||||
<ErrorBanner message={error} />
|
||||
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||
دریافت کد تایید
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ChevronRight, ShieldAlert } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { OtpInput } from "@/components/ui/OtpInput";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { requestOtp } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
const RESEND_SECONDS = 60;
|
||||
|
||||
function VerifyForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const phone = searchParams.get("phone") ?? "";
|
||||
const { login } = useAuth();
|
||||
|
||||
const [otp, setOtp] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [countdown, setCountdown] = useState(RESEND_SECONDS);
|
||||
const [resending, setResending] = useState(false);
|
||||
const [notAdmin, setNotAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [countdown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!phone) router.replace("/login");
|
||||
}, [phone, router]);
|
||||
|
||||
async function handleVerify(code: string) {
|
||||
if (code.length !== 4) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setNotAdmin(false);
|
||||
try {
|
||||
await login(phone, code);
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
const msg = formatApiError(err, "کد وارد شده اشتباهه");
|
||||
if (msg.includes("ادمین")) {
|
||||
setNotAdmin(true);
|
||||
} else {
|
||||
setError(msg);
|
||||
setOtp("");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
setResending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await requestOtp(phone);
|
||||
setCountdown(RESEND_SECONDS);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (notAdmin) {
|
||||
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={() => router.push("/login")}>
|
||||
برگشت به صفحهی ورود
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center px-4">
|
||||
<Card className="w-full max-w-sm flex flex-col gap-6 !p-8">
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) self-start -mb-2"
|
||||
>
|
||||
<ChevronRight size={18} className="text-(--color-content-secondary)" />
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[20px]">
|
||||
کد تایید رو وارد کن
|
||||
</h1>
|
||||
<p className="text-[13px] text-(--color-content-secondary) mt-1">
|
||||
ارسالشده به <span dir="ltr" className="font-bold">{phone}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OtpInput value={otp} onChange={setOtp} onComplete={handleVerify} disabled={loading} />
|
||||
<ErrorBanner message={error} />
|
||||
<Button
|
||||
size="lg"
|
||||
isBlock
|
||||
isLoading={loading}
|
||||
disabled={otp.length !== 4}
|
||||
onClick={() => handleVerify(otp)}
|
||||
>
|
||||
تایید و ورود
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-1.5 text-[13px]">
|
||||
<span className="text-(--color-content-tertiary)">کد نیومد؟</span>
|
||||
{countdown > 0 ? (
|
||||
<span className="font-bold text-(--color-content-secondary)" dir="ltr">
|
||||
{countdown}s
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={resending}
|
||||
className="font-bold text-(--color-brand-600) hover:text-(--color-brand-700) disabled:opacity-50"
|
||||
>
|
||||
دوباره بفرست
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<VerifyForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
LayoutGrid,
|
||||
ListTree,
|
||||
MessageCircleQuestion,
|
||||
LogOut,
|
||||
Heart,
|
||||
UserCheck,
|
||||
Users,
|
||||
HeartHandshake,
|
||||
Images,
|
||||
Activity,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "داشبورد", icon: LayoutGrid },
|
||||
{ href: "/users", label: "کاربران در انتظار تایید", icon: UserCheck },
|
||||
{ href: "/users/all", label: "همه کاربران", icon: Users },
|
||||
{ href: "/matches", label: "رفقا (مچها)", icon: HeartHandshake },
|
||||
{ href: "/photos", label: "نظارت بر عکسها", icon: Images },
|
||||
{ href: "/categories", label: "دستهبندیها و گزینهها", icon: ListTree },
|
||||
{ href: "/questions", label: "سوالات پروفایل", icon: MessageCircleQuestion },
|
||||
{ href: "/monitoring", label: "مانیتورینگ سرور", icon: Activity },
|
||||
{ href: "/backups", label: "بکآپ دیتابیس", icon: Database },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-64 shrink-0 h-dvh sticky top-0 flex flex-col bg-(--color-surface-default) border-l border-(--color-border-subtle)">
|
||||
<div className="flex items-center gap-2.5 px-5 h-16 border-b border-(--color-border-subtle)">
|
||||
<div className="w-8 h-8 rounded-(--radius-sm) bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-sm)">
|
||||
<Heart size={15} className="text-white" fill="white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-[var(--font-display)] font-extrabold text-[14px] leading-none">
|
||||
دلدل
|
||||
</p>
|
||||
<p className="text-[10px] text-(--color-content-tertiary) leading-none mt-0.5">
|
||||
پنل ادمین
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 flex flex-col gap-1 p-3">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-(--radius-sm) px-3.5 h-11 text-[14px] font-semibold transition-colors",
|
||||
active
|
||||
? "bg-(--color-brand-500) text-white shadow-(--shadow-cute-sm)"
|
||||
: "text-(--color-content-secondary) hover:bg-(--color-bg-secondary)"
|
||||
)}
|
||||
>
|
||||
<Icon size={17} />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-(--color-border-subtle) flex flex-col gap-2">
|
||||
<div className="px-2 py-2">
|
||||
<p className="text-[11px] text-(--color-content-tertiary)">وارد شده بهعنوان</p>
|
||||
<p dir="ltr" className="text-[13px] font-bold text-(--color-content-primary) text-right">
|
||||
{user?.phone}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 rounded-(--radius-sm) px-3.5 h-10 text-[13px] font-semibold text-(--color-status-error) hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
خروج از حساب
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ShieldAlert, ShieldCheck, Flag } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { blockUser, unblockUser, reportUser, UserState } from "@/lib/api";
|
||||
|
||||
// دکمههای بلاک/آنبلاک/گزارش یک کاربر؛ هم توی لیست همهی کاربران و هم توی
|
||||
// صفحهی جزئیات یک کاربر استفاده میشه.
|
||||
export function UserModerationActions({
|
||||
userId,
|
||||
state,
|
||||
onChanged,
|
||||
}: {
|
||||
userId: number;
|
||||
state: UserState;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [blockOpen, setBlockOpen] = useState(false);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [blockReason, setBlockReason] = useState("");
|
||||
const [reportReason, setReportReason] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleBlock() {
|
||||
if (blockReason.trim().length < 3) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await blockUser(userId, blockReason.trim());
|
||||
toast.showSuccess("کاربر بلاک شد");
|
||||
setBlockOpen(false);
|
||||
setBlockReason("");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnblock() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await unblockUser(userId);
|
||||
toast.showSuccess("کاربر آنبلاک شد");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReport() {
|
||||
if (reportReason.trim().length < 3) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await reportUser(userId, reportReason.trim());
|
||||
toast.showSuccess("گزارش ثبت شد");
|
||||
setReportOpen(false);
|
||||
setReportReason("");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{state === "blocked" ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ShieldCheck size={14} />}
|
||||
isLoading={loading}
|
||||
onClick={handleUnblock}
|
||||
>
|
||||
آنبلاک
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ShieldAlert size={14} />}
|
||||
className="!text-(--color-status-error) !border-red-200"
|
||||
onClick={() => setBlockOpen(true)}
|
||||
>
|
||||
بلاک
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" icon={<Flag size={14} />} onClick={() => setReportOpen(true)}>
|
||||
گزارش
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal open={blockOpen} onClose={() => setBlockOpen(false)} title="بلاک کردن کاربر">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-[13px] text-(--color-content-secondary)">
|
||||
کاربر بلاکشده دیگه نمیتونه وارد اپ بشه (حتی با توکن معتبر فعلیش).
|
||||
</p>
|
||||
<textarea
|
||||
value={blockReason}
|
||||
onChange={(e) => setBlockReason(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={() => setBlockOpen(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
disabled={blockReason.trim().length < 3}
|
||||
className="!bg-(--color-status-error) !shadow-none hover:!bg-red-700"
|
||||
onClick={handleBlock}
|
||||
>
|
||||
بلاک کن
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={reportOpen} onClose={() => setReportOpen(false)} 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={() => setReportOpen(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
disabled={reportReason.trim().length < 3}
|
||||
onClick={handleReport}
|
||||
>
|
||||
ثبت گزارش
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AvatarProps {
|
||||
name: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
color?: "brand" | "mint" | "yellow" | "sky" | "pink";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-8 h-8 text-[12px]",
|
||||
md: "w-11 h-11 text-[15px]",
|
||||
lg: "w-16 h-16 text-[22px]",
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
brand: "bg-(--color-brand-500) text-white",
|
||||
mint: "bg-(--color-pop-mint) text-emerald-900",
|
||||
yellow: "bg-(--color-pop-yellow) text-amber-900",
|
||||
sky: "bg-(--color-pop-sky) text-sky-950",
|
||||
pink: "bg-(--color-pop-pink) text-pink-950",
|
||||
};
|
||||
|
||||
function initials(name: string) {
|
||||
const parts = name.trim().split(" ").filter(Boolean);
|
||||
if (parts.length === 0) return "؟";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2);
|
||||
return parts[0][0] + parts[1][0];
|
||||
}
|
||||
|
||||
export function Avatar({ name, size = "md", color = "brand", className }: AvatarProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full flex items-center justify-center font-bold shrink-0 shadow-(--shadow-cute-sm)",
|
||||
sizeClasses[size],
|
||||
colorClasses[color],
|
||||
className
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
{initials(name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { HTMLAttributes, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { X, Check } from "lucide-react";
|
||||
|
||||
type BadgeTone = "brand" | "success" | "warning" | "error" | "info" | "neutral";
|
||||
|
||||
const toneClasses: Record<BadgeTone, string> = {
|
||||
brand: "bg-(--color-brand-100) text-(--color-brand-700)",
|
||||
success: "bg-emerald-100 text-emerald-700",
|
||||
warning: "bg-amber-100 text-amber-700",
|
||||
error: "bg-red-100 text-red-700",
|
||||
info: "bg-sky-100 text-sky-700",
|
||||
neutral: "bg-(--color-bg-tertiary) text-(--color-content-secondary)",
|
||||
};
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
tone?: BadgeTone;
|
||||
}
|
||||
|
||||
export function Badge({ className, tone = "brand", children, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-(--radius-full) px-3 py-1 text-[12px] font-bold",
|
||||
toneClasses[tone],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChipProps {
|
||||
label: string;
|
||||
selected?: boolean;
|
||||
onToggle?: (next: boolean) => void;
|
||||
}
|
||||
|
||||
export function Chip({ label, selected: controlledSelected, onToggle }: ChipProps) {
|
||||
const [internal, setInternal] = useState(false);
|
||||
const selected = controlledSelected ?? internal;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setInternal((s) => !s);
|
||||
onToggle?.(!selected);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-8 rounded-(--radius-full) px-4 text-[13px] font-semibold transition-all duration-150 cursor-pointer border-2",
|
||||
selected
|
||||
? "bg-(--color-brand-500) border-(--color-brand-500) text-white shadow-(--shadow-cute-sm)"
|
||||
: "bg-(--color-surface-default) border-(--color-border-default) text-(--color-content-secondary) hover:border-(--color-brand-300) hover:text-(--color-brand-600)"
|
||||
)}
|
||||
>
|
||||
{selected && <Check size={14} />}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface TagProps {
|
||||
label: string;
|
||||
onRemove?: () => void;
|
||||
color?: "brand" | "mint" | "yellow" | "sky" | "pink";
|
||||
}
|
||||
|
||||
const tagColorClasses: Record<NonNullable<TagProps["color"]>, string> = {
|
||||
brand: "bg-(--color-brand-50) text-(--color-brand-700)",
|
||||
mint: "bg-emerald-50 text-emerald-700",
|
||||
yellow: "bg-amber-50 text-amber-700",
|
||||
sky: "bg-sky-50 text-sky-700",
|
||||
pink: "bg-pink-50 text-pink-700",
|
||||
};
|
||||
|
||||
export function Tag({ label, onRemove, color = "brand" }: TagProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-(--radius-xs) px-2.5 py-1 text-[12px] font-semibold",
|
||||
tagColorClasses[color]
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="rounded-full hover:bg-black/10 p-0.5 cursor-pointer"
|
||||
aria-label={`حذف ${label}`}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "tertiary" | "ghost";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
isLoading?: boolean;
|
||||
isBlock?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: "h-9 px-4 text-[13px] gap-1.5",
|
||||
md: "h-11 px-5 text-[15px] gap-2",
|
||||
lg: "h-13 px-6 text-[16px] gap-2.5",
|
||||
};
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-(--color-brand-500) text-(--color-brand-onPrimary) shadow-(--shadow-cute-md) hover:bg-(--color-brand-600) hover:-translate-y-0.5 hover:shadow-(--shadow-cute-lg) active:translate-y-0 active:bg-(--color-brand-700)",
|
||||
secondary:
|
||||
"bg-(--color-brand-100) text-(--color-brand-700) hover:bg-(--color-brand-200) active:bg-(--color-brand-300)",
|
||||
tertiary:
|
||||
"bg-(--color-surface-default) text-(--color-content-primary) border-2 border-(--color-border-default) hover:border-(--color-brand-300) hover:text-(--color-brand-600) active:bg-(--color-bg-secondary)",
|
||||
ghost:
|
||||
"bg-transparent text-(--color-content-secondary) hover:bg-(--color-bg-secondary) hover:text-(--color-brand-600)",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
isLoading = false,
|
||||
isBlock = false,
|
||||
icon,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-(--radius-md) font-[var(--font-sans)] font-semibold",
|
||||
"transition-all duration-150 ease-out select-none cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-(--color-brand-200)",
|
||||
"disabled:opacity-45 disabled:pointer-events-none disabled:translate-y-0 disabled:shadow-none",
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
isBlock && "w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,27 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
hoverable?: boolean;
|
||||
padded?: boolean;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
hoverable = false,
|
||||
padded = true,
|
||||
...props
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-(--radius-lg) bg-(--color-surface-default) shadow-(--shadow-cute-sm) border border-(--color-border-subtle)",
|
||||
padded && "p-5",
|
||||
hoverable &&
|
||||
"transition-all duration-200 hover:-translate-y-1 hover:shadow-(--shadow-cute-md) hover:border-(--color-brand-200)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "./Modal";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "حذف کن",
|
||||
loading,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} title={title}>
|
||||
<p className="text-[14px] text-(--color-content-secondary) leading-6 mb-6">
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
className="!bg-(--color-status-error) !shadow-none hover:!bg-red-700"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
interface SwitchProps {
|
||||
label?: string;
|
||||
defaultChecked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function Switch({ label, defaultChecked = false, onChange }: SwitchProps) {
|
||||
const [checked, setChecked] = useState(defaultChecked);
|
||||
return (
|
||||
<label className="inline-flex items-center gap-3 cursor-pointer select-none">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => {
|
||||
setChecked((c) => !c);
|
||||
onChange?.(!checked);
|
||||
}}
|
||||
className={cn(
|
||||
"relative h-7 w-12 rounded-full transition-colors duration-200 shrink-0",
|
||||
checked ? "bg-(--color-brand-500)" : "bg-(--color-bg-tertiary)"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1 left-1 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200",
|
||||
checked ? "translate-x-5" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{label && <span className="text-[14px] text-(--color-content-primary)">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckboxProps {
|
||||
label?: string;
|
||||
defaultChecked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function Checkbox({ label, defaultChecked = false, onChange }: CheckboxProps) {
|
||||
const [checked, setChecked] = useState(defaultChecked);
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
onClick={() => {
|
||||
setChecked((c) => !c);
|
||||
onChange?.(!checked);
|
||||
}}
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-(--radius-xs) flex items-center justify-center transition-all duration-150 shrink-0 border-2",
|
||||
checked
|
||||
? "bg-(--color-brand-500) border-(--color-brand-500)"
|
||||
: "bg-(--color-surface-default) border-(--color-border-default)"
|
||||
)}
|
||||
>
|
||||
{checked && <Check size={15} className="text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
{label && <span className="text-[14px] text-(--color-content-primary)">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface RadioGroupProps {
|
||||
options: { value: string; label: string }[];
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function RadioGroup({ options, defaultValue, onChange }: RadioGroupProps) {
|
||||
const [value, setValue] = useState(defaultValue ?? options[0]?.value);
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{options.map((opt) => {
|
||||
const selected = value === opt.value;
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="inline-flex items-center gap-2.5 cursor-pointer select-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => {
|
||||
setValue(opt.value);
|
||||
onChange?.(opt.value);
|
||||
}}
|
||||
className={cn(
|
||||
"h-5 w-5 rounded-full flex items-center justify-center border-2 transition-colors shrink-0",
|
||||
selected ? "border-(--color-brand-500)" : "border-(--color-border-default)"
|
||||
)}
|
||||
>
|
||||
{selected && <span className="h-2.5 w-2.5 rounded-full bg-(--color-brand-500)" />}
|
||||
</button>
|
||||
<span className="text-[14px] text-(--color-content-primary)">{opt.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center gap-4 py-12 px-6">
|
||||
<div className="w-20 h-20 rounded-(--radius-xl) bg-(--color-brand-50) flex items-center justify-center text-(--color-brand-500) [animation:wiggle_3s_ease-in-out_infinite]">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 max-w-xs">
|
||||
<h3 className="font-[var(--font-display)] font-bold text-[18px] text-(--color-content-primary)">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-[14px] text-(--color-content-secondary) leading-6">{description}</p>
|
||||
</div>
|
||||
{actionLabel && (
|
||||
<Button size="sm" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export function ErrorBanner({ message }: { message?: string | null }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-(--radius-sm) bg-red-50 border-2 border-red-100 text-(--color-status-error) px-4 py-3 text-[13px] font-medium">
|
||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function FullScreenLoader({ label = "یه لحظه..." }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 py-20">
|
||||
<Loader2 size={28} className="animate-spin text-(--color-brand-500)" />
|
||||
<p className="text-[13px] text-(--color-content-tertiary)">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-(--radius-xl) bg-(--color-surface-default) shadow-(--shadow-cute-lg) [animation:bounce-in_0.25s_ease-out] max-h-[85vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 pt-5 pb-3 sticky top-0 bg-(--color-surface-default)">
|
||||
<h3 className="font-[var(--font-display)] font-extrabold text-[18px]">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 pb-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, KeyboardEvent, ClipboardEvent } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OtpInputProps {
|
||||
length?: number;
|
||||
onComplete?: (code: string) => void;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function OtpInput({
|
||||
length = 4,
|
||||
value,
|
||||
onChange,
|
||||
onComplete,
|
||||
disabled,
|
||||
}: OtpInputProps) {
|
||||
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
|
||||
const digits = value.split("").concat(Array(length).fill("")).slice(0, length);
|
||||
|
||||
function setDigit(index: number, digit: string) {
|
||||
const next = [...digits];
|
||||
next[index] = digit;
|
||||
const joined = next.join("").slice(0, length);
|
||||
onChange(joined);
|
||||
if (digit && index < length - 1) {
|
||||
inputsRef.current[index + 1]?.focus();
|
||||
setFocusIndex(index + 1);
|
||||
}
|
||||
if (joined.length === length && !joined.includes("")) {
|
||||
onComplete?.(joined);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>, index: number) {
|
||||
if (e.key === "Backspace" && !digits[index] && index > 0) {
|
||||
inputsRef.current[index - 1]?.focus();
|
||||
setFocusIndex(index - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent<HTMLInputElement>) {
|
||||
const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
|
||||
if (pasted) {
|
||||
e.preventDefault();
|
||||
onChange(pasted);
|
||||
if (pasted.length === length) onComplete?.(pasted);
|
||||
inputsRef.current[Math.min(pasted.length, length - 1)]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3" dir="ltr">
|
||||
{digits.map((d, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
inputsRef.current[i] = el;
|
||||
}}
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={d}
|
||||
disabled={disabled}
|
||||
onFocus={() => setFocusIndex(i)}
|
||||
onChange={(e) => setDigit(i, e.target.value.replace(/\D/g, "").slice(-1))}
|
||||
onKeyDown={(e) => handleKeyDown(e, i)}
|
||||
onPaste={handlePaste}
|
||||
className={cn(
|
||||
"w-14 h-16 text-center text-[24px] font-bold rounded-(--radius-md) border-2 transition-all",
|
||||
"bg-(--color-surface-default) text-(--color-content-primary)",
|
||||
"focus:outline-none focus:ring-4 focus:ring-(--color-brand-100)",
|
||||
focusIndex === i || d
|
||||
? "border-(--color-brand-500)"
|
||||
: "border-(--color-border-default)"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { InputHTMLAttributes, forwardRef, useId } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
helperText?: string;
|
||||
errorText?: string;
|
||||
leadingIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
({ className, label, helperText, errorText, leadingIcon, id, ...props }, ref) => {
|
||||
const autoId = useId();
|
||||
const inputId = id ?? autoId;
|
||||
const hasError = Boolean(errorText);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="text-[13px] font-semibold text-(--color-content-secondary) px-1"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{leadingIcon && (
|
||||
<span className="absolute inset-y-0 right-4 flex items-center text-(--color-content-tertiary)">
|
||||
{leadingIcon}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={cn(
|
||||
"h-12 w-full rounded-(--radius-sm) bg-(--color-surface-default)",
|
||||
"border-2 border-(--color-border-default) px-4 text-[15px] text-(--color-content-primary)",
|
||||
"placeholder:text-(--color-content-tertiary) transition-colors duration-150",
|
||||
"focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100)",
|
||||
hasError &&
|
||||
"border-(--color-status-error) focus:border-(--color-status-error) focus:ring-red-100",
|
||||
leadingIcon && "pr-11",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{errorText ? (
|
||||
<p className="text-[12px] text-(--color-status-error) px-1">{errorText}</p>
|
||||
) : helperText ? (
|
||||
<p className="text-[12px] text-(--color-content-tertiary) px-1">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
TextField.displayName = "TextField";
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
tone: "success" | "error";
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showSuccess: (message: string) => void;
|
||||
showError: (message: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const push = useCallback((message: string, tone: Toast["tone"]) => {
|
||||
const id = ++idCounter;
|
||||
setToasts((prev) => [...prev, { id, message, tone }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3500);
|
||||
}, []);
|
||||
|
||||
const showSuccess = useCallback((m: string) => push(m, "success"), [push]);
|
||||
const showError = useCallback((m: string) => push(m, "error"), [push]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showSuccess, showError }}>
|
||||
{children}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 items-center">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`flex items-center gap-2 rounded-(--radius-md) px-4 py-3 shadow-(--shadow-cute-lg) text-[13px] font-semibold text-white [animation:bounce-in_0.3s_ease-out] ${
|
||||
t.tone === "success" ? "bg-(--color-status-success)" : "bg-(--color-status-error)"
|
||||
}`}
|
||||
>
|
||||
{t.tone === "success" ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast باید داخل ToastProvider استفاده بشه");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { tokenStore } from "./token-store";
|
||||
|
||||
export const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1";
|
||||
|
||||
// عکسها از مسیر نسبی (/uploads/...) برمیگردن، نه URL کامل؛ باید به origin
|
||||
// سرور (بدون /api/v1) وصل بشن.
|
||||
export const API_ORIGIN = API_BASE.replace(/\/api\/v1\/?$/, "");
|
||||
export function photoUrl(path: string) {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${API_ORIGIN}${path}`;
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
errors?: unknown;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
errors?: unknown;
|
||||
status: number;
|
||||
constructor(message: string, status: number, errors?: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- انواع ----------------
|
||||
|
||||
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
export type UserState = "inactive" | "active" | "blocked";
|
||||
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
phone: string;
|
||||
first_name?: string;
|
||||
gender?: "male" | "female";
|
||||
birth_date?: string;
|
||||
height_cm?: number;
|
||||
weight_kg?: number;
|
||||
current_step: number;
|
||||
is_onboarded: boolean;
|
||||
approval_status: ApprovalStatus;
|
||||
rejection_reason?: string;
|
||||
is_admin: boolean;
|
||||
state: UserState;
|
||||
blocked_reason?: string;
|
||||
blocked_at?: string;
|
||||
}
|
||||
|
||||
export interface StepView {
|
||||
step: number;
|
||||
key: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingStatus {
|
||||
current_step: number;
|
||||
current_step_key: string;
|
||||
is_onboarded: boolean;
|
||||
approval_status: ApprovalStatus;
|
||||
rejection_reason?: string;
|
||||
steps: StepView[];
|
||||
}
|
||||
|
||||
export interface SelectionSummary {
|
||||
category_key: string;
|
||||
option_ids: number[];
|
||||
titles: string[];
|
||||
}
|
||||
|
||||
export interface ProfileAnswer {
|
||||
id: number;
|
||||
user_id: number;
|
||||
question_id: number;
|
||||
answer: string;
|
||||
question?: ProfileQuestion;
|
||||
}
|
||||
|
||||
export interface UserPhoto {
|
||||
id: number;
|
||||
user_id: number;
|
||||
url: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface FullProfile {
|
||||
user: UserProfile;
|
||||
onboarding_status: OnboardingStatus;
|
||||
selections: SelectionSummary[];
|
||||
profile_answers: ProfileAnswer[];
|
||||
photos: UserPhoto[];
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface AuthResponseData {
|
||||
tokens: TokenPair;
|
||||
user: UserProfile;
|
||||
is_new_user: boolean;
|
||||
onboarding_done: boolean;
|
||||
}
|
||||
|
||||
export type CategoryKey =
|
||||
| "education_level"
|
||||
| "field_of_study"
|
||||
| "occupation"
|
||||
| "important_in_relationship"
|
||||
| "interest"
|
||||
| "looking_for";
|
||||
|
||||
export const ALL_CATEGORIES: { key: CategoryKey; fallbackTitle: string; emoji: string }[] = [
|
||||
{ key: "education_level", fallbackTitle: "سطح تحصیلات", emoji: "🎓" },
|
||||
{ key: "field_of_study", fallbackTitle: "رشته تحصیلی", emoji: "📚" },
|
||||
{ key: "occupation", fallbackTitle: "فعالیتها", emoji: "💼" },
|
||||
{ key: "important_in_relationship", fallbackTitle: "اهمیت در رابطه", emoji: "❤️" },
|
||||
{ key: "interest", fallbackTitle: "علاقهمندیها", emoji: "✨" },
|
||||
{ key: "looking_for", fallbackTitle: "دنبال چی میگردی", emoji: "🔍" },
|
||||
];
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
key: CategoryKey;
|
||||
title: string;
|
||||
min_select: number;
|
||||
max_select: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Option {
|
||||
id: number;
|
||||
category_id: number;
|
||||
title: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProfileQuestion {
|
||||
id: number;
|
||||
text: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ---------------- هستهی fetch ----------------
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function rawRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
auth = false
|
||||
): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (auth) {
|
||||
const token = tokenStore.getAccess();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
let body: ApiEnvelope<T> | null = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
// بدنهی خالی
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(body?.message ?? "خطای غیرمنتظره از سرور", res.status, body?.errors);
|
||||
}
|
||||
return (body?.data as T) ?? (undefined as T);
|
||||
}
|
||||
|
||||
async function authRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
try {
|
||||
return await rawRequest<T>(path, options, true);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
const refreshed = await ensureRefreshed();
|
||||
if (refreshed) return await rawRequest<T>(path, options, true);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRefreshed(): Promise<boolean> {
|
||||
const refresh = tokenStore.getRefresh();
|
||||
if (!refresh) return false;
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = rawRequest<TokenPair>("/auth/token/refresh", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ refresh_token: refresh }),
|
||||
})
|
||||
.then((tokens) => {
|
||||
tokenStore.set(tokens.access_token, tokens.refresh_token);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
tokenStore.clear();
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
// ---------------- احراز هویت ----------------
|
||||
|
||||
export function requestOtp(phone: string) {
|
||||
return rawRequest<null>("/auth/request-otp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyOtp(phone: string, otp: string) {
|
||||
return rawRequest<AuthResponseData>("/auth/verify-otp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ phone, otp }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getProfileMe() {
|
||||
return authRequest<{ user: UserProfile }>("/profile/me");
|
||||
}
|
||||
|
||||
// ---------------- ادمین: دستهها و گزینهها ----------------
|
||||
|
||||
export function getCategory(key: CategoryKey) {
|
||||
return authRequest<Category>(`/admin/categories/${key}`);
|
||||
}
|
||||
|
||||
export function updateCategory(
|
||||
key: CategoryKey,
|
||||
payload: { title?: string; min_select?: number; max_select?: number }
|
||||
) {
|
||||
return authRequest<Category>(`/admin/categories/${key}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function listOptions(key: CategoryKey) {
|
||||
return authRequest<Option[]>(`/admin/categories/${key}/options`);
|
||||
}
|
||||
|
||||
export function createOption(key: CategoryKey, payload: { title: string; sort_order?: number }) {
|
||||
return authRequest<Option>(`/admin/categories/${key}/options`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOption(
|
||||
id: number,
|
||||
payload: { title?: string; sort_order?: number; is_active?: boolean }
|
||||
) {
|
||||
return authRequest<Option>(`/admin/options/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteOption(id: number) {
|
||||
return authRequest<null>(`/admin/options/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ---------------- ادمین: سوالات پروفایل ----------------
|
||||
|
||||
export function listQuestions() {
|
||||
return authRequest<ProfileQuestion[]>("/admin/profile-questions");
|
||||
}
|
||||
|
||||
export function createQuestion(payload: { text: string; sort_order?: number }) {
|
||||
return authRequest<ProfileQuestion>("/admin/profile-questions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateQuestion(
|
||||
id: number,
|
||||
payload: { text?: string; sort_order?: number; is_active?: boolean }
|
||||
) {
|
||||
return authRequest<ProfileQuestion>(`/admin/profile-questions/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteQuestion(id: number) {
|
||||
return authRequest<null>(`/admin/profile-questions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ---------------- ادمین: بررسی و تایید کاربران ----------------
|
||||
|
||||
export interface PendingUserView extends UserProfile {
|
||||
photos: string[];
|
||||
}
|
||||
|
||||
export function listPendingUsers() {
|
||||
return authRequest<PendingUserView[]>("/admin/users/pending");
|
||||
}
|
||||
|
||||
export function getUserDetail(id: number) {
|
||||
return authRequest<FullProfile>(`/admin/users/${id}`);
|
||||
}
|
||||
|
||||
export function approveUser(id: number) {
|
||||
return authRequest<UserProfile>(`/admin/users/${id}/approve`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function rejectUser(id: number, reason: string) {
|
||||
return authRequest<UserProfile>(`/admin/users/${id}/reject`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- ادمین: مانیتورینگ ----------------
|
||||
|
||||
export interface MonitoringSummary {
|
||||
total_requests: number;
|
||||
total_errors: number;
|
||||
error_rate_pct: number;
|
||||
avg_latency_ms: number;
|
||||
p95_latency_ms: number;
|
||||
requests_last_min: number;
|
||||
started_at: string;
|
||||
uptime_seconds: number;
|
||||
}
|
||||
|
||||
export interface RequestLogEntry {
|
||||
time: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status_code: number;
|
||||
duration_ms: number;
|
||||
client_ip: string;
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
export function getMonitoringSummary() {
|
||||
return authRequest<MonitoringSummary>("/admin/monitoring/summary");
|
||||
}
|
||||
|
||||
export function getRecentRequests(limit = 100, userId?: number) {
|
||||
const userParam = userId ? `&user_id=${userId}` : "";
|
||||
return authRequest<RequestLogEntry[]>(`/admin/monitoring/requests?limit=${limit}${userParam}`);
|
||||
}
|
||||
|
||||
// ---------------- ادمین: همهی کاربران، بلاک/آنبلاک، گزارش ----------------
|
||||
|
||||
export interface AdminUserListItem extends UserProfile {
|
||||
report_count: number;
|
||||
}
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export function listAllUsers(search: string, page = 1, pageSize = 25) {
|
||||
const searchParam = search ? `&search=${encodeURIComponent(search)}` : "";
|
||||
return authRequest<PaginatedResult<AdminUserListItem>>(
|
||||
`/admin/users?page=${page}&page_size=${pageSize}${searchParam}`
|
||||
);
|
||||
}
|
||||
|
||||
export function blockUser(id: number, reason: string) {
|
||||
return authRequest<UserProfile>(`/admin/users/${id}/block`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export function unblockUser(id: number) {
|
||||
return authRequest<UserProfile>(`/admin/users/${id}/unblock`, { method: "POST" });
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
id: number;
|
||||
user_id: number;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function reportUser(id: number, reason: string) {
|
||||
return authRequest<Report>(`/admin/users/${id}/report`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listUserReports(id: number) {
|
||||
return authRequest<Report[]>(`/admin/users/${id}/reports`);
|
||||
}
|
||||
|
||||
// ---------------- ادمین: مچها (رفقا) ----------------
|
||||
|
||||
export interface AdminMatchView {
|
||||
id: number;
|
||||
created_at: string;
|
||||
user_one_id: number;
|
||||
user_one_name: string;
|
||||
user_one_phone: string;
|
||||
user_two_id: number;
|
||||
user_two_name: string;
|
||||
user_two_phone: string;
|
||||
}
|
||||
|
||||
export function listAllMatches(page = 1, pageSize = 25) {
|
||||
return authRequest<PaginatedResult<AdminMatchView>>(
|
||||
`/admin/matches?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- ادمین: نظارت بر عکسها ----------------
|
||||
|
||||
export interface AdminPhotoView {
|
||||
id: number;
|
||||
user_id: number;
|
||||
url: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
owner_name: string;
|
||||
owner_phone: string;
|
||||
}
|
||||
|
||||
export function listAllPhotos(page = 1, pageSize = 50) {
|
||||
return authRequest<PaginatedResult<AdminPhotoView>>(
|
||||
`/admin/photos?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePhotoAdmin(id: number) {
|
||||
return authRequest<null>(`/admin/photos/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function reportPhoto(id: number, reason: string) {
|
||||
return authRequest<Report>(`/admin/photos/${id}/report`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- ادمین: بکآپ دیتابیس ----------------
|
||||
|
||||
export interface BackupInfo {
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function listBackups() {
|
||||
return authRequest<BackupInfo[]>("/admin/backups");
|
||||
}
|
||||
|
||||
export function createBackup() {
|
||||
return authRequest<BackupInfo>("/admin/backups", { method: "POST" });
|
||||
}
|
||||
|
||||
// دانلود بکآپ نیاز به هدر Authorization داره (اندپوینت فقط-ادمینه)، پس نمیشه
|
||||
// مستقیم از یک <a href> استفاده کرد؛ فایل رو با fetch احرازهویتشده میگیریم و
|
||||
// خودمون دانلودش رو با یک لینک موقت شبیهسازی میکنیم.
|
||||
export async function downloadBackup(filename: string) {
|
||||
const token = tokenStore.getAccess();
|
||||
const res = await fetch(`${API_BASE}/admin/backups/${encodeURIComponent(filename)}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new ApiError("دانلود بکآپ ناموفق بود", res.status);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { tokenStore } from "./token-store";
|
||||
import { UserProfile, verifyOtp as apiVerifyOtp, getProfileMe, ApiError } from "./api";
|
||||
|
||||
type AuthStatus = "loading" | "guest" | "not-admin" | "authenticated";
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: UserProfile | null;
|
||||
login: (phone: string, otp: string) => Promise<UserProfile>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
const access = tokenStore.getAccess();
|
||||
if (!access) {
|
||||
setStatus("guest");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const profile = await getProfileMe();
|
||||
if (!profile.user.is_admin) {
|
||||
setUser(profile.user);
|
||||
setStatus("not-admin");
|
||||
return;
|
||||
}
|
||||
setUser(profile.user);
|
||||
setStatus("authenticated");
|
||||
} catch {
|
||||
tokenStore.clear();
|
||||
setStatus("guest");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
const login = useCallback(async (phone: string, otp: string) => {
|
||||
const data = await apiVerifyOtp(phone, otp);
|
||||
tokenStore.set(data.tokens.access_token, data.tokens.refresh_token);
|
||||
if (!data.user.is_admin) {
|
||||
setUser(data.user);
|
||||
setStatus("not-admin");
|
||||
// توکن رو نگه میداریم نه، پاک میکنیم چون این پنل فقط برای ادمینهاست
|
||||
tokenStore.clear();
|
||||
throw new ApiError("این حساب دسترسی ادمین نداره", 403);
|
||||
}
|
||||
setUser(data.user);
|
||||
setStatus("authenticated");
|
||||
return data.user;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
tokenStore.clear();
|
||||
setUser(null);
|
||||
setStatus("guest");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ status, user, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth باید داخل AuthProvider استفاده بشه");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "./auth-context";
|
||||
import { ApiError } from "./api";
|
||||
|
||||
export function useRequireAdmin() {
|
||||
const router = useRouter();
|
||||
const { status, user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "guest") {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
return { status, user };
|
||||
}
|
||||
|
||||
export function formatApiError(err: unknown, fallback = "یه مشکلی پیش اومد؛ دوباره امتحان کن"): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (typeof err.errors === "string" && err.errors && err.errors !== err.message) {
|
||||
return `${err.message} — ${err.errors}`;
|
||||
}
|
||||
return err.message || fallback;
|
||||
}
|
||||
if (err instanceof Error) return err.message;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
const ACCESS_KEY = "dating_app_access_token";
|
||||
const REFRESH_KEY = "dating_app_refresh_token";
|
||||
|
||||
function isBrowser() {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
export const tokenStore = {
|
||||
getAccess(): string | null {
|
||||
if (!isBrowser()) return null;
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
},
|
||||
getRefresh(): string | null {
|
||||
if (!isBrowser()) return null;
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
},
|
||||
set(access: string, refresh: string) {
|
||||
if (!isBrowser()) return;
|
||||
localStorage.setItem(ACCESS_KEY, access);
|
||||
localStorage.setItem(REFRESH_KEY, refresh);
|
||||
},
|
||||
clear() {
|
||||
if (!isBrowser()) return;
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
export function calcAge(birthDate?: string): number | null {
|
||||
if (!birthDate) return null;
|
||||
const b = new Date(birthDate);
|
||||
if (Number.isNaN(b.getTime())) return null;
|
||||
const now = new Date();
|
||||
let age = now.getFullYear() - b.getFullYear();
|
||||
const m = now.getMonth() - b.getMonth();
|
||||
if (m < 0 || (m === 0 && now.getDate() < b.getDate())) age--;
|
||||
return age;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user