415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import dynamic from 'next/dynamic'
|
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|
|
|
import { texts } from '@/texts'
|
|
import type { PreviousAttendee } from '@/services/events'
|
|
import { addToast } from '@/lib/toast'
|
|
import WizardStepper from '@/components/events/create/WizardStepper'
|
|
import Button from '@/components/formElements/Button'
|
|
import { mapEventToWizardCloneData } from '@/components/events/create/cloneFromEvent'
|
|
import { DetailSkeleton } from '@/components/feedback/LoadingState'
|
|
import {
|
|
combineDateAndMinutes,
|
|
hasRequiredPosters,
|
|
INITIAL_WIZARD_DATA,
|
|
WIZARD_STEPS,
|
|
type EventWizardFormData,
|
|
} from '@/components/events/create/types'
|
|
import { APP_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'
|
|
|
|
const Step1BasicInfo = dynamic(() => import('@/components/events/create/steps/Step1BasicInfo'), {
|
|
ssr: false,
|
|
loading: () => <DetailSkeleton />,
|
|
})
|
|
const Step2ScheduleLocation = dynamic(() => import('@/components/events/create/steps/Step2ScheduleLocation'), {
|
|
ssr: false,
|
|
loading: () => <DetailSkeleton />,
|
|
})
|
|
const Step3PricingCapacity = dynamic(() => import('@/components/events/create/steps/Step3PricingCapacity'), {
|
|
ssr: false,
|
|
loading: () => <DetailSkeleton />,
|
|
})
|
|
const Step4FaqPreview = dynamic(() => import('@/components/events/create/steps/Step4FaqPreview'), {
|
|
ssr: false,
|
|
loading: () => <DetailSkeleton />,
|
|
})
|
|
const Step5GuestList = dynamic(() => import('@/components/events/create/steps/Step5GuestList'), {
|
|
ssr: false,
|
|
})
|
|
|
|
interface EventCreateWizardProps {
|
|
finishRoute?: string
|
|
}
|
|
|
|
export default function EventCreateWizard({ finishRoute = APP_ROUTES.MANAGE_EVENTS }: EventCreateWizardProps = {}) {
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
const searchParams = useSearchParams()
|
|
const cloneFromIdRef = useRef(searchParams.get('cloneFrom'))
|
|
const hasTrackedStartRef = useRef(false)
|
|
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 [wasDraftRestored, setWasDraftRestored] = useState(false)
|
|
const [wasCloneLoaded, setWasCloneLoaded] = 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: 'admin',
|
|
is_clone: Boolean(cloneFromIdRef.current),
|
|
})
|
|
}, [userId])
|
|
|
|
// Shared hook — same key/staleTime as Step1BasicInfo + CategoryBrowse.
|
|
const categoriesQuery = usePublicCategoriesQuery()
|
|
const categories = useMemo(() => categoriesQuery.data?.items ?? [], [categoriesQuery.data])
|
|
const [cities, setCities] = useState<City[]>([])
|
|
|
|
useEffect(() => {
|
|
if (!userId) return
|
|
|
|
let cancelled = false
|
|
const cloneFromId = cloneFromIdRef.current
|
|
|
|
const hydrate = async () => {
|
|
if (cloneFromId) {
|
|
try {
|
|
const [event, media, faqs] = await Promise.all([
|
|
fetchEventForEdit(cloneFromId),
|
|
fetchEventMedia(cloneFromId).catch(() => []),
|
|
fetchEventFaqs(cloneFromId).catch(() => []),
|
|
])
|
|
|
|
if (cancelled) return
|
|
|
|
clearEventDraft(userId)
|
|
setData(mapEventToWizardCloneData(event, media, faqs))
|
|
setStep(1)
|
|
setWasCloneLoaded(true)
|
|
setWasDraftRestored(false)
|
|
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({ ...INITIAL_WIZARD_DATA, ...stored.data })
|
|
setStep(stored.step)
|
|
setWasDraftRestored(true)
|
|
setIsDraftHydrated(true)
|
|
|
|
return
|
|
}
|
|
|
|
// Fresh create: prefill host default venue from profile contact info.
|
|
try {
|
|
const [meResult, cityRows] = await Promise.all([GET_ME(), fetchAllCities()])
|
|
|
|
if (cancelled) return
|
|
|
|
setCities(cityRows)
|
|
|
|
if (meResult.ok) {
|
|
const city = meResult.data.cityId ? cityRows.find((row) => row.id === meResult.data.cityId) : undefined
|
|
|
|
setData({
|
|
...INITIAL_WIZARD_DATA,
|
|
...(meResult.data.defaultAddress ? { address: meResult.data.defaultAddress } : {}),
|
|
...(city ? { cityId: String(city.id), provinceId: String(city.provinceId) } : {}),
|
|
})
|
|
}
|
|
} catch {
|
|
// Prefill is best-effort; wizard still opens with empty location.
|
|
}
|
|
|
|
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])
|
|
|
|
useEffect(() => {
|
|
// دریافت استانها فعلاً غیرفعال است؛ نام شهر مستقیماً از فهرست شهرها خوانده میشود.
|
|
// void fetchProvinces().then(setProvinces)
|
|
void fetchAllCities().then(setCities)
|
|
}, [])
|
|
|
|
const patchData = useCallback((patch: Partial<EventWizardFormData>) => {
|
|
setData((prev) => ({ ...prev, ...patch }))
|
|
}, [])
|
|
|
|
const categoryName = useMemo(() => categories.find((item) => String(item.id) === data.categoryId)?.name, [categories, data.categoryId])
|
|
|
|
// نام استان فعلاً در پیشنمایش رویداد نمایش داده نمیشود.
|
|
// const provinceName = useMemo(() => provinces.find((item) => String(item.id) === data.provinceId)?.name, [provinces, data.provinceId])
|
|
|
|
const cityName = useMemo(() => cities.find((item) => String(item.id) === data.cityId)?.name, [cities, data.cityId])
|
|
|
|
const currentStepTitle = WIZARD_STEPS.find((item) => item.key === step)?.label
|
|
|
|
const handleSaveDraft = async () => {
|
|
if (!hasRequiredPosters(data.media)) {
|
|
addToast({
|
|
title: texts.events.postersRequiredTitle,
|
|
description: texts.events.postersRequiredDescription,
|
|
color: 'danger',
|
|
})
|
|
setStep(1)
|
|
|
|
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: Number(data.provinceId),
|
|
cityId: Number(data.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,
|
|
cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
|
|
cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
|
|
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 { loadPreviousAttendeesForStep5 } = await import('@/components/events/create/steps/Step5GuestList')
|
|
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(APP_ROUTES.MANAGE_EVENT_DETAIL(eventId))
|
|
|
|
return
|
|
}
|
|
|
|
addToast({
|
|
title: texts.events.draftSaveFailed,
|
|
description: detail ?? undefined,
|
|
color: 'danger',
|
|
})
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
if (!userId || !isDraftHydrated) return <DetailSkeleton />
|
|
|
|
return (
|
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
|
<div className="flex flex-col gap-2 rounded-xl border border-primary-100 bg-primary-50 px-3 py-2 text-xs text-primary sm:flex-row sm:items-center sm:justify-between">
|
|
<span>
|
|
{wasCloneLoaded
|
|
? texts.events.wizardCloneHint
|
|
: wasDraftRestored
|
|
? texts.events.wizardDraftRestoredHint
|
|
: texts.events.wizardAutosaveHint}
|
|
</span>
|
|
<Button
|
|
className="text-xs text-secondary-20"
|
|
size="sm"
|
|
variant="light"
|
|
onClick={() => {
|
|
if (userId) clearEventDraft(userId)
|
|
setData({ ...INITIAL_WIZARD_DATA, media: [], faqs: [] })
|
|
setStep(1)
|
|
setWasDraftRestored(false)
|
|
setWasCloneLoaded(false)
|
|
setCreatedEventId(null)
|
|
setShowPostCreateInvite(false)
|
|
}}
|
|
>
|
|
{texts.events.startOver}
|
|
</Button>
|
|
</div>
|
|
|
|
<WizardStepper currentStep={step} />
|
|
<div>
|
|
{currentStepTitle ? <h2 className="mb-6 text-lg font-semibold text-text-dark">{currentStepTitle}</h2> : null}
|
|
{step === 1 && (
|
|
<Step1BasicInfo
|
|
data={data}
|
|
onChange={patchData}
|
|
onNext={(patch) => {
|
|
patchData(patch)
|
|
setStep(2)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<Step2ScheduleLocation
|
|
data={data}
|
|
onBack={() => {
|
|
setStep(1)
|
|
}}
|
|
onChange={patchData}
|
|
onNext={(patch) => {
|
|
patchData(patch)
|
|
setStep(3)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<Step3PricingCapacity
|
|
data={data}
|
|
onBack={() => {
|
|
setStep(2)
|
|
}}
|
|
onChange={patchData}
|
|
onNext={(patch) => {
|
|
patchData(patch)
|
|
setStep(4)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{step === 4 && (
|
|
<Step4FaqPreview
|
|
categoryName={categoryName}
|
|
cityName={cityName}
|
|
data={data}
|
|
isSaving={isSaving}
|
|
/* استان فعلاً در پیشنمایش نمایش داده نمیشود. */
|
|
/* provinceName={provinceName} */
|
|
onBack={() => {
|
|
setStep(3)
|
|
}}
|
|
onChange={patchData}
|
|
onSaveDraft={() => void handleSaveDraft()}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{createdEventId ? (
|
|
<Step5GuestList
|
|
attendees={previousAttendees}
|
|
eventId={createdEventId}
|
|
isOpen={showPostCreateInvite}
|
|
onFinish={() => {
|
|
router.push(finishRoute)
|
|
}}
|
|
onOpenChange={setShowPostCreateInvite}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|