feat(applicant): My Logistics card (hotel/flights/visa+nationality) + confirm-page dashboard link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-04 16:53:21 +02:00
parent 74cd111e3a
commit 53b623fb20
3 changed files with 300 additions and 1 deletions

View File

@@ -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) */}
<AttendingMembersCard />
{/* Grand-finale logistics: hotel, flight, visa (auto-hides when not a confirmed finalist) */}
<MyLogisticsCard />
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
<MentorConversationCard projectId={project.id} />

View File

@@ -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 }) {
</p>
<p className="text-muted-foreground text-sm">
We&apos;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.
</p>
<Button asChild className="mt-4">
<Link href="/applicant">Go to my dashboard</Link>
</Button>
</CardContent>
</Card>
)

View File

@@ -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 (
<div className="space-y-0.5">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide">
{icon}
{label}
</div>
<div className="text-sm">
{[flightNumber, airport].filter(Boolean).join(' · ')}
{at && (
<span className="text-muted-foreground ml-1">
{formatMonacoTime(at)}{' '}
<span className="text-xs">(Monaco time)</span>
</span>
)}
</div>
</div>
)
}
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 (
<div className="flex items-center gap-2">
<span className="text-sm">{currentNationality}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
setValue(currentNationality)
setEditing(true)
}}
aria-label="Edit nationality"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
)
}
return (
<div className="flex items-center gap-2">
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="e.g. French"
className="h-8 max-w-[200px] text-sm"
disabled={mutation.isPending}
/>
<Button
size="sm"
className="h-8"
disabled={!value.trim() || mutation.isPending}
onClick={() => mutation.mutate({ nationality: value.trim() })}
>
{mutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
</Button>
{currentNationality && (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
setValue(currentNationality)
setEditing(false)
}}
disabled={mutation.isPending}
>
Cancel
</Button>
)}
</div>
)
}
export function MyLogisticsCard() {
const { data, isLoading } = trpc.applicant.getMyLogistics.useQuery()
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-56" />
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
)
}
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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-teal-500/10 p-1.5">
<Hotel className="h-4 w-4 text-teal-600" />
</div>
Your grand-finale logistics
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{/* Hotel */}
<div className="space-y-1">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Hotel
</p>
{hotel ? (
<div className="space-y-0.5">
<p className="text-sm font-medium">
{hotel.link ? (
<a
href={hotel.link}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4"
>
{hotel.name}
</a>
) : (
hotel.name
)}
</p>
{hotel.address && (
<p className="text-muted-foreground text-sm">{hotel.address}</p>
)}
</div>
) : (
<p className="text-muted-foreground text-sm">Hotel details coming soon.</p>
)}
</div>
{/* Flights */}
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Flights
</p>
{hasFlightData ? (
<div className="space-y-3">
<FlightRow
label="Arrival"
icon={<PlaneLanding className="h-3 w-3" />}
flightNumber={myFlight.arrivalFlightNumber}
airport={myFlight.arrivalAirport}
at={myFlight.arrivalAt}
/>
<FlightRow
label="Departure"
icon={<PlaneTakeoff className="h-3 w-3" />}
flightNumber={myFlight.departureFlightNumber}
airport={myFlight.departureAirport}
at={myFlight.departureAt}
/>
<Badge variant={FLIGHT_BADGE[myFlight.status].variant}>
{FLIGHT_BADGE[myFlight.status].label}
</Badge>
</div>
) : (
<p className="text-muted-foreground text-sm">
Your flight details will appear here once arranged.
</p>
)}
</div>
{/* Visa — only when visaVisible */}
{visaVisible && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Visa
</p>
<div className="space-y-2">
<Badge
variant={myVisa ? VISA_BADGE[myVisa.status].variant : 'outline'}
className="gap-1"
>
<ShieldCheck className="h-3 w-3" />
{myVisa ? VISA_BADGE[myVisa.status].label : 'Not started'}
</Badge>
{myVisa && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">Passport nationality</p>
<NationalityField
currentNationality={myVisa.nationality}
onSaved={() => {}}
/>
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
)
}