Compare commits

..

No commits in common. "a307400f8342dcd1877d56ad1c4b5c9479ebcb41" and "94351739718b1cbb32be1d5b40f3ffe971f234c4" have entirely different histories.

31 changed files with 3342 additions and 5897 deletions

View File

@ -1,49 +0,0 @@
# 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

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

View File

@ -14,8 +14,7 @@ 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: quality gate is skipped here (same as prior GitHub boot policy); # Direct pushes to main: re-run quality before deploy.
# 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
@ -49,7 +48,7 @@ jobs:
quality: quality:
needs: gate needs: gate
if: needs.gate.outputs.run_quality == 'true' if: needs.gate.outputs.run_quality == 'true'
uses: ./.gitea/workflows/frontend-quality.yml uses: ./.github/workflows/frontend-quality.yml
permissions: permissions:
contents: read contents: read
pull-requests: read pull-requests: read
@ -74,9 +73,7 @@ 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:]')"
# Gitea container registry (same host as git.ghabilee.ir) echo "image=ghcr.io/${owner}/${repo}" >> "$GITHUB_OUTPUT"
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:
@ -98,10 +95,10 @@ jobs:
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- name: Log in to Gitea container registry - name: Log in to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ steps.meta.outputs.registry }} registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
@ -130,9 +127,8 @@ jobs:
- name: Copy Compose definition and deploy - name: Copy Compose definition and deploy
env: env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ github.actor }} GHCR_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 }}
@ -153,9 +149,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}" \
"REGISTRY_TOKEN='${REGISTRY_TOKEN}' REGISTRY_USER='${REGISTRY_USER}' REGISTRY_HOST='${REGISTRY_HOST}' ADMIN_IMAGE='${ADMIN_IMAGE}' sh -s" <<'REMOTE' "GHCR_TOKEN='${GHCR_TOKEN}' GHCR_USER='${GHCR_USER}' ADMIN_IMAGE='${ADMIN_IMAGE}' sh -s" <<'REMOTE'
set -eu set -eu
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_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

@ -56,13 +56,6 @@ 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: 'چک‌این',
@ -130,14 +123,6 @@ 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,7 +13,6 @@ 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'
@ -80,16 +79,9 @@ 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="پیام‌های تماس" />
@ -158,9 +150,13 @@ const ContactMessagesPage = () => {
isLoading={pendingId === message.id} isLoading={pendingId === message.id}
size="sm" size="sm"
variant="light" variant="light"
onClick={() => { onClick={() =>
handleMarkRead(message) void runAction(
}} 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,12 +16,7 @@ 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,
@ -114,9 +109,6 @@ 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,7 +10,6 @@ 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'
@ -53,7 +52,6 @@ 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)
@ -113,12 +111,6 @@ 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">
@ -150,9 +142,7 @@ const NotificationRulesPanel = () => {
rule={rule} rule={rule}
saving={saving === rule.eventKey} saving={saving === rule.eventKey}
onChange={change} onChange={change}
onSave={() => { onSave={() => void save(rule)}
confirmSave(rule)
}}
/> />
))} ))}
</div> </div>

View File

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

View File

