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:
Michilis
2026-06-29 07:39:52 +00:00
co-authored by Cursor
parent f5fe87613f
commit 38526f17b5
15 changed files with 1999 additions and 1237 deletions
@@ -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>
);
}