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:
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
XMarkIcon,
|
||||
ArrowDownTrayIcon,
|
||||
CalendarDaysIcon,
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
ClockIcon,
|
||||
UserPlusIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Button from '@/components/ui/Button';
|
||||
import type { UserTicket } from '@/lib/api';
|
||||
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||
import {
|
||||
ticketScanUrl,
|
||||
ticketPdfUrl,
|
||||
googleCalendarUrl,
|
||||
shareTicket,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
* Full-screen, scannable QR view for the door. Handles single tickets and
|
||||
* multi-ticket bookings (one QR per ticket, with "Share with a guest" on the
|
||||
* spares so guests can be let in with their own code).
|
||||
*/
|
||||
export default function QrTicketModal({
|
||||
tickets,
|
||||
locale,
|
||||
onClose,
|
||||
}: {
|
||||
tickets: UserTicket[];
|
||||
locale: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (tickets.length === 0) return null;
|
||||
|
||||
const first = tickets[0];
|
||||
const event = first.event;
|
||||
const eventTitle =
|
||||
(locale === 'es' && event?.titleEs ? event.titleEs : event?.title) || 'Event';
|
||||
const isMulti = tickets.length > 1;
|
||||
|
||||
const handleShare = async (ticketId: string) => {
|
||||
const result = await shareTicket(ticketId, eventTitle, locale);
|
||||
if (result === 'copied') {
|
||||
toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied');
|
||||
} else if (result === 'failed') {
|
||||
toast.error(locale === 'es' ? 'No se pudo compartir' : 'Could not share');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] overflow-y-auto bg-white">
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-secondary-light-gray bg-white px-4 py-3">
|
||||
<span className="text-sm font-semibold text-primary-dark">
|
||||
{isMulti
|
||||
? locale === 'es'
|
||||
? `${tickets.length} entradas`
|
||||
: `${tickets.length} tickets`
|
||||
: locale === 'es'
|
||||
? 'Tu entrada'
|
||||
: 'Your ticket'}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={locale === 'es' ? 'Cerrar' : 'Close'}
|
||||
className="rounded-full p-2 text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-md px-4 pb-12 pt-6">
|
||||
{/* Event details */}
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold text-primary-dark">{eventTitle}</h2>
|
||||
{event && (
|
||||
<div className="mt-3 space-y-1.5 text-sm text-gray-600">
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4 text-primary-yellow" />
|
||||
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<ClockIcon className="h-4 w-4 text-primary-yellow" />
|
||||
{formatTime(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<MapPinIcon className="h-4 w-4 text-primary-yellow" />
|
||||
{event.location}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* One QR per ticket */}
|
||||
<div className="space-y-5">
|
||||
{tickets.map((ticket, idx) => {
|
||||
const attendee = [ticket.attendeeFirstName, ticket.attendeeLastName]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const isSpare = isMulti && idx > 0;
|
||||
return (
|
||||
<div
|
||||
key={ticket.id}
|
||||
className="rounded-card border border-secondary-light-gray p-5 text-center"
|
||||
>
|
||||
{isMulti && (
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
{locale === 'es' ? 'Entrada' : 'Ticket'} {idx + 1}
|
||||
{attendee ? ` • ${attendee}` : ''}
|
||||
{isSpare
|
||||
? ` • ${locale === 'es' ? 'Invitado' : 'Guest'}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
<div className="inline-block rounded-lg bg-white p-4 shadow-inner">
|
||||
<QRCodeSVG
|
||||
value={ticketScanUrl(ticket.id)}
|
||||
size={220}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 font-mono text-sm font-bold tracking-wide text-primary-dark">
|
||||
{ticket.qrCode}
|
||||
</p>
|
||||
|
||||
{isSpare && (
|
||||
<button
|
||||
onClick={() => handleShare(ticket.id)}
|
||||
className="mx-auto mt-3 flex items-center gap-2 rounded-btn border border-secondary-light-gray px-4 py-2 text-sm font-medium text-primary-dark hover:bg-gray-50"
|
||||
>
|
||||
<UserPlusIcon className="h-4 w-4" />
|
||||
{locale === 'es' ? 'Compartir con un invitado' : 'Share with a guest'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Download + calendar */}
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<a href={ticketPdfUrl(first)} download>
|
||||
<Button className="w-full">
|
||||
<ArrowDownTrayIcon className="mr-2 h-5 w-5" />
|
||||
{isMulti
|
||||
? locale === 'es'
|
||||
? 'Descargar entradas'
|
||||
: 'Download tickets'
|
||||
: locale === 'es'
|
||||
? 'Descargar'
|
||||
: 'Download'}
|
||||
</Button>
|
||||
</a>
|
||||
{event && (
|
||||
<a
|
||||
href={googleCalendarUrl(event, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" className="w-full">
|
||||
<CalendarDaysIcon className="mr-2 h-5 w-5" />
|
||||
{locale === 'es' ? 'Agregar al calendario' : 'Add to calendar'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user