'use client' import { useEffect, useMemo, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { useRouter } from 'next/navigation' import dynamic from 'next/dynamic' import { texts, format } from '@/texts' import type { CreateEventPayload, CreatedEvent } from '@/services/events' import Button from '@/components/formElements/Button' import Input from '@/components/formElements/Input' import CategoryTreeSelect from '@/components/events/create/CategoryTreeSelect' import EventMediaGalleryUploader from '@/components/events/create/EventMediaGalleryUploader' import FaqEditor from '@/components/events/create/FaqEditor' import MapLocationPicker from '@/components/events/create/MapLocationPicker' import { WizardFormFullWidth, WizardFormGrid } from '@/components/events/create/WizardFormLayout' import type { FaqItem, StagedMediaItem } from '@/components/events/create/types' import { combineDateAndMinutes, hasRequiredPosters } from '@/components/events/create/types' import { DetailSkeleton } from '@/components/feedback/LoadingState' import InlineNotice from '@/components/feedback/InlineNotice' import { AdminFormActions, AdminFormSection } from '@/components/forms/AdminFormLayout' import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator' import { useEventScheduleConstraints } from '@/components/events/schedule/eventScheduleConstraints' import { isEventSlugLocked, isFieldLocked, resolveEventEditPolicy } from '@/features/events/eventEditPolicy' import { syncEventFaqs, syncEventMedia } from '@/features/events/edit/syncEventExtras' import { fetchEventCategories, fetchEventForEdit, updateEventAsAdmin, type EventCategory } from '@/services/events' import { fetchEventFaqs, fetchEventMedia, type EventFaq, type EventMedia } from '@/services/eventDetail' import { fetchAllCities, type City } from '@/services/geography' import { extractServerErrorDetail } from '@/services/errorHandler' import { addToast } from '@/lib/toast' import { showFormValidationToast } from '@/lib/formValidationToast' import { AGE_RESTRICTION_OPTIONS, GENDER_RESTRICTION_OPTIONS } from '@/lib/eventAudience' const TextEditor = dynamic(() => import('@/components/formElements/TextEditor'), { ssr: false }) interface EventEditFormValues { title: string slug: string categoryId: string shortDescription: string description: string startDate: string startTime: number endDate: string endTime: number cityId: string address: string lat: number lng: number isFree: boolean price: number capacity: number genderRestriction: 'open' | 'female_only' | 'male_only' ageRestriction: 'open' | 'age_15_19' | 'age_20_24' | 'age_25_plus' cancellationFeePercent: number cancellationFeePercent12To24Hours: number cancellationFeePercentMoreThan24Hours: number isDiscoverable: boolean autoCreateGroup: boolean addressVisibility: 'public' | 'attendees_only' generalArea: string waitlistAutoOffer: boolean sendReviewRequestSms: boolean } const ADDRESS_VISIBILITY_OPTIONS = [ { name: texts.events.addressVisibilityPublic, selectKey: 'public' }, { name: texts.events.addressVisibilityAttendeesOnly, selectKey: 'attendees_only' }, ] interface EventEditFormProps { eventId: string cancelHref: string successHref: string } function splitIsoToDateAndMinutes(iso: string): { date: string; minutes: number } { const d = new Date(iso) return { date: d.toISOString(), minutes: d.getHours() * 60 + d.getMinutes(), } } function eventToFormValues(event: CreatedEvent): EventEditFormValues { const start = splitIsoToDateAndMinutes(String(event.startsAt)) const end = splitIsoToDateAndMinutes(String(event.endsAt)) return { title: event.title, slug: event.slug, categoryId: String(event.categoryId), shortDescription: event.shortDescription ?? '', description: event.description ?? '', startDate: start.date, startTime: start.minutes, endDate: end.date, endTime: end.minutes, cityId: String(event.cityId), // The editing party (owner or admin) always gets the real address from // the backend (see EventsService.applyAddressVisibility) -- the `?? ` // fallbacks only satisfy the DTO's general nullable type. address: event.address ?? '', lat: event.lat ?? 35.6892, lng: event.lng ?? 51.389, isFree: event.isFree, price: event.price, capacity: event.capacity, genderRestriction: event.genderRestriction ?? 'open', ageRestriction: event.ageRestriction ?? 'open', cancellationFeePercent: event.cancellationFeePercent, cancellationFeePercent12To24Hours: event.cancellationFeePercent12To24Hours, cancellationFeePercentMoreThan24Hours: event.cancellationFeePercentMoreThan24Hours, isDiscoverable: event.settings.isDiscoverable, autoCreateGroup: event.settings.autoCreateGroup, addressVisibility: event.settings.addressVisibility, generalArea: event.settings.generalArea ?? '', waitlistAutoOffer: event.settings.waitlistAutoOffer, sendReviewRequestSms: event.settings.sendReviewRequestSms ?? false, } } const EventEditForm = ({ eventId, cancelHref, successHref }: EventEditFormProps) => { const router = useRouter() const FormActions = AdminFormActions const FormSection = AdminFormSection const [event, setEvent] = useState(null) const [categories, setCategories] = useState([]) const [cities, setCities] = useState([]) const [initialMedia, setInitialMedia] = useState([]) const [initialFaqs, setInitialFaqs] = useState([]) const [media, setMedia] = useState([]) const [faqs, setFaqs] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) const [loadError, setLoadError] = useState(null) const form = useForm() const isFree = form.watch('isFree') const lat = form.watch('lat') const lng = form.watch('lng') const addressVisibility = form.watch('addressVisibility') const startDate = form.watch('startDate') const startTime = form.watch('startTime') const endDate = form.watch('endDate') const { todayIso, startMinimumTime, endMinimumTime } = useEventScheduleConstraints(startDate, startTime, endDate) const policy = useMemo( () => (event ? resolveEventEditPolicy(event.status, event.bookedCount, event.reservedCapacity ?? 0) : null), [event] ) useEffect(() => { let cancelled = false const load = async () => { try { setIsLoading(true) setLoadError(null) const [loaded, cats, cityList, mediaItems, faqItems] = await Promise.all([ fetchEventForEdit(eventId), fetchEventCategories(), fetchAllCities(), fetchEventMedia(eventId).catch(() => [] as EventMedia[]), fetchEventFaqs(eventId).catch(() => [] as EventFaq[]), ]) if (cancelled) return setEvent(loaded) setCategories(cats) setCities(cityList) setInitialMedia(mediaItems) setInitialFaqs(faqItems) setMedia( mediaItems .filter((item) => item.mediaType === 'image') .map((item, index) => ({ id: item.id, url: item.url, isPoster: item.isPoster, isSquarePoster: Boolean(item.isSquarePoster), sortOrder: item.sortOrder ?? index, })) ) setFaqs( faqItems.map((faq) => ({ id: faq.id, question: faq.question, answer: faq.answer, })) ) form.reset(eventToFormValues(loaded)) } catch { if (!cancelled) setLoadError(texts.events.eventLoadFailed) } finally { if (!cancelled) setIsLoading(false) } } void load() return () => { cancelled = true } }, [eventId, form]) const scheduleLocked = policy ? isFieldLocked(policy, 'startsAt') : false const locationLocked = policy ? isFieldLocked(policy, 'address') : false const priceLocked = policy ? isFieldLocked(policy, 'price') : false const cancellationFeeLocked = policy ? isFieldLocked(policy, 'cancellationFeePercent') : false const audienceLocked = policy ? isFieldLocked(policy, 'genderRestriction') : false const behavioralLocked = policy?.lockBehavioralSettings ?? false const slugLocked = event ? isEventSlugLocked(event.status) : false const handleSubmit = form.handleSubmit(async (values) => { if (!event || !policy?.canEdit) return try { const startsAt = combineDateAndMinutes(values.startDate, values.startTime) const endsAt = combineDateAndMinutes(values.endDate, values.endTime) if (new Date(endsAt) <= new Date(startsAt)) { addToast({ title: texts.validation.eventWizard.endAfterStart, color: 'danger' }) return } if (!hasRequiredPosters(media)) { addToast({ title: texts.events.postersRequiredTitle, description: texts.events.postersRequiredDescription, color: 'danger', }) return } const selectedCity = cities.find((city) => String(city.id) === values.cityId) if (!selectedCity) { addToast({ title: texts.validation.cityRequired, color: 'danger' }) return } if (policy && values.capacity < policy.minCapacity) { addToast({ title: format(texts.events.capacityBelowOccupied, { min: policy.minCapacity }), color: 'danger', }) return } if ( values.cancellationFeePercent < values.cancellationFeePercent12To24Hours || values.cancellationFeePercent12To24Hours < values.cancellationFeePercentMoreThan24Hours ) { addToast({ title: texts.validation.eventWizard.cancelPenaltyOrder, color: 'danger' }) return } const price = values.isFree ? 0 : Number(values.price) const payload: Partial = { title: values.title.trim(), categoryId: Number(values.categoryId), shortDescription: values.shortDescription.trim() || undefined, description: values.description || undefined, capacity: Number(values.capacity), } if (!behavioralLocked) { payload.settings = { isDiscoverable: values.isDiscoverable, autoCreateGroup: values.autoCreateGroup, waitlistAutoOffer: values.waitlistAutoOffer, sendReviewRequestSms: values.sendReviewRequestSms, } } if (!slugLocked) { payload.slug = values.slug.trim() } if (!scheduleLocked) { payload.startsAt = startsAt payload.endsAt = endsAt } if (!locationLocked) { payload.provinceId = selectedCity.provinceId payload.cityId = Number(values.cityId) payload.address = values.address.trim() payload.lat = values.lat payload.lng = values.lng payload.settings = { ...payload.settings, addressVisibility: values.addressVisibility, generalArea: values.addressVisibility === 'attendees_only' ? values.generalArea.trim() : undefined, } } if (!priceLocked) { payload.isFree = values.isFree payload.price = price } if (!cancellationFeeLocked) { payload.cancellationFeePercent = Number(values.cancellationFeePercent ?? 0) payload.cancellationFeePercent12To24Hours = Number(values.cancellationFeePercent12To24Hours ?? 0) payload.cancellationFeePercentMoreThan24Hours = Number(values.cancellationFeePercentMoreThan24Hours ?? 0) } if (!audienceLocked) { payload.genderRestriction = values.genderRestriction payload.ageRestriction = values.ageRestriction } setIsSaving(true) const updated = await updateEventAsAdmin(eventId, payload) await syncEventMedia(eventId, initialMedia, media) await syncEventFaqs(eventId, initialFaqs, faqs) setEvent(updated) addToast({ title: texts.events.eventUpdated, color: 'success' }) router.push(successHref) } catch (err) { const detail = extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data) addToast({ title: texts.events.eventUpdateFailed, description: detail ?? undefined, color: 'danger', }) } finally { setIsSaving(false) } }, showFormValidationToast) if (isLoading) return if (loadError || !event || !policy) { return

{loadError ?? texts.events.eventNotFound}

} if (!policy.canEdit) { return (

{format(texts.events.editStatusBlocked, { status: event.status })}

) } return (
void handleSubmit(e)} > {policy.hasActiveBookings ? ( {format(texts.events.editLockedWithBookings, { count: event.bookedCount })} ) : null}

{texts.common.description}

{ form.setValue('description', value, { shouldDirty: true }) }} />
{ form.setValue('address', address, { shouldDirty: true }) }} onChange={({ lat: nextLat, lng: nextLng }) => { form.setValue('lat', nextLat, { shouldDirty: true }) form.setValue('lng', nextLng, { shouldDirty: true }) }} /> {addressVisibility === 'attendees_only' && ( )}

{format(texts.events.reservedCapacityEditHint, { count: event.reservedCapacity ?? 0 })}

} >
) } export default EventEditForm