Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,13 +8,13 @@ import { Button } from '@/components/ui/button'
|
||||
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function RoundApplyPage() {
|
||||
export default function StageApplyPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const slug = params.slug as string
|
||||
|
||||
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
|
||||
{ slug, mode: 'round' },
|
||||
{ slug, mode: 'stage' },
|
||||
{ retry: false }
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function RoundApplyPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !config || config.mode !== 'round') {
|
||||
if (error || !config || config.mode !== 'stage') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
|
||||
<div className="text-center">
|
||||
@@ -45,17 +45,17 @@ export default function RoundApplyPage() {
|
||||
|
||||
return (
|
||||
<ApplyWizardDynamic
|
||||
mode="round"
|
||||
mode="stage"
|
||||
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
|
||||
programName={config.program.name}
|
||||
programYear={config.program.year}
|
||||
roundId={config.round.id}
|
||||
isOpen={config.round.isOpen}
|
||||
submissionDeadline={config.round.submissionEndDate}
|
||||
stageId={config.stage.id}
|
||||
isOpen={config.stage.isOpen}
|
||||
submissionDeadline={config.stage.submissionEndDate}
|
||||
onSubmit={async (data) => {
|
||||
await submitMutation.mutateAsync({
|
||||
mode: 'round',
|
||||
roundId: config.round.id,
|
||||
mode: 'stage',
|
||||
stageId: config.stage.id,
|
||||
data: data as any,
|
||||
})
|
||||
}}
|
||||
|
||||
267
src/app/(public)/live-scores/stage/[sessionId]/page.tsx
Normal file
267
src/app/(public)/live-scores/stage/[sessionId]/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useCallback } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Trophy,
|
||||
Star,
|
||||
RefreshCw,
|
||||
Waves,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
export default function StageScoreboardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}) {
|
||||
const { sessionId } = use(params)
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
// Fetch audience context for stage info and cohort data
|
||||
const { data: context } = trpc.live.getAudienceContext.useQuery(
|
||||
{ sessionId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
|
||||
const stageInfo = context?.stageInfo
|
||||
|
||||
// Fetch scores by querying cohort projects + their votes
|
||||
// We use getAudienceContext.openCohorts to get project IDs, then aggregate
|
||||
const openCohorts = context?.openCohorts ?? []
|
||||
const allProjectIds = openCohorts.flatMap(
|
||||
(c: { projectIds?: string[] }) => c.projectIds ?? []
|
||||
)
|
||||
const uniqueProjectIds = [...new Set(allProjectIds)]
|
||||
|
||||
// For live scores, we poll the audience context and compute from the cursor data
|
||||
// The getAudienceContext returns projects with vote data when available
|
||||
const projectScores = (context as Record<string, unknown>)?.projectScores as
|
||||
| Array<{
|
||||
projectId: string
|
||||
title: string
|
||||
teamName?: string | null
|
||||
averageScore: number
|
||||
voteCount: number
|
||||
}>
|
||||
| undefined
|
||||
|
||||
// Sort projects by average score descending
|
||||
const sortedProjects = [...(projectScores ?? [])].sort(
|
||||
(a, b) => b.averageScore - a.averageScore
|
||||
)
|
||||
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore), 1)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/10 to-background">
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Waves className="h-10 w-10 text-brand-blue" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-brand-blue dark:text-brand-teal">
|
||||
MOPC Live Scores
|
||||
</h1>
|
||||
{stageInfo && (
|
||||
<p className="text-sm text-muted-foreground">{stageInfo.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Live
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paused state */}
|
||||
{isPaused && (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex items-center justify-center gap-3 py-6">
|
||||
<Pause className="h-8 w-8 text-amber-600" />
|
||||
<p className="text-lg font-semibold text-amber-700 dark:text-amber-300">
|
||||
Session Paused
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current project highlight */}
|
||||
{activeProject && !isPaused && (
|
||||
<Card className="overflow-hidden border-2 border-brand-blue/30 dark:border-brand-teal/30">
|
||||
<div className="h-1.5 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="flex items-center justify-center gap-2 text-brand-teal text-xs uppercase tracking-wide mb-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Now Presenting
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent className="text-center">
|
||||
<p className="text-sm">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Leaderboard / Rankings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
Rankings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedProjects.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-muted-foreground">
|
||||
{uniqueProjectIds.length === 0
|
||||
? 'Waiting for presentations to begin...'
|
||||
: 'No scores yet. Votes will appear here in real-time.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedProjects.map((project, index) => {
|
||||
const isCurrent = project.projectId === activeProject?.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.projectId}
|
||||
className={`rounded-lg p-4 transition-all duration-300 ${
|
||||
isCurrent
|
||||
? 'bg-brand-blue/5 border border-brand-blue/30 dark:bg-brand-teal/5 dark:border-brand-teal/30'
|
||||
: 'bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Rank */}
|
||||
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{index === 0 ? (
|
||||
<Trophy className="h-4 w-4 text-amber-500" />
|
||||
) : index === 1 ? (
|
||||
<span className="font-bold text-gray-400">2</span>
|
||||
) : index === 2 ? (
|
||||
<span className="font-bold text-amber-600">3</span>
|
||||
) : (
|
||||
<span className="font-bold text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{project.title}</p>
|
||||
{project.teamName && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-xl font-bold tabular-nums">
|
||||
{project.averageScore?.toFixed(1) || '--'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.voteCount} vote{project.voteCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3">
|
||||
<Progress
|
||||
value={
|
||||
project.averageScore
|
||||
? (project.averageScore / maxScore) * 100
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Waiting state */}
|
||||
{!activeProject && !isPaused && sortedProjects.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Trophy className="h-16 w-16 text-amber-500/30 mb-4" />
|
||||
<p className="text-xl font-semibold">Waiting for presentations</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Scores will appear here as projects are presented.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* SSE error */}
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3 text-sm text-destructive">
|
||||
{sseError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Monaco Ocean Protection Challenge · Live Scoreboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
@@ -151,7 +150,7 @@ export function SubmissionDetailClient() {
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{project.round?.program?.year ? `${project.round.program.year} Edition` : ''}{project.round?.name ? ` - ${project.round.name}` : ''}
|
||||
{project.program?.year ? `${project.program.year} Edition` : ''}{project.program?.name ? ` - ${project.program.name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,17 +321,6 @@ export function SubmissionDetailClient() {
|
||||
|
||||
{/* Documents Tab */}
|
||||
<TabsContent value="documents">
|
||||
{/* File Requirements Upload Slots */}
|
||||
{project.roundId && (
|
||||
<div className="mb-4">
|
||||
<RequirementUploadList
|
||||
projectId={project.id}
|
||||
roundId={project.roundId}
|
||||
disabled={!isDraft}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Uploaded Documents</CardTitle>
|
||||
@@ -349,7 +337,7 @@ export function SubmissionDetailClient() {
|
||||
<div className="space-y-2">
|
||||
{project.files.map((file) => {
|
||||
const Icon = fileTypeIcons[file.fileType] || File
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -410,23 +409,7 @@ export default function TeamManagementPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Documents */}
|
||||
{teamData?.roundId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Team Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Upload required documents for your project. Any team member can upload files.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RequirementUploadList
|
||||
projectId={projectId}
|
||||
roundId={teamData.roundId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Team Documents - available via documents page */}
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="bg-muted/50">
|
||||
|
||||
@@ -133,8 +133,8 @@ export function MySubmissionClient() {
|
||||
<div className="space-y-4">
|
||||
{submissions.map((project) => {
|
||||
const projectStatus = project.status ?? 'SUBMITTED'
|
||||
const roundName = project.round?.name
|
||||
const programYear = project.round?.program?.year
|
||||
const programName = project.program?.name
|
||||
const programYear = project.program?.year
|
||||
|
||||
return (
|
||||
<Card key={project.id}>
|
||||
@@ -143,7 +143,7 @@ export function MySubmissionClient() {
|
||||
<div>
|
||||
<CardTitle className="text-lg">{project.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{programYear ? `${programYear} Edition` : ''}{roundName ? ` - ${roundName}` : ''}
|
||||
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={statusColors[projectStatus] || 'secondary'}>
|
||||
|
||||
@@ -194,7 +194,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.round.program.name} - {data.session.round.name}
|
||||
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -257,7 +257,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.round.program.name} - {data.session.round.name}
|
||||
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
215
src/app/(public)/vote/stage/[sessionId]/page.tsx
Normal file
215
src/app/(public)/vote/stage/[sessionId]/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Waves,
|
||||
} from 'lucide-react'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function StageAudienceVotePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}) {
|
||||
const { sessionId } = use(params)
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [hasVoted, setHasVoted] = useState(false)
|
||||
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
const castVoteMutation = trpc.live.castStageVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Your vote has been recorded!')
|
||||
setHasVoted(true)
|
||||
setLastVotedProjectId(activeProject?.id ?? null)
|
||||
setSelectedScore(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Reset vote state when project changes
|
||||
if (activeProject?.id && activeProject.id !== lastVotedProjectId) {
|
||||
if (hasVoted) {
|
||||
setHasVoted(false)
|
||||
setSelectedScore(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = () => {
|
||||
if (!activeProject || selectedScore === null) return
|
||||
castVoteMutation.mutate({
|
||||
sessionId,
|
||||
projectId: activeProject.id,
|
||||
score: selectedScore,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
|
||||
<div className="mx-auto max-w-lg px-4 py-8 space-y-6">
|
||||
{/* MOPC branding header */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Waves className="h-8 w-8 text-brand-blue" />
|
||||
<h1 className="text-2xl font-bold text-brand-blue dark:text-brand-teal">
|
||||
MOPC Live Vote
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Live
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{sseError}</p>
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paused state */}
|
||||
{isPaused ? (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Pause className="h-16 w-16 text-amber-600 mb-4" />
|
||||
<p className="text-xl font-semibold">Voting Paused</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Please wait for the next project...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : activeProject ? (
|
||||
<>
|
||||
{/* Active project card */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-center">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Voting controls */}
|
||||
<Card>
|
||||
<CardContent className="py-6 space-y-6">
|
||||
{hasVoted ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<CheckCircle2 className="h-16 w-16 text-emerald-600 mb-4" />
|
||||
<p className="text-xl font-semibold">Thank you!</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Your vote has been recorded. Waiting for the next project...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-center text-sm font-medium text-muted-foreground">
|
||||
Rate this project from 1 to 10
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-14 text-xl font-bold tabular-nums',
|
||||
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light scale-110'
|
||||
)}
|
||||
onClick={() => setSelectedScore(score)}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-14 text-lg bg-brand-blue hover:bg-brand-blue-light"
|
||||
disabled={selectedScore === null || castVoteMutation.isPending}
|
||||
onClick={handleVote}
|
||||
>
|
||||
{castVoteMutation.isPending ? (
|
||||
'Submitting...'
|
||||
) : selectedScore !== null ? (
|
||||
<>
|
||||
<Star className="mr-2 h-5 w-5" />
|
||||
Vote {selectedScore}/10
|
||||
</>
|
||||
) : (
|
||||
'Select a score to vote'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Star className="h-16 w-16 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-xl font-semibold">Waiting...</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
The next project will appear here shortly.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Monaco Ocean Protection Challenge
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user