Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|
|
|
import { texts } from '@/texts'
|
|
import type { PreviousAttendee } from '@/services/events'
|
|
import { addToast } from '@/lib/toast'
|
|
import ConsumerActionButtons from '@/components/consumer/ConsumerActionButtons'
|
|
import ConsumerWizardStepper from '@/components/events/create/consumer/ConsumerWizardStepper'
|
|
import Step1BasicInfo from '@/components/events/create/consumer/steps/Step1BasicInfo'
|
|
import Step2ScheduleLocation from '@/components/events/create/consumer/steps/Step2ScheduleLocation'
|
|
import Step3PricingCapacity from '@/components/events/create/consumer/steps/Step3PricingCapacity'
|
|
import Step4FaqPreview from '@/components/events/create/consumer/steps/Step4FaqPreview'
|
|
import Step5GuestList, { loadPreviousAttendeesForStep5 } from '@/components/events/create/consumer/steps/Step5GuestList'
|
|
import { mapEventToWizardCloneData } from '@/components/events/create/consumer/cloneFromEvent'
|
|
import { DetailSkeleton } from '@/components/feedback/LoadingState'
|
|
import {
|
|
combineDateAndMinutes,
|
|
hasRequiredPosters,
|
|
INITIAL_WIZARD_DATA,
|
|
type EventWizardFormData,
|
|
} from '@/components/events/create/consumer/types'
|
|
import { CONSUMER_ROUTES } from '@/constants/routes'
|
|
import { createEvent, createEventFaq, fetchEventForEdit } from '@/services/events'
|
|
import { fetchEventFaqs, fetchEventMedia } from '@/services/eventDetail'
|
|
import { fetchAllCities, type City } from '@/services/geography'
|
|
import { extractServerErrorDetail } from '@/services/errorHandler'
|
|
import { GET_ME } from '@/services/users'
|
|
import useAuth from '@/hooks/useAuth'
|
|
import { clearEventDraft, loadEventDraft, saveEventDraft } from '@/features/events/eventDraftStore'
|
|
import { usePublicCategoriesQuery } from '@/queries/consumer/usePublicCategoriesQuery'
|
|
import { ANALYTICS_EVENTS, trackAnalyticsEvent } from '@/lib/analytics'
|
|
import AngleLeftIcon from '@/components/icons/AngleLeftIcon'
|
|
import CloseLinearIcon from '@/components/icons/CloseLinearIcon'
|
|
|
|
interface HostCityPrefill {
|
|
cityId: string
|
|
provinceId: string
|
|
address?: string
|
|
lat?: number
|
|
lng?: number
|
|
}
|
|
|
|
interface ConsumerEventCreateWizardProps {
|
|
finishRoute?: string
|
|
}
|
|
|
|
export default function ConsumerEventCreateWizard({ finishRoute = CONSUMER_ROUTES.MY_EVENTS }: ConsumerEventCreateWizardProps = {}) {
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
const searchParams = useSearchParams()
|
|
const cloneFromIdRef = useRef(searchParams.get('cloneFrom'))
|
|
const hasTrackedStartRef = useRef(false)
|
|
const stepSubmitRef = useRef<(() => void) | null>(null)
|
|
const stepScrollRef = useRef<HTMLDivElement>(null)
|
|
const { user } = useAuth()
|
|
const userId = user?.userId
|
|
const [step, setStep] = useState(1)
|
|
const [data, setData] = useState<EventWizardFormData>(INITIAL_WIZARD_DATA)
|
|
const [isDraftHydrated, setIsDraftHydrated] = useState(false)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [createdEventId, setCreatedEventId] = useState<string | null>(null)
|
|
const [previousAttendees, setPreviousAttendees] = useState<PreviousAttendee[]>([])
|
|
const [showPostCreateInvite, setShowPostCreateInvite] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!userId || hasTrackedStartRef.current) return
|
|
|
|
hasTrackedStartRef.current = true
|
|
trackAnalyticsEvent(ANALYTICS_EVENTS.EVENT_CREATE_STARTED, {
|
|
creation_source: 'consumer',
|
|
is_clone: Boolean(cloneFromIdRef.current),
|
|
})
|
|
}, [userId])
|
|
|
|
const categoriesQuery = usePublicCategoriesQuery()
|
|
const categories = useMemo(() => categoriesQuery.data?.items ?? [], [categoriesQuery.data])
|
|
const [cities, setCities] = useState<City[]>([])
|
|
const [hostCityName, setHostCityName] = useState<string | undefined>()
|
|
|
|
useEffect(() => {
|
|
if (!userId) return
|
|
|
|
let cancelled = false
|
|
const cloneFromId = cloneFromIdRef.current
|
|
|
|
const resolveHostCity = async (): Promise<HostCityPrefill | null> => {
|
|
const [meResult, cityRows] = await Promise.all([GET_ME(), fetchAllCities()])
|
|
|
|
if (cancelled) return null
|
|
|
|
setCities(cityRows)
|
|
|
|
if (!meResult.ok || !meResult.data.cityId) return null
|
|
|
|
const city = cityRows.find((row) => row.id === meResult.data.cityId)
|
|
|
|
if (!city) return null
|
|
|
|
setHostCityName(city.name || meResult.data.cityName || undefined)
|
|
|
|
return {
|
|
cityId: String(city.id),
|
|
provinceId: String(city.provinceId),
|
|
address: meResult.data.defaultAddress || undefined,
|
|
lat: city.lat,
|
|
lng: city.lng,
|
|
}
|
|
}
|
|
|
|
const applyHostCity = (
|
|
base: EventWizardFormData,
|
|
hostCity: HostCityPrefill | null,
|
|
options?: { preferHostAddress?: boolean; preferHostMapCenter?: boolean }
|
|
): EventWizardFormData => {
|
|
if (!hostCity) return base
|
|
|
|
return {
|
|
...base,
|
|
cityId: hostCity.cityId,
|
|
provinceId: hostCity.provinceId,
|
|
...(options?.preferHostAddress && hostCity.address ? { address: hostCity.address } : {}),
|
|
...(options?.preferHostMapCenter && hostCity.lat != null && hostCity.lng != null ? { lat: hostCity.lat, lng: hostCity.lng } : {}),
|
|
}
|
|
}
|
|
|
|
const hydrate = async () => {
|
|
let hostCity: HostCityPrefill | null = null
|
|
|
|
try {
|
|
hostCity = await resolveHostCity()
|
|
} catch {
|
|
// Prefill is best-effort; wizard still opens without city until save guard.
|
|
}
|
|
|
|
if (cancelled) return
|
|
|
|
if (cloneFromId) {
|
|
try {
|
|
const [event, media, faqs] = await Promise.all([
|
|
fetchEventForEdit(cloneFromId),
|
|
fetchEventMedia(cloneFromId).catch(() => []),
|
|
fetchEventFaqs(cloneFromId).catch(() => []),
|
|
])
|
|
|
|
if (cancelled) return
|
|
|
|
clearEventDraft(userId)
|
|
setData(applyHostCity(mapEventToWizardCloneData(event, media, faqs), hostCity))
|
|
setStep(1)
|
|
setIsDraftHydrated(true)
|
|
router.replace(pathname)
|
|
|
|
return
|
|
} catch {
|
|
if (cancelled) return
|
|
addToast({ title: texts.events.wizardCloneLoadFailed, color: 'danger' })
|
|
}
|
|
}
|
|
|
|
const stored = loadEventDraft<EventWizardFormData>(userId)
|
|
|
|
if (stored) {
|
|
setData(applyHostCity({ ...INITIAL_WIZARD_DATA, ...stored.data }, hostCity))
|
|
setStep(stored.step)
|
|
setIsDraftHydrated(true)
|
|
|
|
return
|
|
}
|
|
|
|
setData(
|
|
applyHostCity(INITIAL_WIZARD_DATA, hostCity, {
|
|
preferHostAddress: true,
|
|
preferHostMapCenter: true,
|
|
})
|
|
)
|
|
setIsDraftHydrated(true)
|
|
}
|
|
|
|
void hydrate()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
// Hydrate once per userId. pathname/router are read from the render that starts hydration.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid re-cloning after URL cleanup
|
|
}, [userId])
|
|
|
|
useEffect(() => {
|
|
if (!userId || !isDraftHydrated || createdEventId) return
|
|
|
|
const timeout = window.setTimeout(() => {
|
|
saveEventDraft(userId, data, step)
|
|
}, 250)
|
|
|
|
return () => {
|
|
window.clearTimeout(timeout)
|
|
}
|
|
}, [createdEventId, data, isDraftHydrated, step, userId])
|
|
|
|
// کانتینر اسکرول والد است و با عوض شدن step unmount نمیشود؛ باید دستی به بالا برگردد
|
|
useEffect(() => {
|
|
const el = stepScrollRef.current
|
|
|
|
if (!el) return
|
|
el.scrollTop = 0
|
|
}, [step])
|
|
|
|
const patchData = useCallback((patch: Partial<EventWizardFormData>) => {
|
|
setData((prev) => ({ ...prev, ...patch }))
|
|
}, [])
|
|
|
|
const bindStepSubmit = useCallback((submit: (() => void) | null) => {
|
|
stepSubmitRef.current = submit
|
|
}, [])
|
|
|
|
const categoryName = useMemo(() => categories.find((item) => String(item.id) === data.categoryId)?.name, [categories, data.categoryId])
|
|
const cityName = useMemo(
|
|
() => hostCityName ?? cities.find((item) => String(item.id) === data.cityId)?.name,
|
|
[cities, data.cityId, hostCityName]
|
|
)
|
|
|
|
const submitCurrentStep = useCallback(() => {
|
|
stepSubmitRef.current?.()
|
|
}, [])
|
|
|
|
const handleSaveDraft = async () => {
|
|
if (isSaving) return
|
|
|
|
if (!hasRequiredPosters(data.media)) {
|
|
addToast({
|
|
title: texts.events.postersRequiredTitle,
|
|
description: texts.events.postersRequiredDescription,
|
|
color: 'danger',
|
|
})
|
|
setStep(1)
|
|
|
|
return
|
|
}
|
|
|
|
const cityId = Number(data.cityId)
|
|
const provinceId = Number(data.provinceId)
|
|
|
|
if (!Number.isFinite(cityId) || cityId < 1 || !Number.isFinite(provinceId) || provinceId < 1) {
|
|
addToast({ title: texts.events.hostCityMissing, color: 'danger' })
|
|
setStep(2)
|
|
|
|
return
|
|
}
|
|
|
|
setIsSaving(true)
|
|
|
|
let eventId: string | null = null
|
|
|
|
try {
|
|
const startsAt = combineDateAndMinutes(data.startDate, data.startTime)
|
|
const endsAt = combineDateAndMinutes(data.endDate, data.endTime)
|
|
|
|
if (new Date(endsAt) <= new Date(startsAt)) {
|
|
addToast({ title: texts.validation.eventWizard.endAfterStart, color: 'danger' })
|
|
|
|
return
|
|
}
|
|
|
|
const event = await createEvent({
|
|
title: data.title,
|
|
slug: data.slug,
|
|
categoryId: Number(data.categoryId),
|
|
shortDescription: data.shortDescription || undefined,
|
|
description: data.description || undefined,
|
|
startsAt,
|
|
endsAt,
|
|
provinceId,
|
|
cityId,
|
|
address: data.address,
|
|
lat: data.lat,
|
|
lng: data.lng,
|
|
isFree: data.isFree,
|
|
price: data.isFree ? 0 : data.price,
|
|
capacity: data.capacity,
|
|
reservedCapacity: data.reservedCapacity,
|
|
genderRestriction: data.genderRestriction,
|
|
ageRestriction: data.ageRestriction,
|
|
cancellationFeePercent: data.cancellationFeePercent,
|
|
settings: {
|
|
isDiscoverable: data.isDiscoverable,
|
|
autoCreateGroup: data.autoCreateGroup,
|
|
waitlistAutoOffer: data.waitlistAutoOffer,
|
|
sendReviewRequestSms: data.sendReviewRequestSms,
|
|
addressVisibility: data.addressVisibility,
|
|
generalArea: data.addressVisibility === 'attendees_only' ? data.generalArea : undefined,
|
|
},
|
|
media: data.media.map((item) => ({
|
|
mediaType: 'image' as const,
|
|
url: item.url,
|
|
sortOrder: item.sortOrder,
|
|
isPoster: item.isPoster,
|
|
isSquarePoster: item.isSquarePoster,
|
|
})),
|
|
})
|
|
|
|
eventId = event.id
|
|
setCreatedEventId(event.id)
|
|
if (userId) clearEventDraft(userId)
|
|
|
|
for (let index = 0; index < data.faqs.length; index += 1) {
|
|
const faq = data.faqs[index]
|
|
|
|
await createEventFaq(event.id, {
|
|
question: faq.question,
|
|
answer: faq.answer,
|
|
sortOrder: index,
|
|
})
|
|
}
|
|
|
|
addToast({ title: texts.events.draftSaved, color: 'success' })
|
|
|
|
const attendees = await loadPreviousAttendeesForStep5()
|
|
|
|
if (attendees.length === 0) {
|
|
router.push(finishRoute)
|
|
|
|
return
|
|
}
|
|
|
|
setPreviousAttendees(attendees)
|
|
setShowPostCreateInvite(true)
|
|
} catch (error) {
|
|
const detail = extractServerErrorDetail((error as { response?: { data?: unknown } })?.response?.data)
|
|
|
|
if (eventId) {
|
|
addToast({
|
|
title: texts.events.createdPartialTitle,
|
|
description: detail ?? texts.events.createdPartialDescription,
|
|
color: 'warning',
|
|
})
|
|
router.push(CONSUMER_ROUTES.EVENT_DETAIL(eventId))
|
|
|
|
return
|
|
}
|
|
|
|
addToast({
|
|
title: texts.events.draftSaveFailed,
|
|
description: detail ?? undefined,
|
|
color: 'danger',
|
|
})
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
if (!userId || !isDraftHydrated) {
|
|
return (
|
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
<DetailSkeleton />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
|
<div className="shrink-0">
|
|
<ConsumerWizardStepper currentStep={step} />
|
|
</div>
|
|
|
|
<div
|
|
ref={stepScrollRef}
|
|
className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain pt-4"
|
|
>
|
|
{step === 1 && (
|
|
<Step1BasicInfo
|
|
data={data}
|
|
onBindSubmit={bindStepSubmit}
|
|
onChange={patchData}
|
|
onNext={(patch) => {
|
|
patchData(patch)
|
|
setStep(2)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<Step2ScheduleLocation
|
|
data={data}
|
|
onBindSubmit={bindStepSubmit}
|
|
onChange={patchData}
|
|
onNext={(patch) => {
|
|
patchData(patch)
|
|
setStep(3)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<Step3PricingCapacity
|
|
data={data}
|
|
onBindSubmit={bindStepSubmit}
|
|
onChange={patchData}
|
|
onNext={(patch) => {
|
|
patchData(patch)
|
|
setStep(4)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{step === 4 && (
|
|
<Step4FaqPreview
|
|
categoryName={categoryName}
|
|
cityName={cityName}
|
|
data={data}
|
|
onBindSubmit={bindStepSubmit}
|
|
onChange={patchData}
|
|
onSaveDraft={() => void handleSaveDraft()}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="shrink-0">
|
|
<ConsumerActionButtons
|
|
cancelIcon={step === 1 ? <CloseLinearIcon className="size-3" /> : <AngleLeftIcon className="size-4" />}
|
|
cancelLabel={step === 1 ? texts.common.cancel : texts.events.return}
|
|
className="w-full rounded-consumer-modal bg-white p-4"
|
|
isPrimaryLoading={step === 4 && isSaving}
|
|
primaryLabel={step === 4 ? texts.events.saveDraft : texts.events.saveAndContinue}
|
|
primaryType="button"
|
|
onCancel={() => {
|
|
if (step === 1) {
|
|
router.push(finishRoute)
|
|
|
|
return
|
|
}
|
|
|
|
setStep((current) => current - 1)
|
|
}}
|
|
onPrimary={submitCurrentStep}
|
|
/>
|
|
</div>
|
|
|
|
{createdEventId ? (
|
|
<Step5GuestList
|
|
attendees={previousAttendees}
|
|
eventId={createdEventId}
|
|
isOpen={showPostCreateInvite}
|
|
onFinish={() => {
|
|
router.push(finishRoute)
|
|
}}
|
|
onOpenChange={setShowPostCreateInvite}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|