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,11 +1,12 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import {
AlertDialog,
AlertDialogAction,
@@ -16,7 +17,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { CheckCircle2 } from 'lucide-react'
import { CheckCircle2, Pencil } from 'lucide-react'
interface LiveVotingCriterion {
id: string
@@ -30,12 +31,19 @@ interface LiveVotingFormProps {
projectId: string
votingMode?: 'simple' | 'criteria'
criteria?: LiveVotingCriterion[]
onVoteSubmit: (vote: { score: number; criterionScores?: Record<string, number> }) => void
onVoteSubmit: (vote: {
score: number
criterionScores?: Record<string, number>
comment?: string
}) => void
disabled?: boolean
existingVote?: {
score: number
criterionScoresJson?: Record<string, number>
comment?: string | null
} | null
/** Visual emphasis when the admin opens the scoring phase */
highlighted?: boolean
}
export function LiveVotingForm({
@@ -45,73 +53,113 @@ export function LiveVotingForm({
onVoteSubmit,
disabled = false,
existingVote,
highlighted = false,
}: LiveVotingFormProps) {
const [score, setScore] = useState(existingVote?.score ?? 50)
const [score, setScore] = useState(existingVote?.score ?? 5)
const [criterionScores, setCriterionScores] = useState<Record<string, number>>(
existingVote?.criterionScoresJson ?? {}
)
const [comment, setComment] = useState(existingVote?.comment ?? '')
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
const [hasSubmitted, setHasSubmitted] = useState(!!existingVote)
const [editing, setEditing] = useState(!existingVote)
const handleSubmit = () => {
setConfirmDialogOpen(true)
}
// When the ceremony cursor moves to a new project, reset the form state
useEffect(() => {
setScore(existingVote?.score ?? 5)
setCriterionScores(existingVote?.criterionScoresJson ?? {})
setComment(existingVote?.comment ?? '')
setEditing(!existingVote)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId])
const handleConfirm = () => {
if (votingMode === 'criteria' && criteria) {
// Compute weighted score for display
// The server recomputes the weighted score from criterionScores; this
// 1-10 value is only a fallback and MUST stay within the API's range.
let weightedSum = 0
for (const c of criteria) {
const normalizedScore = (criterionScores[c.id] / c.scale) * 10
const normalizedScore = ((criterionScores[c.id] ?? 0) / c.scale) * 10
weightedSum += normalizedScore * c.weight
}
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum))) * 10 // Scale to 100 for display
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
onVoteSubmit({
score: computedScore,
criterionScores,
comment: comment.trim() || undefined,
})
} else {
onVoteSubmit({ score })
onVoteSubmit({ score, comment: comment.trim() || undefined })
}
setHasSubmitted(true)
setEditing(false)
setConfirmDialogOpen(false)
}
if (hasSubmitted || disabled) {
if (!editing) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<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">Vote Submitted</p>
{votingMode === 'simple' && (
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/100</p>
)}
{votingMode === 'criteria' && criteria && (
{votingMode === 'criteria' && criteria ? (
<div className="mt-3 text-sm text-muted-foreground space-y-1">
{criteria.map((c) => (
<div key={c.id} className="flex justify-between gap-4">
<div key={c.id} className="flex justify-between gap-6">
<span>{c.label}:</span>
<span className="font-medium">{criterionScores[c.id] ?? 0}/{c.scale}</span>
<span className="font-medium">
{criterionScores[c.id] ?? 0}/{c.scale}
</span>
</div>
))}
</div>
) : (
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/10</p>
)}
{comment.trim() && (
<p className="mt-3 max-w-md text-center text-xs italic text-muted-foreground">
{comment.trim()}
</p>
)}
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setEditing(true)}
disabled={disabled}
>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit vote
</Button>
<p className="mt-2 text-xs text-muted-foreground">
You can revise your vote until the session closes
</p>
</CardContent>
</Card>
)
}
const commentField = (
<div className="space-y-2">
<Label className="text-sm">Comment (optional)</Label>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Visible to admins alongside your scores…"
rows={2}
/>
</div>
)
// Criteria-based voting
if (votingMode === 'criteria' && criteria && criteria.length > 0) {
const allScored = criteria.every((c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0)
const allScored = criteria.every(
(c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0
)
return (
<>
<Card>
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardHeader>
<CardTitle>Criteria-Based Voting</CardTitle>
<CardTitle>Score This Project</CardTitle>
<CardDescription>Score each criterion individually</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@@ -153,7 +201,7 @@ export function LiveVotingForm({
onValueChange={(values) =>
setCriterionScores({
...criterionScores,
[criterion.id]: values[0],
[criterion.id]: Math.max(1, values[0]),
})
}
min={0}
@@ -164,13 +212,15 @@ export function LiveVotingForm({
</div>
))}
{commentField}
<Button
onClick={handleSubmit}
disabled={!allScored}
onClick={() => setConfirmDialogOpen(true)}
disabled={!allScored || disabled}
className="w-full"
size="lg"
>
Submit Vote
{existingVote ? 'Update Vote' : 'Submit Vote'}
</Button>
</CardContent>
</Card>
@@ -179,17 +229,19 @@ export function LiveVotingForm({
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription>
<AlertDialogDescription asChild>
<div className="space-y-2 mt-2">
<p className="font-medium">Your scores:</p>
{criteria.map((c) => (
<div key={c.id} className="flex justify-between text-sm">
<span>{c.label}:</span>
<span className="font-semibold">{criterionScores[c.id]}/{c.scale}</span>
<span className="font-semibold">
{criterionScores[c.id]}/{c.scale}
</span>
</div>
))}
<p className="text-xs text-muted-foreground mt-3">
This action cannot be undone. Are you sure?
You can still revise your vote until the session closes.
</p>
</div>
</AlertDialogDescription>
@@ -204,13 +256,13 @@ export function LiveVotingForm({
)
}
// Simple voting (0-100 slider)
// Simple voting (1-10 slider — matches the API contract)
return (
<>
<Card>
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardHeader>
<CardTitle>Live Voting</CardTitle>
<CardDescription>Rate this project on a scale of 0-100</CardDescription>
<CardTitle>Score This Project</CardTitle>
<CardDescription>Rate this project on a scale of 1-10</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
@@ -219,10 +271,12 @@ export function LiveVotingForm({
<div className="flex items-center gap-3">
<Input
type="number"
min="0"
max="100"
min="1"
max="10"
value={score}
onChange={(e) => setScore(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))}
onChange={(e) =>
setScore(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))
}
className="w-20 text-center"
/>
<span className="text-2xl font-bold text-primary">{score}</span>
@@ -231,34 +285,35 @@ export function LiveVotingForm({
<Slider
value={[score]}
onValueChange={(values) => setScore(values[0])}
min={0}
max={100}
onValueChange={(values) => setScore(Math.max(1, values[0]))}
min={1}
max={10}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Poor (0)</span>
<span>Average (50)</span>
<span>Excellent (100)</span>
<span>Poor (1)</span>
<span>Average (5)</span>
<span>Excellent (10)</span>
</div>
</div>
<Button onClick={handleSubmit} className="w-full" size="lg">
Submit Vote
{commentField}
<Button onClick={() => setConfirmDialogOpen(true)} className="w-full" size="lg" disabled={disabled}>
{existingVote ? 'Update Vote' : 'Submit Vote'}
</Button>
</CardContent>
</Card>
{/* Confirmation Dialog */}
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription>
You are about to submit a score of <strong>{score}/100</strong>. This action cannot
be undone. Are you sure?
You are about to submit a score of <strong>{score}/10</strong>. You can still revise
it until the session closes.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>