feat(alert-modal): integrate alert modal for confirmation actions
Enhanced various components to utilize the alert modal for user confirmations before executing critical actions. This includes marking messages as read, saving notification rules, restoring reviews, sending replies, changing ticket statuses, and managing event commissions. The integration improves user experience by ensuring actions are intentional and provides clear feedback on the outcomes.
This commit is contained in:
parent
0a68104e85
commit
96603d31f7
@ -13,6 +13,7 @@ import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
|
|||||||
import StatusChip from '@/components/ui/StatusChip'
|
import StatusChip from '@/components/ui/StatusChip'
|
||||||
import axiosInstance from '@/config/axios'
|
import axiosInstance from '@/config/axios'
|
||||||
import useAdminMutation from '@/hooks/useAdminMutation'
|
import useAdminMutation from '@/hooks/useAdminMutation'
|
||||||
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
import { coerceToString } from '@/helpers'
|
import { coerceToString } from '@/helpers'
|
||||||
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
|
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
|
||||||
import { getBooleanStatus } from '@/constants/status'
|
import { getBooleanStatus } from '@/constants/status'
|
||||||
@ -79,9 +80,16 @@ const columns: PaginationListColumnType[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const ContactMessagesPage = () => {
|
const ContactMessagesPage = () => {
|
||||||
|
const { showAlert } = useAlertModal()
|
||||||
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST })
|
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST })
|
||||||
const [selected, setSelected] = useState<ContactMessageRow | null>(null)
|
const [selected, setSelected] = useState<ContactMessageRow | null>(null)
|
||||||
|
|
||||||
|
const handleMarkRead = (message: ContactMessageRow) => {
|
||||||
|
showAlert('این پیام بهعنوان خواندهشده علامتگذاری شود؟', () =>
|
||||||
|
runAction(message.id, () => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)), 'پیام خوانده شد')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="h-full w-full text-right">
|
<section className="h-full w-full text-right">
|
||||||
<PageNavbar pageTitle="پیامهای تماس" />
|
<PageNavbar pageTitle="پیامهای تماس" />
|
||||||
@ -150,13 +158,9 @@ const ContactMessagesPage = () => {
|
|||||||
isLoading={pendingId === message.id}
|
isLoading={pendingId === message.id}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
void runAction(
|
handleMarkRead(message)
|
||||||
message.id,
|
}}
|
||||||
() => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)),
|
|
||||||
'پیام خوانده شد'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<FileCheckIcon className="size-5" />
|
<FileCheckIcon className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -16,7 +16,12 @@ vi.mock('@/config/axios', () => ({
|
|||||||
patch: axiosMocks.patch,
|
patch: axiosMocks.patch,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
|
||||||
|
void onConfirm?.()
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/lib/toast', () => ({ addToast: vi.fn() }))
|
vi.mock('@/lib/toast', () => ({ addToast: vi.fn() }))
|
||||||
|
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
|
||||||
vi.mock('@/components/formElements/Input', () => ({
|
vi.mock('@/components/formElements/Input', () => ({
|
||||||
default: ({
|
default: ({
|
||||||
description,
|
description,
|
||||||
@ -109,6 +114,9 @@ describe('NotificationRulesPanel', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
|
||||||
|
void onConfirm?.()
|
||||||
|
})
|
||||||
axiosMocks.get.mockReset()
|
axiosMocks.get.mockReset()
|
||||||
axiosMocks.patch.mockReset()
|
axiosMocks.patch.mockReset()
|
||||||
axiosMocks.get.mockResolvedValue({
|
axiosMocks.get.mockResolvedValue({
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import Button from '@/components/formElements/Button'
|
|||||||
import Input from '@/components/formElements/Input'
|
import Input from '@/components/formElements/Input'
|
||||||
import AdminState from '@/components/feedback/AdminState'
|
import AdminState from '@/components/feedback/AdminState'
|
||||||
import axiosInstance from '@/config/axios'
|
import axiosInstance from '@/config/axios'
|
||||||
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
|
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
|
||||||
import { API_ROUTES } from '@/services/config'
|
import { API_ROUTES } from '@/services/config'
|
||||||
import { extractServerErrorDetail } from '@/services/errorHandler'
|
import { extractServerErrorDetail } from '@/services/errorHandler'
|
||||||
@ -52,6 +53,7 @@ const isDirtyDraft = (current: RuleDraft, saved: RuleDraft | undefined) =>
|
|||||||
Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved)
|
Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved)
|
||||||
|
|
||||||
const NotificationRulesPanel = () => {
|
const NotificationRulesPanel = () => {
|
||||||
|
const { showAlert } = useAlertModal()
|
||||||
const [rules, setRules] = useState<Rule[]>([])
|
const [rules, setRules] = useState<Rule[]>([])
|
||||||
const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({})
|
const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -111,6 +113,12 @@ const NotificationRulesPanel = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmSave = (rule: Rule) => {
|
||||||
|
showAlert(`تغییرات قاعدهٔ «${rule.displayName}» ذخیره شود؟`, () => {
|
||||||
|
void save(rule)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm leading-7 text-blue-900">
|
<div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm leading-7 text-blue-900">
|
||||||
@ -142,7 +150,9 @@ const NotificationRulesPanel = () => {
|
|||||||
rule={rule}
|
rule={rule}
|
||||||
saving={saving === rule.eventKey}
|
saving={saving === rule.eventKey}
|
||||||
onChange={change}
|
onChange={change}
|
||||||
onSave={() => void save(rule)}
|
onSave={() => {
|
||||||
|
confirmSave(rule)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -115,7 +115,9 @@ const ReviewsPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleRestore = (row: ReviewRow) => {
|
const handleRestore = (row: ReviewRow) => {
|
||||||
void runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد')
|
showAlert('این نظر دوباره در نمایش عمومی قرار گیرد؟', () =>
|
||||||
|
runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (row: ReviewRow) => {
|
const handleDelete = (row: ReviewRow) => {
|
||||||
|
|||||||
@ -58,15 +58,39 @@ const AdminSupportTicketDetail = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmSend = () => {
|
||||||
|
if (!reply.trim()) return
|
||||||
|
|
||||||
|
showAlert('این پاسخ ثبت و پیامک اطلاعرسانی برای کاربر ارسال شود؟', () => {
|
||||||
|
void send()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmStatusChange = (nextStatus: string) => {
|
||||||
|
if (!ticket || nextStatus === ticket.status) return
|
||||||
|
|
||||||
|
if (nextStatus === 'closed') {
|
||||||
|
showAlert(
|
||||||
|
'این تیکت بسته شود؟ کاربر دیگر نمیتواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمیتواند دوباره باز کند.',
|
||||||
|
() => {
|
||||||
|
void changeStatus('closed')
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ dangerAccept: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = SUPPORT_ADMIN_STATUS_LABELS[nextStatus as keyof typeof SUPPORT_ADMIN_STATUS_LABELS] ?? nextStatus
|
||||||
|
|
||||||
|
showAlert(`وضعیت تیکت به «${label}» تغییر کند؟`, () => {
|
||||||
|
void changeStatus(nextStatus)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const confirmClose = () => {
|
const confirmClose = () => {
|
||||||
showAlert(
|
confirmStatusChange('closed')
|
||||||
'این تیکت بسته شود؟ کاربر دیگر نمیتواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمیتواند دوباره باز کند.',
|
|
||||||
() => {
|
|
||||||
void changeStatus('closed')
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ dangerAccept: true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -104,7 +128,9 @@ const AdminSupportTicketDetail = () => {
|
|||||||
<select
|
<select
|
||||||
className="mt-1 block w-full rounded-lg border border-default-300 p-2"
|
className="mt-1 block w-full rounded-lg border border-default-300 p-2"
|
||||||
value={ticket.status}
|
value={ticket.status}
|
||||||
onChange={(event) => void changeStatus(event.target.value)}
|
onChange={(event) => {
|
||||||
|
confirmStatusChange(event.target.value)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
|
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
|
||||||
<option
|
<option
|
||||||
@ -166,7 +192,7 @@ const AdminSupportTicketDetail = () => {
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
isLoading={pending}
|
isLoading={pending}
|
||||||
onClick={() => void send()}
|
onClick={confirmSend}
|
||||||
>
|
>
|
||||||
ثبت پاسخ و ارسال پیامک
|
ثبت پاسخ و ارسال پیامک
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -140,16 +140,24 @@ const UserEditModal = ({ isOpen, onOpenChange, user, currentAdminId, onSuccess }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (values: AdminUserEditValues) => {
|
const handleSubmit = (values: AdminUserEditValues) => {
|
||||||
// Suspending an account is destructive-ish for the user, so confirm first.
|
|
||||||
const initial = toFormValues(user)
|
const initial = toFormValues(user)
|
||||||
|
const payload = buildDiffPayload(values)
|
||||||
|
|
||||||
if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') {
|
if (Object.keys(payload).length === 0) {
|
||||||
showAlert('این کاربر معلق شود؟ کاربر تا فعالسازی مجدد امکان استفاده از حساب را نخواهد داشت.', () => submitUpdate(values))
|
onOpenChange(false)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
void submitUpdate(values)
|
if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') {
|
||||||
|
showAlert('این کاربر معلق شود؟ کاربر تا فعالسازی مجدد امکان استفاده از حساب را نخواهد داشت.', () => submitUpdate(values), undefined, {
|
||||||
|
dangerAccept: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert('تغییرات این کاربر ذخیره شود؟', () => submitUpdate(values))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -143,7 +143,10 @@ test('edits discoverability and publishes a draft event', async ({ page }) => {
|
|||||||
|
|
||||||
const [updateRequest] = await Promise.all([
|
const [updateRequest] = await Promise.all([
|
||||||
page.waitForRequest((request) => request.method() === 'PATCH' && new URL(request.url()).pathname.endsWith('/admin/events/event-1')),
|
page.waitForRequest((request) => request.method() === 'PATCH' && new URL(request.url()).pathname.endsWith('/admin/events/event-1')),
|
||||||
page.getByRole('switch', { name: 'نمایش در جستجو' }).click({ force: true }),
|
(async () => {
|
||||||
|
await page.getByRole('switch', { name: 'نمایش در جستجو' }).click({ force: true })
|
||||||
|
await page.getByRole('button', { name: 'تأیید' }).click()
|
||||||
|
})(),
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(updateRequest.postDataJSON()).toEqual({ settings: { isDiscoverable: true } })
|
expect(updateRequest.postDataJSON()).toEqual({ settings: { isDiscoverable: true } })
|
||||||
@ -153,6 +156,7 @@ test('edits discoverability and publishes a draft event', async ({ page }) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'انتشار فوری' }).click()
|
await page.getByRole('button', { name: 'انتشار فوری' }).click()
|
||||||
|
await page.getByRole('button', { name: 'تأیید' }).click()
|
||||||
await publishRequest
|
await publishRequest
|
||||||
await expect(page.getByText('منتشرشده', { exact: true })).toBeVisible()
|
await expect(page.getByText('منتشرشده', { exact: true })).toBeVisible()
|
||||||
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)
|
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)
|
||||||
|
|||||||
@ -127,6 +127,7 @@ test('admin approval publishes a pending-review event exactly once', async ({ pa
|
|||||||
await page.goto('/manage-events/event-1')
|
await page.goto('/manage-events/event-1')
|
||||||
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toBeEnabled()
|
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toBeEnabled()
|
||||||
await page.getByRole('button', { name: 'تأیید و انتشار' }).click()
|
await page.getByRole('button', { name: 'تأیید و انتشار' }).click()
|
||||||
|
await page.getByRole('button', { name: 'تأیید' }).click()
|
||||||
|
|
||||||
await expect.poll(() => requests).toEqual([{ path: '/api/v1/admin/events/event-1/approve', payload: null }])
|
await expect.poll(() => requests).toEqual([{ path: '/api/v1/admin/events/event-1/approve', payload: null }])
|
||||||
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toHaveCount(0)
|
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toHaveCount(0)
|
||||||
|
|||||||
@ -197,30 +197,38 @@ 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 = () => {
|
||||||
runAction(
|
const isPendingReview = event?.status === 'pending_review'
|
||||||
'approve',
|
|
||||||
async () => {
|
|
||||||
const updated = (await approveEventAsAdmin(eventId)) as AdminEventDetailData
|
|
||||||
|
|
||||||
mergeEvent(updated)
|
showAlert(isPendingReview ? 'این رویداد تأیید و منتشر شود؟' : 'این رویداد برای انتشار تأیید شود؟', () =>
|
||||||
},
|
runAction(
|
||||||
event?.status === 'pending_review' ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
|
'approve',
|
||||||
|
async () => {
|
||||||
|
const updated = (await approveEventAsAdmin(eventId)) as AdminEventDetailData
|
||||||
|
|
||||||
|
mergeEvent(updated)
|
||||||
|
},
|
||||||
|
isPendingReview ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Bypasses the whole request/approve flow — publishes immediately
|
// Bypasses the whole request/approve flow — publishes immediately
|
||||||
// regardless of whether the host has requested publication or an admin
|
// regardless of whether the host has requested publication or an admin
|
||||||
// has approved yet. Kept as a separate action from "approve" on purpose.
|
// has approved yet. Kept as a separate action from "approve" on purpose.
|
||||||
const handleForcePublish = () =>
|
const handleForcePublish = () => {
|
||||||
runAction(
|
showAlert('این رویداد فوراً و بدون انتظار برای میزبان منتشر شود؟', () =>
|
||||||
'force-publish',
|
runAction(
|
||||||
async () => {
|
'force-publish',
|
||||||
const updated = (await publishEventAsAdmin(eventId)) as AdminEventDetailData
|
async () => {
|
||||||
|
const updated = (await publishEventAsAdmin(eventId)) as AdminEventDetailData
|
||||||
|
|
||||||
mergeEvent(updated)
|
mergeEvent(updated)
|
||||||
},
|
},
|
||||||
'رویداد فورا منتشر شد'
|
'رویداد فورا منتشر شد'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
showAlert('آیا این رویداد به پایان رسیده است؟', () =>
|
showAlert('آیا این رویداد به پایان رسیده است؟', () =>
|
||||||
@ -276,16 +284,18 @@ const AdminEventDetail = () => {
|
|||||||
const handleApproveRevision = () => {
|
const handleApproveRevision = () => {
|
||||||
if (!pendingRevision) return
|
if (!pendingRevision) return
|
||||||
|
|
||||||
void runAction(
|
showAlert('این ویرایش تأیید و روی رویداد اعمال شود؟', () =>
|
||||||
'approve-revision',
|
runAction(
|
||||||
async () => {
|
'approve-revision',
|
||||||
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
|
async () => {
|
||||||
|
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
|
||||||
|
|
||||||
mergeEvent(updated)
|
mergeEvent(updated)
|
||||||
setPendingRevision(null)
|
setPendingRevision(null)
|
||||||
refreshInsights()
|
refreshInsights()
|
||||||
},
|
},
|
||||||
texts.events.revisionApproveSuccess
|
texts.events.revisionApproveSuccess
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,29 +313,31 @@ const AdminEventDetail = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleDiscoverable = async (nextValue: boolean) => {
|
const handleToggleDiscoverable = (nextValue: boolean) => {
|
||||||
if (!event || isTogglingDiscoverable) return
|
if (!event || isTogglingDiscoverable) return
|
||||||
|
|
||||||
const previous = event.settings.isDiscoverable
|
const previous = event.settings.isDiscoverable
|
||||||
|
|
||||||
setEvent({ ...event, settings: { ...event.settings, isDiscoverable: nextValue } })
|
showAlert(nextValue ? 'نمایش این رویداد در جستجوی عمومی فعال شود؟' : 'نمایش این رویداد در جستجوی عمومی غیرفعال شود؟', async () => {
|
||||||
setIsTogglingDiscoverable(true)
|
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: nextValue } } : prev))
|
||||||
|
setIsTogglingDiscoverable(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateEventAsAdmin(eventId, { settings: { isDiscoverable: nextValue } })
|
await updateEventAsAdmin(eventId, { settings: { isDiscoverable: nextValue } })
|
||||||
addToast({ title: 'وضعیت جستجوی عمومی بهروزرسانی شد', color: 'success' })
|
addToast({ title: 'وضعیت جستجوی عمومی بهروزرسانی شد', color: 'success' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: previous } } : prev))
|
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: previous } } : prev))
|
||||||
const detail = extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data)
|
const detail = extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data)
|
||||||
|
|
||||||
addToast({
|
addToast({
|
||||||
title: 'بهروزرسانی ناموفق بود',
|
title: 'بهروزرسانی ناموفق بود',
|
||||||
description: detail ?? undefined,
|
description: detail ?? undefined,
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsTogglingDiscoverable(false)
|
setIsTogglingDiscoverable(false)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -11,8 +11,12 @@ const bulkCreate = vi.fn()
|
|||||||
const setActive = vi.fn()
|
const setActive = vi.fn()
|
||||||
const removeCode = vi.fn()
|
const removeCode = vi.fn()
|
||||||
const addToast = vi.fn()
|
const addToast = vi.fn()
|
||||||
|
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
|
||||||
|
void onConfirm?.()
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
||||||
|
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
|
||||||
vi.mock('@/services/discountCodes', () => ({
|
vi.mock('@/services/discountCodes', () => ({
|
||||||
LIST_DISCOUNT_CODES: (...args: unknown[]) => listCodes(...args),
|
LIST_DISCOUNT_CODES: (...args: unknown[]) => listCodes(...args),
|
||||||
LIST_DISCOUNT_REDEMPTIONS: (...args: unknown[]) => listRedemptions(...args),
|
LIST_DISCOUNT_REDEMPTIONS: (...args: unknown[]) => listRedemptions(...args),
|
||||||
@ -71,6 +75,9 @@ describe('EventDiscountsPanel', () => {
|
|||||||
|
|
||||||
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({
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
GET_DISCOUNT_MANAGEMENT_BOOTSTRAP,
|
GET_DISCOUNT_MANAGEMENT_BOOTSTRAP,
|
||||||
SET_DISCOUNT_CODE_ACTIVE,
|
SET_DISCOUNT_CODE_ACTIVE,
|
||||||
} from '@/services/discountCodes'
|
} from '@/services/discountCodes'
|
||||||
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
|
|
||||||
interface EventDiscountsPanelProps {
|
interface EventDiscountsPanelProps {
|
||||||
eventId: string
|
eventId: string
|
||||||
@ -51,6 +52,7 @@ const DISCOUNT_BEARER_OPTIONS = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false }: EventDiscountsPanelProps) => {
|
const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false }: EventDiscountsPanelProps) => {
|
||||||
|
const { showAlert } = useAlertModal()
|
||||||
const [codes, setCodes] = useState<DiscountCode[]>([])
|
const [codes, setCodes] = useState<DiscountCode[]>([])
|
||||||
const [redemptions, setRedemptions] = useState<DiscountRedemption[]>([])
|
const [redemptions, setRedemptions] = useState<DiscountRedemption[]>([])
|
||||||
const [report, setReport] = useState<DiscountReport | null>(null)
|
const [report, setReport] = useState<DiscountReport | null>(null)
|
||||||
@ -96,26 +98,7 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
|||||||
const canCreate = !isFree && !isEnded
|
const canCreate = !isFree && !isEnded
|
||||||
const canMutateCodes = !isEnded
|
const canMutateCodes = !isEnded
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const executeCreate = async (numericValue: number, numericQuantity: number) => {
|
||||||
const numericValue = toInt(value)
|
|
||||||
const numericQuantity = toInt(quantity)
|
|
||||||
|
|
||||||
if (!numericValue) {
|
|
||||||
addToast({ title: texts.events.discountValueRequired, color: 'warning' })
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (type === 'percent' && (numericValue < 1 || numericValue > 99)) {
|
|
||||||
addToast({ title: texts.events.discountPercentRange, color: 'warning' })
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!numericQuantity) {
|
|
||||||
addToast({ title: texts.events.discountCountRequired, color: 'warning' })
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCreating(true)
|
setIsCreating(true)
|
||||||
const result = await BULK_CREATE_DISCOUNT_CODES(eventId, {
|
const result = await BULK_CREATE_DISCOUNT_CODES(eventId, {
|
||||||
type,
|
type,
|
||||||
@ -143,28 +126,55 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
|||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleActive = async (code: DiscountCode, nextActive: boolean) => {
|
const handleCreate = () => {
|
||||||
|
const numericValue = toInt(value)
|
||||||
|
const numericQuantity = toInt(quantity)
|
||||||
|
|
||||||
|
if (!numericValue) {
|
||||||
|
addToast({ title: texts.events.discountValueRequired, color: 'warning' })
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'percent' && (numericValue < 1 || numericValue > 99)) {
|
||||||
|
addToast({ title: texts.events.discountPercentRange, color: 'warning' })
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!numericQuantity) {
|
||||||
|
addToast({ title: texts.events.discountCountRequired, color: 'warning' })
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert(`${number(numericQuantity)} کد تخفیف ساخته شود؟`, () => {
|
||||||
|
void executeCreate(numericValue, numericQuantity)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleActive = (code: DiscountCode, nextActive: boolean) => {
|
||||||
if (!canMutateCodes) {
|
if (!canMutateCodes) {
|
||||||
addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' })
|
addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' })
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setPendingCodeId(code.id)
|
showAlert(nextActive ? `کد «${code.code}» فعال شود؟` : `کد «${code.code}» غیرفعال شود؟`, async () => {
|
||||||
const result = await SET_DISCOUNT_CODE_ACTIVE(code.id, nextActive)
|
setPendingCodeId(code.id)
|
||||||
|
const result = await SET_DISCOUNT_CODE_ACTIVE(code.id, nextActive)
|
||||||
|
|
||||||
setPendingCodeId(null)
|
setPendingCodeId(null)
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
addToast({ title: texts.events.discountToggleFailed, color: 'danger' })
|
addToast({ title: texts.events.discountToggleFailed, color: 'danger' })
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setCodes((current) => current.map((item) => (item.id === code.id ? { ...item, isActive: nextActive } : item)))
|
setCodes((current) => current.map((item) => (item.id === code.id ? { ...item, isActive: nextActive } : item)))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (code: DiscountCode) => {
|
const handleDelete = (code: DiscountCode) => {
|
||||||
if (!canMutateCodes) {
|
if (!canMutateCodes) {
|
||||||
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
|
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
|
||||||
|
|
||||||
@ -177,19 +187,26 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setPendingCodeId(code.id)
|
showAlert(
|
||||||
const result = await DELETE_DISCOUNT_CODE(code.id)
|
`کد تخفیف «${code.code}» حذف شود؟`,
|
||||||
|
async () => {
|
||||||
|
setPendingCodeId(code.id)
|
||||||
|
const result = await DELETE_DISCOUNT_CODE(code.id)
|
||||||
|
|
||||||
setPendingCodeId(null)
|
setPendingCodeId(null)
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
addToast({ title: texts.events.discountDeleteFailed, description: result.error.message, color: 'danger' })
|
addToast({ title: texts.events.discountDeleteFailed, description: result.error.message, color: 'danger' })
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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[]) => {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { checkInBookingAsAdmin } from '@/services/eventManagement'
|
|||||||
import { API_ROUTES } from '@/services/config'
|
import { API_ROUTES } from '@/services/config'
|
||||||
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
||||||
import useAdminAction from '@/hooks/useAdminAction'
|
import useAdminAction from '@/hooks/useAdminAction'
|
||||||
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
|
|
||||||
// Bookings tab columns — copied from app/(dashboard)/bookings/page.tsx,
|
// Bookings tab columns — copied from app/(dashboard)/bookings/page.tsx,
|
||||||
// minus the `eventId` column (this list is already scoped to one event via
|
// minus the `eventId` column (this list is already scoped to one event via
|
||||||
@ -49,17 +50,21 @@ 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) => {
|
||||||
runAction(
|
showAlert('حضور این مهمان ثبت شود؟', () =>
|
||||||
bookingId,
|
runAction(
|
||||||
async () => {
|
bookingId,
|
||||||
await checkInBookingAsAdmin(bookingId)
|
async () => {
|
||||||
bookingsListRef.current?.refresh()
|
await checkInBookingAsAdmin(bookingId)
|
||||||
onCheckedIn?.()
|
bookingsListRef.current?.refresh()
|
||||||
},
|
onCheckedIn?.()
|
||||||
'حضور مهمان ثبت شد'
|
},
|
||||||
|
'حضور مهمان ثبت شد'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@ -7,8 +7,12 @@ import AdminEventCommissionModal from '@/features/events/detail/admin-event-deta
|
|||||||
|
|
||||||
const updateCommission = vi.fn()
|
const updateCommission = vi.fn()
|
||||||
const addToast = vi.fn()
|
const addToast = vi.fn()
|
||||||
|
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
|
||||||
|
void onConfirm?.()
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
||||||
|
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
|
||||||
vi.mock('@/services/events', () => ({
|
vi.mock('@/services/events', () => ({
|
||||||
updateEventCommissionAsAdmin: (...args: unknown[]) => updateCommission(...args),
|
updateEventCommissionAsAdmin: (...args: unknown[]) => updateCommission(...args),
|
||||||
}))
|
}))
|
||||||
@ -73,6 +77,9 @@ describe('AdminEventCommissionModal', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
|
||||||
|
void onConfirm?.()
|
||||||
|
})
|
||||||
updateCommission.mockResolvedValue({
|
updateCommission.mockResolvedValue({
|
||||||
id: 'event-1',
|
id: 'event-1',
|
||||||
commissionPercent: 12,
|
commissionPercent: 12,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import Modal from '@/components/modals/Modal'
|
|||||||
import { coerceToString } from '@/helpers'
|
import { coerceToString } from '@/helpers'
|
||||||
import { updateEventCommissionAsAdmin } from '@/services/events'
|
import { updateEventCommissionAsAdmin } from '@/services/events'
|
||||||
import useAdminAction from '@/hooks/useAdminAction'
|
import useAdminAction from '@/hooks/useAdminAction'
|
||||||
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
|
|
||||||
interface AdminEventCommissionModalProps {
|
interface AdminEventCommissionModalProps {
|
||||||
eventId: string
|
eventId: string
|
||||||
@ -23,6 +24,7 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
|||||||
const [commissionInput, setCommissionInput] = useState('')
|
const [commissionInput, setCommissionInput] = useState('')
|
||||||
const [commissionError, setCommissionError] = useState<string | null>(null)
|
const [commissionError, setCommissionError] = useState<string | null>(null)
|
||||||
const { pendingId, runAction } = useAdminAction()
|
const { pendingId, runAction } = useAdminAction()
|
||||||
|
const { showAlert } = useAlertModal()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@ -40,29 +42,34 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
void runAction(
|
showAlert(`کمیسیون این رویداد به ${parsed}٪ تغییر کند؟`, () =>
|
||||||
'commission',
|
runAction(
|
||||||
async () => {
|
'commission',
|
||||||
const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData
|
async () => {
|
||||||
|
const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData
|
||||||
|
|
||||||
onUpdated(updated)
|
onUpdated(updated)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
},
|
},
|
||||||
'کمیسیون رویداد بهروزرسانی شد'
|
'کمیسیون رویداد بهروزرسانی شد'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetCommission = () =>
|
const handleResetCommission = () => {
|
||||||
runAction(
|
showAlert('کمیسیون این رویداد به پیشفرض پلتفرم بازگردد؟', () =>
|
||||||
'commission',
|
runAction(
|
||||||
async () => {
|
'commission',
|
||||||
const updated = (await updateEventCommissionAsAdmin(eventId, null)) as AdminEventDetailData
|
async () => {
|
||||||
|
const updated = (await updateEventCommissionAsAdmin(eventId, null)) as AdminEventDetailData
|
||||||
|
|
||||||
onUpdated(updated)
|
onUpdated(updated)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
},
|
},
|
||||||
'کمیسیون رویداد به پیشفرض پلتفرم بازگشت'
|
'کمیسیون رویداد به پیشفرض پلتفرم بازگشت'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@ -75,7 +82,9 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
|||||||
isLoading={pendingId === 'commission'}
|
isLoading={pendingId === 'commission'}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onClick={() => void handleResetCommission()}
|
onClick={() => {
|
||||||
|
handleResetCommission()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
بازگشت به پیشفرض پلتفرم
|
بازگشت به پیشفرض پلتفرم
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ 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 AdminEventLifecycleActions from './AdminEventLifecycleActions'
|
import AdminEventLifecycleActions from './AdminEventLifecycleActions'
|
||||||
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
vi.mock('@/components/formElements/Button', () => ({
|
vi.mock('@/components/formElements/Button', () => ({
|
||||||
default: ({ children, onClick, to }: { children: ReactNode; onClick?: () => void; to?: string }) => (
|
default: ({ children, onClick, to }: { children: ReactNode; onClick?: () => void; to?: string }) => (
|
||||||
@ -28,10 +29,9 @@ const baseEvent = {
|
|||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
describe('AdminEventLifecycleActions', () => {
|
describe('AdminEventLifecycleActions', () => {
|
||||||
it('exposes approve/reject/force-publish for pending_review events', () => {
|
it('exposes approve/reject for pending_review and hides force-publish', () => {
|
||||||
const onApprove = vi.fn()
|
const onApprove = vi.fn()
|
||||||
const onOpenReject = vi.fn()
|
const onOpenReject = vi.fn()
|
||||||
const onForcePublish = vi.fn()
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<AdminEventLifecycleActions
|
<AdminEventLifecycleActions
|
||||||
@ -41,18 +41,63 @@ describe('AdminEventLifecycleActions', () => {
|
|||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
onComplete={vi.fn()}
|
onComplete={vi.fn()}
|
||||||
onDelete={vi.fn()}
|
onDelete={vi.fn()}
|
||||||
onForcePublish={onForcePublish}
|
onForcePublish={vi.fn()}
|
||||||
onOpenReject={onOpenReject}
|
onOpenReject={onOpenReject}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'تأیید و انتشار' }))
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید و انتشار' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'رد' }))
|
fireEvent.click(screen.getByRole('button', { name: 'رد' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' }))
|
|
||||||
|
|
||||||
expect(onApprove).toHaveBeenCalledTimes(1)
|
expect(onApprove).toHaveBeenCalledTimes(1)
|
||||||
expect(onOpenReject).toHaveBeenCalledTimes(1)
|
expect(onOpenReject).toHaveBeenCalledTimes(1)
|
||||||
|
expect(screen.queryByRole('button', { name: 'انتشار فوری' })).toBeNull()
|
||||||
|
expect(screen.getByText(texts.events.adminLifecycleHintPendingReview)).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes approve and force-publish for unapproved draft events', () => {
|
||||||
|
const onApprove = vi.fn()
|
||||||
|
const onForcePublish = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminEventLifecycleActions
|
||||||
|
event={{ ...baseEvent, status: 'draft', adminApprovedAt: null }}
|
||||||
|
pendingId={null}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onForcePublish={onForcePublish}
|
||||||
|
onOpenReject={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید برای انتشار' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' }))
|
||||||
|
|
||||||
|
expect(onApprove).toHaveBeenCalledTimes(1)
|
||||||
expect(onForcePublish).toHaveBeenCalledTimes(1)
|
expect(onForcePublish).toHaveBeenCalledTimes(1)
|
||||||
|
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
|
||||||
|
expect(screen.getByText(texts.events.adminLifecycleHintDraftUnapproved)).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps force-publish for approved drafts waiting on the host', () => {
|
||||||
|
render(
|
||||||
|
<AdminEventLifecycleActions
|
||||||
|
event={{ ...baseEvent, status: 'draft', adminApprovedAt: '2026-01-01T00:00:00.000Z' }}
|
||||||
|
pendingId={null}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onForcePublish={vi.fn()}
|
||||||
|
onOpenReject={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('تأییدشده؛ منتظر میزبان')).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'انتشار فوری' })).toBeTruthy()
|
||||||
|
expect(screen.queryByRole('button', { name: 'تأیید برای انتشار' })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes complete/cancel for published events and hides reject', () => {
|
it('exposes complete/cancel for published events and hides reject', () => {
|
||||||
@ -73,5 +118,6 @@ describe('AdminEventLifecycleActions', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'لغو' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'لغو' })).toBeTruthy()
|
||||||
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
|
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
|
||||||
expect(screen.queryByRole('button', { name: 'حذف' })).toBeNull()
|
expect(screen.queryByRole('button', { name: 'حذف' })).toBeNull()
|
||||||
|
expect(screen.queryByRole('button', { name: 'انتشار فوری' })).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import type { AdminEventDetailData } from './types'
|
|||||||
|
|
||||||
import Button from '@/components/formElements/Button'
|
import Button from '@/components/formElements/Button'
|
||||||
import { APP_ROUTES } from '@/constants/routes'
|
import { APP_ROUTES } from '@/constants/routes'
|
||||||
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
|
import { resolveAdminEventLifecycleHint } from './adminEventLifecycleHints'
|
||||||
|
|
||||||
interface AdminEventLifecycleActionsProps {
|
interface AdminEventLifecycleActionsProps {
|
||||||
event: AdminEventDetailData
|
event: AdminEventDetailData
|
||||||
@ -25,98 +28,105 @@ const AdminEventLifecycleActions = ({
|
|||||||
onComplete,
|
onComplete,
|
||||||
onCancel,
|
onCancel,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: AdminEventLifecycleActionsProps) => (
|
}: 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">
|
const hint = resolveAdminEventLifecycleHint(event)
|
||||||
{['draft', 'pending_review', 'published', 'full'].includes(event.status) ? (
|
|
||||||
<Button
|
return (
|
||||||
size="sm"
|
<div className="flex max-w-full flex-col items-end gap-1">
|
||||||
to={APP_ROUTES.MANAGE_EVENT_EDIT(event.id)}
|
<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">
|
||||||
variant="flat"
|
{['draft', 'pending_review', 'published', 'full'].includes(event.status) ? (
|
||||||
>
|
<Button
|
||||||
ویرایش
|
size="sm"
|
||||||
</Button>
|
to={APP_ROUTES.MANAGE_EVENT_EDIT(event.id)}
|
||||||
) : null}
|
variant="flat"
|
||||||
{event.status === 'draft' && !event.adminApprovedAt ? (
|
>
|
||||||
<Button
|
ویرایش
|
||||||
isLoading={pendingId === 'approve'}
|
</Button>
|
||||||
size="sm"
|
) : null}
|
||||||
onClick={() => {
|
{event.status === 'draft' && !event.adminApprovedAt ? (
|
||||||
onApprove()
|
<Button
|
||||||
}}
|
isLoading={pendingId === 'approve'}
|
||||||
>
|
size="sm"
|
||||||
تأیید برای انتشار
|
onClick={() => {
|
||||||
</Button>
|
onApprove()
|
||||||
) : null}
|
}}
|
||||||
{event.status === 'draft' && event.adminApprovedAt ? (
|
>
|
||||||
<span className="shrink-0 self-center whitespace-nowrap text-xs font-semibold text-fifth-700">تأییدشده؛ منتظر میزبان</span>
|
تأیید برای انتشار
|
||||||
) : null}
|
</Button>
|
||||||
{event.status === 'pending_review' ? (
|
) : null}
|
||||||
<>
|
{event.status === 'draft' && event.adminApprovedAt ? (
|
||||||
<Button
|
<span className="shrink-0 self-center whitespace-nowrap text-xs font-semibold text-fifth-700">تأییدشده؛ منتظر میزبان</span>
|
||||||
isLoading={pendingId === 'approve'}
|
) : null}
|
||||||
size="sm"
|
{event.status === 'pending_review' ? (
|
||||||
onClick={() => {
|
<>
|
||||||
onApprove()
|
<Button
|
||||||
}}
|
isLoading={pendingId === 'approve'}
|
||||||
>
|
size="sm"
|
||||||
تأیید و انتشار
|
onClick={() => {
|
||||||
</Button>
|
onApprove()
|
||||||
<Button
|
}}
|
||||||
color="danger"
|
>
|
||||||
size="sm"
|
تأیید و انتشار
|
||||||
variant="flat"
|
</Button>
|
||||||
onClick={onOpenReject}
|
<Button
|
||||||
>
|
color="danger"
|
||||||
رد
|
size="sm"
|
||||||
</Button>
|
variant="flat"
|
||||||
</>
|
onClick={onOpenReject}
|
||||||
) : null}
|
>
|
||||||
{event.status === 'draft' || event.status === 'pending_review' ? (
|
رد
|
||||||
<Button
|
</Button>
|
||||||
aria-label="انتشار فوری بدون تایید میزبان"
|
</>
|
||||||
isLoading={pendingId === 'force-publish'}
|
) : null}
|
||||||
size="sm"
|
{event.status === 'draft' ? (
|
||||||
variant="flat"
|
<Button
|
||||||
onClick={() => {
|
aria-label={texts.events.adminLifecycleTitleForcePublish}
|
||||||
onForcePublish()
|
isLoading={pendingId === 'force-publish'}
|
||||||
}}
|
size="sm"
|
||||||
>
|
variant="flat"
|
||||||
انتشار فوری
|
onClick={() => {
|
||||||
</Button>
|
onForcePublish()
|
||||||
) : null}
|
}}
|
||||||
{event.status === 'published' || event.status === 'full' ? (
|
>
|
||||||
<>
|
انتشار فوری
|
||||||
<Button
|
</Button>
|
||||||
isLoading={pendingId === 'complete'}
|
) : null}
|
||||||
size="sm"
|
{event.status === 'published' || event.status === 'full' ? (
|
||||||
variant="flat"
|
<>
|
||||||
onClick={onComplete}
|
<Button
|
||||||
>
|
isLoading={pendingId === 'complete'}
|
||||||
پایان
|
size="sm"
|
||||||
</Button>
|
variant="flat"
|
||||||
<Button
|
onClick={onComplete}
|
||||||
color="danger"
|
>
|
||||||
isLoading={pendingId === 'cancel'}
|
پایان
|
||||||
size="sm"
|
</Button>
|
||||||
variant="flat"
|
<Button
|
||||||
onClick={onCancel}
|
color="danger"
|
||||||
>
|
isLoading={pendingId === 'cancel'}
|
||||||
لغو
|
size="sm"
|
||||||
</Button>
|
variant="flat"
|
||||||
</>
|
onClick={onCancel}
|
||||||
) : null}
|
>
|
||||||
{event.bookedCount === 0 ? (
|
لغو
|
||||||
<Button
|
</Button>
|
||||||
color="danger"
|
</>
|
||||||
isLoading={pendingId === 'delete'}
|
) : null}
|
||||||
size="sm"
|
{event.bookedCount === 0 ? (
|
||||||
variant="flat"
|
<Button
|
||||||
onClick={onDelete}
|
color="danger"
|
||||||
>
|
isLoading={pendingId === 'delete'}
|
||||||
حذف
|
size="sm"
|
||||||
</Button>
|
variant="flat"
|
||||||
) : null}
|
onClick={onDelete}
|
||||||
</div>
|
>
|
||||||
)
|
حذف
|
||||||
|
</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
|
export default AdminEventLifecycleActions
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { AdminEventDetailData } from './types'
|
||||||
|
|
||||||
|
import { resolveAdminEventLifecycleHint } from './adminEventLifecycleHints'
|
||||||
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import type { AdminEventDetailData } from './types'
|
||||||
|
|
||||||
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
|
/** راهنمای کوتاه اکشنهای lifecycle — بسته به وضعیت فعلی رویداد */
|
||||||
|
export const resolveAdminEventLifecycleHint = (event: AdminEventDetailData): string | null => {
|
||||||
|
if (event.status === 'draft' && !event.adminApprovedAt) {
|
||||||
|
return texts.events.adminLifecycleHintDraftUnapproved
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === 'draft' && event.adminApprovedAt) {
|
||||||
|
return texts.events.adminLifecycleHintDraftApproved
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === 'pending_review') {
|
||||||
|
return texts.events.adminLifecycleHintPendingReview
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === 'published' || event.status === 'full') {
|
||||||
|
return texts.events.adminLifecycleHintPublished
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.bookedCount === 0) {
|
||||||
|
return texts.events.adminLifecycleHintDeleteOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@ -126,6 +126,12 @@ export const events = {
|
|||||||
revisionReject: 'رد ویرایش',
|
revisionReject: 'رد ویرایش',
|
||||||
revisionRejectReasonLabel: 'دلیل رد',
|
revisionRejectReasonLabel: 'دلیل رد',
|
||||||
revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود',
|
revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود',
|
||||||
|
adminLifecycleHintDraftUnapproved: 'تأیید برای انتشار: فقط اجازه میدهد میزبان خودش منتشر کند · انتشار فوری: همین الان عمومی میشود',
|
||||||
|
adminLifecycleHintDraftApproved: 'ادمین تأیید کرده؛ منتظر Publish میزبان بمانید یا با «انتشار فوری» همین الان منتشر کنید',
|
||||||
|
adminLifecycleHintPendingReview: 'میزبان درخواست انتشار داده؛ «تأیید و انتشار» رویداد را عمومی میکند',
|
||||||
|
adminLifecycleHintPublished: 'پایان: پس از برگزاری · لغو: همهٔ رزروها لغو میشوند',
|
||||||
|
adminLifecycleHintDeleteOnly: 'حذف فقط برای رویدادهای بدون رزرو فعال',
|
||||||
|
adminLifecycleTitleForcePublish: 'انتشار فوری بدون انتظار برای میزبان',
|
||||||
genderOpen: 'آزاد برای عموم',
|
genderOpen: 'آزاد برای عموم',
|
||||||
genderFemaleOnly: 'خانمها',
|
genderFemaleOnly: 'خانمها',
|
||||||
genderMaleOnly: 'آقایان',
|
genderMaleOnly: 'آقایان',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user