Compare commits
No commits in common. "a307400f8342dcd1877d56ad1c4b5c9479ebcb41" and "94351739718b1cbb32be1d5b40f3ffe971f234c4" have entirely different histories.
a307400f83
...
9435173971
@ -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`
|
||||
38
.github/BRANCH_PROTECTION.md
vendored
38
.github/BRANCH_PROTECTION.md
vendored
@ -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).
|
||||
|
||||
@ -14,8 +14,7 @@ permissions:
|
||||
packages: write
|
||||
|
||||
# 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);
|
||||
# quality still runs on pull_request via frontend-quality.yml.
|
||||
# Direct pushes to main: re-run quality before deploy.
|
||||
jobs:
|
||||
gate:
|
||||
name: Detect direct push to main
|
||||
@ -49,7 +48,7 @@ jobs:
|
||||
quality:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.run_quality == 'true'
|
||||
uses: ./.gitea/workflows/frontend-quality.yml
|
||||
uses: ./.github/workflows/frontend-quality.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@ -74,9 +73,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
repo="$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')"
|
||||
# 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"
|
||||
echo "image=ghcr.io/${owner}/${repo}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fetch production build environment
|
||||
env:
|
||||
@ -98,10 +95,10 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea container registry
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.meta.outputs.registry }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@ -130,9 +127,8 @@ jobs:
|
||||
|
||||
- name: Copy Compose definition and deploy
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REGISTRY_USER: ${{ github.actor }}
|
||||
REGISTRY_HOST: git.ghabilee.ir
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
ADMIN_IMAGE: ${{ needs.build.outputs.image }}:${{ github.sha }}
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
@ -153,9 +149,9 @@ jobs:
|
||||
"${VPS_USER}@${VPS_HOST}:/opt/ghabilee-admin/docker-compose.yml"
|
||||
ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
|
||||
"${VPS_USER}@${VPS_HOST}" \
|
||||
"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
|
||||
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"
|
||||
cd /opt/ghabilee-admin
|
||||
ADMIN_IMAGE="$ADMIN_IMAGE" docker compose -f docker-compose.yml up -d --no-deps ghabilee-admin
|
||||
@ -56,13 +56,6 @@ const columns: PaginationListColumnType[] = [
|
||||
type: 'select',
|
||||
filterItems: BOOKING_STATUS_FILTER_ITEMS,
|
||||
},
|
||||
{
|
||||
field: 'cancellationReason',
|
||||
label: 'دلیل لغو',
|
||||
filterable: false,
|
||||
sortable: false,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
field: 'checkedInAt',
|
||||
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),
|
||||
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
|
||||
actions: (row) => {
|
||||
|
||||
@ -13,7 +13,6 @@ import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
|
||||
import StatusChip from '@/components/ui/StatusChip'
|
||||
import axiosInstance from '@/config/axios'
|
||||
import useAdminMutation from '@/hooks/useAdminMutation'
|
||||
import useAlertModal from '@/hooks/useAlertModal'
|
||||
import { coerceToString } from '@/helpers'
|
||||
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
|
||||
import { getBooleanStatus } from '@/constants/status'
|
||||
@ -80,16 +79,9 @@ const columns: PaginationListColumnType[] = [
|
||||
]
|
||||
|
||||
const ContactMessagesPage = () => {
|
||||
const { showAlert } = useAlertModal()
|
||||
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST })
|
||||
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 (
|
||||
<section className="h-full w-full text-right">
|
||||
<PageNavbar pageTitle="پیامهای تماس" />
|
||||
@ -158,9 +150,13 @@ const ContactMessagesPage = () => {
|
||||
isLoading={pendingId === message.id}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
handleMarkRead(message)
|
||||
}}
|
||||
onClick={() =>
|
||||
void runAction(
|
||||
message.id,
|
||||
() => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)),
|
||||
'پیام خوانده شد'
|
||||
)
|
||||
}
|
||||
>
|
||||
<FileCheckIcon className="size-5" />
|
||||
</Button>
|
||||
|
||||
@ -16,12 +16,7 @@ vi.mock('@/config/axios', () => ({
|
||||
patch: axiosMocks.patch,
|
||||
},
|
||||
}))
|
||||
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
|
||||
void onConfirm?.()
|
||||
})
|
||||
|
||||
vi.mock('@/lib/toast', () => ({ addToast: vi.fn() }))
|
||||
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
|
||||
vi.mock('@/components/formElements/Input', () => ({
|
||||
default: ({
|
||||
description,
|
||||
@ -114,9 +109,6 @@ describe('NotificationRulesPanel', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
|
||||
void onConfirm?.()
|
||||
})
|
||||
axiosMocks.get.mockReset()
|
||||
axiosMocks.patch.mockReset()
|
||||
axiosMocks.get.mockResolvedValue({
|
||||
|
||||
@ -10,7 +10,6 @@ import Button from '@/components/formElements/Button'
|
||||
import Input from '@/components/formElements/Input'
|
||||
import AdminState from '@/components/feedback/AdminState'
|
||||
import axiosInstance from '@/config/axios'
|
||||
import useAlertModal from '@/hooks/useAlertModal'
|
||||
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
|
||||
import { API_ROUTES } from '@/services/config'
|
||||
import { extractServerErrorDetail } from '@/services/errorHandler'
|
||||
@ -53,7 +52,6 @@ const isDirtyDraft = (current: RuleDraft, saved: RuleDraft | undefined) =>
|
||||
Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved)
|
||||
|
||||
const NotificationRulesPanel = () => {
|
||||
const { showAlert } = useAlertModal()
|
||||
const [rules, setRules] = useState<Rule[]>([])
|
||||
const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -113,12 +111,6 @@ const NotificationRulesPanel = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const confirmSave = (rule: Rule) => {
|
||||
showAlert(`تغییرات قاعدهٔ «${rule.displayName}» ذخیره شود؟`, () => {
|
||||
void save(rule)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm leading-7 text-blue-900">
|
||||
@ -150,9 +142,7 @@ const NotificationRulesPanel = () => {
|
||||
rule={rule}
|
||||
saving={saving === rule.eventKey}
|
||||
onChange={change}
|
||||
onSave={() => {
|
||||
confirmSave(rule)
|
||||
}}
|
||||
onSave={() => void save(rule)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -115,9 +115,7 @@ const ReviewsPage = () => {
|
||||
}
|
||||
|
||||
const handleRestore = (row: ReviewRow) => {
|
||||
showAlert('این نظر دوباره در نمایش عمومی قرار گیرد؟', () =>
|
||||
runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد')
|
||||
)
|
||||
void runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد')
|
||||
}
|
||||
|
||||
const handleDelete = (row: ReviewRow) => {
|
||||
|
||||
@ -58,18 +58,7 @@ const AdminSupportTicketDetail = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const confirmSend = () => {
|
||||
if (!reply.trim()) return
|
||||
|
||||
showAlert('این پاسخ ثبت و پیامک اطلاعرسانی برای کاربر ارسال شود؟', () => {
|
||||
void send()
|
||||
})
|
||||
}
|
||||
|
||||
const confirmStatusChange = (nextStatus: string) => {
|
||||
if (!ticket || nextStatus === ticket.status) return
|
||||
|
||||
if (nextStatus === 'closed') {
|
||||
const confirmClose = () => {
|
||||
showAlert(
|
||||
'این تیکت بسته شود؟ کاربر دیگر نمیتواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمیتواند دوباره باز کند.',
|
||||
() => {
|
||||
@ -78,19 +67,6 @@ const AdminSupportTicketDetail = () => {
|
||||
undefined,
|
||||
{ dangerAccept: true }
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const label = SUPPORT_ADMIN_STATUS_LABELS[nextStatus] ?? nextStatus
|
||||
|
||||
showAlert(`وضعیت تیکت به «${label}» تغییر کند؟`, () => {
|
||||
void changeStatus(nextStatus)
|
||||
})
|
||||
}
|
||||
|
||||
const confirmClose = () => {
|
||||
confirmStatusChange('closed')
|
||||
}
|
||||
|
||||
return (
|
||||
@ -128,9 +104,7 @@ const AdminSupportTicketDetail = () => {
|
||||
<select
|
||||
className="mt-1 block w-full rounded-lg border border-default-300 p-2"
|
||||
value={ticket.status}
|
||||
onChange={(event) => {
|
||||
confirmStatusChange(event.target.value)
|
||||
}}
|
||||
onChange={(event) => void changeStatus(event.target.value)}
|
||||
>
|
||||
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
|
||||
<option
|
||||
@ -192,7 +166,7 @@ const AdminSupportTicketDetail = () => {
|
||||
</p>
|
||||
<Button
|
||||
isLoading={pending}
|
||||
onClick={confirmSend}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
ثبت پاسخ و ارسال پیامک
|
||||
</Button>
|
||||
|
||||
@ -140,29 +140,16 @@ const UserEditModal = ({ isOpen, onOpenChange, user, currentAdminId, onSuccess }
|
||||
}
|
||||
|
||||
const handleSubmit = (values: AdminUserEditValues) => {
|
||||
// Suspending an account is destructive-ish for the user, so confirm first.
|
||||
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') {
|
||||
showAlert(
|
||||
'این کاربر معلق شود؟ کاربر تا فعالسازی مجدد امکان استفاده از حساب را نخواهد داشت.',
|
||||
() => submitUpdate(values),
|
||||
undefined,
|
||||
{
|
||||
dangerAccept: true,
|
||||
}
|
||||
)
|
||||
showAlert('این کاربر معلق شود؟ کاربر تا فعالسازی مجدد امکان استفاده از حساب را نخواهد داشت.', () => submitUpdate(values))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
showAlert('تغییرات این کاربر ذخیره شود؟', () => submitUpdate(values))
|
||||
void submitUpdate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import axiosInstance, { getOrRefreshAccessToken, resetAxiosAuthModuleState } from '@/config/axios'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
expireRefreshSession: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
@ -25,15 +23,18 @@ const writeStoredToken = (accessToken: string): void => {
|
||||
}
|
||||
|
||||
describe('admin access-token refresh races', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
window.localStorage.clear()
|
||||
const { resetAxiosAuthModuleState } = await import('@/config/axios')
|
||||
|
||||
resetAxiosAuthModuleState()
|
||||
})
|
||||
|
||||
it('replays a late 401 with the newer stored token without rotating refresh again', async () => {
|
||||
writeStoredToken('access-a')
|
||||
const { default: axiosInstance } = await import('@/config/axios')
|
||||
const refreshRequest = vi.spyOn(axios, 'post')
|
||||
const authorizationHeaders: string[] = []
|
||||
let requestCount = 0
|
||||
@ -84,6 +85,7 @@ describe('admin access-token refresh races', () => {
|
||||
})
|
||||
|
||||
try {
|
||||
const { getOrRefreshAccessToken } = await import('@/config/axios')
|
||||
const refreshRequest = vi.spyOn(axios, 'post')
|
||||
|
||||
await expect(getOrRefreshAccessToken()).resolves.toBe('access-b')
|
||||
|
||||
@ -1,38 +1,10 @@
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AlertModalProvider } from '@/context/AlertModalContext'
|
||||
import useAlertModal from '@/hooks/useAlertModal'
|
||||
|
||||
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()
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
function AlertHarness({ onConfirm }: { onConfirm: () => void | Promise<void> }) {
|
||||
const { showAlert } = useAlertModal()
|
||||
|
||||
@ -143,10 +143,7 @@ test('edits discoverability and publishes a draft event', async ({ page }) => {
|
||||
|
||||
const [updateRequest] = await Promise.all([
|
||||
page.waitForRequest((request) => request.method() === 'PATCH' && new URL(request.url()).pathname.endsWith('/admin/events/event-1')),
|
||||
(async () => {
|
||||
await page.getByRole('switch', { name: 'نمایش در جستجو' }).click({ force: true })
|
||||
await page.getByRole('button', { name: 'تأیید' }).click()
|
||||
})(),
|
||||
page.getByRole('switch', { name: 'نمایش در جستجو' }).click({ force: 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 publishRequest
|
||||
await expect(page.getByText('منتشرشده', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)
|
||||
|
||||
@ -127,7 +127,6 @@ test('admin approval publishes a pending-review event exactly once', async ({ pa
|
||||
await page.goto('/manage-events/event-1')
|
||||
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toBeEnabled()
|
||||
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(page.getByRole('button', { name: 'تأیید و انتشار' })).toHaveCount(0)
|
||||
|
||||
@ -197,10 +197,7 @@ const AdminEventDetail = () => {
|
||||
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 handleApprove = () => {
|
||||
const isPendingReview = event?.status === 'pending_review'
|
||||
|
||||
showAlert(isPendingReview ? 'این رویداد تأیید و منتشر شود؟' : 'این رویداد برای انتشار تأیید شود؟', () =>
|
||||
const handleApprove = () =>
|
||||
runAction(
|
||||
'approve',
|
||||
async () => {
|
||||
@ -208,16 +205,13 @@ const AdminEventDetail = () => {
|
||||
|
||||
mergeEvent(updated)
|
||||
},
|
||||
isPendingReview ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
|
||||
event?.status === 'pending_review' ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Bypasses the whole request/approve flow — publishes immediately
|
||||
// regardless of whether the host has requested publication or an admin
|
||||
// has approved yet. Kept as a separate action from "approve" on purpose.
|
||||
const handleForcePublish = () => {
|
||||
showAlert('این رویداد فوراً و بدون انتظار برای میزبان منتشر شود؟', () =>
|
||||
const handleForcePublish = () =>
|
||||
runAction(
|
||||
'force-publish',
|
||||
async () => {
|
||||
@ -227,8 +221,6 @@ const AdminEventDetail = () => {
|
||||
},
|
||||
'رویداد فورا منتشر شد'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
showAlert('آیا این رویداد به پایان رسیده است؟', () =>
|
||||
@ -284,8 +276,7 @@ const AdminEventDetail = () => {
|
||||
const handleApproveRevision = () => {
|
||||
if (!pendingRevision) return
|
||||
|
||||
showAlert('این ویرایش تأیید و روی رویداد اعمال شود؟', () =>
|
||||
runAction(
|
||||
void runAction(
|
||||
'approve-revision',
|
||||
async () => {
|
||||
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
|
||||
@ -296,7 +287,6 @@ const AdminEventDetail = () => {
|
||||
},
|
||||
texts.events.revisionApproveSuccess
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleRejectRevision = (rejectionReason: string) => {
|
||||
@ -313,13 +303,12 @@ const AdminEventDetail = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const handleToggleDiscoverable = (nextValue: boolean) => {
|
||||
const handleToggleDiscoverable = async (nextValue: boolean) => {
|
||||
if (!event || isTogglingDiscoverable) return
|
||||
|
||||
const previous = event.settings.isDiscoverable
|
||||
|
||||
showAlert(nextValue ? 'نمایش این رویداد در جستجوی عمومی فعال شود؟' : 'نمایش این رویداد در جستجوی عمومی غیرفعال شود؟', async () => {
|
||||
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: nextValue } } : prev))
|
||||
setEvent({ ...event, settings: { ...event.settings, isDiscoverable: nextValue } })
|
||||
setIsTogglingDiscoverable(true)
|
||||
|
||||
try {
|
||||
@ -337,7 +326,6 @@ const AdminEventDetail = () => {
|
||||
} finally {
|
||||
setIsTogglingDiscoverable(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@ -348,11 +336,11 @@ const AdminEventDetail = () => {
|
||||
<AdminEventLifecycleActions
|
||||
event={event}
|
||||
pendingId={pendingId}
|
||||
onApprove={handleApprove}
|
||||
onApprove={() => void handleApprove()}
|
||||
onCancel={handleCancel}
|
||||
onComplete={handleComplete}
|
||||
onDelete={handleDelete}
|
||||
onForcePublish={handleForcePublish}
|
||||
onForcePublish={() => void handleForcePublish()}
|
||||
onOpenReject={() => {
|
||||
setHasOpenedRejectModal(true)
|
||||
setIsRejectModalOpen(true)
|
||||
|
||||
@ -11,12 +11,8 @@ const bulkCreate = vi.fn()
|
||||
const setActive = vi.fn()
|
||||
const removeCode = 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('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
|
||||
vi.mock('@/services/discountCodes', () => ({
|
||||
LIST_DISCOUNT_CODES: (...args: unknown[]) => listCodes(...args),
|
||||
LIST_DISCOUNT_REDEMPTIONS: (...args: unknown[]) => listRedemptions(...args),
|
||||
@ -71,16 +67,10 @@ const freshCode = {
|
||||
}
|
||||
|
||||
describe('EventDiscountsPanel', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
|
||||
void onConfirm?.()
|
||||
})
|
||||
listCodes.mockResolvedValue({ ok: true, data: { items: [usedCode, freshCode], totalItemsCount: 2, totalPages: 1 } })
|
||||
listRedemptions.mockResolvedValue({ ok: true, data: { items: [], totalItemsCount: 0, totalPages: 0 } })
|
||||
getReport.mockResolvedValue({
|
||||
@ -146,7 +136,6 @@ describe('EventDiscountsPanel', () => {
|
||||
)
|
||||
|
||||
await screen.findByText('WELCOME20')
|
||||
bulkCreate.mockClear()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '25' } })
|
||||
fireEvent.change(screen.getByLabelText('تعداد کد یکتا'), { target: { value: '2' } })
|
||||
@ -168,8 +157,6 @@ describe('EventDiscountsPanel', () => {
|
||||
)
|
||||
|
||||
await screen.findByText('WELCOME20')
|
||||
bulkCreate.mockClear()
|
||||
addToast.mockClear()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '150' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ساخت کد تخفیف' }))
|
||||
|
||||
@ -18,7 +18,6 @@ import {
|
||||
GET_DISCOUNT_MANAGEMENT_BOOTSTRAP,
|
||||
SET_DISCOUNT_CODE_ACTIVE,
|
||||
} from '@/services/discountCodes'
|
||||
import useAlertModal from '@/hooks/useAlertModal'
|
||||
|
||||
interface EventDiscountsPanelProps {
|
||||
eventId: string
|
||||
@ -52,7 +51,6 @@ const DISCOUNT_BEARER_OPTIONS = [
|
||||
] as const
|
||||
|
||||
const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false }: EventDiscountsPanelProps) => {
|
||||
const { showAlert } = useAlertModal()
|
||||
const [codes, setCodes] = useState<DiscountCode[]>([])
|
||||
const [redemptions, setRedemptions] = useState<DiscountRedemption[]>([])
|
||||
const [report, setReport] = useState<DiscountReport | null>(null)
|
||||
@ -98,7 +96,26 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
||||
const canCreate = !isFree && !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)
|
||||
const result = await BULK_CREATE_DISCOUNT_CODES(eventId, {
|
||||
type,
|
||||
@ -126,39 +143,13 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
||||
await load()
|
||||
}
|
||||
|
||||
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) => {
|
||||
const handleToggleActive = async (code: DiscountCode, nextActive: boolean) => {
|
||||
if (!canMutateCodes) {
|
||||
addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
showAlert(nextActive ? `کد «${code.code}» فعال شود؟` : `کد «${code.code}» غیرفعال شود؟`, async () => {
|
||||
setPendingCodeId(code.id)
|
||||
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)))
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = (code: DiscountCode) => {
|
||||
const handleDelete = async (code: DiscountCode) => {
|
||||
if (!canMutateCodes) {
|
||||
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
|
||||
|
||||
@ -187,9 +177,6 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
||||
return
|
||||
}
|
||||
|
||||
showAlert(
|
||||
`کد تخفیف «${code.code}» حذف شود؟`,
|
||||
async () => {
|
||||
setPendingCodeId(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' })
|
||||
setCodes((current) => current.filter((item) => item.id !== code.id))
|
||||
},
|
||||
undefined,
|
||||
{ dangerAccept: true }
|
||||
)
|
||||
}
|
||||
|
||||
const copyCodes = async (list: string[]) => {
|
||||
@ -475,9 +458,7 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
||||
label=""
|
||||
name={`code-active-${code.id}`}
|
||||
value={code.isActive}
|
||||
onValueChange={(next) => {
|
||||
handleToggleActive(code, Boolean(next))
|
||||
}}
|
||||
onValueChange={(next) => void handleToggleActive(code, Boolean(next))}
|
||||
/>
|
||||
<span className="text-xs text-secondary-30">
|
||||
{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}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => {
|
||||
handleDelete(code)
|
||||
}}
|
||||
onClick={() => void handleDelete(code)}
|
||||
>
|
||||
{texts.common.delete}
|
||||
</Button>
|
||||
|
||||
@ -19,7 +19,6 @@ import { checkInBookingAsAdmin } from '@/services/eventManagement'
|
||||
import { API_ROUTES } from '@/services/config'
|
||||
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
||||
import useAdminAction from '@/hooks/useAdminAction'
|
||||
import useAlertModal from '@/hooks/useAlertModal'
|
||||
|
||||
// Bookings tab columns — copied from app/(dashboard)/bookings/page.tsx,
|
||||
// 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: 'userId', label: 'مهمان', filterable: false, sortable: false, type: 'text' },
|
||||
{ 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: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true, type: 'date' },
|
||||
{ field: 'actions', label: 'عملیات' },
|
||||
@ -50,10 +48,8 @@ interface AdminEventBookingsTabProps {
|
||||
const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEventBookingsTabProps) => {
|
||||
const bookingsListRef = useRef<PaginatedListHandle>(null)
|
||||
const { pendingId, runAction } = useAdminAction()
|
||||
const { showAlert } = useAlertModal()
|
||||
|
||||
const handleCheckIn = (bookingId: string) => {
|
||||
showAlert('حضور این مهمان ثبت شود؟', () =>
|
||||
const handleCheckIn = (bookingId: string) =>
|
||||
runAction(
|
||||
bookingId,
|
||||
async () => {
|
||||
@ -63,8 +59,6 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
|
||||
},
|
||||
'حضور مهمان ثبت شد'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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),
|
||||
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
|
||||
actions: (row) => {
|
||||
@ -151,9 +137,7 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
|
||||
isLoading={pendingId === bookingId}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => {
|
||||
handleCheckIn(bookingId)
|
||||
}}
|
||||
onClick={() => void handleCheckIn(bookingId)}
|
||||
>
|
||||
<FileCheckIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@ -7,12 +7,8 @@ import AdminEventCommissionModal from '@/features/events/detail/admin-event-deta
|
||||
|
||||
const updateCommission = 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('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
|
||||
vi.mock('@/services/events', () => ({
|
||||
updateEventCommissionAsAdmin: (...args: unknown[]) => updateCommission(...args),
|
||||
}))
|
||||
@ -77,9 +73,6 @@ describe('AdminEventCommissionModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
|
||||
void onConfirm?.()
|
||||
})
|
||||
updateCommission.mockResolvedValue({
|
||||
id: 'event-1',
|
||||
commissionPercent: 12,
|
||||
|
||||
@ -10,7 +10,6 @@ import Modal from '@/components/modals/Modal'
|
||||
import { coerceToString } from '@/helpers'
|
||||
import { updateEventCommissionAsAdmin } from '@/services/events'
|
||||
import useAdminAction from '@/hooks/useAdminAction'
|
||||
import useAlertModal from '@/hooks/useAlertModal'
|
||||
|
||||
interface AdminEventCommissionModalProps {
|
||||
eventId: string
|
||||
@ -24,7 +23,6 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
||||
const [commissionInput, setCommissionInput] = useState('')
|
||||
const [commissionError, setCommissionError] = useState<string | null>(null)
|
||||
const { pendingId, runAction } = useAdminAction()
|
||||
const { showAlert } = useAlertModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@ -42,8 +40,7 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
||||
return
|
||||
}
|
||||
|
||||
showAlert(`کمیسیون این رویداد به ${parsed}٪ تغییر کند؟`, () =>
|
||||
runAction(
|
||||
void runAction(
|
||||
'commission',
|
||||
async () => {
|
||||
const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData
|
||||
@ -53,11 +50,9 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
||||
},
|
||||
'کمیسیون رویداد بهروزرسانی شد'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleResetCommission = () => {
|
||||
showAlert('کمیسیون این رویداد به پیشفرض پلتفرم بازگردد؟', () =>
|
||||
const handleResetCommission = () =>
|
||||
runAction(
|
||||
'commission',
|
||||
async () => {
|
||||
@ -68,8 +63,6 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
||||
},
|
||||
'کمیسیون رویداد به پیشفرض پلتفرم بازگشت'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -82,9 +75,7 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
||||
isLoading={pendingId === 'commission'}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => {
|
||||
handleResetCommission()
|
||||
}}
|
||||
onClick={() => void handleResetCommission()}
|
||||
>
|
||||
بازگشت به پیشفرض پلتفرم
|
||||
</Button>
|
||||
|
||||
@ -4,8 +4,6 @@ import type { AdminEventDetailData } from './types'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { texts } from '@/texts'
|
||||
|
||||
import AdminEventLifecycleActions from './AdminEventLifecycleActions'
|
||||
|
||||
vi.mock('@/components/formElements/Button', () => ({
|
||||
@ -30,9 +28,10 @@ const baseEvent = {
|
||||
afterEach(cleanup)
|
||||
|
||||
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 onOpenReject = vi.fn()
|
||||
const onForcePublish = vi.fn()
|
||||
|
||||
render(
|
||||
<AdminEventLifecycleActions
|
||||
@ -42,63 +41,18 @@ describe('AdminEventLifecycleActions', () => {
|
||||
onCancel={vi.fn()}
|
||||
onComplete={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onForcePublish={vi.fn()}
|
||||
onForcePublish={onForcePublish}
|
||||
onOpenReject={onOpenReject}
|
||||
/>
|
||||
)
|
||||
|
||||
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: 'انتشار فوری' }))
|
||||
|
||||
expect(onApprove).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenReject).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', () => {
|
||||
@ -119,6 +73,5 @@ describe('AdminEventLifecycleActions', () => {
|
||||
expect(screen.getByRole('button', { name: 'لغو' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'حذف' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'انتشار فوری' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,9 +4,6 @@ import type { AdminEventDetailData } from './types'
|
||||
|
||||
import Button from '@/components/formElements/Button'
|
||||
import { APP_ROUTES } from '@/constants/routes'
|
||||
import { texts } from '@/texts'
|
||||
|
||||
import { resolveAdminEventLifecycleHint } from './adminEventLifecycleHints'
|
||||
|
||||
interface AdminEventLifecycleActionsProps {
|
||||
event: AdminEventDetailData
|
||||
@ -28,11 +25,7 @@ const AdminEventLifecycleActions = ({
|
||||
onComplete,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: AdminEventLifecycleActionsProps) => {
|
||||
const hint = resolveAdminEventLifecycleHint(event)
|
||||
|
||||
return (
|
||||
<div className="flex max-w-full flex-col items-end gap-1">
|
||||
}: AdminEventLifecycleActionsProps) => (
|
||||
<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) ? (
|
||||
<Button
|
||||
@ -78,9 +71,9 @@ const AdminEventLifecycleActions = ({
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{event.status === 'draft' ? (
|
||||
{event.status === 'draft' || event.status === 'pending_review' ? (
|
||||
<Button
|
||||
aria-label={texts.events.adminLifecycleTitleForcePublish}
|
||||
aria-label="انتشار فوری بدون تایید میزبان"
|
||||
isLoading={pendingId === 'force-publish'}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@ -124,9 +117,6 @@ const AdminEventLifecycleActions = ({
|
||||
</Button>
|
||||
) : null}
|
||||
</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
|
||||
|
||||
@ -48,6 +48,10 @@ const AdminEventOverviewTab = ({ insights }: AdminEventOverviewTabProps) => {
|
||||
label="بازپرداختشده"
|
||||
value={number(insights.registrations.refundedCount)}
|
||||
/>
|
||||
<InsightStat
|
||||
label="غایب"
|
||||
value={number(insights.registrations.noShowCount)}
|
||||
/>
|
||||
<InsightStat
|
||||
label="ظرفیت باقیمانده"
|
||||
value={number(insights.registrations.remainingCapacity)}
|
||||
|
||||
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
8322
openapi.json
8322
openapi.json
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghabilee-admin",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.15",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/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.
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
@ -126,12 +126,6 @@ export const events = {
|
||||
revisionReject: 'رد ویرایش',
|
||||
revisionRejectReasonLabel: 'دلیل رد',
|
||||
revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود',
|
||||
adminLifecycleHintDraftUnapproved: 'تأیید برای انتشار: فقط اجازه میدهد میزبان خودش منتشر کند · انتشار فوری: همین الان عمومی میشود',
|
||||
adminLifecycleHintDraftApproved: 'ادمین تأیید کرده؛ منتظر Publish میزبان بمانید یا با «انتشار فوری» همین الان منتشر کنید',
|
||||
adminLifecycleHintPendingReview: 'میزبان درخواست انتشار داده؛ «تأیید و انتشار» رویداد را عمومی میکند',
|
||||
adminLifecycleHintPublished: 'پایان: پس از برگزاری · لغو: همهٔ رزروها لغو میشوند',
|
||||
adminLifecycleHintDeleteOnly: 'حذف فقط برای رویدادهای بدون رزرو فعال',
|
||||
adminLifecycleTitleForcePublish: 'انتشار فوری بدون انتظار برای میزبان',
|
||||
genderOpen: 'آزاد برای عموم',
|
||||
genderFemaleOnly: 'خانمها',
|
||||
genderMaleOnly: 'آقایان',
|
||||
|
||||
@ -12,11 +12,6 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
include: ['**/*.test.{ts,tsx}'],
|
||||
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: {
|
||||
provider: 'v8',
|
||||
include: ['lib/authRouting.ts', 'helpers/listResponse.ts', 'validation/auth.ts'],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user