admin/features/events/detail/AdminEventDetail.tsx
alisaza 3b86c2c72f feat(api): add notifications endpoints for user notifications
Introduced new API endpoints for managing in-app notifications. The `/api/v1/notifications/me` endpoint retrieves a paginated list of notifications for the authenticated user, while the `/api/v1/notifications/me/unread-count` endpoint returns the count of unread notifications. Enhanced the OpenAPI documentation to reflect these changes, including detailed parameter descriptions and response schemas for better clarity and usability.
2026-09-13 13:04:02 +03:30

505 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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={handleApprove}
onCancel={handleCancel}
onComplete={handleComplete}
onDelete={handleDelete}
onForcePublish={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