fix(events): let the event form submit and say what is wrong

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 `<Field>` 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.
This commit is contained in:
Padreug 2026-09-29 10:01:22 +02:00
commit 285ccc2fdb

View file

@ -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<string, string> = {
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
</Button>
<!-- Disabled only while a submit is genuinely in flight, NOT on
validity. vee-validate's `meta.valid` is false before the form
has ever validated, counts disabled and conditionally rendered
fields against you, and is documented to go stale
(logaretm/vee-validate#4630). Gating on it produced a dead
button with no visible reason; let the click through and the
invalid handler says what is wrong. -->
<Button
type="submit"
class="w-full sm:w-auto"
:disabled="isLoading || !isFormValid"
:disabled="isLoading"
>
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
{{