diff --git a/src/app/(public)/finalist/confirm/[token]/page.tsx b/src/app/(public)/finalist/confirm/[token]/page.tsx new file mode 100644 index 0000000..08bd4e5 --- /dev/null +++ b/src/app/(public)/finalist/confirm/[token]/page.tsx @@ -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(Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, []) + const ms = deadline.getTime() - now + if (ms <= 0) return expired + 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 ( + + {days}d {remHours}h remaining + + ) + } + return ( + + {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}: + {seconds.toString().padStart(2, '0')} remaining + + ) +} + +function FriendlyError({ + title, + message, + icon: Icon, +}: { + title: string + message: string + icon: typeof AlertCircle +}) { + return ( + + +
+ + {title} +
+
+ +

{message}

+
+
+ ) +} + +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>(new Set()) + const [visa, setVisa] = useState>({}) + const [declineReason, setDeclineReason] = useState('') + const [submitState, setSubmitState] = useState<'idle' | 'confirmed' | 'declined' | 'error'>( + 'idle', + ) + const [submitError, setSubmitError] = useState(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 ( +
+ + + +
+ ) + } + + // ── Token errors → friendly states + if (error) { + const msg = error.message ?? '' + if (/expired/i.test(msg)) { + return ( + + ) + } + if (/signature|malformed|payload/i.test(msg)) { + return ( + + ) + } + return ( + + ) + } + if (!data) { + return ( + + ) + } + + // ── Status branches: only PENDING is interactive + if (submitState === 'confirmed' || data.status === 'CONFIRMED') { + return ( + + +
+ + You're in! +
+
+ +

+ Your team's attendance for {data.project.title} is confirmed. +

+

+ 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. +

+
+
+ ) + } + if (submitState === 'declined' || data.status === 'DECLINED') { + return ( + + ) + } + if (data.status === 'EXPIRED') { + return ( + + ) + } + if (data.status === 'SUPERSEDED') { + return ( + + ) + } + + // ── 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 ( +
+ + +
+ + Congratulations! +
+
+ +

+ Your project {data.project.title} is a finalist for the Monaco Ocean + Protection Challenge grand finale. +

+
+

+ Confirm by {formatDeadline(deadline)}. +

+

+ +

+
+
+
+ + + + Who from your team will attend? +

+ You can select up to {cap} team members. Indicate who needs visa + support so we can prepare documents in time. +

+
+ +
    + {data.project.teamMembers.map((tm) => { + const checked = selected.has(tm.userId) + return ( +
  • + + {checked && ( + + )} +
  • + ) + })} +
+ {overCap && ( +

+ Please select no more than {cap} member{cap === 1 ? '' : 's'}. +

+ )} +
+
+ + {submitError && ( +
+ {submitError} +
+ )} + +
+ + + + + + + Decline finalist slot? + + If your team can't attend, we'll offer the slot to a waitlisted team. This + action can't be undone from this page. + + +
+ +