Compare commits

..

10 Commits

Author SHA1 Message Date
a307400f83 ci: migrate Actions from GitHub to Gitea registry [skip ci]
Move workflows under .gitea/workflows and publish images to
git.ghabilee.ir instead of ghcr.io so deploy runs on self-hosted Gitea.
2026-09-13 14:49:37 +03:30
edd468cf1d test: enhance test configurations and cleanup procedures
Updated the Vitest configuration to increase test and hook timeouts to 20 seconds, addressing flaky tests under pre-push load. Additionally, improved test cleanup procedures across multiple test files by ensuring mocks are cleared after each test, enhancing test reliability and maintainability.
2026-09-13 13:42:22 +03:30
3f072976fe test(axios): refactor axios functional tests for improved clarity
Refactored the axios functional tests by removing unnecessary asynchronous imports and simplifying the setup in the `beforeEach` hook. This change enhances the readability and maintainability of the test code, ensuring it aligns with the project's coding standards.
2026-09-13 13:31:19 +03:30
c0428bb65d refactor(UserEditModal): format alert message for better readability
Updated the alert message in the UserEditModal component to improve code readability by formatting the function call across multiple lines. This change enhances maintainability and aligns with the project's coding standards.
2026-09-13 13:09:10 +03:30
3b86c2c72f feat(api): add notifications endpoints for user notifications
Introduced new API endpoints for managing in-app notifications. The `/api/v1/notifications/me` endpoint retrieves a paginated list of notifications for the authenticated user, while the `/api/v1/notifications/me/unread-count` endpoint returns the count of unread notifications. Enhanced the OpenAPI documentation to reflect these changes, including detailed parameter descriptions and response schemas for better clarity and usability.
2026-09-13 13:04:02 +03:30
26feb8f2f1 chore(version): bump version to 0.1.17
Updated the project version in package.json from 0.1.16 to 0.1.17 to reflect the latest changes and improvements.
2026-09-13 12:54:43 +03:30
96603d31f7 feat(alert-modal): integrate alert modal for confirmation actions
Enhanced various components to utilize the alert modal for user confirmations before executing critical actions. This includes marking messages as read, saving notification rules, restoring reviews, sending replies, changing ticket statuses, and managing event commissions. The integration improves user experience by ensuring actions are intentional and provides clear feedback on the outcomes.
2026-09-13 10:14:18 +03:30
0a68104e85 chore(openapi): format tags in API documentation for consistency
Updated the OpenAPI specification to format the 'tags' property as an array on multiple endpoints, ensuring consistent styling across the documentation. Additionally, simplified JSX return statements in the BookingsPage and AdminEventBookingsTab components for improved readability.
2026-09-11 23:00:12 +03:30
32b06db81c fix 2026-09-11 22:56:47 +03:30
3a08625cbe feat(bookings): add cancellation reason column to bookings table
Enhanced the bookings page and admin event bookings tab by adding a new column for 'cancellationReason'. This column displays the reason for cancellations when applicable, improving the visibility of booking statuses. Additionally, removed the 'غایب' (no-show) insight stat from the event overview tab for a cleaner presentation of event insights.
2026-09-11 22:56:04 +03:30
31 changed files with 5898 additions and 3343 deletions

View File

@ -0,0 +1,49 @@
# Branch protection on `main` (Gitea)
Configure in Gitea:
**Repository → Settings → Branches → Add branch protection rule → Branch name pattern: `main`**
Recommended settings:
- [x] Enable push
- [x] Enable merge
- [x] Require pull request reviews (optional for solo work)
- [x] Enable status check
- [x] Require branches to be up to date before merging
### Required status checks
- `Dependency vulnerability scan`
- `Secret scan`
- `Build, test, and quality checks`
Until status checks are wired, use **Pull Request → merge** (not direct push) and rely on
local Husky `pre-push` (`pnpm prepush:check`).
### Gitea Actions secrets
Repo → Settings → Actions → Secrets:
| Secret | Purpose |
| ------------- | --------------------------------------------- |
| `VPS_SSH_KEY` | Private key for SSH deploy to the Iran VPS |
| `VPS_HOST` | VPS host/IP reachable from the Actions runner |
| `VPS_USER` | SSH user (usually `root`) |
`GITHUB_TOKEN` is injected automatically by Gitea Actions (used for the container registry).
### Container registry
Images publish to: `git.ghabilee.ir/<owner>/<repo>:<sha>`
### Telegram deploy alerts
Notify scripts SSH into the VPS and use Telegram credentials already on the server
(`/opt/ghabilee-admin` / shared ops env). No extra Gitea secrets are required for notify
unless you change `scripts/notify-via-vps.sh`.
### Remotes
- Gitea (canonical): `https://git.ghabilee.ir/AliSaZa/admin.git`
- Git SSH: `ssh://git@git.ghabilee.ir:222/AliSaZa/admin.git`

View File

@ -14,7 +14,8 @@ permissions:
packages: write packages: write
# PR merges: build + deploy only (quality ran on pull_request). # PR merges: build + deploy only (quality ran on pull_request).
# Direct pushes to main: re-run quality before deploy. # Direct pushes to main: quality gate is skipped here (same as prior GitHub boot policy);
# quality still runs on pull_request via frontend-quality.yml.
jobs: jobs:
gate: gate:
name: Detect direct push to main name: Detect direct push to main
@ -48,7 +49,7 @@ jobs:
quality: quality:
needs: gate needs: gate
if: needs.gate.outputs.run_quality == 'true' if: needs.gate.outputs.run_quality == 'true'
uses: ./.github/workflows/frontend-quality.yml uses: ./.gitea/workflows/frontend-quality.yml
permissions: permissions:
contents: read contents: read
pull-requests: read pull-requests: read
@ -73,7 +74,9 @@ jobs:
set -euo pipefail set -euo pipefail
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
repo="$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')" repo="$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')"
echo "image=ghcr.io/${owner}/${repo}" >> "$GITHUB_OUTPUT" # Gitea container registry (same host as git.ghabilee.ir)
echo "image=git.ghabilee.ir/${owner}/${repo}" >> "$GITHUB_OUTPUT"
echo "registry=git.ghabilee.ir" >> "$GITHUB_OUTPUT"
- name: Fetch production build environment - name: Fetch production build environment
env: env:
@ -95,10 +98,10 @@ jobs:
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- name: Log in to GHCR - name: Log in to Gitea container registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ${{ steps.meta.outputs.registry }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
@ -127,8 +130,9 @@ jobs:
- name: Copy Compose definition and deploy - name: Copy Compose definition and deploy
env: env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_USER: ${{ github.actor }} REGISTRY_USER: ${{ github.actor }}
REGISTRY_HOST: git.ghabilee.ir
ADMIN_IMAGE: ${{ needs.build.outputs.image }}:${{ github.sha }} ADMIN_IMAGE: ${{ needs.build.outputs.image }}:${{ github.sha }}
VPS_HOST: ${{ secrets.VPS_HOST }} VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }} VPS_USER: ${{ secrets.VPS_USER }}
@ -149,9 +153,9 @@ jobs:
"${VPS_USER}@${VPS_HOST}:/opt/ghabilee-admin/docker-compose.yml" "${VPS_USER}@${VPS_HOST}:/opt/ghabilee-admin/docker-compose.yml"
ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
"${VPS_USER}@${VPS_HOST}" \ "${VPS_USER}@${VPS_HOST}" \
"GHCR_TOKEN='${GHCR_TOKEN}' GHCR_USER='${GHCR_USER}' ADMIN_IMAGE='${ADMIN_IMAGE}' sh -s" <<'REMOTE' "REGISTRY_TOKEN='${REGISTRY_TOKEN}' REGISTRY_USER='${REGISTRY_USER}' REGISTRY_HOST='${REGISTRY_HOST}' ADMIN_IMAGE='${ADMIN_IMAGE}' sh -s" <<'REMOTE'
set -eu set -eu
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
docker pull "$ADMIN_IMAGE" docker pull "$ADMIN_IMAGE"
cd /opt/ghabilee-admin cd /opt/ghabilee-admin
ADMIN_IMAGE="$ADMIN_IMAGE" docker compose -f docker-compose.yml up -d --no-deps ghabilee-admin ADMIN_IMAGE="$ADMIN_IMAGE" docker compose -f docker-compose.yml up -d --no-deps ghabilee-admin

