feat(finale): full ceremony UI — admin console, jury phases+notes, deliberation flow, audience voting, big-screen view

- Admin Ceremony tab: phase driver with real server timers + overtime, run
  order with category grouping + send-to-screens, audience windows + QR
  dialog, override slides, timing log, results reveal builder/stepper
- Admin Deliberation tab: per-category session creation from the round's
  jury group, open/close voting, tally/runoff/override/finalize
- Jury live page: ON_DECK/PRESENTING/QA/SCORING aware, autosaved notes,
  vote comments; LiveVotingForm fixed (criteria score >10 rejection bug,
  permanent lock-out after submit) and made revisable
- Jury deliberation page: identityless submitVote, review-before-rank
  context (finale scores editable, ceremony notes, docs link)
- Jury nav: Live Ceremony + per-category Deliberation links
- Public: rebuilt QR audience voting page (anonymous-safe, favorite-pick
  windows, change-until-close), new /live/ceremony/[roundId] projector view
  with animated reveal, confetti, audience QR slide, override slides

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-10 18:34:50 +02:00
parent c9dc1bfabd
commit a2c6baf718
19 changed files with 2903 additions and 559 deletions

View File

@@ -1,150 +1,325 @@
'use client';
'use client'
import { use } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form';
import { CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
import { use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form'
import { LiveVotingForm } from '@/components/jury/live-voting-form'
import { CheckCircle2, ChevronDown, FileText, PenLine, StickyNote } from 'lucide-react'
import { toast } from 'sonner'
export default function JuryDeliberationPage({ params: paramsPromise }: { params: Promise<{ sessionId: string }> }) {
const params = use(paramsPromise);
const utils = trpc.useUtils();
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
/**
* Per-project review context during deliberation: the juror's finale scores
* (revisable in place — "keep" is simply not touching them), their ceremony
* notes, and a pointer to the project documents.
*/
function ProjectReviewCard({
project,
roundId,
finaleInputs,
votingMode,
criteria,
}: {
project: { id: string; title: string; teamName?: string | null }
roundId: string
finaleInputs: any
votingMode: 'simple' | 'criteria'
criteria?: Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
}) {
const utils = trpc.useUtils()
const [open, setOpen] = useState(false)
const myVote = finaleInputs?.votes?.find((v: any) => v.projectId === project.id)
const myNote = finaleInputs?.notes?.find((n: any) => n.projectId === project.id)
const voteMutation = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
utils.liveVoting.getMyFinaleInputs.invalidate({ roundId })
toast.success('Score updated')
},
onError: (err) => toast.error(err.message),
})
return (
<Collapsible open={open} onOpenChange={setOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer py-4 hover:bg-muted/40">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{project.title}</CardTitle>
{project.teamName && (
<CardDescription className="mt-0.5">{project.teamName}</CardDescription>
)}
</div>
<div className="flex items-center gap-2">
{myVote ? (
<Badge variant="secondary" className="tabular-nums">
My score: {myVote.score}/10
</Badge>
) : (
<Badge variant="outline">Not scored</Badge>
)}
<ChevronDown className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 border-t pt-4">
{myNote?.content && (
<div className="rounded-lg bg-muted/40 p-3">
<p className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<StickyNote className="h-3.5 w-3.5" />
Your ceremony notes
</p>
<p className="whitespace-pre-wrap text-sm">{myNote.content}</p>
</div>
)}
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<PenLine className="h-3.5 w-3.5" />
Your grand-finale score edit to revise, or leave as-is to keep it
</p>
{finaleInputs?.session?.id ? (
<LiveVotingForm
projectId={project.id}
votingMode={votingMode}
criteria={criteria}
existingVote={
myVote
? {
score: myVote.score,
criterionScoresJson: myVote.criterionScoresJson as
| Record<string, number>
| undefined,
comment: myVote.comment,
}
: null
}
onVoteSubmit={(vote) =>
voteMutation.mutate({
sessionId: finaleInputs.session.id,
projectId: project.id,
score: vote.score,
criterionScores: vote.criterionScores,
comment: vote.comment,
})
}
disabled={voteMutation.isPending}
/>
) : (
<p className="text-sm text-muted-foreground">No finale voting session found.</p>
)}
</div>
<Button asChild variant="outline" size="sm">
<Link href="/jury/finals-documents">
<FileText className="mr-2 h-3.5 w-3.5" />
Open project documents
</Link>
</Button>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}
export default function JuryDeliberationPage({
params: paramsPromise,
}: {
params: Promise<{ sessionId: string }>
}) {
const params = use(paramsPromise)
const utils = trpc.useUtils()
const { data: me } = trpc.user.me.useQuery()
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
{ sessionId: params.sessionId },
{ refetchInterval: 10_000 },
);
{ refetchInterval: 10_000 }
)
// The deliberation session points at its round; finale inputs live on the
// LIVE_FINAL round's voting session — resolve via my ceremony context.
const { data: ceremony } = trpc.live.getMyCeremonyContext.useQuery()
const finaleRoundId = ceremony?.liveRoundId ?? null
const { data: finaleInputs } = trpc.liveVoting.getMyFinaleInputs.useQuery(
{ roundId: finaleRoundId ?? '' },
{ enabled: !!finaleRoundId }
)
const submitVoteMutation = trpc.deliberation.submitVote.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
toast.success('Vote submitted successfully');
},
onError: (err) => {
toast.error(err.message);
const [submitting, setSubmitting] = useState(false)
const submitVoteMutation = trpc.deliberation.submitVote.useMutation()
const handleSubmitVote = async (
votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>
) => {
setSubmitting(true)
try {
for (const vote of votes) {
await submitVoteMutation.mutateAsync({
sessionId: params.sessionId,
projectId: vote.projectId,
rank: vote.rank,
isWinnerPick: vote.isWinnerPick,
})
}
toast.success('Your ranking has been submitted')
utils.deliberation.getSession.invalidate({ sessionId: params.sessionId })
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to submit vote')
} finally {
setSubmitting(false)
}
});
}
const handleSubmitVote = (votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>) => {
votes.forEach((vote) => {
submitVoteMutation.mutate({
sessionId: params.sessionId,
juryMemberId: '', // TODO: resolve current user's jury member ID from session participants
projectId: vote.projectId,
rank: vote.rank,
isWinnerPick: vote.isWinnerPick
});
});
};
if (isLoading) {
if (isLoading || !me) {
return (
<div className="space-y-6">
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Loading session...</p>
</CardContent>
</Card>
</div>
);
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Loading session</p>
</CardContent>
</Card>
)
}
if (!session) {
return (
<div className="space-y-6">
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Session not found</p>
</CardContent>
</Card>
</div>
);
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Session not found</p>
</CardContent>
</Card>
)
}
const hasVoted = false; // TODO: check if current user has voted in this session
const isParticipant = (session.participants ?? []).some(
(p: any) => p.user?.user?.id === me.id
)
const hasVoted = (session.votes ?? []).some(
(v: any) => v.juryMember?.user?.id === me.id && v.runoffRound === 0
)
const projects = ((session as any).projects ?? []) as Array<{
id: string
title: string
teamName?: string | null
}>
const votingMode = (finaleInputs?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
const criteria = finaleInputs?.session?.criteriaJson as
| Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
| undefined
if (session.status !== 'VOTING') {
const header = (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation {CATEGORY_LABEL[session.category] ?? session.category}</CardTitle>
<CardDescription className="mt-1">{session.round?.name}</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
</Card>
)
const reviewSection = projects.length > 0 && finaleRoundId && (
<div className="space-y-3">
<div>
<h2 className="text-lg font-semibold">Review Before You Rank</h2>
<p className="text-sm text-muted-foreground">
Your grand-finale scores, notes and the project documents revise a score or keep it.
</p>
</div>
{projects.map((p) => (
<ProjectReviewCard
key={p.id}
project={p}
roundId={finaleRoundId}
finaleInputs={finaleInputs}
votingMode={votingMode}
criteria={criteria}
/>
))}
</div>
)
if (session.status !== 'VOTING' && session.status !== 'RUNOFF') {
return (
<div className="space-y-6">
{header}
<Card>
<CardHeader>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription>
{session.round?.name} - {session.category}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12">
<CardContent className="flex flex-col items-center justify-center py-10">
<p className="text-muted-foreground">
{session.status === 'DELIB_OPEN'
? 'Voting has not started yet. Please wait for the admin to open voting.'
? 'Voting has not started yet — you can already review the projects below.'
: session.status === 'TALLYING'
? 'Voting is closed. Results are being tallied.'
: 'This session is locked.'}
? 'Voting is closed. Results are being tallied.'
: 'This session is locked.'}
</p>
</CardContent>
</Card>
{session.status === 'DELIB_OPEN' && reviewSection}
</div>
);
)
}
if (hasVoted) {
if (!isParticipant) {
return (
<div className="space-y-6">
{header}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription className="mt-1">
{session.round?.name} - {session.category}
</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
<p className="font-medium">Vote Submitted</p>
<p className="mt-1 text-sm text-muted-foreground">
Thank you for your participation in this deliberation
<CardContent className="flex flex-col items-center justify-center py-10">
<p className="text-muted-foreground">
You are not a participant of this deliberation session.
</p>
</CardContent>
</Card>
</div>
);
)
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription className="mt-1">
{session.round?.name} - {session.category}
</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
{session.mode === 'SINGLE_WINNER_VOTE'
? 'Select your top choice for this category.'
: 'Rank all projects from best to least preferred.'}
</p>
</CardContent>
</Card>
{header}
<DeliberationRankingForm
projects={session.results?.map((r) => r.project) ?? []}
mode={session.mode}
onSubmit={handleSubmitVote}
disabled={submitVoteMutation.isPending}
/>
{hasVoted ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-10">
<CheckCircle2 className="mb-3 h-12 w-12 text-green-600" />
<p className="font-medium">Ranking Submitted</p>
<p className="mt-1 text-sm text-muted-foreground">
Thank you the chair will review the collective result.
</p>
</CardContent>
</Card>
) : (
<>
{reviewSection}
<div>
<h2 className="mb-2 text-lg font-semibold">
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Pick Your Winner' : 'Your Ranking'}
</h2>
<DeliberationRankingForm
projects={projects}
mode={session.mode}
onSubmit={handleSubmitVote}
disabled={submitting}
/>
</div>
</>
)}
</div>
);
)
}