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.
505 lines
17 KiB
TypeScript
505 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import type { AdminEventDetailData } from './admin-event-detail/types'
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import dynamic from 'next/dynamic'
|
||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||
|
||
import type { EventCategory, EventRevision } from '@/services/events'
|
||
import type { City, Province } from '@/services/geography'
|
||
import type { EventFaq, EventMedia } from '@/services/eventDetail'
|
||
import type { EventManagementInsights } from '@/services/eventManagement'
|
||
import { Tab } from '@/components/heroui/Tabs'
|
||
import { addToast } from '@/lib/toast'
|
||
import PageNavbar from '@/components/layouts/PageNavbar'
|
||
import { DetailSkeleton } from '@/components/feedback/LoadingState'
|
||
import AdminState from '@/components/feedback/AdminState'
|
||
import AppTabs from '@/components/ui/AppTabs'
|
||
import { APP_ROUTES } from '@/constants/routes'
|
||
import { isEventEnded, isLiveEventStatus } from '@/features/events/eventEditPolicy'
|
||
import { extractServerErrorDetail } from '@/services/errorHandler'
|
||
import {
|
||
approveEventAsAdmin,
|
||
approveEventRevisionAsAdmin,
|
||
cancelEventAsAdmin,
|
||
completeEventAsAdmin,
|
||
deleteEventAsAdmin,
|
||
fetchAdminPendingEventRevision,
|
||
fetchEventCategories,
|
||
fetchAdminEventDetail,
|
||
publishEventAsAdmin,
|
||
rejectEventRevisionAsAdmin,
|
||
updateEventAsAdmin,
|
||
} from '@/services/events'
|
||
import { fetchEventInsightsAsAdmin } from '@/services/eventManagement'
|
||
import { fetchEventFaqs, fetchEventMedia } from '@/services/eventDetail'
|
||
import { fetchAllCities, fetchProvinces } from '@/services/geography'
|
||
import useAdminAction from '@/hooks/useAdminAction'
|
||
import useAlertModal from '@/hooks/useAlertModal'
|
||
import { texts } from '@/texts'
|
||
|
||
import AdminEventDetailsSection from './admin-event-detail/AdminEventDetailsSection'
|
||
import AdminEventLifecycleActions from './admin-event-detail/AdminEventLifecycleActions'
|
||
import AdminEventOverviewTab from './admin-event-detail/AdminEventOverviewTab'
|
||
import AdminEventPendingRevisionCard from './admin-event-detail/AdminEventPendingRevisionCard'
|
||
import AdminEventRevisionRejectModal from './admin-event-detail/AdminEventRevisionRejectModal'
|
||
import AdminEventSummaryCard from './admin-event-detail/AdminEventSummaryCard'
|
||
|
||
const TabContentLoading = () => (
|
||
<div
|
||
aria-busy="true"
|
||
aria-live="polite"
|
||
className="admin-surface flex min-h-32 items-center justify-center gap-3 p-6 text-sm text-text-muted"
|
||
role="status"
|
||
>
|
||
<span className="size-5 animate-spin rounded-full border-2 border-primary/25 border-t-primary motion-reduce:animate-none" />
|
||
در حال بارگذاری اطلاعات…
|
||
</div>
|
||
)
|
||
|
||
const AdminEventFinancialTab = dynamic(() => import('./admin-event-detail/AdminEventFinancialTab'), {
|
||
loading: TabContentLoading,
|
||
ssr: false,
|
||
})
|
||
const EventDiscountsPanel = dynamic(() => import('@/features/events/detail/EventDiscountsPanel'), {
|
||
loading: TabContentLoading,
|
||
ssr: false,
|
||
})
|
||
const AdminEventBookingsTab = dynamic(() => import('./admin-event-detail/AdminEventBookingsTab'), {
|
||
loading: TabContentLoading,
|
||
ssr: false,
|
||
})
|
||
const AdminEventReviewsTab = dynamic(() => import('./admin-event-detail/AdminEventReviewsTab'), {
|
||
loading: TabContentLoading,
|
||
ssr: false,
|
||
})
|
||
const EventGuestListAccessPanel = dynamic(() => import('@/components/events/EventGuestListAccessPanel'), {
|
||
loading: TabContentLoading,
|
||
ssr: false,
|
||
})
|
||
const AdminEventRejectModal = dynamic(() => import('./admin-event-detail/AdminEventRejectModal'), { ssr: false })
|
||
const AdminEventCommissionModal = dynamic(() => import('./admin-event-detail/AdminEventCommissionModal'), { ssr: false })
|
||
|
||
const AdminEventDetail = () => {
|
||
const params = useParams<{ id: string }>()
|
||
const searchParams = useSearchParams()
|
||
const router = useRouter()
|
||
const eventId = params.id
|
||
const initialTab = searchParams.get('tab') === 'discounts' ? 'discounts' : 'overview'
|
||
const [event, setEvent] = useState<AdminEventDetailData | null>(null)
|
||
const [categories, setCategories] = useState<EventCategory[]>([])
|
||
const [cities, setCities] = useState<City[]>([])
|
||
const [provinces, setProvinces] = useState<Province[]>([])
|
||
const [media, setMedia] = useState<EventMedia[]>([])
|
||
const [faqs, setFaqs] = useState<EventFaq[]>([])
|
||
const [insights, setInsights] = useState<EventManagementInsights | null>(null)
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [isTogglingDiscoverable, setIsTogglingDiscoverable] = useState(false)
|
||
const [isRejectModalOpen, setIsRejectModalOpen] = useState(false)
|
||
const [isCommissionModalOpen, setIsCommissionModalOpen] = useState(false)
|
||
const [hasOpenedRejectModal, setHasOpenedRejectModal] = useState(false)
|
||
const [hasOpenedCommissionModal, setHasOpenedCommissionModal] = useState(false)
|
||
const [pendingRevision, setPendingRevision] = useState<EventRevision | null>(null)
|
||
const [isRevisionRejectModalOpen, setIsRevisionRejectModalOpen] = useState(false)
|
||
const [hasOpenedRevisionRejectModal, setHasOpenedRevisionRejectModal] = useState(false)
|
||
const { showAlert } = useAlertModal()
|
||
const { pendingId, runAction } = useAdminAction()
|
||
|
||
const mergeEvent = (updated: AdminEventDetailData) => {
|
||
setEvent((prev) => (prev ? { ...prev, ...updated } : prev))
|
||
}
|
||
|
||
const refreshInsights = () => {
|
||
if (!eventId) return
|
||
void fetchEventInsightsAsAdmin(eventId)
|
||
.then(setInsights)
|
||
.catch(() => undefined)
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!eventId) return
|
||
|
||
let cancelled = false
|
||
|
||
const loadEvent = async () => {
|
||
try {
|
||
setIsLoading(true)
|
||
setError(null)
|
||
|
||
const [loadedEvent, loadedCategories, loadedCities, loadedProvinces, loadedMedia, loadedFaqs, loadedInsights] = await Promise.all([
|
||
fetchAdminEventDetail(eventId) as Promise<AdminEventDetailData>,
|
||
fetchEventCategories().catch(() => []),
|
||
fetchAllCities().catch(() => []),
|
||
fetchProvinces().catch(() => []),
|
||
fetchEventMedia(eventId).catch(() => []),
|
||
fetchEventFaqs(eventId).catch(() => []),
|
||
fetchEventInsightsAsAdmin(eventId).catch(() => null),
|
||
])
|
||
|
||
if (cancelled) return
|
||
|
||
setEvent(loadedEvent)
|
||
setCategories(loadedCategories)
|
||
setCities(loadedCities)
|
||
setProvinces(loadedProvinces)
|
||
setMedia(loadedMedia)
|
||
setFaqs(loadedFaqs)
|
||
setInsights(loadedInsights)
|
||
} catch {
|
||
if (!cancelled) {
|
||
setError('رویداد یافت نشد')
|
||
setEvent(null)
|
||
}
|
||
} finally {
|
||
if (!cancelled) setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
void loadEvent()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [eventId])
|
||
|
||
useEffect(() => {
|
||
if (!eventId || !event || !isLiveEventStatus(event.status)) {
|
||
setPendingRevision(null)
|
||
|
||
return
|
||
}
|
||
|
||
let cancelled = false
|
||
|
||
const loadPendingRevision = async () => {
|
||
try {
|
||
const revision = await fetchAdminPendingEventRevision(eventId)
|
||
|
||
if (!cancelled) setPendingRevision(revision)
|
||
} catch {
|
||
if (!cancelled) {
|
||
setPendingRevision(null)
|
||
addToast({ title: texts.events.revisionLoadFailed, color: 'danger' })
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadPendingRevision()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [event, eventId])
|
||
|
||
const categoryName = useMemo(() => categories.find((item) => item.id === event?.categoryId)?.name ?? '—', [categories, event?.categoryId])
|
||
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 ? 'این رویداد تأیید و منتشر شود؟' : 'این رویداد برای انتشار تأیید شود؟', () =>
|
||
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 = () => {
|
||
showAlert('این رویداد فوراً و بدون انتظار برای میزبان منتشر شود؟', () =>
|
||
runAction(
|
||
'force-publish',
|
||
async () => {
|
||
const updated = (await publishEventAsAdmin(eventId)) as AdminEventDetailData
|
||
|
||
mergeEvent(updated)
|
||
},
|
||
'رویداد فورا منتشر شد'
|
||
)
|
||
)
|
||
}
|
||
|
||
const handleComplete = () => {
|
||
showAlert('آیا این رویداد به پایان رسیده است؟', () =>
|
||
runAction(
|
||
'complete',
|
||
async () => {
|
||
const updated = (await completeEventAsAdmin(eventId)) as AdminEventDetailData
|
||
|
||
mergeEvent(updated)
|
||
refreshInsights()
|
||
},
|
||
'رویداد بهعنوان پایانیافته علامت خورد'
|
||
)
|
||
)
|
||
}
|
||
|
||
const handleCancel = () => {
|
||
showAlert(
|
||
'آیا از لغو این رویداد مطمئن هستید؟ همهی رزروها لغو خواهند شد.',
|
||
() =>
|
||
runAction(
|
||
'cancel',
|
||
async () => {
|
||
const updated = (await cancelEventAsAdmin(eventId)) as AdminEventDetailData
|
||
|
||
mergeEvent(updated)
|
||
refreshInsights()
|
||
},
|
||
'رویداد لغو شد'
|
||
),
|
||
undefined,
|
||
{ dangerAccept: true }
|
||
)
|
||
}
|
||
|
||
const handleDelete = () => {
|
||
showAlert(
|
||
'آیا از حذف این رویداد مطمئن هستید؟ فقط رویدادهای بدون رزرو فعال قابل حذفاند.',
|
||
() =>
|
||
runAction(
|
||
'delete',
|
||
async () => {
|
||
await deleteEventAsAdmin(eventId)
|
||
router.push(APP_ROUTES.MANAGE_EVENTS)
|
||
},
|
||
'رویداد حذف شد'
|
||
),
|
||
undefined,
|
||
{ dangerAccept: true }
|
||
)
|
||
}
|
||
|
||
const handleApproveRevision = () => {
|
||
if (!pendingRevision) return
|
||
|
||
showAlert('این ویرایش تأیید و روی رویداد اعمال شود؟', () =>
|
||
runAction(
|
||
'approve-revision',
|
||
async () => {
|
||
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
|
||
|
||
mergeEvent(updated)
|
||
setPendingRevision(null)
|
||
refreshInsights()
|
||
},
|
||
texts.events.revisionApproveSuccess
|
||
)
|
||
)
|
||
}
|
||
|
||
const handleRejectRevision = (rejectionReason: string) => {
|
||
if (!pendingRevision) return
|
||
|
||
void runAction(
|
||
'reject-revision',
|
||
async () => {
|
||
await rejectEventRevisionAsAdmin(eventId, pendingRevision.id, rejectionReason)
|
||
setPendingRevision(null)
|
||
setIsRevisionRejectModalOpen(false)
|
||
},
|
||
texts.events.revisionRejectSuccess
|
||
)
|
||
}
|
||
|
||
const handleToggleDiscoverable = (nextValue: boolean) => {
|
||
if (!event || isTogglingDiscoverable) return
|
||
|
||
const previous = event.settings.isDiscoverable
|
||
|
||
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)
|
||
|
||
addToast({
|
||
title: 'بهروزرسانی ناموفق بود',
|
||
description: detail ?? undefined,
|
||
color: 'danger',
|
||
})
|
||
} finally {
|
||
setIsTogglingDiscoverable(false)
|
||
}
|
||
})
|
||
}
|
||
|
||
return (
|
||
<section className="w-full h-full">
|
||
<PageNavbar
|
||
endSlot={
|
||
event ? (
|
||
<AdminEventLifecycleActions
|
||
event={event}
|
||
pendingId={pendingId}
|
||
onApprove={() => void handleApprove()}
|
||
onCancel={handleCancel}
|
||
onComplete={handleComplete}
|
||
onDelete={handleDelete}
|
||
onForcePublish={() => void handleForcePublish()}
|
||
onOpenReject={() => {
|
||
setHasOpenedRejectModal(true)
|
||
setIsRejectModalOpen(true)
|
||
}}
|
||
/>
|
||
) : undefined
|
||
}
|
||
pageTitle={event?.title ?? 'جزئیات رویداد'}
|
||
/>
|
||
<div className="admin-page-container flex flex-col gap-5">
|
||
{isLoading && <DetailSkeleton />}
|
||
{!isLoading && error && (
|
||
<div className="admin-surface overflow-hidden">
|
||
<AdminState
|
||
description={error}
|
||
title="دریافت اطلاعات رویداد ناموفق بود"
|
||
variant="error"
|
||
/>
|
||
</div>
|
||
)}
|
||
{!isLoading && event && (
|
||
<>
|
||
<AdminEventSummaryCard
|
||
event={event}
|
||
isTogglingDiscoverable={isTogglingDiscoverable}
|
||
onToggleDiscoverable={handleToggleDiscoverable}
|
||
/>
|
||
|
||
{pendingRevision ? (
|
||
<AdminEventPendingRevisionCard
|
||
pendingId={pendingId}
|
||
revision={pendingRevision}
|
||
onApprove={handleApproveRevision}
|
||
onReject={() => {
|
||
setHasOpenedRevisionRejectModal(true)
|
||
setIsRevisionRejectModalOpen(true)
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
<AppTabs
|
||
aria-label="جزئیات رویداد"
|
||
defaultSelectedKey={event.isFree ? 'overview' : initialTab}
|
||
surface="admin"
|
||
>
|
||
<Tab
|
||
key="overview"
|
||
title="خلاصه"
|
||
>
|
||
<AdminEventOverviewTab insights={insights} />
|
||
</Tab>
|
||
|
||
{event.isFree ? null : (
|
||
<Tab
|
||
key="financial"
|
||
title="مالی"
|
||
>
|
||
<AdminEventFinancialTab insights={insights} />
|
||
</Tab>
|
||
)}
|
||
|
||
{event.isFree ? null : (
|
||
<Tab
|
||
key="discounts"
|
||
title="کد تخفیف"
|
||
>
|
||
<EventDiscountsPanel
|
||
isAdmin
|
||
eventId={eventId}
|
||
isEnded={isEventEnded(event.status, event.endsAt)}
|
||
isFree={event.isFree}
|
||
/>
|
||
</Tab>
|
||
)}
|
||
|
||
<Tab
|
||
key="bookings"
|
||
title="رزروها"
|
||
>
|
||
<AdminEventBookingsTab
|
||
eventId={eventId}
|
||
eventStatus={event.status}
|
||
onCheckedIn={refreshInsights}
|
||
/>
|
||
</Tab>
|
||
|
||
<Tab
|
||
key="reviews"
|
||
title={texts.events.reviews}
|
||
>
|
||
<AdminEventReviewsTab eventId={eventId} />
|
||
</Tab>
|
||
|
||
<Tab
|
||
key="guest-lists"
|
||
title="مهمانان ویژه"
|
||
>
|
||
<EventGuestListAccessPanel eventId={eventId} />
|
||
</Tab>
|
||
</AppTabs>
|
||
|
||
<AdminEventDetailsSection
|
||
categoryName={categoryName}
|
||
cityName={cityName}
|
||
event={event}
|
||
faqs={faqs}
|
||
media={media}
|
||
provinceName={provinceName}
|
||
onEditCommission={() => {
|
||
setHasOpenedCommissionModal(true)
|
||
setIsCommissionModalOpen(true)
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{hasOpenedRejectModal ? (
|
||
<AdminEventRejectModal
|
||
eventId={eventId}
|
||
isOpen={isRejectModalOpen}
|
||
onOpenChange={setIsRejectModalOpen}
|
||
onRejected={mergeEvent}
|
||
/>
|
||
) : null}
|
||
|
||
{hasOpenedCommissionModal ? (
|
||
<AdminEventCommissionModal
|
||
commissionPercent={event?.commissionPercent}
|
||
eventId={eventId}
|
||
isOpen={isCommissionModalOpen}
|
||
onOpenChange={setIsCommissionModalOpen}
|
||
onUpdated={mergeEvent}
|
||
/>
|
||
) : null}
|
||
|
||
{hasOpenedRevisionRejectModal ? (
|
||
<AdminEventRevisionRejectModal
|
||
isLoading={pendingId === 'reject-revision'}
|
||
isOpen={isRevisionRejectModalOpen}
|
||
onOpenChange={setIsRevisionRejectModalOpen}
|
||
onReject={handleRejectRevision}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default AdminEventDetail
|