feat: public finalist confirmation page UI

- /finalist/confirm/[token] under (public) route group
- Browser-local-time deadline + zone label + live countdown
- Default-selects up to defaultAttendeeCap team members
- Per-member "Needs visa?" toggle that surfaces only when selected
- Decline AlertDialog with optional reason textarea
- Distinct friendly states for invalid / expired / already-confirmed /
  already-declined / superseded tokens (not generic errors)
- Smoke-tested end-to-end against live dev server: confirmation row
  flipped to CONFIRMED, AttendingMember row created with correct visa flag
This commit is contained in:
Matt
2026-04-28 18:04:25 +02:00
parent 14a81cd6ec
commit 437bed2326

View File

@@ -0,0 +1,421 @@
'use client'
import { Suspense, use, useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { AlertCircle, CheckCircle2, Loader2, PartyPopper, XCircle } from 'lucide-react'
import { TRPCClientError } from '@trpc/client'
interface PageProps {
params: Promise<{ token: string }>
}
function formatDeadline(d: Date): string {
const main = new Intl.DateTimeFormat(undefined, {
dateStyle: 'long',
timeStyle: 'short',
}).format(d)
const tzPart = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
.formatToParts(d)
.find((p) => p.type === 'timeZoneName')?.value
return tzPart ? `${main} (${tzPart})` : main
}
function CountdownLabel({ deadline }: { deadline: Date }) {
const [now, setNow] = useState<number>(Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
const ms = deadline.getTime() - now
if (ms <= 0) return <span className="text-destructive font-medium">expired</span>
const totalSec = Math.floor(ms / 1000)
const hours = Math.floor(totalSec / 3600)
const minutes = Math.floor((totalSec % 3600) / 60)
const seconds = totalSec % 60
if (hours >= 24) {
const days = Math.floor(hours / 24)
const remHours = hours % 24
return (
<span className="font-medium tabular-nums">
{days}d {remHours}h remaining
</span>
)
}
return (
<span className="font-medium tabular-nums">
{hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:
{seconds.toString().padStart(2, '0')} remaining
</span>
)
}
function FriendlyError({
title,
message,
icon: Icon,
}: {
title: string
message: string
icon: typeof AlertCircle
}) {
return (
<Card className="mx-auto max-w-xl">
<CardHeader>
<div className="flex items-center gap-2">
<Icon className="text-muted-foreground h-5 w-5" />
<CardTitle>{title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{message}</p>
</CardContent>
</Card>
)
}
function FinalistConfirmContent({ token }: { token: string }) {
const { data, isLoading, error } = trpc.finalist.getByToken.useQuery({ token }, { retry: false })
const confirmMutation = trpc.finalist.confirm.useMutation()
const declineMutation = trpc.finalist.decline.useMutation()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [visa, setVisa] = useState<Record<string, boolean>>({})
const [declineReason, setDeclineReason] = useState('')
const [submitState, setSubmitState] = useState<'idle' | 'confirmed' | 'declined' | 'error'>(
'idle',
)
const [submitError, setSubmitError] = useState<string | null>(null)
// Default-select all team members once data arrives
useEffect(() => {
if (data?.project.teamMembers && selected.size === 0 && submitState === 'idle') {
const cap = data.project.program.defaultAttendeeCap
const initial = new Set(
data.project.teamMembers.slice(0, cap).map((tm) => tm.userId),
)
setSelected(initial)
}
}, [data, selected.size, submitState])
// ── Loading
if (isLoading) {
return (
<div className="mx-auto max-w-xl space-y-4">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
)
}
// ── Token errors → friendly states
if (error) {
const msg = error.message ?? ''
if (/expired/i.test(msg)) {
return (
<FriendlyError
icon={AlertCircle}
title="This link has expired"
message="If your team still wants to attend, please contact us at info@monaco-opc.com."
/>
)
}
if (/signature|malformed|payload/i.test(msg)) {
return (
<FriendlyError
icon={AlertCircle}
title="This link is not valid"
message="Please check your email or contact us at info@monaco-opc.com."
/>
)
}
return (
<FriendlyError
icon={AlertCircle}
title="Something went wrong"
message={msg || 'Please try again or contact us at info@monaco-opc.com.'}
/>
)
}
if (!data) {
return (
<FriendlyError
icon={AlertCircle}
title="Confirmation not found"
message="Please check your email link or contact us at info@monaco-opc.com."
/>
)
}
// ── Status branches: only PENDING is interactive
if (submitState === 'confirmed' || data.status === 'CONFIRMED') {
return (
<Card className="mx-auto max-w-xl">
<CardHeader>
<div className="flex items-center gap-2">
<PartyPopper className="text-primary h-5 w-5" />
<CardTitle>You&apos;re in!</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="mb-2">
Your team&apos;s attendance for <strong>{data.project.title}</strong> is confirmed.
</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.
</p>
</CardContent>
</Card>
)
}
if (submitState === 'declined' || data.status === 'DECLINED') {
return (
<FriendlyError
icon={XCircle}
title="Your team has declined"
message="If this was a mistake, please contact us at info@monaco-opc.com."
/>
)
}
if (data.status === 'EXPIRED') {
return (
<FriendlyError
icon={AlertCircle}
title="The confirmation deadline has passed"
message="If your team still wants to attend, please contact us at info@monaco-opc.com."
/>
)
}
if (data.status === 'SUPERSEDED') {
return (
<FriendlyError
icon={AlertCircle}
title="This confirmation is no longer active"
message="Please contact us at info@monaco-opc.com for details."
/>
)
}
// ── PENDING: render the form
const cap = data.project.program.defaultAttendeeCap
const deadline = new Date(data.deadline)
const overCap = selected.size > cap
const noneSelected = selected.size === 0
const toggle = (userId: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(userId)
else next.delete(userId)
return next
})
}
const toggleVisa = (userId: string, checked: boolean) => {
setVisa((prev) => ({ ...prev, [userId]: checked }))
}
const handleConfirm = async () => {
setSubmitError(null)
try {
await confirmMutation.mutateAsync({
token,
attendingUserIds: Array.from(selected),
visaFlags: Object.fromEntries(
Array.from(selected).map((uid) => [uid, !!visa[uid]]),
),
})
setSubmitState('confirmed')
} catch (err) {
setSubmitState('error')
const msg =
err instanceof TRPCClientError ? err.message : err instanceof Error ? err.message : 'Failed'
setSubmitError(msg)
}
}
const handleDecline = async () => {
setSubmitError(null)
try {
await declineMutation.mutateAsync({
token,
reason: declineReason.trim() || undefined,
})
setSubmitState('declined')
} catch (err) {
setSubmitState('error')
const msg =
err instanceof TRPCClientError ? err.message : err instanceof Error ? err.message : 'Failed'
setSubmitError(msg)
}
}
return (
<div className="mx-auto max-w-xl space-y-6">
<Card className="border-primary/40 bg-primary/5">
<CardHeader>
<div className="flex items-center gap-2">
<PartyPopper className="text-primary h-5 w-5" />
<CardTitle>Congratulations!</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-2">
<p>
Your project <strong>{data.project.title}</strong> is a finalist for the Monaco Ocean
Protection Challenge grand finale.
</p>
<div className="bg-background border-amber-300 mt-3 rounded-md border-l-4 p-3 dark:border-amber-700">
<p className="text-sm">
<strong>Confirm by {formatDeadline(deadline)}.</strong>
</p>
<p className="text-muted-foreground mt-1 text-xs">
<CountdownLabel deadline={deadline} />
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Who from your team will attend?</CardTitle>
<p className="text-muted-foreground text-sm">
You can select up to <strong>{cap}</strong> team members. Indicate who needs visa
support so we can prepare documents in time.
</p>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{data.project.teamMembers.map((tm) => {
const checked = selected.has(tm.userId)
return (
<li key={tm.userId} className="flex items-start justify-between gap-4">
<label className="flex flex-1 items-start gap-3 cursor-pointer">
<Checkbox
checked={checked}
onCheckedChange={(c) => toggle(tm.userId, c === true)}
className="mt-0.5"
/>
<div>
<div className="font-medium">{tm.user.name ?? tm.user.email}</div>
<div className="text-muted-foreground text-xs">
{tm.user.email}
{tm.role && tm.role !== 'MEMBER' ? ` · ${tm.role.toLowerCase()}` : ''}
</div>
</div>
</label>
{checked && (
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Needs visa?</span>
<Switch
checked={!!visa[tm.userId]}
onCheckedChange={(c) => toggleVisa(tm.userId, c)}
/>
</label>
)}
</li>
)
})}
</ul>
{overCap && (
<p className="text-destructive mt-3 text-sm">
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
</p>
)}
</CardContent>
</Card>
{submitError && (
<div className="border-destructive bg-destructive/10 text-destructive rounded-md border p-3 text-sm">
{submitError}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" className="text-muted-foreground">
We can&apos;t attend
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Decline finalist slot?</AlertDialogTitle>
<AlertDialogDescription>
If your team can&apos;t attend, we&apos;ll offer the slot to a waitlisted team. This
action can&apos;t be undone from this page.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<label className="text-muted-foreground text-sm" htmlFor="decline-reason">
Reason (optional, helps us improve future editions)
</label>
<Textarea
id="decline-reason"
value={declineReason}
onChange={(e) => setDeclineReason(e.target.value)}
placeholder="e.g. logistics conflict, team disbanded, no longer interested"
rows={3}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDecline}
disabled={declineMutation.isPending}
className="bg-destructive hover:bg-destructive/90"
>
{declineMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Decline finalist slot'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
size="lg"
onClick={handleConfirm}
disabled={overCap || noneSelected || confirmMutation.isPending}
>
{confirmMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Confirming
</>
) : (
<>
<CheckCircle2 className="mr-2 h-4 w-4" />
Confirm Attendance
</>
)}
</Button>
</div>
</div>
)
}
export default function FinalistConfirmPage({ params }: PageProps) {
const { token } = use(params)
return (
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
<FinalistConfirmContent token={token} />
</Suspense>
)
}