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.
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={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
|