View File

@ -1,37 +1,5 @@
# Branch protection on `main` (GitHub Pro required) # Branch protection / CI docs moved to Gitea
Private repositories on GitHub Free cannot enable branch protection via API or See [`.gitea/BRANCH_PROTECTION.md`](../.gitea/BRANCH_PROTECTION.md).
Settings. Upgrade to **GitHub Pro** (or make the repo public), then configure:
**Settings → Branches → Add branch protection rule → Branch name: `main`** Canonical remote: <https://git.ghabilee.ir/AliSaZa/admin>
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 an **admin/backoffice-specific** message via
`scripts/notify-deploy.sh` (distinct from the consumer frontend notify copy).

View File

@ -56,6 +56,13 @@ const columns: PaginationListColumnType[] = [
type: 'select', type: 'select',
filterItems: BOOKING_STATUS_FILTER_ITEMS, filterItems: BOOKING_STATUS_FILTER_ITEMS,
}, },
{
field: 'cancellationReason',
label: 'دلیل لغو',
filterable: false,
sortable: false,
type: 'text',
},
{ {
field: 'checkedInAt', field: 'checkedInAt',
label: 'چک‌این', label: 'چک‌این',
@ -123,6 +130,14 @@ const BookingsPage = () => {
/> />
) )
}, },
cancellationReason: (row, cellValue) => {
const reason = coerceToString(cellValue).trim()
const isCancelledLike = row.status === 'cancelled' || row.status === 'refunded'
if (!isCancelledLike || !reason) return '—'
return <span className="max-w-[220px] whitespace-pre-wrap break-words text-xs text-secondary-20">{reason}</span>
},
checkedInAt: (_row, cellValue) => formatPersianDate(cellValue), checkedInAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue), createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => { actions: (row) => {

View File

@ -13,6 +13,7 @@ import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip' import StatusChip from '@/components/ui/StatusChip'
import axiosInstance from '@/config/axios' import axiosInstance from '@/config/axios'
import useAdminMutation from '@/hooks/useAdminMutation' import useAdminMutation from '@/hooks/useAdminMutation'
import useAlertModal from '@/hooks/useAlertModal'
import { coerceToString } from '@/helpers' import { coerceToString } from '@/helpers'
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters' import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
import { getBooleanStatus } from '@/constants/status' import { getBooleanStatus } from '@/constants/status'
@ -79,9 +80,16 @@ const columns: PaginationListColumnType[] = [
] ]
const ContactMessagesPage = () => { const ContactMessagesPage = () => {
const { showAlert } = useAlertModal()
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST }) const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST })
const [selected, setSelected] = useState<ContactMessageRow | null>(null) const [selected, setSelected] = useState<ContactMessageRow | null>(null)
const handleMarkRead = (message: ContactMessageRow) => {
showAlert('این پیام به‌عنوان خوانده‌شده علامت‌گذاری شود؟', () =>
runAction(message.id, () => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)), 'پیام خوانده شد')
)
}
return ( return (
<section className="h-full w-full text-right"> <section className="h-full w-full text-right">
<PageNavbar pageTitle="پیام‌های تماس" /> <PageNavbar pageTitle="پیام‌های تماس" />
@ -150,13 +158,9 @@ const ContactMessagesPage = () => {
isLoading={pendingId === message.id} isLoading={pendingId === message.id}
size="sm" size="sm"
variant="light" variant="light"
onClick={() => onClick={() => {
void runAction( handleMarkRead(message)
message.id, }}
() => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)),
'پیام خوانده شد'
)
}
> >
<FileCheckIcon className="size-5" /> <FileCheckIcon className="size-5" />
</Button> </Button>

View File

@ -16,7 +16,12 @@ vi.mock('@/config/axios', () => ({
patch: axiosMocks.patch, patch: axiosMocks.patch,
}, },
})) }))
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
vi.mock('@/lib/toast', () => ({ addToast: vi.fn() })) vi.mock('@/lib/toast', () => ({ addToast: vi.fn() }))
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
vi.mock('@/components/formElements/Input', () => ({ vi.mock('@/components/formElements/Input', () => ({
default: ({ default: ({
description, description,
@ -109,6 +114,9 @@ describe('NotificationRulesPanel', () => {
}) })
beforeEach(() => { beforeEach(() => {
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
axiosMocks.get.mockReset() axiosMocks.get.mockReset()
axiosMocks.patch.mockReset() axiosMocks.patch.mockReset()
axiosMocks.get.mockResolvedValue({ axiosMocks.get.mockResolvedValue({

View File

@ -10,6 +10,7 @@ import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input' import Input from '@/components/formElements/Input'
import AdminState from '@/components/feedback/AdminState' import AdminState from '@/components/feedback/AdminState'
import axiosInstance from '@/config/axios' import axiosInstance from '@/config/axios'
import useAlertModal from '@/hooks/useAlertModal'
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse' import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
import { API_ROUTES } from '@/services/config' import { API_ROUTES } from '@/services/config'
import { extractServerErrorDetail } from '@/services/errorHandler' import { extractServerErrorDetail } from '@/services/errorHandler'
@ -52,6 +53,7 @@ const isDirtyDraft = (current: RuleDraft, saved: RuleDraft | undefined) =>
Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved) Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved)
const NotificationRulesPanel = () => { const NotificationRulesPanel = () => {
const { showAlert } = useAlertModal()
const [rules, setRules] = useState<Rule[]>([]) const [rules, setRules] = useState<Rule[]>([])
const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({}) const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -111,6 +113,12 @@ const NotificationRulesPanel = () => {
} }
} }
const confirmSave = (rule: Rule) => {
showAlert(`تغییرات قاعدهٔ «${rule.displayName}» ذخیره شود؟`, () => {
void save(rule)
})
}
return ( return (
<div className="space-y-4"> <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"> <div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm leading-7 text-blue-900">
@ -142,7 +150,9 @@ const NotificationRulesPanel = () => {
rule={rule} rule={rule}
saving={saving === rule.eventKey} saving={saving === rule.eventKey}
onChange={change} onChange={change}
onSave={() => void save(rule)} onSave={() => {
confirmSave(rule)
}}
/> />
))} ))}
</div> </div>

View File

@ -115,7 +115,9 @@ const ReviewsPage = () => {
} }
const handleRestore = (row: ReviewRow) => { const handleRestore = (row: ReviewRow) => {
void runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد') showAlert('این نظر دوباره در نمایش عمومی قرار گیرد؟', () =>
runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد')
)
} }
const handleDelete = (row: ReviewRow) => { const handleDelete = (row: ReviewRow) => {

View File

@ -58,7 +58,18 @@ const AdminSupportTicketDetail = () => {
} }
} }
const confirmClose = () => { const confirmSend = () => {
if (!reply.trim()) return
showAlert('این پاسخ ثبت و پیامک اطلاع‌رسانی برای کاربر ارسال شود؟', () => {
void send()
})
}
const confirmStatusChange = (nextStatus: string) => {
if (!ticket || nextStatus === ticket.status) return
if (nextStatus === 'closed') {
showAlert( showAlert(
'این تیکت بسته شود؟ کاربر دیگر نمی‌تواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمی‌تواند دوباره باز کند.', 'این تیکت بسته شود؟ کاربر دیگر نمی‌تواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمی‌تواند دوباره باز کند.',
() => { () => {
@ -67,6 +78,19 @@ const AdminSupportTicketDetail = () => {
undefined, undefined,
{ dangerAccept: true } { dangerAccept: true }
) )
return
}
const label = SUPPORT_ADMIN_STATUS_LABELS[nextStatus] ?? nextStatus
showAlert(`وضعیت تیکت به «${label}» تغییر کند؟`, () => {
void changeStatus(nextStatus)
})
}
const confirmClose = () => {
confirmStatusChange('closed')
} }
return ( return (
@ -104,7 +128,9 @@ const AdminSupportTicketDetail = () => {
<select <select
className="mt-1 block w-full rounded-lg border border-default-300 p-2" className="mt-1 block w-full rounded-lg border border-default-300 p-2"
value={ticket.status} value={ticket.status}
onChange={(event) => void changeStatus(event.target.value)} onChange={(event) => {
confirmStatusChange(event.target.value)
}}
> >
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => ( {Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
<option <option
@ -166,7 +192,7 @@ const AdminSupportTicketDetail = () => {
</p> </p>
<Button <Button
isLoading={pending} isLoading={pending}
onClick={() => void send()} onClick={confirmSend}
> >
ثبت پاسخ و ارسال پیامک ثبت پاسخ و ارسال پیامک
</Button> </Button>

View File

@ -140,16 +140,29 @@ const UserEditModal = ({ isOpen, onOpenChange, user, currentAdminId, onSuccess }
} }
const handleSubmit = (values: AdminUserEditValues) => { const handleSubmit = (values: AdminUserEditValues) => {
// Suspending an account is destructive-ish for the user, so confirm first.
const initial = toFormValues(user) const initial = toFormValues(user)
const payload = buildDiffPayload(values)
if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') { if (Object.keys(payload).length === 0) {
showAlert('این کاربر معلق شود؟ کاربر تا فعال‌سازی مجدد امکان استفاده از حساب را نخواهد داشت.', () => submitUpdate(values)) onOpenChange(false)
return return
} }
void submitUpdate(values) if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') {
showAlert(
'این کاربر معلق شود؟ کاربر تا فعال‌سازی مجدد امکان استفاده از حساب را نخواهد داشت.',
() => submitUpdate(values),
undefined,
{
dangerAccept: true,
}
)
return
}
showAlert('تغییرات این کاربر ذخیره شود؟', () => submitUpdate(values))
} }
return ( return (

View File

@ -1,6 +1,8 @@
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import axiosInstance, { getOrRefreshAccessToken, resetAxiosAuthModuleState } from '@/config/axios'
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
expireRefreshSession: vi.fn().mockResolvedValue(undefined), expireRefreshSession: vi.fn().mockResolvedValue(undefined),
})) }))
@ -23,18 +25,15 @@ const writeStoredToken = (accessToken: string): void => {
} }
describe('admin access-token refresh races', () => { describe('admin access-token refresh races', () => {
beforeEach(async () => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.restoreAllMocks() vi.restoreAllMocks()
window.localStorage.clear() window.localStorage.clear()
const { resetAxiosAuthModuleState } = await import('@/config/axios')
resetAxiosAuthModuleState() resetAxiosAuthModuleState()
}) })
it('replays a late 401 with the newer stored token without rotating refresh again', async () => { it('replays a late 401 with the newer stored token without rotating refresh again', async () => {
writeStoredToken('access-a') writeStoredToken('access-a')
const { default: axiosInstance } = await import('@/config/axios')
const refreshRequest = vi.spyOn(axios, 'post') const refreshRequest = vi.spyOn(axios, 'post')
const authorizationHeaders: string[] = [] const authorizationHeaders: string[] = []
let requestCount = 0 let requestCount = 0
@ -85,7 +84,6 @@ describe('admin access-token refresh races', () => {
}) })
try { try {
const { getOrRefreshAccessToken } = await import('@/config/axios')
const refreshRequest = vi.spyOn(axios, 'post') const refreshRequest = vi.spyOn(axios, 'post')
await expect(getOrRefreshAccessToken()).resolves.toBe('access-b') await expect(getOrRefreshAccessToken()).resolves.toBe('access-b')

View File

@ -1,10 +1,38 @@
import type { ButtonHTMLAttributes } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { AlertModalProvider } from '@/context/AlertModalContext' import { AlertModalProvider } from '@/context/AlertModalContext'
import useAlertModal from '@/hooks/useAlertModal' import useAlertModal from '@/hooks/useAlertModal'
afterEach(cleanup) vi.mock('@/components/formElements/Button', () => ({
default: ({
children,
isLoading,
fullWidth: _fullWidth,
color: _color,
variant: _variant,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
isLoading?: boolean
fullWidth?: boolean
color?: string
variant?: string
}) => (
<button
{...props}
disabled={props.disabled || isLoading}
>
{children}
</button>
),
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
function AlertHarness({ onConfirm }: { onConfirm: () => void | Promise<void> }) { function AlertHarness({ onConfirm }: { onConfirm: () => void | Promise<void> }) {
const { showAlert } = useAlertModal() const { showAlert } = useAlertModal()

View File

@ -143,7 +143,10 @@ test('edits discoverability and publishes a draft event', async ({ page }) => {
const [updateRequest] = await Promise.all([ const [updateRequest] = await Promise.all([
page.waitForRequest((request) => request.method() === 'PATCH' && new URL(request.url()).pathname.endsWith('/admin/events/event-1')), page.waitForRequest((request) => request.method() === 'PATCH' && new URL(request.url()).pathname.endsWith('/admin/events/event-1')),
page.getByRole('switch', { name: 'نمایش در جستجو' }).click({ force: true }), (async () => {
await page.getByRole('switch', { name: 'نمایش در جستجو' }).click({ force: true })
await page.getByRole('button', { name: 'تأیید' }).click()
})(),
]) ])
expect(updateRequest.postDataJSON()).toEqual({ settings: { isDiscoverable: true } }) expect(updateRequest.postDataJSON()).toEqual({ settings: { isDiscoverable: true } })
@ -153,6 +156,7 @@ test('edits discoverability and publishes a draft event', async ({ page }) => {
) )
await page.getByRole('button', { name: 'انتشار فوری' }).click() await page.getByRole('button', { name: 'انتشار فوری' }).click()
await page.getByRole('button', { name: 'تأیید' }).click()
await publishRequest await publishRequest
await expect(page.getByText('منتشر‌شده', { exact: true })).toBeVisible() await expect(page.getByText('منتشر‌شده', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0) await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)

View File

@ -127,6 +127,7 @@ test('admin approval publishes a pending-review event exactly once', async ({ pa
await page.goto('/manage-events/event-1') await page.goto('/manage-events/event-1')
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toBeEnabled() await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toBeEnabled()
await page.getByRole('button', { name: 'تأیید و انتشار' }).click() await page.getByRole('button', { name: 'تأیید و انتشار' }).click()
await page.getByRole('button', { name: 'تأیید' }).click()
await expect.poll(() => requests).toEqual([{ path: '/api/v1/admin/events/event-1/approve', payload: null }]) await expect.poll(() => requests).toEqual([{ path: '/api/v1/admin/events/event-1/approve', payload: null }])
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toHaveCount(0) await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toHaveCount(0)

View File

@ -197,7 +197,10 @@ const AdminEventDetail = () => {
const cityName = useMemo(() => cities.find((item) => item.id === event?.cityId)?.name ?? '—', [cities, event?.cityId]) const cityName = useMemo(() => cities.find((item) => item.id === event?.cityId)?.name ?? '—', [cities, event?.cityId])
const provinceName = useMemo(() => provinces.find((item) => item.id === event?.provinceId)?.name ?? '—', [event?.provinceId, provinces]) const provinceName = useMemo(() => provinces.find((item) => item.id === event?.provinceId)?.name ?? '—', [event?.provinceId, provinces])
const handleApprove = () => const handleApprove = () => {
const isPendingReview = event?.status === 'pending_review'
showAlert(isPendingReview ? 'این رویداد تأیید و منتشر شود؟' : 'این رویداد برای انتشار تأیید شود؟', () =>
runAction( runAction(
'approve', 'approve',
async () => { async () => {
@ -205,13 +208,16 @@ const AdminEventDetail = () => {
mergeEvent(updated) mergeEvent(updated)
}, },
event?.status === 'pending_review' ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد' isPendingReview ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
) )
)
}
// Bypasses the whole request/approve flow — publishes immediately // Bypasses the whole request/approve flow — publishes immediately
// regardless of whether the host has requested publication or an admin // regardless of whether the host has requested publication or an admin
// has approved yet. Kept as a separate action from "approve" on purpose. // has approved yet. Kept as a separate action from "approve" on purpose.
const handleForcePublish = () => const handleForcePublish = () => {
showAlert('این رویداد فوراً و بدون انتظار برای میزبان منتشر شود؟', () =>
runAction( runAction(
'force-publish', 'force-publish',
async () => { async () => {
@ -221,6 +227,8 @@ const AdminEventDetail = () => {
}, },
'رویداد فورا منتشر شد' 'رویداد فورا منتشر شد'
) )
)
}
const handleComplete = () => { const handleComplete = () => {
showAlert('آیا این رویداد به پایان رسیده است؟', () => showAlert('آیا این رویداد به پایان رسیده است؟', () =>
@ -276,7 +284,8 @@ const AdminEventDetail = () => {
const handleApproveRevision = () => { const handleApproveRevision = () => {
if (!pendingRevision) return if (!pendingRevision) return
void runAction( showAlert('این ویرایش تأیید و روی رویداد اعمال شود؟', () =>
runAction(
'approve-revision', 'approve-revision',
async () => { async () => {
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
@ -287,6 +296,7 @@ const AdminEventDetail = () => {
}, },
texts.events.revisionApproveSuccess texts.events.revisionApproveSuccess
) )
)
} }
const handleRejectRevision = (rejectionReason: string) => { const handleRejectRevision = (rejectionReason: string) => {
@ -303,12 +313,13 @@ const AdminEventDetail = () => {
) )
} }
const handleToggleDiscoverable = async (nextValue: boolean) => { const handleToggleDiscoverable = (nextValue: boolean) => {
if (!event || isTogglingDiscoverable) return if (!event || isTogglingDiscoverable) return
const previous = event.settings.isDiscoverable const previous = event.settings.isDiscoverable
setEvent({ ...event, settings: { ...event.settings, isDiscoverable: nextValue } }) showAlert(nextValue ? 'نمایش این رویداد در جستجوی عمومی فعال شود؟' : 'نمایش این رویداد در جستجوی عمومی غیرفعال شود؟', async () => {
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: nextValue } } : prev))
setIsTogglingDiscoverable(true) setIsTogglingDiscoverable(true)
try { try {
@ -326,6 +337,7 @@ const AdminEventDetail = () => {
} finally { } finally {
setIsTogglingDiscoverable(false) setIsTogglingDiscoverable(false)
} }
})
} }
return ( return (
@ -336,11 +348,11 @@ const AdminEventDetail = () => {
<AdminEventLifecycleActions <AdminEventLifecycleActions
event={event} event={event}
pendingId={pendingId} pendingId={pendingId}
onApprove={() => void handleApprove()} onApprove={handleApprove}
onCancel={handleCancel} onCancel={handleCancel}
onComplete={handleComplete} onComplete={handleComplete}
onDelete={handleDelete} onDelete={handleDelete}
onForcePublish={() => void handleForcePublish()} onForcePublish={handleForcePublish}
onOpenReject={() => { onOpenReject={() => {
setHasOpenedRejectModal(true) setHasOpenedRejectModal(true)
setIsRejectModalOpen(true) setIsRejectModalOpen(true)

View File

@ -11,8 +11,12 @@ const bulkCreate = vi.fn()
const setActive = vi.fn() const setActive = vi.fn()
const removeCode = vi.fn() const removeCode = vi.fn()
const addToast = vi.fn() const addToast = vi.fn()
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) })) vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
vi.mock('@/services/discountCodes', () => ({ vi.mock('@/services/discountCodes', () => ({
LIST_DISCOUNT_CODES: (...args: unknown[]) => listCodes(...args), LIST_DISCOUNT_CODES: (...args: unknown[]) => listCodes(...args),
LIST_DISCOUNT_REDEMPTIONS: (...args: unknown[]) => listRedemptions(...args), LIST_DISCOUNT_REDEMPTIONS: (...args: unknown[]) => listRedemptions(...args),
@ -67,10 +71,16 @@ const freshCode = {
} }
describe('EventDiscountsPanel', () => { describe('EventDiscountsPanel', () => {
afterEach(cleanup) afterEach(() => {
cleanup()
vi.clearAllMocks()
})
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
listCodes.mockResolvedValue({ ok: true, data: { items: [usedCode, freshCode], totalItemsCount: 2, totalPages: 1 } }) listCodes.mockResolvedValue({ ok: true, data: { items: [usedCode, freshCode], totalItemsCount: 2, totalPages: 1 } })
listRedemptions.mockResolvedValue({ ok: true, data: { items: [], totalItemsCount: 0, totalPages: 0 } }) listRedemptions.mockResolvedValue({ ok: true, data: { items: [], totalItemsCount: 0, totalPages: 0 } })
getReport.mockResolvedValue({ getReport.mockResolvedValue({
@ -136,6 +146,7 @@ describe('EventDiscountsPanel', () => {
) )
await screen.findByText('WELCOME20') await screen.findByText('WELCOME20')
bulkCreate.mockClear()
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '25' } }) fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '25' } })
fireEvent.change(screen.getByLabelText('تعداد کد یکتا'), { target: { value: '2' } }) fireEvent.change(screen.getByLabelText('تعداد کد یکتا'), { target: { value: '2' } })
@ -157,6 +168,8 @@ describe('EventDiscountsPanel', () => {
) )
await screen.findByText('WELCOME20') await screen.findByText('WELCOME20')
bulkCreate.mockClear()
addToast.mockClear()
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '150' } }) fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '150' } })
fireEvent.click(screen.getByRole('button', { name: 'ساخت کد تخفیف' })) fireEvent.click(screen.getByRole('button', { name: 'ساخت کد تخفیف' }))

View File

@ -18,6 +18,7 @@ import {
GET_DISCOUNT_MANAGEMENT_BOOTSTRAP, GET_DISCOUNT_MANAGEMENT_BOOTSTRAP,
SET_DISCOUNT_CODE_ACTIVE, SET_DISCOUNT_CODE_ACTIVE,
} from '@/services/discountCodes' } from '@/services/discountCodes'
import useAlertModal from '@/hooks/useAlertModal'
interface EventDiscountsPanelProps { interface EventDiscountsPanelProps {
eventId: string eventId: string
@ -51,6 +52,7 @@ const DISCOUNT_BEARER_OPTIONS = [
] as const ] as const
const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false }: EventDiscountsPanelProps) => { const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false }: EventDiscountsPanelProps) => {
const { showAlert } = useAlertModal()
const [codes, setCodes] = useState<DiscountCode[]>([]) const [codes, setCodes] = useState<DiscountCode[]>([])
const [redemptions, setRedemptions] = useState<DiscountRedemption[]>([]) const [redemptions, setRedemptions] = useState<DiscountRedemption[]>([])
const [report, setReport] = useState<DiscountReport | null>(null) const [report, setReport] = useState<DiscountReport | null>(null)
@ -96,26 +98,7 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
const canCreate = !isFree && !isEnded const canCreate = !isFree && !isEnded
const canMutateCodes = !isEnded const canMutateCodes = !isEnded
const handleCreate = async () => { const executeCreate = async (numericValue: number, numericQuantity: number) => {
const numericValue = toInt(value)
const numericQuantity = toInt(quantity)
if (!numericValue) {
addToast({ title: texts.events.discountValueRequired, color: 'warning' })
return
}
if (type === 'percent' && (numericValue < 1 || numericValue > 99)) {
addToast({ title: texts.events.discountPercentRange, color: 'warning' })
return
}
if (!numericQuantity) {
addToast({ title: texts.events.discountCountRequired, color: 'warning' })
return
}
setIsCreating(true) setIsCreating(true)
const result = await BULK_CREATE_DISCOUNT_CODES(eventId, { const result = await BULK_CREATE_DISCOUNT_CODES(eventId, {
type, type,
@ -143,13 +126,39 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
await load() await load()
} }
const handleToggleActive = async (code: DiscountCode, nextActive: boolean) => { const handleCreate = () => {
const numericValue = toInt(value)
const numericQuantity = toInt(quantity)
if (!numericValue) {
addToast({ title: texts.events.discountValueRequired, color: 'warning' })
return
}
if (type === 'percent' && (numericValue < 1 || numericValue > 99)) {
addToast({ title: texts.events.discountPercentRange, color: 'warning' })
return
}
if (!numericQuantity) {
addToast({ title: texts.events.discountCountRequired, color: 'warning' })
return
}
showAlert(`${number(numericQuantity)} کد تخفیف ساخته شود؟`, () => {
void executeCreate(numericValue, numericQuantity)
})
}
const handleToggleActive = (code: DiscountCode, nextActive: boolean) => {
if (!canMutateCodes) { if (!canMutateCodes) {
addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' }) addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' })
return return
} }
showAlert(nextActive ? `کد «${code.code}» فعال شود؟` : `کد «${code.code}» غیرفعال شود؟`, async () => {
setPendingCodeId(code.id) setPendingCodeId(code.id)
const result = await SET_DISCOUNT_CODE_ACTIVE(code.id, nextActive) const result = await SET_DISCOUNT_CODE_ACTIVE(code.id, nextActive)
@ -162,9 +171,10 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
} }
setCodes((current) => current.map((item) => (item.id === code.id ? { ...item, isActive: nextActive } : item))) setCodes((current) => current.map((item) => (item.id === code.id ? { ...item, isActive: nextActive } : item)))
})
} }
const handleDelete = async (code: DiscountCode) => { const handleDelete = (code: DiscountCode) => {
if (!canMutateCodes) { if (!canMutateCodes) {
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' }) addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
@ -177,6 +187,9 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
return return
} }
showAlert(
`کد تخفیف «${code.code}» حذف شود؟`,
async () => {
setPendingCodeId(code.id) setPendingCodeId(code.id)
const result = await DELETE_DISCOUNT_CODE(code.id) const result = await DELETE_DISCOUNT_CODE(code.id)
@ -190,6 +203,10 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
addToast({ title: texts.events.discountDeleted, color: 'success' }) addToast({ title: texts.events.discountDeleted, color: 'success' })
setCodes((current) => current.filter((item) => item.id !== code.id)) setCodes((current) => current.filter((item) => item.id !== code.id))
},
undefined,
{ dangerAccept: true }
)
} }
const copyCodes = async (list: string[]) => { const copyCodes = async (list: string[]) => {
@ -458,7 +475,9 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
label="" label=""
name={`code-active-${code.id}`} name={`code-active-${code.id}`}
value={code.isActive} value={code.isActive}
onValueChange={(next) => void handleToggleActive(code, Boolean(next))} onValueChange={(next) => {
handleToggleActive(code, Boolean(next))
}}
/> />
<span className="text-xs text-secondary-30"> <span className="text-xs text-secondary-30">
{isEnded ? texts.events.eventEndedShort : code.isActive ? texts.events.availableToGuests : texts.events.deactivated} {isEnded ? texts.events.eventEndedShort : code.isActive ? texts.events.availableToGuests : texts.events.deactivated}
@ -469,7 +488,9 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
disabled={!canMutateCodes || code.redeemedCount > 0 || pendingCodeId === code.id} disabled={!canMutateCodes || code.redeemedCount > 0 || pendingCodeId === code.id}
size="sm" size="sm"
variant="flat" variant="flat"
onClick={() => void handleDelete(code)} onClick={() => {
handleDelete(code)
}}
> >
{texts.common.delete} {texts.common.delete}
</Button> </Button>

View File

@ -19,6 +19,7 @@ import { checkInBookingAsAdmin } from '@/services/eventManagement'
import { API_ROUTES } from '@/services/config' import { API_ROUTES } from '@/services/config'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import useAdminAction from '@/hooks/useAdminAction' import useAdminAction from '@/hooks/useAdminAction'
import useAlertModal from '@/hooks/useAlertModal'
// Bookings tab columns — copied from app/(dashboard)/bookings/page.tsx, // Bookings tab columns — copied from app/(dashboard)/bookings/page.tsx,
// minus the `eventId` column (this list is already scoped to one event via // minus the `eventId` column (this list is already scoped to one event via
@ -34,6 +35,7 @@ const bookingsColumns: PaginationListColumnType[] = [
{ field: 'bookingCode', label: 'کد رزرو', filterable: false, sortable: false, type: 'text' }, { field: 'bookingCode', label: 'کد رزرو', filterable: false, sortable: false, type: 'text' },
{ field: 'userId', label: 'مهمان', filterable: false, sortable: false, type: 'text' }, { field: 'userId', label: 'مهمان', filterable: false, sortable: false, type: 'text' },
{ field: 'status', label: 'وضعیت', filterable: true, sortable: true, type: 'select', filterItems: BOOKING_STATUS_FILTER_ITEMS }, { field: 'status', label: 'وضعیت', filterable: true, sortable: true, type: 'select', filterItems: BOOKING_STATUS_FILTER_ITEMS },
{ field: 'cancellationReason', label: 'دلیل لغو', filterable: false, sortable: false, type: 'text' },
{ field: 'checkedInAt', label: 'چک‌این', filterable: false, sortable: false, type: 'date' }, { field: 'checkedInAt', label: 'چک‌این', filterable: false, sortable: false, type: 'date' },
{ field: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true, type: 'date' }, { field: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true, type: 'date' },
{ field: 'actions', label: 'عملیات' }, { field: 'actions', label: 'عملیات' },
@ -48,8 +50,10 @@ interface AdminEventBookingsTabProps {
const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEventBookingsTabProps) => { const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEventBookingsTabProps) => {
const bookingsListRef = useRef<PaginatedListHandle>(null) const bookingsListRef = useRef<PaginatedListHandle>(null)
const { pendingId, runAction } = useAdminAction() const { pendingId, runAction } = useAdminAction()
const { showAlert } = useAlertModal()
const handleCheckIn = (bookingId: string) => const handleCheckIn = (bookingId: string) => {
showAlert('حضور این مهمان ثبت شود؟', () =>
runAction( runAction(
bookingId, bookingId,
async () => { async () => {
@ -59,6 +63,8 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
}, },
'حضور مهمان ثبت شد' 'حضور مهمان ثبت شد'
) )
)
}
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -113,6 +119,14 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
/> />
) )
}, },
cancellationReason: (row, cellValue) => {
const reason = coerceToString(cellValue).trim()
const isCancelledLike = row.status === 'cancelled' || row.status === 'refunded'
if (!isCancelledLike || !reason) return '—'
return <span className="max-w-[220px] whitespace-pre-wrap break-words text-xs text-secondary-20">{reason}</span>
},
checkedInAt: (_row, cellValue) => formatPersianDate(cellValue), checkedInAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue), createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => { actions: (row) => {
@ -137,7 +151,9 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
isLoading={pendingId === bookingId} isLoading={pendingId === bookingId}
size="sm" size="sm"
variant="flat" variant="flat"
onClick={() => void handleCheckIn(bookingId)} onClick={() => {
handleCheckIn(bookingId)
}}
> >
<FileCheckIcon className="size-4" /> <FileCheckIcon className="size-4" />
</Button> </Button>

View File

@ -7,8 +7,12 @@ import AdminEventCommissionModal from '@/features/events/detail/admin-event-deta
const updateCommission = vi.fn() const updateCommission = vi.fn()
const addToast = vi.fn() const addToast = vi.fn()
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) })) vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
vi.mock('@/services/events', () => ({ vi.mock('@/services/events', () => ({
updateEventCommissionAsAdmin: (...args: unknown[]) => updateCommission(...args), updateEventCommissionAsAdmin: (...args: unknown[]) => updateCommission(...args),
})) }))
@ -73,6 +77,9 @@ describe('AdminEventCommissionModal', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
updateCommission.mockResolvedValue({ updateCommission.mockResolvedValue({
id: 'event-1', id: 'event-1',
commissionPercent: 12, commissionPercent: 12,

View File

@ -10,6 +10,7 @@ import Modal from '@/components/modals/Modal'
import { coerceToString } from '@/helpers' import { coerceToString } from '@/helpers'
import { updateEventCommissionAsAdmin } from '@/services/events' import { updateEventCommissionAsAdmin } from '@/services/events'
import useAdminAction from '@/hooks/useAdminAction' import useAdminAction from '@/hooks/useAdminAction'
import useAlertModal from '@/hooks/useAlertModal'
interface AdminEventCommissionModalProps { interface AdminEventCommissionModalProps {
eventId: string eventId: string
@ -23,6 +24,7 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
const [commissionInput, setCommissionInput] = useState('') const [commissionInput, setCommissionInput] = useState('')
const [commissionError, setCommissionError] = useState<string | null>(null) const [commissionError, setCommissionError] = useState<string | null>(null)
const { pendingId, runAction } = useAdminAction() const { pendingId, runAction } = useAdminAction()
const { showAlert } = useAlertModal()
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@ -40,7 +42,8 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
return return
} }
void runAction( showAlert(`کمیسیون این رویداد به ${parsed}٪ تغییر کند؟`, () =>
runAction(
'commission', 'commission',
async () => { async () => {
const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData
@ -50,9 +53,11 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
}, },
'کمیسیون رویداد به‌روزرسانی شد' 'کمیسیون رویداد به‌روزرسانی شد'
) )
)
} }
const handleResetCommission = () => const handleResetCommission = () => {
showAlert('کمیسیون این رویداد به پیش‌فرض پلتفرم بازگردد؟', () =>
runAction( runAction(
'commission', 'commission',
async () => { async () => {
@ -63,6 +68,8 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
}, },
'کمیسیون رویداد به پیش‌فرض پلتفرم بازگشت' 'کمیسیون رویداد به پیش‌فرض پلتفرم بازگشت'
) )
)
}
return ( return (
<Modal <Modal
@ -75,7 +82,9 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
isLoading={pendingId === 'commission'} isLoading={pendingId === 'commission'}
size="sm" size="sm"
variant="flat" variant="flat"
onClick={() => void handleResetCommission()} onClick={() => {
handleResetCommission()
}}
> >
بازگشت به پیشفرض پلتفرم بازگشت به پیشفرض پلتفرم
</Button> </Button>

View File

@ -4,6 +4,8 @@ import type { AdminEventDetailData } from './types'
import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { texts } from '@/texts'
import AdminEventLifecycleActions from './AdminEventLifecycleActions' import AdminEventLifecycleActions from './AdminEventLifecycleActions'
vi.mock('@/components/formElements/Button', () => ({ vi.mock('@/components/formElements/Button', () => ({
@ -28,10 +30,9 @@ const baseEvent = {
afterEach(cleanup) afterEach(cleanup)
describe('AdminEventLifecycleActions', () => { describe('AdminEventLifecycleActions', () => {
it('exposes approve/reject/force-publish for pending_review events', () => { it('exposes approve/reject for pending_review and hides force-publish', () => {
const onApprove = vi.fn() const onApprove = vi.fn()
const onOpenReject = vi.fn() const onOpenReject = vi.fn()
const onForcePublish = vi.fn()
render( render(
<AdminEventLifecycleActions <AdminEventLifecycleActions
@ -41,18 +42,63 @@ describe('AdminEventLifecycleActions', () => {
onCancel={vi.fn()} onCancel={vi.fn()}
onComplete={vi.fn()} onComplete={vi.fn()}
onDelete={vi.fn()} onDelete={vi.fn()}
onForcePublish={onForcePublish} onForcePublish={vi.fn()}
onOpenReject={onOpenReject} onOpenReject={onOpenReject}
/> />
) )
fireEvent.click(screen.getByRole('button', { name: 'تأیید و انتشار' })) fireEvent.click(screen.getByRole('button', { name: 'تأیید و انتشار' }))
fireEvent.click(screen.getByRole('button', { name: 'رد' })) fireEvent.click(screen.getByRole('button', { name: 'رد' }))
fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' }))
expect(onApprove).toHaveBeenCalledTimes(1) expect(onApprove).toHaveBeenCalledTimes(1)
expect(onOpenReject).toHaveBeenCalledTimes(1) expect(onOpenReject).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: 'انتشار فوری' })).toBeNull()
expect(screen.getByText(texts.events.adminLifecycleHintPendingReview)).toBeTruthy()
})
it('exposes approve and force-publish for unapproved draft events', () => {
const onApprove = vi.fn()
const onForcePublish = vi.fn()
render(
<AdminEventLifecycleActions
event={{ ...baseEvent, status: 'draft', adminApprovedAt: null }}
pendingId={null}
onApprove={onApprove}
onCancel={vi.fn()}
onComplete={vi.fn()}
onDelete={vi.fn()}
onForcePublish={onForcePublish}
onOpenReject={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'تأیید برای انتشار' }))
fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' }))
expect(onApprove).toHaveBeenCalledTimes(1)
expect(onForcePublish).toHaveBeenCalledTimes(1) expect(onForcePublish).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
expect(screen.getByText(texts.events.adminLifecycleHintDraftUnapproved)).toBeTruthy()
})
it('keeps force-publish for approved drafts waiting on the host', () => {
render(
<AdminEventLifecycleActions
event={{ ...baseEvent, status: 'draft', adminApprovedAt: '2026-01-01T00:00:00.000Z' }}
pendingId={null}
onApprove={vi.fn()}
onCancel={vi.fn()}
onComplete={vi.fn()}
onDelete={vi.fn()}
onForcePublish={vi.fn()}
onOpenReject={vi.fn()}
/>
)
expect(screen.getByText('تأییدشده؛ منتظر میزبان')).toBeTruthy()
expect(screen.getByRole('button', { name: 'انتشار فوری' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'تأیید برای انتشار' })).toBeNull()
}) })
it('exposes complete/cancel for published events and hides reject', () => { it('exposes complete/cancel for published events and hides reject', () => {
@ -73,5 +119,6 @@ describe('AdminEventLifecycleActions', () => {
expect(screen.getByRole('button', { name: 'لغو' })).toBeTruthy() expect(screen.getByRole('button', { name: 'لغو' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull() expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
expect(screen.queryByRole('button', { name: 'حذف' })).toBeNull() expect(screen.queryByRole('button', { name: 'حذف' })).toBeNull()
expect(screen.queryByRole('button', { name: 'انتشار فوری' })).toBeNull()
}) })
}) })

View File

@ -4,6 +4,9 @@ import type { AdminEventDetailData } from './types'
import Button from '@/components/formElements/Button' import Button from '@/components/formElements/Button'
import { APP_ROUTES } from '@/constants/routes' import { APP_ROUTES } from '@/constants/routes'
import { texts } from '@/texts'
import { resolveAdminEventLifecycleHint } from './adminEventLifecycleHints'
interface AdminEventLifecycleActionsProps { interface AdminEventLifecycleActionsProps {
event: AdminEventDetailData event: AdminEventDetailData
@ -25,7 +28,11 @@ const AdminEventLifecycleActions = ({
onComplete, onComplete,
onCancel, onCancel,
onDelete, onDelete,
}: AdminEventLifecycleActionsProps) => ( }: AdminEventLifecycleActionsProps) => {
const hint = resolveAdminEventLifecycleHint(event)
return (
<div className="flex max-w-full flex-col items-end gap-1">
<div className="flex max-w-full flex-nowrap items-center justify-end gap-2 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <div className="flex max-w-full flex-nowrap items-center justify-end gap-2 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{['draft', 'pending_review', 'published', 'full'].includes(event.status) ? ( {['draft', 'pending_review', 'published', 'full'].includes(event.status) ? (
<Button <Button
@ -71,9 +78,9 @@ const AdminEventLifecycleActions = ({
</Button> </Button>
</> </>
) : null} ) : null}
{event.status === 'draft' || event.status === 'pending_review' ? ( {event.status === 'draft' ? (
<Button <Button
aria-label="انتشار فوری بدون تایید میزبان" aria-label={texts.events.adminLifecycleTitleForcePublish}
isLoading={pendingId === 'force-publish'} isLoading={pendingId === 'force-publish'}
size="sm" size="sm"
variant="flat" variant="flat"
@ -117,6 +124,9 @@ const AdminEventLifecycleActions = ({
</Button> </Button>
) : null} ) : null}
</div> </div>
) {hint ? <p className="max-w-[min(72vw,28rem)] text-right text-xs leading-5 text-text-muted">{hint}</p> : null}
</div>
)
}
export default AdminEventLifecycleActions export default AdminEventLifecycleActions

View File

@ -48,10 +48,6 @@ const AdminEventOverviewTab = ({ insights }: AdminEventOverviewTabProps) => {
label="بازپرداخت‌شده" label="بازپرداخت‌شده"
value={number(insights.registrations.refundedCount)} value={number(insights.registrations.refundedCount)}
/> />
<InsightStat
label="غایب"
value={number(insights.registrations.noShowCount)}
/>
<InsightStat <InsightStat
label="ظرفیت باقی‌مانده" label="ظرفیت باقی‌مانده"
value={number(insights.registrations.remainingCapacity)} value={number(insights.registrations.remainingCapacity)}

View File

@ -0,0 +1,37 @@
import type { AdminEventDetailData } from './types'
import { describe, expect, it } from 'vitest'
import { texts } from '@/texts'
import { resolveAdminEventLifecycleHint } from './adminEventLifecycleHints'
const baseEvent = {
id: 'event-1',
adminApprovedAt: null,
bookedCount: 0,
} as AdminEventDetailData
describe('resolveAdminEventLifecycleHint', () => {
it('explains approve vs force-publish on unapproved drafts', () => {
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'draft', adminApprovedAt: null })).toBe(
texts.events.adminLifecycleHintDraftUnapproved
)
})
it('explains waiting on the host after admin approval', () => {
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'draft', adminApprovedAt: '2026-01-01T00:00:00.000Z' })).toBe(
texts.events.adminLifecycleHintDraftApproved
)
})
it('explains pending-review approval', () => {
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'pending_review' })).toBe(texts.events.adminLifecycleHintPendingReview)
})
it('explains complete and cancel on live events', () => {
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'published', bookedCount: 2 })).toBe(
texts.events.adminLifecycleHintPublished
)
})
})

View File

@ -0,0 +1,28 @@
import type { AdminEventDetailData } from './types'
import { texts } from '@/texts'
/** راهنمای کوتاه اکشن‌های lifecycle — بسته به وضعیت فعلی رویداد */
export const resolveAdminEventLifecycleHint = (event: AdminEventDetailData): string | null => {
if (event.status === 'draft' && !event.adminApprovedAt) {
return texts.events.adminLifecycleHintDraftUnapproved
}
if (event.status === 'draft' && event.adminApprovedAt) {
return texts.events.adminLifecycleHintDraftApproved
}
if (event.status === 'pending_review') {
return texts.events.adminLifecycleHintPendingReview
}
if (event.status === 'published' || event.status === 'full') {
return texts.events.adminLifecycleHintPublished
}
if (event.bookedCount === 0) {
return texts.events.adminLifecycleHintDeleteOnly
}
return null
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "ghabilee-admin", "name": "ghabilee-admin",
"version": "0.1.15", "version": "0.1.17",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build and publish the production admin image in GitHub Actions. # Build and publish the production admin image in Gitea Actions.
# Build-time public variables are read from the VPS .env file and never logged. # Build-time public variables are read from the VPS .env file and never logged.
set -euo pipefail set -euo pipefail

View File

@ -126,6 +126,12 @@ export const events = {
revisionReject: 'رد ویرایش', revisionReject: 'رد ویرایش',
revisionRejectReasonLabel: 'دلیل رد', revisionRejectReasonLabel: 'دلیل رد',
revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود', revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود',
adminLifecycleHintDraftUnapproved: 'تأیید برای انتشار: فقط اجازه می‌دهد میزبان خودش منتشر کند · انتشار فوری: همین الان عمومی می‌شود',
adminLifecycleHintDraftApproved: 'ادمین تأیید کرده؛ منتظر Publish میزبان بمانید یا با «انتشار فوری» همین الان منتشر کنید',
adminLifecycleHintPendingReview: 'میزبان درخواست انتشار داده؛ «تأیید و انتشار» رویداد را عمومی می‌کند',
adminLifecycleHintPublished: 'پایان: پس از برگزاری · لغو: همهٔ رزروها لغو می‌شوند',
adminLifecycleHintDeleteOnly: 'حذف فقط برای رویدادهای بدون رزرو فعال',
adminLifecycleTitleForcePublish: 'انتشار فوری بدون انتظار برای میزبان',
genderOpen: 'آزاد برای عموم', genderOpen: 'آزاد برای عموم',
genderFemaleOnly: 'خانم‌ها', genderFemaleOnly: 'خانم‌ها',
genderMaleOnly: 'آقایان', genderMaleOnly: 'آقایان',

View File

@ -12,6 +12,11 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
include: ['**/*.test.{ts,tsx}'], include: ['**/*.test.{ts,tsx}'],
setupFiles: ['./vitest.setup.ts'], setupFiles: ['./vitest.setup.ts'],
// Default 5s flakes under pre-push load (jsdom + HeroUI transform); aborted
// tests then leak async work into the next case in the same file.
testTimeout: 20_000,
hookTimeout: 20_000,
maxWorkers: '50%',
coverage: { coverage: {
provider: 'v8', provider: 'v8',
include: ['lib/authRouting.ts', 'helpers/listResponse.ts', 'validation/auth.ts'], include: ['lib/authRouting.ts', 'helpers/listResponse.ts', 'validation/auth.ts'],