Fix booking defaults, dashboard alerts, admin edit flow, and ended-event payments.

Require an explicit payment method on booking, hide stale release banners for past or inactive events, open event editing in place on the detail page, and auto-reject unconfirmed payments after events end without sending email.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-07-12 22:04:22 +00:00
co-authored by Cursor
parent 8d5fbf18b4
commit cb422332c8
13 changed files with 619 additions and 382 deletions
@@ -335,7 +335,7 @@ export function BookingFormStep({
<button
key={method.id}
type="button"
onClick={() => setFormData({ ...formData, paymentMethod: method.id })}
onClick={() => setFormData((prev) => ({ ...prev, paymentMethod: method.id }))}
className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${
formData.paymentMethod === method.id
? 'border-primary-yellow bg-primary-yellow/10'
@@ -377,6 +377,9 @@ export function BookingFormStep({
</>
)}
</div>
{errors.paymentMethod && (
<p className="mt-3 text-sm text-red-600">{errors.paymentMethod}</p>
)}
</Card>
{/* Terms & Privacy agreement */}
@@ -430,7 +433,7 @@ export function BookingFormStep({
size="lg"
className="w-full"
isLoading={submitting}
disabled={paymentMethods.length === 0 || !agreedToTerms}
disabled={paymentMethods.length === 0 || !formData.paymentMethod || !agreedToTerms}
>
{formData.paymentMethod === 'cash'
? t('booking.form.reserveSpot')
@@ -13,7 +13,8 @@ export interface BookingFormData {
email: string;
phone: string;
preferredLanguage: 'en' | 'es';
paymentMethod: PaymentMethod;
// Empty until the user explicitly picks a method (no default selection).
paymentMethod: PaymentMethod | '';
ruc: string;
}
@@ -57,7 +57,7 @@ export default function BookingPage() {
email: '',
phone: '',
preferredLanguage: locale as 'en' | 'es',
paymentMethod: 'cash',
paymentMethod: '',
ruc: '',
});
@@ -130,18 +130,7 @@ export default function BookingPage() {
return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' });
});
setPaymentConfig(paymentRes.paymentOptions);
// Set default payment method based on what's enabled
const config = paymentRes.paymentOptions;
if (config.lightningEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'lightning' }));
} else if (config.cashEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'cash' }));
} else if (config.bankTransferEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'bank_transfer' }));
} else if (config.tpagoEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'tpago' }));
}
// No payment method is pre-selected; the user must choose one.
})
.catch(() => router.push('/events'))
.finally(() => setLoading(false));
@@ -216,6 +205,15 @@ export default function BookingPage() {
}
}
// Payment method must be explicitly chosen and currently enabled
const availableMethods = buildPaymentMethods(paymentConfig, locale);
if (
!formData.paymentMethod ||
!availableMethods.some((m) => m.id === formData.paymentMethod)
) {
newErrors.paymentMethod = t('booking.form.errors.paymentMethodRequired');
}
// Validate additional attendees (if multi-ticket)
attendees.forEach((attendee, index) => {
if (!attendee.firstName.trim() || attendee.firstName.length < 2) {
@@ -304,7 +302,7 @@ export default function BookingPage() {
email: formData.email,
phone: formData.phone,
preferredLanguage: formData.preferredLanguage,
paymentMethod: formData.paymentMethod,
paymentMethod: formData.paymentMethod as PaymentMethod,
...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
// Include attendees array for multi-ticket bookings
...(allAttendees.length > 1 && { attendees: allAttendees }),
@@ -366,7 +364,7 @@ export default function BookingPage() {
bookingId,
qrCode: primaryTicket.qrCode,
qrCodes: ticketsList?.map((t: any) => t.qrCode),
paymentMethod: formData.paymentMethod,
paymentMethod: formData.paymentMethod as PaymentMethod,
ticketCount,
});
setStep('success');
@@ -23,6 +23,7 @@ import {
isUnpaid,
isAwaitingApproval,
isOnHold,
isActionableAttention,
ticketAmount,
shareTicket,
isToday,
@@ -58,7 +59,9 @@ export default function OverviewTab({
// awaiting approval), ordered by soonest event.
const attentionTicket = useMemo(() => {
const candidates = activeTickets.filter(
(t) => isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t)
(t) =>
isActionableAttention(t) &&
(isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t))
);
const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2);
candidates.sort((a, b) => {
@@ -35,6 +35,22 @@ export function isAwaitingApproval(ticket: { payment?: { status?: string } | nul
return ticket.payment?.status === 'pending_approval';
}
/**
* Whether an attention banner (e.g. "your spot was released", "payment pending")
* is still worth showing to the user. It only makes sense to nudge the user when
* the event still exists, is still bookable (published/unlisted), and has not
* already ended. This suppresses stale banners for deleted, unpublished,
* cancelled/completed/archived, or past events.
*/
export function isActionableAttention(ticket: UserTicket): boolean {
const event = ticket.event;
if (!event) return false;
if (event.status !== 'published' && event.status !== 'unlisted') return false;
const refDate = event.endDatetime || event.startDatetime;
if (!refDate) return false;
return parseDate(refDate).getTime() > Date.now();
}
/**
* The moment the seat hold expires. null when paid/awaiting/cancelled or when
* the data needed to compute it is missing.