From 285ccc2fdbda61e2a5b2a20c7faad9d83bcc7cee Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 10:01:22 +0200 Subject: [PATCH] fix(events): let the event form submit and say what is wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Submit button was disabled on `meta.valid`, so an organiser with a fully filled form could be left staring at a dead button with nothing on screen explaining it. Reported from demo: title, dates, price, capacity, payment methods and three named ticket waves all filled, no error text anywhere, submit greyed. Gating on `meta.valid` is a known vee-validate foot-gun: - disabled fields count against form validity, and the maintainer removed `disabled` from `` precisely because consistent behaviour was unachievable. This dialog disables inputs while loading and renders `fiat_currency` conditionally, so it is exposed. - `meta.valid` is documented to go stale — false with zero errors after a form is re-shown (logaretm/vee-validate#4630). - it is false before the form has ever validated, which is why the docs suggest `valid && dirty` rather than `valid` alone. A flag that is unreliable in both directions should not be the only thing between an organiser and their event. So the button is now disabled only while a submit is genuinely in flight, and the reasons come to the user instead: - `handleSubmit`'s invalid callback names the offending fields in a toast ("Check Tickets, Start date, Title"), opens whichever collapsible holds one, and scrolls the first into view. Previously a FormMessage inside a closed section was invisible. - promo codes and ticket waves are not in the Zod schema, so vee-validate cannot block on them; they are checked in the submit handler, which opens their section rather than failing silently. Both editors live inside collapsibles, so this was the same trap. Verified in a browser: a blank form now has an enabled button, and clicking it reports "Check Tickets, Start date, Title". Refs aiolabs/events ticket-wave work; the report came from testing #176 on demo. --- .../events/components/CreateEventDialog.vue | 378 +++++++++++------- 1 file changed, 223 insertions(+), 155 deletions(-) diff --git a/src/modules/events/components/CreateEventDialog.vue b/src/modules/events/components/CreateEventDialog.vue index 76a476a..ad8d9c5 100644 --- a/src/modules/events/components/CreateEventDialog.vue +++ b/src/modules/events/components/CreateEventDialog.vue @@ -428,13 +428,34 @@ watch( }, ); -const { resetForm, meta } = form; -const isFormValid = computed( - () => - meta.value.valid && - !hasPromoErrors(promoErrors.value) && - !hasWaveErrors.value, -); +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 isLoading = ref(false); const today = computed(() => format(new Date(), "yyyy-MM-dd")); @@ -448,160 +469,200 @@ function toggleCategory(cat: string) { } } -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; +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; } - // 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), - ); + if (!paymentService) { + toastService.error("Payment service not available"); + 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; + + const paymentSvc = paymentService as any; + const preferredWallet = paymentSvc?.getPreferredWallet?.(); + if (!preferredWallet) { + toastService.error("No wallet available. Please connect a wallet first."); + return; } - 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; + 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; } - 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; - } -}); + // 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), + ); + } + 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; + } + }, + // 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" }); + } + }, +); const handleOpenChange = (open: boolean) => { if (!open && !isLoading.value) { @@ -1176,10 +1237,17 @@ const handleOpenChange = (open: boolean) => { > Cancel +