admin/components/events/create/EventCreateWizard.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

413 lines
13 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,
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>
)
}