Files
Spanglish/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx
T
MichilisandCursor cb422332c8 Fix booking defaults, dashboard alerts, admin edit flow, and ended-event payments.
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>
2026-07-12 22:04:22 +00:00

424 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}