admin/components/events/create/consumer/ConsumerEventCreateWizard.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

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>
)
}