@ -58,18 +58,7 @@ const AdminSupportTicketDetail = () => {
} }
} }
const confirmSend = () => { const confirmClose = () => {
if (!reply.trim()) return
showAlert('این پاسخ ثبت و پیامک اطلاع‌رسانی برای کاربر ارسال شود؟', () => {
void send()
})
}
const confirmStatusChange = (nextStatus: string) => {
if (!ticket || nextStatus === ticket.status) return
if (nextStatus === 'closed') {
showAlert( showAlert(
'این تیکت بسته شود؟ کاربر دیگر نمی‌تواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمی‌تواند دوباره باز کند.', 'این تیکت بسته شود؟ کاربر دیگر نمی‌تواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمی‌تواند دوباره باز کند.',
() => { () => {
@ -78,19 +67,6 @@ 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 (
@ -128,9 +104,7 @@ 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) => { onChange={(event) => void changeStatus(event.target.value)}
confirmStatusChange(event.target.value)
}}
> >
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => ( {Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
<option <option
@ -192,7 +166,7 @@ const AdminSupportTicketDetail = () => {
</p> </p>
<Button <Button
isLoading={pending} isLoading={pending}
onClick={confirmSend} onClick={() => void send()}
> >
ثبت پاسخ و ارسال پیامک ثبت پاسخ و ارسال پیامک
</Button> </Button>

View File

@ -140,29 +140,16 @@ 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 (Object.keys(payload).length === 0) {
onOpenChange(false)
return
}
if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') { if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') {
showAlert( showAlert('این کاربر معلق شود؟ کاربر تا فعال‌سازی مجدد امکان استفاده از حساب را نخواهد داشت.', () => submitUpdate(values))
'این کاربر معلق شود؟ کاربر تا فعال‌سازی مجدد امکان استفاده از حساب را نخواهد داشت.',
() => submitUpdate(values),
undefined,
{
dangerAccept: true,
}
)
return return
} }
showAlert('تغییرات این کاربر ذخیره شود؟', () => submitUpdate(values)) void submitUpdate(values)
} }
return ( return (

View File

@ -1,8 +1,6 @@
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),
})) }))
@ -25,15 +23,18 @@ const writeStoredToken = (accessToken: string): void => {
} }
describe('admin access-token refresh races', () => { describe('admin access-token refresh races', () => {
beforeEach(() => { beforeEach(async () => {
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
@ -84,6 +85,7 @@ 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,38 +1,10 @@
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'
vi.mock('@/components/formElements/Button', () => ({ afterEach(cleanup)
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,10 +143,7 @@ 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')),
(async () => { page.getByRole('switch', { name: 'نمایش در جستجو' }).click({ force: true }),
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 } })
@ -156,7 +153,6 @@ 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,7 +127,6 @@ 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,10 +197,7 @@ 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 () => {
@ -208,16 +205,13 @@ const AdminEventDetail = () => {
mergeEvent(updated) mergeEvent(updated)
}, },
isPendingReview ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد' event?.status === 'pending_review' ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
) )
)
}
// 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 () => {
@ -227,8 +221,6 @@ const AdminEventDetail = () => {
}, },
'رویداد فورا منتشر شد' 'رویداد فورا منتشر شد'
) )
)
}
const handleComplete = () => { const handleComplete = () => {
showAlert('آیا این رویداد به پایان رسیده است؟', () => showAlert('آیا این رویداد به پایان رسیده است؟', () =>
@ -284,8 +276,7 @@ const AdminEventDetail = () => {
const handleApproveRevision = () => { const handleApproveRevision = () => {
if (!pendingRevision) return if (!pendingRevision) return
showAlert('این ویرایش تأیید و روی رویداد اعمال شود؟', () => void runAction(
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
@ -296,7 +287,6 @@ const AdminEventDetail = () => {
}, },
texts.events.revisionApproveSuccess texts.events.revisionApproveSuccess
) )
)
} }
const handleRejectRevision = (rejectionReason: string) => { const handleRejectRevision = (rejectionReason: string) => {
@ -313,13 +303,12 @@ const AdminEventDetail = () => {
) )
} }
const handleToggleDiscoverable = (nextValue: boolean) => { const handleToggleDiscoverable = async (nextValue: boolean) => {
if (!event || isTogglingDiscoverable) return if (!event || isTogglingDiscoverable) return
const previous = event.settings.isDiscoverable const previous = event.settings.isDiscoverable
showAlert(nextValue ? 'نمایش این رویداد در جستجوی عمومی فعال شود؟' : 'نمایش این رویداد در جستجوی عمومی غیرفعال شود؟', async () => { setEvent({ ...event, settings: { ...event.settings, isDiscoverable: nextValue } })
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: nextValue } } : prev))
setIsTogglingDiscoverable(true) setIsTogglingDiscoverable(true)
try { try {
@ -337,7 +326,6 @@ const AdminEventDetail = () => {
} finally { } finally {
setIsTogglingDiscoverable(false) setIsTogglingDiscoverable(false)
} }
})
} }
return ( return (
@ -348,11 +336,11 @@ const AdminEventDetail = () => {
<AdminEventLifecycleActions <AdminEventLifecycleActions
event={event} event={event}
pendingId={pendingId} pendingId={pendingId}
onApprove={handleApprove} onApprove={() => void handleApprove()}
onCancel={handleCancel} onCancel={handleCancel}
onComplete={handleComplete} onComplete={handleComplete}
onDelete={handleDelete} onDelete={handleDelete}
onForcePublish={handleForcePublish} onForcePublish={() => void handleForcePublish()}
onOpenReject={() => { onOpenReject={() => {
setHasOpenedRejectModal(true) setHasOpenedRejectModal(true)
setIsRejectModalOpen(true) setIsRejectModalOpen(true)

View File

@ -11,12 +11,8 @@ 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),
@ -71,16 +67,10 @@ const freshCode = {
} }
describe('EventDiscountsPanel', () => { describe('EventDiscountsPanel', () => {
afterEach(() => { afterEach(cleanup)
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({
@ -146,7 +136,6 @@ 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' } })
@ -168,8 +157,6 @@ 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,7 +18,6 @@ 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
@ -52,7 +51,6 @@ 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)
@ -98,7 +96,26 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
const canCreate = !isFree && !isEnded const canCreate = !isFree && !isEnded
const canMutateCodes = !isEnded const canMutateCodes = !isEnded
const executeCreate = async (numericValue: number, numericQuantity: number) => { const handleCreate = async () => {
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,
@ -126,39 +143,13 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
await load() await load()
} }
const handleCreate = () => { const handleToggleActive = async (code: DiscountCode, nextActive: boolean) => {
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)
@ -171,10 +162,9 @@ 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 = (code: DiscountCode) => { const handleDelete = async (code: DiscountCode) => {
if (!canMutateCodes) { if (!canMutateCodes) {
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' }) addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
@ -187,9 +177,6 @@ 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)
@ -203,10 +190,6 @@ 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[]) => {
@ -475,9 +458,7 @@ 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) => { onValueChange={(next) => void handleToggleActive(code, Boolean(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}
@ -488,9 +469,7 @@ 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={() => { onClick={() => void handleDelete(code)}
handleDelete(code)
}}
> >
{texts.common.delete} {texts.common.delete}
</Button> </Button>

View File

@ -19,7 +19,6 @@ 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
@ -35,7 +34,6 @@ 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: 'عملیات' },
@ -50,10 +48,8 @@ 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 () => {
@ -63,8 +59,6 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
}, },
'حضور مهمان ثبت شد' 'حضور مهمان ثبت شد'
) )
)
}
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -119,14 +113,6 @@ 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) => {
@ -151,9 +137,7 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
isLoading={pendingId === bookingId} isLoading={pendingId === bookingId}
size="sm" size="sm"
variant="flat" variant="flat"
onClick={() => { onClick={() => void handleCheckIn(bookingId)}
handleCheckIn(bookingId)
}}
> >
<FileCheckIcon className="size-4" /> <FileCheckIcon className="size-4" />
</Button> </Button>

View File

@ -7,12 +7,8 @@ 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),
})) }))
@ -77,9 +73,6 @@ 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,7 +10,6 @@ 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
@ -24,7 +23,6 @@ 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
@ -42,8 +40,7 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
return return
} }
showAlert(`کمیسیون این رویداد به ${parsed}٪ تغییر کند؟`, () => void runAction(
runAction(
'commission', 'commission',
async () => { async () => {
const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData
@ -53,11 +50,9 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
}, },
'کمیسیون رویداد به‌روزرسانی شد' 'کمیسیون رویداد به‌روزرسانی شد'
) )
)
} }
const handleResetCommission = () => { const handleResetCommission = () =>
showAlert('کمیسیون این رویداد به پیش‌فرض پلتفرم بازگردد؟', () =>
runAction( runAction(
'commission', 'commission',
async () => { async () => {
@ -68,8 +63,6 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
}, },
'کمیسیون رویداد به پیش‌فرض پلتفرم بازگشت' 'کمیسیون رویداد به پیش‌فرض پلتفرم بازگشت'
) )
)
}
return ( return (
<Modal <Modal
@ -82,9 +75,7 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
isLoading={pendingId === 'commission'} isLoading={pendingId === 'commission'}
size="sm" size="sm"
variant="flat" variant="flat"
onClick={() => { onClick={() => void handleResetCommission()}
handleResetCommission()
}}
> >
بازگشت به پیشفرض پلتفرم بازگشت به پیشفرض پلتفرم
</Button> </Button>

View File

@ -4,8 +4,6 @@ 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', () => ({
@ -30,9 +28,10 @@ const baseEvent = {
afterEach(cleanup) afterEach(cleanup)
describe('AdminEventLifecycleActions', () => { describe('AdminEventLifecycleActions', () => {
it('exposes approve/reject for pending_review and hides force-publish', () => { it('exposes approve/reject/force-publish for pending_review events', () => {
const onApprove = vi.fn() const onApprove = vi.fn()
const onOpenReject = vi.fn() const onOpenReject = vi.fn()
const onForcePublish = vi.fn()
render( render(
<AdminEventLifecycleActions <AdminEventLifecycleActions
@ -42,63 +41,18 @@ describe('AdminEventLifecycleActions', () => {
onCancel={vi.fn()} onCancel={vi.fn()}
onComplete={vi.fn()} onComplete={vi.fn()}
onDelete={vi.fn()} onDelete={vi.fn()}
onForcePublish={vi.fn()} onForcePublish={onForcePublish}
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: 'رد' }))
expect(onApprove).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: 'انتشار فوری' })) fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' }))
expect(onApprove).toHaveBeenCalledTimes(1) expect(onApprove).toHaveBeenCalledTimes(1)
expect(onOpenReject).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', () => {
@ -119,6 +73,5 @@ 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,9 +4,6 @@ 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
@ -28,11 +25,7 @@ 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
@ -78,9 +71,9 @@ const AdminEventLifecycleActions = ({
</Button> </Button>
</> </>
) : null} ) : null}
{event.status === 'draft' ? ( {event.status === 'draft' || event.status === 'pending_review' ? (
<Button <Button
aria-label={texts.events.adminLifecycleTitleForcePublish} aria-label="انتشار فوری بدون تایید میزبان"
isLoading={pendingId === 'force-publish'} isLoading={pendingId === 'force-publish'}
size="sm" size="sm"
variant="flat" variant="flat"
@ -124,9 +117,6 @@ 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,6 +48,10 @@ 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

@ -1,37 +0,0 @@
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

@ -1,28 +0,0 @@
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.17", "version": "0.1.15",
"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 Gitea Actions. # Build and publish the production admin image in GitHub 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,12 +126,6 @@ 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,11 +12,6 @@ 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'],