diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx index 476fdbd..76e17d4 100644 --- a/src/app/(applicant)/applicant/page.tsx +++ b/src/app/(applicant)/applicant/page.tsx @@ -19,6 +19,7 @@ import { CompetitionTimelineSidebar } from '@/components/applicant/competition-t import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card' import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card' import { AttendingMembersCard } from '@/components/applicant/attending-members-card' +import { MyLogisticsCard } from '@/components/applicant/my-logistics-card' import { LunchBanner } from '@/components/applicant/lunch-banner' import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip' import { AnimatedCard } from '@/components/shared/animated-container' @@ -414,6 +415,9 @@ export default function ApplicantDashboardPage() { {/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */} + {/* Grand-finale logistics: hotel, flight, visa (auto-hides when not a confirmed finalist) */} + + {/* Conversation with assigned mentor (auto-hides when no mentor assigned) */} diff --git a/src/app/(public)/finalist/confirm/[token]/page.tsx b/src/app/(public)/finalist/confirm/[token]/page.tsx index c541cc1..812b947 100644 --- a/src/app/(public)/finalist/confirm/[token]/page.tsx +++ b/src/app/(public)/finalist/confirm/[token]/page.tsx @@ -1,6 +1,7 @@ 'use client' import { Suspense, use, useEffect, useMemo, useState } from 'react' +import Link from 'next/link' import { trpc } from '@/lib/trpc/client' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -180,8 +181,11 @@ function FinalistConfirmContent({ token }: { token: string }) {

We'll be in touch shortly with travel and lunch logistics. You can edit your team - selection from your project page closer to the event. + selection and view hotel, flight, and visa details from your dashboard.

+ ) diff --git a/src/components/applicant/my-logistics-card.tsx b/src/components/applicant/my-logistics-card.tsx new file mode 100644 index 0000000..d9e58b7 --- /dev/null +++ b/src/components/applicant/my-logistics-card.tsx @@ -0,0 +1,291 @@ +'use client' + +import { useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { Hotel, PlaneLanding, PlaneTakeoff, ShieldCheck, Pencil, Loader2 } from 'lucide-react' +import { toast } from 'sonner' +import type { FlightDetailStatus, VisaStatus } from '@prisma/client' + +const FLIGHT_BADGE: Record< + FlightDetailStatus, + { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' } +> = { + PENDING: { label: 'Pending confirmation', variant: 'secondary' }, + CONFIRMED: { label: 'Flight confirmed', variant: 'default' }, +} + +const VISA_BADGE: Record< + VisaStatus, + { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' } +> = { + NOT_NEEDED: { label: 'Visa not needed', variant: 'outline' }, + REQUESTED: { label: 'Visa requested', variant: 'secondary' }, + INVITATION_SENT: { label: 'Invitation sent', variant: 'secondary' }, + APPOINTMENT_BOOKED: { label: 'Appointment booked', variant: 'default' }, + GRANTED: { label: 'Visa granted', variant: 'default' }, + DENIED: { label: 'Visa denied', variant: 'destructive' }, +} + +function formatMonacoTime(d: Date | string | null | undefined): string { + if (!d) return '—' + return new Date(d).toLocaleString('en-GB', { + timeZone: 'Europe/Paris', + dateStyle: 'medium', + timeStyle: 'short', + }) +} + +function FlightRow({ + label, + icon, + flightNumber, + airport, + at, +}: { + label: string + icon: React.ReactNode + flightNumber: string | null | undefined + airport: string | null | undefined + at: Date | string | null | undefined +}) { + const hasData = flightNumber || airport || at + if (!hasData) return null + return ( +
+
+ {icon} + {label} +
+
+ {[flightNumber, airport].filter(Boolean).join(' · ')} + {at && ( + + {formatMonacoTime(at)}{' '} + (Monaco time) + + )} +
+
+ ) +} + +function NationalityField({ + currentNationality, + onSaved, +}: { + currentNationality: string | null | undefined + onSaved: () => void +}) { + const [editing, setEditing] = useState(!currentNationality) + const [value, setValue] = useState(currentNationality ?? '') + + const utils = trpc.useUtils() + const mutation = trpc.applicant.updateMyVisaNationality.useMutation({ + onSuccess: () => { + toast.success('Nationality saved.') + utils.applicant.getMyLogistics.invalidate() + setEditing(false) + onSaved() + }, + onError: (err) => { + toast.error(err.message ?? 'Could not save nationality.') + }, + }) + + if (!editing && currentNationality) { + return ( +
+ {currentNationality} + +
+ ) + } + + return ( +
+ setValue(e.target.value)} + placeholder="e.g. French" + className="h-8 max-w-[200px] text-sm" + disabled={mutation.isPending} + /> + + {currentNationality && ( + + )} +
+ ) +} + +export function MyLogisticsCard() { + const { data, isLoading } = trpc.applicant.getMyLogistics.useQuery() + + if (isLoading) { + return ( + + + + + + + + + ) + } + + if (!data) return null + + const { hotel, myFlight, visaVisible, myVisa } = data + + const hasFlightData = + myFlight && + (myFlight.arrivalAt || + myFlight.arrivalFlightNumber || + myFlight.arrivalAirport || + myFlight.departureAt || + myFlight.departureFlightNumber || + myFlight.departureAirport) + + return ( + + + +
+ +
+ Your grand-finale logistics +
+
+ + {/* Hotel */} +
+

+ Hotel +

+ {hotel ? ( +
+

+ {hotel.link ? ( + + {hotel.name} + + ) : ( + hotel.name + )} +

+ {hotel.address && ( +

{hotel.address}

+ )} +
+ ) : ( +

Hotel details coming soon.

+ )} +
+ + {/* Flights */} +
+

+ Flights +

+ {hasFlightData ? ( +
+ } + flightNumber={myFlight.arrivalFlightNumber} + airport={myFlight.arrivalAirport} + at={myFlight.arrivalAt} + /> + } + flightNumber={myFlight.departureFlightNumber} + airport={myFlight.departureAirport} + at={myFlight.departureAt} + /> + + {FLIGHT_BADGE[myFlight.status].label} + +
+ ) : ( +

+ Your flight details will appear here once arranged. +

+ )} +
+ + {/* Visa — only when visaVisible */} + {visaVisible && ( +
+

+ Visa +

+
+ + + {myVisa ? VISA_BADGE[myVisa.status].label : 'Not started'} + + {myVisa && ( +
+

Passport nationality

+ {}} + /> +
+ )} +
+
+ )} +
+
+ ) +}