Initial commit: dating app admin panel (Next.js)

Admin login, category/option and profile-question management, and
the pending-user review workflow (list, full profile detail,
approve/reject with a reason shown back to the user).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 23:26:24 +03:30
commit 36faf4706a
41 changed files with 10035 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dating-admin",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3001
}
]
}
+2
View File
@@ -0,0 +1,2 @@
# آدرس بک‌اند Go (بدون اسلش آخر)
NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1
+45
View File
@@ -0,0 +1,45 @@
# 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
+70
View File
@@ -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 در پروداکشن قرار بدی.
+18
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+6822
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -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>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { ALL_CATEGORIES } from "@/lib/api";
export default function CategoriesIndexPage() {
return (
<div className="flex flex-col gap-6 max-w-5xl">
<div>
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
دسته‌بندی‌ها و گزینه‌ها 🗂️
</h1>
<p className="text-[14px] text-(--color-content-secondary) mt-1">
هر دسته یه لیست انتخابیه که توی مراحل آنبوردینگ به کاربر نشون داده می‌شه.
روی هرکدوم بزن تا گزینه‌هاش رو مدیریت کنی.
</p>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{ALL_CATEGORIES.map((c) => (
<Link key={c.key} href={`/categories/${c.key}`}>
<Card hoverable className="flex items-center justify-between h-full">
<div>
<p className="text-[24px] mb-1">{c.emoji}</p>
<p className="font-bold text-[15px] text-(--color-content-primary)">
{c.fallbackTitle}
</p>
<p className="text-[11px] font-mono text-(--color-content-tertiary) mt-0.5">
{c.key}
</p>
</div>
<ArrowLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
</Card>
</Link>
))}
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { ReactNode, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ShieldAlert } from "lucide-react";
import { useAuth } from "@/lib/auth-context";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { Sidebar } from "@/components/Sidebar";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
export default function DashboardLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const { status, logout } = useAuth();
useEffect(() => {
if (status === "guest") router.replace("/login");
}, [status, router]);
if (status === "loading" || status === "guest") return <FullScreenLoader />;
if (status === "not-admin") {
return (
<div className="min-h-dvh flex items-center justify-center px-4">
<Card className="w-full max-w-sm flex flex-col items-center gap-4 !p-8 text-center">
<div className="w-14 h-14 rounded-(--radius-lg) bg-red-50 flex items-center justify-center">
<ShieldAlert size={26} className="text-(--color-status-error)" />
</div>
<h2 className="font-[var(--font-display)] font-extrabold text-[18px]">
دسترسی نداری
</h2>
<p className="text-[13px] text-(--color-content-secondary) leading-6">
حساب واردشده دسترسی ادمین نداره.
</p>
<Button
variant="tertiary"
onClick={() => {
logout();
router.push("/login");
}}
>
خروج و ورود با حساب دیگه
</Button>
</Card>
</div>
);
}
return (
<div className="flex min-h-dvh">
<Sidebar />
<main className="flex-1 min-w-0 p-8">{children}</main>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { 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>
);
}
+253
View File
@@ -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>
);
}
+316
View File
@@ -0,0 +1,316 @@
"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,
} 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 { formatApiError } from "@/lib/hooks";
import { calcAge } from "@/lib/utils";
import {
FullProfile,
ApprovalStatus,
getUserDetail,
approveUser,
rejectUser,
} 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 [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));
}
}, [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>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<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}`} />
)}
<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>
)}
<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>
);
}
function RejectModal({
open,
onClose,
onConfirm,
loading,
}: {
open: boolean;
onClose: () => void;
onConfirm: (reason: string) => void;
loading: boolean;
}) {
const [reason, setReason] = useState("");
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (open) setReason("");
}, [open]);
const valid = reason.trim().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>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={4}
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"
autoFocus
/>
</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(reason.trim())}
>
رد کردن کاربر
</Button>
</div>
</div>
</Modal>
);
}
+95
View File
@@ -0,0 +1,95 @@
"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 { UserProfile, listPendingUsers } from "@/lib/api";
export default function PendingUsersPage() {
const [users, setUsers] = useState<UserProfile[] | 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)" : ""
}`}
>
<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.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

+188
View File
@@ -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);
}
}
+23
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+153
View File
@@ -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>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { LayoutGrid, ListTree, MessageCircleQuestion, LogOut, Heart, UserCheck } 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: "/categories", label: "دسته‌بندی‌ها و گزینه‌ها", icon: ListTree },
{ href: "/questions", label: "سوالات پروفایل", icon: MessageCircleQuestion },
];
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>
);
}
+45
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+76
View File
@@ -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";
+27
View File
@@ -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}
/>
);
}
+45
View File
@@ -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>
);
}
+112
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+11
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+85
View File
@@ -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>
);
}
+60
View File
@@ -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";
+65
View File
@@ -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;
}
+308
View File
@@ -0,0 +1,308 @@
import { tokenStore } from "./token-store";
export const API_BASE =
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1";
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 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;
}
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 FullProfile {
user: UserProfile;
onboarding_status: OnboardingStatus;
selections: SelectionSummary[];
profile_answers: ProfileAnswer[];
}
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 function listPendingUsers() {
return authRequest<UserProfile[]>("/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 }),
});
}
+87
View File
@@ -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;
}
+30
View File
@@ -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;
}
+27
View File
@@ -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);
},
};
+16
View File
@@ -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;
}
+34
View File
@@ -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"]
}