Security recovery: hold sweep, dashboard updates, and admin fixes.
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
groupByBooking,
|
||||
isUnpaid,
|
||||
isAwaitingApproval,
|
||||
isOnHold,
|
||||
ticketAmount,
|
||||
shareTicket,
|
||||
isToday,
|
||||
@@ -57,11 +58,12 @@ export default function OverviewTab({
|
||||
// awaiting approval), ordered by soonest event.
|
||||
const attentionTicket = useMemo(() => {
|
||||
const candidates = activeTickets.filter(
|
||||
(t) => isUnpaid(t) || isAwaitingApproval(t)
|
||||
(t) => isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t)
|
||||
);
|
||||
const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2);
|
||||
candidates.sort((a, b) => {
|
||||
const aUnpaid = isUnpaid(a) ? 0 : 1;
|
||||
const bUnpaid = isUnpaid(b) ? 0 : 1;
|
||||
const aUnpaid = priority(a);
|
||||
const bUnpaid = priority(b);
|
||||
if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid;
|
||||
const aStart = a.event?.startDatetime
|
||||
? parseDate(a.event.startDatetime).getTime()
|
||||
@@ -190,7 +192,19 @@ export default function OverviewTab({
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusPill status={status} locale={locale} />
|
||||
{isUnpaid(t) ? (
|
||||
{isOnHold(t) ? (
|
||||
<PayActions
|
||||
ticketId={t.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
onHold
|
||||
/>
|
||||
) : isUnpaid(t) ? (
|
||||
<PayActions
|
||||
ticketId={t.id}
|
||||
amount={amount}
|
||||
@@ -346,7 +360,19 @@ function HeroCard({
|
||||
)}
|
||||
|
||||
{/* Smart primary action. */}
|
||||
{isUnpaid(ticket) ? (
|
||||
{isOnHold(ticket) ? (
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="md"
|
||||
onHold
|
||||
/>
|
||||
) : isUnpaid(ticket) ? (
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
|
||||
: payment.event?.title) || (locale === 'es' ? 'Evento' : 'Event');
|
||||
const canMarkPaid =
|
||||
payment.status === 'pending' && isManualProvider(payment.provider);
|
||||
const canRebook = payment.status === 'on_hold';
|
||||
return (
|
||||
<Card key={payment.id} className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -131,7 +132,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end">
|
||||
{canMarkPaid && (
|
||||
{canRebook ? (
|
||||
<PayActions
|
||||
ticketId={payment.ticketId}
|
||||
amount={Number(payment.amount)}
|
||||
@@ -141,7 +142,21 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
onHold
|
||||
/>
|
||||
) : (
|
||||
canMarkPaid && (
|
||||
<PayActions
|
||||
ticketId={payment.ticketId}
|
||||
amount={Number(payment.amount)}
|
||||
currency={payment.currency}
|
||||
destination={eventTitle}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{payment.invoice && (
|
||||
<a
|
||||
|
||||
@@ -16,9 +16,11 @@ import { StatusPill, deriveTicketStatus } from './_shared/status';
|
||||
import {
|
||||
groupByBooking,
|
||||
isUnpaid,
|
||||
isOnHold,
|
||||
ticketAmount,
|
||||
ticketPdfUrl,
|
||||
pyg,
|
||||
HOLD_THRESHOLD_HOURS,
|
||||
type BookingGroup,
|
||||
} from './_shared/helpers';
|
||||
import PayActions from './_shared/PayActions';
|
||||
@@ -176,12 +178,31 @@ function BookingCard({
|
||||
<p className="text-gray-500">
|
||||
{pyg(amount, currency)}
|
||||
</p>
|
||||
{isOnHold(ticket) && (
|
||||
<p className="text-slate-600">
|
||||
{locale === 'es'
|
||||
? `Tu lugar fue liberado porque el pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.`
|
||||
: `Your spot has been released because payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 sm:w-44">
|
||||
{isUnpaid(ticket) ? (
|
||||
{isOnHold(ticket) ? (
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="stack"
|
||||
size="sm"
|
||||
onHold
|
||||
/>
|
||||
) : isUnpaid(ticket) ? (
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
ticketAmount,
|
||||
pyg,
|
||||
isAwaitingApproval,
|
||||
isOnHold,
|
||||
HOLD_THRESHOLD_HOURS,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
@@ -38,6 +40,41 @@ export default function AttentionBanner({
|
||||
: ticket.event?.title) || (locale === 'es' ? 'tu evento' : 'your event');
|
||||
const { amount, currency } = ticketAmount(ticket);
|
||||
|
||||
if (isOnHold(ticket)) {
|
||||
return (
|
||||
<div className="rounded-card border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-slate-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-slate-800">
|
||||
{locale === 'es'
|
||||
? `Tu lugar para ${eventTitle} fue liberado`
|
||||
: `Your spot for ${eventTitle} was released`}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-slate-600">
|
||||
{locale === 'es'
|
||||
? `El pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.`
|
||||
: `Payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 sm:pl-9">
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={eventTitle}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
onHold
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAwaitingApproval(ticket)) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-card border border-amber-200 bg-amber-50 p-4">
|
||||
|
||||
@@ -23,6 +23,11 @@ interface PayActionsProps {
|
||||
/** Stack the two buttons full-width (cards) vs inline (rows). */
|
||||
layout?: 'stack' | 'inline';
|
||||
className?: string;
|
||||
/**
|
||||
* The booking's spot was released after the hold threshold passed. Hides the
|
||||
* "Pay now" link (money was already sent) and labels the retry "Rebook".
|
||||
*/
|
||||
onHold?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +46,7 @@ export default function PayActions({
|
||||
size = 'sm',
|
||||
layout = 'stack',
|
||||
className,
|
||||
onHold = false,
|
||||
}: PayActionsProps) {
|
||||
const { t } = useLanguage();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
@@ -76,18 +82,22 @@ export default function PayActions({
|
||||
return (
|
||||
<>
|
||||
<div className={`${containerClass} ${className || ''}`}>
|
||||
<Link href={`/booking/${ticketId}`} className={btnWidth}>
|
||||
<Button size={size} className={btnWidth}>
|
||||
{locale === 'es' ? 'Pagar ahora' : 'Pay now'}
|
||||
</Button>
|
||||
</Link>
|
||||
{!onHold && (
|
||||
<Link href={`/booking/${ticketId}`} className={btnWidth}>
|
||||
<Button size={size} className={btnWidth}>
|
||||
{locale === 'es' ? 'Pagar ahora' : 'Pay now'}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={onHold ? 'primary' : 'outline'}
|
||||
size={size}
|
||||
className={btnWidth}
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{locale === 'es' ? 'Ya pagué' : "I've paid"}
|
||||
{onHold
|
||||
? (locale === 'es' ? 'Reservar de nuevo' : 'Rebook')
|
||||
: (locale === 'es' ? 'Ya pagué' : "I've paid")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -108,16 +118,24 @@ export default function PayActions({
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 text-center text-lg font-semibold text-primary-dark">
|
||||
{locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?'}
|
||||
{onHold
|
||||
? (locale === 'es' ? '¿Reservar de nuevo?' : 'Rebook your spot?')
|
||||
: (locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?')}
|
||||
</h3>
|
||||
<p className="mb-6 text-center text-sm text-gray-600">
|
||||
{locale === 'es'
|
||||
? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?`
|
||||
: `Did you already send the ${pyg(amount, currency)} for ${destination}?`}
|
||||
{onHold
|
||||
? (locale === 'es'
|
||||
? `Intentaremos reservar tu lugar de nuevo para ${destination}.`
|
||||
: `We'll try to re-reserve your spot for ${destination}.`)
|
||||
: (locale === 'es'
|
||||
? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?`
|
||||
: `Did you already send the ${pyg(amount, currency)} for ${destination}?`)}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button isLoading={marking} className="w-full" onClick={handleConfirm}>
|
||||
{locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid"}
|
||||
{onHold
|
||||
? (locale === 'es' ? 'Sí, reservar de nuevo' : 'Yes, rebook')
|
||||
: (locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -6,6 +6,16 @@ import { formatPrice, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
// ticket was created, capped at the event start time.
|
||||
export const PAYMENT_HOLD_HOURS = 24;
|
||||
|
||||
// Hours a pending-approval booking (payment already marked as sent) can wait
|
||||
// for admin review before the auto-hold sweep releases the spot. Mirrors the
|
||||
// backend's HOLD_THRESHOLD_HOURS env default - keep these in sync.
|
||||
export const HOLD_THRESHOLD_HOURS = 72;
|
||||
|
||||
/** True once a booking has been auto-released after the approval hold window. */
|
||||
export function isOnHold(ticket: Pick<UserTicket, 'status'> & { payment?: { status?: string } | null }): boolean {
|
||||
return ticket.status === 'on_hold' || ticket.payment?.status === 'on_hold';
|
||||
}
|
||||
|
||||
/** Currency is Guarani with no decimals, e.g. "21 PYG". */
|
||||
export function pyg(amount: number, currency: string = 'PYG'): string {
|
||||
return formatPrice(Number(amount) || 0, currency || 'PYG');
|
||||
|
||||
@@ -9,12 +9,14 @@ import type { UserTicket, Payment } from '@/lib/api';
|
||||
// unpaid -> pale red
|
||||
// attended -> pale blue (replaces the raw "checked_in" value)
|
||||
// cancelled -> pale gray
|
||||
// onHold -> pale slate (spot released after the payment deadline passed)
|
||||
export type DashStatus =
|
||||
| 'confirmed'
|
||||
| 'awaiting'
|
||||
| 'unpaid'
|
||||
| 'attended'
|
||||
| 'cancelled';
|
||||
| 'cancelled'
|
||||
| 'onHold';
|
||||
|
||||
/**
|
||||
* Collapse a ticket status + payment status into a single user-facing status.
|
||||
@@ -26,6 +28,7 @@ export function deriveTicketStatus(
|
||||
): DashStatus {
|
||||
if (ticketStatus === 'checked_in') return 'attended';
|
||||
if (ticketStatus === 'cancelled') return 'cancelled';
|
||||
if (ticketStatus === 'on_hold' || paymentStatus === 'on_hold') return 'onHold';
|
||||
if (paymentStatus === 'paid' || ticketStatus === 'confirmed') return 'confirmed';
|
||||
if (paymentStatus === 'pending_approval') return 'awaiting';
|
||||
return 'unpaid';
|
||||
@@ -35,6 +38,7 @@ export function deriveTicketStatus(
|
||||
export function derivePaymentStatus(paymentStatus?: string): DashStatus {
|
||||
if (paymentStatus === 'paid') return 'confirmed';
|
||||
if (paymentStatus === 'pending_approval') return 'awaiting';
|
||||
if (paymentStatus === 'on_hold') return 'onHold';
|
||||
if (paymentStatus === 'refunded') return 'cancelled';
|
||||
return 'unpaid';
|
||||
}
|
||||
@@ -46,6 +50,7 @@ export function statusLabel(status: DashStatus, locale: string): string {
|
||||
unpaid: { en: 'Unpaid', es: 'No pagado' },
|
||||
attended: { en: 'Attended', es: 'Asistió' },
|
||||
cancelled: { en: 'Cancelled', es: 'Cancelado' },
|
||||
onHold: { en: 'On Hold', es: 'En Espera' },
|
||||
};
|
||||
return locale === 'es' ? labels[status].es : labels[status].en;
|
||||
}
|
||||
@@ -56,6 +61,7 @@ const PILL_STYLES: Record<DashStatus, string> = {
|
||||
unpaid: 'bg-red-100 text-red-700',
|
||||
attended: 'bg-blue-100 text-blue-800',
|
||||
cancelled: 'bg-gray-100 text-gray-600',
|
||||
onHold: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
|
||||
export function StatusPill({
|
||||
|
||||
Reference in New Issue
Block a user