Redesign user dashboard with overview tab and i18n payment copy.
Consolidate profile and security into AccountTab, add shared dashboard components, and move awaiting-approval payment messages to translations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,209 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
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 { parseDate } from '@/lib/utils';
|
||||
import { formatDateLong, parseDate } from '@/lib/utils';
|
||||
import { StatusPill, deriveTicketStatus } from './_shared/status';
|
||||
import {
|
||||
groupByBooking,
|
||||
isUnpaid,
|
||||
ticketAmount,
|
||||
ticketPdfUrl,
|
||||
pyg,
|
||||
type BookingGroup,
|
||||
} from './_shared/helpers';
|
||||
import PayActions from './_shared/PayActions';
|
||||
import QrTicketModal from './_shared/QrTicketModal';
|
||||
|
||||
interface TicketsTabProps {
|
||||
tickets: UserTicket[];
|
||||
language: string;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export default function TicketsTab({ tickets, language }: TicketsTabProps) {
|
||||
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('all');
|
||||
type Filter = 'all' | 'upcoming' | 'past';
|
||||
|
||||
const now = new Date();
|
||||
const filteredTickets = tickets.filter((ticket) => {
|
||||
if (filter === 'all') return true;
|
||||
const eventDate = ticket.event?.startDatetime
|
||||
? new Date(ticket.event.startDatetime)
|
||||
: null;
|
||||
if (filter === 'upcoming') return eventDate && eventDate > now;
|
||||
if (filter === 'past') return eventDate && eventDate <= now;
|
||||
return true;
|
||||
});
|
||||
export default function TicketsTab({ tickets, language: locale, onChange }: TicketsTabProps) {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [qrTickets, setQrTickets] = useState<UserTicket[] | null>(null);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
// 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 formatCurrency = (amount: number, currency: string = 'PYG') => {
|
||||
if (currency === 'PYG') {
|
||||
return `${amount.toLocaleString('es-PY')} PYG`;
|
||||
}
|
||||
return `$${amount.toFixed(2)} ${currency}`;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
confirmed: 'bg-green-100 text-green-800',
|
||||
checked_in: 'bg-blue-100 text-blue-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
};
|
||||
const labels: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
confirmed: 'Confirmed',
|
||||
checked_in: 'Checked In',
|
||||
pending: 'Pending',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
es: {
|
||||
confirmed: 'Confirmado',
|
||||
checked_in: 'Registrado',
|
||||
pending: 'Pendiente',
|
||||
cancelled: 'Cancelado',
|
||||
},
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{labels[language]?.[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
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 Buttons */}
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'upcoming', 'past'] as const).map((f) => (
|
||||
{/* Filter chips */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filter === f
|
||||
? 'bg-secondary-blue text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' && (language === 'es' ? 'Todas' : 'All')}
|
||||
{f === 'upcoming' && (language === 'es' ? 'Próximas' : 'Upcoming')}
|
||||
{f === 'past' && (language === 'es' ? 'Pasadas' : 'Past')}
|
||||
{locale === 'es' ? chip.label.es : chip.label.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tickets List */}
|
||||
{filteredTickets.length === 0 ? (
|
||||
{groups.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-600 mb-4">
|
||||
{language === 'es' ? 'No tienes entradas' : 'You have no tickets'}
|
||||
<p className="mb-4 text-gray-600">
|
||||
{locale === 'es' ? 'No tienes entradas' : 'You have no tickets'}
|
||||
</p>
|
||||
<Link href="/events">
|
||||
<Button>
|
||||
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
|
||||
</Button>
|
||||
<Button>{locale === 'es' ? 'Explorar eventos' : 'Browse events'}</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredTickets.map((ticket) => (
|
||||
<Card key={ticket.id} className="p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||
{/* Event Image */}
|
||||
{ticket.event?.bannerUrl && (
|
||||
<div className="w-full md:w-32 h-24 rounded-lg overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={ticket.event.bannerUrl}
|
||||
alt={ticket.event.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticket Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold">
|
||||
{language === 'es' && ticket.event?.titleEs
|
||||
? ticket.event.titleEs
|
||||
: ticket.event?.title || 'Event'}
|
||||
</h3>
|
||||
{getStatusBadge(ticket.status)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
{ticket.event?.startDatetime && (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Fecha:' : 'Date:'}
|
||||
</span>{' '}
|
||||
{formatDate(ticket.event.startDatetime)}
|
||||
</p>
|
||||
)}
|
||||
{ticket.event?.location && (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Lugar:' : 'Location:'}
|
||||
</span>{' '}
|
||||
{ticket.event.location}
|
||||
</p>
|
||||
)}
|
||||
{ticket.payment && (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Pago:' : 'Payment:'}
|
||||
</span>{' '}
|
||||
{formatCurrency(ticket.payment.amount, ticket.payment.currency)} -
|
||||
<span className={`ml-1 ${
|
||||
ticket.payment.status === 'paid' ? 'text-green-600' : 'text-yellow-600'
|
||||
}`}>
|
||||
{ticket.payment.status === 'paid'
|
||||
? (language === 'es' ? 'Pagado' : 'Paid')
|
||||
: (language === 'es' ? 'Pendiente' : 'Pending')}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link href={`/booking/success/${ticket.id}`}>
|
||||
<Button size="sm" className="w-full">
|
||||
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
|
||||
</Button>
|
||||
</Link>
|
||||
{(ticket.status === 'confirmed' || ticket.status === 'checked_in') && (
|
||||
<a
|
||||
href={ticket.bookingId
|
||||
? `/api/tickets/booking/${ticket.bookingId}/pdf`
|
||||
: `/api/tickets/${ticket.id}/pdf`
|
||||
}
|
||||
download
|
||||
className="text-center"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
{language === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{ticket.invoice && (
|
||||
<a
|
||||
href={ticket.invoice.pdfUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-center"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 sm:w-44">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user