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:
commit
e1eaf5eff5
430
.cursorrules
Normal file
430
.cursorrules
Normal 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
76
.dockerignore
Normal 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
4
.env.example
Normal 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
37
.github/BRANCH_PROTECTION.md
vendored
Normal 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
221
.github/workflows/deploy-vps.yml
vendored
Normal 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
68
.github/workflows/frontend-quality.yml
vendored
Normal 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
9
.gitignore
vendored
Normal 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
10
.gitleaks.toml
Normal 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
4
.husky/pre-commit
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
pnpm precommit:check
|
||||||
9
.husky/pre-push
Executable file
9
.husky/pre-push
Executable 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
3
.prettierignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Ignore artifacts:
|
||||||
|
build
|
||||||
|
coverage
|
||||||
16
.prettierrc
Normal file
16
.prettierrc
Normal 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
9
AGENTS.md
Normal 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 -->
|
||||||
78
Dockerfile
Normal file
78
Dockerfile
Normal 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
21
LICENSE
Normal 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.
|
||||||
218
PRODUCTION_READINESS_PLAN.md
Normal file
218
PRODUCTION_READINESS_PLAN.md
Normal 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
14
README.md
Normal 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
28
api/orval-client.ts
Normal 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
|
||||||
15
app/(dashboard)/admin/events/[id]/page.tsx
Normal file
15
app/(dashboard)/admin/events/[id]/page.tsx
Normal 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
|
||||||
199
app/(dashboard)/audit-logs/page.tsx
Normal file
199
app/(dashboard)/audit-logs/page.tsx
Normal 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
|
||||||
303
app/(dashboard)/bank-accounts/page.tsx
Normal file
303
app/(dashboard)/bank-accounts/page.tsx
Normal 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
|
||||||
@ -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
|
||||||
433
app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx
Normal file
433
app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx
Normal 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
|
||||||
146
app/(dashboard)/blog-articles/page.tsx
Normal file
146
app/(dashboard)/blog-articles/page.tsx
Normal 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
|
||||||
148
app/(dashboard)/bookings/page.tsx
Normal file
148
app/(dashboard)/bookings/page.tsx
Normal 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
|
||||||
@ -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 ?? '')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
238
app/(dashboard)/chat-oversight/[id]/page.tsx
Normal file
238
app/(dashboard)/chat-oversight/[id]/page.tsx
Normal 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
|
||||||
134
app/(dashboard)/chat-oversight/page.tsx
Normal file
134
app/(dashboard)/chat-oversight/page.tsx
Normal 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
|
||||||
99
app/(dashboard)/cities/page.tsx
Normal file
99
app/(dashboard)/cities/page.tsx
Normal 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
|
||||||
204
app/(dashboard)/contact-messages/page.tsx
Normal file
204
app/(dashboard)/contact-messages/page.tsx
Normal 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
|
||||||
434
app/(dashboard)/dashboard/page.tsx
Normal file
434
app/(dashboard)/dashboard/page.tsx
Normal 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
|
||||||
157
app/(dashboard)/discount-codes/page.tsx
Normal file
157
app/(dashboard)/discount-codes/page.tsx
Normal 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
19
app/(dashboard)/error.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
255
app/(dashboard)/event-categories/_components/CategoryTree.tsx
Normal file
255
app/(dashboard)/event-categories/_components/CategoryTree.tsx
Normal 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
|
||||||
@ -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
|
||||||
15
app/(dashboard)/event-categories/page.tsx
Normal file
15
app/(dashboard)/event-categories/page.tsx
Normal 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
|
||||||
7
app/(dashboard)/events/page.tsx
Normal file
7
app/(dashboard)/events/page.tsx
Normal 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
|
||||||
102
app/(dashboard)/guest-lists/_components/GuestListItemsModal.tsx
Normal file
102
app/(dashboard)/guest-lists/_components/GuestListItemsModal.tsx
Normal 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
|
||||||
109
app/(dashboard)/guest-lists/page.tsx
Normal file
109
app/(dashboard)/guest-lists/page.tsx
Normal 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
|
||||||
515
app/(dashboard)/identity-verifications/page.tsx
Normal file
515
app/(dashboard)/identity-verifications/page.tsx
Normal 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
|
||||||
14
app/(dashboard)/layout.tsx
Normal file
14
app/(dashboard)/layout.tsx
Normal 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
|
||||||
5
app/(dashboard)/loading.tsx
Normal file
5
app/(dashboard)/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { PageLoading } from '@/components/feedback/LoadingState'
|
||||||
|
|
||||||
|
export default function DashboardLoading() {
|
||||||
|
return <PageLoading label="در حال آمادهسازی پنل مدیریت" />
|
||||||
|
}
|
||||||
39
app/(dashboard)/manage-events/[id]/edit/page.tsx
Normal file
39
app/(dashboard)/manage-events/[id]/edit/page.tsx
Normal 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
|
||||||
11
app/(dashboard)/manage-events/[id]/page.tsx
Normal file
11
app/(dashboard)/manage-events/[id]/page.tsx
Normal 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
|
||||||
5
app/(dashboard)/manage-events/new/page.tsx
Normal file
5
app/(dashboard)/manage-events/new/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import AdminEventCreate from '@/features/events/create/AdminEventCreate'
|
||||||
|
|
||||||
|
const AdminEventCreatePage = () => <AdminEventCreate />
|
||||||
|
|
||||||
|
export default AdminEventCreatePage
|
||||||
247
app/(dashboard)/manage-events/page.tsx
Normal file
247
app/(dashboard)/manage-events/page.tsx
Normal 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
|
||||||
7
app/(dashboard)/manual-notifications/page.tsx
Normal file
7
app/(dashboard)/manual-notifications/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
import { APP_ROUTES } from '@/constants/routes'
|
||||||
|
|
||||||
|
export default function ManualNotificationsRedirectPage() {
|
||||||
|
redirect(APP_ROUTES.NOTIFICATIONS)
|
||||||
|
}
|
||||||
7
app/(dashboard)/notification-rules/page.tsx
Normal file
7
app/(dashboard)/notification-rules/page.tsx
Normal 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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
209
app/(dashboard)/notifications/_components/SmsMessagesPanel.tsx
Normal file
209
app/(dashboard)/notifications/_components/SmsMessagesPanel.tsx
Normal 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
|
||||||
110
app/(dashboard)/notifications/page.tsx
Normal file
110
app/(dashboard)/notifications/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
app/(dashboard)/payments/page.tsx
Normal file
196
app/(dashboard)/payments/page.tsx
Normal 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
|
||||||
10
app/(dashboard)/provinces/page.tsx
Normal file
10
app/(dashboard)/provinces/page.tsx
Normal 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')
|
||||||
|
}
|
||||||
7
app/(dashboard)/push-deliveries/page.tsx
Normal file
7
app/(dashboard)/push-deliveries/page.tsx
Normal 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)
|
||||||
|
}
|
||||||
227
app/(dashboard)/reviews/page.tsx
Normal file
227
app/(dashboard)/reviews/page.tsx
Normal 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
|
||||||
@ -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
|
||||||
329
app/(dashboard)/settlements/page.tsx
Normal file
329
app/(dashboard)/settlements/page.tsx
Normal 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
|
||||||
7
app/(dashboard)/sms-messages/page.tsx
Normal file
7
app/(dashboard)/sms-messages/page.tsx
Normal 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)
|
||||||
|
}
|
||||||
148
app/(dashboard)/support-tickets/[id]/page.tsx
Normal file
148
app/(dashboard)/support-tickets/[id]/page.tsx
Normal 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
|
||||||
93
app/(dashboard)/support-tickets/page.tsx
Normal file
93
app/(dashboard)/support-tickets/page.tsx
Normal 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
|
||||||
272
app/(dashboard)/user-reports/page.tsx
Normal file
272
app/(dashboard)/user-reports/page.tsx
Normal 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
|
||||||
262
app/(dashboard)/users/[id]/_components/UserEditModal.tsx
Normal file
262
app/(dashboard)/users/[id]/_components/UserEditModal.tsx
Normal 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
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
247
app/(dashboard)/users/[id]/_components/UserMobileChangeModal.tsx
Normal file
247
app/(dashboard)/users/[id]/_components/UserMobileChangeModal.tsx
Normal 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
|
||||||
103
app/(dashboard)/users/[id]/_components/WalletCreditModal.tsx
Normal file
103
app/(dashboard)/users/[id]/_components/WalletCreditModal.tsx
Normal 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
|
||||||
444
app/(dashboard)/users/[id]/page.tsx
Normal file
444
app/(dashboard)/users/[id]/page.tsx
Normal 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
|
||||||
117
app/(dashboard)/users/page.tsx
Normal file
117
app/(dashboard)/users/page.tsx
Normal 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
|
||||||
179
app/(dashboard)/wallet-deposits/page.tsx
Normal file
179
app/(dashboard)/wallet-deposits/page.tsx
Normal 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
|
||||||
350
app/(dashboard)/withdrawal-requests/page.tsx
Normal file
350
app/(dashboard)/withdrawal-requests/page.tsx
Normal 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
|
||||||
30
app/api/auth/clear-session/route.test.ts
Normal file
30
app/api/auth/clear-session/route.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
26
app/api/auth/clear-session/route.ts
Normal file
26
app/api/auth/clear-session/route.ts
Normal 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
|
||||||
|
}
|
||||||
93
app/api/download-upload/route.ts
Normal file
93
app/api/download-upload/route.ts
Normal 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
53
app/auth/layout.tsx
Normal 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
15
app/auth/page.tsx
Normal 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
19
app/error.tsx
Normal 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
23
app/global-error.tsx
Normal 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
80
app/layout.tsx
Normal 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
5
app/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { DetailSkeleton } from '@/components/feedback/LoadingState'
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <DetailSkeleton />
|
||||||
|
}
|
||||||
19
app/not-found.tsx
Normal file
19
app/not-found.tsx
Normal 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
8
app/page.tsx
Normal 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
29
app/providers.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
components/PaginatedList.test.tsx
Normal file
65
components/PaginatedList.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
32
components/PaginatedList.tsx
Normal file
32
components/PaginatedList.tsx
Normal 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
|
||||||
124
components/admin/ManualPayoutModal.tsx
Normal file
124
components/admin/ManualPayoutModal.tsx
Normal 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
|
||||||
215
components/auth/AdminAuthContent.tsx
Normal file
215
components/auth/AdminAuthContent.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
components/auth/AdminRoleGuard.tsx
Normal file
26
components/auth/AdminRoleGuard.tsx
Normal 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
|
||||||
32
components/auth/PendingUserGuard.tsx
Normal file
32
components/auth/PendingUserGuard.tsx
Normal 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
|
||||||
88
components/consumer/ConsumerActionButtons.tsx
Normal file
88
components/consumer/ConsumerActionButtons.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
components/consumer/ConsumerButton.tsx
Normal file
168
components/consumer/ConsumerButton.tsx
Normal 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
|
||||||
243
components/consumer/ConsumerInput.tsx
Normal file
243
components/consumer/ConsumerInput.tsx
Normal 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
Loading…
Reference in New Issue
Block a user