Require an explicit payment method on booking, hide stale release banners for past or inactive events, open event editing in place on the detail page, and auto-reject unconfirmed payments after events end without sending email. Co-authored-by: Cursor <cursoragent@cursor.com>
424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
'use client';
|
||
|
||
import { useMemo, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import {
|
||
CalendarIcon,
|
||
ClockIcon,
|
||
MapPinIcon,
|
||
ArrowTopRightOnSquareIcon,
|
||
TicketIcon,
|
||
UserPlusIcon,
|
||
QrCodeIcon,
|
||
} from '@heroicons/react/24/outline';
|
||
import toast from 'react-hot-toast';
|
||
import Card from '@/components/ui/Card';
|
||
import Button from '@/components/ui/Button';
|
||
import type { UserTicket, NextEventInfo } from '@/lib/api';
|
||
import { useLanguage } from '@/context/LanguageContext';
|
||
import { formatDateLong, formatTime, parseDate } from '@/lib/utils';
|
||
import { StatusPill, deriveTicketStatus } from './_shared/status';
|
||
import {
|
||
groupByBooking,
|
||
isUnpaid,
|
||
isAwaitingApproval,
|
||
isOnHold,
|
||
isActionableAttention,
|
||
ticketAmount,
|
||
shareTicket,
|
||
isToday,
|
||
type BookingGroup,
|
||
} from './_shared/helpers';
|
||
import PayActions from './_shared/PayActions';
|
||
import AttentionBanner from './_shared/AttentionBanner';
|
||
import QrTicketModal from './_shared/QrTicketModal';
|
||
|
||
interface OverviewTabProps {
|
||
nextEvent: NextEventInfo | null;
|
||
tickets: UserTicket[];
|
||
locale: string;
|
||
userName: string;
|
||
onChange: () => void;
|
||
}
|
||
|
||
export default function OverviewTab({
|
||
nextEvent,
|
||
tickets,
|
||
locale,
|
||
userName,
|
||
onChange,
|
||
}: OverviewTabProps) {
|
||
const [qrTickets, setQrTickets] = useState<UserTicket[] | null>(null);
|
||
|
||
const activeTickets = useMemo(
|
||
() => tickets.filter((t) => t.status !== 'cancelled'),
|
||
[tickets]
|
||
);
|
||
|
||
// Banner: the single booking that most needs attention (unpaid first, then
|
||
// awaiting approval), ordered by soonest event.
|
||
const attentionTicket = useMemo(() => {
|
||
const candidates = activeTickets.filter(
|
||
(t) =>
|
||
isActionableAttention(t) &&
|
||
(isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t))
|
||
);
|
||
const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2);
|
||
candidates.sort((a, b) => {
|
||
const aUnpaid = priority(a);
|
||
const bUnpaid = priority(b);
|
||
if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid;
|
||
const aStart = a.event?.startDatetime
|
||
? parseDate(a.event.startDatetime).getTime()
|
||
: Infinity;
|
||
const bStart = b.event?.startDatetime
|
||
? parseDate(b.event.startDatetime).getTime()
|
||
: Infinity;
|
||
return aStart - bStart;
|
||
});
|
||
return candidates[0] || null;
|
||
}, [activeTickets]);
|
||
|
||
// Hero: full tickets for the next event's booking.
|
||
const heroGroup = useMemo<BookingGroup | null>(() => {
|
||
if (!nextEvent) return null;
|
||
const primary =
|
||
activeTickets.find((t) => t.id === nextEvent.ticket.id) || null;
|
||
if (!primary) return null;
|
||
const groupTickets = primary.bookingId
|
||
? activeTickets.filter((t) => t.bookingId === primary.bookingId)
|
||
: [primary];
|
||
return { bookingId: primary.bookingId || primary.id, tickets: groupTickets };
|
||
}, [nextEvent, activeTickets]);
|
||
|
||
// "Also coming up": upcoming booking groups other than the hero.
|
||
const upcomingGroups = useMemo<BookingGroup[]>(() => {
|
||
const now = Date.now();
|
||
const groups = groupByBooking(
|
||
activeTickets.filter(
|
||
(t) => t.event && parseDate(t.event.startDatetime).getTime() > now
|
||
)
|
||
);
|
||
return groups.filter((g) => g.bookingId !== heroGroup?.bookingId);
|
||
}, [activeTickets, heroGroup]);
|
||
|
||
const handleShare = async (ticket: UserTicket) => {
|
||
const title =
|
||
(locale === 'es' && ticket.event?.titleEs
|
||
? ticket.event.titleEs
|
||
: ticket.event?.title) || 'Event';
|
||
const result = await shareTicket(ticket.id, title, 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');
|
||
};
|
||
|
||
// Empty state.
|
||
if (activeTickets.length === 0) {
|
||
return (
|
||
<Card className="p-8 text-center">
|
||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-yellow/15">
|
||
<TicketIcon className="h-7 w-7 text-primary-yellow" />
|
||
</div>
|
||
<h2 className="mb-1 text-xl font-semibold">
|
||
{locale === 'es' ? `¡Hola, ${userName}!` : `Welcome, ${userName}!`}
|
||
</h2>
|
||
<p className="mx-auto mb-6 max-w-sm text-gray-600">
|
||
{locale === 'es'
|
||
? 'Todavía no tienes reservas. Encuentra tu primer evento de intercambio de idiomas.'
|
||
: "You have no bookings yet. Find your first language exchange event."}
|
||
</p>
|
||
<Link href="/events">
|
||
<Button size="lg">
|
||
{locale === 'es' ? 'Explorar eventos' : 'Browse events'}
|
||
</Button>
|
||
</Link>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 1 — Attention banner (only when money is owed / awaiting). */}
|
||
{attentionTicket && (
|
||
<AttentionBanner
|
||
ticket={attentionTicket}
|
||
locale={locale}
|
||
onChange={onChange}
|
||
/>
|
||
)}
|
||
|
||
{/* 2 — Next event hero. */}
|
||
{heroGroup && heroGroup.tickets[0].event && (
|
||
<HeroCard
|
||
group={heroGroup}
|
||
locale={locale}
|
||
onChange={onChange}
|
||
onShowQr={() => setQrTickets(heroGroup.tickets)}
|
||
onShare={handleShare}
|
||
/>
|
||
)}
|
||
|
||
{/* 3 — Also coming up. */}
|
||
{upcomingGroups.length > 0 && (
|
||
<Card className="p-6">
|
||
<h3 className="mb-4 text-lg font-semibold">
|
||
{locale === 'es' ? 'También se viene' : 'Also coming up'}
|
||
</h3>
|
||
<div className="space-y-3">
|
||
{upcomingGroups.map((group) => {
|
||
const t = group.tickets[0];
|
||
const status = deriveTicketStatus(t.status, t.payment?.status);
|
||
const title =
|
||
(locale === 'es' && t.event?.titleEs
|
||
? t.event.titleEs
|
||
: t.event?.title) || 'Event';
|
||
const { amount, currency } = ticketAmount(t);
|
||
return (
|
||
<div
|
||
key={group.bookingId}
|
||
className="flex flex-col gap-3 rounded-card bg-secondary-gray p-4 sm:flex-row sm:items-center sm:justify-between"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<p className="truncate font-medium">{title}</p>
|
||
{group.tickets.length > 1 && (
|
||
<span className="text-xs text-gray-500">
|
||
×{group.tickets.length}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="mt-0.5 text-sm text-gray-600">
|
||
{t.event && formatDateLong(t.event.startDatetime, locale as 'en' | 'es')}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<StatusPill status={status} locale={locale} />
|
||
{isOnHold(t) ? (
|
||
<PayActions
|
||
ticketId={t.id}
|
||
amount={amount}
|
||
currency={currency}
|
||
destination={title}
|
||
locale={locale}
|
||
onPaid={onChange}
|
||
layout="inline"
|
||
size="sm"
|
||
onHold
|
||
/>
|
||
) : isUnpaid(t) ? (
|
||
<PayActions
|
||
ticketId={t.id}
|
||
amount={amount}
|
||
currency={currency}
|
||
destination={title}
|
||
locale={locale}
|
||
onPaid={onChange}
|
||
layout="inline"
|
||
size="sm"
|
||
/>
|
||
) : (
|
||
(status === 'confirmed' || status === 'attended') && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setQrTickets(group.tickets)}
|
||
>
|
||
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
|
||
</Button>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 6 — Browse events. */}
|
||
<div className="flex justify-center pt-2">
|
||
<Link href="/events">
|
||
<Button variant="outline" size="lg">
|
||
{locale === 'es' ? 'Explorar eventos' : 'Browse events'}
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
|
||
{qrTickets && (
|
||
<QrTicketModal
|
||
tickets={qrTickets}
|
||
locale={locale}
|
||
onClose={() => setQrTickets(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HeroCard({
|
||
group,
|
||
locale,
|
||
onChange,
|
||
onShowQr,
|
||
onShare,
|
||
}: {
|
||
group: BookingGroup;
|
||
locale: string;
|
||
onChange: () => void;
|
||
onShowQr: () => void;
|
||
onShare: (ticket: UserTicket) => void;
|
||
}) {
|
||
const { t } = useLanguage();
|
||
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 today = isToday(event.startDatetime);
|
||
const showQrReady = status === 'confirmed' || status === 'attended';
|
||
|
||
return (
|
||
<Card className="overflow-hidden">
|
||
{/* Image with status pill + today badge overlay. */}
|
||
<div className="relative h-44 w-full bg-secondary-gray sm:h-56">
|
||
{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-12 w-12 text-gray-300" />
|
||
</div>
|
||
)}
|
||
<div className="absolute right-3 top-3 flex items-center gap-2">
|
||
{today && (
|
||
<span className="rounded-full bg-primary-yellow px-3 py-1 text-xs font-semibold text-primary-dark shadow-sm">
|
||
{locale === 'es' ? 'Hoy' : 'Today'}
|
||
</span>
|
||
)}
|
||
<StatusPill status={status} locale={locale} className="shadow-sm" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-6">
|
||
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-primary-yellow">
|
||
{locale === 'es' ? 'Tu próximo evento' : 'Your next event'}
|
||
</p>
|
||
<div className="mb-4 flex items-start justify-between gap-3">
|
||
<h3 className="text-2xl font-bold leading-tight">{title}</h3>
|
||
{multi && (
|
||
<span className="whitespace-nowrap rounded-full bg-secondary-gray px-3 py-1 text-sm font-medium text-gray-700">
|
||
{locale === 'es'
|
||
? `${group.tickets.length} entradas`
|
||
: `${group.tickets.length} tickets`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-5 space-y-2 text-gray-600">
|
||
<p className="flex items-center gap-3">
|
||
<CalendarIcon className="h-5 w-5 text-primary-yellow" />
|
||
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
|
||
</p>
|
||
<p className="flex items-center gap-3">
|
||
<ClockIcon className="h-5 w-5 text-primary-yellow" />
|
||
{formatTime(event.startDatetime, locale as 'en' | 'es')}
|
||
</p>
|
||
<p className="flex items-center gap-3">
|
||
<MapPinIcon className="h-5 w-5 text-primary-yellow" />
|
||
<span className="flex-1">{event.location}</span>
|
||
{event.locationUrl && (
|
||
<a
|
||
href={event.locationUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-1 whitespace-nowrap text-sm font-medium text-secondary-blue hover:underline"
|
||
>
|
||
{locale === 'es' ? 'Cómo llegar' : 'Get directions'}
|
||
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
|
||
</a>
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Today + ready → show the QR prominently for check-in. */}
|
||
{today && showQrReady && (
|
||
<div className="mb-4 rounded-card bg-primary-yellow/10 p-4 text-center">
|
||
<p className="mb-3 text-sm font-medium text-primary-dark">
|
||
{locale === 'es'
|
||
? 'Muestra este código en la entrada'
|
||
: 'Show this code at the door'}
|
||
</p>
|
||
<Button onClick={onShowQr} size="lg" className="w-full sm:w-auto">
|
||
<QrCodeIcon className="mr-2 h-5 w-5" />
|
||
{locale === 'es' ? 'Mostrar código QR' : 'Show QR code'}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Smart primary action. */}
|
||
{isOnHold(ticket) ? (
|
||
<PayActions
|
||
ticketId={ticket.id}
|
||
amount={amount}
|
||
currency={currency}
|
||
destination={title}
|
||
locale={locale}
|
||
onPaid={onChange}
|
||
layout="inline"
|
||
size="md"
|
||
onHold
|
||
/>
|
||
) : isUnpaid(ticket) ? (
|
||
<PayActions
|
||
ticketId={ticket.id}
|
||
amount={amount}
|
||
currency={currency}
|
||
destination={title}
|
||
locale={locale}
|
||
onPaid={onChange}
|
||
layout="inline"
|
||
size="md"
|
||
/>
|
||
) : isAwaitingApproval(ticket) ? (
|
||
<p className="rounded-card bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||
{t('dashboard.payment.receivedConfirmShortly')}
|
||
</p>
|
||
) : (
|
||
!today && (
|
||
<Button onClick={onShowQr} size="md">
|
||
<TicketIcon className="mr-2 h-5 w-5" />
|
||
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
|
||
</Button>
|
||
)
|
||
)}
|
||
|
||
{/* Multi-ticket guest reminder. */}
|
||
{multi && showQrReady && (
|
||
<div className="mt-4 flex flex-col gap-2 rounded-card border border-secondary-light-gray p-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<p className="text-sm text-gray-600">
|
||
{locale === 'es'
|
||
? 'Tienes una entrada de más para un invitado.'
|
||
: 'You have a spare ticket for a guest.'}
|
||
</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => onShare(group.tickets[1])}
|
||
>
|
||
<UserPlusIcon className="mr-2 h-4 w-4" />
|
||
{locale === 'es' ? 'Compartir con un invitado' : 'Share with a guest'}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|