Files
Spanglish/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx
T

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>
);
}