diff --git a/src/modules/events/components/CreateEventDialog.vue b/src/modules/events/components/CreateEventDialog.vue index ad8d9c5..76a476a 100644 --- a/src/modules/events/components/CreateEventDialog.vue +++ b/src/modules/events/components/CreateEventDialog.vue @@ -428,34 +428,13 @@ watch( }, ); -const { resetForm } = form; - -/** Field name -> what the organiser calls it, for the invalid-submit toast. */ -const FIELD_LABELS: Record = { - name: "Title", - info: "Description", - event_start_date: "Start date", - event_start_time: "Start time", - event_end_date: "End date", - event_end_time: "End time", - location: "Location", - currency: "Price currency", - payment_methods: "Payment methods", - fiat_currency: "Fiat currency", - amount_tickets: "Tickets", - price_per_ticket: "Price", - notification_subject: "Subject", - notification_body: "Body", -}; - -/** - * Open whichever collapsible section holds a problem, so the reason is on - * screen rather than hidden behind a closed panel. - */ -function revealSectionFor(field: string) { - if (field === "promo_codes") promoOpen.value = true; - if (field === "ticket_waves") wavesOpen.value = true; -} +const { resetForm, meta } = form; +const isFormValid = computed( + () => + meta.value.valid && + !hasPromoErrors(promoErrors.value) && + !hasWaveErrors.value, +); const isLoading = ref(false); const today = computed(() => format(new Date(), "yyyy-MM-dd")); @@ -469,200 +448,160 @@ function toggleCategory(cat: string) { } } -const onSubmit = form.handleSubmit( - async (formValues) => { - // Promo codes and ticket waves are not part of the Zod schema, so - // vee-validate cannot block on them. Check here and OPEN the section - // that holds the problem — both editors live inside collapsibles, and - // an error rendered inside a closed one is invisible. - if (hasWaveErrors.value) { - revealSectionFor("ticket_waves"); - toastService.error("Check the ticket waves below"); - return; - } - if (hasPromoErrors(promoErrors.value)) { - revealSectionFor("promo_codes"); - toastService.error("Check the promo codes below"); - return; +const onSubmit = form.handleSubmit(async (formValues) => { + if (!isFormValid.value) return; + + if (!paymentService) { + toastService.error("Payment service not available"); + return; + } + + const paymentSvc = paymentService as any; + const preferredWallet = paymentSvc?.getPreferredWallet?.(); + if (!preferredWallet) { + toastService.error("No wallet available. Please connect a wallet first."); + return; + } + + isLoading.value = true; + try { + const eventData: CreateEventRequest = { + name: formValues.name, + event_start_date: withLocalTzOffset( + foldDateTime(formValues.event_start_date, formValues.event_start_time), + ), + }; + 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; } - if (!paymentService) { - toastService.error("Payment service not available"); - return; + // Optional fields — only include if provided + if (formValues.info) eventData.info = formValues.info; + if (formValues.event_end_date) { + eventData.event_end_date = withLocalTzOffset( + foldDateTime(formValues.event_end_date, formValues.event_end_time), + ); } - - const paymentSvc = paymentService as any; - const preferredWallet = paymentSvc?.getPreferredWallet?.(); - if (!preferredWallet) { - toastService.error("No wallet available. Please connect a wallet first."); - return; + 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; + // allow_fiat always sends (derived from the rail list) so a + // true→false flip propagates on edit; fiat_currency only sends when + // fiat is on (no point persisting a rail-currency the backend won't use). + const acceptsFiat = formValues.payment_methods.includes("fiat"); + eventData.allow_fiat = acceptsFiat; + if (acceptsFiat && formValues.fiat_currency) { + eventData.fiat_currency = formValues.fiat_currency; + } + if (formValues.amount_tickets !== undefined) + 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; - isLoading.value = true; - try { - const eventData: CreateEventRequest = { - name: formValues.name, - event_start_date: withLocalTzOffset( - foldDateTime( - formValues.event_start_date, - formValues.event_start_time, - ), - ), - }; - 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; + // Notification config + promo codes go inside the `extra` envelope. + // On edit overlay onto the existing event.extra so fields only the + // LNbits admin UI sets (conditional, min_tickets) survive the + // round-trip. promo_codes is always sent explicitly: what the + // organizer sees in the editor is the truth, an empty list clears. + // Ticket waves. The price / capacity / currency fields above ARE the + // primary wave, so they have to be written INTO it: the backend + // derives the event-level values FROM the waves on every write + // (`sync_event_ticket_waves`), so sending a changed amount_tickets + // beside an unchanged wave list leaves the wave's old number winning + // and the organizer's edit is silently discarded. + // Seeded so a NEW event's implied primary wave matches what the + // backend would synthesize. `create_event` stamps `time` and derives + // the wave window from it plus the closing/end/start dates; without + // the same inputs here the wave lands with empty date strings, which + // the backend's date parse throws on for every later read. + const storedWaves = ensureTicketWaves({ + ...(props.event ?? {}), + time: props.event?.time ?? new Date().toISOString(), + event_start_date: eventData.event_start_date, + event_end_date: eventData.event_end_date ?? null, + closing_date: props.event?.closing_date ?? null, + extra: props.event?.extra ?? null, + }); + const [primaryWave] = applyFormToPrimaryWave(storedWaves, { + closing_date: eventData.closing_date, + currency: formValues.currency, + allow_fiat: formValues.payment_methods.includes("fiat"), + fiat_currency: formValues.fiat_currency, + amount_tickets: formValues.amount_tickets, + price_per_ticket: formValues.price_per_ticket ?? 0, + }); + + // Notification config + promo codes go inside the `extra` envelope. + // On edit overlay onto the existing event.extra so fields only the + // LNbits admin UI sets (conditional, min_tickets) survive the + // round-trip. promo_codes is always sent explicitly: what the + // organizer sees in the editor is the truth, an empty list clears. + eventData.extra = { + ...(props.event?.extra ?? {}), + payment_methods: formValues.payment_methods, + email_notifications: formValues.email_notifications, + nostr_notifications: formValues.nostr_notifications, + notification_subject: formValues.notification_subject, + notification_body: formValues.notification_body, + promo_codes: toWirePromoCodes(promoRows.value), + ticket_waves: [primaryWave, ...extraWaves.value], + }; + + if (isEditMode.value) { + if (!props.onUpdateEvent || !props.event?.id) { + toastService.error("Update handler missing"); + return; } - - // Optional fields — only include if provided - if (formValues.info) eventData.info = formValues.info; - if (formValues.event_end_date) { - eventData.event_end_date = withLocalTzOffset( - foldDateTime(formValues.event_end_date, formValues.event_end_time), - ); + await props.onUpdateEvent(props.event.id, eventData); + toastService.success( + willLandInPending.value + ? "Updated — awaiting re-approval. Hidden from the public feed until reviewed." + : "Event updated!", + ); + emit("event-updated"); + } else { + if (!props.onCreateEvent) { + toastService.error("Create handler missing"); + return; } - 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; - // allow_fiat always sends (derived from the rail list) so a - // true→false flip propagates on edit; fiat_currency only sends when - // fiat is on (no point persisting a rail-currency the backend won't use). - const acceptsFiat = formValues.payment_methods.includes("fiat"); - eventData.allow_fiat = acceptsFiat; - if (acceptsFiat && formValues.fiat_currency) { - eventData.fiat_currency = formValues.fiat_currency; - } - if (formValues.amount_tickets !== undefined) - 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; - - // Notification config + promo codes go inside the `extra` envelope. - // On edit overlay onto the existing event.extra so fields only the - // LNbits admin UI sets (conditional, min_tickets) survive the - // round-trip. promo_codes is always sent explicitly: what the - // organizer sees in the editor is the truth, an empty list clears. - // Ticket waves. The price / capacity / currency fields above ARE the - // primary wave, so they have to be written INTO it: the backend - // derives the event-level values FROM the waves on every write - // (`sync_event_ticket_waves`), so sending a changed amount_tickets - // beside an unchanged wave list leaves the wave's old number winning - // and the organizer's edit is silently discarded. - // Seeded so a NEW event's implied primary wave matches what the - // backend would synthesize. `create_event` stamps `time` and derives - // the wave window from it plus the closing/end/start dates; without - // the same inputs here the wave lands with empty date strings, which - // the backend's date parse throws on for every later read. - const storedWaves = ensureTicketWaves({ - ...(props.event ?? {}), - time: props.event?.time ?? new Date().toISOString(), - event_start_date: eventData.event_start_date, - event_end_date: eventData.event_end_date ?? null, - closing_date: props.event?.closing_date ?? null, - extra: props.event?.extra ?? null, - }); - const [primaryWave] = applyFormToPrimaryWave(storedWaves, { - closing_date: eventData.closing_date, - currency: formValues.currency, - allow_fiat: formValues.payment_methods.includes("fiat"), - fiat_currency: formValues.fiat_currency, - amount_tickets: formValues.amount_tickets, - price_per_ticket: formValues.price_per_ticket ?? 0, - }); - - // Notification config + promo codes go inside the `extra` envelope. - // On edit overlay onto the existing event.extra so fields only the - // LNbits admin UI sets (conditional, min_tickets) survive the - // round-trip. promo_codes is always sent explicitly: what the - // organizer sees in the editor is the truth, an empty list clears. - eventData.extra = { - ...(props.event?.extra ?? {}), - payment_methods: formValues.payment_methods, - email_notifications: formValues.email_notifications, - nostr_notifications: formValues.nostr_notifications, - notification_subject: formValues.notification_subject, - notification_body: formValues.notification_body, - promo_codes: toWirePromoCodes(promoRows.value), - ticket_waves: [primaryWave, ...extraWaves.value], - }; - - if (isEditMode.value) { - if (!props.onUpdateEvent || !props.event?.id) { - toastService.error("Update handler missing"); - return; - } - await props.onUpdateEvent(props.event.id, eventData); - toastService.success( - willLandInPending.value - ? "Updated — awaiting re-approval. Hidden from the public feed until reviewed." - : "Event updated!", - ); - emit("event-updated"); - } else { - if (!props.onCreateEvent) { - toastService.error("Create handler missing"); - return; - } - await props.onCreateEvent(eventData); - toastService.success( - willLandInPending.value - ? "Submitted! Awaiting admin approval — your draft is visible on your feed with a Pending badge." - : "Event submitted!", - ); - emit("event-created"); - } - - resetForm(); - selectedCategories.value = []; - promoRows.value = []; - extraWaves.value = []; - knownWaveIds.value = []; - bannerImages.value = []; - emit("update:open", false); - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : isEditMode.value - ? "Failed to update event" - : "Failed to create event"; - toastService.error(errorMessage); - } finally { - isLoading.value = false; + await props.onCreateEvent(eventData); + toastService.success( + willLandInPending.value + ? "Submitted! Awaiting admin approval — your draft is visible on your feed with a Pending badge." + : "Event submitted!", + ); + emit("event-created"); } - }, - // vee-validate blocks the submit when the schema fails. Say WHY: a field - // whose FormMessage sits inside a closed section — or is not rendered at - // all — otherwise leaves a dead button and no explanation, which is the - // dead end disabling the button used to create. - ({ errors }) => { - const fields = Object.keys(errors); - fields.forEach(revealSectionFor); - const named = fields.map((f) => FIELD_LABELS[f] ?? f); - toastService.error( - named.length - ? `Check ${named.join(", ")}` - : "Some details still need attention", - ); - const first = fields[0]; - if (first) { - document - .querySelector(`[name="${first}"], #${CSS.escape(first)}`) - ?.scrollIntoView({ behavior: "smooth", block: "center" }); - } - }, -); + + resetForm(); + selectedCategories.value = []; + promoRows.value = []; + extraWaves.value = []; + knownWaveIds.value = []; + bannerImages.value = []; + emit("update:open", false); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : isEditMode.value + ? "Failed to update event" + : "Failed to create event"; + toastService.error(errorMessage); + } finally { + isLoading.value = false; + } +}); const handleOpenChange = (open: boolean) => { if (!open && !isLoading.value) { @@ -1237,17 +1176,10 @@ const handleOpenChange = (open: boolean) => { > Cancel -