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,394 @@
|
||||
'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,
|
||||
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) => isUnpaid(t) || isAwaitingApproval(t)
|
||||
);
|
||||
candidates.sort((a, b) => {
|
||||
const aUnpaid = isUnpaid(a) ? 0 : 1;
|
||||
const bUnpaid = isUnpaid(b) ? 0 : 1;
|
||||
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} />
|
||||
{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. */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user