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
@@ -0,0 +1,93 @@
'use client';
import {
ExclamationTriangleIcon,
CheckCircleIcon,
} from '@heroicons/react/24/outline';
import type { UserTicket } from '@/lib/api';
import { useLanguage } from '@/context/LanguageContext';
import PayActions from './PayActions';
import { HoldCountdown } from './Countdown';
import {
holdExpiry,
ticketAmount,
pyg,
isAwaitingApproval,
} from './helpers';
/**
* The very-top Overview banner. Shown only when a booking needs attention:
* • unpaid -> red banner naming event + amount, a hold-expiry countdown,
* and the "Pay now" / "I've paid" actions.
* • awaiting -> calm amber "payment received, we will confirm it
* shortly" state with no further action.
*/
export default function AttentionBanner({
ticket,
locale,
onChange,
}: {
ticket: UserTicket;
locale: string;
onChange: () => void;
}) {
const { t } = useLanguage();
const eventTitle =
(locale === 'es' && ticket.event?.titleEs
? ticket.event.titleEs
: ticket.event?.title) || (locale === 'es' ? 'tu evento' : 'your event');
const { amount, currency } = ticketAmount(ticket);
if (isAwaitingApproval(ticket)) {
return (
<div className="flex items-start gap-3 rounded-card border border-amber-200 bg-amber-50 p-4">
<CheckCircleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-amber-600" />
<div>
<p className="font-semibold text-amber-900">
{t('dashboard.payment.received')}
</p>
<p className="text-sm text-amber-800">
{t('dashboard.payment.confirmForEvent', {
amount: pyg(amount, currency),
event: eventTitle,
})}
</p>
</div>
</div>
);
}
const expiry = holdExpiry(ticket);
return (
<div className="rounded-card border border-red-200 bg-red-50 p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-600" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-red-900">
{locale === 'es'
? `Debes ${pyg(amount, currency)} para ${eventTitle}`
: `You owe ${pyg(amount, currency)} for ${eventTitle}`}
</p>
{expiry && (
<p className="mt-0.5 text-sm text-red-700">
<HoldCountdown target={expiry} locale={locale} />
</p>
)}
</div>
</div>
<div className="mt-3 sm:pl-9">
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={eventTitle}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
/>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Live, human countdown to a target time. Renders a short message such as
* "Pay within 23h or your spot is released". Once the target passes it shows a
* calm "expired" message instead of a negative timer.
*/
export function HoldCountdown({
target,
locale,
}: {
target: Date;
locale: string;
}) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60 * 1000);
return () => clearInterval(id);
}, []);
const msLeft = target.getTime() - now;
if (msLeft <= 0) {
return (
<span>
{locale === 'es'
? 'Paga pronto para mantener tu lugar'
: 'Pay soon to keep your spot'}
</span>
);
}
const totalMinutes = Math.floor(msLeft / (60 * 1000));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
let remaining: string;
if (hours >= 1) {
remaining = `${hours}h${minutes > 0 ? ` ${minutes}m` : ''}`;
} else {
remaining = `${minutes}m`;
}
return (
<span>
{locale === 'es'
? `Paga en ${remaining} o tu lugar se libera`
: `Pay within ${remaining} or your spot is released`}
</span>
);
}
@@ -0,0 +1,136 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import toast from 'react-hot-toast';
import { CheckCircleIcon } from '@heroicons/react/24/outline';
import Button from '@/components/ui/Button';
import { ticketsApi } from '@/lib/api';
import { useLanguage } from '@/context/LanguageContext';
import { pyg } from './helpers';
interface PayActionsProps {
ticketId: string;
/** Amount owed, used in the confirm copy. */
amount: number;
currency?: string;
/** Where the money is going — event name or account label. */
destination: string;
locale: string;
/** Called after the payment is successfully marked as sent. */
onPaid?: () => void;
size?: 'sm' | 'md' | 'lg';
/** Stack the two buttons full-width (cards) vs inline (rows). */
layout?: 'stack' | 'inline';
className?: string;
}
/**
* The two unpaid-ticket actions used across Overview, Tickets and Payments:
* • "Pay now" -> opens the existing manual-payment page (TPago / bank details)
* • "I've paid" -> short confirm step, then marks the payment as sent
* (moves the ticket to "Awaiting approval").
*/
export default function PayActions({
ticketId,
amount,
currency = 'PYG',
destination,
locale,
onPaid,
size = 'sm',
layout = 'stack',
className,
}: PayActionsProps) {
const { t } = useLanguage();
const [confirming, setConfirming] = useState(false);
const [marking, setMarking] = useState(false);
const handleConfirm = async () => {
setMarking(true);
try {
await ticketsApi.markPaymentSent(ticketId);
toast.success(t('dashboard.payment.receivedConfirmSoon'));
setConfirming(false);
onPaid?.();
} catch (error: any) {
// Idempotent backend: if already marked, treat as success.
if (error?.message?.includes('already')) {
setConfirming(false);
onPaid?.();
return;
}
toast.error(
error?.message ||
(locale === 'es' ? 'No se pudo marcar el pago' : 'Could not mark payment')
);
} finally {
setMarking(false);
}
};
const containerClass =
layout === 'stack' ? 'flex flex-col gap-2' : 'flex flex-wrap gap-2';
const btnWidth = layout === 'stack' ? 'w-full' : '';
return (
<>
<div className={`${containerClass} ${className || ''}`}>
<Link href={`/booking/${ticketId}`} className={btnWidth}>
<Button size={size} className={btnWidth}>
{locale === 'es' ? 'Pagar ahora' : 'Pay now'}
</Button>
</Link>
<Button
variant="outline"
size={size}
className={btnWidth}
onClick={() => setConfirming(true)}
>
{locale === 'es' ? 'Ya pagué' : "I've paid"}
</Button>
</div>
{confirming && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
onClick={() => !marking && setConfirming(false)}
>
<div
className="w-full max-w-sm rounded-card bg-white p-6 shadow-card-hover"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
<CheckCircleIcon className="h-7 w-7 text-amber-600" />
</div>
</div>
<h3 className="mb-2 text-center text-lg font-semibold text-primary-dark">
{locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?'}
</h3>
<p className="mb-6 text-center text-sm text-gray-600">
{locale === 'es'
? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?`
: `Did you already send the ${pyg(amount, currency)} for ${destination}?`}
</p>
<div className="flex flex-col gap-2">
<Button isLoading={marking} className="w-full" onClick={handleConfirm}>
{locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid"}
</Button>
<Button
variant="ghost"
className="w-full"
disabled={marking}
onClick={() => setConfirming(false)}
>
{locale === 'es' ? 'Todavía no' : 'Not yet'}
</Button>
</div>
</div>
</div>
)}
</>
);
}
@@ -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>
);
}
@@ -0,0 +1,148 @@
import type { UserTicket, Event } from '@/lib/api';
import { formatPrice, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
// Hours a booking holds a seat before the spot is released. There is no
// server-side enforcement field, so the countdown is derived from when the
// ticket was created, capped at the event start time.
export const PAYMENT_HOLD_HOURS = 24;
/** Currency is Guarani with no decimals, e.g. "21 PYG". */
export function pyg(amount: number, currency: string = 'PYG'): string {
return formatPrice(Number(amount) || 0, currency || 'PYG');
}
export function isManualProvider(provider?: string): boolean {
return provider === 'bank_transfer' || provider === 'tpago';
}
/** A ticket is unpaid when its payment is still pending (not approved/paid). */
export function isUnpaid(ticket: Pick<UserTicket, 'status'> & { payment?: { status?: string } | null }): boolean {
const ps = ticket.payment?.status;
return ticket.status !== 'cancelled' && (ps === 'pending' || ps === 'failed');
}
export function isAwaitingApproval(ticket: { payment?: { status?: string } | null }): boolean {
return ticket.payment?.status === 'pending_approval';
}
/**
* The moment the seat hold expires. null when paid/awaiting/cancelled or when
* the data needed to compute it is missing.
*/
export function holdExpiry(ticket: UserTicket): Date | null {
if (!isUnpaid(ticket)) return null;
const createdStr = ticket.payment?.createdAt || ticket.createdAt;
if (!createdStr) return null;
const created = parseDate(createdStr);
let expiry = new Date(created.getTime() + PAYMENT_HOLD_HOURS * 60 * 60 * 1000);
// Never hold past the event itself.
const eventStart = ticket.event?.startDatetime ? parseDate(ticket.event.startDatetime) : null;
if (eventStart && eventStart < expiry) expiry = eventStart;
return expiry;
}
/** Amount owed for a ticket (payment amount preferred, falls back to event price). */
export function ticketAmount(ticket: UserTicket): { amount: number; currency: string } {
const amount = ticket.payment?.amount ?? ticket.event?.price ?? 0;
const currency = ticket.payment?.currency ?? ticket.event?.currency ?? 'PYG';
return { amount: Number(amount) || 0, currency };
}
export interface BookingGroup {
bookingId: string;
tickets: UserTicket[];
}
/**
* Group tickets that belong to the same booking (multi-ticket / guest tickets).
* Tickets without a bookingId become single-ticket groups keyed by their id.
* Order of first appearance is preserved.
*/
export function groupByBooking(tickets: UserTicket[]): BookingGroup[] {
const groups = new Map<string, UserTicket[]>();
const order: string[] = [];
for (const ticket of tickets) {
const key = ticket.bookingId || ticket.id;
if (!groups.has(key)) {
groups.set(key, []);
order.push(key);
}
groups.get(key)!.push(ticket);
}
return order.map((bookingId) => ({ bookingId, tickets: groups.get(bookingId)! }));
}
/** Public URL embedded in the scannable QR (matches the PDF + scanner contract). */
export function ticketScanUrl(ticketId: string): string {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `${origin}/ticket/${ticketId}`;
}
/** Download URL for a ticket / whole booking PDF. */
export function ticketPdfUrl(ticket: UserTicket): string {
return ticket.bookingId
? `/api/tickets/booking/${ticket.bookingId}/pdf`
: `/api/tickets/${ticket.id}/pdf`;
}
/** Build a Google Calendar "add event" URL — no backend needed. */
export function googleCalendarUrl(event: Event, locale: string): string {
const start = parseDate(event.startDatetime);
const end = event.endDatetime
? parseDate(event.endDatetime)
: new Date(start.getTime() + 2 * 60 * 60 * 1000);
const fmt = (d: Date) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
const title = (locale === 'es' && event.titleEs ? event.titleEs : event.title) || 'Spanglish';
const params = new URLSearchParams({
action: 'TEMPLATE',
text: title,
dates: `${fmt(start)}/${fmt(end)}`,
location: event.location || '',
ctz: EVENT_TIMEZONE,
});
return `https://www.google.com/calendar/render?${params.toString()}`;
}
/**
* Share a single (spare) ticket's QR with a guest. Uses the Web Share API when
* available, otherwise copies the ticket link to the clipboard.
*/
export async function shareTicket(
ticketId: string,
eventTitle: string,
locale: string
): Promise<'shared' | 'copied' | 'failed'> {
const url = ticketScanUrl(ticketId);
const text =
locale === 'es'
? `Tu entrada para ${eventTitle}`
: `Your ticket for ${eventTitle}`;
try {
if (typeof navigator !== 'undefined' && (navigator as any).share) {
await (navigator as any).share({ title: eventTitle, text, url });
return 'shared';
}
if (typeof navigator !== 'undefined' && navigator.clipboard) {
await navigator.clipboard.writeText(url);
return 'copied';
}
return 'failed';
} catch (err: any) {
// User cancelled the native share sheet.
if (err?.name === 'AbortError') return 'shared';
return 'failed';
}
}
/** True when the event's calendar date is today (Asunción time). */
export function isToday(dateStr?: string): boolean {
if (!dateStr) return false;
const opts: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: EVENT_TIMEZONE,
};
const fmt = (d: Date) => d.toLocaleDateString('en-CA', opts);
return fmt(parseDate(dateStr)) === fmt(new Date());
}
@@ -0,0 +1,95 @@
'use client';
import clsx from 'clsx';
import type { UserTicket, Payment } from '@/lib/api';
// One logical status per meaning, mapped to a single pale colour everywhere.
// confirmed -> pale green
// awaiting -> pale amber (user said they paid, admin checking)
// unpaid -> pale red
// attended -> pale blue (replaces the raw "checked_in" value)
// cancelled -> pale gray
export type DashStatus =
| 'confirmed'
| 'awaiting'
| 'unpaid'
| 'attended'
| 'cancelled';
/**
* Collapse a ticket status + payment status into a single user-facing status.
* Never surface raw values like "checked_in" or "pending_approval".
*/
export function deriveTicketStatus(
ticketStatus?: string,
paymentStatus?: string
): DashStatus {
if (ticketStatus === 'checked_in') return 'attended';
if (ticketStatus === 'cancelled') return 'cancelled';
if (paymentStatus === 'paid' || ticketStatus === 'confirmed') return 'confirmed';
if (paymentStatus === 'pending_approval') return 'awaiting';
return 'unpaid';
}
/** Status for a standalone payment record (Payments tab). */
export function derivePaymentStatus(paymentStatus?: string): DashStatus {
if (paymentStatus === 'paid') return 'confirmed';
if (paymentStatus === 'pending_approval') return 'awaiting';
if (paymentStatus === 'refunded') return 'cancelled';
return 'unpaid';
}
export function statusLabel(status: DashStatus, locale: string): string {
const labels: Record<DashStatus, { en: string; es: string }> = {
confirmed: { en: 'Confirmed', es: 'Confirmado' },
awaiting: { en: 'Awaiting approval', es: 'Esperando aprobación' },
unpaid: { en: 'Unpaid', es: 'No pagado' },
attended: { en: 'Attended', es: 'Asistió' },
cancelled: { en: 'Cancelled', es: 'Cancelado' },
};
return locale === 'es' ? labels[status].es : labels[status].en;
}
const PILL_STYLES: Record<DashStatus, string> = {
confirmed: 'bg-green-100 text-green-800',
awaiting: 'bg-amber-100 text-amber-800',
unpaid: 'bg-red-100 text-red-700',
attended: 'bg-blue-100 text-blue-800',
cancelled: 'bg-gray-100 text-gray-600',
};
export function StatusPill({
status,
locale,
className,
}: {
status: DashStatus;
locale: string;
className?: string;
}) {
return (
<span
className={clsx(
'inline-flex items-center rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap',
PILL_STYLES[status],
className
)}
>
{statusLabel(status, locale)}
</span>
);
}
/** Convenience: derive + render a pill straight from a ticket. */
export function TicketStatusPill({
ticket,
locale,
className,
}: {
ticket: Pick<UserTicket, 'status'> & { payment?: Pick<Payment, 'status'> | null };
locale: string;
className?: string;
}) {
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
return <StatusPill status={status} locale={locale} className={className} />;
}