250 lines
8.1 KiB
TypeScript
250 lines
8.1 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import {
|
|
CalendarIcon,
|
|
MapPinIcon,
|
|
ArrowDownTrayIcon,
|
|
TicketIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import Card from '@/components/ui/Card';
|
|
import Button from '@/components/ui/Button';
|
|
import { UserTicket } from '@/lib/api';
|
|
import { formatDateLong, parseDate } from '@/lib/utils';
|
|
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';
|
|
import QrTicketModal from './_shared/QrTicketModal';
|
|
|
|
interface TicketsTabProps {
|
|
tickets: UserTicket[];
|
|
language: string;
|
|
onChange: () => void;
|
|
}
|
|
|
|
type Filter = 'all' | 'upcoming' | 'past';
|
|
|
|
export default function TicketsTab({ tickets, language: locale, onChange }: TicketsTabProps) {
|
|
const [filter, setFilter] = useState<Filter>('all');
|
|
const [qrTickets, setQrTickets] = useState<UserTicket[] | null>(null);
|
|
|
|
// Group multi-ticket bookings so both QRs sit together, then filter by the
|
|
// group's event date.
|
|
const groups = useMemo<BookingGroup[]>(() => {
|
|
const now = Date.now();
|
|
return groupByBooking(tickets).filter((group) => {
|
|
if (filter === 'all') return true;
|
|
const start = group.tickets[0].event?.startDatetime;
|
|
if (!start) return false; // undated tickets only show under "All"
|
|
const isUpcoming = parseDate(start).getTime() > now;
|
|
return filter === 'upcoming' ? isUpcoming : !isUpcoming;
|
|
});
|
|
}, [tickets, filter]);
|
|
|
|
const chips: { id: Filter; label: { en: string; es: string } }[] = [
|
|
{ id: 'all', label: { en: 'All', es: 'Todas' } },
|
|
{ id: 'upcoming', label: { en: 'Upcoming', es: 'Próximas' } },
|
|
{ id: 'past', label: { en: 'Past', es: 'Pasadas' } },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Filter chips */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{chips.map((chip) => (
|
|
<button
|
|
key={chip.id}
|
|
onClick={() => setFilter(chip.id)}
|
|
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
|
filter === chip.id
|
|
? 'bg-primary-yellow text-primary-dark'
|
|
: 'bg-secondary-gray text-gray-700 hover:bg-secondary-light-gray'
|
|
}`}
|
|
>
|
|
{locale === 'es' ? chip.label.es : chip.label.en}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{groups.length === 0 ? (
|
|
<Card className="p-8 text-center">
|
|
<p className="mb-4 text-gray-600">
|
|
{locale === 'es' ? 'No tienes entradas' : 'You have no tickets'}
|
|
</p>
|
|
<Link href="/events">
|
|
<Button>{locale === 'es' ? 'Explorar eventos' : 'Browse events'}</Button>
|
|
</Link>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{groups.map((group) => (
|
|
<BookingCard
|
|
key={group.bookingId}
|
|
group={group}
|
|
locale={locale}
|
|
onChange={onChange}
|
|
onShowQr={() => setQrTickets(group.tickets)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{qrTickets && (
|
|
<QrTicketModal
|
|
tickets={qrTickets}
|
|
locale={locale}
|
|
onClose={() => setQrTickets(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BookingCard({
|
|
group,
|
|
locale,
|
|
onChange,
|
|
onShowQr,
|
|
}: {
|
|
group: BookingGroup;
|
|
locale: string;
|
|
onChange: () => void;
|
|
onShowQr: () => void;
|
|
}) {
|
|
const ticket = group.tickets[0];
|
|
const event = ticket.event;
|
|
const title =
|
|
(locale === 'es' && event?.titleEs ? event.titleEs : event?.title) || 'Event';
|
|
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
|
|
const { amount, currency } = ticketAmount(ticket);
|
|
const multi = group.tickets.length > 1;
|
|
const ready = status === 'confirmed' || status === 'attended';
|
|
|
|
return (
|
|
<Card className="p-4">
|
|
<div className="flex flex-col gap-4 sm:flex-row">
|
|
{/* Image */}
|
|
<div className="h-32 w-full flex-shrink-0 overflow-hidden rounded-card bg-secondary-gray sm:h-24 sm:w-32">
|
|
{event?.bannerUrl ? (
|
|
<img src={event.bannerUrl} alt={title} className="h-full w-full object-cover" />
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<TicketIcon className="h-8 w-8 text-gray-300" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<h3 className="flex items-center gap-2 font-semibold">
|
|
<span className="truncate">{title}</span>
|
|
{multi && (
|
|
<span className="whitespace-nowrap rounded-full bg-secondary-gray px-2 py-0.5 text-xs font-medium text-gray-700">
|
|
{locale === 'es'
|
|
? `${group.tickets.length} entradas`
|
|
: `${group.tickets.length} tickets`}
|
|
</span>
|
|
)}
|
|
</h3>
|
|
</div>
|
|
<StatusPill status={status} locale={locale} />
|
|
</div>
|
|
|
|
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
|
{event?.startDatetime && (
|
|
<p className="flex items-center gap-2">
|
|
<CalendarIcon className="h-4 w-4 text-primary-yellow" />
|
|
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
|
|
</p>
|
|
)}
|
|
{event?.location && (
|
|
<p className="flex items-center gap-2">
|
|
<MapPinIcon className="h-4 w-4 text-primary-yellow" />
|
|
<span className="truncate">{event.location}</span>
|
|
</p>
|
|
)}
|
|
<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">
|
|
{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}
|
|
currency={currency}
|
|
destination={title}
|
|
locale={locale}
|
|
onPaid={onChange}
|
|
layout="stack"
|
|
size="sm"
|
|
/>
|
|
) : (
|
|
<>
|
|
{ready && (
|
|
<Button size="sm" className="w-full" onClick={onShowQr}>
|
|
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
|
|
</Button>
|
|
)}
|
|
{ready && (
|
|
<a href={ticketPdfUrl(ticket)} download className="w-full">
|
|
<Button variant="outline" size="sm" className="w-full">
|
|
<ArrowDownTrayIcon className="mr-1.5 h-4 w-4" />
|
|
{locale === 'es' ? 'Descargar' : 'Download'}
|
|
</Button>
|
|
</a>
|
|
)}
|
|
{ticket.invoice && (
|
|
<a
|
|
href={ticket.invoice.pdfUrl || '#'}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="w-full"
|
|
>
|
|
<Button variant="ghost" size="sm" className="w-full">
|
|
{locale === 'es' ? 'Factura' : 'Invoice'}
|
|
</Button>
|
|
</a>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|