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 { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
||||||
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
||||||
import { AttendingMembersCard } from '@/components/applicant/attending-members-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 { LunchBanner } from '@/components/applicant/lunch-banner'
|
||||||
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
|
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
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 attendee roster (auto-hides until confirmation status is CONFIRMED) */}
|
||||||
<AttendingMembersCard />
|
<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) */}
|
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
|
||||||
<MentorConversationCard projectId={project.id} />
|
<MentorConversationCard projectId={project.id} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, use, useEffect, useMemo, useState } from 'react'
|
import { Suspense, use, useEffect, useMemo, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -180,8 +181,11 @@ function FinalistConfirmContent({ token }: { token: string }) {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
We'll be in touch shortly with travel and lunch logistics. You can edit your team
|
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>
|
</p>
|
||||||
|
<Button asChild className="mt-4">
|
||||||
|
<Link href="/applicant">Go to my dashboard</Link>
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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