Compare commits

..

No commits in common. "9b48ba73e2e1c0a141d2e102915e0a1c3e8492b5" and "d9de0fdd73a560d960a572c66b37cb780519ad96" have entirely different histories.

View file

@ -428,34 +428,13 @@ watch(
}, },
); );
const { resetForm } = form; const { resetForm, meta } = form;
const isFormValid = computed(
/** Field name -> what the organiser calls it, for the invalid-submit toast. */ () =>
const FIELD_LABELS: Record<string, string> = { meta.value.valid &&
name: "Title", !hasPromoErrors(promoErrors.value) &&
info: "Description", !hasWaveErrors.value,
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 isLoading = ref(false);
const today = computed(() => format(new Date(), "yyyy-MM-dd")); const today = computed(() => format(new Date(), "yyyy-MM-dd"));
@ -469,200 +448,160 @@ function toggleCategory(cat: string) {
} }
} }
const onSubmit = form.handleSubmit( const onSubmit = form.handleSubmit(async (formValues) => {
async (formValues) => { if (!isFormValid.value) return;
// 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 if (!paymentService) {
// that holds the problem — both editors live inside collapsibles, and toastService.error("Payment service not available");
// an error rendered inside a closed one is invisible. return;
if (hasWaveErrors.value) { }
revealSectionFor("ticket_waves");
toastService.error("Check the ticket waves below"); const paymentSvc = paymentService as any;
return; const preferredWallet = paymentSvc?.getPreferredWallet?.();
} if (!preferredWallet) {
if (hasPromoErrors(promoErrors.value)) { toastService.error("No wallet available. Please connect a wallet first.");
revealSectionFor("promo_codes"); return;
toastService.error("Check the promo codes below"); }
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) { // Optional fields — only include if provided
toastService.error("Payment service not available"); if (formValues.info) eventData.info = formValues.info;
return; 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;
const paymentSvc = paymentService as any; if (bannerImages.value.length > 0) {
const preferredWallet = paymentSvc?.getPreferredWallet?.(); eventData.banner = imageService.getImageUrl(bannerImages.value[0].alias);
if (!preferredWallet) { } else if (isEditMode.value) {
toastService.error("No wallet available. Please connect a wallet first."); // User cleared the banner during edit — propagate the null so the
return; // 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; // Notification config + promo codes go inside the `extra` envelope.
try { // On edit overlay onto the existing event.extra so fields only the
const eventData: CreateEventRequest = { // LNbits admin UI sets (conditional, min_tickets) survive the
name: formValues.name, // round-trip. promo_codes is always sent explicitly: what the
event_start_date: withLocalTzOffset( // organizer sees in the editor is the truth, an empty list clears.
foldDateTime( // Ticket waves. The price / capacity / currency fields above ARE the
formValues.event_start_date, // primary wave, so they have to be written INTO it: the backend
formValues.event_start_time, // 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.
if (!isEditMode.value) { // Seeded so a NEW event's implied primary wave matches what the
// Wallet binds at creation. The backend ignores the field on // backend would synthesize. `create_event` stamps `time` and derives
// update so we leave it off the edit payload for clean wire. // the wave window from it plus the closing/end/start dates; without
eventData.wallet = preferredWallet.id; // 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);
// Optional fields — only include if provided toastService.success(
if (formValues.info) eventData.info = formValues.info; willLandInPending.value
if (formValues.event_end_date) { ? "Updated — awaiting re-approval. Hidden from the public feed until reviewed."
eventData.event_end_date = withLocalTzOffset( : "Event updated!",
foldDateTime(formValues.event_end_date, formValues.event_end_time), );
); emit("event-updated");
} else {
if (!props.onCreateEvent) {
toastService.error("Create handler missing");
return;
} }
if (formValues.location) eventData.location = formValues.location; await props.onCreateEvent(eventData);
if (bannerImages.value.length > 0) { toastService.success(
eventData.banner = imageService.getImageUrl( willLandInPending.value
bannerImages.value[0].alias, ? "Submitted! Awaiting admin approval — your draft is visible on your feed with a Pending badge."
); : "Event submitted!",
} else if (isEditMode.value) { );
// User cleared the banner during edit — propagate the null so the emit("event-created");
// 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 resetForm();
// whose FormMessage sits inside a closed section — or is not rendered at selectedCategories.value = [];
// all — otherwise leaves a dead button and no explanation, which is the promoRows.value = [];
// dead end disabling the button used to create. extraWaves.value = [];
({ errors }) => { knownWaveIds.value = [];
const fields = Object.keys(errors); bannerImages.value = [];
fields.forEach(revealSectionFor); emit("update:open", false);
const named = fields.map((f) => FIELD_LABELS[f] ?? f); } catch (error) {
toastService.error( const errorMessage =
named.length error instanceof Error
? `Check ${named.join(", ")}` ? error.message
: "Some details still need attention", : isEditMode.value
); ? "Failed to update event"
const first = fields[0]; : "Failed to create event";
if (first) { toastService.error(errorMessage);
document } finally {
.querySelector(`[name="${first}"], #${CSS.escape(first)}`) isLoading.value = false;
?.scrollIntoView({ behavior: "smooth", block: "center" }); }
} });
},
);
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
if (!open && !isLoading.value) { if (!open && !isLoading.value) {
@ -1237,17 +1176,10 @@ const handleOpenChange = (open: boolean) => {
> >
Cancel Cancel
</Button> </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 <Button
type="submit" type="submit"
class="w-full sm:w-auto" class="w-full sm:w-auto"
:disabled="isLoading" :disabled="isLoading || !isFormValid"
> >
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" /> <Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
{{ {{