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>
188 lines
6.4 KiB
TypeScript
188 lines
6.4 KiB
TypeScript
'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>
|
|
);
|
|
}
|