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:
alisaza 2026-09-13 10:14:18 +03:30
parent 0a68104e85
commit 96603d31f7
19 changed files with 473 additions and 229 deletions

View File

@ -13,6 +13,7 @@ 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'
@ -79,9 +80,16 @@ 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="پیام‌های تماس" />
@ -150,13 +158,9 @@ const ContactMessagesPage = () => {
isLoading={pendingId === message.id}
size="sm"
variant="light"
onClick={() =>
void runAction(
message.id,
() => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)),
'پیام خوانده شد'
)
}
onClick={() => {
handleMarkRead(message)
}}
>
<FileCheckIcon className="size-5" />
</Button>

View File

@ -16,7 +16,12 @@ 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,
@ -109,6 +114,9 @@ describe('NotificationRulesPanel', () => {
})
beforeEach(() => {
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
axiosMocks.get.mockReset()
axiosMocks.patch.mockReset()
axiosMocks.get.mockResolvedValue({

View File

@ -10,6 +10,7 @@ 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'
@ -52,6 +53,7 @@ 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)
@ -111,6 +113,12 @@ 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">
@ -142,7 +150,9 @@ const NotificationRulesPanel = () => {
rule={rule}
saving={saving === rule.eventKey}
onChange={change}
onSave={() => void save(rule)}
onSave={() => {
confirmSave(rule)
}}
/>
))}
</div>

View File

@ -115,7 +115,9 @@ const ReviewsPage = () => {
}
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) => {

View File

@ -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 = () => {
showAlert(
'این تیکت بسته شود؟ کاربر دیگر نمی‌تواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمی‌تواند دوباره باز کند.',
() => {
void changeStatus('closed')
},
undefined,
{ dangerAccept: true }
)
confirmStatusChange('closed')
}
return (
@ -104,7 +128,9 @@ const AdminSupportTicketDetail = () => {
<select
className="mt-1 block w-full rounded-lg border border-default-300 p-2"
value={ticket.status}
onChange={(event) => void changeStatus(event.target.value)}
onChange={(event) => {
confirmStatusChange(event.target.value)
}}
>
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
<option
@ -166,7 +192,7 @@ const AdminSupportTicketDetail = () => {
</p>
<Button
isLoading={pending}
onClick={() => void send()}
onClick={confirmSend}
>
ثبت پاسخ و ارسال پیامک
</Button>

View File

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

View File

@ -143,7 +143,10 @@ 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')),
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 } })
@ -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 publishRequest
await expect(page.getByText('منتشر‌شده', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)

View File

@ -127,6 +127,7 @@ test('admin approval publishes a pending-review event exactly once', async ({ pa
await page.goto('/manage-events/event-1')
await 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)

View File

@ -197,30 +197,38 @@ 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 = () =>
runAction(
'approve',
async () => {
const updated = (await approveEventAsAdmin(eventId)) as AdminEventDetailData
const handleApprove = () => {
const isPendingReview = event?.status === 'pending_review'
mergeEvent(updated)
},
event?.status === 'pending_review' ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
showAlert(isPendingReview ? 'این رویداد تأیید و منتشر شود؟' : 'این رویداد برای انتشار تأیید شود؟', () =>
runAction(
'approve',
async () => {
const updated = (await approveEventAsAdmin(eventId)) as AdminEventDetailData
mergeEvent(updated)
},
isPendingReview ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
)
)
}
// 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 = () =>
runAction(
'force-publish',
async () => {
const updated = (await publishEventAsAdmin(eventId)) as AdminEventDetailData
const handleForcePublish = () => {
showAlert('این رویداد فوراً و بدون انتظار برای میزبان منتشر شود؟', () =>
runAction(
'force-publish',
async () => {
const updated = (await publishEventAsAdmin(eventId)) as AdminEventDetailData
mergeEvent(updated)
},
'رویداد فورا منتشر شد'
mergeEvent(updated)
},
'رویداد فورا منتشر شد'
)
)
}
const handleComplete = () => {
showAlert('آیا این رویداد به پایان رسیده است؟', () =>
@ -276,16 +284,18 @@ const AdminEventDetail = () => {
const handleApproveRevision = () => {
if (!pendingRevision) return
void runAction(
'approve-revision',
async () => {
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
showAlert('این ویرایش تأیید و روی رویداد اعمال شود؟', () =>
runAction(
'approve-revision',
async () => {
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
mergeEvent(updated)
setPendingRevision(null)
refreshInsights()
},
texts.events.revisionApproveSuccess
mergeEvent(updated)
setPendingRevision(null)
refreshInsights()
},
texts.events.revisionApproveSuccess
)
)
}
@ -303,29 +313,31 @@ const AdminEventDetail = () => {
)
}
const handleToggleDiscoverable = async (nextValue: boolean) => {
const handleToggleDiscoverable = (nextValue: boolean) => {
if (!event || isTogglingDiscoverable) return
const previous = event.settings.isDiscoverable
setEvent({ ...event, settings: { ...event.settings, isDiscoverable: nextValue } })
setIsTogglingDiscoverable(true)
showAlert(nextValue ? 'نمایش این رویداد در جستجوی عمومی فعال شود؟' : 'نمایش این رویداد در جستجوی عمومی غیرفعال شود؟', async () => {
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: nextValue } } : prev))
setIsTogglingDiscoverable(true)
try {
await updateEventAsAdmin(eventId, { settings: { isDiscoverable: nextValue } })
addToast({ title: 'وضعیت جستجوی عمومی به‌روزرسانی شد', color: 'success' })
} catch (err) {
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: previous } } : prev))
const detail = extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data)
try {
await updateEventAsAdmin(eventId, { settings: { isDiscoverable: nextValue } })
addToast({ title: 'وضعیت جستجوی عمومی به‌روزرسانی شد', color: 'success' })
} catch (err) {
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: previous } } : prev))
const detail = extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data)
addToast({
title: 'به‌روزرسانی ناموفق بود',
description: detail ?? undefined,
color: 'danger',
})
} finally {
setIsTogglingDiscoverable(false)
}
addToast({
title: 'به‌روزرسانی ناموفق بود',
description: detail ?? undefined,
color: 'danger',
})
} finally {
setIsTogglingDiscoverable(false)
}
})
}
return (

View File

@ -11,8 +11,12 @@ 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,6 +75,9 @@ describe('EventDiscountsPanel', () => {
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({

View File

@ -18,6 +18,7 @@ import {
GET_DISCOUNT_MANAGEMENT_BOOTSTRAP,
SET_DISCOUNT_CODE_ACTIVE,
} from '@/services/discountCodes'
import useAlertModal from '@/hooks/useAlertModal'
interface EventDiscountsPanelProps {
eventId: string
@ -51,6 +52,7 @@ 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)
@ -96,26 +98,7 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
const canCreate = !isFree && !isEnded
const canMutateCodes = !isEnded
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
}
const executeCreate = async (numericValue: number, numericQuantity: number) => {
setIsCreating(true)
const result = await BULK_CREATE_DISCOUNT_CODES(eventId, {
type,
@ -143,28 +126,55 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
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) {
addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' })
return
}
setPendingCodeId(code.id)
const result = await SET_DISCOUNT_CODE_ACTIVE(code.id, nextActive)
showAlert(nextActive ? `کد «${code.code}» فعال شود؟` : `کد «${code.code}» غیرفعال شود؟`, async () => {
setPendingCodeId(code.id)
const result = await SET_DISCOUNT_CODE_ACTIVE(code.id, nextActive)
setPendingCodeId(null)
setPendingCodeId(null)
if (!result.ok) {
addToast({ title: texts.events.discountToggleFailed, color: 'danger' })
if (!result.ok) {
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) {
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
@ -177,19 +187,26 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
return
}
setPendingCodeId(code.id)
const result = await DELETE_DISCOUNT_CODE(code.id)
showAlert(
`کد تخفیف «${code.code}» حذف شود؟`,
async () => {
setPendingCodeId(code.id)
const result = await DELETE_DISCOUNT_CODE(code.id)
setPendingCodeId(null)
setPendingCodeId(null)
if (!result.ok) {
addToast({ title: texts.events.discountDeleteFailed, description: result.error.message, color: 'danger' })
if (!result.ok) {
addToast({ title: texts.events.discountDeleteFailed, description: result.error.message, color: 'danger' })
return
}
return
}
addToast({ title: texts.events.discountDeleted, color: 'success' })
setCodes((current) => current.filter((item) => item.id !== code.id))
addToast({ title: texts.events.discountDeleted, color: 'success' })
setCodes((current) => current.filter((item) => item.id !== code.id))
},
undefined,
{ dangerAccept: true }
)
}
const copyCodes = async (list: string[]) => {

View File

@ -19,6 +19,7 @@ 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
@ -49,17 +50,21 @@ interface AdminEventBookingsTabProps {
const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEventBookingsTabProps) => {
const bookingsListRef = useRef<PaginatedListHandle>(null)
const { pendingId, runAction } = useAdminAction()
const { showAlert } = useAlertModal()
const handleCheckIn = (bookingId: string) =>
runAction(
bookingId,
async () => {
await checkInBookingAsAdmin(bookingId)
bookingsListRef.current?.refresh()
onCheckedIn?.()
},
'حضور مهمان ثبت شد'
const handleCheckIn = (bookingId: string) => {
showAlert('حضور این مهمان ثبت شود؟', () =>
runAction(
bookingId,
async () => {
await checkInBookingAsAdmin(bookingId)
bookingsListRef.current?.refresh()
onCheckedIn?.()
},
'حضور مهمان ثبت شد'
)
)
}
return (
<div className="flex flex-col gap-2">

View File

@ -7,8 +7,12 @@ 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),
}))
@ -73,6 +77,9 @@ describe('AdminEventCommissionModal', () => {
beforeEach(() => {
vi.clearAllMocks()
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
updateCommission.mockResolvedValue({
id: 'event-1',
commissionPercent: 12,

View File

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

View File

@ -5,6 +5,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AdminEventLifecycleActions from './AdminEventLifecycleActions'
import { texts } from '@/texts'
vi.mock('@/components/formElements/Button', () => ({
default: ({ children, onClick, to }: { children: ReactNode; onClick?: () => void; to?: string }) => (
@ -28,10 +29,9 @@ const baseEvent = {
afterEach(cleanup)
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 onOpenReject = vi.fn()
const onForcePublish = vi.fn()
render(
<AdminEventLifecycleActions
@ -41,18 +41,63 @@ describe('AdminEventLifecycleActions', () => {
onCancel={vi.fn()}
onComplete={vi.fn()}
onDelete={vi.fn()}
onForcePublish={onForcePublish}
onForcePublish={vi.fn()}
onOpenReject={onOpenReject}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'تأیید و انتشار' }))
fireEvent.click(screen.getByRole('button', { name: 'رد' }))
fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' }))
expect(onApprove).toHaveBeenCalledTimes(1)
expect(onOpenReject).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: 'انتشار فوری' })).toBeNull()
expect(screen.getByText(texts.events.adminLifecycleHintPendingReview)).toBeTruthy()
})
it('exposes approve and force-publish for unapproved draft events', () => {
const onApprove = vi.fn()
const onForcePublish = vi.fn()
render(
<AdminEventLifecycleActions
event={{ ...baseEvent, status: 'draft', adminApprovedAt: null }}
pendingId={null}
onApprove={onApprove}
onCancel={vi.fn()}
onComplete={vi.fn()}
onDelete={vi.fn()}
onForcePublish={onForcePublish}
onOpenReject={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'تأیید برای انتشار' }))
fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' }))
expect(onApprove).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', () => {
@ -73,5 +118,6 @@ 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()
})
})

View File

@ -4,6 +4,9 @@ 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
@ -25,98 +28,105 @@ const AdminEventLifecycleActions = ({
onComplete,
onCancel,
onDelete,
}: 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
size="sm"
to={APP_ROUTES.MANAGE_EVENT_EDIT(event.id)}
variant="flat"
>
ویرایش
</Button>
) : null}
{event.status === 'draft' && !event.adminApprovedAt ? (
<Button
isLoading={pendingId === 'approve'}
size="sm"
onClick={() => {
onApprove()
}}
>
تأیید برای انتشار
</Button>
) : null}
{event.status === 'draft' && event.adminApprovedAt ? (
<span className="shrink-0 self-center whitespace-nowrap text-xs font-semibold text-fifth-700">تأییدشده؛ منتظر میزبان</span>
) : null}
{event.status === 'pending_review' ? (
<>
<Button
isLoading={pendingId === 'approve'}
size="sm"
onClick={() => {
onApprove()
}}
>
تأیید و انتشار
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onClick={onOpenReject}
>
رد
</Button>
</>
) : null}
{event.status === 'draft' || event.status === 'pending_review' ? (
<Button
aria-label="انتشار فوری بدون تایید میزبان"
isLoading={pendingId === 'force-publish'}
size="sm"
variant="flat"
onClick={() => {
onForcePublish()
}}
>
انتشار فوری
</Button>
) : null}
{event.status === 'published' || event.status === 'full' ? (
<>
<Button
isLoading={pendingId === 'complete'}
size="sm"
variant="flat"
onClick={onComplete}
>
پایان
</Button>
<Button
color="danger"
isLoading={pendingId === 'cancel'}
size="sm"
variant="flat"
onClick={onCancel}
>
لغو
</Button>
</>
) : null}
{event.bookedCount === 0 ? (
<Button
color="danger"
isLoading={pendingId === 'delete'}
size="sm"
variant="flat"
onClick={onDelete}
>
حذف
</Button>
) : null}
</div>
)
}: AdminEventLifecycleActionsProps) => {
const hint = resolveAdminEventLifecycleHint(event)
return (
<div className="flex max-w-full flex-col items-end gap-1">
<div className="flex max-w-full flex-nowrap items-center justify-end gap-2 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{['draft', 'pending_review', 'published', 'full'].includes(event.status) ? (
<Button
size="sm"
to={APP_ROUTES.MANAGE_EVENT_EDIT(event.id)}
variant="flat"
>
ویرایش
</Button>
) : null}
{event.status === 'draft' && !event.adminApprovedAt ? (
<Button
isLoading={pendingId === 'approve'}
size="sm"
onClick={() => {
onApprove()
}}
>
تأیید برای انتشار
</Button>
) : null}
{event.status === 'draft' && event.adminApprovedAt ? (
<span className="shrink-0 self-center whitespace-nowrap text-xs font-semibold text-fifth-700">تأییدشده؛ منتظر میزبان</span>
) : null}
{event.status === 'pending_review' ? (
<>
<Button
isLoading={pendingId === 'approve'}
size="sm"
onClick={() => {
onApprove()
}}
>
تأیید و انتشار
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onClick={onOpenReject}
>
رد
</Button>
</>
) : null}
{event.status === 'draft' ? (
<Button
aria-label={texts.events.adminLifecycleTitleForcePublish}
isLoading={pendingId === 'force-publish'}
size="sm"
variant="flat"
onClick={() => {
onForcePublish()
}}
>
انتشار فوری
</Button>
) : null}
{event.status === 'published' || event.status === 'full' ? (
<>
<Button
isLoading={pendingId === 'complete'}
size="sm"
variant="flat"
onClick={onComplete}
>
پایان
</Button>
<Button
color="danger"
isLoading={pendingId === 'cancel'}
size="sm"
variant="flat"
onClick={onCancel}
>
لغو
</Button>
</>
) : null}
{event.bookedCount === 0 ? (
<Button
color="danger"
isLoading={pendingId === 'delete'}
size="sm"
variant="flat"
onClick={onDelete}
>
حذف
</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

View File

@ -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)
})
})

View File

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

View File

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