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:
@@ -32,6 +32,7 @@ import {
|
||||
type Criterion,
|
||||
} from '@/components/forms/evaluation-form-builder'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ROUND_FIELD_VISIBILITY } from '@/types/round-settings'
|
||||
import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor'
|
||||
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar, LayoutTemplate } from 'lucide-react'
|
||||
import {
|
||||
@@ -202,17 +203,18 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
}, [evaluationForm, loadingForm, criteriaInitialized])
|
||||
|
||||
const onSubmit = async (data: UpdateRoundForm) => {
|
||||
const visibility = ROUND_FIELD_VISIBILITY[roundType]
|
||||
// Update round with type, settings, and notification
|
||||
await updateRound.mutateAsync({
|
||||
id: roundId,
|
||||
name: data.name,
|
||||
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
|
||||
requiredReviews: visibility?.showRequiredReviews ? data.requiredReviews : 0,
|
||||
minAssignmentsPerJuror: data.minAssignmentsPerJuror,
|
||||
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
|
||||
roundType,
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ?? null,
|
||||
votingEndAt: data.votingEndAt ?? null,
|
||||
votingStartAt: visibility?.showVotingWindow ? (data.votingStartAt ?? null) : null,
|
||||
votingEndAt: visibility?.showVotingWindow ? (data.votingEndAt ?? null) : null,
|
||||
})
|
||||
|
||||
// Update evaluation form if criteria changed and no evaluations exist
|
||||
@@ -301,7 +303,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
)}
|
||||
/>
|
||||
|
||||
{roundType !== 'FILTERING' && (
|
||||
{ROUND_FIELD_VISIBILITY[roundType]?.showRequiredReviews && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="requiredReviews"
|
||||
@@ -328,6 +330,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{ROUND_FIELD_VISIBILITY[roundType]?.showAssignmentLimits && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -379,6 +382,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,7 @@ import {
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { ROUND_FIELD_VISIBILITY, roundTypeLabels } from '@/types/round-settings'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
||||
@@ -148,8 +149,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
// Progress data is now included in round.get response (eliminates duplicate evaluation.groupBy)
|
||||
const progress = round?.progress
|
||||
|
||||
// Check if this is a filtering round - roundType is stored directly on the round
|
||||
// Check round type
|
||||
const isFilteringRound = round?.roundType === 'FILTERING'
|
||||
const isLiveEventRound = round?.roundType === 'LIVE_EVENT'
|
||||
|
||||
// Filtering queries (only fetch for FILTERING rounds)
|
||||
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
|
||||
@@ -165,6 +167,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
{ roundId },
|
||||
{ enabled: isFilteringRound }
|
||||
)
|
||||
|
||||
// Live voting session (only fetch for LIVE_EVENT rounds)
|
||||
const { data: liveSession } = trpc.liveVoting.getSession.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: isLiveEventRound, staleTime: 30_000 }
|
||||
)
|
||||
const { data: latestJob, refetch: refetchLatestJob } =
|
||||
trpc.filtering.getLatestJob.useQuery(
|
||||
{ roundId },
|
||||
@@ -398,6 +406,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const visibility = ROUND_FIELD_VISIBILITY[round.roundType] || ROUND_FIELD_VISIBILITY.EVALUATION
|
||||
const isLiveEvent = isLiveEventRound
|
||||
|
||||
const now = new Date()
|
||||
const isVotingOpen =
|
||||
round.status === 'ACTIVE' &&
|
||||
@@ -462,6 +473,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{round.name}</h1>
|
||||
{getStatusBadge()}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{roundTypeLabels[round.roundType] || round.roundType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -577,6 +591,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{visibility.showAssignmentLimits && (
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Judge Assignments</CardTitle>
|
||||
@@ -593,7 +608,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{visibility.showRequiredReviews && (
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
|
||||
@@ -606,28 +623,48 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
<p className="text-xs text-muted-foreground">per project</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completion</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{isLiveEvent ? 'Session' : 'Completion'}
|
||||
</CardTitle>
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
|
||||
{isLiveEvent ? (
|
||||
<Zap className="h-4 w-4 text-brand-teal" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{progress?.completionPercentage || 0}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress?.completedAssignments || 0} of {progress?.totalAssignments || 0}
|
||||
</p>
|
||||
{isLiveEvent && liveSession ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold capitalize">
|
||||
{liveSession.status === 'IN_PROGRESS' ? 'Live' : liveSession.status.toLowerCase().replace('_', ' ')}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{liveSession.audienceVoterCount} audience voters
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{progress?.completionPercentage || 0}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress?.completedAssignments || 0} of {progress?.totalAssignments || 0}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Progress */}
|
||||
{progress && progress.totalAssignments > 0 && (
|
||||
{/* Progress - only for evaluation rounds */}
|
||||
{visibility.showRequiredReviews && progress && progress.totalAssignments > 0 && (
|
||||
<AnimatedCard index={1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -662,7 +699,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Voting Window */}
|
||||
{/* Voting Window - only for evaluation rounds */}
|
||||
{visibility.showVotingWindow && (
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -759,6 +797,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Filtering Section (for FILTERING rounds) */}
|
||||
{isFilteringRound && (
|
||||
@@ -1268,8 +1307,133 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Live Event Section (for LIVE_EVENT rounds) */}
|
||||
{isLiveEventRound && liveSession && (
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Zap className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Live Voting Session
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Real-time voting during project presentations
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/live-voting`}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Session Status */}
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
|
||||
{liveSession.status === 'IN_PROGRESS' ? (
|
||||
<Play className="h-5 w-5 text-green-600" />
|
||||
) : liveSession.status === 'COMPLETED' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{liveSession.status === 'IN_PROGRESS' ? 'Live Now' : liveSession.status.toLowerCase().replace('_', ' ')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
|
||||
<FileText className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{liveSession.round.projects.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Projects</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
|
||||
<BarChart3 className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{liveSession.currentVotes.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Jury Votes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
|
||||
<Users className="h-5 w-5 text-violet-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{liveSession.audienceVoterCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Audience</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Status Indicator */}
|
||||
{liveSession.status === 'IN_PROGRESS' && liveSession.currentProjectId && (
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
|
||||
<div>
|
||||
<p className="font-medium text-green-900 dark:text-green-100">
|
||||
Voting in progress
|
||||
</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
Project {(liveSession.currentProjectIndex ?? 0) + 1} of {liveSession.round.projects.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{liveSession.status === 'COMPLETED' && (
|
||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-blue-600" />
|
||||
<p className="font-medium text-blue-900 dark:text-blue-100">
|
||||
Voting session completed - view results in the dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="flex flex-wrap gap-3 pt-2 border-t">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/live-voting`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Session Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
{liveSession.allowAudienceVotes && (
|
||||
<Button variant="outline" asChild>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={`/vote/${liveSession.id}` as any}>
|
||||
<QrCode className="mr-2 h-4 w-4" />
|
||||
Audience Voting Page
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<AnimatedCard index={4}>
|
||||
<AnimatedCard index={isFilteringRound || isLiveEventRound ? 5 : 4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
@@ -1311,44 +1475,70 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Round Management */}
|
||||
{/* Type-Specific Management */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Round Management</p>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{isFilteringRound ? 'Filtering' : isLiveEvent ? 'Live Event' : 'Evaluation'} Management
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Jury Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/live-voting`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Live Voting
|
||||
</Link>
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
|
||||
disabled={bulkSummaries.isPending}
|
||||
>
|
||||
{bulkSummaries.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileSearch className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<p>Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/* Filtering-specific actions */}
|
||||
{isFilteringRound && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/filtering/rules`}>
|
||||
<ListChecks className="mr-2 h-4 w-4" />
|
||||
View Rules
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Evaluation-specific actions */}
|
||||
{visibility.showAssignmentLimits && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Jury Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Live Event-specific actions */}
|
||||
{isLiveEvent && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/live-voting`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Open Live Session
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Evaluation-round-only: AI Summaries */}
|
||||
{!isFilteringRound && !isLiveEvent && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
|
||||
disabled={bulkSummaries.isPending}
|
||||
>
|
||||
{bulkSummaries.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileSearch className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<p>Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ROUND_FIELD_VISIBILITY } from '@/types/round-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle, Bell, LayoutTemplate } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
@@ -124,14 +125,15 @@ function CreateRoundContent() {
|
||||
})
|
||||
|
||||
const onSubmit = async (data: CreateRoundForm) => {
|
||||
const visibility = ROUND_FIELD_VISIBILITY[roundType]
|
||||
await createRound.mutateAsync({
|
||||
programId: data.programId,
|
||||
name: data.name,
|
||||
roundType,
|
||||
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
|
||||
requiredReviews: visibility?.showRequiredReviews ? data.requiredReviews : 0,
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ?? undefined,
|
||||
votingEndAt: data.votingEndAt ?? undefined,
|
||||
votingStartAt: visibility?.showVotingWindow ? (data.votingStartAt ?? undefined) : undefined,
|
||||
votingEndAt: visibility?.showVotingWindow ? (data.votingEndAt ?? undefined) : undefined,
|
||||
entryNotificationType: entryNotificationType || undefined,
|
||||
})
|
||||
}
|
||||
@@ -291,7 +293,7 @@ function CreateRoundContent() {
|
||||
)}
|
||||
/>
|
||||
|
||||
{roundType !== 'FILTERING' && (
|
||||
{ROUND_FIELD_VISIBILITY[roundType]?.showRequiredReviews && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="requiredReviews"
|
||||
@@ -326,6 +328,7 @@ function CreateRoundContent() {
|
||||
onSettingsChange={setRoundSettings}
|
||||
/>
|
||||
|
||||
{ROUND_FIELD_VISIBILITY[roundType]?.showVotingWindow && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Voting Window</CardTitle>
|
||||
@@ -377,6 +380,7 @@ function CreateRoundContent() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Team Notification */}
|
||||
<Card>
|
||||
|
||||
@@ -63,10 +63,13 @@ import {
|
||||
Loader2,
|
||||
GripVertical,
|
||||
ArrowRight,
|
||||
List,
|
||||
GitBranchPlus,
|
||||
} from 'lucide-react'
|
||||
import { format, isPast, isFuture } from 'date-fns'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { RoundPipeline } from '@/components/admin/round-pipeline'
|
||||
|
||||
type RoundData = {
|
||||
id: string
|
||||
@@ -81,7 +84,7 @@ type RoundData = {
|
||||
}
|
||||
}
|
||||
|
||||
function RoundsContent() {
|
||||
function RoundsContent({ viewMode }: { viewMode: 'list' | 'pipeline' }) {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({
|
||||
includeRounds: true,
|
||||
})
|
||||
@@ -107,6 +110,45 @@ function RoundsContent() {
|
||||
)
|
||||
}
|
||||
|
||||
if (viewMode === 'pipeline') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{programs.map((program, index) => (
|
||||
<AnimatedCard key={program.id} index={index}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{program.year} Edition</CardTitle>
|
||||
<CardDescription>
|
||||
{program.name} - {program.status}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/admin/rounds/new?program=${program.id}`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Round
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(program.rounds && program.rounds.length > 0) ? (
|
||||
<RoundPipeline rounds={program.rounds} programName={program.name} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
|
||||
<p>No rounds created yet</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{programs.map((program, index) => (
|
||||
@@ -669,6 +711,8 @@ function RoundsListSkeleton() {
|
||||
}
|
||||
|
||||
export default function RoundsPage() {
|
||||
const [viewMode, setViewMode] = useState<'list' | 'pipeline'>('list')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -679,11 +723,31 @@ export default function RoundsPage() {
|
||||
Manage selection rounds and voting periods
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-lg border p-1">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="mr-1.5 h-4 w-4" />
|
||||
List
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'pipeline' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={() => setViewMode('pipeline')}
|
||||
>
|
||||
<GitBranchPlus className="mr-1.5 h-4 w-4" />
|
||||
Pipeline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<RoundsListSkeleton />}>
|
||||
<RoundsContent />
|
||||
<RoundsContent viewMode={viewMode} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { toast } from 'sonner'
|
||||
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff } from 'lucide-react'
|
||||
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff, Send } from 'lucide-react'
|
||||
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
|
||||
import type { LiveVotingCriterion } from '@/types/round-settings'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ sessionId: string }>
|
||||
@@ -26,6 +28,7 @@ const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [criterionScores, setCriterionScores] = useState<Record<string, number>>({})
|
||||
const [countdown, setCountdown] = useState<number | null>(null)
|
||||
|
||||
// Fetch session data - reduced polling since SSE handles real-time
|
||||
@@ -34,6 +37,9 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
{ refetchInterval: 10000 }
|
||||
)
|
||||
|
||||
const votingMode = data?.session.votingMode || 'simple'
|
||||
const criteria = (data?.session.criteriaJson as LiveVotingCriterion[] | null) || []
|
||||
|
||||
// SSE for real-time updates
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
@@ -41,6 +47,7 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
|
||||
const onProjectChange = useCallback(() => {
|
||||
setSelectedScore(null)
|
||||
setCriterionScores({})
|
||||
setCountdown(null)
|
||||
refetch()
|
||||
}, [refetch])
|
||||
@@ -88,12 +95,28 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
useEffect(() => {
|
||||
if (data?.userVote) {
|
||||
setSelectedScore(data.userVote.score)
|
||||
// Restore criterion scores if available
|
||||
if (data.userVote.criterionScoresJson) {
|
||||
setCriterionScores(data.userVote.criterionScoresJson as Record<string, number>)
|
||||
}
|
||||
} else {
|
||||
setSelectedScore(null)
|
||||
setCriterionScores({})
|
||||
}
|
||||
}, [data?.userVote, data?.currentProject?.id])
|
||||
|
||||
const handleVote = (score: number) => {
|
||||
// Initialize criterion scores with mid-values when criteria change
|
||||
useEffect(() => {
|
||||
if (votingMode === 'criteria' && criteria.length > 0 && Object.keys(criterionScores).length === 0) {
|
||||
const initial: Record<string, number> = {}
|
||||
for (const c of criteria) {
|
||||
initial[c.id] = Math.ceil(c.scale / 2)
|
||||
}
|
||||
setCriterionScores(initial)
|
||||
}
|
||||
}, [votingMode, criteria, criterionScores])
|
||||
|
||||
const handleSimpleVote = (score: number) => {
|
||||
if (!data?.currentProject) return
|
||||
setSelectedScore(score)
|
||||
vote.mutate({
|
||||
@@ -103,6 +126,37 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
const handleCriteriaVote = () => {
|
||||
if (!data?.currentProject) return
|
||||
|
||||
// Compute a rough overall score for the `score` field
|
||||
let weightedSum = 0
|
||||
for (const c of criteria) {
|
||||
const cScore = criterionScores[c.id] || 1
|
||||
const normalizedScore = (cScore / c.scale) * 10
|
||||
weightedSum += normalizedScore * c.weight
|
||||
}
|
||||
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
|
||||
|
||||
vote.mutate({
|
||||
sessionId,
|
||||
projectId: data.currentProject.id,
|
||||
score: computedScore,
|
||||
criterionScores,
|
||||
})
|
||||
}
|
||||
|
||||
const computeWeightedScore = (): number => {
|
||||
if (criteria.length === 0) return 0
|
||||
let weightedSum = 0
|
||||
for (const c of criteria) {
|
||||
const cScore = criterionScores[c.id] || 1
|
||||
const normalizedScore = (cScore / c.scale) * 10
|
||||
weightedSum += normalizedScore * c.weight
|
||||
}
|
||||
return Math.round(Math.min(10, Math.max(1, weightedSum)) * 10) / 10
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <JuryVotingSkeleton />
|
||||
}
|
||||
@@ -169,27 +223,83 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
</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={vote.isPending || countdown === 0}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
{/* Voting UI - Simple mode */}
|
||||
{votingMode === 'simple' && (
|
||||
<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={() => handleSimpleVote(score)}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
1 = Low, 10 = Excellent
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
1 = Low, 10 = Excellent
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voting UI - Criteria mode */}
|
||||
{votingMode === 'criteria' && criteria.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium text-center">Score Each Criterion</p>
|
||||
{criteria.map((c) => (
|
||||
<div key={c.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{c.label}</p>
|
||||
{c.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{c.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-lg font-bold text-primary ml-3 w-12 text-right">
|
||||
{criterionScores[c.id] || 1}/{c.scale}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={c.scale}
|
||||
step={1}
|
||||
value={[criterionScores[c.id] || 1]}
|
||||
onValueChange={([val]) => {
|
||||
setCriterionScores((prev) => ({
|
||||
...prev,
|
||||
[c.id]: val,
|
||||
}))
|
||||
}}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Computed weighted score */}
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<p className="text-sm font-medium">Weighted Score</p>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{computeWeightedScore().toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleCriteriaVote}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vote status */}
|
||||
{hasVoted && (
|
||||
|
||||
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} />
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest): Promise<Response> {
|
||||
async start(controller) {
|
||||
// Track state for change detection
|
||||
let lastVoteCount = -1
|
||||
let lastAudienceVoteCount = -1
|
||||
let lastProjectId: string | null = null
|
||||
let lastStatus: string | null = null
|
||||
|
||||
@@ -53,6 +54,7 @@ export async function GET(request: NextRequest): Promise<Response> {
|
||||
currentProjectId: true,
|
||||
currentProjectIndex: true,
|
||||
votingEndsAt: true,
|
||||
allowAudienceVotes: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -86,19 +88,21 @@ export async function GET(request: NextRequest): Promise<Response> {
|
||||
|
||||
// Check for vote updates on the current project
|
||||
if (currentSession.currentProjectId) {
|
||||
const voteCount = await prisma.liveVote.count({
|
||||
// Jury votes
|
||||
const juryVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastVoteCount !== -1 && voteCount !== lastVoteCount) {
|
||||
// Get the latest vote info
|
||||
if (lastVoteCount !== -1 && juryVoteCount !== lastVoteCount) {
|
||||
const latestVotes = await prisma.liveVote.findMany({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
select: {
|
||||
score: true,
|
||||
@@ -113,6 +117,7 @@ export async function GET(request: NextRequest): Promise<Response> {
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
_avg: { score: true },
|
||||
_count: true,
|
||||
@@ -120,13 +125,43 @@ export async function GET(request: NextRequest): Promise<Response> {
|
||||
|
||||
sendEvent('vote_update', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
totalVotes: voteCount,
|
||||
totalVotes: juryVoteCount,
|
||||
averageScore: avgScore._avg.score,
|
||||
latestVote: latestVotes[0] || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastVoteCount = voteCount
|
||||
lastVoteCount = juryVoteCount
|
||||
|
||||
// Audience votes (separate event)
|
||||
if (currentSession.allowAudienceVotes) {
|
||||
const audienceVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastAudienceVoteCount !== -1 && audienceVoteCount !== lastAudienceVoteCount) {
|
||||
const audienceAvg = await prisma.liveVote.aggregate({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
_avg: { score: true },
|
||||
})
|
||||
|
||||
sendEvent('audience_vote', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
audienceVotes: audienceVoteCount,
|
||||
audienceAverage: audienceAvg._avg.score,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastAudienceVoteCount = audienceVoteCount
|
||||
}
|
||||
}
|
||||
|
||||
// Stop polling if session is completed
|
||||
|
||||
Reference in New Issue
Block a user