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,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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user