'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(null) const { user } = useAuth() const userId = user?.userId const [step, setStep] = useState(1) const [data, setData] = useState(INITIAL_WIZARD_DATA) const [isDraftHydrated, setIsDraftHydrated] = useState(false) const [isSaving, setIsSaving] = useState(false) const [createdEventId, setCreatedEventId] = useState(null) const [previousAttendees, setPreviousAttendees] = useState([]) 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([]) const [hostCityName, setHostCityName] = useState() useEffect(() => { if (!userId) return let cancelled = false const cloneFromId = cloneFromIdRef.current const resolveHostCity = async (): Promise => { 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(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) => { 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 (
) } return (
{step === 1 && ( { patchData(patch) setStep(2) }} /> )} {step === 2 && ( { patchData(patch) setStep(3) }} /> )} {step === 3 && ( { patchData(patch) setStep(4) }} /> )} {step === 4 && ( void handleSaveDraft()} /> )}
: } 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} />
{createdEventId ? ( { router.push(finishRoute) }} onOpenChange={setShowPostCreateInvite} /> ) : null}
) }