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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user