From cd35fae674821c90e651d67778cb03acba7b58e0 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 21 May 2026 12:29:49 +0200 Subject: [PATCH] feat(activities): dual-mode CreateEventDialog supports edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept optional `event` prop and `onUpdateEvent` handler. Dialog toggles title, description, submit button text, and a warning Alert based on edit mode plus an `isAdmin`/`autoApprove` pair the parent supplies. On open in edit mode, populate the form from the event — split stored "YYYY-MM-DD[THH:MM]" back into date+time inputs, restore categories, and seed bannerImages from the stored URL by extracting the pict-rs file ID (same pattern as market's CreateProductDialog). A clearing-the-banner action during edit sends `banner: null` so the backend wipes the field instead of keeping the old image. Auto-mirror watcher is guarded against firing during the initial population. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/CreateEventDialog.vue | 165 +++++++++++++++--- 1 file changed, 143 insertions(+), 22 deletions(-) diff --git a/src/modules/activities/components/CreateEventDialog.vue b/src/modules/activities/components/CreateEventDialog.vue index dd5ab5f..34477a2 100644 --- a/src/modules/activities/components/CreateEventDialog.vue +++ b/src/modules/activities/components/CreateEventDialog.vue @@ -32,28 +32,45 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Calendar, Loader2, MapPin } from 'lucide-vue-next' +import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next' import { toastService } from '@/core/services/ToastService' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import ImageUpload from '@/modules/base/components/ImageUpload.vue' import DatePicker from '@/modules/base/components/DatePicker.vue' import TimePicker from '@/modules/base/components/TimePicker.vue' +import { Alert, AlertDescription } from '@/components/ui/alert' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { TicketApiService } from '../services/TicketApiService' -import type { CreateEventRequest } from '../types/ticket' +import type { CreateEventRequest, TicketedEvent } from '../types/ticket' import { ALL_CATEGORIES } from '../types/category' interface Props { open: boolean - onCreateEvent: (eventData: CreateEventRequest) => Promise + /** When set, dialog opens in edit mode for this event. */ + event?: TicketedEvent | null + /** Create handler. Required when not editing. */ + onCreateEvent?: (eventData: CreateEventRequest) => Promise + /** Update handler. Required when editing. */ + onUpdateEvent?: (eventId: string, eventData: CreateEventRequest) => Promise + /** Whether the current user is an LNbits admin. Drives the + * "edit will go back to pending approval" warning copy. */ + isAdmin?: boolean + /** Whether the events extension has auto_approve enabled. */ + autoApprove?: boolean } const props = defineProps() const emit = defineEmits<{ 'update:open': [value: boolean] 'event-created': [] + 'event-updated': [] }>() +const isEditMode = computed(() => Boolean(props.event?.id)) +const willGoToPending = computed( + () => isEditMode.value && !props.isAdmin && !props.autoApprove +) + const { t } = useI18n() // Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM") @@ -117,6 +134,18 @@ interface BannerImage extends UploadedImage { } const bannerImages = ref([]) +// Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM]" back +// into separate date + time pieces for the form inputs. +function splitDateTime(value: string | null | undefined): { date: string; time: string } { + if (!value) return { date: '', time: '' } + const [date, time = ''] = value.split('T') + return { date, time: time.slice(0, 5) } +} + +// When `true`, suppress the auto-mirror watcher so we don't clobber an +// edit-mode population with start-date side effects mid-setValues. +const isPopulating = ref(false) + // Auto-mirror end date to start: when the user picks a start date, // surface that same date in the end-date picker so a one-day event // requires no extra clicks. Don't overwrite an end date the user @@ -125,6 +154,7 @@ const bannerImages = ref([]) watch( () => form.values.event_start_date, (start, prev) => { + if (isPopulating.value) return if (!start) return const end = form.values.event_end_date if (!end || end < start || end === prev) { @@ -141,18 +171,61 @@ const availableCurrencies = ref(['sat']) const loadingCurrencies = ref(false) const selectedCategories = ref([]) -watch(() => props.open, async (isOpen) => { - if (isOpen && ticketApi && !loadingCurrencies.value) { - loadingCurrencies.value = true - try { - availableCurrencies.value = await ticketApi.getCurrencies() - } catch (error) { - console.warn('Failed to load currencies:', error) - } finally { - loadingCurrencies.value = false - } +function populateFromEvent(event: TicketedEvent) { + isPopulating.value = true + const start = splitDateTime(event.event_start_date) + const end = splitDateTime(event.event_end_date) + form.setValues({ + name: event.name, + info: event.info ?? '', + event_start_date: start.date, + event_start_time: start.time, + event_end_date: end.date, + event_end_time: end.time, + location: event.location ?? '', + currency: event.currency ?? 'sat', + amount_tickets: event.amount_tickets ?? 0, + price_per_ticket: event.price_per_ticket ?? 0, + }) + selectedCategories.value = [...(event.categories ?? [])] + if (event.banner) { + // Mirror the URL-to-alias bridge from market's CreateProductDialog + // so the renders the existing banner via its pict-rs + // file ID. delete_token is unknown for already-uploaded images, so + // removal just clears the slot client-side. + const url = event.banner + const alias = url.includes('/image/original/') + ? url.split('/image/original/')[1] + : url + bannerImages.value = [ + { alias, isPrimary: true, delete_token: '', details: {} as any }, + ] + } else { + bannerImages.value = [] } - if (!isOpen) { + // Release the watcher guard on the next tick so vee-validate's batched + // updates settle before user input can drive the auto-mirror. + setTimeout(() => { + isPopulating.value = false + }, 0) +} + +watch(() => props.open, async (isOpen) => { + if (isOpen) { + if (ticketApi && !loadingCurrencies.value) { + loadingCurrencies.value = true + try { + availableCurrencies.value = await ticketApi.getCurrencies() + } catch (error) { + console.warn('Failed to load currencies:', error) + } finally { + loadingCurrencies.value = false + } + } + if (props.event) { + populateFromEvent(props.event) + } + } else { selectedCategories.value = [] } }) @@ -195,7 +268,11 @@ const onSubmit = form.handleSubmit(async (formValues) => { formValues.event_start_date, formValues.event_start_time ), - wallet: preferredWallet.id, + } + if (!isEditMode.value) { + // Wallet binds at creation. The backend ignores the field on + // update so we leave it off the edit payload for clean wire. + eventData.wallet = preferredWallet.id } // Optional fields — only include if provided @@ -209,21 +286,49 @@ const onSubmit = form.handleSubmit(async (formValues) => { if (formValues.location) eventData.location = formValues.location if (bannerImages.value.length > 0) { eventData.banner = imageService.getImageUrl(bannerImages.value[0].alias) + } else if (isEditMode.value) { + // User cleared the banner during edit — propagate the null so the + // backend wipes the field instead of keeping the old image. + eventData.banner = null } if (formValues.currency) eventData.currency = formValues.currency if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value - await props.onCreateEvent(eventData) - toastService.success('Event submitted!') + if (isEditMode.value) { + if (!props.onUpdateEvent || !props.event?.id) { + toastService.error('Update handler missing') + return + } + await props.onUpdateEvent(props.event.id, eventData) + toastService.success( + willGoToPending.value + ? 'Event updated — pending re-approval' + : 'Event updated!' + ) + emit('event-updated') + } else { + if (!props.onCreateEvent) { + toastService.error('Create handler missing') + return + } + await props.onCreateEvent(eventData) + toastService.success('Event submitted!') + emit('event-created') + } + resetForm() selectedCategories.value = [] bannerImages.value = [] emit('update:open', false) - emit('event-created') } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to create event' + const errorMessage = + error instanceof Error + ? error.message + : isEditMode.value + ? 'Failed to update event' + : 'Failed to create event' toastService.error(errorMessage) } finally { isLoading.value = false @@ -246,15 +351,27 @@ const handleOpenChange = (open: boolean) => { - Create Event + {{ isEditMode ? 'Edit Event' : 'Create Event' }} - Only a title and start date are required. + {{ + isEditMode + ? 'Update event details. Tickets already sold are not affected.' + : 'Only a title and start date are required.' + }}
+ + + + Saving will resubmit for approval. The event will be removed + from public feeds until reviewed. + + + @@ -451,7 +568,11 @@ const handleOpenChange = (open: boolean) => {