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:
@@ -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} />
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
|
||||
291
src/components/applicant/my-logistics-card.tsx
Normal file
291
src/components/applicant/my-logistics-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user