Round system redesign: criteria voting, audience voting, pipeline view, and admin UX improvements
- Schema: Extend LiveVotingSession with votingMode, criteriaJson, audience fields; add AudienceVoter model; make LiveVote.userId nullable for audience voters - Backend: Criteria-based voting with weighted scores, audience registration/voting with token-based dedup, configurable jury/audience weight in results - Jury UI: Criteria scoring with per-criterion sliders alongside simple 1-10 mode - Public audience voting page at /vote/[sessionId] with mobile-first design - Admin live voting: Tabbed layout (Session/Config/Results), criteria config, audience settings, weight-adjustable results with tie detection - Round type settings: Visual card selector replacing dropdown, feature tags - Round detail page: Live event status section, type-specific stats and actions - Round pipeline view: Horizontal visualization with bottleneck detection, List/Pipeline toggle on rounds page - SSE: Separate jury/audience vote events, audience vote tracking - Field visibility: Hide irrelevant fields per round type in create/edit forms Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
391
src/app/(public)/vote/[sessionId]/page.tsx
Normal file
391
src/app/(public)/vote/[sessionId]/page.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect, useCallback } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Users,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Vote,
|
||||
} from 'lucide-react'
|
||||
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}
|
||||
|
||||
const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
const TOKEN_KEY = 'mopc_audience_token_'
|
||||
|
||||
function AudienceVotingContent({ sessionId }: { sessionId: string }) {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [countdown, setCountdown] = useState<number | null>(null)
|
||||
const [hasVotedForProject, setHasVotedForProject] = useState(false)
|
||||
|
||||
// Check for saved token on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(TOKEN_KEY + sessionId)
|
||||
if (saved) {
|
||||
setToken(saved)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Fetch session data
|
||||
const { data, isLoading, refetch } = trpc.liveVoting.getAudienceSession.useQuery(
|
||||
{ sessionId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
|
||||
// SSE for real-time updates
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const onProjectChange = useCallback(() => {
|
||||
setSelectedScore(null)
|
||||
setHasVotedForProject(false)
|
||||
setCountdown(null)
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const { isConnected } = useLiveVotingSSE(sessionId, {
|
||||
onSessionStatus,
|
||||
onProjectChange,
|
||||
})
|
||||
|
||||
// Register mutation
|
||||
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setToken(result.token)
|
||||
localStorage.setItem(TOKEN_KEY + sessionId, result.token)
|
||||
toast.success('Registered! You can now vote.')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Vote mutation
|
||||
const castVote = trpc.liveVoting.castAudienceVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote recorded!')
|
||||
setHasVotedForProject(true)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Update countdown
|
||||
useEffect(() => {
|
||||
if (data?.timeRemaining !== null && data?.timeRemaining !== undefined) {
|
||||
setCountdown(data.timeRemaining)
|
||||
} else {
|
||||
setCountdown(null)
|
||||
}
|
||||
}, [data?.timeRemaining])
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (countdown === null || countdown <= 0) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev === null || prev <= 0) return 0
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [countdown])
|
||||
|
||||
// Reset vote state when project changes
|
||||
useEffect(() => {
|
||||
setSelectedScore(null)
|
||||
setHasVotedForProject(false)
|
||||
}, [data?.currentProject?.id])
|
||||
|
||||
const handleRegister = () => {
|
||||
register.mutate({
|
||||
sessionId,
|
||||
identifier: identifier.trim() || undefined,
|
||||
identifierType: identifier.includes('@')
|
||||
? 'email'
|
||||
: identifier.trim()
|
||||
? 'name'
|
||||
: 'anonymous',
|
||||
})
|
||||
}
|
||||
|
||||
const handleVote = (score: number) => {
|
||||
if (!token || !data?.currentProject) return
|
||||
setSelectedScore(score)
|
||||
castVote.mutate({
|
||||
sessionId,
|
||||
projectId: data.currentProject.id,
|
||||
score,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <AudienceVotingSkeleton />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Session Not Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
This voting session does not exist or has ended.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.session.allowAudienceVotes) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Users className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Audience Voting Not Available</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Audience voting is not enabled for this session.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Registration step
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Vote className="h-6 w-6 text-primary" />
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.round.program.name} - {data.session.round.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-center text-muted-foreground">
|
||||
Register to participate in audience voting
|
||||
</p>
|
||||
|
||||
{data.session.audienceRequireId && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier">Your Email or Name</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
placeholder="email@example.com or your name"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for audience voting verification
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data.session.audienceRequireId && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier">Your Name (optional)</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
placeholder="Enter your name (optional)"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleRegister}
|
||||
disabled={
|
||||
register.isPending ||
|
||||
(data.session.audienceRequireId && !identifier.trim())
|
||||
}
|
||||
>
|
||||
{register.isPending ? 'Registering...' : 'Join Voting'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Voting UI
|
||||
const isVoting = data.session.status === 'IN_PROGRESS'
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Vote className="h-6 w-6 text-primary" />
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.round.program.name} - {data.session.round.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{isVoting && data.currentProject ? (
|
||||
<>
|
||||
{/* Current project */}
|
||||
<div className="text-center space-y-2">
|
||||
<Badge variant="default" className="mb-2">
|
||||
Now Presenting
|
||||
</Badge>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{data.currentProject.title}
|
||||
</h2>
|
||||
{data.currentProject.teamName && (
|
||||
<p className="text-muted-foreground">
|
||||
{data.currentProject.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-primary mb-2">
|
||||
{countdown !== null ? `${countdown}s` : '--'}
|
||||
</div>
|
||||
<Progress
|
||||
value={countdown !== null ? (countdown / 30) * 100 : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Time remaining to vote
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Score buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-center">Your Score</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{SCORE_OPTIONS.map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
size="lg"
|
||||
className="h-14 text-xl font-bold"
|
||||
onClick={() => handleVote(score)}
|
||||
disabled={castVote.isPending || countdown === 0}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
1 = Low, 10 = Excellent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vote status */}
|
||||
{hasVotedForProject && (
|
||||
<Alert className="bg-green-500/10 border-green-500">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<AlertDescription>
|
||||
Your vote has been recorded! You can change it before time runs out.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Waiting state */
|
||||
<div className="text-center py-12">
|
||||
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Waiting for Next Project
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{data.session.status === 'COMPLETED'
|
||||
? 'The voting session has ended. Thank you for participating!'
|
||||
: 'Voting will begin when the next project is presented.'}
|
||||
</p>
|
||||
{data.session.status !== 'COMPLETED' && (
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
This page will update automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isConnected ? 'Connected' : 'Reconnecting...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AudienceVotingSkeleton() {
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<Skeleton className="h-6 w-40 mx-auto" />
|
||||
<Skeleton className="h-4 w-56 mx-auto mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AudienceVotingPage({ params }: PageProps) {
|
||||
const { sessionId } = use(params)
|
||||
|
||||
return <AudienceVotingContent sessionId={sessionId} />
|
||||
}
|
||||
Reference in New Issue
Block a user