feat: initial ghabilee-admin backoffice app

Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
This commit is contained in:
alisaza 2026-09-05 13:12:59 +03:30
commit e1eaf5eff5
818 changed files with 114437 additions and 0 deletions

430
.cursorrules Normal file
View File

@ -0,0 +1,430 @@
# Cursor Rules - Ghabilee Admin Panel
## Project Overview
This is a Next.js 16 admin panel project using TypeScript, React 19, and Tailwind CSS.
## General Coding Standards
### TypeScript & React
- Always use TypeScript with strict mode enabled
- Use functional components with hooks (no class components)
- Use `'use client'` directive for client components
- Define proper TypeScript interfaces/types for all props, state, and data structures
- Use type inference where appropriate, but be explicit for public APIs
- Prefer `interface` over `type` for object shapes
- Use React.FC or explicit return types for components when needed
### File Organization
- Use path aliases (`@/`) for imports instead of relative paths
- Follow the existing folder structure:
- `app/` - Next.js app router pages and layouts
- `components/` - Reusable React components (shared across multiple routes)
- `context/` - React Context providers
- `hooks/` - Custom React hooks
- `services/` - API service functions
- `validation/` - Zod validation schemas
- `helpers/` - Utility functions
- `types/` - TypeScript type definitions
- `styles/` - Global styles and SCSS files
### Component Organization (Colocation Pattern)
- **Page-specific components**: Create a `_components/` folder within each route directory
- **Shared/reusable components**: Place in the global `components/` directory
- The underscore prefix (`_`) makes the folder a private segment - it will never become part of the route structure
- Example:
```
app/[locale]/(dashboard)/matches/
├── _components/ # Private: Page-specific match components
│ ├── MatchCard.tsx
│ └── MatchFilters.tsx
└── page.tsx
```
- Benefits: Easy to find related components, clear separation, easier refactoring
### Naming Conventions
- Components: PascalCase (e.g., `AuthContext.tsx`, `Button.tsx`)
- Files: Match component/export name
- Functions: camelCase (e.g., `handleSubmit`, `fetchUser`)
- Constants: UPPER_SNAKE_CASE (e.g., `API_ROUTES`)
- Interfaces/Types: PascalCase (e.g., `User`, `AuthContextType`)
### Code Style
- Use 2 spaces for indentation
- Use single quotes for strings
- No semicolons at the end of statements (enforced by Prettier with `"semi": false`)
- Use trailing commas in multi-line objects/arrays
- Keep lines under 140 characters (enforced by Prettier `printWidth: 140`)
- Use destructuring for props and state
- Prefer const over let, avoid var
### React Patterns
- Use custom hooks for reusable logic (e.g., `useAuth`, `useLoading`)
- Use Context API for global state management
- Prefer composition over inheritance
- Use React.memo() for performance optimization when needed
- Extract complex logic into separate functions or hooks
### Form Handling
- Always use `react-hook-form` with `@hookform/resolvers/zod`
- Define validation schemas in `validation/` directory using Zod
- Use `FormProvider` for form context
- Handle form errors appropriately with user-friendly messages
### Zod Validation with next-intl
- **CRITICAL: Zod schemas must NOT be static when using translations**
- **Always define validation schemas as factory functions** that receive the translation function `t`
- **Build the schema inside the component** (or server action) where `t` is available
- This ensures:
- ✅ Localized error messages
- ✅ Compatibility with locale changes
- ✅ Clean separation between validation logic and React
**Pattern for Validation Files:**
```typescript
import { z } from 'zod'
type TFunc = (key: string) => string
export const FormValidation = (t: TFunc) =>
z.object({
field: z.string().min(1, t('required')),
email: z.string().email(t('invalidEmail')),
})
```
**Client Component Usage:**
```typescript
'use client'
import { useTranslations } from 'next-intl'
import { FormValidation } from '@/validation/form'
const Component = () => {
const tValidation = useTranslations('validation')
const form = useForm({
resolver: zodResolver(FormValidation(tValidation)),
})
// ...
}
```
**Server Action Usage:**
```typescript
import { getTranslations } from 'next-intl/server'
import { FormValidation } from '@/validation/form'
export async function serverAction() {
const t = await getTranslations('validation')
const schema = FormValidation(t)
// schema.parse(...)
}
```
**Key Rules:**
- ❌ **DO NOT** define schemas statically: `export const Schema = z.object({...})`
- ✅ **ALWAYS** use factory functions: `export const Schema = (t: TFunc) => z.object({...})`
- ❌ **DO NOT** use `useTranslations` inside validation files (it's a React hook)
- ✅ **ALWAYS** pass `t` as a parameter to the factory function
- ✅ **CRITICAL: Only ONE validation object in fa.json**
- **ONLY** use `validation` object in `messages/fa.json` for all validation messages
- **NEVER** create nested validation objects like `project.validation`, `activity.validation`, etc.
- **ALWAYS** add new validation keys to the main `validation` object
- **ALWAYS** use `useTranslations('validation')` in context files, never use nested paths like `project.validation`
### API & Data Fetching
- Keep API calls in `services/` directory
- Use `axiosInstance` from `@/config/axios` for API requests
- Always handle errors with try/catch blocks
- Use async/await instead of promises chains
- Show loading states using `useLoading` hook
- Display user-friendly error messages using toast notifications
- **CRITICAL: Do NOT use `/system` endpoints**
- Endpoints that end with `/system` (such as `.../create/system`, `.../update/system`, `.../delete/system`) must not be used in this project.
- Always use the non-system equivalent endpoint (for example, `.../create` instead of `.../create/system`).
- If only a `/system` endpoint exists in docs, leave a `TODO` comment and ask for backend clarification before implementing.
- **CRITICAL: Numeric IDs in backend payloads**
- Every identifier sent to the backend in request bodies or query params that the API expects as a number (`project_id`, `user_id`, `city_id`, foreign keys, etc.) must be a **JavaScript `number`**, not a string.
- URL/route params from Next.js (`useParams`, dynamic `[id]` segments) are **strings**; convert at the **service boundary** (or immediately before the axios call) with `Number(id)`, `parseInt(id, 10)`, or equivalent, and validate with `Number.isFinite` when the contract requires a valid id.
- Prefer typing service payloads with `project_id: number` (and similar) so TypeScript enforces the contract; do not spread raw string params into POST bodies without conversion.
### Services & API Pattern
- **Single source of data**: All API access goes through `services/`. Pages and components must **never** import mock JSON (e.g. `supplierMockData.json`) directly—always call service functions (e.g. `GET_PROJECTS`, `GET_SUPPLIERS`).
- **CRITICAL: Paginated list meta — DRY**
- Backend list endpoints follow `docs/list-endpoints.en.md`.
- Query: `page`, `pageSize`, `sort`, `filters` (see `ListQueryDto` on backend).
- Response inside `data`: `{ items, response: { page, pageSize, totalItemsCount, totalPages, sort, filters } }`.
- **ALWAYS** reuse `ApiListMeta`, `PaginatedListMeta`, and `ApiPaginatedResult<T>` from `@/types/apiListMeta`.
- Parse API responses with `parseRemittanceList()` from `@/helpers` (unwraps `{ success, data }`).
- For empty/fallback meta, use `createEmptyPaginatedListMeta(page, pageSize)`.
- **CRITICAL: New services and backend-backed endpoints — NO mock**
- When adding a **new** service file or wiring a feature to a **real backend endpoint**, implement **only the real API** via `axiosInstance` and `API_ROUTES` in `services/config.ts`.
- **Do NOT** add mock branches (`isMockMode()`, `simulateApiDelay`, `maybeThrowMockError`), **do NOT** create `mocks/<feature>/` folders, and **do NOT** import mock JSON/JS for that feature.
- Follow the pattern in `services/activityOwner.ts`, `services/activityProvider.ts`, `services/projectMember.ts`, `services/projectProvider.ts`: POST/GET to backend, parse `{ success, body }`, `normalizeServiceError`, numeric IDs at the service boundary.
- Legacy services may still contain mock code from earlier development; do not copy that pattern into new work—migrate to API-only when touching them.
- **Error messages**: All service error texts live in **one** top-level `messages` object in `messages/fa.json` (e.g. `mockServerError`, `projectNotFound`, `mediaNotFound`, `ticketNotFound`, `supplierNotFound`, `contractNotFound`, `inquiryNotFound`, `inquiryLoadError`). Services reject with a **messageKey** (e.g. `messages.mockServerError`). In UI, use `getServiceErrorMessage(error, tMessages)` with `useTranslations('messages')`.
- **Hooks**: A custom hook (e.g. `useProjects`) is **optional**. Create one only when multiple pages/components share the same data and loading/error state; otherwise call the service directly in the page or context (e.g. `useEffect` + state).
### Internationalization (i18n)
- Always use `next-intl` for translations
- Use `useTranslations` hook for accessing translations
- Never hardcode text strings - always use translation keys
- Translation keys should be in `messages/{locale}.json` files
- Support locales: `en`, `fa`, `ar`, `ku`
- **IMPORTANT: Development Phase i18n Rule**
- The project is multilingual but currently in active development
- **ONLY add new translation keys to `messages/fa.json`**
- **ONLY read/use translations from `fa.json`**
- Do NOT add translation keys to other locale files (`en.json`, `ar.json`, `ku.json`) during development
- This rule applies until the project reaches a stable state
- When adding new features, only update `fa.json` with new keys
- **CRITICAL: Reuse Existing Translation Keys**
- **ALWAYS check if a translation key already exists** in `messages/fa.json` before adding a new one
- **NEVER duplicate translation keys** - if a key exists elsewhere, reuse it instead of creating a duplicate
- **ONLY add new keys** for truly new content that doesn't exist anywhere else
- When reusing existing keys, use multiple `useTranslations` hooks if needed:
```typescript
// ✅ CORRECT - Reuse existing keys from different namespaces
const tPersonnel = useTranslations('project.personnel.staff.projectUsers')
const tPersonnelColumns = useTranslations('project.personnel.executive.humanResources.table.columns')
// Use existing keys
label: tPersonnelColumns('firstName') // Reused from existing namespace
label: tPersonnel('table.columns.email') // New key, only in projectUsers
```
- **Example**: If `firstName`, `lastName`, `number`, `avatar`, `mobile`, `nationalCode` already exist in `executive.humanResources.table.columns`, do NOT add them again in `projectUsers.table.columns`
- **Only add truly new keys** like `email` and `role` that don't exist elsewhere
- **CRITICAL: Keep translation usage simple by default**
- For service/UI errors, prefer direct usage: `getServiceErrorMessage(error, tMessages)`.
- Do **NOT** introduce `useRef` wrappers like `tMessagesRef` by default.
- Use a ref for translation functions only in rare, justified cases where you must avoid a specific effect/callback re-run and still need the latest translator.
- If a ref is used, add a short comment in Persian explaining why direct `tMessages` is not sufficient in that spot.
### Error Handling
- Always wrap async operations in try/catch blocks
- Use `addToast` from `@heroui/toast` for user notifications
- Show errors to users via `getServiceErrorMessage(error, tMessages)` (see **Services & API Pattern**). Do not commit `console.log`; avoid `console.error` in production code—prefer toast for user-facing errors
- Provide meaningful error messages to users
- Use finally blocks for cleanup (e.g., setLoading(false))
### Delete actions (حذف)
- هر جا دکمه یا عملیات **حذف** داده از سرور را انجام می‌دهد (endpointهای delete یا POST حذف)، **قبل از فراخوانی API** از کاربر تأیید بگیر.
- الگوی ترجیحی: هوک **`useConfirmDelete`** از `@/hooks/useConfirmDelete` که روی `useAlertModal` سوار است و `requestDelete({ message, onConfirmed, confirmData? })` را با `{ dangerAccept: true }` صدا می‌زند؛ متن `message` را از `next-intl` بگیر (کلید موجود در `fa.json` در اولویت).
- برای UI تکراری: **`DeleteConfirmIconButton`** (`@/components/delete/DeleteConfirmIconButton`) برای دکمهٔ آیکن حذف.
- برای **`DropdownMenu` / منوی HeroUI**: فرزند باید مستقیماً **`DropdownItem`** از همان پکیج باشد (کامپوننت میانی دور `DropdownItem` با React Aria خطای `getCollectionNode` می‌دهد). از **`useConfirmDelete`** در همان کامپوننت والد و `onPress={() => requestDelete({ message, onConfirmed })}` روی `DropdownItem` استفاده کن؛ فراخوانی API فقط داخل `onConfirmed`.
- اگر سناریو غیراستاندارد است، مستقیم `useAlertModal` + `showAlert` با همان قانون «تأیید قبل از API» مجاز است.
### Admin overlays (مودال، نه دراور)
- در پنل ادمین (`app/(dashboard)/**`) از HeroUI `Drawer`، `AdminDetailDrawer` یا شیت کناری استفاده نکن.
- بررسی مدرک، پیش‌نمایش، فرم و جزئیات رکورد روی همان صفحهٔ لیست باید با `import Modal from '@/components/modals/Modal'` وسط صفحه باز شود.
- تأییدها همان `useAlertModal` بماند.
### Styling
- Use Tailwind CSS utility classes for styling
- Use SCSS files in `styles/` for complex styles
- Follow mobile-first responsive design approach
- Use HeroUI (NextUI) components when available
- Maintain consistent spacing and design system
- **Spacing utilities:** `px-2 py-2` → `p-2` (same for `m` and `gap-x`/`gap-y` when equal). Keep unequal axes (`px-4 py-3`).
- **Breakpoint padding:** do not add `sm:`/`md:` padding or margin that is the same or one Tailwind step from the base (`p-4 md:p-5` → `p-4`). Keep real jumps (`px-4 sm:px-6`) and layout changes (`flex-col sm:flex-row`).
- **Type size:** `text-xs sm:text-sm` (or `md:text-sm`) → keep `text-xs` only; do not bump caption/meta text at `sm`/`md`.
- **Integer spacing:** do not use `.5` steps (`py-2.5`, `px-1.5`, `gap-0.5`). Round up to the next integer (`py-3`, `px-2`, `gap-1`).
### State Management
- Use React Context for global state (AuthContext, GlobalContext, LoadingContext)
- Use useState for local component state
- Use useEffect for side effects with proper cleanup
- Store user data in localStorage when needed
- Use Cookies for authentication tokens
### Comments & Documentation
- Write comments in Persian (Farsi) for complex logic
- Add JSDoc comments for public functions and components
- Keep comments concise and meaningful
- Explain "why" not "what" in comments
### Performance
- Use dynamic imports for code splitting when appropriate
- Optimize images using Next.js Image component
- Avoid unnecessary re-renders
- Use useMemo and useCallback when needed for expensive operations
### Security
- Never commit sensitive data (API keys, tokens)
- Use environment variables for configuration
- Validate all user inputs using Zod schemas
- Sanitize data before displaying to prevent XSS
### Git & Version Control
- Write clear, descriptive commit messages
- Follow conventional commit format when possible
- Use lint-staged and husky for pre-commit hooks
- Keep commits focused and atomic
## Specific Patterns
### Chart.js Font Configuration
- **CRITICAL: Always access font directly in Chart.js options, not at component level**
- **DO NOT** store font in a variable at component level (e.g., `const persianFont = ...`)
- **ALWAYS** use `window.getComputedStyle(document.body).fontFamily` directly in chart options
- This ensures the font is loaded when Chart.js reads the options, not when the component first renders
- Font may not be loaded when component initializes, causing incorrect font display
**Correct Pattern:**
```typescript
const chartOptions = {
plugins: {
tooltip: {
bodyFont: {
family: window.getComputedStyle(document.body).fontFamily,
size: 12,
},
titleFont: {
family: window.getComputedStyle(document.body).fontFamily,
size: 12,
},
},
},
scales: {
x: {
ticks: {
font: {
family: window.getComputedStyle(document.body).fontFamily,
size: 12,
},
},
},
y: {
ticks: {
font: {
family: window.getComputedStyle(document.body).fontFamily,
size: 12,
},
},
},
},
}
```
**Incorrect Pattern (DO NOT USE):**
```typescript
// ❌ WRONG - Font may not be loaded when component renders
const persianFont = typeof window !== 'undefined'
? window.getComputedStyle(document.body).fontFamily
: 'sans-serif'
const chartOptions = {
plugins: {
tooltip: {
bodyFont: { family: persianFont, size: 12 },
},
},
}
```
**For Custom Plugins:**
When using custom plugins (e.g., `afterDraw`), access font inside the plugin function:
```typescript
const customPlugin = {
id: 'customPlugin',
afterDraw: (chart: ChartJS) => {
const ctx = chart.ctx
const loadedFont = window.getComputedStyle(document.body).fontFamily
ctx.font = `bold 16px ${loadedFont}`
// ... rest of plugin code
},
}
```
### Modal with Select Input
- **When a Modal contains an Input with `generalType="select"`**: Use HeroUI Modal components from `@heroui/react`, NOT the custom Modal from `@/components/modals/Modal`
- **ALWAYS** use: `import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/react'`
- **NEVER** use: `import Modal from '@/components/modals/Modal'` when the modal has a select input
- This ensures proper rendering and interaction of select dropdowns inside modals (the custom Modal wrapper can cause z-index/portal issues with select menus)
### Authentication
- Use `AuthContext` for authentication state
- Store tokens in Cookies and localStorage
- Use `jwt-decode` for token decoding
- Handle token expiration properly
- **CRITICAL: Auth-required actions use the login modal, not `/auth` redirect**
- Whenever a user action needs login (follow, bookmark, reserve, chat, profile-gated actions, etc.), call `requireAuth` from `useAuthGate()` (`@/context/AuthGateContext`).
- Pattern: `requireAuth(() => { /* protected action */ })` — if the user is fully authenticated the action runs immediately; otherwise the AuthGate login modal opens on the current page and runs the pending action after success.
- The route that renders the action **must** be under `SessionProviders` (which includes `AuthGateProvider`). If adding auth-gated UI to a public/SEO layout, wrap that layout with `SessionProviders`.
- **NEVER** send the user to `/auth` or `/auth?redirect=...` for these in-page actions.
- The dedicated `/auth` page is only for explicit auth entry (admin login flow, deep links that intentionally land on auth, etc.) — not for consumer CTA gates.
### Component Structure
```typescript
'use client'
import { ... } from '...'
interface ComponentProps {
// props definition
}
const Component = ({ prop1, prop2 }: ComponentProps) => {
// hooks
// state
// handlers
// effects
return (
// JSX
)
}
export default Component
```
### Service Functions
```typescript
import axiosInstance from '@/config/axios'
export const SERVICE_FUNCTION = async (data: DataType) => {
try {
const res = await axiosInstance.post('/endpoint', data)
return res
} catch (error) {
throw error
}
}
```
## What NOT to Do
- **Don't send string IDs to the backend** when the API expects numbers—normalize ids to `number` in `services/` before the request
- Don't use `any` type - use proper types or `unknown`
- Don't mix async/await with .then() chains
- Don't create components without proper TypeScript types
- Don't hardcode strings - use i18n
- Don't ignore TypeScript errors
- Don't commit console.log or console.error—use toast with getServiceErrorMessage for user-facing errors
- Don't use inline styles when Tailwind classes are available
- Don't create deeply nested components - extract into smaller components
- **Don't define Zod validation schemas statically** - always use factory functions with `next-intl`
- **Don't use `useTranslations` inside validation files** - it's a React hook and cannot be used in non-component files
- **Don't put page-specific components in global `components/`** - use `_components/` folder within the route
- **Don't create component folders without underscore prefix in routes** - use `_components/` not `components/`
- **Don't duplicate translation keys** - always check if a key exists in `fa.json` before adding a new one, reuse existing keys instead
- **Don't use `@/components/modals/Modal`** when the modal contains an Input with `generalType="select"` - use HeroUI Modal from `@heroui/react` instead
- **Don't redirect to `/auth` for in-page auth gates** — use `requireAuth` from `useAuthGate()` so the login modal opens instead
- **Don't import mock JSON** (e.g. `supplierMockData.json`) in pages or components—use service functions from `services/`
- **Don't add mock layers to new services**—when a service is new or connected to the backend, use API-only (no `isMockMode`, no `mocks/`, no mock delay/error simulation)
- **Don't call delete APIs** (or any destructive server delete) without prior user confirmation—use `useAlertModal` / `showAlert` first (see **Delete actions**)
- **Don't use Drawer / `AdminDetailDrawer` in the admin panel** — use `Modal` from `@/components/modals/Modal` (see **Admin overlays**)
- **Don't write `px-* py-*` when both values match** — use `p-*`; don't add breakpoint padding that only changes by one Tailwind step
- **Don't pair `text-xs` with `sm:text-sm` / `md:text-sm`** — use `text-xs` at every breakpoint
- **Don't use half-step spacing** (`p-2.5`, `px-1.5`, `mt-0.5`) — round up to an integer step
- **Don't duplicate paginated list meta types** — use `ApiListMeta` / `ApiPaginatedResult<T>` from `@/types/apiListMeta` and `createEmptyApiListMeta` instead of defining `{ total, page, limit, total_page }` per service
## When Adding New Features
1. Check existing patterns in the codebase first
2. Follow the established folder structure and colocation pattern:
- Page-specific components → `_components/` within route directory
- Shared components → global `components/` directory
3. Add proper TypeScript types
4. Add validation schemas if handling user input (use factory pattern with next-intl)
5. **Check for existing translation keys** in `messages/fa.json` before adding new ones - reuse existing keys when possible
6. Add translations for all user-facing text (only to `fa.json` during development) - **only add truly new keys that don't exist elsewhere**
7. Handle loading and error states; call services (API-only for new/backend features—see **Services & API Pattern**) and `getServiceErrorMessage` for errors
8. For new backend endpoints: add routes in `services/config.ts`, implement the service **without mock**, and ensure **ids in payloads are numbers** (convert route/query strings in the service layer)
9. Test the feature thoroughly

76
.dockerignore Normal file
View File

@ -0,0 +1,76 @@
# Dependencies
node_modules
.pnpm-store
# Build output
.next
out
dist
build
# Git
.git
.gitignore
.gitattributes
# IDE / Editor
.idea
.vscode
*.swp
*.swo
.DS_Store
# Debug / Logs
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
*.log
# Testing / Coverage
coverage
.nyc_output
*.lcov
.jest
__tests__
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
# Env (injected at build/runtime; never bake secrets into the image context)
.env
.env.*
# Docs / Misc
README*
CHANGELOG*
LICENSE
*.md
.cursorrules
CLAUDE.md
*.copy.md
*_REVIEW.md
*_REPORT.md
*_GUIDE*.md
*_IMPLEMENTATION.md
*_STRUCTURE*.md
structure.txt
ROUTING_DEBUG.md
# Husky / Lint tooling (not needed in image)
.husky
.eslintrc*
.prettierrc
.prettierignore
eslint.config.mjs
generateTree.js
# Docker
Dockerfile*
.dockerignore
docker-compose*
.docker
# Misc (hero.ts برای @plugin در globals.css لازم است؛ در image کپی می‌شود)
proxy.ts

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
NEXT_PUBLIC_SITE_URL=https://backoffice.ghabilee.ir
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_FILE_SERVER_URL=
API_PROXY_TARGET=

37
.github/BRANCH_PROTECTION.md vendored Normal file
View File

@ -0,0 +1,37 @@
# Branch protection on `main` (GitHub Pro required)
Private repositories on GitHub Free cannot enable branch protection via API or
Settings. Upgrade to **GitHub Pro** (or make the repo public), then configure:
**Settings → Branches → Add branch protection rule → Branch name: `main`**
Recommended settings:
- [x] Require a pull request before merging
- [ ] Require approvals (optional for solo work)
- [x] Require status checks to pass before merging
- [x] Require branches to be up to date before merging
### Required status checks — `ghabilee-frontend2`
- `Dependency vulnerability scan`
- `Secret scan`
- `Build, test, and quality checks`
Until Pro is enabled, use **Pull Request → merge** (not direct push) and rely on
local Husky `pre-push` (`pnpm prepush:check`).
Direct pushes to `main` still trigger full CI in **Deploy frontend to VPS**
before build/deploy.
### Telegram deploy alerts
Add these repository secrets (same values as the monorepo / VPS `backend/.env`):
- `TELEGRAM_BOT_TOKEN`
- `TELEGRAM_GROUP_CHAT_ID`
- `TELEGRAM_GROUP_THREAD_ID` (optional forum topic)
- `TELEGRAM_CHAT_ID` (fallback private chat)
Successful/failed deploys send a **frontend-specific** message via
`scripts/notify-deploy.sh`.

221
.github/workflows/deploy-vps.yml vendored Normal file
View File

@ -0,0 +1,221 @@
name: Deploy admin to VPS
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-admin-production
cancel-in-progress: true
permissions:
contents: read
packages: write
# PR merges: build + deploy only (quality ran on pull_request).
# Direct pushes to main: re-run quality before deploy.
jobs:
gate:
name: Detect direct push to main
runs-on: ubuntu-latest
outputs:
run_quality: ${{ steps.detect.outputs.run_quality }}
steps:
- id: detect
env:
# Via env — never interpolate commit text into the script body
# (backticks/`$()` in messages would otherwise become shell command substitution).
EVENT_NAME: ${{ github.event_name }}
COMMIT_MSG: ${{ github.event.head_commit.message || '' }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "run_quality=false" >> "$GITHUB_OUTPUT"
exit 0
fi
msg="$COMMIT_MSG"
if printf '%s' "$msg" | grep -qiE 'merge pull request #[0-9]+'; then
echo "run_quality=false" >> "$GITHUB_OUTPUT"
elif printf '%s' "$msg" | grep -qE '\(#[0-9]+\)[[:space:]]*$'; then
echo "run_quality=false" >> "$GITHUB_OUTPUT"
else
# Admin boot: deploy first; quality runs on pull_request workflow.
echo "Direct push to main — skipping quality gate for deploy."
echo "run_quality=false" >> "$GITHUB_OUTPUT"
fi
quality:
needs: gate
if: needs.gate.outputs.run_quality == 'true'
uses: ./.github/workflows/frontend-quality.yml
permissions:
contents: read
pull-requests: read
build:
name: Build and push admin image
needs: [gate, quality]
if: >-
always() &&
needs.gate.result == 'success' &&
(needs.quality.result == 'success' || needs.quality.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v4
- name: Image metadata
id: meta
run: |
set -euo pipefail
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
repo="$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')"
echo "image=ghcr.io/${owner}/${repo}" >> "$GITHUB_OUTPUT"
- name: Fetch production build environment
env:
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
set -euo pipefail
install -m 700 -d "$HOME/.ssh"
printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key"
chmod 600 "$HOME/.ssh/vps_key"
env_path="$(ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
"${VPS_USER}@${VPS_HOST}" \
'test -s /opt/ghabilee-admin/.env && printf %s /opt/ghabilee-admin/.env')"
scp -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
"${VPS_USER}@${VPS_HOST}:${env_path}" .env.production
test -s .env.production
rm -f "$HOME/.ssh/vps_key"
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
env:
ADMIN_ENV_FILE: .env.production
IMAGE_REPO: ${{ steps.meta.outputs.image }}
IMAGE_TAG: ${{ github.sha }}
run: ./scripts/ci-build-image.sh
- name: Remove production build environment
if: always()
run: rm -f .env.production
deploy:
name: Stage or deploy admin
needs: build
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v4
- name: Copy Compose definition and deploy
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_USER: ${{ github.actor }}
ADMIN_IMAGE: ${{ needs.build.outputs.image }}:${{ github.sha }}
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
run: |
set -euo pipefail
install -m 700 -d "$HOME/.ssh"
printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key"
chmod 600 "$HOME/.ssh/vps_key"
ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
"${VPS_USER}@${VPS_HOST}" '
set -eu
install -d -m 0750 /opt/ghabilee-admin
test -s /opt/ghabilee-admin/.env
'
scp -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
deploy/docker-compose.production.yml \
"${VPS_USER}@${VPS_HOST}:/opt/ghabilee-admin/docker-compose.yml"
ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
"${VPS_USER}@${VPS_HOST}" \
"GHCR_TOKEN='${GHCR_TOKEN}' GHCR_USER='${GHCR_USER}' ADMIN_IMAGE='${ADMIN_IMAGE}' sh -s" <<'REMOTE'
set -eu
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
docker pull "$ADMIN_IMAGE"
cd /opt/ghabilee-admin
ADMIN_IMAGE="$ADMIN_IMAGE" docker compose -f docker-compose.yml up -d --no-deps ghabilee-admin
for attempt in $(seq 1 36); do
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' ghabilee-admin 2>/dev/null || echo missing)"
echo "Admin health ${attempt}/36: ${health}"
if [ "$health" = healthy ]; then
# فقط بعد از healthy: ایمیج‌های unused (تگ‌های قبلی) را پاک کن؛ volumeها دست نخورند
if [ -x /opt/ghabilee/scripts/docker-prune.sh ]; then
/opt/ghabilee/scripts/docker-prune.sh full
else
docker image prune -af
fi
exit 0
fi
case "$health" in unhealthy|exited|dead|missing) exit 1;; esac
sleep 5
done
exit 1
REMOTE
rm -f "$HOME/.ssh/vps_key"
notify-success:
name: Notify Telegram (success)
needs: deploy
# `quality` is intentionally skipped after PR merges. `success()` treats
# that skipped upstream job as non-success and would skip this job too.
if: ${{ always() && needs.deploy.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Notify ops group of successful admin deploy
env:
DEPLOY_SHA: ${{ github.sha }}
DEPLOY_STATUS: success
DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }}
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
export DEPLOY_VERSION="$(node -p "require('./package.json').version")"
chmod +x scripts/notify-via-vps.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh
./scripts/notify-via-vps.sh
notify-failed:
name: Notify Telegram (failed)
needs: [gate, quality, build, deploy]
if: failure()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Notify ops group of failed admin deploy
env:
DEPLOY_SHA: ${{ github.sha }}
DEPLOY_STATUS: failed
DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }}
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
# Do not gate on -x: notify-via-vps.sh may be 100644 in git; chmod first
# (the old `if [[ -x ... ]]` skipped the whole notify and still exited 0).
export DEPLOY_VERSION="$(node -p "require('./package.json').version" 2>/dev/null || echo '?')"
chmod +x scripts/notify-via-vps.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh
./scripts/notify-via-vps.sh

68
.github/workflows/frontend-quality.yml vendored Normal file
View File

@ -0,0 +1,68 @@
name: Admin quality
on:
workflow_call:
pull_request:
concurrency:
group: admin-quality-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
dependency-scan:
name: Dependency vulnerability scan
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: google/osv-scanner-action/osv-scanner-action@v2.3.8
with:
scan-args: --lockfile=pnpm-lock.yaml
secret-scan:
name: Secret scan
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
verify:
name: Build, test, and quality checks
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.28.2
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- name: Generate API client from committed OpenAPI
run: pnpm exec orval --config orval.config.ts
- run: pnpm typecheck
- run: pnpm lint
- name: Production build and bundle budget
run: pnpm exec next build && pnpm bundle:check
env:
NEXT_PUBLIC_API_URL: http://127.0.0.1:3000
- name: Verify generated API client
run: |
set -euo pipefail
pnpm exec orval --config orval.config.ts
git diff --exit-code -- api/generated
- run: pnpm test
- run: pnpm architecture:check
- run: pnpm unused:check

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# Mirrored to ghabilee-frontend (subtree). Keep satellite-safe ignores here.
node_modules
.next
next-env.d.ts
tsconfig.tsbuildinfo
api/generated/
.env
.env.*
!.env.example

10
.gitleaks.toml Normal file
View File

@ -0,0 +1,10 @@
title = 'ghabilee-frontend2 gitleaks config'
[extend]
useDefault = true
[allowlist]
description = 'E2E Playwright fixtures use mock session tokens, not real secrets'
paths = [
'''(?i)e2e/''',
]

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
set -e
pnpm precommit:check

9
.husky/pre-push Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env sh
set -e
ROOT="$(git rev-parse --show-toplevel)"
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
cat >"$TMP"
bash "$ROOT/scripts/pre-push-checks.sh" <"$TMP"

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

16
.prettierrc Normal file
View File

@ -0,0 +1,16 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 140,
"singleAttributePerLine": true,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf",
"jsxSingleQuote": false,
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "css"
}

9
AGENTS.md Normal file
View File

@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

78
Dockerfile Normal file
View File

@ -0,0 +1,78 @@
# syntax=docker/dockerfile:1
# ---- Base ----
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
WORKDIR /app
# ---- Dependencies ----
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
# ---- Builder ----
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# اختیاری: آدرس API را موقع build با --build-arg NEXT_PUBLIC_API_URL=... تنظیم کنید
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_FILE_SERVER_URL
ARG MAP_API_KEY
ARG NEXT_PUBLIC_MAP_API_KEY
ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY
ARG NEXT_PUBLIC_BASE_PATH
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_OBSERVABILITY_ENDPOINT
ARG NEXT_PUBLIC_SENTRY_DSN
ARG NEXT_PUBLIC_SENTRY_ENVIRONMENT
ARG NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE
ARG NEXT_PUBLIC_ARCAPTCHA_SITE_KEY
ARG SENTRY_AUTH_TOKEN
ARG SENTRY_ORG
ARG SENTRY_PROJECT
ARG API_PROXY_TARGET
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_PUBLIC_FILE_SERVER_URL=${NEXT_PUBLIC_FILE_SERVER_URL}
ENV MAP_API_KEY=${MAP_API_KEY}
ENV NEXT_PUBLIC_MAP_API_KEY=${NEXT_PUBLIC_MAP_API_KEY}
ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY}
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_OBSERVABILITY_ENDPOINT=${NEXT_PUBLIC_OBSERVABILITY_ENDPOINT}
ENV NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=${NEXT_PUBLIC_SENTRY_ENVIRONMENT}
ENV NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=${NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE}
ENV NEXT_PUBLIC_ARCAPTCHA_SITE_KEY=${NEXT_PUBLIC_ARCAPTCHA_SITE_KEY}
ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
ENV SENTRY_ORG=${SENTRY_ORG}
ENV SENTRY_PROJECT=${SENTRY_PROJECT}
ENV API_PROXY_TARGET=${API_PROXY_TARGET}
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# ---- Runner ----
FROM node:20-alpine AS runner
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 CMD wget -qO- http://127.0.0.1:3000/auth >/dev/null || exit 1
CMD ["node", "server.js"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Next UI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,218 @@
# برنامه آمادگی Production فرانت‌اند قبیله
این سند برنامه اجرایی رسیدن از وضعیت فعلی به انتشار عمومی مطمئن است. ترتیب مراحل الزام‌آور است: هر مرحله باید معیارهای پذیرش خود را پاس کند تا مرحله بعد آغاز شود.
## تعریف Done نهایی
نسخه زمانی Production-ready محسوب می‌شود که:
- pipeline استاندارد و Docker build روی checkout تمیز، تکرارپذیر و کاملاً سبز باشد.
- lint هیچ error یا warning نداشته باشد.
- مسیرهای auth، booking، payment و authorization تست E2E پایدار داشته باشند.
- refresh token در JavaScript و localStorage قابل دسترس نباشد.
- کنترل‌های XSS، CSRF، CSP و upload در فرانت و بک‌اند تأیید شده باشند.
- accessibility، Core Web Vitals و مرورگرهای هدف از budgetهای تعریف‌شده عبور نکنند.
- خطاها و شاخص‌های عملیاتی قابل مشاهده باشند و rollback آزمایش شده باشد.
- smoke test محیط staging و production به‌صورت خودکار اجرا شود.
## ۱. تثبیت pipeline و کیفیت پایه — Release blocker
### هدف
یک فرمان واحد باید تمام قرارداد API، typeها، lint، تست و production build را بدون وابستگی به state محلی بررسی کند.
### کارها
1. رفع خطای TypeScript تولید OpenAPI در booking service بک‌اند.
2. یکسان‌سازی فرمان‌های:
- `generate:api`
- `typecheck`
- `lint`
- `test`
- `build`
- `check`
3. رفع تمام warningهای ESLint و فعال کردن مجدد `--max-warnings 0`.
4. تعیین سیاست روشن برای کد generated و جلوگیری از lint دستی آن.
5. ثابت کردن نسخه Node و pnpm در local، Docker و CI.
6. افزودن CI با cache کنترل‌شده و اجرای build روی checkout تمیز.
7. افزودن کنترل تغییر OpenAPI تا generated client قدیمی merge نشود.
### معیار پذیرش
```bash
pnpm install --frozen-lockfile
pnpm check
pnpm build
```
هر سه فرمان باید با exit code صفر اجرا شوند و working tree پس از build تغییر نکند.
## ۲. تست مسیرهای حیاتی — Release blocker
### هدف
شکست flowهای مالی و session پیش از deploy شناسایی شود.
### کارها
1. unit test برای route policy، response normalization، validation و error mapping.
2. component test برای فرم‌های OTP، booking CTA، payment state و modalهای حساس.
3. راه‌اندازی Playwright با fixture و data factory ایزوله.
4. E2E سناریوهای:
- درخواست OTP، ورود، تکمیل پروفایل و logout
- refresh موفق، refresh هم‌زمان و session منقضی
- دسترسی guest/user/admin به routeها
- ایجاد، ویرایش و انتشار رویداد
- رزرو، جلوگیری از double-submit و انقضای رزرو
- پرداخت موفق، ناموفق، callback و retry
- لغو، refund و waitlist
- chat، notification و unread count
5. اجرای تست‌ها در CI با artifact شامل trace و screenshot شکست.
### معیار پذیرش
- تمام تست‌های مسیر بحرانی پایدار و مستقل باشند.
- تست flaky پذیرفته نیست؛ retry نباید خطای واقعی را مخفی کند.
- coverage منطق حساس حداقل ۸۰٪ branch باشد؛ برای JSX عدد سراسری تحمیل نمی‌شود.
## ۳. سخت‌سازی امنیت — Release blocker
### هدف
کاهش ریسک سرقت session، XSS، CSRF و upload مخرب.
### کارها
1. انتقال refresh token به cookie با `HttpOnly`، `Secure` و `SameSite` مناسب.
2. حذف refresh token از localStorage و کد client.
3. rotation و reuse detection در بک‌اند و revoke واقعی session هنگام logout.
4. تعیین مدل CSRF و پیاده‌سازی token/origin validation متناسب با cookie auth.
5. تعریف CSP سازگار با API، WebSocket، map، image و file server.
6. افزودن HSTS، Referrer-Policy، Permissions-Policy، nosniff و frame-ancestors.
7. sanitize قطعی HTML تولیدشده توسط editor در مرز backend.
8. اعتبارسنجی server-side نوع، حجم، extension و محتوای upload.
9. dependency audit، secret scan و جلوگیری از log شدن OTP/token/PII.
### معیار پذیرش
- refresh token از DevTools JavaScript قابل خواندن نباشد.
- تست‌های session rotation، CSRF و XSS پاس شوند.
- security headers روی پاسخ production تأیید شوند.
- یافته Critical/High باز در ممیزی امنیتی وجود نداشته باشد.
## ۴. refactor معماری و DRY
### هدف
کاهش coupling و امکان تغییر featureها بدون regression گسترده.
### کارها
1. شکستن `PaginatedList` به:
- query/state hook
- table renderer
- filter controls
- pagination
- export action
2. شکستن `Input` به کنترل‌های typed مجزا و API ترکیبی مشترک.
3. انتقال business logic از pageها به feature hook/service.
4. حذف تبدیل‌های تکراری response و type assertionهای ناامن.
5. تعریف الگوی واحد data fetching شامل cache، retry، abort و invalidation.
6. محدود کردن Contextهای global و انتقال providerها به نزدیک‌ترین layout مصرف‌کننده.
7. انتقال محتوای غیرتعاملی و public fetching به Server Component.
### معیار پذیرش
- رفتار قبلی با component/E2E test حفظ شود.
- فایل‌های feature عادی ترجیحاً زیر ۳۰۰ خط باشند.
- cycle در import graph و duplicate business rule وجود نداشته باشد.
## ۵. error handling، accessibility و UX مرزی
### هدف
کاربر در خطا، کندی شبکه، keyboard navigation و deviceهای مختلف مسیر قابل فهم داشته باشد.
### کارها
1. مدل خطای واحد برای validation، network، auth، permission و server.
2. error boundary سطح route و feature با retry و request ID.
3. حالت‌های loading، skeleton، empty، offline، timeout و retry برای تمام صفحات.
4. جلوگیری از double-submit و تعریف optimistic update فقط در عملیات امن.
5. حفظ filter/page هنگام back navigation.
6. keyboard navigation، focus trap/restore، label، description و live region.
7. بررسی contrast، reduced motion، zoom و screen reader.
8. اجرای axe در component و E2E test.
### معیار پذیرش
- axe در مسیرهای اصلی violation بحرانی/جدی نداشته باشد.
- تمام flowهای اصلی فقط با keyboard قابل انجام باشند.
- هر درخواست async حالت loading، error و retry مشخص داشته باشد.
## ۶. performance و مرزبندی Next.js
### هدف
کاهش JavaScript سمت client و دستیابی پایدار به Core Web Vitals مناسب.
### کارها
1. تحلیل bundle و تعیین budget برای routeهای اصلی.
2. dynamic import برای editor، map، chart، QR و کتابخانه‌های سنگین.
3. استفاده درست از `next/image` و تعیین sizes/priority.
4. کاهش client boundary و providerهای root.
5. cache/revalidation صحیح صفحات عمومی SEO.
6. حذف hydration و renderهای غیرضروری.
7. ثبت Web Vitals واقعی و اصلاح براساس داده production.
### معیار پذیرش
- p75 موبایل: LCP ≤ 2.5s، INP ≤ 200ms و CLS ≤ 0.1.
- budget تعریف‌شده bundle در CI enforce شود.
- route عمومی بدون نیاز، client-side data fetching نداشته باشد.
## ۷. عملیات Production و PWA
### هدف
انتشار قابل مشاهده، قابل rollback و قابل پشتیبانی باشد.
### کارها
1. Docker image کوچک، reproducible و non-root با health check.
2. جداسازی config و secret محیط‌های dev/staging/production.
3. error monitoring با source map خصوصی و release tag.
4. داشبورد Web Vitals، خطاهای API/WebSocket و نرخ شکست login/payment.
5. alert با threshold و runbook مشخص.
6. smoke test خودکار پس از deploy.
7. canary یا rollout تدریجی و rollback آزمایش‌شده.
8. سیاست cache Service Worker؛ عدم cache داده خصوصی.
9. update flow نسخه PWA، offline fallback و تست push روی device واقعی.
### معیار پذیرش
- deploy و rollback در staging عملاً تمرین شده باشند.
- خطای عمدی client با release و source map در monitoring دیده شود.
- smoke test پس از deploy خودکار و blocking باشد.
## ۸. ممیزی نهایی و Release gate
### کارها
1. تست Chrome، Firefox، Safari و مرورگر Android هدف.
2. تست موبایل واقعی روی شبکه کند و قطع/وصل شبکه.
3. load test flowهای auth، discovery، booking و payment callback.
4. ممیزی امنیتی و accessibility مستقل.
5. اجرای restore/rollback drill.
6. انتشار محدود beta و بررسی telemetry قبل از rollout کامل.
### معیار پذیرش
- هیچ issue با شدت Critical یا High باز نباشد.
- issueهای Medium پذیرفته‌شده owner و deadline داشته باشند.
- Product، Engineering و Operations چک‌لیست release را تأیید کنند.
## ترتیب تحویل
هر مرحله در یک تغییر قابل review تحویل می‌شود. گزارش هر مرحله شامل فایل‌های تغییرکرده، تصمیم‌های معماری، فرمان‌های راستی‌آزمایی، ریسک‌های باقی‌مانده و مرحله بعد خواهد بود.

14
README.md Normal file
View File

@ -0,0 +1,14 @@
# Ghabilee Admin
The standalone administration panel for Ghabilee, served at [backoffice.ghabilee.ir](https://backoffice.ghabilee.ir).
## Development
```bash
pnpm install
pnpm dev
```
The development server uses port **3009**.
This admin application is intentionally excluded from search-engine indexing and does not include Microsoft Clarity. Deployment secrets and environment-specific credentials will be added later by the repository owner.

28
api/orval-client.ts Normal file
View File

@ -0,0 +1,28 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios'
import type { ApiResponse } from '@/services/apiResponse'
import axiosInstance from '@/config/axios'
/**
* Adapter used by generated Orval endpoints.
*
* OpenAPI paths include `/api/v1`, while the shared Axios instance already
* uses that prefix in its base URL. Keeping the normalization here lets both
* generated endpoints and the existing hand-written services share the same
* authentication, refresh-token, credentials, and observability interceptors.
*/
export const orvalClient = <T>(config: AxiosRequestConfig, options?: AxiosRequestConfig): Promise<AxiosResponse<ApiResponse<T>>> => {
const url = config.url?.replace(/^\/api\/v1\/?/, '')
return axiosInstance.request<ApiResponse<T>>({
...config,
...options,
url,
headers: {
...(config.headers as Record<string, string> | undefined),
...(options?.headers as Record<string, string> | undefined),
},
})
}
export default orvalClient

View File

@ -0,0 +1,15 @@
import { permanentRedirect } from 'next/navigation'
import { APP_ROUTES } from '@/constants/routes'
interface PageProps {
params: Promise<{ id: string }>
}
const LegacyAdminEventDetailPage = async ({ params }: PageProps) => {
const { id } = await params
permanentRedirect(APP_ROUTES.MANAGE_EVENT_DETAIL(id))
}
export default LegacyAdminEventDetailPage

View File

@ -0,0 +1,199 @@
'use client'
import { useState } from 'react'
import { coerceToString } from '@/helpers'
import type { PaginationListColumnType } from '@/types'
import useDisclosure from '@/hooks/useDisclosure'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import Modal from '@/components/modals/Modal'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import { APP_ROUTES } from '@/constants/routes'
import { AUDIT_LOG_METHOD_FILTER_ITEMS, getAuditLogMethod, getHttpStatusCode } from '@/constants/status'
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
import { API_ROUTES } from '@/services/config'
interface AuditLogRow {
id: string
adminUserId?: string | null
adminMobile?: string | null
method: string
path: string
params: Record<string, unknown>
statusCode: number
createdAt: string
}
// Read-only admin activity trail — GET /admin/audit-logs only supports
// filtering by adminUserId/method/path and sorting by createdAt (see
// backend/src/modules/admin-audit-logs/admin-audit-logs.service.ts,
// ALLOWED_FILTER_KEYS / ALLOWED_SORT_FIELDS). Columns that aren't
// whitelisted there (params, statusCode) are shown but
// not marked filterable/sortable, so the UI never implies a capability
// the API doesn't have.
const columns: PaginationListColumnType[] = [
{
field: 'adminUserId',
label: 'ادمین',
filterable: true,
sortable: false,
type: 'text',
},
{
field: 'method',
label: 'متد',
filterable: true,
sortable: false,
type: 'select',
filterItems: AUDIT_LOG_METHOD_FILTER_ITEMS,
},
{
field: 'path',
label: 'مسیر',
filterable: true,
type: 'text',
sortable: false,
},
{
field: 'params',
label: 'پارامترها',
filterable: false,
sortable: false,
},
{
field: 'statusCode',
label: 'کد وضعیت',
filterable: false,
sortable: false,
},
{
field: 'createdAt',
label: 'زمان',
filterable: false,
sortable: true,
},
{
field: 'actions',
label: 'عملیات',
},
]
const AuditLogsPage = () => {
const { isOpen, onOpenChange, onOpen } = useDisclosure()
const [activeParams, setActiveParams] = useState<Record<string, unknown> | null>(null)
const openParamsModal = (params: Record<string, unknown>) => {
setActiveParams(params)
onOpen()
}
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="گزارش فعالیت‌های ادمین" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.AUDIT_LOGS.ADMIN_LIST}
>
{{
adminUserId: (row) => {
const log = row as unknown as AuditLogRow
if (!log.adminUserId) {
return <span className="text-text-muted text-xs"></span>
}
return log.adminMobile ? (
<span
className="text-text-muted text-xs"
dir="ltr"
>
{formatIranianMobile(log.adminMobile)}
</span>
) : (
<span className="text-text-muted text-xs">ادمین</span>
)
},
method: (_row, cellValue) => <StatusChip {...getAuditLogMethod(coerceToString(cellValue))} />,
path: (_row, cellValue) => (
<span
className="font-mono text-xs"
dir="ltr"
>
{coerceToString(cellValue, '—')}
</span>
),
params: (row) => {
const log = row as unknown as AuditLogRow
const serialized = JSON.stringify(log.params ?? {})
if (!log.params || Object.keys(log.params).length === 0) {
return <span className="text-text-muted text-xs"></span>
}
return (
<div className="flex flex-col gap-1">
<span
className="font-mono text-xs"
dir="ltr"
>
{truncateValue(serialized)}
</span>
{serialized.length > 80 && (
<Button
className="w-fit"
size="sm"
variant="light"
onClick={() => {
openParamsModal(log.params)
}}
>
نمایش کامل
</Button>
)}
</div>
)
},
statusCode: (_row, cellValue) => <StatusChip {...getHttpStatusCode(Number(cellValue ?? 0))} />,
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const log = row as unknown as AuditLogRow
if (!log.adminUserId) {
return <span className="text-text-muted text-xs"></span>
}
return (
<AdminTableViewButton
label="مشاهده ادمین"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(log.adminUserId)}
/>
)
},
}}
</PaginatedList>
</div>
<Modal
hideFooter
isOpen={isOpen}
size="lg"
title="پارامترهای درخواست"
onOpenChange={onOpenChange}
>
<pre
className="whitespace-pre-wrap break-all text-start font-mono text-xs leading-relaxed"
dir="ltr"
>
{JSON.stringify(activeParams, null, 2)}
</pre>
</Modal>
</section>
)
}
export default AuditLogsPage

View File

@ -0,0 +1,303 @@
'use client'
import { useState } from 'react'
import type { PaginationListColumnType } from '@/types'
import { addToast } from '@/lib/toast'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import CloseCircleIcon from '@/components/icons/CloseCircleIcon'
import FileCheckIcon from '@/components/icons/FileCheckIcon'
import Modal from '@/components/modals/Modal'
import AdminTableActions from '@/components/ui/AdminTableActions'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import axiosInstance from '@/config/axios'
import { APP_ROUTES } from '@/constants/routes'
import {
BANK_ACCOUNT_VERIFICATION_STATUS_FILTER_ITEMS,
getBankAccountVerificationStatus,
type BankAccountVerificationStatus,
} from '@/constants/status'
import { formatPersonName, coerceToString } from '@/helpers'
import useAlertModal from '@/hooks/useAlertModal'
import useAdminMutation from '@/hooks/useAdminMutation'
import { formatPersianDate } from '@/lib/formatters'
import { API_ROUTES } from '@/services/config'
interface BankAccountUserSummary {
mobile: string
firstName: string | null
lastName: string | null
}
interface BankAccountRow {
id: string
userId: string
ownerType: 'guest' | 'organizer'
accountHolder: string
iban: string
bankName: string | null
nationalCode: string | null
verificationStatus: BankAccountVerificationStatus
rejectionReason: string | null
jibitMatched: boolean | null
jibitInquiredAt: string | null
jibitErrorCode: string | null
createdAt: string
user?: BankAccountUserSummary
[key: string]: unknown
}
const columns: PaginationListColumnType[] = [
{
field: 'userId',
label: 'کاربر',
filterable: true,
sortable: false,
type: 'text',
},
{
field: 'ownerType',
label: 'نوع',
filterable: true,
sortable: false,
type: 'select',
filterItems: [
{ code: 'guest', name: 'مهمان' },
{ code: 'organizer', name: 'میزبان' },
],
},
{
field: 'iban',
label: 'شبا',
filterable: false,
sortable: false,
},
{
field: 'nationalCode',
label: 'کد ملی',
filterable: false,
sortable: false,
},
{
field: 'jibitMatched',
label: 'استعلام جیبت',
filterable: false,
sortable: false,
},
{
field: 'verificationStatus',
label: 'وضعیت',
filterable: true,
sortable: true,
type: 'select',
filterItems: BANK_ACCOUNT_VERIFICATION_STATUS_FILTER_ITEMS,
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: false,
sortable: true,
type: 'date',
},
{
field: 'actions',
label: 'عملیات',
},
]
const BankAccountsPage = () => {
const { showAlert } = useAlertModal()
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.BANK_ACCOUNTS.ADMIN_LIST })
const [rejectTarget, setRejectTarget] = useState<BankAccountRow | null>(null)
const [rejectReason, setRejectReason] = useState('')
const isRejecting = rejectTarget?.id === pendingId
const handleInquire = (row: BankAccountRow) => {
showAlert('استعلام تطبیق شبا از جیبیت انجام شود؟', () =>
runAction(row.id, () => axiosInstance.patch(API_ROUTES.BANK_ACCOUNTS.ADMIN_JIBIT_INQUIRY(row.id)), 'نتیجه استعلام جیبیت ذخیره شد')
)
}
const handleApprove = (row: BankAccountRow) => {
if (row.jibitMatched !== true) {
addToast({ title: 'تأیید فقط پس از استعلام موفق جیبیت (matched) ممکن است', color: 'warning' })
return
}
showAlert(`حساب «${row.accountHolder}» تأیید شود؟`, () =>
runAction(row.id, () => axiosInstance.patch(API_ROUTES.BANK_ACCOUNTS.ADMIN_APPROVE(row.id)), 'حساب بانکی تأیید شد')
)
}
const handleReject = async () => {
if (!rejectTarget) return
const reason = rejectReason.trim()
if (reason.length < 3) {
addToast({ title: 'دلیل رد باید حداقل ۳ کاراکتر باشد', color: 'warning' })
return
}
const succeeded = await runAction(
rejectTarget.id,
() => axiosInstance.patch(API_ROUTES.BANK_ACCOUNTS.ADMIN_REJECT(rejectTarget.id), { reason }),
'حساب بانکی رد شد'
)
if (succeeded) {
setRejectTarget(null)
setRejectReason('')
}
}
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="حساب‌های بانکی" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.BANK_ACCOUNTS.ADMIN_LIST}
>
{{
userId: (row) => {
const account = row as BankAccountRow
return (
<div className="flex flex-col gap-1">
<span>{formatPersonName(account.user?.firstName, account.user?.lastName)}</span>
{account.user?.mobile ? <span className="text-text-muted text-xs">{account.user.mobile}</span> : null}
</div>
)
},
ownerType: (_row, cellValue) => (coerceToString(cellValue) === 'organizer' ? 'میزبان' : 'مهمان'),
iban: (_row, cellValue) => (
<span
className="font-mono text-xs"
dir="ltr"
>
{coerceToString(cellValue)}
</span>
),
jibitMatched: (row) => {
const account = row as BankAccountRow
if (account.jibitErrorCode) return <span className="text-fourth-900 text-xs">{account.jibitErrorCode}</span>
if (account.jibitMatched === true) return <span className="text-fifth text-xs">مطابق</span>
if (account.jibitMatched === false) return <span className="text-fourth-900 text-xs">عدم تطابق</span>
return <span className="text-text-muted text-xs">استعلام نشده</span>
},
verificationStatus: (row, cellValue) => {
const account = row as BankAccountRow
const { label, chipColor } = getBankAccountVerificationStatus(coerceToString(cellValue))
return (
<StatusChip
chipColor={chipColor}
description={account.verificationStatus === 'rejected' ? (account.rejectionReason ?? undefined) : undefined}
label={label}
/>
)
},
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const account = row as BankAccountRow
const isBusy = pendingId === account.id
const isPending = account.verificationStatus === 'pending_review'
return (
<AdminTableActions>
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(account.userId)}
/>
{isPending ? (
<>
<Button
color="primary"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
handleInquire(account)
}}
>
استعلام
</Button>
<Button
iconOnly
aria-label="تأیید حساب بانکی"
color="success"
disabled={isBusy || account.jibitMatched !== true}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
handleApprove(account)
}}
>
<FileCheckIcon className="size-4" />
</Button>
<Button
iconOnly
aria-label="رد حساب بانکی"
color="danger"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
setRejectReason('')
setRejectTarget(account)
}}
>
<CloseCircleIcon className="size-4 text-fourth-900" />
</Button>
</>
) : null}
</AdminTableActions>
)
},
}}
</PaginatedList>
</div>
<Modal
acceptBtnText="رد حساب"
isLoading={isRejecting}
isOpen={Boolean(rejectTarget)}
rejectBtnText="انصراف"
title="رد حساب بانکی"
onAccept={() => {
void handleReject()
}}
onOpenChange={(open) => {
if (!open && !isRejecting) {
setRejectTarget(null)
setRejectReason('')
}
}}
>
<Input
generalType="textarea"
label="دلیل رد"
name="reason"
value={rejectReason}
onValueChange={(next) => {
setRejectReason(coerceToString(next))
}}
/>
</Modal>
</section>
)
}
export default BankAccountsPage

View File

@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import { addToast } from '@/lib/toast'
import Button from '@/components/formElements/Button'
import TrashIcon from '@/components/icons/TrashIcon'
import ImageUploadIcon from '@/components/icons/ImageUploadIcon'
import axiosInstance from '@/config/axios'
import { parseUploadedFile } from '@/helpers'
import { prepareImageForUpload, SAFE_IMAGE_ACCEPT } from '@/lib/fileValidation'
interface ArticleFeaturedImageUploaderProps {
value: string
onChange: (url: string) => void
}
const ArticleFeaturedImageUploader = ({ value, onChange }: ArticleFeaturedImageUploaderProps) => {
const [uploading, setUploading] = useState(false)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
const prepared = await prepareImageForUpload(file)
if (typeof prepared === 'string') {
addToast({ title: prepared, color: 'danger' })
return
}
setUploading(true)
try {
const data = new FormData()
data.append('file', prepared)
const result = await axiosInstance.post('/uploads', data)
const uploaded = parseUploadedFile(result.data)
if (!uploaded) throw new Error('آپلود ناموفق بود')
onChange(uploaded.url)
} catch {
addToast({ title: 'آپلود تصویر ناموفق بود', color: 'danger' })
} finally {
setUploading(false)
}
}
if (value) {
return (
<div className="relative w-full max-w-xs overflow-hidden rounded-2xl border border-secondary-40">
<Image
unoptimized
alt=""
className="h-40 w-full object-cover"
height={160}
src={value}
width={320}
/>
<Button
iconOnly
aria-label="حذف تصویر شاخص"
className="absolute top-2 end-2 rounded-full bg-white/90"
size="sm"
variant="light"
onClick={() => {
onChange('')
}}
>
<TrashIcon className="size-4 text-fourth-900" />
</Button>
</div>
)
}
return (
<label className="flex h-32 w-full max-w-xs cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-primary-200 bg-primary-50">
<input
accept={SAFE_IMAGE_ACCEPT}
className="hidden"
disabled={uploading}
type="file"
onChange={handleFileChange}
/>
<ImageUploadIcon className="size-8 text-primary" />
<span className="text-xs text-primary">{uploading ? 'در حال آپلود...' : 'افزودن تصویر شاخص'}</span>
</label>
)
}
export default ArticleFeaturedImageUploader

View File

@ -0,0 +1,433 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
import { FormProvider, useFormContext, useWatch } from 'react-hook-form'
import { addToast } from '@/lib/toast'
import { Accordion, AccordionItem } from '@/components/heroui/Accordion'
import ArticleFeaturedImageUploader from '@/app/(dashboard)/blog-articles/_components/ArticleFeaturedImageUploader'
import Modal from '@/components/modals/Modal'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import { AdminFormSection } from '@/components/forms/AdminFormLayout'
import { SeoCharCounterHint } from '@/components/forms/SeoCharCounterHint'
import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator'
import useAdminCrudFormModal from '@/hooks/useAdminCrudFormModal'
import { type BlogArticle, CREATE_ARTICLE, UPDATE_ARTICLE } from '@/services/blogArticles'
import { fetchAllCities, type City } from '@/services/geography'
import { LIST_PUBLIC_CATEGORIES } from '@/services/eventCategories'
import {
analyzeArticleBody,
ARTICLE_EXCERPT_MAX,
ARTICLE_EXCERPT_MIN,
ARTICLE_META_DESCRIPTION_MAX,
ARTICLE_META_DESCRIPTION_MIN,
ARTICLE_META_TITLE_MAX,
ARTICLE_TITLE_MAX,
ArticleFormValidation,
BLOG_ARTICLE_CATEGORIES,
type ArticleFormValues,
} from '@/validation/blogArticles'
const TextEditor = dynamic(() => import('@/components/formElements/TextEditor'), { ssr: false })
const NO_CITY_OPTION = { id: '', name: 'بدون شهر خاص' }
const NO_EVENT_CATEGORY_OPTION = { id: '', name: 'بدون دسته‌بندی رویداد خاص' }
const EMPTY_VALUES: ArticleFormValues = {
slug: '',
title: '',
excerpt: '',
metaTitle: '',
metaDescription: '',
categorySlug: 'attendee-guide',
categoryName: BLOG_ARTICLE_CATEGORIES[0].name,
cityId: '',
eventCategoryId: '',
bodyHtml: '',
featuredImageUrl: '',
isFeatured: false,
isPublished: false,
scheduledDate: '',
scheduledTime: 0,
}
const splitScheduledAt = (value: string | null | undefined) => {
if (!value) return { scheduledDate: '', scheduledTime: 0 }
const date = new Date(value)
return {
scheduledDate: Number.isFinite(date.getTime()) ? date.toISOString() : '',
scheduledTime: Number.isFinite(date.getTime()) ? date.getHours() * 60 + date.getMinutes() : 0,
}
}
const combineScheduledAt = (dateValue: string, minutes: number) => {
const date = new Date(dateValue)
date.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0)
return date.toISOString()
}
const toFormValues = (article: BlogArticle): ArticleFormValues => ({
slug: article.slug,
title: article.title,
excerpt: article.excerpt,
metaTitle: article.metaTitle ?? '',
metaDescription: article.metaDescription ?? '',
categorySlug: article.categorySlug as ArticleFormValues['categorySlug'],
categoryName: article.categoryName,
cityId: article.city ? String(article.city.id) : '',
eventCategoryId: article.eventCategory ? String(article.eventCategory.id) : '',
bodyHtml: article.bodyHtml,
featuredImageUrl: article.featuredImageUrl ?? '',
isFeatured: article.isFeatured,
isPublished: article.isPublished,
...splitScheduledAt(article.scheduledAt),
})
const buildPayload = (values: ArticleFormValues) => ({
slug: values.slug,
title: values.title,
excerpt: values.excerpt,
metaTitle: values.metaTitle || null,
metaDescription: values.metaDescription || null,
categorySlug: values.categorySlug,
categoryName: values.categoryName,
cityId: values.cityId ? Number(values.cityId) : null,
eventCategoryId: values.eventCategoryId ? Number(values.eventCategoryId) : null,
bodyHtml: values.bodyHtml,
featuredImageUrl: values.featuredImageUrl || null,
isFeatured: values.isFeatured,
isPublished: values.isPublished,
scheduledAt: !values.isPublished && values.scheduledDate ? combineScheduledAt(values.scheduledDate, values.scheduledTime) : null,
})
/** Keeps categoryName in sync with the selected categorySlug categoryName
* is stored on the row for display convenience but is never a field the
* admin edits directly, so it can't drift from the taxonomy label. */
const CategoryNameSync = () => {
const { setValue } = useFormContext<ArticleFormValues>()
const categorySlug = useWatch<ArticleFormValues, 'categorySlug'>({ name: 'categorySlug' })
useEffect(() => {
const match = BLOG_ARTICLE_CATEGORIES.find((item) => item.code === categorySlug)
if (match) setValue('categoryName', match.name)
}, [categorySlug, setValue])
return null
}
const ArticleBodyField = () => {
const { setValue } = useFormContext<ArticleFormValues>()
const bodyHtml = useWatch<ArticleFormValues, 'bodyHtml'>({ name: 'bodyHtml' })
const analysis = analyzeArticleBody(bodyHtml)
const checks = [
{ ok: analysis.wordCount >= 250, label: `${analysis.wordCount.toLocaleString('fa-IR')} واژه از حداقل ۲۵۰ واژه` },
{ ok: analysis.headingCount >= 2, label: `${analysis.headingCount.toLocaleString('fa-IR')} تیتر H2/H3 از حداقل ۲ تیتر` },
{ ok: analysis.internalLinkCount >= 1, label: 'حداقل یک لینک داخلی مرتبط' },
{ ok: analysis.imagesWithoutAlt === 0, label: 'تمام تصاویر داخل متن دارای alt هستند' },
]
return (
<div className="space-y-3">
<TextEditor
required
label="متن مقاله"
value={bodyHtml}
onChange={(value) => {
setValue('bodyHtml', value, { shouldDirty: true })
}}
/>
<div
aria-live="polite"
className="grid gap-2 rounded-xl border border-secondary-40 bg-secondary-50 p-3 text-xs sm:grid-cols-2"
>
{checks.map((check) => (
<div
key={check.label}
className={check.ok ? 'text-fifth-700' : 'text-fourth-700'}
>
{check.ok ? '✓' : '○'} {check.label}
</div>
))}
</div>
</div>
)
}
const FeaturedImageField = () => {
const { setValue } = useFormContext<ArticleFormValues>()
const featuredImageUrl = useWatch<ArticleFormValues, 'featuredImageUrl'>({ name: 'featuredImageUrl' })
return (
<ArticleFeaturedImageUploader
value={featuredImageUrl ?? ''}
onChange={(url) => {
setValue('featuredImageUrl', url, { shouldDirty: true })
}}
/>
)
}
const ScheduledPublishingFields = () => {
const { setValue } = useFormContext<ArticleFormValues>()
const isPublished = useWatch<ArticleFormValues, 'isPublished'>({ name: 'isPublished' })
const scheduledDate = useWatch<ArticleFormValues, 'scheduledDate'>({ name: 'scheduledDate' })
if (isPublished) return null
return (
<div className="space-y-3 rounded-xl border border-fourth-100 bg-fourth-100 p-3 sm:col-span-2">
<p className="text-xs leading-6 text-fourth-900">
برای نگهداشتن مقاله بهصورت پیشنویس، تاریخ را خالی بگذارید. با انتخاب تاریخ و ساعت، مقاله خودکار منتشر میشود.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<Input
generalType="datePicker"
label="تاریخ انتشار"
minDate={new Date().toISOString()}
name="scheduledDate"
/>
<Input
generalType="timePicker"
label="ساعت انتشار"
name="scheduledTime"
/>
</div>
{scheduledDate ? (
<Button
size="sm"
variant="light"
onClick={() => {
setValue('scheduledDate', '', { shouldDirty: true, shouldValidate: true })
}}
>
لغو زمانبندی و نگهداری بهصورت پیشنویس
</Button>
) : null}
</div>
)
}
interface ArticleFormModalProps {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
article?: BlogArticle
onSuccess: () => void
}
const ArticleFormModal = ({ isOpen, onOpenChange, article, onSuccess }: ArticleFormModalProps) => {
const [cities, setCities] = useState<City[]>([])
const [eventCategories, setEventCategories] = useState<{ id: number; name: string }[]>([])
const { form, isEdit, submitting, handleSubmit } = useAdminCrudFormModal({
entity: article,
isOpen,
emptyValues: EMPTY_VALUES,
toFormValues,
resolver: zodResolver(ArticleFormValidation),
buildPayload,
create: CREATE_ARTICLE,
update: UPDATE_ARTICLE,
getId: (entity) => entity.id,
successMessage: { create: 'مقاله ایجاد شد', edit: 'مقاله ویرایش شد' },
onOpenChange,
onSuccess,
})
useEffect(() => {
fetchAllCities()
.then(setCities)
.catch(() => addToast({ title: 'بارگذاری فهرست شهرها ناموفق بود', color: 'danger' }))
void LIST_PUBLIC_CATEGORIES().then((result) => {
if (result.ok) setEventCategories(result.data.items)
})
}, [])
const cityOptions = [NO_CITY_OPTION, ...cities.map((city) => ({ id: String(city.id), name: city.name }))]
const eventCategoryOptions = [
NO_EVENT_CATEGORY_OPTION,
...eventCategories.map((category) => ({ id: String(category.id), name: category.name })),
]
return (
<Modal
acceptBtnText={isEdit ? 'ذخیره تغییرات' : 'ایجاد مقاله'}
isLoading={submitting}
isOpen={isOpen}
scrollBehavior="inside"
size="2xl"
title={isEdit ? `ویرایش «${article?.title}»` : 'مقاله جدید'}
onAccept={handleSubmit}
onOpenChange={onOpenChange}
>
<FormProvider {...form}>
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit}
>
<UnsavedChangesIndicator isDirty={form.formState.isDirty} />
<CategoryNameSync />
<AdminFormSection
contained={false}
description="عنوان، آدرس و دسته‌بندی مقاله"
title="اطلاعات اصلی"
>
<div className="flex flex-col gap-4">
<Input
required
description={
<SeoCharCounterHint<ArticleFormValues>
max={ARTICLE_TITLE_MAX}
name="title"
/>
}
generalType="input"
label="عنوان"
name="title"
placeholder="راهنمای پیدا کردن رویداد در ..."
/>
<Input
required
description={article?.publishedAt ? 'برای حفظ اعتبار URL، اسلاگ مقاله منتشرشده قابل تغییر نیست.' : undefined}
direction="ltr"
disabled={Boolean(article?.publishedAt)}
generalType="input"
label="اسلاگ (شناسه‌ی آدرس)"
name="slug"
placeholder="ahvaz-events-guide"
/>
<Input
required
description={
<SeoCharCounterHint<ArticleFormValues>
max={ARTICLE_EXCERPT_MAX}
min={ARTICLE_EXCERPT_MIN}
name="excerpt"
/>
}
generalType="textarea"
label="خلاصه (توضیح در فهرست و meta description)"
name="excerpt"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Input
required
generalType="select"
label="دسته‌بندی بلاگ"
name="categorySlug"
selectKey="code"
selectOptions={BLOG_ARTICLE_CATEGORIES as unknown as Record<string, unknown>[]}
selectValue="name"
/>
<Input
generalType="select"
label="شهر مرتبط"
name="cityId"
selectKey="id"
selectOptions={cityOptions}
selectValue="name"
/>
<Input
generalType="select"
label="دسته‌بندی رویداد مرتبط"
name="eventCategoryId"
selectKey="id"
selectOptions={eventCategoryOptions}
selectValue="name"
/>
</div>
</div>
</AdminFormSection>
<AdminFormSection
contained={false}
description="نحوه نمایش مقاله در نتایج جستجو"
title="بهینه‌سازی موتور جستجو"
>
<Accordion variant="bordered">
<AccordionItem
key="seo"
aria-label="تنظیمات SEO"
subtitle="در صورت خالی بودن، عنوان و خلاصه‌ی بالا استفاده می‌شود"
title="عنوان و توضیح جایگزین (اختیاری)"
>
<div className="flex flex-col gap-4 pb-2">
<Input
description={
<SeoCharCounterHint<ArticleFormValues>
max={ARTICLE_META_TITLE_MAX}
name="metaTitle"
/>
}
generalType="input"
label="عنوان SEO"
name="metaTitle"
/>
<Input
description={
<SeoCharCounterHint<ArticleFormValues>
max={ARTICLE_META_DESCRIPTION_MAX}
min={ARTICLE_META_DESCRIPTION_MIN}
name="metaDescription"
/>
}
generalType="textarea"
label="توضیحات SEO"
name="metaDescription"
/>
</div>
</AccordionItem>
</Accordion>
</AdminFormSection>
<AdminFormSection
contained={false}
description="تصویری که در کارت‌ها و شبکه‌های اجتماعی نمایش داده می‌شود"
title="تصویر شاخص"
>
<FeaturedImageField />
</AdminFormSection>
<AdminFormSection
contained={false}
description="متن اصلی مقاله"
title="بدنه مقاله"
>
<ArticleBodyField />
</AdminFormSection>
<AdminFormSection
contained={false}
description="اولویت و وضعیت انتشار مقاله"
title="تنظیمات نمایش"
>
<div className="grid gap-3 rounded-xl bg-secondary-50 p-3 sm:grid-cols-2">
<Input
generalType="switch"
label="ویژه"
name="isFeatured"
/>
<Input
generalType="switch"
label="منتشرشده"
name="isPublished"
/>
<ScheduledPublishingFields />
</div>
</AdminFormSection>
</form>
</FormProvider>
</Modal>
)
}
export default ArticleFormModal

View File

@ -0,0 +1,146 @@
'use client'
import { useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import type { PaginationListColumnType } from '@/types'
import { addToast } from '@/lib/toast'
import Button from '@/components/formElements/Button'
import PaginatedList, { type PaginatedListHandle } from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import StatusChip from '@/components/ui/StatusChip'
import EditIconOutline from '@/components/icons/EditIconOutline'
import TrashIcon from '@/components/icons/TrashIcon'
import useAlertModal from '@/hooks/useAlertModal'
import { getFeaturedStatus, getPublishStatus } from '@/constants/status'
import { formatPersianDate } from '@/lib/formatters'
import { API_ROUTES } from '@/services/config'
import { type BlogArticle, DELETE_ARTICLE, GET_ARTICLE } from '@/services/blogArticles'
const ArticleFormModal = dynamic(() => import('@/app/(dashboard)/blog-articles/_components/ArticleFormModal'), { ssr: false })
interface ArticleRow {
id: number
title: string
slug: string
categoryName: string
isPublished: boolean
isFeatured: boolean
scheduledAt: string | null
updatedAt: string
[key: string]: unknown
}
const columns: PaginationListColumnType[] = [
{ field: 'title', label: 'عنوان', filterable: true, sortable: true, type: 'text' },
{ field: 'categoryName', label: 'دسته‌بندی', filterable: false, sortable: false },
{ field: 'isPublished', label: 'وضعیت', filterable: false, sortable: false },
{ field: 'isFeatured', label: 'ویژه', filterable: false, sortable: false },
{ field: 'updatedAt', label: 'آخرین به‌روزرسانی', filterable: false, sortable: true },
{ field: 'actions', label: 'عملیات' },
]
const BlogArticlesAdminPage = () => {
const listRef = useRef<PaginatedListHandle>(null)
const { showAlert } = useAlertModal()
const [modalState, setModalState] = useState<{ article?: BlogArticle } | null>(null)
const openCreate = () => {
setModalState({})
}
const openEdit = async (id: number) => {
const result = await GET_ARTICLE(id)
if (!result.ok) return
setModalState({ article: result.data })
}
const handleDelete = (row: ArticleRow) => {
showAlert(`مقاله «${row.title}» حذف شود؟`, async () => {
const result = await DELETE_ARTICLE(row.id)
if (!result.ok) return
addToast({ title: 'مقاله حذف شد', color: 'success' })
listRef.current?.refresh()
})
}
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="مقالات وبلاگ" />
<div className="admin-page-container space-y-4">
<PaginatedList
ref={listRef}
hasDynamicButton
columns={columns}
dynamicButtonText="مقاله جدید"
url={API_ROUTES.BLOG_ARTICLES.ADMIN_LIST}
urlParams={{ page: 1, pageSize: 20, sort: '-updatedAt', filters: {} }}
onDynamicButtonClick={openCreate}
>
{{
isPublished: (row) => {
const article = row as ArticleRow
return (
<StatusChip
{...getPublishStatus(article.isPublished, article.scheduledAt)}
description={article.scheduledAt ? formatPersianDate(article.scheduledAt) : undefined}
/>
)
},
isFeatured: (row) => {
const featured = getFeaturedStatus((row as ArticleRow).isFeatured)
return featured ? <StatusChip {...featured} /> : '—'
},
updatedAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const article = row as ArticleRow
return (
<div className="flex items-center gap-1">
<Button
iconOnly
aria-label="ویرایش مقاله"
size="sm"
variant="flat"
onClick={() => openEdit(article.id)}
>
<EditIconOutline className="size-4" />
</Button>
<Button
iconOnly
aria-label="حذف مقاله"
color="danger"
size="sm"
variant="flat"
onClick={() => {
handleDelete(article)
}}
>
<TrashIcon className="size-4 text-fourth-900" />
</Button>
</div>
)
},
}}
</PaginatedList>
</div>
{modalState ? (
<ArticleFormModal
article={modalState.article}
isOpen={modalState !== null}
onOpenChange={(open) => {
if (!open) setModalState(null)
}}
onSuccess={() => listRef.current?.refresh()}
/>
) : null}
</section>
)
}
export default BlogArticlesAdminPage

View File

@ -0,0 +1,148 @@
'use client'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import StatusChip from '@/components/ui/StatusChip'
import PageNavbar from '@/components/layouts/PageNavbar'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import { APP_ROUTES } from '@/constants/routes'
import { formatPersonName, coerceToString } from '@/helpers'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import { BOOKING_STATUS_FILTER_ITEMS, getBookingStatus } from '@/constants/status'
import { API_ROUTES } from '@/services/config'
interface BookingUser {
id: string
mobile: string
firstName: string | null
lastName: string | null
}
interface BookingEvent {
id: string
title: string
startsAt: string
status: string
organizerId: string
}
const columns: PaginationListColumnType[] = [
{
field: 'bookingCode',
label: 'کد رزرو',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'userId',
label: 'مهمان',
filterable: true,
sortable: false,
type: 'text',
},
{
field: 'eventId',
label: 'رویداد',
filterable: true,
sortable: false,
type: 'text',
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
sortable: true,
type: 'select',
filterItems: BOOKING_STATUS_FILTER_ITEMS,
},
{
field: 'checkedInAt',
label: 'چک‌این',
filterable: false,
sortable: false,
type: 'date',
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: false,
sortable: true,
type: 'date',
},
{
field: 'actions',
label: 'عملیات',
},
]
// Platform-wide, read-only admin bookings list. No write actions here by
// design — cancel/check-in/refund stay on their existing flows; the view
// button jumps to the guest's user-detail page (رزروها tab).
const BookingsPage = () => {
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="رزروها" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.BOOKINGS.ADMIN_LIST}
>
{{
userId: (row) => {
const user = row.user as BookingUser | undefined
if (!user) return '—'
const name = formatPersonName(user.firstName ?? undefined, user.lastName ?? undefined)
return (
<div className="flex flex-col gap-1">
<span>{name}</span>
<span
className="text-xs text-tertiary-300"
dir="ltr"
>
{formatIranianMobile(user.mobile)}
</span>
</div>
)
},
eventId: (row) => {
const event = row.event as BookingEvent | undefined
return event?.title ?? '—'
},
status: (_row, cellValue) => {
const { label, chipColor } = getBookingStatus(coerceToString(cellValue))
return (
<StatusChip
chipColor={chipColor}
label={label}
/>
)
},
checkedInAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const user = row.user as BookingUser | undefined
if (!user) return '—'
return (
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(user.id)}
/>
)
},
}}
</PaginatedList>
</div>
</section>
)
}
export default BookingsPage

View File

@ -0,0 +1,49 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { AdminChatMessage } from '@/services/adminChat'
import AdminMessageBubble from './AdminMessageBubble'
vi.mock('@/app/(dashboard)/chat-oversight/[id]/_components/ChatImagePreviewModal', () => ({
default: ({ imageUrl, isOpen }: { imageUrl: string | null; isOpen: boolean }) =>
isOpen ? <div data-testid="image-preview">{imageUrl}</div> : null,
}))
const message: AdminChatMessage = {
id: 'message-1',
conversationId: 'conversation-1',
senderId: 'user-1',
senderMobile: '09121234567',
senderFirstName: 'سارا',
senderLastName: 'احمدی',
senderAvatarUrl: null,
senderGender: 'female',
body: 'سلام، این یک پیام آزمایشی است.',
imageUrl: 'https://example.com/message.jpg',
createdAt: '2026-08-01T12:30:00.000Z',
}
describe('AdminMessageBubble', () => {
it('shows oversight identity and opens the shared image preview', () => {
render(
<AdminMessageBubble
isClusterEnd
isClusterStart
showAvatar
showSenderName
message={message}
side="primary"
/>
)
expect(screen.getByText('سارا احمدی')).toBeInTheDocument()
expect(screen.getByText('سلام، این یک پیام آزمایشی است.')).toBeInTheDocument()
expect(screen.getByText(/0912/)).toBeInTheDocument()
expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'مشاهده تصویر' }))
expect(screen.getByTestId('image-preview')).toHaveTextContent(message.imageUrl ?? '')
})
})

View File

@ -0,0 +1,131 @@
'use client'
import { useState } from 'react'
import ChatImagePreviewModal from '@/app/(dashboard)/chat-oversight/[id]/_components/ChatImagePreviewModal'
import Button from '@/components/formElements/Button'
import { formatPersonName } from '@/helpers'
import { cn } from '@/lib/cn'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import { resolveUserAvatarSrc } from '@/lib/resolveUserAvatarSrc'
import type { AdminChatMessage } from '@/services/adminChat'
interface AdminMessageBubbleProps {
message: AdminChatMessage
side: 'primary' | 'secondary'
showAvatar: boolean
showSenderName: boolean
isClusterStart: boolean
isClusterEnd: boolean
}
const formatTime = (value: string) =>
formatPersianDate(value, {
hour: '2-digit',
minute: '2-digit',
})
const AdminMessageBubble = ({ message, side, showAvatar, showSenderName, isClusterStart, isClusterEnd }: AdminMessageBubbleProps) => {
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null)
const isPrimary = side === 'primary'
const senderName = formatPersonName(message.senderFirstName, message.senderLastName, 'کاربر')
const senderMeta = message.senderMobile ? formatIranianMobile(message.senderMobile) : message.senderId
const avatarSrc = resolveUserAvatarSrc({
avatarUrl: message.senderAvatarUrl,
gender: message.senderGender,
})
return (
<div className={cn('flex w-full', isPrimary ? 'justify-start' : 'justify-end', isClusterStart ? 'mt-2' : 'mt-1')}>
<div className={cn('flex max-w-[88%] gap-2 sm:max-w-[75%]', isPrimary ? 'flex-row' : 'flex-row-reverse')}>
<div className="flex w-9 shrink-0 flex-col justify-end">
{showAvatar ? (
<div className="size-9 overflow-hidden rounded-full border border-consumer-border bg-consumer-surface-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
className="size-full object-cover"
decoding="async"
loading="lazy"
src={avatarSrc}
/>
</div>
) : (
<span className="size-9" />
)}
</div>
<div
className={cn(
'max-w-full px-3 py-2 text-sm',
isPrimary
? cn(
'bg-primary text-white',
isClusterStart && isClusterEnd
? 'rounded-2xl rounded-br-sm'
: isClusterStart
? 'rounded-2xl rounded-br-md rounded-bl-md'
: isClusterEnd
? 'rounded-2xl rounded-br-sm rounded-tl-md rounded-tr-md'
: 'rounded-md'
)
: cn(
'border border-consumer-border bg-consumer-surface text-consumer-text',
isClusterStart && isClusterEnd
? 'rounded-2xl rounded-bl-sm'
: isClusterStart
? 'rounded-2xl rounded-bl-md rounded-br-md'
: isClusterEnd
? 'rounded-2xl rounded-bl-sm rounded-tl-md rounded-tr-md'
: 'rounded-md'
)
)}
>
{showSenderName ? (
<div className="mb-1 leading-tight">
<p className={cn('text-sm font-bold', isPrimary ? 'text-white' : 'text-primary')}>{senderName}</p>
<p
className={cn('mt-1 text-[10px]', isPrimary ? 'text-primary-100' : 'text-default-400')}
dir="ltr"
>
{senderMeta}
</p>
</div>
) : null}
{message.body ? <p className="whitespace-pre-wrap break-words leading-relaxed">{message.body}</p> : null}
{message.imageUrl ? (
<Button
aria-label="مشاهده تصویر"
className="mt-1 h-auto min-h-0 w-full overflow-hidden rounded-lg p-0"
variant="light"
onClick={() => {
setPreviewImageUrl(message.imageUrl ?? null)
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt="پیوست"
className="max-h-52 w-full cursor-zoom-in rounded-lg object-cover"
decoding="async"
loading="lazy"
src={message.imageUrl}
/>
</Button>
) : null}
<div className={cn('mt-1 text-[10px]', isPrimary ? 'text-primary-100' : 'text-default-400')}>{formatTime(message.createdAt)}</div>
</div>
</div>
<ChatImagePreviewModal
imageUrl={previewImageUrl}
isOpen={Boolean(previewImageUrl)}
onOpenChange={(open) => {
if (!open) setPreviewImageUrl(null)
}}
/>
</div>
)
}
export default AdminMessageBubble

View File

@ -0,0 +1,139 @@
'use client'
/**
* ChatImagePreviewModal full-screen-ish preview + download for chat images.
*
* Purpose
* - Opens from `MessageBubble`; uses ConsumerModal. Download URL resolution
* stays local (guess filename / trusted upload URL).
*/
import Image from 'next/image'
import { useState } from 'react'
import ConsumerButton from '@/components/consumer/ConsumerButton'
import CloseIcon from '@/components/icons/CloseIcon'
import CloudDownloadIcon from '@/components/icons/CloudDownloadIcon'
import ConsumerModal from '@/components/consumer/ConsumerModal'
import { withBasePath } from '@/constants/images'
import { texts } from '@/texts'
import { addToast } from '@/lib/toast'
interface ChatImagePreviewModalProps {
imageUrl: string | null
isOpen: boolean
onOpenChange: (open: boolean) => void
}
const guessFileName = (imageUrl: string) => {
try {
const pathname = new URL(imageUrl, window.location.origin).pathname
const base = pathname.split('/').filter(Boolean).at(-1)
if (base && /\.[a-z0-9]{2,5}$/i.test(base)) return decodeURIComponent(base)
} catch {
// ignore invalid URL
}
return `chat-image-${Date.now()}.jpg`
}
const resolveDownloadUrl = (imageUrl: string) => {
try {
const target = new URL(imageUrl, window.location.origin)
if (target.origin === window.location.origin) return target.toString()
} catch {
return imageUrl
}
// Cross-origin file hosts (e.g. ghabilee.ir from localhost) often omit CORS
// for /uploads — proxy through Next so the browser sees a same-origin blob.
return `${withBasePath('/api/download-upload')}?url=${encodeURIComponent(imageUrl)}`
}
const ChatImagePreviewModal = ({ imageUrl, isOpen, onOpenChange }: ChatImagePreviewModalProps) => {
const [isDownloading, setIsDownloading] = useState(false)
const handleDownload = async () => {
if (!imageUrl || isDownloading) return
setIsDownloading(true)
try {
const response = await fetch(resolveDownloadUrl(imageUrl))
if (!response.ok) throw new Error('download failed')
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = guessFileName(imageUrl)
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(objectUrl)
} catch {
window.open(imageUrl, '_blank', 'noopener,noreferrer')
addToast({ title: texts.chats.downloadFallbackToast, color: 'warning' })
} finally {
setIsDownloading(false)
}
}
return (
<ConsumerModal
hideCloseButton
hideFooter
hideHeader
backdrop="blur"
bodyClassName="!p-0"
className="!max-h-[95dvh] border-0 bg-transparent p-0 shadow-none"
containerClassName="bg-transparent"
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<div className="relative flex min-h-[40vh] flex-col rounded-t-2xl bg-white">
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between gap-2 p-3">
<ConsumerButton
iconOnly
aria-label={texts.common.close}
className="min-h-11 min-w-11 bg-consumer-surface-muted text-consumer-text hover:bg-consumer-surface-muted/80"
fill="none"
size="sm"
onClick={() => {
onOpenChange(false)
}}
>
<CloseIcon className="size-5" />
</ConsumerButton>
<ConsumerButton
aria-label={texts.chats.downloadImageAria}
className="min-h-11 min-w-11 bg-consumer-surface-muted text-consumer-text hover:bg-consumer-surface-muted/80"
fill="none"
iconStart={!isDownloading ? <CloudDownloadIcon className="size-4" /> : undefined}
isLoading={isDownloading}
onClick={() => void handleDownload()}
>
{texts.chats.download}
</ConsumerButton>
</div>
<div className="relative min-h-[40vh] w-full flex-1">
{imageUrl ? (
<Image
fill
alt={texts.chats.chatAttachmentAlt}
className="object-contain p-3 pt-14"
sizes="100vw"
src={imageUrl}
/>
) : null}
</div>
</div>
</ConsumerModal>
)
}
export default ChatImagePreviewModal

View File

@ -0,0 +1,13 @@
interface DateSeparatorProps {
label: string
}
const DateSeparator = ({ label }: DateSeparatorProps) => (
<div className="flex justify-center py-3">
<span className="rounded-full border border-white/80 bg-white/65 px-3.5 py-1 text-xs font-medium text-secondary-20 shadow-sm backdrop-blur-sm">
{label}
</span>
</div>
)
export default DateSeparator

View File

@ -0,0 +1,238 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import DateSeparator from '@/app/(dashboard)/chat-oversight/[id]/_components/DateSeparator'
import Button from '@/components/formElements/Button'
import AdminState from '@/components/feedback/AdminState'
import { DetailSkeleton, ListSkeleton } from '@/components/feedback/LoadingState'
import PageNavbar from '@/components/layouts/PageNavbar'
import StatusChip from '@/components/ui/StatusChip'
import { APP_ROUTES } from '@/constants/routes'
import { groupMessagesForDisplay } from '@/features/chat/groupMessagesForDisplay'
import { formatPersonName } from '@/helpers'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import useAdminChatSocket from '@/hooks/useAdminChatSocket'
import {
GET_ADMIN_CONVERSATION,
GET_ADMIN_CONVERSATION_MESSAGES,
GET_ADMIN_CONVERSATION_PARTICIPANTS,
type AdminChatMessage,
} from '@/services/adminChat'
import AdminMessageBubble from './_components/AdminMessageBubble'
const ChatOversightDetailPage = () => {
const params = useParams<{ id: string }>()
const conversationId = params.id
const [liveMessages, setLiveMessages] = useState<AdminChatMessage[]>([])
const handleLiveMessage = useCallback((message: AdminChatMessage) => {
setLiveMessages((current) => (current.some((item) => item.id === message.id) ? current : [...current, message]))
}, [])
const socket = useAdminChatSocket(conversationId, handleLiveMessage)
const conversationQuery = useQuery({
queryKey: ['admin', 'chat-oversight', conversationId],
queryFn: () => GET_ADMIN_CONVERSATION(conversationId),
enabled: Boolean(conversationId),
})
const participantsQuery = useQuery({
queryKey: ['admin', 'chat-oversight', conversationId, 'participants'],
queryFn: () => GET_ADMIN_CONVERSATION_PARTICIPANTS(conversationId),
enabled: Boolean(conversationId),
})
const messagesQuery = useInfiniteQuery({
queryKey: ['admin', 'chat-oversight', conversationId, 'messages'],
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam }) => GET_ADMIN_CONVERSATION_MESSAGES(conversationId, pageParam ? { before: pageParam } : undefined),
getNextPageParam: (lastPage) => (lastPage.hasMoreBefore ? (lastPage.olderCursor ?? undefined) : undefined),
enabled: Boolean(conversationId),
})
const messages = useMemo(() => {
const seen = new Set<string>()
const history = [...(messagesQuery.data?.pages ?? [])].reverse().flatMap((page) => page.items)
return [...history, ...liveMessages].filter((message) => {
if (seen.has(message.id)) return false
seen.add(message.id)
return true
})
}, [liveMessages, messagesQuery.data])
const displayItems = useMemo(() => groupMessagesForDisplay(messages, { showSenderNames: true }), [messages])
const conversation = conversationQuery.data
const participants = useMemo(() => participantsQuery.data ?? [], [participantsQuery.data])
const participantSideById = useMemo(
() => new Map(participants.map((participant, index) => [participant.userId, index % 2 === 0 ? 'primary' : 'secondary'] as const)),
[participants]
)
const title = conversation
? conversation.type === 'event_group'
? conversation.eventTitle || 'گروه رویداد'
: participants
.map((participant) => formatPersonName(participant.firstName, participant.lastName, formatIranianMobile(participant.mobile)))
.join(' و ') || 'گفتگوی خصوصی'
: 'جزئیات گفتگو'
const loadError = conversationQuery.error ?? participantsQuery.error ?? messagesQuery.error
const isInitialLoading = conversationQuery.isLoading || participantsQuery.isLoading || messagesQuery.isLoading
return (
<section className="h-full w-full text-right">
<PageNavbar
description="حالت نظارت مخفی و فقط‌خواندنی"
endSlot={
<Button
size="sm"
to={APP_ROUTES.CHAT_OVERSIGHT}
variant="flat"
>
بازگشت به گفتگوها
</Button>
}
pageTitle={title}
/>
<div className="admin-page-container flex flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-fourth-100 bg-fourth-100 px-4 py-3">
<div>
<p className="font-bold text-fourth-900">حالت نظارت فقط خواندنی</p>
<p className="mt-1 text-xs leading-6 text-fourth-900">
مشاهده شما عضویت، اعلان یا تغییر وضعیت خواندهشدن برای کاربران ایجاد نمیکند و در گزارش فعالیت ادمین ثبت میشود.
</p>
</div>
{conversation ? (
<div className="flex items-center gap-2">
<StatusChip
chipColor={socket.status === 'connected' ? 'success' : socket.status === 'error' ? 'danger' : 'warning'}
label={socket.status === 'connected' ? 'اتصال زنده' : socket.status === 'error' ? 'خطای سوکت' : 'در حال اتصال'}
/>
<StatusChip
chipColor={conversation.type === 'event_group' ? 'warning' : 'default'}
label={conversation.type === 'event_group' ? 'گروه رویداد' : 'گفتگوی خصوصی'}
/>
</div>
) : null}
</div>
{socket.error ? (
<p className="rounded-xl border border-fourth-100 bg-fourth-100 px-3 py-2 text-xs text-fourth-900">
{socket.error} پیامهای ذخیرهشده همچنان از طریق API قابل مشاهدهاند.
</p>
) : null}
{isInitialLoading ? (
<>
<DetailSkeleton />
<ListSkeleton count={6} />
</>
) : null}
{!isInitialLoading && loadError ? (
<div className="admin-surface">
<AdminState
actionLabel="تلاش دوباره"
description={loadError instanceof Error ? loadError.message : 'دریافت اطلاعات گفتگو ناموفق بود.'}
title="خطا در دریافت گفتگو"
variant="error"
onAction={() => {
void conversationQuery.refetch()
void participantsQuery.refetch()
void messagesQuery.refetch()
}}
/>
</div>
) : null}
{!isInitialLoading && !loadError && conversation ? (
<>
<section className="admin-surface p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-bold text-foreground">اعضای گفتگو</h2>
<span className="text-xs text-muted">{conversation.memberCount.toLocaleString('fa-IR')} نفر</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{participants.map((participant) => (
<div
key={participant.userId}
className="rounded-xl border border-border bg-surface-secondary px-3 py-2"
>
<p className="text-sm font-semibold">{formatPersonName(participant.firstName, participant.lastName, 'کاربر')}</p>
<p
className="mt-1 text-xs text-muted"
dir="ltr"
>
{formatIranianMobile(participant.mobile)}
</p>
</div>
))}
</div>
</section>
<section className="admin-surface p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-bold text-foreground">پیامهای گفتگو</h2>
{conversation.lastMessageAt ? (
<span className="text-xs text-muted">آخرین فعالیت: {formatPersianDate(conversation.lastMessageAt)}</span>
) : null}
</div>
{messagesQuery.hasNextPage ? (
<div className="mb-4 flex justify-center">
<Button
isLoading={messagesQuery.isFetchingNextPage}
size="sm"
variant="flat"
onClick={() => void messagesQuery.fetchNextPage()}
>
بارگذاری پیامهای قدیمیتر
</Button>
</div>
) : null}
{messages.length === 0 ? (
<AdminState
description="هنوز پیامی در این گفتگو ثبت نشده است."
title="گفتگو بدون پیام است"
/>
) : (
<div className="rounded-2xl border border-consumer-border bg-consumer-canvas px-3 py-4 sm:px-6">
<div className="mx-auto flex w-full max-w-3xl flex-col">
{displayItems.map((item) => {
if (item.kind === 'date') {
return (
<DateSeparator
key={item.key}
label={item.label}
/>
)
}
return (
<AdminMessageBubble
key={item.key}
isClusterEnd={item.isClusterEnd}
isClusterStart={item.isClusterStart}
message={item.message}
showAvatar={item.showAvatar}
showSenderName={item.showSenderName}
side={participantSideById.get(item.message.senderId) ?? 'primary'}
/>
)
})}
</div>
</div>
)}
</section>
</>
) : null}
</div>
</section>
)
}
export default ChatOversightDetailPage

View File

@ -0,0 +1,134 @@
'use client'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import { APP_ROUTES } from '@/constants/routes'
import { formatPersonName, coerceToString } from '@/helpers'
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
import { API_ROUTES } from '@/services/config'
import type { AdminConversation } from '@/services/adminChat'
const CONVERSATION_TYPE_FILTER_ITEMS = [
{ code: 'event_group', name: 'گروه رویداد' },
{ code: 'direct', name: 'گفتگوی خصوصی' },
]
const columns: PaginationListColumnType[] = [
{
field: 'type',
label: 'نوع گفتگو',
filterable: true,
sortable: false,
type: 'select',
filterItems: CONVERSATION_TYPE_FILTER_ITEMS,
},
{
field: 'search',
label: 'رویداد یا کاربران',
filterable: true,
sortable: false,
type: 'text',
},
{
field: 'eventId',
label: 'شناسه رویداد',
filterable: true,
hideInTable: true,
sortable: false,
type: 'text',
},
{
field: 'userId',
label: 'شناسه کاربر',
filterable: true,
hideInTable: true,
sortable: false,
type: 'text',
},
{
field: 'preview',
label: 'آخرین پیام',
filterable: false,
sortable: false,
},
{
field: 'memberCount',
label: 'اعضا',
filterable: false,
sortable: false,
},
{
field: 'lastMessageAt',
label: 'آخرین فعالیت',
filterable: false,
sortable: true,
},
{
field: 'createdAt',
label: 'تاریخ ایجاد',
filterable: true,
sortable: true,
type: 'dateFromTo',
},
{ field: 'actions', label: 'عملیات' },
]
const participantLabel = (conversation: AdminConversation) => {
if (conversation.type === 'event_group') return conversation.eventTitle || 'گروه رویداد'
return conversation.participantPreview
.map((participant) => formatPersonName(participant.firstName, participant.lastName, formatIranianMobile(participant.mobile)))
.join(' و ')
}
const ChatOversightPage = () => (
<section className="h-full w-full text-right">
<PageNavbar
description="مشاهده فقط‌خواندنی گفتگوهای گروهی و خصوصی؛ تمام دسترسی‌ها ثبت می‌شوند"
pageTitle="نظارت بر گفتگوها"
/>
<div className="admin-page-container">
<div className="mb-4 rounded-2xl border border-fourth-100 bg-fourth-100 px-4 py-3 text-sm text-fourth-900">
این بخش فقط برای نظارت است. حضور ادمین در اعضای گفتگو نمایش داده نمیشود و امکان ارسال یا تغییر پیام وجود ندارد.
</div>
<PaginatedList
columns={columns}
url={API_ROUTES.CHAT_OVERSIGHT.ADMIN_LIST}
>
{{
type: (row) => {
const conversation = row as unknown as AdminConversation
return (
<StatusChip
chipColor={conversation.type === 'event_group' ? 'warning' : 'default'}
label={conversation.type === 'event_group' ? 'گروه رویداد' : 'گفتگوی خصوصی'}
/>
)
},
search: (row) => participantLabel(row as unknown as AdminConversation),
preview: (_row, value) => truncateValue(coerceToString(value, '—')),
memberCount: (_row, value) => Number(value ?? 0).toLocaleString('fa-IR'),
lastMessageAt: (_row, value) => (value ? formatPersianDate(value) : 'بدون پیام'),
createdAt: (_row, value) => formatPersianDate(value),
actions: (row) => {
const conversation = row as unknown as AdminConversation
return (
<AdminTableViewButton
label="مشاهده گفتگو"
mode="navigate"
to={APP_ROUTES.CHAT_OVERSIGHT_DETAIL(conversation.id)}
/>
)
},
}}
</PaginatedList>
</div>
</section>
)
export default ChatOversightPage

View File

@ -0,0 +1,99 @@
'use client'
import { useEffect, useState } from 'react'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import { TableSkeleton } from '@/components/feedback/LoadingState'
import { fetchAllCities, type City } from '@/services/geography'
const columns: PaginationListColumnType[] = [
{
field: 'name',
label: 'نام شهر',
filterable: false,
type: 'text',
sortable: false,
},
// ستون استان فعلاً نمایش داده نمی‌شود؛ مدیریت موقعیت فقط بر اساس شهر است.
// {
// field: 'provinceId',
// label: 'شناسه استان',
// filterable: false,
// type: 'number',
// sortable: false,
// },
{
field: 'lat',
label: 'عرض جغرافیایی',
filterable: false,
type: 'number',
sortable: false,
},
{
field: 'lng',
label: 'طول جغرافیایی',
filterable: false,
type: 'number',
sortable: false,
},
]
const CitiesPage = () => {
const [cities, setCities] = useState<City[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let isMounted = true
const loadCities = async () => {
setIsLoading(true)
try {
const rows = await fetchAllCities()
if (isMounted) setCities(rows)
} finally {
if (isMounted) setIsLoading(false)
}
}
void loadCities()
return () => {
isMounted = false
}
}, [])
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="شهرها" />
<div className="admin-page-container space-y-4">
{/*
GET /geography/cities and GET /geography/provinces/:id/cities are
both flat/unpaginated, with no server-side filtering -- this
native select re-fetches a different flat list on change instead
of relying on PaginatedList's (server-driven) filter row, which
has nothing to talk to here. Table columns are non-filterable/
non-sortable for the same reason. The pre-disable province-filter
dropdown (a native <select> bound to `selectedProvinceId`) is
preserved in git history (blame this file), not kept commented
out here.
*/}
{isLoading ? (
<div className="admin-surface overflow-hidden">
<TableSkeleton />
</div>
) : (
<PaginatedList
columns={columns}
staticData={cities}
/>
)}
</div>
</section>
)
}
export default CitiesPage

View File

@ -0,0 +1,204 @@
'use client'
import { useState } from 'react'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import FileCheckIcon from '@/components/icons/FileCheckIcon'
import Modal from '@/components/modals/Modal'
import AdminTableActions from '@/components/ui/AdminTableActions'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import axiosInstance from '@/config/axios'
import useAdminMutation from '@/hooks/useAdminMutation'
import { coerceToString } from '@/helpers'
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
import { getBooleanStatus } from '@/constants/status'
import { API_ROUTES } from '@/services/config'
interface ContactMessageRow {
id: string
fullName: string
mobile: string
subject: string
message: string
readAt?: string | null
readBy?: string | null
createdAt: string
[key: string]: unknown
}
const columns: PaginationListColumnType[] = [
{
field: 'fullName',
label: 'نام',
filterable: false,
sortable: false,
},
{
field: 'mobile',
label: 'شماره تماس',
filterable: false,
sortable: false,
},
{
field: 'subject',
label: 'موضوع',
filterable: false,
sortable: false,
},
{
field: 'message',
label: 'پیام',
filterable: false,
sortable: false,
},
{
field: 'readStatus',
label: 'وضعیت',
filterable: true,
sortable: false,
type: 'select',
filterItems: [
{ code: 'unread', name: 'خوانده‌نشده' },
{ code: 'read', name: 'خوانده‌شده' },
],
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: false,
sortable: true,
},
{
field: 'actions',
label: 'عملیات',
},
]
const ContactMessagesPage = () => {
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST })
const [selected, setSelected] = useState<ContactMessageRow | null>(null)
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="پیام‌های تماس" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST}
>
{{
fullName: (_row, cellValue) => coerceToString(cellValue),
mobile: (_row, cellValue) => <span dir="ltr">{formatIranianMobile(coerceToString(cellValue))}</span>,
subject: (_row, cellValue) => coerceToString(cellValue),
message: (row, cellValue) => {
const message = row as ContactMessageRow
const body = coerceToString(cellValue)
return (
<div className="flex flex-col gap-1">
<span className="text-sm">{truncateValue(body)}</span>
{body.length > 80 ? (
<Button
className="w-fit"
size="sm"
variant="light"
onClick={() => {
setSelected(message)
}}
>
نمایش کامل
</Button>
) : null}
</div>
)
},
readStatus: (row) => {
const message = row as ContactMessageRow
const { label, chipColor } = getBooleanStatus(Boolean(message.readAt), {
true: 'خوانده‌شده',
false: 'خوانده‌نشده',
})
return (
<StatusChip
chipColor={chipColor}
label={label}
/>
)
},
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const message = row as ContactMessageRow
return (
<AdminTableActions>
<AdminTableViewButton
label="مشاهده پیام"
mode="open-detail"
onClick={() => {
setSelected(message)
}}
/>
{message.readAt ? null : (
<Button
iconOnly
aria-label="علامت‌گذاری به‌عنوان خوانده‌شده"
isLoading={pendingId === message.id}
size="sm"
variant="light"
onClick={() =>
void runAction(
message.id,
() => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)),
'پیام خوانده شد'
)
}
>
<FileCheckIcon className="size-5" />
</Button>
)}
</AdminTableActions>
)
},
}}
</PaginatedList>
</div>
<Modal
hideFooter
isOpen={Boolean(selected)}
size="lg"
title={selected?.subject ?? 'پیام تماس'}
onOpenChange={(open) => {
if (!open) setSelected(null)
}}
>
{selected ? (
<div className="space-y-4 text-sm">
<div>
<p className="font-bold">فرستنده</p>
<p className="mt-1">{selected.fullName}</p>
<p
className="mt-1 text-text-muted"
dir="ltr"
>
{formatIranianMobile(selected.mobile)}
</p>
</div>
<div>
<p className="font-bold">پیام</p>
<p className="mt-1 whitespace-pre-wrap leading-relaxed">{selected.message}</p>
</div>
<p className="text-text-muted">{formatPersianDate(selected.createdAt)}</p>
</div>
) : null}
</Modal>
</section>
)
}
export default ContactMessagesPage

View File

@ -0,0 +1,434 @@
'use client'
import type { ReactNode } from 'react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Card, CardBody, CardHeader } from '@/components/heroui/Card'
import PageNavbar from '@/components/layouts/PageNavbar'
import { DetailSkeleton, TableSkeleton } from '@/components/feedback/LoadingState'
import AdminState from '@/components/feedback/AdminState'
import { GET_ADMIN_REPORTS_OVERVIEW, type AdminReportsOverview } from '@/services/adminReports'
import { APP_ROUTES } from '@/constants/routes'
import { formatNumber as formatNumberBase } from '@/helpers'
import UserFillIcon from '@/components/icons/UserFillIcon'
import CalendarIcon from '@/components/icons/CalendarIcon'
import BookmarkIcon from '@/components/icons/BookmarkIcon'
import UserIdCardIcon from '@/components/icons/UserIdCardIcon'
import CoinDollarIcon from '@/components/icons/CoinDollarIcon'
import AngleLeftIcon from '@/components/icons/AngleLeftIcon'
import PlusIcon from '@/components/icons/PlusIcon'
interface StatItem {
label: string
value: number | string
href?: string
}
type KpiItem = Omit<StatItem, 'href'> & {
href: string
icon: ReactNode
}
const formatNumber = (value: number | string) => {
const num = typeof value === 'string' ? Number(value) : value
if (!Number.isFinite(num)) return String(value)
return formatNumberBase(num)
}
const StatCell = ({ item }: { item: StatItem }) => {
const content = (
<>
<span className="text-sm leading-5 text-secondary-30 md:text-xs">{item.label}</span>
<span className="flex items-center gap-1 text-xl font-bold text-secondary-10 transition-colors group-hover:text-primary-600 md:text-2xl">
{formatNumber(item.value)}
{item.href ? (
<AngleLeftIcon className="size-4 text-secondary-30 transition-transform group-hover:-translate-x-1 group-hover:text-primary-500" />
) : null}
</span>
</>
)
const className =
'group flex h-full min-h-24 flex-col justify-center gap-2 bg-white p-3 text-right transition-colors hover:bg-secondary-50/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500'
return item.href ? (
<Link
aria-label={`${item.label}: ${formatNumber(item.value)}`}
className={className}
href={item.href}
>
{content}
</Link>
) : (
<div className={className}>{content}</div>
)
}
const KpiCard = ({ item }: { item: KpiItem }) => (
<Link
className="group flex min-w-0 items-center gap-3 rounded-2xl border border-white/10 bg-white/10 p-3 text-white backdrop-blur-sm transition-colors hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
href={item.href}
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-white/15 transition-transform group-hover:scale-105">
{item.icon}
</span>
<div className="min-w-0">
<p className="truncate text-[10px] text-white/65 sm:text-xs">{item.label}</p>
<p className="mt-1 text-lg font-bold sm:text-xl">{formatNumber(item.value)}</p>
</div>
</Link>
)
const StatGroup = ({ title, description, icon, items }: { title: string; description: string; icon: ReactNode; items: StatItem[] }) => (
<Card className="admin-surface flex h-full flex-col gap-0 overflow-hidden p-0">
<CardHeader className="!flex-row flex w-full items-center justify-start gap-3 border-b border-secondary-40 px-5 py-4 text-right">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary-50 text-primary-600">{icon}</span>
<div className="min-w-0 text-right">
<h2 className="text-sm font-bold text-secondary-10 md:text-base">{title}</h2>
<p className="mt-1 text-sm text-secondary-30">{description}</p>
</div>
</CardHeader>
<CardBody className="flex flex-1 flex-col gap-0 bg-secondary-40 p-0">
<div
className={
items.length <= 2
? 'grid h-full auto-rows-fr grid-cols-1 gap-px sm:grid-cols-2'
: 'grid h-full auto-rows-fr grid-cols-2 gap-px lg:grid-cols-4'
}
>
{items.map((item) => (
<StatCell
key={item.label}
item={item}
/>
))}
</div>
</CardBody>
</Card>
)
const ActionQueue = ({ overview }: { overview: AdminReportsOverview }) => {
const items = [
{
label: 'احراز هویت نیازمند بررسی',
value: overview.identityVerifications.pending,
href: `${APP_ROUTES.IDENTITY_VERIFICATIONS}?filters[status]=pending`,
},
{
label: 'رویداد در انتظار بررسی',
value: overview.events.byStatus.pending_review,
href: `${APP_ROUTES.MANAGE_EVENTS}?filters[status]=pending_review`,
},
{
label: 'رزرو در انتظار پرداخت',
value: overview.bookings.byStatus.pending_payment,
href: `${APP_ROUTES.BOOKINGS}?filters[status]=pending_payment`,
},
]
const total = items.reduce((sum, item) => sum + item.value, 0)
return (
<Card className="admin-surface flex h-full flex-col gap-0 overflow-hidden p-0">
<CardHeader className="!flex-row flex w-full items-center justify-between gap-3 border-b border-separator px-5 py-4 text-right">
<div className="min-w-0 text-right">
<h2 className="text-sm font-bold text-foreground md:text-base">صف اقدامات امروز</h2>
<p className="mt-1 text-sm text-muted">مواردی که برای ادامه فرایند به توجه ادمین نیاز دارند</p>
</div>
<span
aria-label={`${formatNumber(total)} اقدام باز`}
className="shrink-0 rounded-full bg-fourth-100 px-3 py-1 text-xs font-bold text-fourth-900"
>
{formatNumber(total)} باز
</span>
</CardHeader>
<CardBody className="flex flex-1 flex-col p-3">
<div className="grid h-full flex-1 grid-cols-3 gap-2">
{items.map((item) => (
<Link
key={item.label}
className="group flex h-full min-w-0 items-center justify-between gap-2 rounded-2xl border border-border bg-surface-secondary p-3 transition-colors hover:border-accent hover:bg-accent-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus"
href={item.href}
>
<div className="min-w-0 text-right">
<p className="text-sm leading-4 text-muted sm:text-xs sm:leading-5">{item.label}</p>
<p className="mt-1 text-lg font-bold text-foreground sm:text-xl">{formatNumber(item.value)}</p>
</div>
<AngleLeftIcon className="size-4 shrink-0 text-muted transition-transform group-hover:-translate-x-1 group-hover:text-accent sm:size-5" />
</Link>
))}
</div>
</CardBody>
</Card>
)
}
const DashboardPage = () => {
const [overview, setOverview] = useState<AdminReportsOverview | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => {
let cancelled = false
const load = async () => {
setIsLoading(true)
setError(null)
const result = await GET_ADMIN_REPORTS_OVERVIEW()
if (cancelled) return
if (!result.ok) {
setOverview(null)
setError(result.error?.message ?? 'دریافت گزارش‌ها ناموفق بود')
} else {
setOverview(result.data)
}
setIsLoading(false)
}
void load()
return () => {
cancelled = true
}
}, [reloadKey])
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="پیشخوان" />
<div className="admin-page-container flex flex-col gap-5">
{isLoading && (
<div className="flex flex-col gap-5">
<DetailSkeleton />
<div className="admin-surface overflow-hidden">
<TableSkeleton rows={5} />
</div>
</div>
)}
{!isLoading && error && (
<div className="admin-surface overflow-hidden">
<AdminState
actionLabel="تلاش دوباره"
description={error}
title="دریافت نمای کلی ناموفق بود"
variant="error"
onAction={() => {
setReloadKey((current) => current + 1)
}}
/>
</div>
)}
{!isLoading && overview && (
<>
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-l from-[#082e70] via-primary-700 to-primary-600 px-5 py-6 text-white shadow-xl shadow-primary-900/10 sm:px-7 sm:py-8">
<div className="absolute -start-12 -top-20 size-52 rounded-full bg-white/10 blur-2xl" />
<div className="relative">
<p className="text-xs font-medium text-white/70">نمای کلی سامانه</p>
<h2 className="mt-2 text-xl font-bold sm:text-2xl">سلام، به پنل مدیریت قبیله خوش آمدید</h2>
<p className="mt-2 max-w-2xl text-xs leading-6 text-white/70">
مهمترین شاخصهای کاربران، رویدادها، رزروها و عملیات مالی را در یک نگاه بررسی کنید.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Link
className="inline-flex min-h-9 items-center gap-2 rounded-xl bg-white px-3 text-xs font-semibold text-primary-700 transition-colors hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
href={APP_ROUTES.MANAGE_EVENT_NEW}
>
<PlusIcon className="size-4" />
ایجاد رویداد
</Link>
<Link
className="inline-flex min-h-9 items-center rounded-xl border border-white/20 bg-white/10 px-3 text-xs font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
href={`${APP_ROUTES.IDENTITY_VERIFICATIONS}?filters[status]=pending`}
>
بررسی احرازهای در انتظار
</Link>
</div>
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
{(
[
{
label: 'کل کاربران',
value: overview.users.total,
icon: <UserFillIcon className="size-5" />,
href: APP_ROUTES.USERS,
},
{
label: 'رویدادهای فعال',
value: overview.events.active,
icon: <CalendarIcon className="size-5" />,
href: `${APP_ROUTES.MANAGE_EVENTS}?filters[status]=published`,
},
{
label: 'کل رزروها',
value: overview.bookings.total,
icon: <BookmarkIcon className="size-5" />,
href: APP_ROUTES.BOOKINGS,
},
{
label: 'در انتظار احراز',
value: overview.identityVerifications.pending,
icon: <UserIdCardIcon className="size-5" />,
href: `${APP_ROUTES.IDENTITY_VERIFICATIONS}?filters[status]=pending`,
},
] satisfies KpiItem[]
).map((item) => (
<KpiCard
key={item.label}
item={item}
/>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 items-stretch gap-5 md:grid-cols-2">
<ActionQueue overview={overview} />
<StatGroup
description="شاخص‌های کلیدی گردش مالی پلتفرم"
icon={<CoinDollarIcon className="size-5" />}
items={[
{
label: 'کل کمیسیون پلتفرم (تومان)',
value: overview.financial.totalCommissionCollected,
href: APP_ROUTES.SETTLEMENTS,
},
{
label: 'کل موجودی کیف‌پول‌ها (تومان)',
value: overview.financial.totalWalletBalance,
href: APP_ROUTES.WITHDRAWAL_REQUESTS,
},
]}
title="مالی"
/>
<StatGroup
description="آمار انتشار و برگزاری رویدادها"
icon={<CalendarIcon className="size-5" />}
items={[
{ label: 'کل رویدادها', value: overview.events.total },
// «فعال» شامل هر دو وضعیت published و full است، اما فیلتر لیست
// رویدادها تک‌مقداری است (کاما در PaginatedList به‌عنوان بازه‌ی
// from/to تفسیر می‌شود، نه چند مقدار) — طبق تصمیم کاربر به published
// لینک می‌شود؛ رکورد‌های full را می‌توان جدا از ردیف «تکمیل ظرفیت» دید.
{
label: 'منتشر‌ یا تکمیل ظرفیت',
value: overview.events.active,
href: `${APP_ROUTES.MANAGE_EVENTS}?filters[status]=published`,
},
{ label: 'کل برگذار‌شده', value: overview.events.totalHeld },
{
label: 'پیش‌نویس',
value: overview.events.byStatus.draft,
href: `${APP_ROUTES.MANAGE_EVENTS}?filters[status]=draft`,
},
{
label: 'در انتظار بررسی',
value: overview.events.byStatus.pending_review,
href: `${APP_ROUTES.MANAGE_EVENTS}?filters[status]=pending_review`,
},
{ label: 'منتشر‌شده', value: overview.events.byStatus.published },
{ label: 'تکمیل ظرفیت', value: overview.events.byStatus.full },
{ label: 'لغو‌شده', value: overview.events.byStatus.cancelled },
]}
title="رویدادها"
/>
<StatGroup
description="وضعیت رزروهای ثبت‌شده در سامانه"
icon={<BookmarkIcon className="size-5" />}
items={[
{ label: 'کل رزروها', value: overview.bookings.total },
// «فعال» = pending_payment + confirmed (رزروهای زنده‌ای که ظرفیت را نگه می‌دارند).
// فیلتر لیست تک‌مقداری است؛ مثل رویدادهای فعال به confirmed لینک می‌شود.
{
label: 'فعال',
value: overview.bookings.byStatus.pending_payment + overview.bookings.byStatus.confirmed,
href: `${APP_ROUTES.BOOKINGS}?filters[status]=confirmed`,
},
{
label: 'در انتظار پرداخت',
value: overview.bookings.byStatus.pending_payment,
href: `${APP_ROUTES.BOOKINGS}?filters[status]=pending_payment`,
},
{
label: 'تأیید‌شده',
value: overview.bookings.byStatus.confirmed,
href: `${APP_ROUTES.BOOKINGS}?filters[status]=confirmed`,
},
{ label: 'لغو‌شده', value: overview.bookings.byStatus.cancelled },
{ label: 'منقضی‌شده', value: overview.bookings.byStatus.expired },
{ label: 'بازپرداخت‌شده', value: overview.bookings.byStatus.refunded },
{ label: 'عدم حضور', value: overview.bookings.byStatus.no_show },
]}
title="رزروها"
/>
<StatGroup
description="وضعیت حساب‌ها و فعالیت کاربران"
icon={<UserFillIcon className="size-5" />}
items={[
{ label: 'کل کاربران', value: overview.users.total },
{
label: 'فعال',
value: overview.users.active,
href: `${APP_ROUTES.USERS}?filters[status]=active`,
},
{
label: 'در انتظار تکمیل پروفایل',
value: overview.users.pending,
href: `${APP_ROUTES.USERS}?filters[status]=pending`,
},
{
label: 'معلق',
value: overview.users.suspended,
href: `${APP_ROUTES.USERS}?filters[status]=suspended`,
},
]}
title="کاربران"
/>
<StatGroup
description="فرآیند بررسی و تأیید هویت میزبان‌ها"
icon={<UserIdCardIcon className="size-5" />}
items={[
{
label: 'بدون احراز',
value: overview.identityVerifications.none,
href: `${APP_ROUTES.USERS}?filters[identityStatus]=none`,
},
{
label: 'در انتظار',
value: overview.identityVerifications.pending,
href: `${APP_ROUTES.IDENTITY_VERIFICATIONS}?filters[status]=pending`,
},
{
label: 'تأیید‌شده',
value: overview.identityVerifications.verified,
href: `${APP_ROUTES.USERS}?filters[identityStatus]=verified`,
},
{
label: 'رد‌شده',
value: overview.identityVerifications.rejected,
href: `${APP_ROUTES.USERS}?filters[identityStatus]=rejected`,
},
]}
title="احراز هویت"
/>
</div>
</>
)}
</div>
</section>
)
}
export default DashboardPage

View File

@ -0,0 +1,157 @@
'use client'
import { useEffect, useState } from 'react'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import { APP_ROUTES } from '@/constants/routes'
import { EVENT_STATUS_FILTER_ITEMS, getEventStatus } from '@/constants/status'
import { formatCurrency, formatPersonName, coerceToString } from '@/helpers'
import { formatPersianDate } from '@/lib/formatters'
import { API_ROUTES } from '@/services/config'
interface EventOrganizer {
id: string
firstName: string | null
lastName: string | null
}
interface EventRow {
id: string
title: string
status: string
isFree: boolean
price: number
endsAt: string
organizer?: EventOrganizer
[key: string]: unknown
}
/**
* Admin entry point for per-event discount codes. Codes are always scoped to
* one paid event this page lists those events and deep-links into the
* discounts tab on the event detail page (where create / report / delete live).
*/
const columns: PaginationListColumnType[] = [
{
field: 'title',
label: 'رویداد',
filterable: true,
sortable: true,
type: 'text',
},
{
field: 'organizerId',
label: 'میزبان',
filterable: false,
sortable: false,
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
sortable: true,
type: 'select',
filterItems: EVENT_STATUS_FILTER_ITEMS,
},
{
field: 'price',
label: 'قیمت',
filterable: false,
sortable: true,
},
{
field: 'endsAt',
label: 'پایان',
filterable: false,
sortable: true,
},
{
field: 'actions',
label: 'عملیات',
},
]
const DiscountCodesAdminPage = () => {
const [currentTime, setCurrentTime] = useState<number | null>(null)
useEffect(() => {
setCurrentTime(Date.now())
}, [])
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="کد تخفیف" />
<div className="admin-page-container space-y-4">
<p className="rounded-2xl border border-secondary-40 bg-white px-4 py-3 text-sm leading-7 text-secondary-30">
کد تخفیف فقط برای رویدادهای <strong>پولی</strong> تعریف میشود. رویداد مورد نظر را انتخاب کنید و از تب «کد تخفیف» کد بسازید،
غیرفعال کنید یا گزارش مصرف را ببینید. هنگام ساخت میتوانید هزینه را از سهم پلتفرم یا میزبان کم کنید.
</p>
<PaginatedList
columns={columns}
url={API_ROUTES.EVENTS.ADMIN_LIST}
urlParams={{
page: 1,
pageSize: 20,
sort: '',
filters: { isFree: 'false' },
}}
>
{{
organizerId: (row) => {
const event = row as EventRow
const organizer = event.organizer
return formatPersonName(organizer?.firstName, organizer?.lastName)
},
status: (_row, cellValue) => {
const { label, chipColor } = getEventStatus(coerceToString(cellValue))
return (
<StatusChip
chipColor={chipColor}
label={label}
/>
)
},
price: (row, cellValue) => {
const event = row as EventRow
const amount = typeof cellValue === 'number' ? cellValue : Number(cellValue)
if (event.isFree || !Number.isFinite(amount)) return '—'
return formatCurrency(amount)
},
endsAt: (row, cellValue) => {
const event = row as EventRow
const ended = currentTime !== null && new Date(event.endsAt).getTime() <= currentTime
return (
<span className={ended ? 'text-fourth-700' : undefined}>
{formatPersianDate(cellValue)}
{ended ? ' (پایان‌یافته)' : ''}
</span>
)
},
actions: (row) => {
const event = row as EventRow
return (
<AdminTableViewButton
label="مدیریت کد تخفیف"
mode="navigate"
to={APP_ROUTES.MANAGE_EVENT_DISCOUNTS(event.id)}
/>
)
},
}}
</PaginatedList>
</div>
</section>
)
}
export default DiscountCodesAdminPage

19
app/(dashboard)/error.tsx Normal file
View File

@ -0,0 +1,19 @@
'use client'
import { useEffect } from 'react'
import { RouteErrorView } from '@/components/feedback/RouteErrorView'
import { reportClientError } from '@/lib/observability/client'
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
reportClientError(error, 'dashboard')
}, [error])
return (
<RouteErrorView
digest={error.digest}
onRetry={reset}
/>
)
}

View File

@ -0,0 +1,288 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { FormProvider, useWatch } from 'react-hook-form'
import { Accordion, AccordionItem } from '@/components/heroui/Accordion'
import Modal from '@/components/modals/Modal'
import Input from '@/components/formElements/Input'
import { AdminFormSection } from '@/components/forms/AdminFormLayout'
import { SeoCharCounterHint } from '@/components/forms/SeoCharCounterHint'
import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator'
import useAdminCrudFormModal from '@/hooks/useAdminCrudFormModal'
import { CREATE_CATEGORY, type EventCategory, UPDATE_CATEGORY } from '@/services/eventCategories'
import {
CATEGORY_META_DESCRIPTION_MAX,
CATEGORY_META_KEYWORDS_MAX,
CATEGORY_META_TITLE_MAX,
CategoryFormValidation,
type CategoryFormValues,
} from '@/validation/eventCategories'
const EMPTY_VALUES: CategoryFormValues = {
name: '',
slug: '',
shortDescription: '',
description: '',
icon: '',
color: '',
metaTitle: '',
metaDescription: '',
metaKeywords: '',
ogImageUrl: '',
sortOrder: 0,
isActive: true,
isFeatured: false,
}
const toFormValues = (category: EventCategory): CategoryFormValues => ({
name: category.name,
slug: category.slug,
shortDescription: category.shortDescription ?? '',
description: category.description ?? '',
icon: category.icon ?? '',
color: category.color ?? '',
metaTitle: category.metaTitle ?? '',
metaDescription: category.metaDescription ?? '',
metaKeywords: category.metaKeywords ?? '',
ogImageUrl: category.ogImageUrl ?? '',
sortOrder: category.sortOrder,
isActive: category.isActive,
isFeatured: category.isFeatured,
})
const buildPayload = (values: CategoryFormValues) => ({
name: values.name,
slug: values.slug,
shortDescription: values.shortDescription || undefined,
description: values.description || undefined,
icon: values.icon || undefined,
color: values.color || undefined,
metaTitle: values.metaTitle || undefined,
metaDescription: values.metaDescription || undefined,
metaKeywords: values.metaKeywords || undefined,
ogImageUrl: values.ogImageUrl || undefined, // empty string intentional falsy
sortOrder: values.sortOrder,
isActive: values.isActive,
isFeatured: values.isFeatured,
})
/**
* "مشاهده صفحه" preview link `/category/{slug}`. Hard-coded path
* string per saza3 task prompt: `SEO_ROUTES.CATEGORY_LANDING` belongs to
* saza1's foundation (`feat/seo-foundation`, not yet merged) swap this
* to `SEO_ROUTES.CATEGORY_LANDING(categorySlug)` once that lands.
*/
const SeoPreviewLink = () => {
const categorySlug = useWatch<CategoryFormValues, 'slug'>({ name: 'slug' })
if (!categorySlug) return null
return (
<a
className="text-sm text-primary underline"
href={`/category/${categorySlug}`}
rel="noreferrer"
target="_blank"
>
مشاهده صفحه
</a>
)
}
interface CategoryFormModalProps {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
/** Present when editing; absent when creating a new node. */
category?: EventCategory
/**
* Parent to create the new node under, or null for a root category.
* Only used in create mode this form does not support moving an
* existing category to a different parent (see CategoryTree.tsx: the
* tree is keyed by parentId per loaded level, and re-parenting would
* mean invalidating/reloading two levels at once, which is more than
* this first pass needs; sortOrder/edit-in-place cover the common case).
*/
parentId?: number | null
parentName?: string | null
onSuccess: () => void
}
const CategoryFormModal = ({ isOpen, onOpenChange, category, parentId = null, parentName, onSuccess }: CategoryFormModalProps) => {
const { form, isEdit, submitting, handleSubmit } = useAdminCrudFormModal({
entity: category,
isOpen,
emptyValues: EMPTY_VALUES,
toFormValues,
resolver: zodResolver(CategoryFormValidation),
buildPayload,
// parentId only applies on create — the hook itself stays unaware of it.
create: (payload) => CREATE_CATEGORY({ ...payload, parentId }),
update: UPDATE_CATEGORY,
getId: (entity) => entity.id,
successMessage: { create: 'دسته‌بندی ایجاد شد', edit: 'دسته‌بندی ویرایش شد' },
onOpenChange,
onSuccess,
})
return (
<Modal
acceptBtnText={isEdit ? 'ذخیره تغییرات' : 'ایجاد دسته‌بندی'}
isLoading={submitting}
isOpen={isOpen}
title={isEdit ? `ویرایش «${category?.name}»` : parentId ? `زیردسته‌ی جدید زیر «${parentName ?? ''}»` : 'دسته‌بندی جدید (سطح اول)'}
onAccept={handleSubmit}
onOpenChange={onOpenChange}
>
<FormProvider {...form}>
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit}
>
<UnsavedChangesIndicator isDirty={form.formState.isDirty} />
<AdminFormSection
contained={false}
description="عنوان، آدرس و اطلاعات نمایشی دسته‌بندی"
title="اطلاعات اصلی"
>
<div className="flex flex-col gap-4">
<Input
required
generalType="input"
label="نام دسته‌بندی"
name="name"
placeholder="مثلاً «ورکشاپ»"
/>
<Input
required
direction="ltr"
generalType="input"
label="اسلاگ (شناسه‌ی آدرس)"
name="slug"
placeholder="workshop"
/>
<Input
generalType="textarea"
label="توضیح کوتاه"
name="shortDescription"
placeholder="یک خط توضیح برای این دسته‌بندی"
/>
<Input
generalType="textarea"
label="توضیح کامل"
name="description"
placeholder="توضیح کامل‌تر — روی صفحه‌ی لندینگ این دسته‌بندی نمایش داده می‌شود"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input
generalType="input"
label="آیکون"
name="icon"
placeholder="palette"
/>
<Input
direction="ltr"
generalType="input"
label="رنگ (هگز)"
name="color"
placeholder="#FF5733"
/>
</div>
</div>
</AdminFormSection>
<AdminFormSection
contained={false}
description="نحوه نمایش دسته‌بندی در نتایج جستجو و شبکه‌های اجتماعی"
title="بهینه‌سازی موتور جستجو"
>
<Accordion variant="bordered">
<AccordionItem
key="seo"
aria-label="تنظیمات SEO"
subtitle="این فیلدها روی صفحه‌ی /category/{slug} استفاده می‌شوند"
title="تنظیمات SEO"
>
<div className="flex flex-col gap-4 pb-2">
<Input
description={
<SeoCharCounterHint<CategoryFormValues>
max={CATEGORY_META_TITLE_MAX}
name="metaTitle"
/>
}
generalType="input"
label="عنوان SEO"
name="metaTitle"
placeholder="مثلاً «ورکشاپ‌ها | قبیله»"
/>
<Input
description={
<SeoCharCounterHint<CategoryFormValues>
max={CATEGORY_META_DESCRIPTION_MAX}
name="metaDescription"
/>
}
generalType="textarea"
label="توضیحات SEO"
name="metaDescription"
placeholder="یک یا دو جمله برای نمایش در نتایج گوگل"
/>
<Input
description={
<SeoCharCounterHint<CategoryFormValues>
max={CATEGORY_META_KEYWORDS_MAX}
name="metaKeywords"
/>
}
generalType="input"
label="کلمات کلیدی"
name="metaKeywords"
placeholder="ورکشاپ, آموزش, رویداد"
/>
<Input
direction="ltr"
generalType="input"
label="تصویر Open Graph (URL)"
name="ogImageUrl"
placeholder="https://cdn.example.com/og.jpg"
/>
<SeoPreviewLink />
</div>
</AccordionItem>
</Accordion>
</AdminFormSection>
<AdminFormSection
contained={false}
description="اولویت و وضعیت نمایش دسته‌بندی در سامانه"
title="تنظیمات نمایش"
>
<div className="flex flex-col gap-4">
<Input
generalType="numberInput"
label="ترتیب نمایش"
minValue={0}
name="sortOrder"
/>
<div className="grid gap-3 rounded-xl bg-secondary-50 p-3 sm:grid-cols-2">
<Input
generalType="switch"
label="فعال"
name="isActive"
/>
<Input
generalType="switch"
label="ویژه"
name="isFeatured"
/>
</div>
</div>
</AdminFormSection>
</form>
</FormProvider>
</Modal>
)
}
export default CategoryFormModal

View File

@ -0,0 +1,49 @@
import { StrictMode } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CategoryTree from '@/app/(dashboard)/event-categories/_components/CategoryTree'
const mocks = vi.hoisted(() => ({
deleteCategory: vi.fn(),
getCategoryChildren: vi.fn(),
showAlert: vi.fn(),
}))
vi.mock('@/app/(dashboard)/event-categories/_components/CategoryFormModal', () => ({ default: () => null }))
vi.mock('@/app/(dashboard)/event-categories/_components/CategoryTreeNode', () => ({
default: ({ category }: { category: { name: string } }) => <div>{category.name}</div>,
}))
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert: mocks.showAlert }) }))
vi.mock('@/lib/toast', () => ({ addToast: vi.fn() }))
vi.mock('@/services/eventCategories', () => ({
DELETE_CATEGORY: mocks.deleteCategory,
GET_CATEGORY_CHILDREN: mocks.getCategoryChildren,
}))
describe('CategoryTree', () => {
beforeEach(() => {
mocks.deleteCategory.mockReset()
mocks.getCategoryChildren.mockReset()
mocks.showAlert.mockReset()
})
it('loads the root once and renders it in Strict Mode', async () => {
mocks.getCategoryChildren.mockResolvedValue({
ok: true,
data: [{ id: 1, name: 'دسته اصلی', parentId: null }],
})
render(
<StrictMode>
<CategoryTree />
</StrictMode>
)
expect(await screen.findByText('دسته اصلی')).toBeInTheDocument()
await waitFor(() => {
expect(mocks.getCategoryChildren).toHaveBeenCalledOnce()
})
expect(mocks.getCategoryChildren).toHaveBeenCalledWith(null)
})
})

View File

@ -0,0 +1,255 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import CategoryFormModal from '@/app/(dashboard)/event-categories/_components/CategoryFormModal'
import CategoryTreeNode from '@/app/(dashboard)/event-categories/_components/CategoryTreeNode'
import { ListSkeleton } from '@/components/feedback/LoadingState'
import Button from '@/components/formElements/Button'
import PlusIcon from '@/components/icons/PlusIcon'
import useAlertModal from '@/hooks/useAlertModal'
import { addToast } from '@/lib/toast'
import { DELETE_CATEGORY, GET_CATEGORY_CHILDREN, type EventCategory } from '@/services/eventCategories'
const ROOT_KEY = 'root'
const keyFor = (parentId: number | null) => (parentId === null ? ROOT_KEY : String(parentId))
type ModalState = { mode: 'create'; parentId: number | null; parentName: string | null } | { mode: 'edit'; category: EventCategory } | null
const CategoryTree = () => {
const { showAlert } = useAlertModal()
// Single source of truth for every level of the tree that's been
// fetched so far, keyed by parentId ('root' for top-level). A node's
// own component stays purely presentational — it just renders whatever
// slice of this state belongs to it. This makes "refresh this level
// after a mutation" a one-line re-fetch into the same key, instead of
// each node owning (and having to be told to invalidate) its own texts.
const [childrenByParent, setChildrenByParent] = useState<Record<string, EventCategory[]>>({})
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(new Set())
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set())
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set())
const [modalState, setModalState] = useState<ModalState>(null)
const isMountedRef = useRef(true)
const inFlightLoadsRef = useRef(new Map<string, Promise<EventCategory[]>>())
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
}
}, [])
const setLoading = useCallback((key: string, value: boolean) => {
setLoadingKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
const loadChildren = useCallback(
(parentId: number | null): Promise<EventCategory[]> => {
const key = keyFor(parentId)
const inFlightLoad = inFlightLoadsRef.current.get(key)
if (inFlightLoad) return inFlightLoad
if (!isMountedRef.current) return Promise.resolve([])
setLoading(key, true)
const load = GET_CATEGORY_CHILDREN(parentId)
.then((result) => {
if (!isMountedRef.current) return result.ok ? result.data : []
setLoading(key, false)
if (!result.ok) return []
setChildrenByParent((prev) => ({ ...prev, [key]: result.data }))
return result.data
})
.finally(() => {
if (inFlightLoadsRef.current.get(key) === load) {
inFlightLoadsRef.current.delete(key)
}
})
inFlightLoadsRef.current.set(key, load)
return load
},
[setLoading]
)
useEffect(() => {
// Root level loads once on mount; every deeper level loads lazily
// the first time its parent is expanded (see toggleExpand below).
// Deferred to a microtask so the setState calls inside loadChildren
// don't run synchronously within the effect body itself.
queueMicrotask(() => {
void loadChildren(null)
})
}, [loadChildren])
const toggleExpand = async (category: EventCategory) => {
const isExpanded = expandedIds.has(category.id)
if (isExpanded) {
setExpandedIds((prev) => {
const next = new Set(prev)
next.delete(category.id)
return next
})
return
}
setExpandedIds((prev) => new Set(prev).add(category.id))
// Cached from a previous expand — no need to hit the network again.
if (childrenByParent[keyFor(category.id)] === undefined) {
await loadChildren(category.id)
}
}
const handleCreateSuccess = (parentId: number | null) => {
void loadChildren(parentId)
// A brand-new child obviously isn't visible unless its parent is
// expanded — force that so the organizer sees what they just made.
if (parentId !== null) {
setExpandedIds((prev) => new Set(prev).add(parentId))
}
}
const handleDelete = async (category: EventCategory) => {
// The backend's soft-delete has no guard against deleting a category
// that still has children (see docs/workflows/event-categories.md) —
// doing so would orphan them (their parentId would point at a
// deleted row, invisible from this tree forever after). Rather than
// ship that footgun, make sure we actually know whether this node
// has children before offering to delete it, fetching if we don't.
let kids = childrenByParent[keyFor(category.id)]
kids ??= await loadChildren(category.id)
if (!isMountedRef.current) return
if (kids.length > 0) {
addToast({
title: 'این دسته‌بندی زیردسته دارد',
description: 'برای حذف این دسته، ابتدا همه‌ی زیردسته‌های آن را حذف یا به جای دیگری منتقل کنید.',
color: 'warning',
})
return
}
showAlert(
`دسته‌بندی «${category.name}» برای همیشه حذف شود؟ این عملیات قابل بازگشت نیست.`,
async () => {
setDeletingIds((prev) => new Set(prev).add(category.id))
const result = await DELETE_CATEGORY(category.id)
if (isMountedRef.current) {
setDeletingIds((prev) => {
const next = new Set(prev)
next.delete(category.id)
return next
})
}
if (!result.ok) return
if (!isMountedRef.current) return
addToast({ title: 'دسته‌بندی حذف شد', color: 'success' })
void loadChildren(category.parentId)
},
undefined,
{ dangerAccept: true }
)
}
const renderNode = (category: EventCategory, depth: number) => {
const isExpanded = expandedIds.has(category.id)
const key = keyFor(category.id)
return (
<div key={category.id}>
<CategoryTreeNode
category={category}
childCategories={childrenByParent[key]}
depth={depth}
isDeleting={deletingIds.has(category.id)}
isExpanded={isExpanded}
isLoadingChildren={loadingKeys.has(key)}
onAddChild={(parent) => {
setModalState({ mode: 'create', parentId: parent.id, parentName: parent.name })
}}
onDelete={handleDelete}
onEdit={(cat) => {
setModalState({ mode: 'edit', category: cat })
}}
onToggleExpand={toggleExpand}
/>
{isExpanded && childrenByParent[key]?.map((child) => renderNode(child, depth + 1))}
</div>
)
}
const rootCategories = childrenByParent[ROOT_KEY]
const isRootLoading = loadingKeys.has(ROOT_KEY) && rootCategories === undefined
return (
<div className="admin-surface w-full overflow-hidden text-right">
<div className="flex flex-col gap-3 border-b border-secondary-40 bg-white px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="font-bold text-secondary-10">ساختار دستهبندیها</h2>
<p className="mt-1 text-xs text-secondary-30">دستهها را باز کنید و زیردستهها، ترتیب و وضعیت آنها را مدیریت کنید.</p>
</div>
<Button
iconStart={<PlusIcon className="size-4" />}
onClick={() => {
setModalState({ mode: 'create', parentId: null, parentName: null })
}}
>
دستهبندی جدید (سطح اول)
</Button>
</div>
<div className="min-h-72 bg-secondary-50/30 p-3 md:p-5">
{isRootLoading ? (
<ListSkeleton count={6} />
) : !rootCategories || rootCategories.length === 0 ? (
<div className="py-20 text-center text-sm text-secondary-30">هنوز هیچ دستهبندیای ثبت نشده است.</div>
) : (
rootCategories.map((category) => renderNode(category, 0))
)}
</div>
<CategoryFormModal
category={modalState?.mode === 'edit' ? modalState.category : undefined}
isOpen={modalState !== null}
parentId={modalState?.mode === 'create' ? modalState.parentId : modalState?.mode === 'edit' ? modalState.category.parentId : null}
parentName={modalState?.mode === 'create' ? modalState.parentName : null}
onOpenChange={(open) => {
if (!open) setModalState(null)
}}
onSuccess={() => {
if (modalState?.mode === 'create') handleCreateSuccess(modalState.parentId)
else if (modalState?.mode === 'edit') void loadChildren(modalState.category.parentId)
}}
/>
</div>
)
}
export default CategoryTree

View File

@ -0,0 +1,147 @@
'use client'
import type { EventCategory } from '@/services/eventCategories'
import Button from '@/components/formElements/Button'
import ChevronRightIcon from '@/components/icons/ChevronRightIcon'
import FolderSolidIcon from '@/components/icons/FolderSolidIcon'
import PlusIcon from '@/components/icons/PlusIcon'
import EditIconOutline from '@/components/icons/EditIconOutline'
import TrashIcon from '@/components/icons/TrashIcon'
import DotLoadingIcon from '@/components/icons/DotLoadingIcon'
import StatusChip from '@/components/ui/StatusChip'
import { getActiveStatus, getFeaturedStatus } from '@/constants/status'
interface CategoryTreeNodeProps {
category: EventCategory
depth: number
isExpanded: boolean
isLoadingChildren: boolean
isDeleting: boolean
childCategories: EventCategory[] | undefined
onToggleExpand: (category: EventCategory) => void
onAddChild: (category: EventCategory) => void
onEdit: (category: EventCategory) => void
onDelete: (category: EventCategory) => void
}
const INDENT_PER_DEPTH = 24
const IconButton = ({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) => (
<span
onClick={(event) => {
event.stopPropagation()
}}
onKeyDown={(event) => {
event.stopPropagation()
}}
>
<Button
iconOnly
aria-label={label}
className="rounded-lg p-2 text-secondary-30 hover:bg-primary-50 hover:text-primary"
size="sm"
variant="light"
onClick={onClick}
>
{children}
</Button>
</span>
)
const CategoryTreeNode = ({
category,
depth,
isExpanded,
isLoadingChildren,
isDeleting,
childCategories,
onToggleExpand,
onAddChild,
onEdit,
onDelete,
}: CategoryTreeNodeProps) => {
const childCount = childCategories?.length ?? 0
const featured = getFeaturedStatus(category.isFeatured)
return (
<div>
<div
className="group mb-1 flex cursor-pointer items-center gap-2 rounded-xl border border-transparent bg-white p-3 shadow-sm shadow-neutral-10/[0.02] transition-all hover:border-primary-100 hover:bg-primary-50/40"
role="button"
style={{ paddingRight: depth * INDENT_PER_DEPTH }}
tabIndex={0}
onClick={() => {
onToggleExpand(category)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') onToggleExpand(category)
}}
>
<span className={`shrink-0 transition-transform text-tertiary-300 ${isExpanded ? '-rotate-90' : ''}`}>
{isLoadingChildren ? <DotLoadingIcon className="size-4" /> : <ChevronRightIcon className="size-3" />}
</span>
{isExpanded ? (
<FolderSolidIcon className="size-5 shrink-0 text-primary" />
) : (
<FolderSolidIcon className="size-5 shrink-0 text-primary" />
)}
<span className="truncate text-sm font-semibold text-secondary-20">{category.name}</span>
{!category.isActive && <StatusChip {...getActiveStatus(false)} />}
{featured ? <StatusChip {...featured} /> : null}
<span className="ltr:ml-auto rtl:mr-auto flex items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100">
<IconButton
label="افزودن زیردسته"
onClick={() => {
onAddChild(category)
}}
>
<PlusIcon className="size-4" />
</IconButton>
<IconButton
label="ویرایش"
onClick={() => {
onEdit(category)
}}
>
<EditIconOutline className="size-4" />
</IconButton>
<IconButton
label="حذف"
onClick={() => {
onDelete(category)
}}
>
{isDeleting ? <DotLoadingIcon className="size-4" /> : <TrashIcon className="size-4" />}
</IconButton>
</span>
</div>
{isExpanded && (
<div>
{isLoadingChildren && childCategories === undefined ? (
<div
aria-hidden
className="py-2"
style={{ paddingRight: (depth + 1) * INDENT_PER_DEPTH }}
>
<span className="block h-9 w-1/2 animate-pulse rounded-xl bg-secondary-40/70 motion-reduce:animate-none" />
</div>
) : childCount === 0 ? (
<div
className="text-xs text-tertiary-300 py-1"
style={{ paddingRight: (depth + 1) * INDENT_PER_DEPTH }}
>
بدون زیردسته
</div>
) : null}
</div>
)}
</div>
)
}
export default CategoryTreeNode

View File

@ -0,0 +1,15 @@
import CategoryTree from '@/app/(dashboard)/event-categories/_components/CategoryTree'
import PageNavbar from '@/components/layouts/PageNavbar'
const CategoriesPage = () => {
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="دسته‌بندی‌های رویداد" />
<div className="admin-page-container">
<CategoryTree />
</div>
</section>
)
}
export default CategoriesPage

View File

@ -0,0 +1,7 @@
import { permanentRedirect } from 'next/navigation'
import { APP_ROUTES } from '@/constants/routes'
const LegacyAdminEventsPage = () => permanentRedirect(APP_ROUTES.MANAGE_EVENTS)
export default LegacyAdminEventsPage

View File

@ -0,0 +1,102 @@
'use client'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import Modal from '@/components/modals/Modal'
import { APP_ROUTES } from '@/constants/routes'
import { formatPersonName, coerceToString } from '@/helpers'
import { API_ROUTES } from '@/services/config'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
export interface GuestListItemRow {
id: string
listId: string
mobile: string
firstName: string | null
lastName: string | null
userId: string | null
note: string | null
createdAt: string
}
interface GuestListItemsModalProps {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
list: { id: string; name: string } | null
}
const columns: PaginationListColumnType[] = [
{
field: 'mobile',
label: 'موبایل',
filterable: false,
sortable: false,
},
{
field: 'firstName',
label: 'نام',
filterable: false,
sortable: true,
},
{
field: 'note',
label: 'یادداشت',
filterable: false,
sortable: false,
},
{
field: 'createdAt',
label: 'تاریخ افزودن',
filterable: false,
sortable: true,
},
{
field: 'actions',
label: 'عملیات',
},
]
const GuestListItemsModal = ({ isOpen, onOpenChange, list }: GuestListItemsModalProps) => (
<Modal
hideFooter
isOpen={isOpen}
size="3xl"
title={list ? `مخاطبین «${list.name}»` : 'مخاطبین لیست'}
onOpenChange={onOpenChange}
>
{list && (
<PaginatedList
key={list.id}
columns={columns}
url={API_ROUTES.GUEST_LISTS.ADMIN_ITEMS(list.id)}
>
{{
mobile: (_row, cellValue) => <span dir="ltr">{formatIranianMobile(cellValue)}</span>,
firstName: (row) => {
const item = row as unknown as GuestListItemRow
return formatPersonName(item.firstName, item.lastName)
},
note: (_row, cellValue) => (cellValue ? coerceToString(cellValue) : '—'),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const item = row as unknown as GuestListItemRow
if (!item.userId) return '—'
return (
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(item.userId)}
/>
)
},
}}
</PaginatedList>
)}
</Modal>
)
export default GuestListItemsModal

View File

@ -0,0 +1,109 @@
'use client'
import { useState } from 'react'
import type { PaginationListColumnType } from '@/types'
import useDisclosure from '@/hooks/useDisclosure'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import GuestListItemsModal from '@/app/(dashboard)/guest-lists/_components/GuestListItemsModal'
import { API_ROUTES } from '@/services/config'
import { formatPersianDate, truncateValue } from '@/lib/formatters'
interface GuestListRow {
id: string
organizerId: string
name: string
description: string | null
createdAt: string
updatedAt: string
}
// Read-only admin visibility — GET /admin/guest-lists only supports
// filtering by organizerId and sorting by createdAt (see
// backend/src/modules/event-extras/admin-guest-lists.controller.ts,
// ADMIN_GUEST_LIST_FILTER_KEYS / GUEST_LIST_SORT_FIELDS). No write actions:
// creating/editing/deleting lists stays organizer self-service, and sending
// invitations isn't implemented in the backend yet (docs/workflows/guest-list-invitation.md).
// organizerId is filter-only (hideInTable) — the eye opens the list contacts modal.
const columns: PaginationListColumnType[] = [
{
field: 'name',
label: 'نام لیست',
filterable: false,
sortable: false,
},
{
field: 'organizerId',
label: 'میزبان',
filterable: true,
sortable: false,
type: 'text',
hideInTable: true,
},
{
field: 'description',
label: 'توضیحات',
filterable: false,
sortable: false,
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: false,
sortable: true,
},
{
field: 'actions',
label: 'عملیات',
},
]
const GuestListsPage = () => {
const { isOpen, onOpenChange, onOpen } = useDisclosure()
const [activeList, setActiveList] = useState<{ id: string; name: string } | null>(null)
const openItemsModal = (list: GuestListRow) => {
setActiveList({ id: list.id, name: list.name })
onOpen()
}
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="لیست‌های مهمان" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.GUEST_LISTS.ADMIN_LIST}
>
{{
description: (_row, cellValue) => truncateValue(cellValue, 60),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const list = row as unknown as GuestListRow
return (
<AdminTableViewButton
label="مشاهده مخاطبین"
mode="open-detail"
onClick={() => {
openItemsModal(list)
}}
/>
)
},
}}
</PaginatedList>
</div>
<GuestListItemsModal
isOpen={isOpen}
list={activeList}
onOpenChange={onOpenChange}
/>
</section>
)
}
export default GuestListsPage

View File

@ -0,0 +1,515 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import type { PaginationListColumnType } from '@/types'
import { addToast } from '@/lib/toast'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import CloseCircleIcon from '@/components/icons/CloseCircleIcon'
import EyeIcon from '@/components/icons/EyeIcon'
import FileCheckIcon from '@/components/icons/FileCheckIcon'
import Modal from '@/components/modals/Modal'
import AdminTableActions from '@/components/ui/AdminTableActions'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import axiosInstance from '@/config/axios'
import { APP_ROUTES } from '@/constants/routes'
import { getIdentityStatus, IDENTITY_VERIFICATION_QUEUE_FILTER_ITEMS } from '@/constants/status'
import { formatPersonName, coerceToString } from '@/helpers'
import { unwrapApiPayload } from '@/helpers/listResponse'
import useAlertModal from '@/hooks/useAlertModal'
import useAdminMutation from '@/hooks/useAdminMutation'
import { formatPersianDate } from '@/lib/formatters'
import { API_ROUTES } from '@/services/config'
type IdentityVerificationStatus = 'pending' | 'verified' | 'rejected'
interface RejectionReason {
id: number
code: string
label: string
sortOrder: number
}
interface VerificationUserSummary {
mobile: string
firstName: string | null
lastName: string | null
}
interface VerificationRow {
id: string
userId: string
status: IdentityVerificationStatus
nationalCode: string | null
contractVersion?: string | null
contractAcceptedAt?: string | null
contractAcceptanceId?: string | null
jibitMatched?: boolean | null
jibitInquiredAt?: string | null
jibitErrorCode?: string | null
reason?: { code: string; label: string } | null
rejectionReason?: string | null
user?: VerificationUserSummary
createdAt: string
reviewedAt?: string | null
[key: string]: unknown
}
interface RejectFormValues {
reasonId: string
freeTextReason: string
}
const REJECT_FORM_DEFAULTS: RejectFormValues = {
reasonId: '',
freeTextReason: '',
}
const columns: PaginationListColumnType[] = [
{
field: 'userId',
label: 'متقاضی',
filterable: true,
type: 'text',
sortable: false,
},
{
field: 'nationalCode',
label: 'کد ملی',
filterable: false,
sortable: false,
},
{
field: 'jibitMatched',
label: 'استعلام جیبت',
filterable: false,
sortable: false,
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
type: 'select',
sortable: true,
filterItems: IDENTITY_VERIFICATION_QUEUE_FILTER_ITEMS,
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: true,
sortable: true,
type: 'dateFromTo',
},
{
field: 'actions',
label: 'عملیات',
},
]
const formatJibitCell = (row: VerificationRow) => {
if (row.jibitErrorCode) return `خطا: ${row.jibitErrorCode}`
if (row.jibitMatched === true) return 'تطبیق دارد'
if (row.jibitMatched === false) return 'تطبیق ندارد'
return '—'
}
const getUserSummary = (row: VerificationRow): VerificationUserSummary & { id: string } => {
const user = row.user
return {
id: row.userId,
mobile: user?.mobile ?? '—',
firstName: user?.firstName ?? null,
lastName: user?.lastName ?? null,
}
}
const IdentityVerificationsPage = () => {
const { showAlert } = useAlertModal()
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.IDENTITY.ADMIN_LIST })
const [detailTarget, setDetailTarget] = useState<VerificationRow | null>(null)
const [rejectTarget, setRejectTarget] = useState<VerificationRow | null>(null)
const [rejectionReasons, setRejectionReasons] = useState<RejectionReason[]>([])
const isRejecting = rejectTarget?.id === pendingId
const rejectForm = useForm<RejectFormValues>({
defaultValues: REJECT_FORM_DEFAULTS,
})
const selectedReasonId = rejectForm.watch('reasonId')
const freeTextReason = rejectForm.watch('freeTextReason')
const loadRejectionReasons = useCallback(async () => {
try {
const response = await axiosInstance.get(API_ROUTES.IDENTITY.REJECTION_REASONS)
const payload = unwrapApiPayload(response.data)
const reasons = Array.isArray(payload) ? payload : []
setRejectionReasons(reasons as RejectionReason[])
} catch {
addToast({ title: 'بارگذاری دلایل رد ناموفق بود', color: 'danger' })
}
}, [])
useEffect(() => {
if (rejectTarget) {
void loadRejectionReasons()
}
}, [rejectTarget, loadRejectionReasons])
const handleApprove = (row: VerificationRow) => {
showAlert('این درخواست احراز هویت تأیید شود؟', () =>
runAction(row.id, () => axiosInstance.patch(API_ROUTES.IDENTITY.ADMIN_APPROVE(row.id)), 'درخواست تأیید شد')
)
}
const handleJibitInquiry = (row: VerificationRow) => {
showAlert('استعلام تطابق کدملی با موبایل از جیبت انجام شود؟', () =>
runAction(
row.id,
async () => {
const response = await axiosInstance.post(API_ROUTES.IDENTITY.ADMIN_JIBIT_INQUIRY(row.id))
const payload = unwrapApiPayload<Partial<VerificationRow>>(response.data) ?? {}
setDetailTarget({
...row,
...payload,
user: payload.user ?? row.user,
})
return response
},
'استعلام جیبت انجام شد'
)
)
}
const openRejectModal = (row: VerificationRow) => {
rejectForm.reset(REJECT_FORM_DEFAULTS)
setRejectTarget(row)
}
const closeRejectModal = () => {
if (isRejecting) return
setRejectTarget(null)
rejectForm.reset(REJECT_FORM_DEFAULTS)
}
const handleReject = async () => {
if (!rejectTarget) return
const reasonId = selectedReasonId ? Number(selectedReasonId) : undefined
const rejectionReason = freeTextReason.trim() || undefined
if (!reasonId && !rejectionReason) {
addToast({ title: 'حداقل یکی از دلیل ساختاریافته یا توضیح آزاد را وارد کنید', color: 'warning' })
return
}
const succeeded = await runAction(
rejectTarget.id,
() =>
axiosInstance.patch(API_ROUTES.IDENTITY.ADMIN_REJECT(rejectTarget.id), {
...(reasonId ? { reasonId } : {}),
...(rejectionReason ? { rejectionReason } : {}),
}),
'درخواست رد شد'
)
if (succeeded) {
setRejectTarget(null)
rejectForm.reset(REJECT_FORM_DEFAULTS)
}
}
const renderStatusCell = (row: VerificationRow, cellValue: unknown) => {
const { label, chipColor } = getIdentityStatus(coerceToString(cellValue))
const description =
row.status === 'rejected' && (row.reason?.label || row.rejectionReason)
? `${row.reason?.label ?? ''}${row.reason?.label && row.rejectionReason ? ' — ' : ''}${row.rejectionReason ?? ''}`
: undefined
return (
<StatusChip
chipColor={chipColor}
description={description}
label={label}
/>
)
}
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="احراز هویت میزبان" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.IDENTITY.ADMIN_LIST}
>
{{
userId: (row) => {
const user = getUserSummary(row as VerificationRow)
return (
<div className="flex flex-col gap-1">
<span>{formatPersonName(user.firstName, user.lastName)}</span>
<span className="text-text-muted text-xs">{user.mobile}</span>
</div>
)
},
jibitMatched: (row) => formatJibitCell(row as VerificationRow),
status: (row, cellValue) => renderStatusCell(row as VerificationRow, cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const verification = row as VerificationRow
const isBusy = pendingId === verification.id
const user = getUserSummary(verification)
return (
<AdminTableActions>
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(user.id)}
/>
<Button
iconOnly
aria-label="جزئیات درخواست"
color="default"
size="sm"
variant="flat"
onClick={() => {
setDetailTarget(verification)
}}
>
<EyeIcon className="size-4" />
</Button>
{verification.status === 'pending' ? (
<>
<Button
aria-label="استعلام جیبت"
color="primary"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
handleJibitInquiry(verification)
}}
>
استعلام
</Button>
<Button
iconOnly
aria-label="تأیید احراز هویت"
color="success"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
handleApprove(verification)
}}
>
<FileCheckIcon className="size-4" />
</Button>
<Button
iconOnly
aria-label="رد احراز هویت"
color="danger"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
openRejectModal(verification)
}}
>
<CloseCircleIcon className="size-4 text-fourth-900" />
</Button>
</>
) : null}
</AdminTableActions>
)
},
}}
</PaginatedList>
</div>
<Modal
footerChildren={
detailTarget?.status === 'pending' ? (
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-3">
<Button
fullWidth
color="primary"
disabled={pendingId === detailTarget.id}
isLoading={pendingId === detailTarget.id}
variant="flat"
onClick={() => {
handleJibitInquiry(detailTarget)
}}
>
استعلام جیبت
</Button>
<Button
fullWidth
color="danger"
disabled={pendingId === detailTarget.id}
variant="flat"
onClick={() => {
const target = detailTarget
setDetailTarget(null)
openRejectModal(target)
}}
>
رد درخواست
</Button>
<Button
fullWidth
color="success"
disabled={pendingId === detailTarget.id}
isLoading={pendingId === detailTarget.id}
onClick={() => {
const target = detailTarget
setDetailTarget(null)
handleApprove(target)
}}
>
تأیید درخواست
</Button>
</div>
) : undefined
}
hideFooter={detailTarget?.status !== 'pending'}
isDismissable={detailTarget ? pendingId !== detailTarget.id : true}
isOpen={Boolean(detailTarget)}
size="2xl"
title="جزئیات احراز هویت"
onOpenChange={(open) => {
if (!open && pendingId !== detailTarget?.id) setDetailTarget(null)
}}
>
{detailTarget ? (
<dl className="grid grid-cols-1 gap-3 rounded-2xl bg-surface-secondary p-4 sm:grid-cols-2">
<div>
<dt className="text-xs text-muted">متقاضی</dt>
<dd className="mt-1 text-sm font-semibold">{formatPersonName(detailTarget.user?.firstName, detailTarget.user?.lastName)}</dd>
</div>
<div>
<dt className="text-xs text-muted">شماره موبایل</dt>
<dd
className="mt-1 text-sm font-semibold"
dir="ltr"
>
{detailTarget.user?.mobile ?? '—'}
</dd>
</div>
<div>
<dt className="text-xs text-muted">کد ملی</dt>
<dd
className="mt-1 text-sm font-semibold"
dir="ltr"
>
{detailTarget.nationalCode ?? '—'}
</dd>
</div>
<div>
<dt className="text-xs text-muted">شناسه برگزارکننده</dt>
<dd
className="mt-1 break-all text-sm font-semibold"
dir="ltr"
>
{detailTarget.userId}
</dd>
</div>
<div>
<dt className="text-xs text-muted">نسخه قرارداد</dt>
<dd className="mt-1 text-sm font-semibold">{detailTarget.contractVersion ?? '—'}</dd>
</div>
<div>
<dt className="text-xs text-muted">تاریخ پذیرش</dt>
<dd className="mt-1 text-sm font-semibold">
{detailTarget.contractAcceptedAt ? formatPersianDate(detailTarget.contractAcceptedAt) : '—'}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs text-muted">شناسه پذیرش الکترونیکی</dt>
<dd
className="mt-1 break-all text-sm font-semibold"
dir="ltr"
>
{detailTarget.contractAcceptanceId ?? '—'}
</dd>
</div>
<div>
<dt className="text-xs text-muted">نتیجه استعلام جیبت</dt>
<dd className="mt-1 text-sm font-semibold">{formatJibitCell(detailTarget)}</dd>
</div>
<div>
<dt className="text-xs text-muted">زمان استعلام</dt>
<dd className="mt-1 text-sm font-semibold">
{detailTarget.jibitInquiredAt ? formatPersianDate(detailTarget.jibitInquiredAt) : '—'}
</dd>
</div>
</dl>
) : null}
</Modal>
<Modal
acceptDanger
acceptBtnDisabled={(!selectedReasonId && !freeTextReason.trim()) || isRejecting}
acceptBtnText="رد درخواست"
hideCloseButton={isRejecting}
isDismissable={!isRejecting}
isLoading={isRejecting}
isOpen={Boolean(rejectTarget)}
rejectBtnText="انصراف"
size="sm"
title="رد درخواست احراز هویت"
onAccept={() => {
void handleReject()
}}
onOpenChange={(open) => {
if (!open) closeRejectModal()
}}
onReject={closeRejectModal}
>
<FormProvider {...rejectForm}>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">
حداقل یکی از «دلیل ساختاریافته» یا «توضیح آزاد» را وارد کنید. این اطلاعات برای کاربر نمایش داده میشود.
</p>
<Input
generalType="select"
label="دلیل ساختاریافته"
name="reasonId"
placeholder="انتخاب کنید"
selectKey="id"
selectOptions={rejectionReasons}
selectValue="label"
/>
<Input
generalType="textarea"
label="توضیح آزاد"
name="freeTextReason"
placeholder="در صورت نیاز توضیح تکمیلی برای کاربر بنویسید"
textAreaMinRows={3}
/>
</div>
</FormProvider>
</Modal>
</section>
)
}
export default IdentityVerificationsPage

View File

@ -0,0 +1,14 @@
import type { Metadata } from 'next'
import DashboardShell from '@/components/layouts/DashboardShell'
// Entire (dashboard) group is the admin panel — auth-gated and already
// disallowed in robots.ts. `noindex` here is defense in depth so a linked
// admin URL can never surface in search results even without a crawl.
export const metadata: Metadata = {
robots: { index: false, follow: false },
}
const UserLayout = ({ children }: { children: React.ReactNode }) => <DashboardShell>{children}</DashboardShell>
export default UserLayout

View File

@ -0,0 +1,5 @@
import { PageLoading } from '@/components/feedback/LoadingState'
export default function DashboardLoading() {
return <PageLoading label="در حال آماده‌سازی پنل مدیریت" />
}

View File

@ -0,0 +1,39 @@
'use client'
import dynamic from 'next/dynamic'
import { useParams } from 'next/navigation'
import PageNavbar from '@/components/layouts/PageNavbar'
import { DetailSkeleton } from '@/components/feedback/LoadingState'
import { APP_ROUTES } from '@/constants/routes'
import { texts } from '@/texts'
const EventEditForm = dynamic(() => import('@/features/events/edit/EventEditForm'), {
ssr: false,
loading: () => <DetailSkeleton />,
})
const AdminEventEditPage = () => {
const eventId = useParams<{ id: string }>().id
if (!eventId) return <p className="p-4 text-sm text-fourth-900">{texts.events.invalidEventId}</p>
const detailHref = APP_ROUTES.MANAGE_EVENT_DETAIL(eventId)
return (
<section className="h-full w-full">
<PageNavbar pageTitle={texts.events.editEventTitle} />
<div className="admin-page-container">
<EventEditForm
accessMode="admin"
cancelHref={detailHref}
eventId={eventId}
layout="admin"
successHref={detailHref}
/>
</div>
</section>
)
}
export default AdminEventEditPage

View File

@ -0,0 +1,11 @@
import type { Metadata } from 'next'
import AdminEventDetail from '@/features/events/detail/AdminEventDetail'
export const metadata: Metadata = {
robots: { index: false, follow: false },
}
const AdminEventDetailPage = () => <AdminEventDetail />
export default AdminEventDetailPage

View File

@ -0,0 +1,5 @@
import AdminEventCreate from '@/features/events/create/AdminEventCreate'
const AdminEventCreatePage = () => <AdminEventCreate />
export default AdminEventCreatePage

View File

@ -0,0 +1,247 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import type { PaginationListColumnType } from '@/types'
import Button from '@/components/formElements/Button'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import axiosInstance from '@/config/axios'
import { APP_ROUTES } from '@/constants/routes'
import { EVENT_STATUS_FILTER_ITEMS, getBooleanStatus, getEventStatus } from '@/constants/status'
import { formatCurrency, formatPersonName, coerceToString } from '@/helpers'
import { formatPersianDate } from '@/lib/formatters'
import { API_ROUTES } from '@/services/config'
interface NamedEntity {
id: number | string
name: string
}
interface EventOrganizer {
id: string
firstName: string | null
lastName: string | null
}
interface EventRow {
id: string
title: string
status: string
isDiscoverable: boolean
isFree: boolean
price: number
category?: NamedEntity
province?: NamedEntity
city?: NamedEntity
organizer?: EventOrganizer
[key: string]: unknown
}
interface FilterOption {
code: string
name: string
}
const IS_FREE_FILTER_ITEMS: FilterOption[] = [
{ code: 'true', name: 'بله' },
{ code: 'false', name: 'خیر' },
]
const IS_DISCOVERABLE_FILTER_ITEMS: FilterOption[] = [
{ code: 'true', name: 'بله' },
{ code: 'false', name: 'خیر' },
]
const toSelectFilterItems = (items: NamedEntity[]): FilterOption[] => items.map((item) => ({ code: String(item.id), name: item.name }))
const extractApiList = <T,>(raw: unknown): T[] => {
if (Array.isArray(raw)) return raw as T[]
if (typeof raw === 'object' && raw !== null && 'data' in raw) {
const data = raw.data
if (Array.isArray(data)) return data as T[]
}
return []
}
const getOrganizer = (row: EventRow): EventOrganizer => {
if (row.organizer && typeof row.organizer === 'object') {
return row.organizer
}
return { id: '—', firstName: null, lastName: null }
}
const EventsPage = () => {
const [categoryFilterItems, setCategoryFilterItems] = useState<FilterOption[]>([])
const [cityFilterItems, setCityFilterItems] = useState<FilterOption[]>([])
useEffect(() => {
const loadFilterOptions = async () => {
try {
const [categoriesRes, citiesRes] = await Promise.all([
axiosInstance.get(API_ROUTES.EVENT_CATEGORIES.ADMIN_FLAT),
axiosInstance.get(API_ROUTES.GEOGRAPHY.ALL_CITIES),
])
setCategoryFilterItems(toSelectFilterItems(extractApiList<NamedEntity>(categoriesRes.data)))
setCityFilterItems(toSelectFilterItems(extractApiList<NamedEntity>(citiesRes.data)))
} catch {
setCategoryFilterItems([])
setCityFilterItems([])
}
}
void loadFilterOptions()
}, [])
const columns = useMemo<PaginationListColumnType[]>(
() => [
{
field: 'title',
label: 'عنوان',
filterable: true,
type: 'text',
sortable: false,
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
type: 'select',
sortable: false,
filterItems: EVENT_STATUS_FILTER_ITEMS,
},
{
field: 'isDiscoverable',
label: 'قابل‌جستجو',
filterable: true,
type: 'select',
sortable: false,
filterItems: IS_DISCOVERABLE_FILTER_ITEMS,
},
{
field: 'categoryId',
label: 'دسته‌بندی',
filterable: true,
type: 'select',
sortable: false,
filterItems: categoryFilterItems,
},
{
field: 'organizerId',
label: 'برگزارکننده',
filterable: true,
type: 'text',
sortable: false,
},
// Province-level filtering is disabled here too — see cities/page.tsx's
// note for why (city-only admin surface). Only the city filter below
// is active.
{
field: 'cityId',
label: 'شهر',
filterable: true,
type: 'select',
sortable: false,
filterItems: cityFilterItems,
},
{
field: 'startsAt',
label: 'تاریخ شروع',
filterable: true,
sortable: true,
type: 'dateFromTo',
},
{
field: 'isFree',
label: 'رایگان',
filterable: true,
type: 'select',
sortable: false,
filterItems: IS_FREE_FILTER_ITEMS,
},
{
field: 'price',
label: 'قیمت',
filterable: true,
type: 'inputFromTo',
sortable: false,
},
{
field: 'actions',
label: 'عملیات',
},
],
[categoryFilterItems, cityFilterItems]
)
return (
<section className="h-full w-full text-right">
<PageNavbar
endSlot={
<Button
size="sm"
to={APP_ROUTES.MANAGE_EVENT_NEW}
>
افزودن رویداد
</Button>
}
pageTitle="رویدادها"
/>
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.EVENTS.ADMIN_LIST}
>
{{
status: (_row, cellValue) => <StatusChip {...getEventStatus(coerceToString(cellValue))} />,
isDiscoverable: (_row, cellValue) => <StatusChip {...getBooleanStatus(typeof cellValue === 'boolean' ? cellValue : null)} />,
categoryId: (row) => {
const event = row as EventRow
return event.category?.name ?? '—'
},
organizerId: (row) => {
const organizer = getOrganizer(row as EventRow)
return formatPersonName(organizer.firstName, organizer.lastName)
},
cityId: (row) => {
const event = row as EventRow
return event.city?.name ?? '—'
},
startsAt: (_row, cellValue) => formatPersianDate(cellValue),
isFree: (_row, cellValue) => <StatusChip {...getBooleanStatus(typeof cellValue === 'boolean' ? cellValue : null)} />,
price: (row, cellValue) => {
const event = row as EventRow
if (event.isFree) return '—'
const amount = typeof cellValue === 'number' ? cellValue : Number(cellValue)
if (!Number.isFinite(amount)) return '—'
return formatCurrency(amount)
},
actions: (row) => (
<AdminTableViewButton
label="مشاهده رویداد"
mode="navigate"
to={APP_ROUTES.MANAGE_EVENT_DETAIL(String((row as EventRow).id))}
/>
),
}}
</PaginatedList>
</div>
</section>
)
}
export default EventsPage

View File

@ -0,0 +1,7 @@
import { redirect } from 'next/navigation'
import { APP_ROUTES } from '@/constants/routes'
export default function ManualNotificationsRedirectPage() {
redirect(APP_ROUTES.NOTIFICATIONS)
}

View File

@ -0,0 +1,7 @@
import { redirect } from 'next/navigation'
import { APP_ROUTES } from '@/constants/routes'
export default function NotificationRulesRedirectPage() {
redirect(APP_ROUTES.NOTIFICATIONS_SETTINGS)
}

View File

@ -0,0 +1,179 @@
'use client'
import { useState } from 'react'
import type { PaginationListColumnType } from '@/types'
import useDisclosure from '@/hooks/useDisclosure'
import PaginatedList from '@/components/PaginatedList'
import Button from '@/components/formElements/Button'
import Modal from '@/components/modals/Modal'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import { APP_ROUTES, CONSUMER_ROUTES } from '@/constants/routes'
import { coerceToString, formatPersonName } from '@/helpers'
import { API_ROUTES } from '@/services/config'
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
interface InAppNotificationRow {
id: string
userId: string
mobile: string
category: string
title: string
body: string
actionUrl: string | null
status: string
readAt: string | null
createdAt: string
recipient?: {
firstName: string | null
lastName: string | null
}
}
const columns: PaginationListColumnType[] = [
{ field: 'userId', label: 'گیرنده', filterable: true, sortable: false, type: 'text' },
{ field: 'mobile', label: 'موبایل', filterable: true, sortable: false, type: 'text' },
{ field: 'title', label: 'عنوان', filterable: false, sortable: false },
{ field: 'category', label: 'دسته', filterable: true, sortable: false, type: 'text' },
{
field: 'readStatus',
label: 'خواندن',
filterable: true,
sortable: false,
type: 'select',
filterItems: [
{ code: 'unread', name: 'خوانده‌نشده' },
{ code: 'read', name: 'خوانده‌شده' },
],
},
{ field: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true },
{ field: 'actions', label: 'عملیات' },
]
const InAppNotificationsPanel = () => {
const { isOpen, onOpen, onOpenChange } = useDisclosure()
const [activeRow, setActiveRow] = useState<InAppNotificationRow | null>(null)
const openDetails = (row: InAppNotificationRow) => {
setActiveRow(row)
onOpen()
}
return (
<>
<PaginatedList
columns={columns}
url={API_ROUTES.IN_APP_NOTIFICATIONS.ADMIN_LIST}
>
{{
userId: (row) => {
const notification = row as unknown as InAppNotificationRow
const name = formatPersonName(notification.recipient?.firstName, notification.recipient?.lastName)
return (
<div className="flex flex-col gap-1">
{name !== '—' && <span className="text-sm">{name}</span>}
<span
className="text-text-muted text-xs"
dir="ltr"
>
{formatIranianMobile(notification.mobile)}
</span>
</div>
)
},
mobile: (_row, cellValue) => <span dir="ltr">{formatIranianMobile(coerceToString(cellValue))}</span>,
title: (row, cellValue) => {
const notification = row as unknown as InAppNotificationRow
return (
<div className="flex flex-col gap-1">
<span className="text-sm">{truncateValue(coerceToString(cellValue), 50)}</span>
<span className="text-text-muted text-xs">{truncateValue(notification.body, 70)}</span>
</div>
)
},
category: (_row, cellValue) => coerceToString(cellValue) || '—',
readStatus: (row) => {
const notification = row as unknown as InAppNotificationRow
return notification.readAt ? (
<StatusChip
chipColor="success"
label="خوانده‌شده"
/>
) : (
<StatusChip
chipColor="warning"
label="خوانده‌نشده"
/>
)
},
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const notification = row as unknown as InAppNotificationRow
return (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="light"
onClick={() => {
openDetails(notification)
}}
>
جزئیات
</Button>
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(notification.userId)}
/>
</div>
)
},
}}
</PaginatedList>
<Modal
hideFooter
isOpen={isOpen}
size="2xl"
title="جزئیات اعلان درون‌برنامه‌ای"
onOpenChange={onOpenChange}
>
<div className="space-y-4 text-sm leading-7">
<div>
<p className="font-semibold text-secondary-10">عنوان</p>
<p>{activeRow?.title ?? '—'}</p>
</div>
<div>
<p className="font-semibold text-secondary-10">متن اعلان</p>
<p className="whitespace-pre-wrap">{activeRow?.body ?? '—'}</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<p className="font-semibold text-secondary-10">مسیر مقصد</p>
<p dir="ltr">{activeRow?.actionUrl ?? CONSUMER_ROUTES.PROFILE_NOTIFICATIONS}</p>
</div>
<div>
<p className="font-semibold text-secondary-10">دسته</p>
<p>{activeRow?.category ?? '—'}</p>
</div>
<div>
<p className="font-semibold text-secondary-10">وضعیت خواندن</p>
<p>{activeRow?.readAt ? formatPersianDate(activeRow.readAt) : 'خوانده نشده'}</p>
</div>
<div>
<p className="font-semibold text-secondary-10">تاریخ ثبت</p>
<p>{formatPersianDate(activeRow?.createdAt)}</p>
</div>
</div>
</div>
</Modal>
</>
)
}
export default InAppNotificationsPanel

View File

@ -0,0 +1,158 @@
import type { ReactNode } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NotificationRulesPanel from '@/app/(dashboard)/notifications/_components/NotificationRulesPanel'
const axiosMocks = vi.hoisted(() => ({
get: vi.fn(),
patch: vi.fn(),
}))
vi.mock('@/config/axios', () => ({
default: {
get: axiosMocks.get,
patch: axiosMocks.patch,
},
}))
vi.mock('@/lib/toast', () => ({ addToast: vi.fn() }))
vi.mock('@/components/formElements/Input', () => ({
default: ({
description,
generalType,
label,
selectOptions,
value,
onValueChange,
}: {
description?: ReactNode
generalType?: string
label: string
selectOptions?: { code: string; name: string }[]
value?: unknown
onValueChange?: (value: unknown) => void
}) => (
<label>
{label}
{generalType === 'select' ? (
<select
aria-label={label}
value={String(value ?? '')}
onChange={(event) => onValueChange?.(event.target.value)}
>
{(selectOptions ?? []).map((option) => (
<option
key={option.code}
value={option.code}
>
{option.name}
</option>
))}
</select>
) : (
<input
aria-label={label}
value={String(value ?? '')}
onChange={(event) => onValueChange?.(generalType === 'switch' ? event.target.value === 'true' : event.target.value)}
/>
)}
{description ? <span>{description}</span> : null}
</label>
),
}))
vi.mock('@/components/formElements/Button', () => ({
default: ({ children, disabled, onClick }: { children: ReactNode; disabled?: boolean; onClick?: () => void }) => (
<button
disabled={disabled}
type="button"
onClick={onClick}
>
{children}
</button>
),
}))
const bookingRule = {
eventKey: 'booking_confirmed_guest',
displayName: 'تأیید رزرو برای مهمان',
description: 'پس از قطعی‌شدن رزرو',
enabled: true,
channel: 'in_app',
titleTemplate: 'رزرو شما تأیید شد',
bodyTemplate: 'رزرو شما برای «{{eventTitle}}» با موفقیت تأیید شد.',
actionUrlTemplate: '/bookings/{{bookingId}}',
allowedVariables: ['eventTitle', 'bookingId'],
sampleVariables: { eventTitle: 'دورهمی آخر هفته', bookingId: 'booking-sample' },
smsAllowed: true,
updatedAt: '2026-08-18T00:00:00.000Z',
}
const chatRule = {
eventKey: 'chat_message',
displayName: 'پیام جدید چت',
description: 'برای سایر اعضای گفتگو',
enabled: true,
channel: 'in_app',
titleTemplate: 'پیام جدید',
bodyTemplate: '{{messagePreview}}',
actionUrlTemplate: '/chats/{{conversationId}}',
allowedVariables: ['messagePreview', 'conversationId'],
sampleVariables: { messagePreview: 'سلام، ساعت را هماهنگ کنیم؟', conversationId: 'chat-sample' },
smsAllowed: false,
updatedAt: '2026-08-18T00:00:00.000Z',
}
describe('NotificationRulesPanel', () => {
afterEach(() => {
cleanup()
})
beforeEach(() => {
axiosMocks.get.mockReset()
axiosMocks.patch.mockReset()
axiosMocks.get.mockResolvedValue({
data: { success: true, data: [bookingRule, chatRule] },
})
})
it('shows allowed variables, sample preview, and keeps save disabled until dirty', async () => {
render(<NotificationRulesPanel />)
expect(await screen.findByText('{{eventTitle}}')).toBeInTheDocument()
expect(screen.getByText('رزرو شما برای «دورهمی آخر هفته» با موفقیت تأیید شد.')).toBeInTheDocument()
expect(screen.getByText('/bookings/booking-sample')).toBeInTheDocument()
expect(screen.queryByText('ذخیره نشده')).not.toBeInTheDocument()
const saveButtons = screen.getAllByRole('button', { name: 'ذخیره' })
expect(saveButtons[0]).toBeDisabled()
fireEvent.change(screen.getAllByLabelText('عنوان')[0], {
target: { value: 'رزرو قطعی شد' },
})
expect(await screen.findByText('ذخیره نشده')).toBeInTheDocument()
expect(screen.getByText('رزرو قطعی شد')).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'ذخیره' })[0]).not.toBeDisabled()
})
it('hides SMS channels for chat and warns when SMS-only is selected', async () => {
render(<NotificationRulesPanel />)
expect(await screen.findByText('پیام جدید چت')).toBeInTheDocument()
expect(screen.getByText('پیامک برای پیام‌های چت ارسال نمی‌شود؛ فقط اعلان درون‌برنامه‌ای و پوش.')).toBeInTheDocument()
const chatChannel = screen.getAllByLabelText('کانال ارسال')[1]
expect(chatChannel.querySelector('option[value="sms"]')).toBeNull()
expect(chatChannel.querySelector('option[value="in_app"]')).not.toBeNull()
fireEvent.change(screen.getAllByLabelText('کانال ارسال')[0], {
target: { value: 'sms' },
})
expect(await screen.findByText('با انتخاب فقط پیامک، اعلان درون‌برنامه‌ای و پوش ارسال نمی‌شود.')).toBeInTheDocument()
expect(screen.getByText(/کاراکتر/)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,325 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { coerceToString } from '@/helpers'
import { addToast } from '@/lib/toast'
import { renderNotificationActionUrlPreview, renderNotificationTemplate } from '@/lib/notificationTemplate'
import { countSmsSegments } from '@/lib/smsSegments'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import AdminState from '@/components/feedback/AdminState'
import axiosInstance from '@/config/axios'
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
import { API_ROUTES } from '@/services/config'
import { extractServerErrorDetail } from '@/services/errorHandler'
type Channel = 'in_app' | 'sms' | 'both'
interface Rule {
eventKey: string
displayName: string
description: string
enabled: boolean
channel: Channel
titleTemplate: string
bodyTemplate: string
actionUrlTemplate: string | null
allowedVariables: string[]
sampleVariables: Record<string, string>
smsAllowed: boolean
updatedAt: string
}
type RuleDraft = Pick<Rule, 'enabled' | 'channel' | 'titleTemplate' | 'bodyTemplate'> & {
actionUrlTemplate: string
}
const CHANNELS: { code: Channel; name: string }[] = [
{ code: 'in_app', name: 'درون‌برنامه‌ای + پوش' },
{ code: 'sms', name: 'پیامک' },
{ code: 'both', name: 'درون‌برنامه‌ای + پوش + پیامک' },
]
const toDraft = (rule: Rule): RuleDraft => ({
enabled: rule.enabled,
channel: rule.channel,
titleTemplate: rule.titleTemplate,
bodyTemplate: rule.bodyTemplate,
actionUrlTemplate: rule.actionUrlTemplate ?? '',
})
const isDirtyDraft = (current: RuleDraft, saved: RuleDraft | undefined) =>
Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved)
const NotificationRulesPanel = () => {
const [rules, setRules] = useState<Rule[]>([])
const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await axiosInstance.get(API_ROUTES.NOTIFICATION_RULES.ADMIN_LIST)
const payload = unwrapApiData<Rule[]>(response.data as ApiSuccessBody<Rule[]>)
const nextRules = Array.isArray(payload) ? payload : []
setRules(nextRules)
setSavedDrafts(Object.fromEntries(nextRules.map((rule) => [rule.eventKey, toDraft(rule)])))
} catch {
setError('دریافت تنظیمات اعلان‌ها ناموفق بود.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const change = (eventKey: string, patch: Partial<Rule>) => {
setRules((current) => current.map((rule) => (rule.eventKey === eventKey ? { ...rule, ...patch } : rule)))
}
const save = async (rule: Rule) => {
setSaving(rule.eventKey)
try {
const response = await axiosInstance.patch(API_ROUTES.NOTIFICATION_RULES.ADMIN_UPDATE(rule.eventKey), {
enabled: rule.enabled,
channel: rule.channel,
titleTemplate: rule.titleTemplate,
bodyTemplate: rule.bodyTemplate,
actionUrlTemplate: rule.actionUrlTemplate ?? '',
})
const updated = unwrapApiData<Rule>(response.data as ApiSuccessBody<Rule>)
if (updated) {
setRules((current) => current.map((item) => (item.eventKey === rule.eventKey ? { ...item, ...updated } : item)))
setSavedDrafts((current) => ({ ...current, [rule.eventKey]: toDraft({ ...rule, ...updated }) }))
}
addToast({ title: 'تنظیمات اعلان ذخیره شد', color: 'success' })
} catch (saveError) {
addToast({
title:
extractServerErrorDetail((saveError as { response?: { data?: unknown } })?.response?.data) ?? 'ذخیره تنظیمات اعلان ناموفق بود',
color: 'danger',
})
} finally {
setSaving(null)
}
}
return (
<div className="space-y-4">
<div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm leading-7 text-blue-900">
متغیرهای داخل دو آکولاد مثل <span dir="ltr">{'{{eventTitle}}'}</span> هنگام ارسال با اطلاعات واقعی جایگزین میشوند. فقط متغیرهای
همان رویداد مجاز است. اعلان درونبرنامهای در صورت فعالبودن اشتراک مرورگر، Web Push هم ارسال میکند.
</div>
{loading ? <p className="py-10 text-center text-sm text-secondary-30">در حال دریافت تنظیمات</p> : null}
{!loading && error ? (
<AdminState
actionLabel="تلاش دوباره"
description={error}
title="خطا"
variant="error"
onAction={() => void load()}
/>
) : null}
{!loading && !error && rules.length === 0 ? (
<AdminState
description="هنوز قاعدهٔ اعلانی برای ویرایش وجود ندارد."
title="تنظیماتی یافت نشد"
/>
) : null}
{rules.map((rule) => (
<RuleCard
key={rule.eventKey}
dirty={isDirtyDraft(toDraft(rule), savedDrafts[rule.eventKey])}
rule={rule}
saving={saving === rule.eventKey}
onChange={change}
onSave={() => void save(rule)}
/>
))}
</div>
)
}
const RuleCard = ({
dirty,
rule,
saving,
onChange,
onSave,
}: {
dirty: boolean
rule: Rule
saving: boolean
onChange: (eventKey: string, patch: Partial<Rule>) => void
onSave: () => void
}) => {
const channelOptions = useMemo(() => {
const allowed = CHANNELS.filter((channel) => channel.code === 'in_app' || rule.smsAllowed)
if (allowed.some((channel) => channel.code === rule.channel)) return allowed
const current = CHANNELS.find((channel) => channel.code === rule.channel)
return current ? [...allowed, current] : allowed
}, [rule.channel, rule.smsAllowed])
const includesSms = rule.channel === 'sms' || rule.channel === 'both'
const smsCount = includesSms ? countSmsSegments(rule.bodyTemplate) : null
const previewTitle = renderNotificationTemplate(rule.titleTemplate, rule.sampleVariables)
const previewBody = renderNotificationTemplate(rule.bodyTemplate, rule.sampleVariables)
const previewActionUrl = rule.actionUrlTemplate ? renderNotificationActionUrlPreview(rule.actionUrlTemplate, rule.sampleVariables) : ''
return (
<article className="admin-surface space-y-4 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-bold text-secondary-10">{rule.displayName}</h2>
{dirty ? (
<span
className="rounded-full bg-fourth-900/15 px-2 py-0.5 text-xs text-fourth-900"
role="status"
>
ذخیره نشده
</span>
) : null}
</div>
<p className="mt-1 text-sm text-secondary-30">{rule.description}</p>
<code className="mt-2 inline-block text-xs text-secondary-30">{rule.eventKey}</code>
</div>
<Input
generalType="switch"
label={rule.enabled ? 'فعال' : 'غیرفعال'}
name={`enabled-${rule.eventKey}`}
value={rule.enabled}
onValueChange={(enabled) => {
onChange(rule.eventKey, { enabled: Boolean(enabled) })
}}
/>
</div>
{rule.allowedVariables.length === 0 ? (
<p className="text-xs text-secondary-30">این رویداد متغیر قابلجایگزینی ندارد.</p>
) : (
<div>
<p className="mb-2 text-xs text-secondary-30">متغیرهای مجاز</p>
<div className="flex flex-wrap gap-2">
{rule.allowedVariables.map((variable) => (
<code
key={variable}
className="rounded-lg bg-surface-secondary px-2 py-1 text-xs text-secondary-30"
dir="ltr"
>
{`{{${variable}}}`}
</code>
))}
</div>
</div>
)}
<Input
description={rule.smsAllowed ? undefined : 'پیامک برای پیام‌های چت ارسال نمی‌شود؛ فقط اعلان درون‌برنامه‌ای و پوش.'}
generalType="select"
label="کانال ارسال"
name={`channel-${rule.eventKey}`}
selectKey="code"
selectOptions={channelOptions}
selectValue="name"
value={rule.channel}
variant="bordered"
onValueChange={(next) => {
if (next) onChange(rule.eventKey, { channel: coerceToString(next) as Channel })
}}
/>
{rule.channel === 'sms' ? (
<p
className="rounded-2xl border border-fourth-900/40 bg-fourth-900/10 p-3 text-sm leading-6 text-fourth-900"
role="status"
>
با انتخاب فقط پیامک، اعلان درونبرنامهای و پوش ارسال نمیشود.
</p>
) : null}
{!rule.smsAllowed && rule.channel !== 'in_app' ? (
<p
className="rounded-2xl border border-fourth-900/40 bg-fourth-900/10 p-3 text-sm leading-6 text-fourth-900"
role="alert"
>
پیامک برای این رویداد مجاز نیست. کانال را به درونبرنامهای برگردانید.
</p>
) : null}
<Input
generalType="input"
label="عنوان"
name={`title-${rule.eventKey}`}
value={rule.titleTemplate}
onValueChange={(titleTemplate) => {
onChange(rule.eventKey, { titleTemplate: coerceToString(titleTemplate) })
}}
/>
<Input
description={
smsCount
? `${smsCount.chars.toLocaleString('fa-IR')} کاراکتر · ${smsCount.segments.toLocaleString('fa-IR')} پیامک${
smsCount.segments > 1 ? ' — متن را کوتاه نگه دارید' : ''
}`
: undefined
}
generalType="textarea"
label="متن اعلان"
name={`body-${rule.eventKey}`}
value={rule.bodyTemplate}
onValueChange={(bodyTemplate) => {
onChange(rule.eventKey, { bodyTemplate: coerceToString(bodyTemplate) })
}}
/>
<Input
direction="ltr"
generalType="input"
label="مسیر مقصد پس از کلیک"
name={`actionUrl-${rule.eventKey}`}
value={rule.actionUrlTemplate ?? ''}
onValueChange={(actionUrlTemplate) => {
onChange(rule.eventKey, { actionUrlTemplate: coerceToString(actionUrlTemplate) })
}}
/>
<div className="rounded-2xl border border-border bg-surface-secondary p-4">
<p className="text-xs text-muted">پیشنمایش با دادهٔ نمونه</p>
<p className="mt-2 font-bold text-foreground">{previewTitle || '—'}</p>
<p className="mt-1 whitespace-pre-wrap text-sm leading-7 text-foreground">{previewBody || '—'}</p>
{previewActionUrl ? (
<code
className="mt-2 block text-xs text-muted"
dir="ltr"
>
{previewActionUrl}
</code>
) : null}
</div>
<div className="flex justify-end">
<Button
disabled={!dirty}
isLoading={saving}
variant="solid"
onClick={onSave}
>
ذخیره
</Button>
</div>
</article>
)
}
export default NotificationRulesPanel

View File

@ -0,0 +1,218 @@
'use client'
import { useState } from 'react'
import type { PaginationListColumnType } from '@/types'
import useDisclosure from '@/hooks/useDisclosure'
import PaginatedList from '@/components/PaginatedList'
import Button from '@/components/formElements/Button'
import Modal from '@/components/modals/Modal'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import { APP_ROUTES, CONSUMER_ROUTES } from '@/constants/routes'
import { coerceToString, formatPersonName } from '@/helpers'
import { API_ROUTES } from '@/services/config'
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
interface PushDeliveryRow {
id: string
notificationId: string
userId: string
subscriptionId: string
mobile: string
category: string
title: string
body: string
actionUrl: string | null
status: string
errorMessage: string | null
userAgent: string | null
displayedAt: string | null
openedAt: string | null
createdAt: string
recipient?: {
firstName: string | null
lastName: string | null
}
}
const columns: PaginationListColumnType[] = [
{ field: 'userId', label: 'گیرنده', filterable: true, sortable: false, type: 'text' },
{ field: 'mobile', label: 'موبایل', filterable: true, sortable: false, type: 'text' },
{ field: 'title', label: 'عنوان', filterable: false, sortable: false },
{
field: 'status',
label: 'ارسال',
filterable: true,
sortable: false,
type: 'select',
filterItems: [
{ code: 'sent', name: 'ارسال‌شده' },
{ code: 'failed', name: 'ناموفق' },
],
},
{ field: 'displayedAt', label: 'نمایش', filterable: false, sortable: false },
{ field: 'openedAt', label: 'بازشدن', filterable: false, sortable: false },
{ field: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true },
{ field: 'actions', label: 'عملیات' },
]
const getPushStatus = (row: PushDeliveryRow) => {
if (row.status === 'failed') {
return {
label: 'ناموفق',
chipColor: 'danger' as const,
description: row.errorMessage ?? undefined,
}
}
if (row.openedAt) {
return { label: 'باز شده', chipColor: 'success' as const }
}
if (row.displayedAt) {
return { label: 'نمایش داده شده', chipColor: 'warning' as const }
}
return { label: 'ارسال‌شده', chipColor: 'default' as const }
}
const PushDeliveriesPanel = () => {
const { isOpen, onOpen, onOpenChange } = useDisclosure()
const [activeDelivery, setActiveDelivery] = useState<PushDeliveryRow | null>(null)
const openDetails = (row: PushDeliveryRow) => {
setActiveDelivery(row)
onOpen()
}
return (
<>
<PaginatedList
columns={columns}
url={API_ROUTES.PUSH_DELIVERIES.ADMIN_LIST}
>
{{
userId: (row) => {
const delivery = row as unknown as PushDeliveryRow
const name = formatPersonName(delivery.recipient?.firstName, delivery.recipient?.lastName)
return (
<div className="flex flex-col gap-1">
{name !== '—' && <span className="text-sm">{name}</span>}
<span
className="text-text-muted text-xs"
dir="ltr"
>
{formatIranianMobile(delivery.mobile)}
</span>
</div>
)
},
mobile: (_row, cellValue) => <span dir="ltr">{formatIranianMobile(coerceToString(cellValue))}</span>,
title: (row, cellValue) => {
const delivery = row as unknown as PushDeliveryRow
return (
<div className="flex flex-col gap-1">
<span className="text-sm">{truncateValue(coerceToString(cellValue), 50)}</span>
<span className="text-text-muted text-xs">{truncateValue(delivery.body, 70)}</span>
</div>
)
},
status: (row) => {
const delivery = row as unknown as PushDeliveryRow
const status = getPushStatus(delivery)
return (
<StatusChip
chipColor={status.chipColor}
description={status.description}
label={status.label}
/>
)
},
displayedAt: (_row, cellValue) => formatPersianDate(cellValue),
openedAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const delivery = row as unknown as PushDeliveryRow
return (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="light"
onClick={() => {
openDetails(delivery)
}}
>
جزئیات
</Button>
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(delivery.userId)}
/>
</div>
)
},
}}
</PaginatedList>
<Modal
hideFooter
isOpen={isOpen}
size="2xl"
title="جزئیات تحویل پوش"
onOpenChange={onOpenChange}
>
<div className="space-y-4 text-sm leading-7">
<div>
<p className="font-semibold text-secondary-10">عنوان</p>
<p>{activeDelivery?.title ?? '—'}</p>
</div>
<div>
<p className="font-semibold text-secondary-10">متن اعلان</p>
<p className="whitespace-pre-wrap">{activeDelivery?.body ?? '—'}</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<p className="font-semibold text-secondary-10">مسیر مقصد</p>
<p dir="ltr">{activeDelivery?.actionUrl ?? CONSUMER_ROUTES.PROFILE_NOTIFICATIONS}</p>
</div>
<div>
<p className="font-semibold text-secondary-10">دسته</p>
<p>{activeDelivery?.category ?? '—'}</p>
</div>
<div>
<p className="font-semibold text-secondary-10">زمان نمایش</p>
<p>{formatPersianDate(activeDelivery?.displayedAt)}</p>
</div>
<div>
<p className="font-semibold text-secondary-10">زمان بازشدن</p>
<p>{formatPersianDate(activeDelivery?.openedAt)}</p>
</div>
</div>
<div>
<p className="font-semibold text-secondary-10">User-Agent</p>
<p
className="break-all text-xs text-secondary-30"
dir="ltr"
>
{activeDelivery?.userAgent ?? '—'}
</p>
</div>
{activeDelivery?.errorMessage ? (
<div>
<p className="font-semibold text-fourth-900">خطای تحویل</p>
<p className="whitespace-pre-wrap text-fourth-900">{activeDelivery.errorMessage}</p>
</div>
) : null}
</div>
</Modal>
</>
)
}
export default PushDeliveriesPanel

View File

@ -0,0 +1,375 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { addToast } from '@/lib/toast'
import Input from '@/components/formElements/Input'
import Button from '@/components/formElements/Button'
import Modal from '@/components/modals/Modal'
import axiosInstance from '@/config/axios'
import { coerceToString } from '@/helpers'
import { countSmsSegments } from '@/lib/smsSegments'
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
import { API_ROUTES } from '@/services/config'
import { extractServerErrorDetail } from '@/services/errorHandler'
type Channel = 'in_app' | 'sms' | 'both'
type FieldErrors = Partial<Record<'channel' | 'title' | 'body' | 'recipients' | 'actionUrl' | 'otpCode', string>>
const CHANNELS: { code: Channel; name: string }[] = [
{ code: 'in_app', name: 'درون‌برنامه‌ای و پوش' },
{ code: 'sms', name: 'پیامک' },
{ code: 'both', name: 'هر دو' },
]
const splitList = (value: string) =>
value
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean)
const INTERNAL_PATH_PATTERN = /^\/(?!\/).*/
interface ManualNotificationResponse {
recipientCount: number
missingMobiles: string[]
missingUserIds: string[]
}
interface SendManualNotificationModalProps {
defaultChannel: Channel
isOpen: boolean
onClose: () => void
onSent: () => void
}
const SendManualNotificationModal = ({ defaultChannel, isOpen, onClose, onSent }: SendManualNotificationModalProps) => {
const [channel, setChannel] = useState<Channel>(defaultChannel)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [actionUrl, setActionUrl] = useState('')
const [mobilesText, setMobilesText] = useState('')
const [userIdsText, setUserIdsText] = useState('')
const [otpCode, setOtpCode] = useState('')
const [otpRequested, setOtpRequested] = useState(false)
const [otpExpiresIn, setOtpExpiresIn] = useState<number | null>(null)
const [errors, setErrors] = useState<FieldErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isRequestingOtp, setIsRequestingOtp] = useState(false)
const includesInApp = channel === 'in_app' || channel === 'both'
const includesSms = channel === 'sms' || channel === 'both'
const recipientsPreview = useMemo(
() => ({
mobiles: splitList(mobilesText),
userIds: splitList(userIdsText),
}),
[mobilesText, userIdsText]
)
const totalRecipients = recipientsPreview.mobiles.length + recipientsPreview.userIds.length
const requiresBulkOtp = includesSms && totalRecipients >= 2
const smsCount = includesSms ? countSmsSegments(body) : null
const resetForm = () => {
setChannel(defaultChannel)
setTitle('')
setBody('')
setActionUrl('')
setMobilesText('')
setUserIdsText('')
setOtpCode('')
setOtpRequested(false)
setOtpExpiresIn(null)
setErrors({})
}
useEffect(() => {
if (!isOpen) return
setChannel(defaultChannel)
setErrors({})
}, [defaultChannel, isOpen])
const handleClose = () => {
if (isSubmitting) return
resetForm()
onClose()
}
const validate = (requireOtp = true): FieldErrors => {
const nextErrors: FieldErrors = {}
if (!CHANNELS.some((item) => item.code === channel)) {
nextErrors.channel = 'کانال ارسال را انتخاب کنید'
}
if (includesInApp && !title.trim()) nextErrors.title = 'عنوان اعلان الزامی است'
if (!body.trim()) nextErrors.body = 'متن الزامی است'
if (recipientsPreview.mobiles.length === 0 && recipientsPreview.userIds.length === 0) {
nextErrors.recipients = 'حداقل یک موبایل یا شناسه کاربر وارد کنید'
}
if (includesInApp && actionUrl.trim() && !INTERNAL_PATH_PATTERN.test(actionUrl.trim())) {
nextErrors.actionUrl = 'لینک مقصد باید با / شروع شود و داخلی باشد'
}
if (requireOtp && requiresBulkOtp && !otpCode.trim()) {
nextErrors.otpCode = 'برای ارسال گروهی پیامک، کد تأیید الزامی است'
}
return nextErrors
}
const handleRequestOtp = async () => {
const nextErrors = validate(false)
if (nextErrors.recipients || nextErrors.body || nextErrors.channel) {
setErrors(nextErrors)
return
}
try {
setIsRequestingOtp(true)
const response = await axiosInstance.post(API_ROUTES.MANUAL_NOTIFICATIONS.ADMIN_REQUEST_OTP)
const payload = unwrapApiData<{ expiresIn?: number }>(response.data as ApiSuccessBody<{ expiresIn?: number }>)
setOtpRequested(true)
setOtpExpiresIn(Number(payload?.expiresIn ?? 300))
addToast({
title: 'کد تأیید ارسال شد',
description: 'کد به موبایل ادمین (همان اکانت ورود) فرستاده شد.',
color: 'success',
})
} catch (err) {
addToast({
title: 'ارسال کد ناموفق بود',
description: extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data) ?? 'دوباره تلاش کنید.',
color: 'danger',
})
} finally {
setIsRequestingOtp(false)
}
}
const handleSubmit = async () => {
const nextErrors = validate()
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
try {
setIsSubmitting(true)
const response = await axiosInstance.post(API_ROUTES.MANUAL_NOTIFICATIONS.ADMIN_CREATE, {
channel,
title: includesInApp ? title.trim() : title.trim() || undefined,
body: body.trim(),
actionUrl: includesInApp ? actionUrl.trim() || undefined : undefined,
mobiles: recipientsPreview.mobiles,
userIds: recipientsPreview.userIds,
otpCode: requiresBulkOtp ? otpCode.trim() : undefined,
})
const payload = unwrapApiData<ManualNotificationResponse>(response.data as ApiSuccessBody<ManualNotificationResponse>)
const recipientCount = Number(payload?.recipientCount ?? 0)
const missingMobiles = Array.isArray(payload?.missingMobiles) ? payload.missingMobiles.length : 0
const missingUserIds = Array.isArray(payload?.missingUserIds) ? payload.missingUserIds.length : 0
const missingCount = missingMobiles + missingUserIds
addToast({
title: 'ارسال انجام شد',
description:
recipientCount > 0
? `${recipientCount} گیرنده پیدا شد${missingCount > 0 ? `؛ ${missingCount} ورودی هم پیدا نشد.` : '.'}`
: 'هیچ گیرنده‌ای resolve نشد.',
color: recipientCount > 0 ? 'success' : 'warning',
})
resetForm()
onSent()
} catch (err) {
addToast({
title: 'ارسال ناموفق بود',
description:
extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data) ??
'مقادیر ورودی را بررسی کنید و دوباره تلاش کنید.',
color: 'danger',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Modal
acceptBtnText="ارسال"
isLoading={isSubmitting}
isOpen={isOpen}
rejectBtnText="انصراف"
size="2xl"
title="ارسال اعلان"
onAccept={() => void handleSubmit()}
onOpenChange={(open) => {
if (!open) handleClose()
}}
onReject={handleClose}
>
<div className="flex flex-col gap-4">
<Input
generalType="select"
label="کانال ارسال"
name="channel"
selectKey="code"
selectOptions={CHANNELS}
selectValue="name"
value={channel}
variant="bordered"
onValueChange={(next) => {
if (next === 'in_app' || next === 'sms' || next === 'both') {
setChannel(next)
setOtpCode('')
setOtpRequested(false)
setOtpExpiresIn(null)
}
}}
/>
{errors.channel && <span className="text-tiny text-fourth-900">{errors.channel}</span>}
<p className="text-text-muted text-xs leading-6">
{channel === 'sms'
? 'فقط پیامک ارسال می‌شود. هزینه پیامک برای هر گیرنده محاسبه می‌شود.'
: channel === 'both'
? 'اعلان داخل اپ، پوش مرورگر (در صورت اشتراک فعال) و پیامک با هم ارسال می‌شوند.'
: 'اعلان داخل اپ ساخته می‌شود و اگر کاربر اشتراک مرورگر فعال داشته باشد، پوش هم می‌رود.'}
</p>
{includesInApp ? (
<>
<Input
generalType="input"
label="عنوان اعلان"
name="title"
value={title}
onValueChange={(next) => {
setTitle(coerceToString(next))
}}
/>
{errors.title && <span className="text-tiny text-fourth-900">{errors.title}</span>}
</>
) : null}
<Input
description={
smsCount
? `${smsCount.chars.toLocaleString('fa-IR')} کاراکتر · ${smsCount.segments.toLocaleString('fa-IR')} پیامک${
smsCount.segments > 1 ? ' — متن را کوتاه نگه دارید' : ''
}`
: undefined
}
generalType="textarea"
label={channel === 'sms' ? 'متن پیامک' : 'متن اعلان'}
name="body"
value={body}
onValueChange={(next) => {
setBody(coerceToString(next))
}}
/>
{errors.body && <span className="text-tiny text-fourth-900">{errors.body}</span>}
{includesInApp ? (
<>
<Input
direction="ltr"
generalType="input"
label="مسیر مقصد پس از کلیک (اختیاری)"
name="actionUrl"
placeholder="/profile"
value={actionUrl}
onValueChange={(next) => {
setActionUrl(coerceToString(next))
}}
/>
{errors.actionUrl && <span className="text-tiny text-fourth-900">{errors.actionUrl}</span>}
</>
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-1">
<Input
generalType="textarea"
label="موبایل کاربران"
name="mobiles"
placeholder={'09153641196\n989121234567'}
value={mobilesText}
onValueChange={(next) => {
setMobilesText(coerceToString(next))
setOtpCode('')
setOtpRequested(false)
setOtpExpiresIn(null)
}}
/>
<span className="text-text-muted text-xs">هر موبایل را در یک خط جدا یا با کاما وارد کنید.</span>
</div>
<div className="flex flex-col gap-1">
<Input
direction="ltr"
generalType="textarea"
label="شناسه کاربران"
name="userIds"
placeholder={'c3c921d5-4418-42ed-bf39-0f04b1d5123b'}
value={userIdsText}
onValueChange={(next) => {
setUserIdsText(coerceToString(next))
setOtpCode('')
setOtpRequested(false)
setOtpExpiresIn(null)
}}
/>
<span className="text-text-muted text-xs">برای هدفگیری دقیق میتوانید UUID کاربر را هم وارد کنید.</span>
</div>
</div>
{errors.recipients && <span className="text-tiny text-fourth-900">{errors.recipients}</span>}
{requiresBulkOtp ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-medium">ارسال گروهی پیامک نیاز به تأیید دارد</p>
<p className="mt-2 leading-6">
برای جلوگیری از ارسال اشتباهی، ابتدا کد تأیید را به موبایل ادمین بفرستید و سپس همان کد را وارد کنید.
</p>
<div className="mt-4 flex flex-col gap-3">
<Button
color="warning"
disabled={isRequestingOtp}
isLoading={isRequestingOtp}
type="button"
onClick={() => void handleRequestOtp()}
>
{isRequestingOtp ? 'در حال ارسال کد…' : 'ارسال کد تأیید به موبایل ادمین'}
</Button>
<Input
direction="ltr"
generalType="input"
label="کد تأیید"
name="otpCode"
placeholder="1234"
value={otpCode}
onValueChange={(next) => {
setOtpCode(coerceToString(next))
}}
/>
{errors.otpCode && <span className="text-tiny text-fourth-900">{errors.otpCode}</span>}
{otpRequested && otpExpiresIn ? (
<span className="text-xs text-amber-800">
کد تا {Math.floor(otpExpiresIn / 60).toLocaleString('fa-IR')} دقیقه معتبر است.
</span>
) : null}
</div>
</div>
) : null}
<div className="rounded-2xl border border-secondary-40 bg-secondary-50 p-4 text-sm text-secondary-20">
<p>پیشنمایش گیرندهها:</p>
<p className="mt-2">موبایل: {recipientsPreview.mobiles.length}</p>
<p>شناسه کاربر: {recipientsPreview.userIds.length}</p>
</div>
</div>
</Modal>
)
}
export default SendManualNotificationModal

View File

@ -0,0 +1,209 @@
'use client'
import { useState } from 'react'
import type { PaginationListColumnType } from '@/types'
import useDisclosure from '@/hooks/useDisclosure'
import PaginatedList from '@/components/PaginatedList'
import Button from '@/components/formElements/Button'
import Modal from '@/components/modals/Modal'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import { APP_ROUTES } from '@/constants/routes'
import { getSmsStatus, SMS_STATUS_FILTER_ITEMS } from '@/constants/status'
import { formatPersonName, coerceToString } from '@/helpers'
import { API_ROUTES } from '@/services/config'
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
interface SmsMessageRow {
id: string
notificationId: string
userId: string
mobile: string
messageBody: string
status: string
provider?: string | null
providerRef?: string | null
errorMessage?: string | null
sentAt?: string | null
deliveredAt?: string | null
createdAt: string
updatedAt: string
recipient?: {
firstName: string | null
lastName: string | null
}
}
// Read-only troubleshooting list — GET /admin/sms-messages only supports
// filtering by status/userId and sorting by createdAt (see
// backend/src/modules/notifications/notifications.service.ts,
// ADMIN_SMS_FILTER_KEYS / ALLOWED_SORT_FIELDS). Columns that aren't
// whitelisted there (provider, sentAt, deliveredAt) are shown but not
// marked filterable/sortable, so the UI never implies a capability the
// API doesn't have.
const columns: PaginationListColumnType[] = [
{
field: 'userId',
label: 'گیرنده',
filterable: true,
sortable: false,
type: 'text',
},
{
field: 'mobile',
label: 'موبایل',
filterable: true,
sortable: false,
type: 'text',
},
{
field: 'messageBody',
label: 'متن پیامک',
filterable: false,
sortable: false,
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
sortable: false,
type: 'select',
filterItems: SMS_STATUS_FILTER_ITEMS,
},
{
field: 'provider',
label: 'ارائه‌دهنده',
filterable: false,
sortable: false,
},
{
field: 'providerRef',
label: 'کد پیگیری',
filterable: false,
sortable: false,
},
{
field: 'sentAt',
label: 'زمان ارسال',
filterable: false,
sortable: false,
},
{
field: 'deliveredAt',
label: 'زمان تحویل',
filterable: false,
sortable: false,
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: false,
sortable: true,
},
{
field: 'actions',
label: 'عملیات',
},
]
const SmsMessagesPanel = () => {
const { isOpen, onOpenChange, onOpen } = useDisclosure()
const [activeMessage, setActiveMessage] = useState<SmsMessageRow | null>(null)
const openMessageModal = (row: SmsMessageRow) => {
setActiveMessage(row)
onOpen()
}
return (
<>
<PaginatedList
columns={columns}
url={API_ROUTES.SMS_MESSAGES.ADMIN_LIST}
>
{{
userId: (row) => {
const message = row as unknown as SmsMessageRow
const name = formatPersonName(message.recipient?.firstName, message.recipient?.lastName)
return (
<div className="flex flex-col gap-1">
{name !== '—' && <span className="text-sm">{name}</span>}
<span
className="text-text-muted text-xs"
dir="ltr"
>
{formatIranianMobile(message.mobile)}
</span>
</div>
)
},
mobile: (_row, cellValue) => <span dir="ltr">{formatIranianMobile(coerceToString(cellValue))}</span>,
messageBody: (row, cellValue) => {
const message = row as unknown as SmsMessageRow
const body = coerceToString(cellValue)
return (
<div className="flex flex-col gap-1">
<span className="text-sm">{truncateValue(body)}</span>
{body.length > 80 && (
<Button
className="w-fit"
size="sm"
variant="light"
onClick={() => {
openMessageModal(message)
}}
>
نمایش کامل
</Button>
)}
</div>
)
},
status: (row, cellValue) => {
const message = row as unknown as SmsMessageRow
const { label, chipColor } = getSmsStatus(coerceToString(cellValue))
return (
<StatusChip
chipColor={chipColor}
description={message.status === 'failed' ? message.errorMessage : undefined}
label={label}
/>
)
},
provider: (_row, cellValue) => (cellValue ? coerceToString(cellValue) : '—'),
providerRef: (_row, cellValue) => (cellValue ? coerceToString(cellValue) : '—'),
sentAt: (_row, cellValue) => formatPersianDate(cellValue),
deliveredAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const message = row as unknown as SmsMessageRow
return (
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(message.userId)}
/>
)
},
}}
</PaginatedList>
<Modal
hideFooter
isOpen={isOpen}
size="lg"
title="متن کامل پیامک"
onOpenChange={onOpenChange}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{activeMessage?.messageBody}</p>
</Modal>
</>
)
}
export default SmsMessagesPanel

View File

@ -0,0 +1,110 @@
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'
import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import { Tab } from '@/components/heroui/Tabs'
import AppTabs from '@/components/ui/AppTabs'
import { useQueryTab } from '@/hooks/useQueryTab'
const InAppNotificationsPanel = dynamic(() => import('@/app/(dashboard)/notifications/_components/InAppNotificationsPanel'), {
ssr: false,
})
const PushDeliveriesPanel = dynamic(() => import('@/app/(dashboard)/notifications/_components/PushDeliveriesPanel'), {
ssr: false,
})
const SmsMessagesPanel = dynamic(() => import('@/app/(dashboard)/notifications/_components/SmsMessagesPanel'), {
ssr: false,
})
const NotificationRulesPanel = dynamic(() => import('@/app/(dashboard)/notifications/_components/NotificationRulesPanel'), {
ssr: false,
})
const SendManualNotificationModal = dynamic(() => import('@/app/(dashboard)/notifications/_components/SendManualNotificationModal'), {
ssr: false,
})
const NOTIFICATION_TABS = ['in-app', 'push', 'sms', 'settings'] as const
type NotificationTab = (typeof NOTIFICATION_TABS)[number]
type SendChannel = 'in_app' | 'sms' | 'both'
const defaultChannelForTab = (tab: NotificationTab): SendChannel => (tab === 'sms' ? 'sms' : 'in_app')
export default function NotificationsPage() {
const [selectedTab, handleTabChange] = useQueryTab({
values: NOTIFICATION_TABS,
defaultValue: 'in-app',
})
const [isSendOpen, setIsSendOpen] = useState(false)
const [listEpoch, setListEpoch] = useState(0)
return (
<section className="h-full w-full text-right">
<PageNavbar
endSlot={
selectedTab === 'settings' ? undefined : (
<Button
size="sm"
variant="solid"
onClick={() => {
setIsSendOpen(true)
}}
>
ارسال
</Button>
)
}
pageTitle="اعلان‌ها"
/>
<div className="admin-page-container">
<AppTabs
aria-label="کانال‌های اعلان"
classNames={{ tabList: 'overflow-x-auto', tab: 'min-w-fit px-4' }}
selectedKey={selectedTab}
surface="admin"
onSelectionChange={handleTabChange}
>
<Tab
key="in-app"
title="درون‌برنامه‌ای"
>
{selectedTab === 'in-app' ? <InAppNotificationsPanel key={listEpoch} /> : null}
</Tab>
<Tab
key="push"
title="پوش"
>
{selectedTab === 'push' ? <PushDeliveriesPanel key={listEpoch} /> : null}
</Tab>
<Tab
key="sms"
title="پیامک‌ها"
>
{selectedTab === 'sms' ? <SmsMessagesPanel key={listEpoch} /> : null}
</Tab>
<Tab
key="settings"
title="تنظیمات"
>
{selectedTab === 'settings' ? <NotificationRulesPanel /> : null}
</Tab>
</AppTabs>
</div>
{isSendOpen ? (
<SendManualNotificationModal
defaultChannel={defaultChannelForTab(selectedTab)}
isOpen={isSendOpen}
onClose={() => {
setIsSendOpen(false)
}}
onSent={() => {
setIsSendOpen(false)
setListEpoch((current) => current + 1)
}}
/>
) : null}
</section>
)
}

View File

@ -0,0 +1,196 @@
'use client'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import StatusChip from '@/components/ui/StatusChip'
import PageNavbar from '@/components/layouts/PageNavbar'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import { APP_ROUTES } from '@/constants/routes'
import { formatCurrency, formatPersonName, coerceToString } from '@/helpers'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import { getPaymentMethod, getPaymentStatus, PAYMENT_METHOD_FILTER_ITEMS, PAYMENT_STATUS_FILTER_ITEMS } from '@/constants/status'
import { API_ROUTES } from '@/services/config'
interface PaymentUser {
id: string
mobile: string
firstName: string | null
lastName: string | null
}
const columns: PaginationListColumnType[] = [
{
field: 'paymentCode',
label: 'کد پرداخت',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'user',
label: 'کاربر',
filterable: false,
sortable: false,
},
{
field: 'eventTitle',
label: 'رویداد',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'bookingCode',
label: 'کد رزرو',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'method',
label: 'روش پرداخت',
filterable: true,
sortable: false,
type: 'select',
filterItems: PAYMENT_METHOD_FILTER_ITEMS,
},
{
field: 'gatewayProvider',
label: 'درگاه',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'gatewayTrackingId',
label: 'شناسه درگاه',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'gatewayRefId',
label: 'RRN درگاه',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
sortable: true,
type: 'select',
filterItems: PAYMENT_STATUS_FILTER_ITEMS,
},
{
field: 'totalAmount',
label: 'مبلغ کل',
filterable: false,
sortable: false,
},
{
field: 'paidAt',
label: 'تاریخ پرداخت',
filterable: false,
sortable: true,
type: 'date',
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: true,
sortable: true,
type: 'dateFromTo',
},
{
field: 'actions',
label: 'عملیات',
},
]
const PaymentsPage = () => {
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="پرداخت‌ها" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.PAYMENTS.ADMIN_LIST}
>
{{
user: (row) => {
const user = row.user as PaymentUser | undefined
if (!user) return '—'
const name = formatPersonName(user.firstName ?? undefined, user.lastName ?? undefined)
return (
<div className="flex flex-col">
<span>{name}</span>
<span
className="text-xs text-tertiary-300"
dir="ltr"
>
{formatIranianMobile(user.mobile)}
</span>
</div>
)
},
method: (row, cellValue) => {
const provider = coerceToString(row.gatewayProvider)
if (provider) {
return (
<StatusChip
chipColor="default"
label={`درگاه (${provider})`}
/>
)
}
return <StatusChip {...getPaymentMethod(coerceToString(cellValue))} />
},
gatewayProvider: (_row, cellValue) => coerceToString(cellValue) || '—',
gatewayTrackingId: (_row, cellValue) => (
<span
className="font-mono text-xs"
dir="ltr"
>
{coerceToString(cellValue) || '—'}
</span>
),
gatewayRefId: (_row, cellValue) => (
<span
className="font-mono text-xs"
dir="ltr"
>
{coerceToString(cellValue) || '—'}
</span>
),
status: (_row, cellValue) => <StatusChip {...getPaymentStatus(coerceToString(cellValue))} />,
totalAmount: (_row, cellValue) => formatCurrency(Number(cellValue ?? 0)),
paidAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const user = row.user as PaymentUser | undefined
if (!user) return '—'
return (
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(user.id)}
/>
)
},
}}
</PaginatedList>
</div>
</section>
)
}
export default PaymentsPage

View File

@ -0,0 +1,10 @@
import { redirect } from 'next/navigation'
// Province-level management UI is disabled — the admin surface only manages
// cities (see `cities/page.tsx`'s own note on why province filtering is
// off). The pre-disable implementation (a standalone PaginatedList of
// provinces) is preserved in git history (blame this file) rather than kept
// here commented out, should it need restoring.
export default function ProvincesPage() {
redirect('/cities')
}

View File

@ -0,0 +1,7 @@
import { redirect } from 'next/navigation'
import { APP_ROUTES } from '@/constants/routes'
export default function PushDeliveriesRedirectPage() {
redirect(APP_ROUTES.NOTIFICATIONS_PUSH)
}

View File

@ -0,0 +1,227 @@
'use client'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import EyeCrossedIcon from '@/components/icons/EyeCrossedIcon'
import FileCheckIcon from '@/components/icons/FileCheckIcon'
import TrashIcon from '@/components/icons/TrashIcon'
import AdminTableActions from '@/components/ui/AdminTableActions'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import axiosInstance from '@/config/axios'
import { APP_ROUTES } from '@/constants/routes'
import useAlertModal from '@/hooks/useAlertModal'
import useAdminMutation from '@/hooks/useAdminMutation'
import { formatPersonName, coerceToString } from '@/helpers'
import { formatPersianDate, truncateValue } from '@/lib/formatters'
import { getReviewStatus, REVIEW_STATUS_FILTER_ITEMS, type ReviewStatus } from '@/constants/status'
import { API_ROUTES } from '@/services/config'
const columns: PaginationListColumnType[] = [
{
field: 'eventId',
label: 'رویداد',
filterable: true,
type: 'text',
sortable: false,
},
{
field: 'userId',
label: 'نویسنده',
filterable: false,
sortable: false,
},
{
field: 'rating',
label: 'امتیاز',
filterable: true,
type: 'number',
sortable: true,
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
type: 'select',
sortable: false,
filterItems: REVIEW_STATUS_FILTER_ITEMS,
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: true,
sortable: true,
type: 'dateFromTo',
},
{
field: 'body',
label: 'متن نظر',
filterable: false,
sortable: false,
},
{
field: 'actions',
label: 'عملیات',
},
]
interface ReviewEventSummary {
id: string
title: string
}
interface ReviewUserSummary {
id: string
firstName: string | null
lastName: string | null
}
interface ReviewRow {
id: string
eventId: string
userId: string
status: ReviewStatus
event?: ReviewEventSummary
user?: ReviewUserSummary
[key: string]: unknown
}
const getEventSummary = (row: ReviewRow) => {
if (row.event && typeof row.event === 'object') {
return row.event
}
return { id: row.eventId, title: '—' }
}
const getUserSummary = (row: ReviewRow) => {
if (row.user && typeof row.user === 'object') {
return row.user
}
return { id: row.userId, firstName: null, lastName: null }
}
const ReviewsPage = () => {
const { showAlert } = useAlertModal()
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.REVIEWS.ADMIN_LIST })
const handleHide = (row: ReviewRow) => {
showAlert('این نظر از نمایش عمومی مخفی شود؟', () =>
runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_HIDE(row.id)), 'نظر مخفی شد')
)
}
const handleRestore = (row: ReviewRow) => {
void runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد')
}
const handleDelete = (row: ReviewRow) => {
showAlert(
'این نظر برای همیشه حذف شود؟ این عملیات قابل بازگشت نیست.',
() => runAction(row.id, () => axiosInstance.delete(API_ROUTES.REVIEWS.ADMIN_DELETE(row.id)), 'نظر حذف شد'),
undefined,
{ dangerAccept: true }
)
}
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="مدیریت نظرات" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.REVIEWS.ADMIN_LIST}
>
{{
eventId: (row) => getEventSummary(row as ReviewRow).title,
userId: (row) => {
const user = getUserSummary(row as ReviewRow)
return formatPersonName(user.firstName, user.lastName)
},
body: (_row, cellValue) => truncateValue(cellValue),
status: (_row, cellValue) => {
const { label, chipColor } = getReviewStatus(coerceToString(cellValue))
return (
<StatusChip
chipColor={chipColor}
label={label}
/>
)
},
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const review = row as ReviewRow
const isBusy = pendingId === review.id
const event = getEventSummary(review)
return (
<AdminTableActions>
<AdminTableViewButton
label="مشاهده رویداد"
mode="navigate"
to={APP_ROUTES.MANAGE_EVENT_DETAIL(event.id)}
/>
{review.status !== 'deleted' && review.status === 'published' ? (
<Button
iconOnly
aria-label="مخفی کردن نظر"
color="warning"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
handleHide(review)
}}
>
<EyeCrossedIcon className="size-4" />
</Button>
) : null}
{review.status !== 'deleted' && review.status === 'hidden' ? (
<Button
iconOnly
aria-label="بازگردانی نظر"
color="success"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
handleRestore(review)
}}
>
<FileCheckIcon className="size-4" />
</Button>
) : null}
{review.status !== 'deleted' ? (
<Button
iconOnly
aria-label="حذف نظر"
color="danger"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
handleDelete(review)
}}
>
<TrashIcon className="size-4 text-fourth-900" />
</Button>
) : null}
</AdminTableActions>
)
},
}}
</PaginatedList>
</div>
</section>
)
}
export default ReviewsPage

View File

@ -0,0 +1,204 @@
'use client'
import { useState } from 'react'
import DatePicker from 'react-multi-date-picker'
import persian from 'react-date-object/calendars/persian'
import persian_fa from 'react-date-object/locales/persian_fa'
import 'react-multi-date-picker/styles/layouts/mobile.css'
import { addToast } from '@/lib/toast'
import Input from '@/components/formElements/Input'
import Modal from '@/components/modals/Modal'
import axiosInstance from '@/config/axios'
import { convertToISOFormat, coerceToString } from '@/helpers'
import { API_ROUTES } from '@/services/config'
import { extractServerErrorDetail } from '@/services/errorHandler'
interface CreateSettlementModalProps {
isOpen: boolean
onClose: () => void
onCreated: () => void
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
type FieldErrors = Partial<Record<'organizerId' | 'bankAccountId' | 'periodStart' | 'periodEnd', string>>
function toDatePickerValue(value: string): Date | null {
if (!value) return null
const date = new Date(value)
return Number.isFinite(date.getTime()) ? date : null
}
const CreateSettlementModal = ({ isOpen, onClose, onCreated }: CreateSettlementModalProps) => {
const [organizerId, setOrganizerId] = useState('')
const [bankAccountId, setBankAccountId] = useState('')
const [periodStart, setPeriodStart] = useState('')
const [periodEnd, setPeriodEnd] = useState('')
const [errors, setErrors] = useState<FieldErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const resetForm = () => {
setOrganizerId('')
setBankAccountId('')
setPeriodStart('')
setPeriodEnd('')
setErrors({})
}
const handleClose = () => {
if (isSubmitting) return
resetForm()
onClose()
}
const validate = (): FieldErrors => {
const nextErrors: FieldErrors = {}
if (!organizerId.trim()) {
nextErrors.organizerId = 'شناسه میزبان الزامی است'
} else if (!UUID_PATTERN.test(organizerId.trim())) {
nextErrors.organizerId = 'شناسه میزبان باید یک UUID معتبر باشد'
}
if (!bankAccountId.trim()) {
nextErrors.bankAccountId = 'شناسه حساب بانکی الزامی است'
} else if (!UUID_PATTERN.test(bankAccountId.trim())) {
nextErrors.bankAccountId = 'شناسه حساب بانکی باید یک UUID معتبر باشد'
}
if (!periodStart) {
nextErrors.periodStart = 'ابتدای بازه الزامی است'
}
if (!periodEnd) {
nextErrors.periodEnd = 'انتهای بازه الزامی است'
}
if (periodStart && periodEnd && periodEnd < periodStart) {
nextErrors.periodEnd = 'انتهای بازه باید بعد از ابتدای بازه باشد'
}
return nextErrors
}
const handleSubmit = async () => {
const nextErrors = validate()
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
try {
setIsSubmitting(true)
await axiosInstance.post(API_ROUTES.SETTLEMENTS.ADMIN_CREATE, {
organizerId: organizerId.trim(),
bankAccountId: bankAccountId.trim(),
periodStart,
periodEnd,
})
addToast({ title: 'تسویه با موفقیت ایجاد شد', color: 'success' })
resetForm()
onCreated()
} catch (err) {
const detail = extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data)
addToast({
title: 'ایجاد تسویه ناموفق بود',
description: detail ?? 'ممکن است حساب بانکی معتبر نباشد یا درآمد قابل تسویه‌ای در این بازه وجود نداشته باشد.',
color: 'danger',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Modal
acceptBtnText="ایجاد تسویه"
isLoading={isSubmitting}
isOpen={isOpen}
rejectBtnText="انصراف"
size="lg"
title="ایجاد تسویه جدید"
onAccept={handleSubmit}
onOpenChange={(open) => {
if (!open) handleClose()
}}
onReject={handleClose}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Input
generalType="input"
label="شناسه میزبان (organizerId)"
name="organizerId"
placeholder="مثال: 3c1b2e2a-0000-0000-0000-000000000000"
value={organizerId}
onValueChange={(next) => {
setOrganizerId(coerceToString(next))
}}
/>
{errors.organizerId && <span className="text-tiny text-fourth-900">{errors.organizerId}</span>}
</div>
<div className="flex flex-col gap-1">
<Input
generalType="input"
label="شناسه حساب بانکی میزبان (bankAccountId)"
name="bankAccountId"
placeholder="مثال: 7fa1c9d4-0000-0000-0000-000000000000"
value={bankAccountId}
onValueChange={(next) => {
setBankAccountId(coerceToString(next))
}}
/>
{errors.bankAccountId && <span className="text-tiny text-fourth-900">{errors.bankAccountId}</span>}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<span className="text-sm text-text-muted">ابتدای بازه</span>
<DatePicker
portal
calendar={persian}
calendarPosition="bottom-right"
className={errors.periodStart ? 'date-picker-input date-picker-input--invalid' : 'date-picker-input'}
containerStyle={{ width: '100%' }}
format="YYYY/MM/DD"
locale={persian_fa}
placeholder="انتخاب تاریخ"
value={toDatePickerValue(periodStart)}
onChange={(date) => {
setPeriodStart(date ? convertToISOFormat(date).split('T')[0] : '')
}}
/>
{errors.periodStart && <span className="text-tiny text-fourth-900">{errors.periodStart}</span>}
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-text-muted">انتهای بازه</span>
<DatePicker
portal
calendar={persian}
calendarPosition="bottom-right"
className={errors.periodEnd ? 'date-picker-input date-picker-input--invalid' : 'date-picker-input'}
containerStyle={{ width: '100%' }}
format="YYYY/MM/DD"
locale={persian_fa}
placeholder="انتخاب تاریخ"
value={toDatePickerValue(periodEnd)}
onChange={(date) => {
setPeriodEnd(date ? convertToISOFormat(date).split('T')[0] : '')
}}
/>
{errors.periodEnd && <span className="text-tiny text-fourth-900">{errors.periodEnd}</span>}
</div>
</div>
</div>
</Modal>
)
}
export default CreateSettlementModal

View File

@ -0,0 +1,329 @@
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'
import { useQueryClient } from '@tanstack/react-query'
import { addToast } from '@/lib/toast'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import FileCheckIcon from '@/components/icons/FileCheckIcon'
import Modal from '@/components/modals/Modal'
import { ListSkeleton } from '@/components/feedback/LoadingState'
import AdminTableActions from '@/components/ui/AdminTableActions'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import axiosInstance from '@/config/axios'
import { getSettlementStatus, type SettlementStatus, SETTLEMENT_STATUS_FILTER_ITEMS } from '@/constants/status'
import { formatCurrency, coerceToString } from '@/helpers'
import { unwrapApiPayload } from '@/helpers/listResponse'
import useAdminMutation from '@/hooks/useAdminMutation'
import { formatPersianDate } from '@/lib/formatters'
import { adminKeys } from '@/queries/admin/adminKeys'
import { API_ROUTES } from '@/services/config'
const CreateSettlementModal = dynamic(() => import('@/app/(dashboard)/settlements/_components/CreateSettlementModal'), { ssr: false })
const ManualPayoutModal = dynamic(() => import('@/components/admin/ManualPayoutModal'), { ssr: false })
interface SettlementItem {
id: string
amount: number | string
organizerEarningId: string
eventId: string
eventTitle: string
}
interface SettlementRow {
id: string
settlementCode: string
organizerId: string
bankAccountId: string
periodStart: string
periodEnd: string
totalAmount: number | string
status: SettlementStatus
processedAt?: string | null
failureReason?: string | null
manualTrackingCode?: string | null
manualReceiptUrl?: string | null
manualNote?: string | null
createdAt: string
updatedAt: string
items?: SettlementItem[]
[key: string]: unknown
}
const COMPLETABLE_STATUSES: SettlementStatus[] = ['pending', 'processing']
const columns: PaginationListColumnType[] = [
{
field: 'settlementCode',
label: 'کد تسویه',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'organizerId',
label: 'میزبان',
filterable: true,
sortable: false,
type: 'text',
hideInTable: true,
},
{
field: 'period',
label: 'بازه زمانی',
filterable: false,
sortable: false,
},
{
field: 'totalAmount',
label: 'مبلغ کل',
filterable: false,
sortable: true,
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
sortable: true,
type: 'select',
filterItems: SETTLEMENT_STATUS_FILTER_ITEMS,
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: false,
sortable: true,
type: 'date',
},
{
field: 'actions',
label: 'عملیات',
},
]
const formatDate = (value: unknown, withTime = false) =>
formatPersianDate(value, withTime ? { dateStyle: 'medium', timeStyle: 'short' } : { dateStyle: 'medium' })
const SettlementsPage = () => {
const queryClient = useQueryClient()
const { pendingId, runAction } = useAdminMutation({
errorMessage: 'تکمیل تسویه با خطا مواجه شد',
url: API_ROUTES.SETTLEMENTS.ADMIN_LIST,
})
const [detailTarget, setDetailTarget] = useState<SettlementRow | null>(null)
const [detailItems, setDetailItems] = useState<SettlementItem[]>([])
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [manualTarget, setManualTarget] = useState<SettlementRow | null>(null)
const openDetail = async (row: SettlementRow) => {
setDetailTarget(row)
setDetailItems([])
setIsLoadingDetail(true)
try {
const response = await axiosInstance.get(API_ROUTES.SETTLEMENTS.ADMIN_DETAIL(row.id))
const payload = unwrapApiPayload<{ items?: SettlementItem[] }>(response.data)
setDetailItems(Array.isArray(payload.items) ? payload.items : [])
} catch {
addToast({ title: 'بارگذاری جزئیات تسویه ناموفق بود', color: 'danger' })
} finally {
setIsLoadingDetail(false)
}
}
const closeDetail = () => {
setDetailTarget(null)
setDetailItems([])
}
return (
<section className="h-full w-full text-right">
<PageNavbar
endSlot={
<Button
size="sm"
onClick={() => {
setIsCreateOpen(true)
}}
>
ایجاد تسویه
</Button>
}
pageTitle="تسویه‌ها"
/>
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.SETTLEMENTS.ADMIN_LIST}
>
{{
period: (row) => {
const settlement = row as SettlementRow
return (
<span className="text-sm">
{formatDate(settlement.periodStart)} {formatDate(settlement.periodEnd)}
</span>
)
},
totalAmount: (_row, cellValue) => formatCurrency(Number(cellValue ?? 0)),
createdAt: (_row, cellValue) => formatDate(cellValue, true),
status: (row, cellValue) => {
const settlement = row as SettlementRow
const { label, chipColor } = getSettlementStatus(coerceToString(cellValue))
return (
<StatusChip
chipColor={chipColor}
description={settlement.status === 'failed' ? settlement.failureReason : undefined}
label={label}
/>
)
},
actions: (row) => {
const settlement = row as SettlementRow
const isBusy = pendingId === settlement.id
const canComplete = COMPLETABLE_STATUSES.includes(settlement.status)
return (
<AdminTableActions>
<AdminTableViewButton
label="مشاهده جزئیات تسویه"
mode="open-detail"
onClick={() => openDetail(settlement)}
/>
{canComplete ? (
<Button
iconOnly
aria-label="تکمیل تسویه"
color="success"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
setManualTarget(settlement)
}}
>
<FileCheckIcon className="size-4" />
</Button>
) : null}
</AdminTableActions>
)
},
}}
</PaginatedList>
</div>
<Modal
hideFooter
isLoading={isLoadingDetail}
isOpen={Boolean(detailTarget)}
rejectBtnText="بستن"
size="2xl"
title={detailTarget ? `اقلام تسویه «${detailTarget.settlementCode}»` : ''}
onOpenChange={(open) => {
if (!open) closeDetail()
}}
onReject={closeDetail}
>
{isLoadingDetail ? (
<ListSkeleton count={3} />
) : (
<div className="flex flex-col gap-3">
{detailTarget?.status === 'failed' && detailTarget?.failureReason ? (
<div className="rounded-md bg-fourth-100 px-3 py-2 text-sm text-fourth-900">
<span className="font-semibold">دلیل شکست: </span>
{detailTarget.failureReason}
</div>
) : null}
{detailTarget?.status === 'completed' ? (
<div className="flex flex-col gap-2 rounded-md bg-fifth-50 px-3 py-3 text-sm">
<p>
<span className="font-semibold">شماره پیگیری: </span>
<span dir="ltr">{detailTarget.manualTrackingCode ?? '—'}</span>
</p>
{detailTarget.manualNote ? (
<p>
<span className="font-semibold">یادداشت: </span>
{detailTarget.manualNote}
</p>
) : null}
{detailTarget.manualReceiptUrl ? (
<a
className="w-fit text-primary underline"
href={detailTarget.manualReceiptUrl}
rel="noreferrer"
target="_blank"
>
مشاهده رسید پرداخت
</a>
) : null}
</div>
) : null}
{detailItems.length === 0 ? (
<div className="py-6 text-center text-sm text-text-muted">هیچ قلمی برای این تسویه ثبت نشده است.</div>
) : (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-[1fr_auto] gap-2 border-b border-divider pb-2 text-xs font-semibold text-text-muted">
<span>رویداد</span>
<span>مبلغ</span>
</div>
{detailItems.map((item) => (
<div
key={item.id}
className="grid grid-cols-[1fr_auto] items-center gap-2 border-b border-divider py-2 text-sm last:border-b-0"
>
<span>{item.eventTitle}</span>
<span>{formatCurrency(Number(item.amount ?? 0))}</span>
</div>
))}
</div>
)}
</div>
)}
</Modal>
{isCreateOpen ? (
<CreateSettlementModal
isOpen={isCreateOpen}
onClose={() => {
setIsCreateOpen(false)
}}
onCreated={() => {
setIsCreateOpen(false)
void queryClient.invalidateQueries({ queryKey: adminKeys.listByUrl(API_ROUTES.SETTLEMENTS.ADMIN_LIST) })
}}
/>
) : null}
{manualTarget ? (
<ManualPayoutModal
isOpen
isLoading={manualTarget.id === pendingId}
title={`ثبت پرداخت «${manualTarget.settlementCode}»`}
onClose={() => {
setManualTarget(null)
}}
onSubmit={async (payload) => {
return runAction(
manualTarget.id,
() => axiosInstance.patch(API_ROUTES.SETTLEMENTS.ADMIN_COMPLETE(manualTarget.id), payload),
'تسویه با رسید دستی تکمیل شد'
)
}}
/>
) : null}
</section>
)
}
export default SettlementsPage

View File

@ -0,0 +1,7 @@
import { redirect } from 'next/navigation'
import { APP_ROUTES } from '@/constants/routes'
export default function SmsMessagesRedirectPage() {
redirect(APP_ROUTES.NOTIFICATIONS_SMS)
}

View File

@ -0,0 +1,148 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import { APP_ROUTES } from '@/constants/routes'
import { addToast } from '@/lib/toast'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import {
adminReplyToSupportTicket,
getAdminSupportTicket,
SUPPORT_ADMIN_STATUS_LABELS,
SUPPORT_CATEGORY_LABELS,
SUPPORT_PRIORITY_LABELS,
updateSupportTicketStatus,
type SupportTicket,
} from '@/services/supportTickets'
const AdminSupportTicketDetail = () => {
const { id } = useParams<{ id: string }>()
const [ticket, setTicket] = useState<SupportTicket | null>(null)
const [reply, setReply] = useState('')
const [pending, setPending] = useState(false)
useEffect(() => {
void getAdminSupportTicket(id)
.then(setTicket)
.catch(() => addToast({ title: 'دریافت تیکت ناموفق بود', color: 'danger' }))
}, [id])
const send = async () => {
if (!reply.trim()) return
try {
setPending(true)
const updated = await adminReplyToSupportTicket(id, reply)
setTicket(updated)
setReply('')
addToast({ title: 'پاسخ ثبت شد و پیامک کاربر در صف ارسال قرار گرفت', color: 'success' })
} catch {
addToast({ title: 'ثبت پاسخ ناموفق بود', color: 'danger' })
} finally {
setPending(false)
}
}
const changeStatus = async (status: string) => {
try {
setTicket(await updateSupportTicketStatus(id, status))
addToast({ title: 'وضعیت تیکت تغییر کرد', color: 'success' })
} catch {
addToast({ title: 'تغییر وضعیت ناموفق بود', color: 'danger' })
}
}
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle={ticket?.subject ?? 'جزئیات تیکت'} />
<div className="admin-page-container mx-auto max-w-4xl space-y-5">
<Button
size="sm"
to={APP_ROUTES.SUPPORT_TICKETS}
variant="light"
>
بازگشت به تیکتها
</Button>
{!ticket ? (
<p className="py-12 text-center text-text-muted">در حال دریافت</p>
) : (
<>
<div className="rounded-2xl border border-default-200 bg-white p-5">
<div className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div>
<p className="text-text-muted">کاربر</p>
<strong>{ticket.user.displayName || 'کاربر'}</strong>
<p dir="ltr">{formatIranianMobile(ticket.user.mobile)}</p>
</div>
<div>
<p className="text-text-muted">دستهبندی</p>
<strong>{SUPPORT_CATEGORY_LABELS[ticket.category]}</strong>
</div>
<div>
<p className="text-text-muted">اولویت</p>
<strong>{SUPPORT_PRIORITY_LABELS[ticket.priority]}</strong>
</div>
<label>
<span className="text-text-muted">وضعیت</span>
<select
className="mt-1 block w-full rounded-lg border border-default-300 p-2"
value={ticket.status}
onChange={(event) => void changeStatus(event.target.value)}
>
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
<option
key={value}
value={value}
>
{label}
</option>
))}
</select>
</label>
</div>
</div>
<div className="space-y-3 rounded-2xl border border-default-200 bg-white p-5">
{ticket.messages.map((message) => (
<div
key={message.id}
className={`max-w-[82%] rounded-2xl p-4 ${message.isAdmin ? 'mr-auto bg-primary text-white' : 'ml-auto bg-default-100'}`}
>
<p className="text-xs opacity-70">{message.isAdmin ? 'پشتیبانی' : ticket.user.displayName || 'کاربر'}</p>
<p className="mt-1 whitespace-pre-wrap leading-7">{message.body}</p>
<p className="mt-2 text-xs opacity-60">{formatPersianDate(message.createdAt)}</p>
</div>
))}
</div>
{ticket.status !== 'closed' ? (
<div className="rounded-2xl border border-default-200 bg-white p-5">
<Input
generalType="textarea"
label="پاسخ پشتیبانی"
name="adminReply"
textAreaMinRows={5}
value={reply}
onValueChange={(value) => {
setReply(String(value))
}}
/>
<p className="my-3 text-xs text-text-muted">پس از ثبت پاسخ، برای کاربر پیامک اطلاعرسانی ارسال میشود.</p>
<Button
isLoading={pending}
onClick={() => void send()}
>
ثبت پاسخ و ارسال پیامک
</Button>
</div>
) : null}
</>
)}
</div>
</section>
)
}
export default AdminSupportTicketDetail

View File

@ -0,0 +1,93 @@
'use client'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import { APP_ROUTES } from '@/constants/routes'
import { API_ROUTES } from '@/services/config'
import {
SUPPORT_ADMIN_STATUS_LABELS,
SUPPORT_CATEGORY_LABELS,
SUPPORT_PRIORITY_LABELS,
type SupportTicket,
} from '@/services/supportTickets'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import { coerceToString } from '@/helpers'
const columns: PaginationListColumnType[] = [
{ field: 'subject', label: 'موضوع', filterable: false, sortable: false },
{ field: 'user', label: 'کاربر', filterable: false, sortable: false },
{
field: 'category',
label: 'دسته‌بندی',
filterable: true,
sortable: false,
type: 'select',
filterItems: Object.entries(SUPPORT_CATEGORY_LABELS).map(([code, name]) => ({ code, name })),
},
{
field: 'priority',
label: 'اولویت',
filterable: true,
sortable: false,
type: 'select',
filterItems: Object.entries(SUPPORT_PRIORITY_LABELS).map(([code, name]) => ({ code, name })),
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
sortable: false,
type: 'select',
filterItems: Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([code, name]) => ({ code, name })),
},
{ field: 'lastMessageAt', label: 'آخرین پیام', filterable: false, sortable: true },
{ field: 'actions', label: 'عملیات' },
]
const AdminSupportTicketsPage = () => (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="تیکت‌های پشتیبانی" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.SUPPORT_TICKETS.ADMIN_LIST}
>
{{
subject: (_row, value) => coerceToString(value),
user: (row) => {
const ticket = row as unknown as SupportTicket
return (
<div>
<p>{ticket.user.displayName || 'کاربر'}</p>
<p
className="text-xs text-text-muted"
dir="ltr"
>
{formatIranianMobile(ticket.user.mobile)}
</p>
</div>
)
},
category: (_row, value) => SUPPORT_CATEGORY_LABELS[coerceToString(value)] ?? coerceToString(value),
priority: (_row, value) => SUPPORT_PRIORITY_LABELS[coerceToString(value)] ?? coerceToString(value),
status: (_row, value) => SUPPORT_ADMIN_STATUS_LABELS[coerceToString(value)] ?? coerceToString(value),
lastMessageAt: (_row, value) => formatPersianDate(value),
actions: (row) => (
<Button
size="sm"
to={APP_ROUTES.SUPPORT_TICKET_DETAIL((row as unknown as SupportTicket).id)}
variant="light"
>
مشاهده و پاسخ
</Button>
),
}}
</PaginatedList>
</div>
</section>
)
export default AdminSupportTicketsPage

View File

@ -0,0 +1,272 @@
'use client'
import { useState } from 'react'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import CloseCircleIcon from '@/components/icons/CloseCircleIcon'
import CloseIcon from '@/components/icons/CloseIcon'
import FileCheckIcon from '@/components/icons/FileCheckIcon'
import Modal from '@/components/modals/Modal'
import AdminTableActions from '@/components/ui/AdminTableActions'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import axiosInstance from '@/config/axios'
import { APP_ROUTES } from '@/constants/routes'
import useAlertModal from '@/hooks/useAlertModal'
import useAdminMutation from '@/hooks/useAdminMutation'
import { formatPersonName, coerceToString } from '@/helpers'
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
import { getReportStatus, REPORT_STATUS_FILTER_ITEMS, type ReportStatus } from '@/constants/status'
import { API_ROUTES } from '@/services/config'
const columns: PaginationListColumnType[] = [
{
field: 'reporterId',
label: 'گزارش‌دهنده',
filterable: true,
type: 'text',
sortable: false,
},
{
field: 'reportedId',
label: 'کاربر گزارش‌شده',
filterable: true,
type: 'text',
sortable: false,
},
{
field: 'reason',
label: 'دلیل',
filterable: false,
sortable: false,
},
{
field: 'description',
label: 'توضیحات',
filterable: false,
sortable: false,
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
type: 'select',
sortable: true,
filterItems: REPORT_STATUS_FILTER_ITEMS,
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: false,
sortable: true,
type: 'dateFromTo',
},
{
field: 'actions',
label: 'عملیات',
},
]
interface ReportReasonSummary {
id: number
code: string
label: string
sortOrder: number
}
interface ReportUserSummary {
mobile: string
firstName: string | null
lastName: string | null
}
interface UserReportRow {
id: string
reporterId: string
reportedId: string
reasonId?: number | null
description?: string | null
status: ReportStatus
reviewedAt?: string | null
reviewedBy?: string | null
createdAt: string
reason?: ReportReasonSummary | null
reporter: ReportUserSummary
reported: ReportUserSummary
[key: string]: unknown
}
const UserPartyCell = ({ summary }: { summary: ReportUserSummary }) => (
<div className="flex flex-col">
<span>{formatPersonName(summary.firstName, summary.lastName)}</span>
<span className="text-text-muted text-xs">{formatIranianMobile(summary.mobile)}</span>
</div>
)
const UserReportsPage = () => {
const { showAlert } = useAlertModal()
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.USER_REPORTS.ADMIN_LIST })
const [selectedDescription, setSelectedDescription] = useState<string | null>(null)
const handleReview = (row: UserReportRow) => {
showAlert('این گزارش بررسی و تأیید شود؟', () =>
runAction(row.id, () => axiosInstance.patch(API_ROUTES.USER_REPORTS.ADMIN_REVIEW(row.id)), 'گزارش بررسی شد')
)
}
const handleDismiss = (row: UserReportRow) => {
showAlert('این گزارش رد شود؟', () =>
runAction(row.id, () => axiosInstance.patch(API_ROUTES.USER_REPORTS.ADMIN_DISMISS(row.id)), 'گزارش رد شد')
)
}
const handleBlock = (row: UserReportRow) => {
showAlert(
'کاربر گزارش‌شده به‌نمایندگی از گزارش‌دهنده مسدود شود؟ این گزارش نیز به‌عنوان بررسی‌شده علامت‌گذاری می‌شود.',
() => runAction(row.id, () => axiosInstance.post(API_ROUTES.USER_REPORTS.ADMIN_BLOCK(row.id)), 'کاربر مسدود و گزارش بررسی شد'),
undefined,
{ dangerAccept: true }
)
}
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="گزارش‌های کاربران" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.USER_REPORTS.ADMIN_LIST}
>
{{
reporterId: (row) => {
const report = row as UserReportRow
return <UserPartyCell summary={report.reporter} />
},
reportedId: (row) => {
const report = row as UserReportRow
return <UserPartyCell summary={report.reported} />
},
reason: (row) => {
const report = row as UserReportRow
return report.reason?.label ?? '—'
},
description: (row) => {
const report = row as UserReportRow
const description = report.description
if (!description || description.trim().length === 0) return '—'
return (
<Button
className="h-auto min-h-0 justify-start p-0 text-sm text-primary hover:underline"
size="sm"
variant="light"
onClick={() => {
setSelectedDescription(description)
}}
>
{truncateValue(description)}
</Button>
)
},
status: (row, cellValue) => {
const report = row as UserReportRow
const { label, chipColor } = getReportStatus(coerceToString(cellValue))
const isClosed = report.status === 'reviewed' || report.status === 'dismissed'
return (
<StatusChip
chipColor={chipColor}
description={isClosed && report.reviewedAt ? formatPersianDate(report.reviewedAt) : undefined}
label={label}
/>
)
},
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const report = row as UserReportRow
const isBusy = pendingId === report.id
return (
<AdminTableActions>
<AdminTableViewButton
label="مشاهده کاربر گزارش‌شده"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(report.reportedId)}
/>
{report.status === 'pending' ? (
<>
<Button
iconOnly
aria-label="تأیید بررسی گزارش"
color="success"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
handleReview(report)
}}
>
<FileCheckIcon className="size-4" />
</Button>
<Button
iconOnly
aria-label="رد گزارش"
color="default"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
handleDismiss(report)
}}
>
<CloseIcon className="size-4" />
</Button>
<Button
iconOnly
aria-label="مسدودسازی کاربر"
color="danger"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
handleBlock(report)
}}
>
<CloseCircleIcon className="size-4 text-fourth-900" />
</Button>
</>
) : null}
</AdminTableActions>
)
},
}}
</PaginatedList>
</div>
<Modal
hideFooter
isOpen={selectedDescription !== null}
title="توضیحات گزارش"
onClose={() => {
setSelectedDescription(null)
}}
onOpenChange={(isOpen) => {
if (!isOpen) setSelectedDescription(null)
}}
>
<p className="whitespace-pre-wrap text-sm text-text-main">{selectedDescription}</p>
</Modal>
</section>
)
}
export default UserReportsPage

View File

@ -0,0 +1,262 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useState } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { addToast } from '@/lib/toast'
import type { AdminUserDetail } from '@/services/adminUserDetail'
import Modal from '@/components/modals/Modal'
import Input from '@/components/formElements/Input'
import { AdminFormSection } from '@/components/forms/AdminFormLayout'
import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator'
import { ADMIN_UPDATE_USER, type AdminUpdateUserPayload } from '@/services/adminUsers'
import { API_ROUTES } from '@/services/config'
import { AdminUserEditValidation, type AdminUserEditValues } from '@/validation/adminUsers'
import useAlertModal from '@/hooks/useAlertModal'
import axiosInstance from '@/config/axios'
const STATUS_OPTIONS = [
{ id: 'active', name: 'فعال' },
{ id: 'suspended', name: 'معلق' },
]
const HOST_PLAN_OPTIONS = [
{ id: 'free', name: 'رایگان (حداکثر ۱۰ ایونت)' },
{ id: 'unlimited', name: 'نامحدود' },
]
const GENDER_OPTIONS = [
{ id: 'male', name: 'مرد' },
{ id: 'female', name: 'زن' },
{ id: 'other', name: 'سایر' },
]
interface CityOption {
id: number
name: string
}
const toFormValues = (user: AdminUserDetail): AdminUserEditValues => ({
firstName: user.firstName ?? '',
lastName: user.lastName ?? '',
gender: user.gender ?? '',
cityId: user.cityId ? String(user.cityId) : '',
avatarUrl: user.avatarUrl ?? '',
bio: user.bio ?? '',
status: user.status === 'suspended' ? 'suspended' : 'active',
hostPlan: user.hostPlan ?? 'free',
})
interface UserEditModalProps {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
user: AdminUserDetail
/** Current admin's own id — status can't be self-edited. */
currentAdminId?: string
onSuccess: () => void
}
const UserEditModal = ({ isOpen, onOpenChange, user, currentAdminId, onSuccess }: UserEditModalProps) => {
const { showAlert } = useAlertModal()
const [submitting, setSubmitting] = useState(false)
const [cities, setCities] = useState<CityOption[]>([])
const isSelfEdit = Boolean(currentAdminId) && currentAdminId === user.id
const form = useForm<AdminUserEditValues>({
resolver: zodResolver(AdminUserEditValidation),
defaultValues: toFormValues(user),
})
const { reset } = form
// Modal stays mounted across opens, so reset explicitly whenever it opens
// (and the target user's data may have refreshed since the last edit).
useEffect(() => {
if (isOpen) {
reset(toFormValues(user))
}
}, [isOpen, reset, user])
useEffect(() => {
if (!isOpen || cities.length > 0) return
axiosInstance
.get(API_ROUTES.GEOGRAPHY.ALL_CITIES)
.then((res) => {
const payload: unknown = res.data
let list: unknown = []
if (Array.isArray(payload)) {
list = payload
} else if (typeof payload === 'object' && payload !== null) {
const record = payload as { data?: unknown; body?: unknown }
list = record.data ?? record.body ?? []
}
setCities(Array.isArray(list) ? (list as { id: number; name: string }[]) : [])
})
.catch(() => {
setCities([])
})
}, [isOpen, cities.length])
const buildDiffPayload = (values: AdminUserEditValues): AdminUpdateUserPayload => {
const initial = toFormValues(user)
const payload: AdminUpdateUserPayload = {}
if (values.firstName && values.firstName !== initial.firstName) payload.firstName = values.firstName
if (values.lastName && values.lastName !== initial.lastName) payload.lastName = values.lastName
if (values.gender && values.gender !== initial.gender) payload.gender = values.gender
if (values.cityId && values.cityId !== initial.cityId) payload.cityId = Number(values.cityId)
if (values.avatarUrl && values.avatarUrl !== initial.avatarUrl) payload.avatarUrl = values.avatarUrl
if (values.bio && values.bio !== initial.bio) payload.bio = values.bio
if (!isSelfEdit && values.status !== initial.status) payload.status = values.status
if (values.hostPlan !== initial.hostPlan) payload.hostPlan = values.hostPlan
return payload
}
const submitUpdate = async (values: AdminUserEditValues) => {
const payload = buildDiffPayload(values)
if (Object.keys(payload).length === 0) {
onOpenChange(false)
return
}
setSubmitting(true)
const result = await ADMIN_UPDATE_USER(user.id, payload)
setSubmitting(false)
if (!result.ok) return
addToast({ title: 'اطلاعات کاربر ذخیره شد', color: 'success' })
onOpenChange(false)
onSuccess()
}
const handleSubmit = (values: AdminUserEditValues) => {
// Suspending an account is destructive-ish for the user, so confirm first.
const initial = toFormValues(user)
if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') {
showAlert('این کاربر معلق شود؟ کاربر تا فعال‌سازی مجدد امکان استفاده از حساب را نخواهد داشت.', () => submitUpdate(values))
return
}
void submitUpdate(values)
}
return (
<Modal
acceptBtnText="ذخیره تغییرات"
isLoading={submitting}
isOpen={isOpen}
title={`ویرایش «${[user.firstName, user.lastName].filter(Boolean).join(' ') || user.mobile}»`}
onAccept={form.handleSubmit(handleSubmit)}
onOpenChange={onOpenChange}
>
<FormProvider {...form}>
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<UnsavedChangesIndicator isDirty={form.formState.isDirty} />
<AdminFormSection
contained={false}
description="اطلاعات عمومی نمایش‌داده‌شده در پروفایل کاربر"
title="اطلاعات پروفایل"
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input
generalType="input"
label="نام"
name="firstName"
placeholder="نام"
variant="flat"
/>
<Input
generalType="input"
label="نام خانوادگی"
name="lastName"
placeholder="نام خانوادگی"
variant="flat"
/>
</div>
<Input
generalType="select"
label="پلن میزبانی"
name="hostPlan"
selectKey="id"
selectOptions={HOST_PLAN_OPTIONS}
selectValue="name"
/>
<Input
generalType="select"
label="جنسیت"
name="gender"
selectKey="id"
selectOptions={GENDER_OPTIONS}
selectValue="name"
/>
<Input
generalType="select"
label="شهر"
name="cityId"
selectKey="id"
selectOptions={cities}
selectValue="name"
/>
<Input
direction="ltr"
generalType="input"
label="آدرس تصویر پروفایل"
name="avatarUrl"
placeholder="https://..."
variant="flat"
/>
<Input
generalType="textarea"
label="بیوگرافی"
name="bio"
placeholder="بیوگرافی کوتاه کاربر"
variant="flat"
/>
</div>
</AdminFormSection>
<AdminFormSection
contained={false}
description="سطح دسترسی و امکان استفاده از حساب"
title="تنظیمات مدیریتی"
>
<div className="flex flex-col gap-4">
{isSelfEdit && (
<div className="rounded-xl border border-fourth-100 bg-fourth-100 px-3 py-2 text-xs leading-5 text-fourth-900">
وضعیت حساب خودتان از اینجا قابل ویرایش نیست.
</div>
)}
<Input
disabled={isSelfEdit}
generalType="select"
label="وضعیت حساب"
name="status"
selectKey="id"
selectOptions={STATUS_OPTIONS}
selectValue="name"
/>
</div>
</AdminFormSection>
</form>
</FormProvider>
</Modal>
)
}
export default UserEditModal

View File

@ -0,0 +1,133 @@
import type { ReactNode } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import UserMobileChangeModal from '@/app/(dashboard)/users/[id]/_components/UserMobileChangeModal'
const changeMobile = vi.fn()
const addToast = vi.fn()
vi.mock('@/services/adminUsers', () => ({
ADMIN_CHANGE_USER_MOBILE: (...args: unknown[]) => changeMobile(...args),
}))
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
vi.mock('@/components/formElements/Input', () => ({
default: ({ label, value, onValueChange }: { label: string; value?: unknown; onValueChange?: (value: string) => void }) => (
<label>
{label}
<input
aria-label={label}
value={String(value ?? '')}
onChange={(event) => onValueChange?.(event.target.value)}
/>
</label>
),
}))
vi.mock('@/components/formElements/Button', () => ({
default: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
<button
type="button"
onClick={onClick}
>
{children}
</button>
),
}))
vi.mock('@/components/modals/Modal', () => ({
default: ({
isOpen,
title,
children,
acceptBtnText,
rejectBtnText,
acceptBtnDisabled,
onAccept,
onReject,
}: {
isOpen: boolean
title: string
children: ReactNode
acceptBtnText: string
rejectBtnText: string
acceptBtnDisabled?: boolean
onAccept?: () => void
onReject?: () => void
}) =>
isOpen ? (
<div
aria-label={title}
role="dialog"
>
{children}
<button
type="button"
onClick={onReject}
>
{rejectBtnText}
</button>
<button
disabled={acceptBtnDisabled}
type="button"
onClick={onAccept}
>
{acceptBtnText}
</button>
</div>
) : null,
}))
describe('UserMobileChangeModal', () => {
afterEach(cleanup)
beforeEach(() => {
vi.clearAllMocks()
changeMobile.mockResolvedValue({ ok: true, data: { id: 'user-1', mobile: '989121231231' } })
})
it('requires all three confirmation stages before changing the mobile', async () => {
const onSuccess = vi.fn()
const onOpenChange = vi.fn()
render(
<UserMobileChangeModal
isOpen
user={{ id: 'user-1', mobile: '989111111111', firstName: 'Test', lastName: 'User' }}
onOpenChange={onOpenChange}
onSuccess={onSuccess}
/>
)
const firstAccept = screen.getByRole('button', { name: 'بررسی شماره' })
expect(firstAccept).toBeDisabled()
fireEvent.change(screen.getByLabelText('شماره موبایل جدید'), { target: { value: '09121231231' } })
expect(firstAccept).toBeEnabled()
fireEvent.click(firstAccept)
const secondAccept = screen.getByRole('button', { name: 'تأیید و ادامه' })
expect(secondAccept).toBeDisabled()
fireEvent.change(screen.getByLabelText('برای تأیید، شماره فعلی کاربر را وارد کنید'), {
target: { value: '09111111111' },
})
expect(secondAccept).toBeEnabled()
fireEvent.click(secondAccept)
const finalAccept = screen.getByRole('button', { name: 'تغییر قطعی شماره' })
expect(finalAccept).toBeDisabled()
fireEvent.change(screen.getByLabelText('عبارت «تغییر شماره» را وارد کنید'), {
target: { value: 'تغییر شماره' },
})
expect(finalAccept).toBeEnabled()
fireEvent.click(finalAccept)
await waitFor(() => {
expect(changeMobile).toHaveBeenCalledWith('user-1', '989121231231')
})
expect(addToast).toHaveBeenCalledWith(expect.objectContaining({ color: 'success' }))
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})

View File

@ -0,0 +1,247 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import Modal from '@/components/modals/Modal'
import { formatIranianMobile } from '@/lib/formatters'
import { addToast } from '@/lib/toast'
import type { AdminUserDetail } from '@/services/adminUserDetail'
import { ADMIN_CHANGE_USER_MOBILE } from '@/services/adminUsers'
import {
confirmsCurrentMobile,
confirmsFinalMobileChange,
MOBILE_CHANGE_FINAL_CONFIRMATION,
normalizeAdminMobile,
} from '@/validation/adminUserMobile'
type MobileChangeUser = Pick<AdminUserDetail, 'id' | 'mobile' | 'firstName' | 'lastName'>
interface UserMobileChangeModalProps {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
user: MobileChangeUser
onSuccess: () => void
}
const TOTAL_STEPS = 3
const UserMobileChangeModal = ({ isOpen, onOpenChange, user, onSuccess }: UserMobileChangeModalProps) => {
const [step, setStep] = useState(1)
const [newMobileInput, setNewMobileInput] = useState('')
const [confirmedNewMobile, setConfirmedNewMobile] = useState('')
const [currentMobileConfirmation, setCurrentMobileConfirmation] = useState('')
const [finalConfirmation, setFinalConfirmation] = useState('')
const [submitting, setSubmitting] = useState(false)
const normalizedNewMobile = useMemo(() => normalizeAdminMobile(newMobileInput), [newMobileInput])
const newMobileError = useMemo(() => {
if (!newMobileInput.trim()) return null
if (!normalizedNewMobile) return 'شماره موبایل معتبر نیست.'
if (normalizedNewMobile === user.mobile) return 'شماره جدید باید با شماره فعلی متفاوت باشد.'
return null
}, [newMobileInput, normalizedNewMobile, user.mobile])
const reset = useCallback(() => {
setStep(1)
setNewMobileInput('')
setConfirmedNewMobile('')
setCurrentMobileConfirmation('')
setFinalConfirmation('')
setSubmitting(false)
}, [])
useEffect(() => {
if (isOpen) reset()
}, [isOpen, reset, user.id])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) reset()
onOpenChange(nextOpen)
}
const canContinue =
step === 1
? Boolean(normalizedNewMobile) && normalizedNewMobile !== user.mobile
: step === 2
? confirmsCurrentMobile(currentMobileConfirmation, user.mobile)
: confirmsFinalMobileChange(finalConfirmation)
const submit = async () => {
setSubmitting(true)
const result = await ADMIN_CHANGE_USER_MOBILE(user.id, confirmedNewMobile)
setSubmitting(false)
if (!result.ok) return
addToast({
color: 'success',
title: 'شماره موبایل کاربر تغییر کرد',
description: 'تمام نشست‌های فعال کاربر باطل شدند.',
})
onSuccess()
handleOpenChange(false)
}
const handleAccept = () => {
if (!canContinue) return
if (step === 1 && normalizedNewMobile) {
setConfirmedNewMobile(normalizedNewMobile)
setStep(2)
return
}
if (step === 2) {
setStep(3)
return
}
void submit()
}
const handleReject = () => {
if (step > 1) {
setStep((current) => current - 1)
return
}
handleOpenChange(false)
}
const displayName = [user.firstName, user.lastName].filter(Boolean).join(' ') || formatIranianMobile(user.mobile)
return (
<Modal
acceptBtnDisabled={!canContinue}
acceptBtnText={step === 1 ? 'بررسی شماره' : step === 2 ? 'تأیید و ادامه' : 'تغییر قطعی شماره'}
acceptDanger={step === 3}
isDismissable={!submitting}
isLoading={submitting}
isOpen={isOpen}
rejectBtnText={step === 1 ? 'انصراف' : 'مرحله قبل'}
title={`تغییر شماره «${displayName}»`}
onAccept={handleAccept}
onOpenChange={handleOpenChange}
onReject={handleReject}
>
<div className="flex flex-col gap-5">
<div
aria-label={`مرحله ${step} از ${TOTAL_STEPS}`}
aria-valuemax={TOTAL_STEPS}
aria-valuemin={1}
aria-valuenow={step}
className="flex items-center gap-2"
role="progressbar"
>
{Array.from({ length: TOTAL_STEPS }, (_, index) => (
<span
key={index}
className={`h-2 flex-1 rounded-full ${index < step ? 'bg-primary' : 'bg-secondary-40'}`}
/>
))}
</div>
{step === 1 && (
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-secondary-40 bg-secondary-50 p-3 text-sm leading-6 text-secondary-30">
شماره فعلی:{' '}
<span
className="font-bold text-secondary-10"
dir="ltr"
>
{formatIranianMobile(user.mobile)}
</span>
</div>
<Input
autoFocus
englishDigitsOnly
required
direction="ltr"
generalType="input"
label="شماره موبایل جدید"
name="newMobile"
placeholder="09121231231"
value={newMobileInput}
variant="flat"
onValueChange={(value) => {
setNewMobileInput(typeof value === 'string' ? value : '')
}}
/>
{newMobileError && <p className="text-xs font-medium text-fourth">{newMobileError}</p>}
</div>
)}
{step === 2 && (
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-fourth-100 bg-fourth-100 p-4 text-sm leading-7 text-fourth-900">
<p className="font-bold">پیامدهای این تغییر را دوباره بررسی کنید:</p>
<ul className="mt-2 list-inside list-disc">
<li>شماره قبلی برای ثبتنام یک حساب جدید آزاد میشود.</li>
<li>شماره جدید تا ورود موفق با OTP تأییدنشده خواهد بود.</li>
<li>تمام نشستها و توکنهای فعال کاربر فوراً باطل میشوند.</li>
</ul>
<div className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 border-t border-fourth-100 pt-3">
<span>از</span>
<b dir="ltr">{formatIranianMobile(user.mobile)}</b>
<span>به</span>
<b dir="ltr">{formatIranianMobile(confirmedNewMobile)}</b>
</div>
</div>
<Input
autoFocus
englishDigitsOnly
direction="ltr"
generalType="input"
label="برای تأیید، شماره فعلی کاربر را وارد کنید"
name="currentMobileConfirmation"
placeholder={formatIranianMobile(user.mobile)}
value={currentMobileConfirmation}
variant="flat"
onValueChange={(value) => {
setCurrentMobileConfirmation(typeof value === 'string' ? value : '')
}}
/>
</div>
)}
{step === 3 && (
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-fourth/20 bg-fourth/5 p-4 text-sm leading-7 text-fourth">
این آخرین تأیید است. پس از انجام عملیات، کاربر از همه دستگاهها خارج میشود و باید با شماره جدید OTP دریافت کند.
</div>
<Input
autoFocus
generalType="input"
label={`عبارت «${MOBILE_CHANGE_FINAL_CONFIRMATION}» را وارد کنید`}
name="finalConfirmation"
placeholder={MOBILE_CHANGE_FINAL_CONFIRMATION}
value={finalConfirmation}
variant="flat"
onValueChange={(value) => {
setFinalConfirmation(typeof value === 'string' ? value : '')
}}
/>
</div>
)}
{step > 1 && (
<Button
className="self-start px-0"
color="default"
size="sm"
variant="light"
onClick={handleReject}
>
بازگشت به مرحله قبل
</Button>
)}
</div>
</Modal>
)
}
export default UserMobileChangeModal

View File

@ -0,0 +1,103 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useState } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { addToast } from '@/lib/toast'
import type { AdminUserDetail } from '@/services/adminUserDetail'
import Modal from '@/components/modals/Modal'
import Input from '@/components/formElements/Input'
import { formatToman } from '@/features/admin-users/adminUserDetailUi'
import { ADMIN_CREDIT_WALLET } from '@/services/adminWallet'
import { AdminWalletCreditValidation, type AdminWalletCreditValues } from '@/validation/adminWalletCredit'
const DEFAULT_VALUES: AdminWalletCreditValues = {
amount: '',
description: '',
}
interface WalletCreditModalProps {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
user: AdminUserDetail
onSuccess: () => void
}
const WalletCreditModal = ({ isOpen, onOpenChange, user, onSuccess }: WalletCreditModalProps) => {
const [submitting, setSubmitting] = useState(false)
const form = useForm<AdminWalletCreditValues>({
resolver: zodResolver(AdminWalletCreditValidation),
defaultValues: DEFAULT_VALUES,
})
const { reset } = form
useEffect(() => {
if (isOpen) {
reset(DEFAULT_VALUES)
}
}, [isOpen, reset])
const handleSubmit = async (values: AdminWalletCreditValues) => {
const description = values.description?.trim()
const payload = {
amount: Number(values.amount),
...(description ? { description } : {}),
}
setSubmitting(true)
const result = await ADMIN_CREDIT_WALLET(user.id, payload)
setSubmitting(false)
if (!result.ok) return
addToast({
title: `کیف‌پول شارژ شد · موجودی جدید: ${formatToman(result.data.walletBalance)}`,
color: 'success',
})
onOpenChange(false)
onSuccess()
}
const displayName = [user.firstName, user.lastName].filter(Boolean).join(' ') || user.mobile
return (
<Modal
acceptBtnText="شارژ کیف‌پول"
isLoading={submitting}
isOpen={isOpen}
title={`شارژ کیف‌پول «${displayName}»`}
onAccept={form.handleSubmit(handleSubmit)}
onOpenChange={onOpenChange}
>
<FormProvider {...form}>
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<p className="text-sm text-text-muted">
موجودی فعلی: <span className="font-medium text-text-dark">{formatToman(user.walletBalance)}</span>
</p>
<Input
englishDigitsOnly
direction="ltr"
generalType="input"
label="مبلغ (تومان)"
name="amount"
placeholder="مثلاً ۱۰۰۰۰۰"
/>
<Input
generalType="textarea"
label="توضیحات (اختیاری)"
name="description"
placeholder="حداقل ۳ کاراکتر در صورت وارد کردن"
/>
</form>
</FormProvider>
</Modal>
)
}
export default WalletCreditModal

View File

@ -0,0 +1,444 @@
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'
import { useParams } from 'next/navigation'
import { Tab } from '@/components/heroui/Tabs'
import useDisclosure from '@/hooks/useDisclosure'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import { DetailSkeleton, TableSkeleton } from '@/components/feedback/LoadingState'
import AdminState from '@/components/feedback/AdminState'
import { APP_ROUTES } from '@/constants/routes'
import { formatPersonName, coerceToString } from '@/helpers'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import StatusChip from '@/components/ui/StatusChip'
import AppTabs from '@/components/ui/AppTabs'
import useAuth from '@/hooks/useAuth'
import useAlertModal from '@/hooks/useAlertModal'
import { addToast } from '@/lib/toast'
import { useAdminUserDetail } from '@/features/admin-users/useAdminUserDetail'
import { ADMIN_ATTEST_IDENTITY } from '@/services/adminUserDetail'
import {
AdminUserActivitySummary,
AdminUserContactInfo,
AdminUserOverview,
blocksColumns,
bookingsColumns,
followsColumns,
formatToman,
hostedEventsColumns,
paymentsColumns,
reportsColumns,
reviewsColumns,
} from '@/features/admin-users/adminUserDetailUi'
import {
getActiveStatus,
getBooleanStatus,
getBookingStatus,
getEventStatus,
getPaymentMethod,
getPaymentStatus,
getReportStatus,
getReviewStatus,
} from '@/constants/status'
import { API_ROUTES } from '@/services/config'
const UserEditModal = dynamic(() => import('@/app/(dashboard)/users/[id]/_components/UserEditModal'), { ssr: false })
const WalletCreditModal = dynamic(() => import('@/app/(dashboard)/users/[id]/_components/WalletCreditModal'), { ssr: false })
const UserMobileChangeModal = dynamic(() => import('@/app/(dashboard)/users/[id]/_components/UserMobileChangeModal'), { ssr: false })
const UserDetailPage = () => {
const params = useParams<{ id: string }>()
const userId = params.id
const { user: currentAdmin } = useAuth()
const currentAdminId = currentAdmin?.userId
const { data: detail, error, isLoading, refetch: refetchDetail } = useAdminUserDetail(userId)
const { showAlert } = useAlertModal()
const { isOpen: isEditOpen, onOpen: openEdit, onOpenChange: onEditOpenChange } = useDisclosure()
const { isOpen: isCreditOpen, onOpen: openCredit, onOpenChange: onCreditOpenChange } = useDisclosure()
const { isOpen: isMobileChangeOpen, onOpen: openMobileChange, onOpenChange: onMobileChangeOpenChange } = useDisclosure()
const [editModalLoaded, setEditModalLoaded] = useState(false)
const [creditModalLoaded, setCreditModalLoaded] = useState(false)
const [mobileChangeModalLoaded, setMobileChangeModalLoaded] = useState(false)
const canChangeMobile = Boolean(detail && currentAdminId && currentAdminId !== detail.id)
const canAttestIdentity = Boolean(detail && detail.identityStatus !== 'verified')
const handleOpenEdit = () => {
setEditModalLoaded(true)
openEdit()
}
const handleOpenCredit = () => {
setCreditModalLoaded(true)
openCredit()
}
const handleOpenMobileChange = () => {
setMobileChangeModalLoaded(true)
openMobileChange()
}
const handleAttestIdentity = () => {
if (!userId || !canAttestIdentity) return
showAlert('احراز هویت این کاربر بدون ارسال درخواست از طرف خودش تأیید شود؟ پس از تأیید می‌تواند رویداد بسازد.', async () => {
const result = await ADMIN_ATTEST_IDENTITY(userId)
if (!result.ok) return
addToast({ title: 'احراز هویت تأیید شد', color: 'success' })
await refetchDetail()
})
}
return (
<section className="h-full w-full text-right">
<PageNavbar
endSlot={
<div className="flex items-center gap-2">
{detail && (
<>
{canChangeMobile && (
<Button
color="warning"
size="sm"
variant="flat"
onClick={handleOpenMobileChange}
>
تغییر شماره
</Button>
)}
<Button
size="sm"
variant="flat"
onClick={handleOpenCredit}
>
شارژ کیفپول
</Button>
{canAttestIdentity && (
<Button
color="success"
size="sm"
variant="flat"
onClick={handleAttestIdentity}
>
تأیید احراز هویت
</Button>
)}
<Button
size="sm"
variant="solid"
onClick={handleOpenEdit}
>
ویرایش
</Button>
</>
)}
<Button
size="sm"
to={APP_ROUTES.USERS}
variant="flat"
>
بازگشت به لیست
</Button>
</div>
}
pageTitle={detail ? formatPersonName(detail.firstName, detail.lastName, 'جزئیات کاربر') : 'جزئیات کاربر'}
/>
{detail && (
<>
{editModalLoaded ? (
<UserEditModal
currentAdminId={currentAdminId}
isOpen={isEditOpen}
user={detail}
onOpenChange={onEditOpenChange}
onSuccess={() => void refetchDetail()}
/>
) : null}
{creditModalLoaded ? (
<WalletCreditModal
isOpen={isCreditOpen}
user={detail}
onOpenChange={onCreditOpenChange}
onSuccess={() => void refetchDetail()}
/>
) : null}
{canChangeMobile && mobileChangeModalLoaded ? (
<UserMobileChangeModal
isOpen={isMobileChangeOpen}
user={detail}
onOpenChange={onMobileChangeOpenChange}
onSuccess={() => void refetchDetail()}
/>
) : null}
</>
)}
<div className="admin-page-container flex flex-col gap-5">
{isLoading && (
<div className="flex flex-col gap-5">
<DetailSkeleton />
<div className="admin-surface overflow-hidden">
<TableSkeleton />
</div>
</div>
)}
{!isLoading && error && (
<div className="admin-surface overflow-hidden">
<AdminState
actionLabel="تلاش دوباره"
description={error.message}
title="دریافت اطلاعات کاربر ناموفق بود"
variant="error"
onAction={() => void refetchDetail()}
/>
</div>
)}
{!isLoading && detail && (
<>
<AdminUserOverview detail={detail} />
<AdminUserContactInfo detail={detail} />
<AppTabs
aria-label="جزئیات کاربر"
surface="admin"
>
<Tab
key="hosted-events"
title="رویدادهای میزبانی‌شده"
>
<div className="flex justify-end pb-2">
<Button
size="sm"
to={`${APP_ROUTES.MANAGE_EVENTS}?filters[organizerId]=${userId}`}
variant="flat"
>
مشاهده در لیست کلی
</Button>
</div>
<PaginatedList
columns={hostedEventsColumns}
itemsKey="events"
url={API_ROUTES.USERS.ADMIN_HOSTED_EVENTS(userId)}
>
{{
status: (_row, cellValue) => <StatusChip {...getEventStatus(coerceToString(cellValue))} />,
startsAt: (_row, cellValue) => formatPersianDate(cellValue),
isFree: (_row, cellValue) => <StatusChip {...getBooleanStatus(Boolean(cellValue))} />,
price: (_row, cellValue) => formatToman(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
}}
</PaginatedList>
</Tab>
<Tab
key="bookings"
title="رزروها"
>
<div className="flex justify-end pb-2">
<Button
size="sm"
to={`${APP_ROUTES.BOOKINGS}?filters[userId]=${userId}`}
variant="flat"
>
مشاهده در لیست کلی
</Button>
</div>
<PaginatedList
columns={bookingsColumns}
url={API_ROUTES.USERS.ADMIN_BOOKINGS(userId)}
>
{{
status: (_row, cellValue) => <StatusChip {...getBookingStatus(coerceToString(cellValue))} />,
checkedInAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
}}
</PaginatedList>
</Tab>
<Tab
key="payments"
title="پرداخت‌ها"
>
<div className="flex justify-end pb-2">
<Button
size="sm"
to={`${APP_ROUTES.PAYMENTS}?filters[userId]=${userId}`}
variant="flat"
>
مشاهده در لیست کلی
</Button>
</div>
<PaginatedList
columns={paymentsColumns}
url={API_ROUTES.USERS.ADMIN_PAYMENTS(userId)}
>
{{
method: (_row, cellValue) => <StatusChip {...getPaymentMethod(coerceToString(cellValue))} />,
status: (_row, cellValue) => <StatusChip {...getPaymentStatus(coerceToString(cellValue))} />,
totalAmount: (_row, cellValue) => formatToman(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
}}
</PaginatedList>
</Tab>
<Tab
key="reviews-written"
title="نظرات نوشته‌شده"
>
<PaginatedList
columns={reviewsColumns}
url={`${API_ROUTES.USERS.ADMIN_REVIEWS(userId)}?type=written`}
>
{{
status: (_row, cellValue) => <StatusChip {...getReviewStatus(coerceToString(cellValue))} />,
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
}}
</PaginatedList>
</Tab>
<Tab
key="reviews-received"
title="نظرات دریافت‌شده"
>
<PaginatedList
columns={reviewsColumns}
url={`${API_ROUTES.USERS.ADMIN_REVIEWS(userId)}?type=received`}
>
{{
status: (_row, cellValue) => <StatusChip {...getReviewStatus(coerceToString(cellValue))} />,
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
}}
</PaginatedList>
</Tab>
<Tab
key="following"
title="دنبال‌شونده‌ها"
>
<PaginatedList
columns={followsColumns}
url={`${API_ROUTES.USERS.ADMIN_FOLLOWS(userId)}?type=following`}
>
{{
mobile: (_row, cellValue) => formatIranianMobile(cellValue),
notifyNewEvents: (_row, cellValue) => <StatusChip {...getActiveStatus(Boolean(cellValue))} />,
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
}}
</PaginatedList>
</Tab>
<Tab
key="followers"
title="دنبال‌کننده‌ها"
>
<PaginatedList
columns={followsColumns}
url={`${API_ROUTES.USERS.ADMIN_FOLLOWS(userId)}?type=followers`}
>
{{
mobile: (_row, cellValue) => formatIranianMobile(cellValue),
notifyNewEvents: (_row, cellValue) => <StatusChip {...getActiveStatus(Boolean(cellValue))} />,
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
}}
</PaginatedList>
</Tab>
<Tab
key="reports-filed"
title="گزارش‌های ثبت‌شده"
>
<div className="flex justify-end pb-2">
<Button
size="sm"
to={`${APP_ROUTES.USER_REPORTS}?filters[reporterId]=${userId}`}
variant="flat"
>
مشاهده در صف بررسی
</Button>
</div>
<PaginatedList
columns={reportsColumns}
url={`${API_ROUTES.USERS.ADMIN_REPORTS(userId)}?direction=filed`}
>
{{
otherUserMobile: (_row, cellValue) => formatIranianMobile(cellValue),
status: (_row, cellValue) => <StatusChip {...getReportStatus(coerceToString(cellValue))} />,
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
}}
</PaginatedList>
</Tab>
<Tab
key="reports-received"
title="گزارش‌های دریافت‌شده"
>
<div className="flex justify-end pb-2">
<Button
size="sm"
to={`${APP_ROUTES.USER_REPORTS}?filters[reportedId]=${userId}`}
variant="flat"
>
مشاهده در صف بررسی
</Button>
</div>
<PaginatedList
columns={reportsColumns}
url={`${API_ROUTES.USERS.ADMIN_REPORTS(userId)}?direction=received`}
>
{{
otherUserMobile: (_row, cellValue) => formatIranianMobile(cellValue),
status: (_row, cellValue) => <StatusChip {...getReportStatus(coerceToString(cellValue))} />,
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
}}
</PaginatedList>
</Tab>
<Tab
key="blocking"
title="بلاک‌کرده"
>
<PaginatedList
columns={blocksColumns}
url={`${API_ROUTES.USERS.ADMIN_BLOCKS(userId)}?direction=blocking`}
>
{{
otherUserMobile: (_row, cellValue) => formatIranianMobile(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
}}
</PaginatedList>
</Tab>
<Tab
key="blocked-by"
title="بلاک‌شده توسط"
>
<PaginatedList
columns={blocksColumns}
url={`${API_ROUTES.USERS.ADMIN_BLOCKS(userId)}?direction=blockedBy`}
>
{{
otherUserMobile: (_row, cellValue) => formatIranianMobile(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
}}
</PaginatedList>
</Tab>
</AppTabs>
<AdminUserActivitySummary detail={detail} />
</>
)}
</div>
</section>
)
}
export default UserDetailPage

View File

@ -0,0 +1,117 @@
'use client'
import { coerceToString } from '@/helpers'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import StatusChip from '@/components/ui/StatusChip'
import PageNavbar from '@/components/layouts/PageNavbar'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import { APP_ROUTES } from '@/constants/routes'
import {
getIdentityStatus,
getUserAccountStatus,
getUserRole,
IDENTITY_STATUS_FILTER_ITEMS,
USER_ACCOUNT_STATUS_FILTER_ITEMS,
USER_ROLE_FILTER_ITEMS,
} from '@/constants/status'
import { API_ROUTES } from '@/services/config'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
const columns: PaginationListColumnType[] = [
{
field: 'firstName',
label: 'نام',
filterable: true,
type: 'text',
sortable: false,
},
{
field: 'lastName',
label: 'نام خانوادگی',
filterable: true,
type: 'text',
sortable: false,
},
{
field: 'mobile',
label: 'موبایل',
filterable: true,
type: 'text',
sortable: true,
},
{
field: 'role',
label: 'نقش',
filterable: true,
type: 'select',
sortable: true,
filterItems: USER_ROLE_FILTER_ITEMS,
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
type: 'select',
sortable: true,
filterItems: USER_ACCOUNT_STATUS_FILTER_ITEMS,
},
{
field: 'identityStatus',
label: 'احراز هویت',
filterable: true,
type: 'select',
sortable: false,
filterItems: IDENTITY_STATUS_FILTER_ITEMS,
},
{
field: 'lastLoginAt',
label: 'آخرین ورود',
filterable: true,
sortable: true,
type: 'dateFromTo',
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: true,
sortable: true,
type: 'dateFromTo',
},
{
field: 'actions',
label: 'عملیات',
},
]
const UsersPage = () => {
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="کاربران" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.USERS.ADMIN_LIST}
>
{{
mobile: (_row, cellValue) => formatIranianMobile(cellValue),
role: (_row, cellValue) => <StatusChip {...getUserRole(coerceToString(cellValue))} />,
status: (_row, cellValue) => <StatusChip {...getUserAccountStatus(coerceToString(cellValue))} />,
identityStatus: (_row, cellValue) => <StatusChip {...getIdentityStatus(coerceToString(cellValue))} />,
lastLoginAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => (
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(String(row.id))}
/>
),
}}
</PaginatedList>
</div>
</section>
)
}
export default UsersPage

View File

@ -0,0 +1,179 @@
'use client'
import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList'
import StatusChip from '@/components/ui/StatusChip'
import PageNavbar from '@/components/layouts/PageNavbar'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import { APP_ROUTES } from '@/constants/routes'
import { formatCurrency, formatPersonName, coerceToString } from '@/helpers'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import { getPaymentStatus, PAYMENT_STATUS_FILTER_ITEMS } from '@/constants/status'
import { API_ROUTES } from '@/services/config'
interface DepositUser {
id: string
mobile: string
firstName: string | null
lastName: string | null
}
const PURPOSE_LABELS: Record<string, string> = {
top_up: 'شارژ کیف پول',
booking_checkout: 'پرداخت رزرو',
}
const PURPOSE_FILTER_ITEMS = [
{ code: 'top_up', name: PURPOSE_LABELS.top_up },
{ code: 'booking_checkout', name: PURPOSE_LABELS.booking_checkout },
]
const columns: PaginationListColumnType[] = [
{
field: 'depositCode',
label: 'کد واریز',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'user',
label: 'کاربر',
filterable: false,
sortable: false,
},
{
field: 'purpose',
label: 'هدف',
filterable: true,
sortable: false,
type: 'select',
filterItems: PURPOSE_FILTER_ITEMS,
},
{
field: 'amount',
label: 'مبلغ',
filterable: false,
sortable: true,
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
sortable: true,
type: 'select',
filterItems: PAYMENT_STATUS_FILTER_ITEMS,
},
{
field: 'gatewayProvider',
label: 'درگاه',
filterable: true,
sortable: false,
type: 'text',
},
{
field: 'gatewayTrackingId',
label: 'شناسه درگاه',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'gatewayRefId',
label: 'RRN درگاه',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'completedAt',
label: 'تاریخ تکمیل',
filterable: false,
sortable: true,
type: 'date',
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: true,
sortable: true,
type: 'dateFromTo',
},
{
field: 'actions',
label: 'عملیات',
},
]
const WalletDepositsPage = () => {
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="واریزهای کیف پول" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.WALLET_DEPOSITS.ADMIN_LIST}
>
{{
user: (row) => {
const user = row.user as DepositUser | undefined
if (!user) return '—'
const name = formatPersonName(user.firstName ?? undefined, user.lastName ?? undefined)
return (
<div className="flex flex-col">
<span>{name}</span>
<span
className="text-xs text-tertiary-300"
dir="ltr"
>
{formatIranianMobile(user.mobile)}
</span>
</div>
)
},
purpose: (_row, cellValue) => PURPOSE_LABELS[coerceToString(cellValue)] ?? coerceToString(cellValue) ?? '—',
amount: (_row, cellValue) => formatCurrency(Number(cellValue ?? 0)),
status: (_row, cellValue) => <StatusChip {...getPaymentStatus(coerceToString(cellValue))} />,
gatewayProvider: (_row, cellValue) => coerceToString(cellValue) || '—',
gatewayTrackingId: (_row, cellValue) => (
<span
className="font-mono text-xs"
dir="ltr"
>
{coerceToString(cellValue) || '—'}
</span>
),
gatewayRefId: (_row, cellValue) => (
<span
className="font-mono text-xs"
dir="ltr"
>
{coerceToString(cellValue) || '—'}
</span>
),
completedAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const user = row.user as DepositUser | undefined
if (!user) return '—'
return (
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(user.id)}
/>
)
},
}}
</PaginatedList>
</div>
</section>
)
}
export default WalletDepositsPage

View File

@ -0,0 +1,350 @@
'use client'
import { useState } from 'react'
import type { PaginationListColumnType } from '@/types'
import { addToast } from '@/lib/toast'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import Input from '@/components/formElements/Input'
import Button from '@/components/formElements/Button'
import CloseCircleIcon from '@/components/icons/CloseCircleIcon'
import FileCheckIcon from '@/components/icons/FileCheckIcon'
import ManualPayoutModal from '@/components/admin/ManualPayoutModal'
import PlayCircleIcon from '@/components/icons/PlayCircleIcon'
import Modal from '@/components/modals/Modal'
import AdminTableActions from '@/components/ui/AdminTableActions'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import axiosInstance from '@/config/axios'
import { APP_ROUTES } from '@/constants/routes'
import { getWithdrawalRequestStatus, WITHDRAWAL_REQUEST_STATUS_FILTER_ITEMS } from '@/constants/status'
import { formatCurrency, formatPersonName, coerceToString } from '@/helpers'
import useAlertModal from '@/hooks/useAlertModal'
import useAdminMutation from '@/hooks/useAdminMutation'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import { API_ROUTES } from '@/services/config'
type WithdrawalRequestStatusValue = 'pending' | 'processing' | 'completed' | 'rejected'
interface WithdrawalRequestUserSummary {
mobile: string
firstName: string | null
lastName: string | null
}
interface WithdrawalRequestBankAccountSummary {
iban: string
bankName: string | null
accountHolder: string | null
}
interface WithdrawalRequestRow {
id: string
userId: string
walletId: string
bankAccountId: string
withdrawalCode: string
amount: number
status: WithdrawalRequestStatusValue
rejectionReason: string | null
processedAt: string | null
createdAt: string
updatedAt: string
user?: WithdrawalRequestUserSummary
bankAccount?: WithdrawalRequestBankAccountSummary
[key: string]: unknown
}
const columns: PaginationListColumnType[] = [
{
field: 'withdrawalCode',
label: 'کد درخواست',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'userId',
label: 'کاربر',
filterable: true,
sortable: false,
type: 'text',
},
{
field: 'amount',
label: 'مبلغ',
filterable: false,
sortable: true,
},
{
field: 'status',
label: 'وضعیت',
filterable: true,
sortable: true,
type: 'select',
filterItems: WITHDRAWAL_REQUEST_STATUS_FILTER_ITEMS,
},
{
field: 'createdAt',
label: 'تاریخ ثبت',
filterable: false,
sortable: true,
},
{
field: 'processedAt',
label: 'تاریخ پردازش',
filterable: false,
sortable: false,
},
{
field: 'bankDetails',
label: 'حساب بانکی',
filterable: false,
sortable: false,
},
{
field: 'actions',
label: 'عملیات',
},
]
const WithdrawalRequestsPage = () => {
const { showAlert } = useAlertModal()
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_LIST })
const [rejectTarget, setRejectTarget] = useState<WithdrawalRequestRow | null>(null)
const [manualTarget, setManualTarget] = useState<WithdrawalRequestRow | null>(null)
const [rejectReason, setRejectReason] = useState('')
const isRejecting = rejectTarget?.id === pendingId
const handleProcess = (row: WithdrawalRequestRow) => {
showAlert('این درخواست برداشت به وضعیت «در حال پردازش» تغییر کند؟', () =>
runAction(
row.id,
() => axiosInstance.patch(API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_PROCESS(row.id)),
'درخواست به «در حال پردازش» تغییر کرد'
)
)
}
const openRejectModal = (row: WithdrawalRequestRow) => {
setRejectReason('')
setRejectTarget(row)
}
const closeRejectModal = () => {
if (isRejecting) return
setRejectTarget(null)
setRejectReason('')
}
const handleReject = async () => {
if (!rejectTarget) return
const reason = rejectReason.trim()
if (reason.length < 3) {
addToast({ title: 'دلیل رد باید حداقل ۳ کاراکتر باشد', color: 'warning' })
return
}
const succeeded = await runAction(
rejectTarget.id,
() => axiosInstance.patch(API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_REJECT(rejectTarget.id), { reason }),
'درخواست رد شد'
)
if (succeeded) {
setRejectTarget(null)
setRejectReason('')
}
}
const renderStatusCell = (row: WithdrawalRequestRow, cellValue: unknown) => {
const { label, chipColor } = getWithdrawalRequestStatus(coerceToString(cellValue))
return (
<StatusChip
chipColor={chipColor}
description={row.status === 'rejected' ? row.rejectionReason : undefined}
label={label}
/>
)
}
return (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="درخواست‌های برداشت" />
<div className="admin-page-container">
<PaginatedList
columns={columns}
url={API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_LIST}
>
{{
userId: (row) => {
const withdrawal = row as WithdrawalRequestRow
return (
<div className="flex flex-col gap-1">
<span>{formatPersonName(withdrawal.user?.firstName, withdrawal.user?.lastName)}</span>
{withdrawal.user?.mobile ? (
<span
className="text-text-muted text-xs"
dir="ltr"
>
{formatIranianMobile(withdrawal.user.mobile)}
</span>
) : null}
</div>
)
},
amount: (_row, cellValue) => formatCurrency(Number(cellValue ?? 0)),
status: (row, cellValue) => renderStatusCell(row as WithdrawalRequestRow, cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
processedAt: (_row, cellValue) => formatPersianDate(cellValue),
bankDetails: (row) => {
const withdrawal = row as WithdrawalRequestRow
const bankAccount = withdrawal.bankAccount
if (!bankAccount) {
return <span className="text-text-muted text-xs"></span>
}
return (
<div className="flex flex-col gap-1">
<span
className="text-xs font-mono"
dir="ltr"
>
{bankAccount.iban || '—'}
</span>
<span className="text-text-muted text-xs">{bankAccount.bankName ?? '—'}</span>
<span className="text-text-muted text-xs">{bankAccount.accountHolder ?? '—'}</span>
</div>
)
},
actions: (row) => {
const withdrawal = row as WithdrawalRequestRow
const isBusy = pendingId === withdrawal.id
const canProcess = withdrawal.status === 'pending' || withdrawal.status === 'processing'
return (
<AdminTableActions>
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(withdrawal.userId)}
/>
{canProcess && withdrawal.status === 'pending' ? (
<Button
iconOnly
aria-label="پردازش درخواست برداشت"
color="primary"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
handleProcess(withdrawal)
}}
>
<PlayCircleIcon
className="size-4"
color="currentColor"
/>
</Button>
) : null}
{canProcess ? (
<>
<Button
iconOnly
aria-label="تکمیل درخواست برداشت"
color="success"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
setManualTarget(withdrawal)
}}
>
<FileCheckIcon className="size-4" />
</Button>
<Button
iconOnly
aria-label="رد درخواست برداشت"
color="danger"
disabled={isBusy}
isLoading={isBusy}
size="sm"
variant="flat"
onClick={() => {
openRejectModal(withdrawal)
}}
>
<CloseCircleIcon className="size-4 text-fourth-900" />
</Button>
</>
) : null}
</AdminTableActions>
)
},
}}
</PaginatedList>
</div>
<Modal
acceptDanger
acceptBtnDisabled={rejectReason.trim().length < 3}
acceptBtnText="رد درخواست"
isLoading={isRejecting}
isOpen={Boolean(rejectTarget)}
rejectBtnText="انصراف"
size="lg"
title="رد درخواست برداشت"
onAccept={handleReject}
onOpenChange={(open) => {
if (!open) closeRejectModal()
}}
onReject={closeRejectModal}
>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">دلیل رد این درخواست را بنویسید. این توضیح برای کاربر نمایش داده میشود.</p>
<Input
generalType="textarea"
label="دلیل رد"
name="rejectReason"
placeholder="حداقل ۳ کاراکتر"
textAreaMinRows={3}
value={rejectReason}
onValueChange={(next) => {
setRejectReason(coerceToString(next))
}}
/>
</div>
</Modal>
<ManualPayoutModal
isLoading={manualTarget?.id === pendingId}
isOpen={Boolean(manualTarget)}
title={manualTarget ? `ثبت پرداخت «${manualTarget.withdrawalCode}»` : 'ثبت پرداخت دستی'}
onClose={() => {
setManualTarget(null)
}}
onSubmit={async (payload) => {
if (!manualTarget) return false
return runAction(
manualTarget.id,
() => axiosInstance.patch(API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_COMPLETE(manualTarget.id), payload),
'برداشت با رسید دستی تکمیل شد'
)
}}
/>
</section>
)
}
export default WithdrawalRequestsPage

View File

@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { CLEAR_REFRESH_SESSION_HEADER, REFRESH_TOKEN_COOKIE_DEV, REFRESH_TOKEN_COOKIE_HOST } from '@/lib/refreshSessionCookie'
import { POST } from './route'
describe('POST /api/auth/clear-session', () => {
it('rejects requests without the logout header', () => {
const response = POST(new Request('http://127.0.0.1/api/auth/clear-session', { method: 'POST' }))
expect(response.status).toBe(403)
expect(response.headers.getSetCookie()).toEqual([])
})
it('expires both Nest refresh cookie names when the header is present', () => {
const response = POST(
new Request('http://127.0.0.1/api/auth/clear-session', {
method: 'POST',
headers: { [CLEAR_REFRESH_SESSION_HEADER]: '1' },
})
)
const cookies = response.headers.getSetCookie()
expect(response.status).toBe(200)
expect(response.headers.get('Cache-Control')).toBe('no-store')
expect(cookies).toHaveLength(3)
expect(cookies.some((cookie) => cookie.startsWith(`${REFRESH_TOKEN_COOKIE_DEV}=`) && cookie.includes('Max-Age=0'))).toBe(true)
expect(cookies.some((cookie) => cookie.startsWith(`${REFRESH_TOKEN_COOKIE_HOST}=`) && cookie.includes('Secure'))).toBe(true)
})
})

View File

@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { CLEAR_REFRESH_SESSION_HEADER, expiredRefreshSessionSetCookieHeaders } from '@/lib/refreshSessionCookie'
export const dynamic = 'force-dynamic'
/**
* Browser-side cookie cleanup only: expires Nest httpOnly refresh cookies on this origin.
* Does not revoke `refresh_tokens` in Nest an already-exfiltrated token stays valid until
* expiry unless retried `POST /auth/logout` succeeded. Complementary to `attemptServerLogout`.
* Requires `x-ghabilee-logout` so a cross-site form POST cannot CSRF-clear cookies.
*/
export function POST(request: Request) {
if (request.headers.get(CLEAR_REFRESH_SESSION_HEADER) !== '1') {
return NextResponse.json({ ok: false }, { status: 403 })
}
const response = NextResponse.json({ ok: true })
response.headers.set('Cache-Control', 'no-store')
for (const cookie of expiredRefreshSessionSetCookieHeaders()) {
response.headers.append('Set-Cookie', cookie)
}
return response
}

View File

@ -0,0 +1,93 @@
import { type NextRequest, NextResponse } from 'next/server'
const guessFileName = (imageUrl: string) => {
try {
const pathname = new URL(imageUrl).pathname
const base = pathname.split('/').filter(Boolean).at(-1)
if (base && /\.[a-z0-9]{2,5}$/i.test(base)) return decodeURIComponent(base)
} catch {
// ignore
}
return `chat-image-${Date.now()}.jpg`
}
const resolveAllowedUploadOrigins = (): string[] => {
const origins = new Set<string>()
const fileServer = process.env.NEXT_PUBLIC_FILE_SERVER_URL?.trim()
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim()
const apiUrl = process.env.NEXT_PUBLIC_API_URL?.trim()
for (const value of [fileServer, siteUrl, apiUrl]) {
if (!value) continue
try {
origins.add(new URL(value).origin)
} catch {
// ignore invalid env URLs
}
}
origins.add('https://ghabilee.ir')
origins.add('https://www.ghabilee.ir')
origins.add('https://dev.ghabilee.ir')
return [...origins]
}
/**
* Same-origin download proxy for chat/upload images.
* Needed when the page origin (e.g. localhost:3008) differs from the file host
* (ghabilee.ir) and the CDN has not yet emitted Access-Control-Allow-Origin.
*/
export async function GET(request: NextRequest) {
const rawUrl = request.nextUrl.searchParams.get('url')?.trim()
if (!rawUrl) {
return NextResponse.json({ message: 'url is required' }, { status: 400 })
}
let target: URL
try {
target = new URL(rawUrl)
} catch {
return NextResponse.json({ message: 'invalid url' }, { status: 400 })
}
if (target.protocol !== 'https:' && target.protocol !== 'http:') {
return NextResponse.json({ message: 'unsupported protocol' }, { status: 400 })
}
const allowed = resolveAllowedUploadOrigins()
if (!allowed.includes(target.origin)) {
return NextResponse.json({ message: 'url host is not allowed' }, { status: 403 })
}
if (!target.pathname.startsWith('/uploads/')) {
return NextResponse.json({ message: 'only /uploads paths are allowed' }, { status: 403 })
}
const upstream = await fetch(target.toString(), {
headers: { Accept: 'image/*,*/*' },
redirect: 'error',
})
if (!upstream.ok) {
return NextResponse.json({ message: 'upstream fetch failed' }, { status: 502 })
}
const contentType = upstream.headers.get('content-type') || 'application/octet-stream'
const fileName = guessFileName(target.toString())
const bytes = await upstream.arrayBuffer()
return new NextResponse(bytes, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${fileName}"`,
'Cache-Control': 'private, no-store',
},
})
}

53
app/auth/layout.tsx Normal file
View File

@ -0,0 +1,53 @@
import type { Metadata } from 'next'
import React from 'react'
import Image from 'next/image'
import SessionProviders from '@/components/providers/SessionProviders'
import { withBasePath } from '@/constants/images'
import { texts } from '@/texts'
// Login/signup — already disallowed in robots.ts; noindex is defense in
// depth against the URL surfacing blank in search results if ever linked.
export const metadata: Metadata = {
robots: { index: false, follow: false },
}
const authHeroSrc = withBasePath('/images/auth-v2.png')
const logoSrc = withBasePath('/logo.svg')
const AuthLayout = ({ children }: { children: React.ReactNode }) => {
return (
<SessionProviders>
<section className="consumer-mobile-viewport relative min-h-[100dvh] overflow-hidden bg-background-primary">
<div
aria-hidden
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: `url(${authHeroSrc})` }}
/>
<div
aria-hidden
className="absolute inset-0 bg-black/20 backdrop-blur-md"
/>
<div className="relative z-10 flex min-h-[100dvh] items-center justify-center p-4">
<div className="flex w-full max-w-md flex-col rounded-2xl bg-white/90 p-4 backdrop-blur-sm">
<div className="flex min-h-[400px] flex-1 flex-col items-center justify-center">
<Image
priority
alt={texts.auth.logoAlt}
className="mb-8 size-32"
height={200}
src={logoSrc}
width={200}
/>
{children}
</div>
</div>
</div>
</section>
</SessionProviders>
)
}
export default AuthLayout

15
app/auth/page.tsx Normal file
View File

@ -0,0 +1,15 @@
'use client'
import { Suspense } from 'react'
import { PageLoading } from '@/components/feedback/LoadingState'
import { AdminAuthContent } from '@/components/auth/AdminAuthContent'
import { texts } from '@/texts'
export default function AuthPage() {
return (
<Suspense fallback={<PageLoading label={texts.auth.preparingLogin} />}>
<AdminAuthContent />
</Suspense>
)
}

19
app/error.tsx Normal file
View File

@ -0,0 +1,19 @@
'use client'
import { useEffect } from 'react'
import { RouteErrorView } from '@/components/feedback/RouteErrorView'
import { reportClientError } from '@/lib/observability/client'
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
reportClientError(error, 'root')
}, [error])
return (
<RouteErrorView
digest={error.digest}
onRetry={reset}
/>
)
}

23
app/global-error.tsx Normal file
View File

@ -0,0 +1,23 @@
'use client'
import { useEffect } from 'react'
import { RouteErrorView } from '@/components/feedback/RouteErrorView'
import { reportClientError } from '@/lib/observability/client'
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
reportClientError(error, 'global')
}, [error])
return (
<html lang="fa">
<body>
<RouteErrorView
digest={error.digest}
onRetry={reset}
/>
</body>
</html>
)
}

80
app/layout.tsx Normal file
View File

@ -0,0 +1,80 @@
import '@/styles/globals.css'
import '@/styles/main.scss'
import type { Metadata, Viewport } from 'next'
import { headers } from 'next/headers'
import clsx from 'clsx'
import React from 'react'
import { Providers } from '@/app/providers'
import { pinarFont } from '@/config/fonts'
import { texts } from '@/texts'
import { SITE_URL } from '@/lib/seo/metadata'
import { OfflineBanner } from '@/components/feedback/OfflineBanner'
import { AccountSuspensionBanner } from '@/components/feedback/AccountSuspensionBanner'
import { withBasePath } from '@/constants/images'
import { AppToastProvider } from '@/components/feedback/AppToastProvider'
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: `${texts.common.brandName} — Backoffice`,
template: `%s | ${texts.common.brandName} Backoffice`,
},
description: 'پنل مدیریت قبیله',
applicationName: `${texts.common.brandName} Backoffice`,
robots: { index: false, follow: false, nocache: true },
icons: {
icon: [
{ url: withBasePath('/logo.svg'), type: 'image/svg+xml' },
{ url: withBasePath('/icons/icon-192.png'), sizes: '192x192', type: 'image/png' },
{ url: withBasePath('/icons/icon-512.png'), sizes: '512x512', type: 'image/png' },
],
apple: [{ url: withBasePath('/icons/apple-touch-icon.png'), sizes: '180x180' }],
},
formatDetection: {
telephone: false,
},
}
export const viewport: Viewport = {
themeColor: '#6d28d9',
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// Reading the nonce-bearing request headers opts the route into dynamic
// rendering so Next can apply the per-request CSP nonce to its scripts.
const nonce = (await headers()).get('x-nonce') ?? undefined
return (
<html
suppressHydrationWarning
className={clsx('!overflow-auto', pinarFont.variable)}
dir="rtl"
lang="fa"
>
<body
suppressHydrationWarning
className="min-h-[100dvh] bg-background font-sans antialiased overflow-hidden"
>
<Providers
themeProps={{
attribute: 'class',
defaultTheme: 'light',
forcedTheme: 'light',
nonce,
}}
>
<AccountSuspensionBanner />
<OfflineBanner />
<div className="min-h-[100dvh]">{children}</div>
<AppToastProvider />
</Providers>
</body>
</html>
)
}

5
app/loading.tsx Normal file
View File

@ -0,0 +1,5 @@
import { DetailSkeleton } from '@/components/feedback/LoadingState'
export default function Loading() {
return <DetailSkeleton />
}

19
app/not-found.tsx Normal file
View File

@ -0,0 +1,19 @@
import Button from '@/components/formElements/Button'
import { texts } from '@/texts'
const NotFound = () => {
return (
<section className="bg-white dark:bg-secondary-10">
<div className="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
<div className="mx-auto max-w-screen-sm text-center">
<h1 className="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-primary">404</h1>
<p className="mb-4 text-3xl tracking-normal font-bold text-text-dark md:text-4xl">{texts.common.notFoundTitle}</p>
<p className="mb-4 text-lg font-light text-text-light-25">{texts.common.pageNotFound}</p>
<Button to="/">{texts.common.home}</Button>
</div>
</div>
</section>
)
}
export default NotFound

8
app/page.tsx Normal file
View File

@ -0,0 +1,8 @@
import { redirect } from 'next/navigation'
import { APP_ROUTES } from '@/constants/routes'
/** Root of backoffice always lands on the admin dashboard. */
export default function AdminRootPage() {
redirect(APP_ROUTES.DASHBOARD)
}

29
app/providers.tsx Normal file
View File

@ -0,0 +1,29 @@
'use client'
import React, { useState } from 'react'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { I18nProvider } from '@react-aria/i18n'
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from 'next-themes'
import { getQueryClient, getQueryPersistOptions } from '@/lib/queryClient'
export interface ProvidersProps {
children: React.ReactNode
themeProps?: ThemeProviderProps
}
export function Providers({ children, themeProps }: ProvidersProps) {
const [queryClient] = useState(() => getQueryClient())
const [persistOptions] = useState(() => getQueryPersistOptions())
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={persistOptions}
>
<I18nProvider locale="fa-IR">
<NextThemesProvider {...themeProps}>{children}</NextThemesProvider>
</I18nProvider>
</PersistQueryClientProvider>
)
}

View File

@ -0,0 +1,65 @@
import { act, render, waitFor } from '@testing-library/react'
import { createRef } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PaginatedList, { type PaginatedListHandle } from '@/components/paginated-list/PaginatedListImpl'
const renderWithQuery = (ui: React.ReactElement) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}
const fetchPaginatedList = vi.fn()
const replace = vi.fn()
vi.mock('next/navigation', () => ({
usePathname: () => '/test',
useRouter: () => ({ replace }),
}))
vi.mock('@/hooks/useQueryParams', () => ({ default: () => ({}) }))
vi.mock('@/components/paginated-list/PaginatedListFilterModal', () => ({ default: () => null }))
vi.mock('@/components/paginated-list/PaginatedListPagination', () => ({
default: () => null,
ROWS_PER_PAGE_OPTIONS: [{ code: '20', name: '20' }],
}))
vi.mock('@/components/paginated-list/PaginatedListTable', () => ({ default: () => null }))
vi.mock('@/components/paginated-list/PaginatedListToolbar', () => ({ default: () => null }))
vi.mock('@/components/paginated-list/paginatedListApi', () => ({
fetchPaginatedList: (...args: unknown[]) => fetchPaginatedList(...args),
fetchPaginatedListExport: vi.fn(),
}))
describe('PaginatedList refresh handle', () => {
beforeEach(() => {
replace.mockClear()
fetchPaginatedList.mockReset()
fetchPaginatedList.mockResolvedValue({ items: [], pagination: { page: 1, pageSize: 20, totalItems: 0 } })
})
it('fetches the current list again when refresh is called', async () => {
const ref = createRef<PaginatedListHandle>()
renderWithQuery(
<PaginatedList
ref={ref}
columns={[{ field: 'id', label: 'شناسه' }]}
url="/items"
/>
)
await waitFor(() => {
expect(fetchPaginatedList).toHaveBeenCalled()
})
const initialCalls = fetchPaginatedList.mock.calls.length
act(() => ref.current?.refresh())
await waitFor(() => {
expect(fetchPaginatedList.mock.calls.length).toBeGreaterThan(initialCalls)
})
})
})

View File

@ -0,0 +1,32 @@
'use client'
import { forwardRef, type ComponentProps } from 'react'
import dynamic from 'next/dynamic'
import { TableSkeleton } from '@/components/feedback/LoadingState'
import type { PaginatedListHandle } from '@/components/paginated-list/PaginatedListImpl'
export type { PaginatedListHandle }
const PaginatedListImpl = dynamic(() => import('@/components/paginated-list/PaginatedListImpl'), {
ssr: false,
loading: () => (
<div className="admin-surface overflow-hidden">
<TableSkeleton
showHeader
columnCount={4}
/>
</div>
),
})
const PaginatedList = forwardRef<PaginatedListHandle, ComponentProps<typeof PaginatedListImpl>>(function PaginatedList(props, ref) {
return (
<PaginatedListImpl
ref={ref}
{...props}
/>
)
})
export default PaginatedList

View File

@ -0,0 +1,124 @@
'use client'
import { useEffect, useState } from 'react'
import Input from '@/components/formElements/Input'
import FileUpload from '@/components/media/FileUpload'
import Modal from '@/components/modals/Modal'
import { coerceToString } from '@/helpers'
import { addToast } from '@/lib/toast'
export interface ManualPayoutPayload {
trackingCode: string
receiptUrl: string
note?: string
}
interface ManualPayoutModalProps {
isOpen: boolean
isLoading?: boolean
title: string
onClose: () => void
onSubmit: (payload: ManualPayoutPayload) => Promise<boolean>
}
const ManualPayoutModal = ({ isOpen, isLoading = false, title, onClose, onSubmit }: ManualPayoutModalProps) => {
const [trackingCode, setTrackingCode] = useState('')
const [receiptFileId, setReceiptFileId] = useState('')
const [receiptUrl, setReceiptUrl] = useState('')
const [note, setNote] = useState('')
useEffect(() => {
if (!isOpen) {
setTrackingCode('')
setReceiptFileId('')
setReceiptUrl('')
setNote('')
}
}, [isOpen])
const handleSubmit = async () => {
const normalizedTrackingCode = trackingCode.trim()
if (!normalizedTrackingCode) {
addToast({ title: 'شماره پیگیری پرداخت را وارد کنید', color: 'warning' })
return
}
if (!receiptUrl) {
addToast({ title: 'تصویر رسید پرداخت را بارگذاری کنید', color: 'warning' })
return
}
const succeeded = await onSubmit({
trackingCode: normalizedTrackingCode,
receiptUrl,
note: note.trim() || undefined,
})
if (succeeded) {
onClose()
}
}
return (
<Modal
acceptBtnDisabled={!trackingCode.trim() || !receiptUrl}
acceptBtnText="ثبت پرداخت دستی"
isLoading={isLoading}
isOpen={isOpen}
rejectBtnText="انصراف"
title={title}
onAccept={() => {
void handleSubmit()
}}
onOpenChange={(open) => {
if (!open && !isLoading) onClose()
}}
onReject={onClose}
>
<div className="flex flex-col gap-4">
<Input
direction="ltr"
generalType="input"
label="شماره پیگیری بانکی"
name="manualTrackingCode"
value={trackingCode}
onValueChange={(value) => {
setTrackingCode(coerceToString(value))
}}
/>
<FileUpload
accept={['image']}
buttonText="بارگذاری تصویر رسید"
fileId={receiptFileId || undefined}
fileUploaded={(fileId, mode, _fileType, fileUrl) => {
if (mode === 'remove') {
setReceiptFileId('')
setReceiptUrl('')
return
}
setReceiptFileId(fileId)
setReceiptUrl(fileUrl ?? '')
}}
inputId="manual-payout-receipt"
/>
<Input
generalType="textarea"
label="یادداشت برای کاربر (اختیاری)"
name="manualPayoutNote"
textAreaMinRows={3}
value={note}
onValueChange={(value) => {
setNote(coerceToString(value))
}}
/>
</div>
</Modal>
)
}
export default ManualPayoutModal

View File

@ -0,0 +1,215 @@
'use client'
import Link from 'next/link'
import { FormProvider } from 'react-hook-form'
import { Progress } from '@/components/heroui/Progress'
import Button from '@/components/formElements/Button'
import Checkbox from '@/components/formElements/Checkbox'
import Input from '@/components/formElements/Input'
import ConsumerInput from '@/components/consumer/ConsumerInput'
import { texts, format } from '@/texts'
import { useAuthFlow } from '@/features/auth/useAuthFlow'
import { useDiscoveryCitiesQuery } from '@/queries/consumer/useWalletAndBookmarkQueries'
import { showFormValidationToast } from '@/lib/formValidationToast'
import { OTP_LENGTH } from '@/validation/auth'
const GENDER_OPTIONS = [
{ name: texts.common.genderMale, selectKey: 'male' },
{ name: texts.common.genderFemale, selectKey: 'female' },
]
export function AdminAuthContent() {
const flow = useAuthFlow({})
const citiesQuery = useDiscoveryCitiesQuery(flow.step === 'profile')
const cityOptions = (citiesQuery.data ?? []).map((city) => ({ id: String(city.id), name: city.name }))
const title = flow.step === 'otp' ? texts.auth.otpTitle : flow.step === 'profile' ? texts.auth.profileTitle : null
return (
<div className="mx-auto flex w-full max-w-xs flex-col">
{title ? <div className="mb-5 text-center text-lg font-bold text-tertiary">{title}</div> : null}
{flow.accountBlockMessage ? (
<div className="rounded-xl border border-fourth-100 bg-fourth-100 px-4 py-3 text-center text-sm text-fourth-900">
{flow.accountBlockMessage}
</div>
) : null}
{flow.step === 'mobile' ? (
<FormProvider {...flow.sendOtpForm}>
<form onSubmit={flow.sendOtpForm.handleSubmit(flow.sendOtp, showFormValidationToast)}>
<ConsumerInput
generalType="input"
inputType="tel"
label={texts.auth.mobileLabel}
name="mobile"
placeholder={texts.auth.mobilePlaceholder}
/>
<Button
fullWidth
className="mt-4"
isLoading={flow.loading}
type="submit"
>
{texts.common.continue}
</Button>
</form>
</FormProvider>
) : null}
{flow.step === 'otp' ? (
<>
<FormProvider {...flow.checkOtpForm}>
<form onSubmit={flow.checkOtpForm.handleSubmit(flow.checkOtp, showFormValidationToast)}>
<ConsumerInput
autoFocus
className="px-2"
generalType="otp"
name="code"
otpLength={OTP_LENGTH}
/>
{flow.otpError ? <p className="mt-2 text-center text-sm text-fourth-900">{flow.otpError}</p> : null}
{flow.isRegistration ? (
<div className="mt-5">
<Checkbox
className="items-start gap-3 rounded-2xl border border-primary-100 bg-primary-50/60 p-4 data-[selected=true]:border-primary-100 data-[selected=true]:bg-primary-50/60"
isSelected={flow.termsAccepted}
onValueChange={flow.updateTermsAccepted}
>
<span
aria-describedby={flow.termsError ? 'terms-error' : undefined}
className="text-sm leading-7 text-tertiary-400"
>
{texts.auth.termsConfirmPrefix}{' '}
<Link
className="font-bold text-primary underline underline-offset-4"
href="/terms"
target="_blank"
>
{texts.auth.termsLink}
</Link>{' '}
{texts.auth.termsConfirmSuffix}
</span>
</Checkbox>
{flow.termsError ? (
<p
className="mt-2 text-sm text-fourth-900"
id="terms-error"
>
{flow.termsError}
</p>
) : null}
</div>
) : null}
<Button
fullWidth
className="mt-6"
isLoading={flow.loading}
type="submit"
>
{flow.isRegistration ? texts.auth.confirmAndContinueRegister : texts.common.login}
</Button>
</form>
</FormProvider>
<div className="my-6 text-center text-sm text-secondary-20">
{flow.resendCodeTime > 0 ? (
<>
<Progress
aria-label={texts.auth.resendTimerAria}
className="mb-4 max-w-md [&>div]:bg-tertiary-50 [&>div>div]:bg-tertiary-100"
size="md"
value={((flow.otpTtl - flow.resendCodeTime) / flow.otpTtl) * 100}
/>
<p>{texts.auth.didNotReceiveCode}</p>
<p className="mt-1 text-tertiary-600">{format(texts.auth.resendInSeconds, { seconds: flow.resendCodeTime })}</p>
</>
) : (
<Button
className="text-primary"
variant="light"
onClick={() => flow.sendOtp({ mobile: flow.mobile })}
>
{texts.auth.resendCode}
</Button>
)}
</div>
<Button
fullWidth
color="warning"
variant="light"
onClick={flow.resetToMobile}
>
{texts.auth.changeMobile}
</Button>
</>
) : null}
{flow.step === 'profile' ? (
<FormProvider {...flow.completeProfileForm}>
<form
className="flex flex-col gap-5"
onSubmit={flow.completeProfileForm.handleSubmit(flow.submitProfile, showFormValidationToast)}
>
<div>
<ConsumerInput
required
generalType="input"
label={texts.common.firstName}
name="firstName"
placeholder={texts.common.firstNamePlaceholder}
/>
</div>
<div>
<ConsumerInput
required
generalType="input"
label={texts.common.lastName}
name="lastName"
placeholder={texts.common.lastNamePlaceholder}
/>
</div>
<div>
<ConsumerInput
required
generalType="radio"
label={texts.common.gender}
name="gender"
orientation="horizontal"
radioOptions={GENDER_OPTIONS}
selectKey="selectKey"
/>
</div>
<div>
<Input
required
generalType="birthDatePicker"
label={texts.common.birthDate}
name="dateOfBirth"
placeholder={texts.common.birthDatePlaceholder}
/>
</div>
<div>
<ConsumerInput
required
generalType="select"
label={texts.common.city}
name="cityId"
placeholder={citiesQuery.isPending ? texts.auth.loadingCities : texts.common.selectCity}
selectKey="id"
selectOptions={cityOptions}
selectValue="name"
/>
{citiesQuery.isError ? <p className="mt-2 text-sm text-fourth-900">{texts.auth.citiesLoadFailed}</p> : null}
</div>
<Button
fullWidth
isLoading={flow.loading}
type="submit"
>
{texts.auth.completeRegistration}
</Button>
</form>
</FormProvider>
) : null}
</div>
)
}

View File

@ -0,0 +1,26 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { CONSUMER_ROUTES } from '@/constants/routes'
import useAuth from '@/hooks/useAuth'
const AdminRoleGuard = ({ children }: { children: React.ReactNode }) => {
const { user } = useAuth()
const router = useRouter()
useEffect(() => {
if (user && user.role !== 'admin') {
router.replace(CONSUMER_ROUTES.HOME)
}
}, [router, user])
if (user && user.role !== 'admin') {
return null
}
return <>{children}</>
}
export default AdminRoleGuard

View File

@ -0,0 +1,32 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import useAuth from '@/hooks/useAuth'
/**
* Handles accounts that are still `pending` profile completion.
* - Consumer users keep the current page visible; AuthGate modal collects profile.
* - Admins are redirected to `/auth?step=profile` and the shell is hidden meanwhile.
*/
const PendingUserGuard = ({ children }: { children: React.ReactNode }) => {
const { user } = useAuth()
const router = useRouter()
const isPending = user?.status === 'pending'
const isAdmin = user?.role === 'admin'
useEffect(() => {
if (isPending && isAdmin) {
router.replace('/auth?step=profile')
}
}, [isAdmin, isPending, router])
if (isPending && isAdmin) {
return null
}
return <>{children}</>
}
export default PendingUserGuard

View File

@ -0,0 +1,88 @@
'use client'
/**
* ConsumerActionButtons standard dual CTA row (primary + cancel) for consumer UI.
*/
import type { ReactNode } from 'react'
import ConsumerButton from '@/components/consumer/ConsumerButton'
import AngleLeftIcon from '@/components/icons/AngleLeftIcon'
import CloseLinearIcon from '@/components/icons/CloseLinearIcon'
import { cn } from '@/lib/cn'
import { texts } from '@/texts'
import { type ConsumerButtonAlign } from './consumerButtonTypes'
export interface ConsumerActionButtonsProps {
cancelDisabled?: boolean
cancelIcon?: ReactNode
cancelLabel?: string
cancelType?: 'button' | 'reset'
className?: string
isCancelLoading?: boolean
isPrimaryLoading?: boolean
onCancel?: () => void
onPrimary?: () => void
primaryDisabled?: boolean
primaryDanger?: boolean
primaryIcon?: ReactNode
primaryLabel?: string
primaryType?: 'button' | 'submit'
align?: ConsumerButtonAlign
}
export default function ConsumerActionButtons({
cancelDisabled = false,
cancelIcon = <CloseLinearIcon className="size-3" />,
cancelLabel = texts.common.cancel,
cancelType = 'button',
className,
isCancelLoading = false,
isPrimaryLoading = false,
onCancel,
onPrimary,
primaryDisabled = false,
primaryDanger = false,
primaryIcon = <AngleLeftIcon className="size-4 rotate-180" />,
primaryLabel = texts.events.nextStep,
primaryType = 'submit',
align = 'start',
}: ConsumerActionButtonsProps) {
return (
<div className={cn('flex w-full items-start gap-2', className)}>
<ConsumerButton
align={align}
aria-label={primaryLabel}
className={cn('min-w-0 flex-1', primaryDanger && 'bg-fourth-900 hover:bg-fourth-700 active:bg-fourth-700')}
disabled={primaryDisabled}
fill={primaryDanger ? 'none' : 'navy'}
fontSize={16}
iconStart={primaryIcon}
isLoading={isPrimaryLoading}
size="lg"
textColor={primaryDanger ? 'white' : undefined}
type={primaryType}
onClick={onPrimary}
>
{primaryLabel}
</ConsumerButton>
<ConsumerButton
aria-label={cancelLabel}
className="shrink-0"
disabled={cancelDisabled}
fill="none"
fontSize={16}
iconEnd={cancelIcon}
isLoading={isCancelLoading}
size="lg"
textColor="text-tertiary"
type={cancelType}
onClick={onCancel}
>
{cancelLabel}
</ConsumerButton>
</div>
)
}

View File

@ -0,0 +1,168 @@
'use client'
/**
* ConsumerButton consumer-only actions (Figma-aligned).
*
* **When to use**
* - Any button, submit, or link-styled action in `app/(consumer)/**`,
* `components/consumer/**`, and shared consumer overlays (auth, PWA, booking).
*
* **When NOT to use**
* - Admin/dashboard and event-create wizard `components/formElements/Button`.
* - Dual cancel/primary rows `ConsumerActionButtons` (do not hand-roll pairs).
*
* **Props (design)**
* - `fill`: `orange` | `navy` | `fourth` | `fifth` | `gray` | `none` (default `orange`)
* - `size`: `xs` | `sm` | `md` | `lg` | `xl` default `lg` text, `sm` icon-only
* - `radius`: `full` | `control` (10px)
* - `textColor`: `white`, Tailwind `text-*`, or hex default white on filled
* - `fontSize`: default 14
* - `align`: `center` | `start` (RTL: start = right)
* - Icons: `iconStart` / `iconEnd` / `icon` + `iconPosition`
* - `isLoading` disables the control and swaps the icon slot for a spinner (label stays)
*
* **همنام بودن هماندازه بودن:** `size` here `ConsumerInput` `size` for
* the same token (see `frontend/docs/consumer-ui-guidelines.md` §4).
*
* **className policy:** layout-only (`flex-1`, `mt-5`, `w-full`, ) is OK.
* Do **not** pass visual overrides (`bg-*`, `text-*`, `rounded-*`, `min-h-*`)
* to match Figma stop, tell the product owner, and extend this component
* after explicit approval.
*
* Guidelines: `frontend/docs/consumer-ui-guidelines.md`
*/
import Link from 'next/link'
import { Spinner } from '@heroui/react'
import { forwardRef } from 'react'
import { buildConsumerButtonStyle } from '@/components/consumer/consumerButtonStyles'
import type { ConsumerButtonProps } from '@/components/consumer/consumerButtonTypes'
export type { ConsumerButtonProps } from '@/components/consumer/consumerButtonTypes'
const ConsumerButton = forwardRef<HTMLButtonElement, ConsumerButtonProps>(
(
{
'aria-controls': ariaControls,
'aria-expanded': ariaExpanded,
'aria-label': ariaLabel,
'aria-pressed': ariaPressed,
'aria-checked': ariaChecked,
'aria-selected': ariaSelected,
align = 'center',
children,
className,
radius = 'full',
disabled = false,
fill = 'orange',
fontSize = 14,
fullWidth = false,
icon,
iconEnd,
iconOnly = false,
iconPosition = 'start',
iconStart,
isLoading = false,
role,
size,
target = '_self',
textColor,
to,
type = 'button',
onClick,
},
ref
) => {
const isDisabled = disabled || isLoading
const resolvedIconStart = iconStart ?? (iconPosition === 'start' ? icon : undefined)
const resolvedIconEnd = iconEnd ?? (iconPosition === 'end' ? icon : undefined)
const { className: resolvedClassName, style } = buildConsumerButtonStyle({
align,
className,
radius,
fill,
fontSize,
fullWidth,
iconOnly,
size,
textColor,
})
const iconContent = resolvedIconStart ?? (iconOnly ? children : undefined)
const loadingSpinner = (
<Spinner
color="current"
size="sm"
/>
)
// لودینگ: فقط اسلات آیکن با اسپینر عوض شود؛ لیبل باقی بماند
const showStartSpinner = isLoading && Boolean(resolvedIconStart || !resolvedIconEnd)
const showEndSpinner = isLoading && !resolvedIconStart && Boolean(resolvedIconEnd)
const content = iconOnly ? (
isLoading ? (
loadingSpinner
) : (
iconContent
)
) : (
<>
{showStartSpinner ? loadingSpinner : resolvedIconStart}
{children}
{showEndSpinner ? loadingSpinner : isLoading ? null : resolvedIconEnd}
</>
)
const ariaProps = {
'aria-checked': ariaChecked,
'aria-controls': ariaControls,
'aria-expanded': ariaExpanded,
'aria-label': ariaLabel,
'aria-pressed': ariaPressed,
'aria-selected': ariaSelected,
role,
}
if (to) {
return (
<Link
{...ariaProps}
aria-disabled={isDisabled || undefined}
className={resolvedClassName}
href={to}
style={style}
target={target}
onClick={
isDisabled
? (event) => {
event.preventDefault()
}
: onClick
}
>
{content}
</Link>
)
}
return (
<button
ref={ref}
{...ariaProps}
className={resolvedClassName}
disabled={isDisabled}
style={style}
type={type}
onClick={onClick}
>
{content}
</button>
)
}
)
ConsumerButton.displayName = 'ConsumerButton'
export default ConsumerButton

View File

@ -0,0 +1,243 @@
'use client'
/**
* ConsumerInput consumer-only form fields (direct heroui + RHF).
*
* **Design props**
* - `size`: `sm` | `md` | `lg`
* - `radius`: `full` (pill) | `control` (10px)
* - `tone`: `muted` (#E2E2E280 fill) | `bordered` (white + border). Select is always `muted`.
*
* Use in `app/(consumer)/**` and `components/consumer/**` instead of
* `components/formElements/Input`.
*
* Guidelines: `frontend/docs/consumer-ui-guidelines.md`
*/
import { useEffect } from 'react'
import { FormProvider, useForm, useFormContext, type UseFormReturn } from 'react-hook-form'
import ConsumerChoiceField from '@/components/consumer/input/ConsumerChoiceField'
import ConsumerSelectField from '@/components/consumer/input/ConsumerSelectField'
import ConsumerTextareaField from '@/components/consumer/input/ConsumerTextareaField'
import { ConsumerNumberField, ConsumerTextField } from '@/components/consumer/input/ConsumerTextField'
import { defaultValueForConsumerField, type ConsumerInputProps } from '@/components/consumer/input/consumerInputTypes'
import { texts, format } from '@/texts'
export type { ConsumerInputProps } from '@/components/consumer/input/consumerInputTypes'
function useOptionalFormContext(): UseFormReturn | null {
const context = useFormContext() as UseFormReturn | null
return context?.control ? context : null
}
function ConsumerInputFields({
radius = 'control',
size = 'md',
tone,
isClearable = false,
inputWrapper,
orientation = 'vertical',
direction = 'rtl',
englishDigitsOnly = false,
otpLength = 4,
otpClassNames,
selectKey = 'code',
selectValue = 'name',
textAreaMinRows = 5,
...props
}: ConsumerInputProps) {
const { control } = useFormContext()
const placeholder =
props.placeholder ??
(props.generalType === 'select'
? undefined
: typeof props.label === 'string'
? format(texts.common.enterField, { label: props.label })
: '')
switch (props.generalType) {
case 'numberInput':
return (
<ConsumerNumberField
ariaLabel={props.ariaLabel}
autoFocus={props.autoFocus}
className={props.className}
control={control}
description={props.description}
direction={direction}
disabled={props.disabled}
englishDigitsOnly={englishDigitsOnly}
formatOptions={props.formatOptions}
iconEnd={props.iconEnd}
iconStart={props.iconStart}
inputWrapper={inputWrapper}
isClearable={isClearable}
label={props.label}
labelIcon={props.labelIcon}
maxValue={props.maxValue}
minValue={props.minValue}
name={props.name}
placeholder={placeholder}
radius={radius}
readonly={props.readonly}
required={props.required}
size={size}
tone={tone}
onBlur={props.onBlur}
onClear={props.onClear}
/>
)
case 'input':
return (
<ConsumerTextField
ariaLabel={props.ariaLabel}
autoFocus={props.autoFocus}
className={props.className}
control={control}
description={props.description}
direction={direction}
disabled={props.disabled}
englishDigitsOnly={englishDigitsOnly}
iconEnd={props.iconEnd}
iconStart={props.iconStart}
inputType={props.inputType}
inputWrapper={inputWrapper}
isClearable={isClearable}
label={props.label}
labelIcon={props.labelIcon}
maxValue={props.maxValue}
minValue={props.minValue}
name={props.name}
placeholder={placeholder}
radius={radius}
readonly={props.readonly}
required={props.required}
size={size}
tone={tone}
onBlur={props.onBlur}
onClear={props.onClear}
onKeyDown={props.onKeyDown}
/>
)
case 'textarea':
return (
<ConsumerTextareaField
autoFocus={props.autoFocus}
className={props.className}
control={control}
description={props.description}
disabled={props.disabled}
inputWrapper={inputWrapper}
isClearable={isClearable}
label={props.label}
name={props.name}
placeholder={placeholder}
radius={radius}
readonly={props.readonly}
required={props.required}
textAreaMinRows={textAreaMinRows}
tone={tone}
onBlur={props.onBlur}
/>
)
case 'select':
return (
<ConsumerSelectField
ariaLabel={props.ariaLabel}
autoFocus={props.autoFocus}
className={props.className}
control={control}
description={props.description}
disabled={props.disabled}
iconEnd={props.iconEnd}
iconStart={props.iconStart}
inputWrapper={inputWrapper}
label={props.label}
multiple={props.multiple}
name={props.name}
options={props.selectOptions ?? []}
placeholder={placeholder}
radius={radius}
readonly={props.readonly}
required={props.required}
selectKey={selectKey}
selectValue={selectValue}
size={size}
tone={tone}
/>
)
case 'radio':
case 'checkbox':
case 'otp':
case 'switch':
return (
<ConsumerChoiceField
autoFocus={props.autoFocus}
className={props.className}
control={control}
description={props.description}
disabled={props.disabled}
kind={props.generalType}
label={props.label}
name={props.name}
options={props.radioOptions ?? []}
orientation={orientation}
otpClassNames={otpClassNames}
otpLength={otpLength}
placeholder={placeholder}
radius={radius}
readonly={props.readonly}
required={props.required}
selectKey={selectKey}
size={size}
/>
)
default:
return null
}
}
function ConsumerInputStandalone(props: ConsumerInputProps) {
const { name, generalType, value, onValueChange } = props
const methods = useForm({
defaultValues: { [name]: value ?? defaultValueForConsumerField(generalType) },
values: value !== undefined ? { [name]: value } : undefined,
})
useEffect(() => {
if (!onValueChange) return
const subscription = methods.watch((values, info) => {
if (info.name && info.name !== name) return
onValueChange(values[name])
})
return () => {
subscription.unsubscribe()
}
}, [methods, name, onValueChange])
return (
<FormProvider {...methods}>
<ConsumerInputFields {...props} />
</FormProvider>
)
}
export default function ConsumerInput(props: ConsumerInputProps) {
const form = useOptionalFormContext()
const isControlled = props.value !== undefined || props.onValueChange !== undefined
if (!form || isControlled) {
return <ConsumerInputStandalone {...props} />
}
return <ConsumerInputFields {...props} />
}

Some files were not shown because too many files have changed in this diff Show More