'use client'
import { Suspense, use, useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
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 { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from 'sonner'
import {
ArrowLeft,
Play,
Pause,
Square,
Clock,
Users,
Zap,
GripVertical,
AlertCircle,
ExternalLink,
RefreshCw,
QrCode,
Settings2,
Scale,
UserCheck,
} from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useLiveVotingSSE, type VoteUpdate } from '@/hooks/use-live-voting-sse'
import { QRCodeDisplay } from '@/components/shared/qr-code-display'
interface PageProps {
params: Promise<{ id: string }>
}
interface Project {
id: string
title: string
teamName: string | null
}
function SortableProject({
project,
isActive,
isVoting,
}: {
project: Project
isActive: boolean
isVoting: boolean
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: project.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
{project.title}
{project.teamName && (
{project.teamName}
)}
{isActive && (
{isVoting ? 'Voting' : 'Current'}
)}
)
}
function LiveVotingContent({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const [projectOrder, setProjectOrder] = useState([])
const [countdown, setCountdown] = useState(null)
const [votingDuration, setVotingDuration] = useState(30)
const [liveVoteCount, setLiveVoteCount] = useState(null)
const [liveAvgScore, setLiveAvgScore] = useState(null)
// Fetch session data - reduced polling since SSE handles real-time
const { data: sessionData, isLoading, refetch } = trpc.liveVoting.getSession.useQuery(
{ roundId },
{ refetchInterval: 5000 }
)
// SSE for real-time vote updates
const onVoteUpdate = useCallback((data: VoteUpdate) => {
setLiveVoteCount(data.totalVotes)
setLiveAvgScore(data.averageScore)
}, [])
const onSessionStatus = useCallback(() => {
refetch()
}, [refetch])
const onProjectChange = useCallback(() => {
setLiveVoteCount(null)
setLiveAvgScore(null)
refetch()
}, [refetch])
const { isConnected } = useLiveVotingSSE(
sessionData?.id || null,
{
onVoteUpdate,
onSessionStatus,
onProjectChange,
}
)
// Mutations
const setOrder = trpc.liveVoting.setProjectOrder.useMutation({
onSuccess: () => {
toast.success('Project order updated')
},
onError: (error) => {
toast.error(error.message)
},
})
const startVoting = trpc.liveVoting.startVoting.useMutation({
onSuccess: () => {
toast.success('Voting started')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const stopVoting = trpc.liveVoting.stopVoting.useMutation({
onSuccess: () => {
toast.success('Voting stopped')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const endSession = trpc.liveVoting.endSession.useMutation({
onSuccess: () => {
toast.success('Session ended')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const updateSessionConfig = trpc.liveVoting.updateSessionConfig.useMutation({
onSuccess: () => {
toast.success('Session config updated')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const updatePresentationSettings = trpc.liveVoting.updatePresentationSettings.useMutation({
onSuccess: () => {
toast.success('Presentation settings updated')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// Initialize project order
useEffect(() => {
if (sessionData) {
const storedOrder = (sessionData.projectOrderJson as string[]) || []
if (storedOrder.length > 0) {
setProjectOrder(storedOrder)
} else {
setProjectOrder(sessionData.round.projects.map((p) => p.id))
}
}
}, [sessionData])
// Countdown timer
useEffect(() => {
if (!sessionData?.votingEndsAt || sessionData.status !== 'IN_PROGRESS') {
setCountdown(null)
return
}
const updateCountdown = () => {
const remaining = new Date(sessionData.votingEndsAt!).getTime() - Date.now()
setCountdown(Math.max(0, Math.floor(remaining / 1000)))
}
updateCountdown()
const interval = setInterval(updateCountdown, 1000)
return () => clearInterval(interval)
}, [sessionData?.votingEndsAt, sessionData?.status])
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = projectOrder.indexOf(active.id as string)
const newIndex = projectOrder.indexOf(over.id as string)
const newOrder = arrayMove(projectOrder, oldIndex, newIndex)
setProjectOrder(newOrder)
if (sessionData) {
setOrder.mutate({
sessionId: sessionData.id,
projectIds: newOrder,
})
}
}
}
const handleStartVoting = (projectId: string) => {
if (!sessionData) return
startVoting.mutate({
sessionId: sessionData.id,
projectId,
durationSeconds: votingDuration,
})
}
const handleStopVoting = () => {
if (!sessionData) return
stopVoting.mutate({ sessionId: sessionData.id })
}
const handleEndSession = () => {
if (!sessionData) return
endSession.mutate({ sessionId: sessionData.id })
}
if (isLoading) {
return
}
if (!sessionData) {
return (
Error
Failed to load session
)
}
const projects = sessionData.round.projects
const sortedProjects = projectOrder
.map((id) => projects.find((p) => p.id === id))
.filter((p): p is Project => !!p)
// Add any projects not in the order
const missingProjects = projects.filter((p) => !projectOrder.includes(p.id))
const allProjects = [...sortedProjects, ...missingProjects]
const isVoting = sessionData.status === 'IN_PROGRESS'
const isCompleted = sessionData.status === 'COMPLETED'
return (
{/* Header */}
Live Voting
{sessionData.status.replace('_', ' ')}
{sessionData.round.program.name} - {sessionData.round.name}
refetch()}>
Refresh
{/* Main control panel */}
{/* Voting status */}
{isVoting && (
Currently Voting
{projects.find((p) => p.id === sessionData.currentProjectId)?.title}
{countdown !== null ? countdown : '--'}s
remaining
{countdown !== null && (
)}
)}
{/* Project order */}
Presentation Order
Drag to reorder projects. Click "Start Voting" to begin voting
for a project.
{allProjects.length === 0 ? (
No finalist projects found for this round
) : (
p.id)}
strategy={verticalListSortingStrategy}
>
{allProjects.map((project) => (
handleStartVoting(project.id)}
disabled={
isVoting ||
isCompleted ||
startVoting.isPending
}
>
))}
)}
{/* Sidebar */}
{/* Controls */}
Controls
End Session
{/* Live stats */}
Current Votes
{isConnected && (
)}
{(() => {
const voteCount = liveVoteCount ?? sessionData.currentVotes.length
const avgScore = liveAvgScore ?? (
sessionData.currentVotes.length > 0
? sessionData.currentVotes.reduce((sum, v) => sum + v.score, 0) / sessionData.currentVotes.length
: null
)
if (voteCount === 0) {
return (
No votes yet
)
}
return (
Total votes
{voteCount}
Average score
{avgScore !== null ? avgScore.toFixed(1) : '--'}
)
})()}
{/* Session Configuration */}
Session Config
Audience Voting
{
updateSessionConfig.mutate({
sessionId: sessionData.id,
allowAudienceVotes: checked,
})
}}
disabled={isCompleted}
/>
{sessionData.allowAudienceVotes && (
)}
Tie-Breaker Method
{
updateSessionConfig.mutate({
sessionId: sessionData.id,
tieBreakerMethod: v as 'admin_decides' | 'highest_individual' | 'revote',
})
}}
disabled={isCompleted}
>
Admin Decides
Highest Individual Score
Revote
Score Display Format
)?.scoreDisplayFormat as string || 'bar'
}
onValueChange={(v) => {
const existing = (sessionData.presentationSettingsJson as Record) || {}
updatePresentationSettings.mutate({
sessionId: sessionData.id,
presentationSettingsJson: {
...existing,
scoreDisplayFormat: v as 'bar' | 'number' | 'radial',
},
})
}}
disabled={isCompleted}
>
Bar Chart
Number Only
Radial
{/* QR Codes & Links */}
Voting Links
Share these links with participants
Open Jury Page
Open Scoreboard
)
}
function LiveVotingSkeleton() {
return (
)
}
export default function LiveVotingPage({ params }: PageProps) {
const { id } = use(params)
return (
}>
)
}