feat(finale): full ceremony UI — admin console, jury phases+notes, deliberation flow, audience voting, big-screen view

- Admin Ceremony tab: phase driver with real server timers + overtime, run
  order with category grouping + send-to-screens, audience windows + QR
  dialog, override slides, timing log, results reveal builder/stepper
- Admin Deliberation tab: per-category session creation from the round's
  jury group, open/close voting, tally/runoff/override/finalize
- Jury live page: ON_DECK/PRESENTING/QA/SCORING aware, autosaved notes,
  vote comments; LiveVotingForm fixed (criteria score >10 rejection bug,
  permanent lock-out after submit) and made revisable
- Jury deliberation page: identityless submitVote, review-before-rank
  context (finale scores editable, ceremony notes, docs link)
- Jury nav: Live Ceremony + per-category Deliberation links
- Public: rebuilt QR audience voting page (anonymous-safe, favorite-pick
  windows, change-until-close), new /live/ceremony/[roundId] projector view
  with animated reveal, confetti, audience QR slide, override slides

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-10 18:34:50 +02:00
parent c9dc1bfabd
commit a2c6baf718
19 changed files with 2903 additions and 559 deletions

5
.gitignore vendored
View File

@@ -63,3 +63,8 @@ build-output.txt
private/ private/
public/build-id.json public/build-id.json
.remember/ .remember/
# Local tooling + session screenshots
.claude/
.serena/
/*.png

10
package-lock.json generated
View File

@@ -65,6 +65,7 @@
"openai": "^6.16.0", "openai": "^6.16.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"qrcode.react": "^4.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -13417,6 +13418,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",

View File

@@ -79,6 +79,7 @@
"openai": "^6.16.0", "openai": "^6.16.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"qrcode.react": "^4.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -79,6 +79,8 @@ import {
ListChecks, ListChecks,
FileText, FileText,
Languages, Languages,
MonitorPlay,
Scale,
} from 'lucide-react' } from 'lucide-react'
import { import {
Tooltip, Tooltip,
@@ -93,6 +95,8 @@ import { FileRequirementsEditor } from '@/components/admin/round/file-requiremen
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard' import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview' import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table' import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
import { LiveControlPanel } from '@/components/admin/live/live-control-panel'
import { DeliberationControlPanel } from '@/components/admin/deliberation/deliberation-control-panel'
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card' import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card' import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card' import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
@@ -973,6 +977,10 @@ export default function RoundDetailPage() {
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []), ...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []), ...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []), ...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
...(isGrandFinale ? [{ value: 'ceremony', label: 'Ceremony', icon: MonitorPlay }] : []),
...(round?.roundType === 'DELIBERATION'
? [{ value: 'deliberation', label: 'Deliberation', icon: Scale }]
: []),
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []), ...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []), ...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
{ value: 'config', label: 'Config', icon: Settings }, { value: 'config', label: 'Config', icon: Settings },
@@ -1662,6 +1670,20 @@ export default function RoundDetailPage() {
</TabsContent> </TabsContent>
)} )}
{/* ═══════════ CEREMONY TAB (LIVE_FINAL) ═══════════ */}
{isGrandFinale && (
<TabsContent value="ceremony" className="space-y-4">
<LiveControlPanel roundId={roundId} competitionId={competitionId} />
</TabsContent>
)}
{/* ═══════════ DELIBERATION TAB (DELIBERATION rounds) ═══════════ */}
{round?.roundType === 'DELIBERATION' && (
<TabsContent value="deliberation" className="space-y-4">
<DeliberationControlPanel roundId={roundId} competitionId={competitionId} />
</TabsContent>
)}
{/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */} {/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
{hasJury && !isEvaluation && ( {hasJury && !isEvaluation && (
<TabsContent value="jury" className="space-y-6"> <TabsContent value="jury" className="space-y-6">

View File

@@ -1,76 +1,142 @@
'use client' 'use client'
import { use, useState } from 'react' import { use, useEffect, useMemo, useRef, useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { LiveVotingForm } from '@/components/jury/live-voting-form' import { LiveVotingForm } from '@/components/jury/live-voting-form'
import { remainingSeconds, formatClock } from '@/lib/live-timer'
import { Clock, Mic2, MessageCircleQuestion, PenLine, Sparkles } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
const PHASE_META: Record<string, { label: string; icon: typeof Mic2 }> = {
PRESENTING: { label: 'Presentation', icon: Mic2 },
QA: { label: 'Q&A', icon: MessageCircleQuestion },
SCORING: { label: 'Scoring open', icon: PenLine },
}
function PhaseCountdown({ phase }: { phase: {
phaseStartedAt: Date | string | null
phaseDurationSeconds: number | null
phasePausedAt: Date | string | null
phasePausedAccumMs: number
} }) {
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
const remaining = remainingSeconds(phase)
if (remaining === null) return null
const over = remaining < 0
return (
<Badge
variant={over ? 'destructive' : 'secondary'}
className={`gap-1 tabular-nums text-sm ${over ? 'animate-pulse' : ''}`}
>
<Clock className="h-3.5 w-3.5" />
{formatClock(remaining)}
{over && <span className="font-semibold">OVER</span>}
{phase.phasePausedAt && <span>· paused</span>}
</Badge>
)
}
export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) { export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
const params = use(paramsPromise) const params = use(paramsPromise)
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [notes, setNotes] = useState('')
const [priorDataOpen, setPriorDataOpen] = useState(false)
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId }) const { data: cursor } = trpc.live.getCursor.useQuery(
{ roundId: params.roundId },
// Fetch live voting session data { refetchInterval: 2000 }
const { data: sessionData } = trpc.liveVoting.getSessionForVoting.useQuery(
{ sessionId: params.roundId },
{ enabled: !!params.roundId, refetchInterval: 2000 }
) )
const { data: sessionData } = trpc.liveVoting.getSessionForVotingByRound.useQuery(
{ roundId: params.roundId },
{ refetchInterval: 2000 }
)
const { data: myNotes } = trpc.live.getMyNotes.useQuery({ roundId: params.roundId })
// Placeholder for prior data - this would need to be implemented in evaluation router // ── Persisted notes (autosave, keyed per project) ────────────────────────
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null const [noteDrafts, setNoteDrafts] = useState<Record<string, string>>({})
const [noteStatus, setNoteStatus] = useState<'idle' | 'saving' | 'saved'>('idle')
const submitVoteMutation = trpc.liveVoting.vote.useMutation({ const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
onSuccess: () => { const saveNote = trpc.live.saveNote.useMutation({
utils.liveVoting.getSessionForVoting.invalidate() onSuccess: () => setNoteStatus('saved'),
toast.success('Vote submitted successfully') onError: () => setNoteStatus('idle'),
},
onError: (err: any) => {
toast.error(err.message)
},
}) })
const handleVoteSubmit = (vote: { score: number; criterionScores?: Record<string, number> }) => { const activeProject = cursor?.activeProject ?? null
const projectId = cursor?.activeProject?.id || sessionData?.currentProject?.id const activeProjectId = activeProject?.id ?? null
if (!projectId) return
const sessionId = sessionData?.session?.id || params.roundId const savedNoteFor = useMemo(() => {
const map: Record<string, string> = {}
for (const n of myNotes ?? []) map[n.projectId] = n.content
return map
}, [myNotes])
const currentDraft =
activeProjectId != null
? noteDrafts[activeProjectId] ?? savedNoteFor[activeProjectId] ?? ''
: ''
const handleNoteChange = (value: string) => {
if (!activeProjectId) return
setNoteDrafts((d) => ({ ...d, [activeProjectId]: value }))
setNoteStatus('saving')
if (saveTimer.current) clearTimeout(saveTimer.current)
const projectId = activeProjectId
saveTimer.current = setTimeout(() => {
saveNote.mutate({ roundId: params.roundId, projectId, content: value })
}, 800)
}
// ── Voting ───────────────────────────────────────────────────────────────
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
utils.liveVoting.getSessionForVotingByRound.invalidate()
toast.success('Vote submitted')
},
onError: (err) => toast.error(err.message),
})
const handleVoteSubmit = (vote: {
score: number
criterionScores?: Record<string, number>
comment?: string
}) => {
if (!activeProjectId || !sessionData?.session?.id) return
submitVoteMutation.mutate({ submitVoteMutation.mutate({
sessionId, sessionId: sessionData.session.id,
projectId, projectId: activeProjectId,
score: vote.score, score: vote.score,
criterionScores: vote.criterionScores, criterionScores: vote.criterionScores,
comment: vote.comment,
}) })
} }
// Extract voting mode and criteria from session
const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria' const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
const criteria = (sessionData?.session?.criteriaJson as Array<{ const criteria = sessionData?.session?.criteriaJson as
id: string | Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
label: string | undefined
description?: string
scale: number
weight: number
}> | undefined)
const activeProject = cursor?.activeProject || sessionData?.currentProject const phase = cursor?.projectPhase ?? 'ON_DECK'
const categoryLabel =
activeProject?.competitionCategory === 'STARTUP'
? 'Startup'
: activeProject?.competitionCategory === 'BUSINESS_CONCEPT'
? 'Business Concept'
: null
if (!activeProject) { if (!activeProject) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">Waiting for ceremony to begin...</p> <Sparkles className="mb-3 h-8 w-8 text-brand-teal/60" />
<p className="font-medium">Waiting for the ceremony to begin</p>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
The admin will control which project is displayed Projects will appear here automatically as they take the stage
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -78,105 +144,113 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
) )
} }
// ── ON_DECK: "Up next" banner, no scoring yet ───────────────────────────
if (phase === 'ON_DECK') {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Current Project Display */} <Card className="overflow-hidden border-0 bg-gradient-to-r from-[#053d57] to-[#0a5a7c] text-white">
<CardContent className="py-12 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-white/70">
Up next
</p>
<h1 className="mt-3 text-3xl font-bold sm:text-4xl">{activeProject.title}</h1>
{activeProject.teamName && (
<p className="mt-2 text-lg text-white/80">{activeProject.teamName}</p>
)}
{categoryLabel && (
<Badge className="mt-4 bg-white/15 text-white hover:bg-white/15">{categoryLabel}</Badge>
)}
<p className="mt-6 text-sm text-white/60">Presentation starting shortly</p>
</CardContent>
</Card>
{activeProject.description && (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <CardTitle className="text-base">About this project</CardTitle>
<div>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
<CardDescription className="mt-2">
Live project presentation
</CardDescription>
</div>
{votingMode === 'criteria' && (
<Badge variant="secondary">Criteria Voting</Badge>
)}
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{activeProject.description && ( <p className="text-sm text-muted-foreground">{activeProject.description}</p>
<p className="text-muted-foreground">{activeProject.description}</p>
)}
</CardContent> </CardContent>
</Card> </Card>
)}
</div>
)
}
{/* Prior Jury Data (Collapsible) */} const phaseMeta = PHASE_META[phase] ?? PHASE_META.PRESENTING
{priorData && ( const PhaseIcon = phaseMeta.icon
<Collapsible open={priorDataOpen} onOpenChange={setPriorDataOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Prior Evaluation Data</CardTitle>
{priorDataOpen ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-sm font-medium text-muted-foreground">Average Score</p>
<p className="mt-1 text-2xl font-bold">
{priorData.averageScore?.toFixed(1) || 'N/A'}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="mt-1 text-2xl font-bold">{priorData.evaluationCount || 0}</p>
</div>
</div>
{priorData.strengths && (
<div>
<p className="text-sm font-medium text-muted-foreground">Key Strengths</p>
<p className="mt-1 text-sm">{priorData.strengths}</p>
</div>
)}
{priorData.weaknesses && (
<div>
<p className="text-sm font-medium text-muted-foreground">Areas for Improvement</p>
<p className="mt-1 text-sm">{priorData.weaknesses}</p>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)}
{/* Notes Section */} return (
<div className="space-y-6">
{/* Current Project + phase */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
<CardDescription className="mt-1">
{activeProject.teamName}
{categoryLabel ? ` · ${categoryLabel}` : ''}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant={phase === 'SCORING' ? 'default' : 'outline'} className="gap-1.5">
<PhaseIcon className="h-3.5 w-3.5" />
{phaseMeta.label}
</Badge>
{cursor && <PhaseCountdown phase={cursor} />}
</div>
</div>
</CardHeader>
{activeProject.description && (
<CardContent>
<p className="text-sm text-muted-foreground">{activeProject.description}</p>
</CardContent>
)}
</Card>
{/* Notes — persisted, autosaved */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Your Notes</CardTitle> <CardTitle>Your Notes</CardTitle>
<CardDescription>Optional notes for this project</CardDescription> <CardDescription>Private resurfaced during deliberation</CardDescription>
</div>
<span className="text-xs text-muted-foreground">
{noteStatus === 'saving' ? 'Saving…' : noteStatus === 'saved' ? 'Saved' : ''}
</span>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Textarea <Textarea
value={notes} value={currentDraft}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => handleNoteChange(e.target.value)}
placeholder="Add your observations and comments..." placeholder="Observations during the presentation and Q&A…"
rows={4} rows={4}
/> />
</CardContent> </CardContent>
</Card> </Card>
{/* Voting Form */} {/* Scoring — available from presentation start, spotlighted at SCORING */}
<LiveVotingForm <LiveVotingForm
projectId={activeProject.id} projectId={activeProject.id}
votingMode={votingMode} votingMode={votingMode}
criteria={criteria} criteria={criteria}
existingVote={sessionData?.userVote ? { existingVote={
sessionData?.userVote
? {
score: sessionData.userVote.score, score: sessionData.userVote.score,
criterionScoresJson: sessionData.userVote.criterionScoresJson as Record<string, number> | undefined criterionScoresJson: sessionData.userVote.criterionScoresJson as
} : null} | Record<string, number>
| undefined,
comment: sessionData.userVote.comment,
}
: null
}
onVoteSubmit={handleVoteSubmit} onVoteSubmit={handleVoteSubmit}
disabled={submitVoteMutation.isPending} disabled={submitVoteMutation.isPending}
highlighted={phase === 'SCORING'}
/> />
</div> </div>
) )

View File

@@ -1,150 +1,325 @@
'use client'; 'use client'
import { use } from 'react'; import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'; import Link from 'next/link'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form'; import { Badge } from '@/components/ui/badge'
import { CheckCircle2 } from 'lucide-react'; import { Button } from '@/components/ui/button'
import { toast } from 'sonner'; import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form'
import { LiveVotingForm } from '@/components/jury/live-voting-form'
import { CheckCircle2, ChevronDown, FileText, PenLine, StickyNote } from 'lucide-react'
import { toast } from 'sonner'
export default function JuryDeliberationPage({ params: paramsPromise }: { params: Promise<{ sessionId: string }> }) { const CATEGORY_LABEL: Record<string, string> = {
const params = use(paramsPromise); BUSINESS_CONCEPT: 'Business Concepts',
const utils = trpc.useUtils(); STARTUP: 'Startups',
}
/**
* Per-project review context during deliberation: the juror's finale scores
* (revisable in place — "keep" is simply not touching them), their ceremony
* notes, and a pointer to the project documents.
*/
function ProjectReviewCard({
project,
roundId,
finaleInputs,
votingMode,
criteria,
}: {
project: { id: string; title: string; teamName?: string | null }
roundId: string
finaleInputs: any
votingMode: 'simple' | 'criteria'
criteria?: Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
}) {
const utils = trpc.useUtils()
const [open, setOpen] = useState(false)
const myVote = finaleInputs?.votes?.find((v: any) => v.projectId === project.id)
const myNote = finaleInputs?.notes?.find((n: any) => n.projectId === project.id)
const voteMutation = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
utils.liveVoting.getMyFinaleInputs.invalidate({ roundId })
toast.success('Score updated')
},
onError: (err) => toast.error(err.message),
})
return (
<Collapsible open={open} onOpenChange={setOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer py-4 hover:bg-muted/40">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{project.title}</CardTitle>
{project.teamName && (
<CardDescription className="mt-0.5">{project.teamName}</CardDescription>
)}
</div>
<div className="flex items-center gap-2">
{myVote ? (
<Badge variant="secondary" className="tabular-nums">
My score: {myVote.score}/10
</Badge>
) : (
<Badge variant="outline">Not scored</Badge>
)}
<ChevronDown className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 border-t pt-4">
{myNote?.content && (
<div className="rounded-lg bg-muted/40 p-3">
<p className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<StickyNote className="h-3.5 w-3.5" />
Your ceremony notes
</p>
<p className="whitespace-pre-wrap text-sm">{myNote.content}</p>
</div>
)}
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<PenLine className="h-3.5 w-3.5" />
Your grand-finale score edit to revise, or leave as-is to keep it
</p>
{finaleInputs?.session?.id ? (
<LiveVotingForm
projectId={project.id}
votingMode={votingMode}
criteria={criteria}
existingVote={
myVote
? {
score: myVote.score,
criterionScoresJson: myVote.criterionScoresJson as
| Record<string, number>
| undefined,
comment: myVote.comment,
}
: null
}
onVoteSubmit={(vote) =>
voteMutation.mutate({
sessionId: finaleInputs.session.id,
projectId: project.id,
score: vote.score,
criterionScores: vote.criterionScores,
comment: vote.comment,
})
}
disabled={voteMutation.isPending}
/>
) : (
<p className="text-sm text-muted-foreground">No finale voting session found.</p>
)}
</div>
<Button asChild variant="outline" size="sm">
<Link href="/jury/finals-documents">
<FileText className="mr-2 h-3.5 w-3.5" />
Open project documents
</Link>
</Button>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}
export default function JuryDeliberationPage({
params: paramsPromise,
}: {
params: Promise<{ sessionId: string }>
}) {
const params = use(paramsPromise)
const utils = trpc.useUtils()
const { data: me } = trpc.user.me.useQuery()
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery( const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
{ sessionId: params.sessionId }, { sessionId: params.sessionId },
{ refetchInterval: 10_000 }, { refetchInterval: 10_000 }
); )
// The deliberation session points at its round; finale inputs live on the
// LIVE_FINAL round's voting session — resolve via my ceremony context.
const { data: ceremony } = trpc.live.getMyCeremonyContext.useQuery()
const finaleRoundId = ceremony?.liveRoundId ?? null
const { data: finaleInputs } = trpc.liveVoting.getMyFinaleInputs.useQuery(
{ roundId: finaleRoundId ?? '' },
{ enabled: !!finaleRoundId }
)
const submitVoteMutation = trpc.deliberation.submitVote.useMutation({ const [submitting, setSubmitting] = useState(false)
onSuccess: () => { const submitVoteMutation = trpc.deliberation.submitVote.useMutation()
utils.deliberation.getSession.invalidate();
toast.success('Vote submitted successfully');
},
onError: (err) => {
toast.error(err.message);
}
});
const handleSubmitVote = (votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>) => { const handleSubmitVote = async (
votes.forEach((vote) => { votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>
submitVoteMutation.mutate({ ) => {
setSubmitting(true)
try {
for (const vote of votes) {
await submitVoteMutation.mutateAsync({
sessionId: params.sessionId, sessionId: params.sessionId,
juryMemberId: '', // TODO: resolve current user's jury member ID from session participants
projectId: vote.projectId, projectId: vote.projectId,
rank: vote.rank, rank: vote.rank,
isWinnerPick: vote.isWinnerPick isWinnerPick: vote.isWinnerPick,
}); })
}); }
}; toast.success('Your ranking has been submitted')
utils.deliberation.getSession.invalidate({ sessionId: params.sessionId })
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to submit vote')
} finally {
setSubmitting(false)
}
}
if (isLoading) { if (isLoading || !me) {
return ( return (
<div className="space-y-6">
<Card> <Card>
<CardContent className="flex items-center justify-center py-12"> <CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Loading session...</p> <p className="text-muted-foreground">Loading session</p>
</CardContent> </CardContent>
</Card> </Card>
</div> )
);
} }
if (!session) { if (!session) {
return ( return (
<div className="space-y-6">
<Card> <Card>
<CardContent className="flex items-center justify-center py-12"> <CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Session not found</p> <p className="text-muted-foreground">Session not found</p>
</CardContent> </CardContent>
</Card> </Card>
</div> )
);
} }
const hasVoted = false; // TODO: check if current user has voted in this session const isParticipant = (session.participants ?? []).some(
(p: any) => p.user?.user?.id === me.id
)
const hasVoted = (session.votes ?? []).some(
(v: any) => v.juryMember?.user?.id === me.id && v.runoffRound === 0
)
const projects = ((session as any).projects ?? []) as Array<{
id: string
title: string
teamName?: string | null
}>
const votingMode = (finaleInputs?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
const criteria = finaleInputs?.session?.criteriaJson as
| Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
| undefined
if (session.status !== 'VOTING') { const header = (
return (
<div className="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Deliberation Session</CardTitle> <div className="flex items-start justify-between">
<CardDescription> <div>
{session.round?.name} - {session.category} <CardTitle>Deliberation {CATEGORY_LABEL[session.category] ?? session.category}</CardTitle>
</CardDescription> <CardDescription className="mt-1">{session.round?.name}</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12"> </Card>
)
const reviewSection = projects.length > 0 && finaleRoundId && (
<div className="space-y-3">
<div>
<h2 className="text-lg font-semibold">Review Before You Rank</h2>
<p className="text-sm text-muted-foreground">
Your grand-finale scores, notes and the project documents revise a score or keep it.
</p>
</div>
{projects.map((p) => (
<ProjectReviewCard
key={p.id}
project={p}
roundId={finaleRoundId}
finaleInputs={finaleInputs}
votingMode={votingMode}
criteria={criteria}
/>
))}
</div>
)
if (session.status !== 'VOTING' && session.status !== 'RUNOFF') {
return (
<div className="space-y-6">
{header}
<Card>
<CardContent className="flex flex-col items-center justify-center py-10">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{session.status === 'DELIB_OPEN' {session.status === 'DELIB_OPEN'
? 'Voting has not started yet. Please wait for the admin to open voting.' ? 'Voting has not started yet — you can already review the projects below.'
: session.status === 'TALLYING' : session.status === 'TALLYING'
? 'Voting is closed. Results are being tallied.' ? 'Voting is closed. Results are being tallied.'
: 'This session is locked.'} : 'This session is locked.'}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{session.status === 'DELIB_OPEN' && reviewSection}
</div> </div>
); )
} }
if (hasVoted) { if (!isParticipant) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{header}
<Card> <Card>
<CardHeader> <CardContent className="flex flex-col items-center justify-center py-10">
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription className="mt-1">
{session.round?.name} - {session.category}
</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
<p className="font-medium">Vote Submitted</p>
<p className="mt-1 text-sm text-muted-foreground">
Thank you for your participation in this deliberation
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription className="mt-1">
{session.round?.name} - {session.category}
</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{session.mode === 'SINGLE_WINNER_VOTE' You are not a participant of this deliberation session.
? 'Select your top choice for this category.'
: 'Rank all projects from best to least preferred.'}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div>
)
}
return (
<div className="space-y-6">
{header}
{hasVoted ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-10">
<CheckCircle2 className="mb-3 h-12 w-12 text-green-600" />
<p className="font-medium">Ranking Submitted</p>
<p className="mt-1 text-sm text-muted-foreground">
Thank you the chair will review the collective result.
</p>
</CardContent>
</Card>
) : (
<>
{reviewSection}
<div>
<h2 className="mb-2 text-lg font-semibold">
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Pick Your Winner' : 'Your Ranking'}
</h2>
<DeliberationRankingForm <DeliberationRankingForm
projects={session.results?.map((r) => r.project) ?? []} projects={projects}
mode={session.mode} mode={session.mode}
onSubmit={handleSubmitVote} onSubmit={handleSubmitVote}
disabled={submitVoteMutation.isPending} disabled={submitting}
/> />
</div> </div>
); </>
)}
</div>
)
} }

View File

@@ -0,0 +1,535 @@
'use client'
/**
* Big-screen ceremony view — projected on stage at the grand finale.
* Award-night broadcast aesthetic: deep layered ocean field, extreme
* Montserrat scale contrast, red as a scalpel accent, gold reserved for the
* winner moment. Pure derivation of server state (poll 2s), full-bleed over
* the public layout, no interactive chrome.
*/
import { use, useEffect, useMemo, useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { QRCodeSVG } from 'qrcode.react'
import { trpc } from '@/lib/trpc/client'
import { remainingSeconds, formatClock } from '@/lib/live-timer'
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
const WINDOW_TITLE: Record<string, string> = {
'CATEGORY:BUSINESS_CONCEPT': 'Vote for your favorite Business Concept',
'CATEGORY:STARTUP': 'Vote for your favorite Startup',
OVERALL: 'Vote for your favorite project of the night',
}
function useTick() {
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
}
// ─── Atmosphere ──────────────────────────────────────────────────────────────
function OceanField({ children }: { children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 overflow-hidden bg-[#021f2e] font-[Montserrat,sans-serif] text-white">
{/* Layered ocean-light gradients */}
<div
className="absolute inset-0"
style={{
background:
'radial-gradient(120% 90% at 50% 110%, #0a5a7c 0%, #053d57 45%, #021f2e 100%)',
}}
/>
<motion.div
className="absolute -inset-x-1/4 top-[-40%] h-[80%] opacity-25"
style={{
background:
'radial-gradient(50% 100% at 50% 0%, rgba(85,127,140,0.9) 0%, transparent 70%)',
}}
animate={{ x: ['-8%', '8%', '-8%'] }}
transition={{ repeat: Infinity, duration: 18, ease: 'easeInOut' }}
/>
{/* Grain for projector richness */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.05] mix-blend-overlay"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
}}
/>
{children}
</div>
)
}
function StatusBar({ programName, label }: { programName: string | null; label?: string | null }) {
return (
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-12 py-8">
<p className="text-sm font-semibold uppercase tracking-[0.4em] text-white/50">
{programName ?? 'Monaco Ocean Protection Challenge'}
</p>
{label && (
<p className="flex items-center gap-3 text-sm font-semibold uppercase tracking-[0.4em] text-white/50">
<span className="inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-[#de0f1e]" />
{label}
</p>
)}
</div>
)
}
function SignatureRule() {
return (
<div className="mx-auto flex w-48 items-center gap-0">
<div className="h-px flex-1 bg-white/25" />
<div className="h-[3px] w-10 bg-[#de0f1e]" />
<div className="h-px flex-1 bg-white/25" />
</div>
)
}
const slideIn = {
initial: { opacity: 0, y: 36, scale: 0.985, filter: 'blur(6px)' },
animate: { opacity: 1, y: 0, scale: 1, filter: 'blur(0px)' },
exit: { opacity: 0, y: -24, scale: 0.99, filter: 'blur(4px)' },
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as const },
}
// ─── Slides ──────────────────────────────────────────────────────────────────
function StaticSlide({ kind, programName }: { kind: string; programName: string | null }) {
const copy: Record<string, { eyebrow: string; title: string; sub?: string }> = {
welcome: {
eyebrow: programName ?? 'Monaco Ocean Protection Challenge',
title: 'Grand Finale',
sub: 'Welcome',
},
break: { eyebrow: 'Intermission', title: 'Back shortly', sub: 'Enjoy the break' },
deliberation: {
eyebrow: 'The jury has retired',
title: 'Deliberation in progress',
sub: 'Results follow shortly',
},
thanks: { eyebrow: programName ?? 'Grand Finale', title: 'Thank you', sub: 'See you next year' },
}
const c = copy[kind] ?? copy.welcome
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">{c.eyebrow}</p>
<h1 className="text-[clamp(4rem,10vw,9rem)] font-extrabold leading-none tracking-tight">
{c.title}
</h1>
<SignatureRule />
{c.sub && <p className="text-2xl font-light text-white/70">{c.sub}</p>}
</div>
)
}
function PhaseSlide({
state,
}: {
state: {
phase: {
projectPhase: string
phaseStartedAt: string | Date | null
phaseDurationSeconds: number | null
phasePausedAt: string | Date | null
phasePausedAccumMs: number
} | null
activeProject: { title: string; teamName: string | null; competitionCategory: string | null } | null
}
}) {
useTick()
const phase = state.phase
const project = state.activeProject
if (!phase || !project) return <StaticSlide kind="welcome" programName={null} />
const remaining = remainingSeconds(phase)
const over = remaining !== null && remaining < 0
const category = project.competitionCategory
? CATEGORY_LABEL[project.competitionCategory]
: null
if (phase.projectPhase === 'ON_DECK') {
return (
<div className="flex h-full flex-col items-center justify-center gap-10 px-16 text-center">
<motion.p
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
className="text-xl font-semibold uppercase tracking-[0.5em] text-[#557f8c]"
>
Up next
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 30, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 80, damping: 16, delay: 0.15 }}
className="max-w-[90vw] text-[clamp(3.5rem,9vw,8rem)] font-extrabold leading-[1.02] tracking-tight"
>
{project.teamName ?? project.title}
</motion.h1>
<SignatureRule />
<div className="space-y-2">
{project.teamName && <p className="text-3xl font-light text-white/80">{project.title}</p>}
{category && (
<p className="text-lg font-semibold uppercase tracking-[0.35em] text-white/45">
{category}
</p>
)}
</div>
</div>
)
}
if (phase.projectPhase === 'SCORING') {
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">
{project.teamName ?? project.title}
</p>
<h1 className="text-[clamp(3rem,7vw,6rem)] font-extrabold tracking-tight">
The jury is scoring
</h1>
<SignatureRule />
<motion.div
className="flex gap-3"
initial="hidden"
animate="visible"
variants={{ visible: { transition: { staggerChildren: 0.25 } } }}
>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="h-3 w-3 rounded-full bg-[#557f8c]"
animate={{ opacity: [0.25, 1, 0.25] }}
transition={{ repeat: Infinity, duration: 1.6, delay: i * 0.3 }}
/>
))}
</motion.div>
</div>
)
}
// PRESENTING / QA
const phaseLabel = phase.projectPhase === 'QA' ? 'Q&A' : 'Presentation'
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<div className="space-y-3">
{category && (
<p className="text-base font-semibold uppercase tracking-[0.4em] text-white/45">
{category}
</p>
)}
<h1 className="max-w-[92vw] text-[clamp(3rem,8vw,7rem)] font-extrabold leading-[1.03] tracking-tight">
{project.teamName ?? project.title}
</h1>
{project.teamName && (
<p className="text-2xl font-light text-white/70">{project.title}</p>
)}
</div>
<SignatureRule />
<div className="space-y-2">
<p className="text-lg font-semibold uppercase tracking-[0.45em] text-[#557f8c]">
{phaseLabel}
</p>
{remaining !== null && (
<motion.p
className={`text-[clamp(4rem,9vw,8rem)] font-bold tabular-nums leading-none ${
over ? 'text-[#de0f1e]' : 'text-white'
}`}
animate={over ? { opacity: [1, 0.55, 1] } : {}}
transition={over ? { repeat: Infinity, duration: 1.2 } : {}}
style={over ? { textShadow: '0 0 60px rgba(222,15,30,0.55)' } : {}}
>
{formatClock(remaining)}
</motion.p>
)}
{phase.phasePausedAt && (
<p className="text-base font-semibold uppercase tracking-[0.35em] text-white/45">
paused
</p>
)}
</div>
</div>
)
}
function AudienceVoteSlide({
windowKey,
closesAt,
voteCount,
voteUrl,
}: {
windowKey: string | null
closesAt: string | Date | null
voteCount: number
voteUrl: string
}) {
useTick()
const secondsLeft = closesAt
? Math.max(0, Math.floor((new Date(closesAt).getTime() - Date.now()) / 1000))
: null
return (
<div className="flex h-full items-center justify-center gap-24 px-20">
<motion.div
animate={{ scale: [1, 1.015, 1] }}
transition={{ repeat: Infinity, duration: 4, ease: 'easeInOut' }}
className="shrink-0 rounded-[2.5rem] bg-white p-10 shadow-[0_0_120px_rgba(85,127,140,0.45)]"
>
{voteUrl && <QRCodeSVG value={voteUrl} size={400} />}
</motion.div>
<div className="max-w-3xl space-y-8">
<p className="text-lg font-semibold uppercase tracking-[0.45em] text-[#de0f1e]">
Audience vote open now
</p>
<h1 className="text-[clamp(2.5rem,5.5vw,4.5rem)] font-extrabold leading-tight tracking-tight">
{WINDOW_TITLE[windowKey ?? ''] ?? 'Vote for your favorite'}
</h1>
<p className="text-2xl font-light text-white/70">
Scan the code with your phone one vote each
</p>
<div className="flex items-end gap-14 pt-2">
{secondsLeft !== null && (
<div>
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/45">
Closes in
</p>
<p
className={`text-7xl font-bold tabular-nums ${
secondsLeft <= 30 ? 'text-[#de0f1e]' : ''
}`}
>
{formatClock(secondsLeft)}
</p>
</div>
)}
<div>
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/45">
Votes cast
</p>
<motion.p
key={voteCount}
initial={{ scale: 1.25, color: '#de0f1e' }}
animate={{ scale: 1, color: '#ffffff' }}
className="text-7xl font-bold tabular-nums"
>
{voteCount}
</motion.p>
</div>
</div>
</div>
</div>
)
}
// ─── Reveal ──────────────────────────────────────────────────────────────────
function Confetti({ gold }: { gold?: boolean }) {
const pieces = useMemo(
() =>
Array.from({ length: 56 }, (_, i) => ({
left: ((i * 137.5) % 100),
delay: (i % 14) * 0.09,
duration: 2.6 + ((i * 7) % 10) / 6,
size: 7 + ((i * 13) % 9),
rotate: (i * 73) % 360,
color: gold
? ['#e8c34a', '#de0f1e', '#ffffff', '#f0d98c'][i % 4]
: ['#de0f1e', '#557f8c', '#ffffff', '#9fc3cf'][i % 4],
})),
[gold]
)
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{pieces.map((p, i) => (
<motion.span
key={i}
className="absolute top-[-5%] block"
style={{
left: `${p.left}%`,
width: p.size,
height: p.size * 0.45,
backgroundColor: p.color,
borderRadius: 1,
}}
initial={{ y: '-10vh', rotate: p.rotate, opacity: 0 }}
animate={{ y: '115vh', rotate: p.rotate + 540, opacity: [0, 1, 1, 0.8] }}
transition={{ duration: p.duration, delay: p.delay, ease: 'easeIn' }}
/>
))}
</div>
)
}
type RevealStep = {
kind: string
category?: string
place?: number
title?: string
subtitle?: string
}
function RevealSlide({ step }: { step: RevealStep }) {
const isWinner = step.kind === 'place' && step.place === 1
const isAudience = step.kind === 'audience-award' || step.kind === 'overall-favorite'
if (step.kind === 'category-intro') {
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">Results</p>
<h1 className="text-[clamp(4rem,9vw,8rem)] font-extrabold tracking-tight">
{step.title ?? CATEGORY_LABEL[step.category ?? ''] ?? ''}
</h1>
<SignatureRule />
</div>
)
}
if (step.kind === 'thanks') {
return <StaticSlide kind="thanks" programName={null} />
}
return (
<div className="relative flex h-full flex-col items-center justify-center gap-10 px-16 text-center">
{(isWinner || isAudience) && <Confetti gold={isWinner} />}
{isWinner && (
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(55% 45% at 50% 52%, rgba(232,195,74,0.16) 0%, transparent 70%)',
}}
/>
)}
<motion.p
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className={`text-xl font-semibold uppercase tracking-[0.5em] ${
isAudience ? 'text-[#de0f1e]' : isWinner ? 'text-[#e8c34a]' : 'text-[#557f8c]'
}`}
>
{step.subtitle ?? ''}
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 60, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 70, damping: 14, delay: 0.35 }}
className={`max-w-[92vw] font-extrabold leading-[1.02] tracking-tight ${
isWinner
? 'text-[clamp(4.5rem,11vw,10rem)]'
: 'text-[clamp(3.5rem,8vw,7.5rem)]'
}`}
style={isWinner ? { textShadow: '0 0 90px rgba(232,195,74,0.35)' } : undefined}
>
{step.title ?? ''}
</motion.h1>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
<SignatureRule />
</motion.div>
</div>
)
}
function ResultsSplash() {
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<motion.p
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ repeat: Infinity, duration: 2.4 }}
className="text-lg font-semibold uppercase tracking-[0.5em] text-[#de0f1e]"
>
The moment has come
</motion.p>
<h1 className="text-[clamp(4rem,10vw,9rem)] font-extrabold tracking-tight">Results</h1>
<SignatureRule />
</div>
)
}
// ─── Page ────────────────────────────────────────────────────────────────────
export default function CeremonyPage({
params: paramsPromise,
}: {
params: Promise<{ roundId: string }>
}) {
const params = use(paramsPromise)
const { data: state } = trpc.liveVoting.getCeremonyState.useQuery(
{ roundId: params.roundId },
{ refetchInterval: 2000 }
)
const voteUrl =
typeof window !== 'undefined'
? `${window.location.origin}/vote/competition/${params.roundId}`
: ''
if (!state) {
return (
<OceanField>
<div className="flex h-full items-center justify-center" />
</OceanField>
)
}
// Display precedence: override → reveal → audience window → phase → welcome
const reveal = state.reveal
const revealStep =
reveal && (reveal.status === 'REVEALING' || reveal.status === 'DONE')
? ((reveal.steps[reveal.currentStepIndex] ?? null) as RevealStep | null)
: null
let slideKey: string
let slide: React.ReactNode
let statusLabel: string | null = null
if (state.overrideSlide) {
slideKey = `override-${state.overrideSlide}`
slide = <StaticSlide kind={state.overrideSlide} programName={state.programName} />
} else if (reveal && reveal.status === 'ARMED') {
slideKey = 'reveal-armed'
slide = <ResultsSplash />
statusLabel = 'Results'
} else if (revealStep) {
slideKey = `reveal-${reveal!.currentStepIndex}`
slide = <RevealSlide step={revealStep} />
statusLabel = 'Results'
} else if (state.audience.open) {
slideKey = `audience-${state.audience.windowKey}`
slide = (
<AudienceVoteSlide
windowKey={state.audience.windowKey}
closesAt={state.audience.closesAt}
voteCount={state.audience.voteCount}
voteUrl={voteUrl}
/>
)
statusLabel = 'Live'
} else if (state.phase && state.activeProject) {
slideKey = `phase-${state.phase.projectPhase}-${state.activeProject.title}`
slide = <PhaseSlide state={state} />
statusLabel = 'Live'
} else {
slideKey = 'welcome'
slide = <StaticSlide kind="welcome" programName={state.programName} />
}
return (
<OceanField>
<StatusBar programName={state.programName} label={statusLabel} />
<AnimatePresence mode="wait">
<motion.div key={slideKey} className="absolute inset-0" {...slideIn}>
{slide}
</motion.div>
</AnimatePresence>
</OceanField>
)
}

View File

@@ -1,88 +1,274 @@
'use client'; 'use client'
import { use, useEffect, useState } from 'react'; /**
import { trpc } from '@/lib/trpc/client'; * Audience voting page — reached by scanning the QR code on the big screen.
import { Card, CardContent } from '@/components/ui/card'; * Zero-instruction flow: scan → (auto token) → wait → tap your favorite →
import { AudienceVoteCard } from '@/components/public/audience-vote-card'; * done. Votes can be changed until the window closes. Uses ONLY public
import { toast } from 'sonner'; * procedures: attendees have no account.
*/
export default function AudienceVotePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) { import { use, useEffect, useState } from 'react'
const params = use(paramsPromise); import { motion, AnimatePresence } from 'motion/react'
const utils = trpc.useUtils(); import { trpc } from '@/lib/trpc/client'
const [hasVoted, setHasVoted] = useState(false); import { formatClock } from '@/lib/live-timer'
import { Check, Heart, Hourglass, Vote } from 'lucide-react'
import { toast } from 'sonner'
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId }); const WINDOW_TITLE: Record<string, string> = {
'CATEGORY:BUSINESS_CONCEPT': 'Pick your favorite Business Concept',
const submitVoteMutation = trpc.liveVoting.castAudienceVote.useMutation({ 'CATEGORY:STARTUP': 'Pick your favorite Startup',
onSuccess: () => { OVERALL: 'Pick your favorite project of the night',
setHasVoted(true);
// Store in localStorage to prevent duplicate votes
if (cursor?.activeProject?.id) {
localStorage.setItem(`voted-${params.roundId}-${cursor.activeProject.id}`, 'true');
} }
toast.success('Vote submitted! Thank you for participating.');
},
onError: (err) => {
toast.error(err.message);
}
});
// Check localStorage on mount function useCountdown(closesAt: string | Date | null | undefined) {
const [, tick] = useState(0)
useEffect(() => { useEffect(() => {
if (cursor?.activeProject?.id) { const id = setInterval(() => tick((t) => t + 1), 1000)
const voted = localStorage.getItem(`voted-${params.roundId}-${cursor.activeProject.id}`); return () => clearInterval(id)
if (voted === 'true') { }, [])
setHasVoted(true); if (!closesAt) return null
return Math.max(0, Math.floor((new Date(closesAt).getTime() - Date.now()) / 1000))
} }
export default function AudienceVotePage({
params: paramsPromise,
}: {
params: Promise<{ roundId: string }>
}) {
const params = use(paramsPromise)
const utils = trpc.useUtils()
const { data: context, isLoading: contextLoading } =
trpc.liveVoting.getAudienceContextByRound.useQuery({ roundId: params.roundId })
const sessionId = context?.sessionId ?? null
// ── Anonymous voter token, persisted per session in this browser ─────────
const [token, setToken] = useState<string | null>(null)
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
onSuccess: (res) => {
if (sessionId) localStorage.setItem(`mopc-audience-${sessionId}`, res.token)
setToken(res.token)
},
})
useEffect(() => {
if (!sessionId || !context?.allowAudienceVotes) return
const stored = localStorage.getItem(`mopc-audience-${sessionId}`)
if (stored) {
setToken(stored)
} else if (!register.isPending && !token) {
register.mutate({ sessionId })
} }
}, [cursor?.activeProject?.id, params.roundId]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, context?.allowAudienceVotes])
const handleVote = () => { const { data: win } = trpc.liveVoting.getAudienceWindow.useQuery(
if (!cursor?.activeProject?.id) return; { sessionId: sessionId ?? '', token: token ?? undefined },
{ enabled: !!sessionId, refetchInterval: 3000 }
)
submitVoteMutation.mutate({ const [selected, setSelected] = useState<string | null>(null)
projectId: cursor.activeProject.id, const cast = trpc.liveVoting.castFavoriteVote.useMutation({
sessionId: params.roundId, onSuccess: () => {
score: 1, utils.liveVoting.getAudienceWindow.invalidate()
token: `audience-${Date.now()}` setSelected(null)
}); toast.success('Vote recorded!')
}; },
onError: (err) => toast.error(err.message),
})
if (!cursor?.activeProject) { const secondsLeft = useCountdown(win?.closesAt)
const open = !!win?.open && (secondsLeft === null || secondsLeft > 0)
const myVote = win?.myVoteProjectId ?? null
// Reset local selection when a new window opens
useEffect(() => {
setSelected(null)
}, [win?.windowKey])
if (contextLoading) {
return <CenteredState icon={Hourglass} title="Loading…" />
}
if (!context) {
return ( return (
<div className="container mx-auto flex min-h-screen items-center justify-center p-4"> <CenteredState
<Card className="w-full max-w-2xl"> icon={Vote}
<CardContent className="flex flex-col items-center justify-center py-12"> title="No vote here yet"
<p className="text-center text-lg text-muted-foreground"> subtitle="This voting link isn't active. Keep an eye on the big screen!"
No project is currently being presented
</p>
<p className="mt-2 text-center text-sm text-muted-foreground">
Please wait for the ceremony to begin
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
<div className="w-full">
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold text-[#053d57]">Monaco Ocean Protection Challenge</h1>
<p className="mt-2 text-lg text-muted-foreground">Live Audience Voting</p>
</div>
<AudienceVoteCard
project={cursor.activeProject}
onVote={handleVote}
hasVoted={hasVoted}
/> />
)
<p className="mt-6 text-center text-sm text-muted-foreground"> }
Live voting in progress if (!context.allowAudienceVotes) {
</p> return (
</div> <CenteredState
</div> icon={Vote}
); title="Audience voting is not open"
subtitle="Voting will be enabled during the event."
/>
)
}
return (
<div className="mx-auto max-w-lg">
{/* Gala header */}
<div className="-mx-4 -mt-8 mb-6 bg-gradient-to-br from-[#021f2e] via-[#053d57] to-[#0a5a7c] px-6 py-8 text-center text-white sm:rounded-b-2xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.35em] text-white/60">
{context.programName ?? 'Grand Finale'}
</p>
<h1 className="mt-2 text-2xl font-bold">Audience Vote</h1>
{open && secondsLeft !== null && (
<div className="mx-auto mt-3 inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-1.5 text-sm tabular-nums">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[#de0f1e]" />
closes in {formatClock(secondsLeft)}
</div>
)}
</div>
<AnimatePresence mode="wait">
{!open ? (
<motion.div
key="waiting"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="py-10 text-center"
>
<motion.div
animate={{ y: [0, -6, 0] }}
transition={{ repeat: Infinity, duration: 2.4, ease: 'easeInOut' }}
className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#053d57]/8"
>
<Hourglass className="h-7 w-7 text-[#053d57]" />
</motion.div>
<h2 className="text-lg font-semibold text-[#053d57]">
Voting opens after the presentations
</h2>
<p className="mx-auto mt-2 max-w-xs text-sm text-muted-foreground">
Keep this page open the ballot appears here the moment voting starts.
</p>
</motion.div>
) : (
<motion.div
key={`open-${win?.windowKey}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="space-y-3 pb-28"
>
<h2 className="text-center text-lg font-semibold text-[#053d57]">
{WINDOW_TITLE[win?.windowKey ?? ''] ?? 'Pick your favorite'}
</h2>
<p className="text-center text-xs text-muted-foreground">
One vote you can change it until voting closes
</p>
<div className="space-y-2.5 pt-2">
{(win?.projects ?? []).map((project) => {
const isPicked = (selected ?? myVote) === project.id
return (
<button
key={project.id}
onClick={() => setSelected(project.id)}
className={`w-full rounded-2xl border-2 p-4 text-left transition-all active:scale-[0.99] ${
isPicked
? 'border-[#de0f1e] bg-[#053d57] text-white shadow-lg'
: 'border-border bg-card hover:border-[#557f8c]'
}`}
>
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
isPicked ? 'bg-[#de0f1e]' : 'bg-[#053d57]/8'
}`}
>
{isPicked ? (
<Check className="h-5 w-5 text-white" />
) : (
<Heart className="h-4 w-4 text-[#557f8c]" />
)}
</div>
<div className="min-w-0">
<p className="truncate font-semibold">
{project.teamName ?? project.title}
</p>
{project.teamName && (
<p
className={`truncate text-xs ${
isPicked ? 'text-white/70' : 'text-muted-foreground'
}`}
>
{project.title}
</p>
)}
</div>
</div>
</button>
)
})}
</div>
{/* Pinned confirm bar */}
<AnimatePresence>
{selected && selected !== myVote && (
<motion.div
initial={{ y: 80 }}
animate={{ y: 0 }}
exit={{ y: 80 }}
className="fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] backdrop-blur"
>
<div className="mx-auto max-w-lg">
<button
onClick={() =>
sessionId &&
token &&
cast.mutate({ sessionId, token, projectId: selected })
}
disabled={cast.isPending || !token}
className="w-full rounded-xl bg-[#de0f1e] py-3.5 font-semibold text-white shadow-lg transition-transform active:scale-[0.98] disabled:opacity-60"
>
{cast.isPending
? 'Submitting…'
: myVote
? 'Change my vote'
: 'Confirm my vote'}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{myVote && (!selected || selected === myVote) && (
<div className="pt-2 text-center">
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-600/10 px-4 py-1.5 text-sm font-medium text-green-700">
<Check className="h-4 w-4" />
Vote recorded tap another to change it
</span>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function CenteredState({
icon: Icon,
title,
subtitle,
}: {
icon: typeof Vote
title: string
subtitle?: string
}) {
return (
<div className="py-16 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#053d57]/8">
<Icon className="h-7 w-7 text-[#053d57]" />
</div>
<h2 className="text-lg font-semibold text-[#053d57]">{title}</h2>
{subtitle && (
<p className="mx-auto mt-2 max-w-xs text-sm text-muted-foreground">{subtitle}</p>
)}
</div>
)
} }

View File

@@ -91,7 +91,9 @@ export function AdminOverrideDialog({
<Label>Project Rankings</Label> <Label>Project Rankings</Label>
<div className="space-y-2"> <div className="space-y-2">
{projectIds.map((projectId) => { {projectIds.map((projectId) => {
const project = session?.results?.find((r) => r.project.id === projectId)?.project; const project =
(session as any)?.projects?.find((p: any) => p.id === projectId) ??
session?.results?.find((r) => r.project.id === projectId)?.project;
return ( return (
<div key={projectId} className="flex items-center gap-3"> <div key={projectId} className="flex items-center gap-3">
<Input <Input

View File

@@ -0,0 +1,260 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ResultsPanel } from './results-panel'
import { AdminOverrideDialog } from './admin-override-dialog'
import { Gavel, Lock, Play, Plus, Square, Users } from 'lucide-react'
import { toast } from 'sonner'
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
const STATUS_BADGE: Record<string, { label: string; variant: 'secondary' | 'default' | 'destructive' | 'outline' }> = {
DELIB_OPEN: { label: 'Open — voting not started', variant: 'secondary' },
VOTING: { label: 'Voting', variant: 'default' },
TALLYING: { label: 'Tallying', variant: 'outline' },
RUNOFF: { label: 'Runoff', variant: 'destructive' },
DELIB_LOCKED: { label: 'Locked', variant: 'secondary' },
}
function SessionCard({ session, competitionId }: { session: any; competitionId: string }) {
const utils = trpc.useUtils()
const [overrideOpen, setOverrideOpen] = useState(false)
const { data: detail } = trpc.deliberation.getSession.useQuery(
{ sessionId: session.id },
{ refetchInterval: 10_000 }
)
const invalidate = () => {
utils.deliberation.getSession.invalidate({ sessionId: session.id })
utils.deliberation.listSessions.invalidate({ competitionId })
}
const onError = (err: { message: string }) => toast.error(err.message)
const openVoting = trpc.deliberation.openVoting.useMutation({
onSuccess: () => {
invalidate()
toast.success('Deliberation voting opened — jurors can now rank')
},
onError,
})
const closeVoting = trpc.deliberation.closeVoting.useMutation({
onSuccess: () => {
invalidate()
toast.success('Voting closed — tallying')
},
onError,
})
const status = detail?.status ?? session.status
const badge = STATUS_BADGE[status] ?? { label: status, variant: 'outline' as const }
const voteCount = detail?.votes?.length ?? session._count?.votes ?? 0
const participantCount = detail?.participants?.length ?? session._count?.participants ?? 0
const votedJurors = new Set(
(detail?.votes ?? []).map((v: any) => v.juryMember?.id ?? v.juryMemberId)
).size
const projectIds: string[] =
detail?.projects?.map((p: any) => p.id) ??
detail?.results?.map((r: any) => r.project.id) ??
[]
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle className="text-lg">
{CATEGORY_LABEL[session.category] ?? session.category}
</CardTitle>
<CardDescription className="mt-0.5 flex items-center gap-3">
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{votedJurors}/{participantCount} jurors voted
</span>
<span>{voteCount} ballots</span>
<span>{session.mode === 'FULL_RANKING' ? 'Full ranking' : 'Single winner'}</span>
</CardDescription>
</div>
<Badge variant={badge.variant}>{badge.label}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{status === 'DELIB_OPEN' && (
<Button
onClick={() => openVoting.mutate({ sessionId: session.id })}
disabled={openVoting.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open voting
</Button>
)}
{(status === 'VOTING' || status === 'RUNOFF') && (
<Button
variant="outline"
onClick={() => closeVoting.mutate({ sessionId: session.id })}
disabled={closeVoting.isPending}
>
<Square className="mr-2 h-4 w-4" />
Close voting & tally
</Button>
)}
{status !== 'DELIB_LOCKED' && (
<Button variant="outline" onClick={() => setOverrideOpen(true)}>
<Gavel className="mr-2 h-4 w-4" />
Set rankings manually
</Button>
)}
{status === 'DELIB_LOCKED' && (
<Badge variant="secondary" className="gap-1 py-1.5">
<Lock className="h-3.5 w-3.5" />
Results locked
</Badge>
)}
</div>
{/* Aggregated results + runoff/finalize controls */}
{(status === 'TALLYING' || status === 'RUNOFF' || status === 'DELIB_LOCKED') && (
<ResultsPanel sessionId={session.id} />
)}
<AdminOverrideDialog
sessionId={session.id}
open={overrideOpen}
onOpenChange={setOverrideOpen}
projectIds={projectIds}
/>
</CardContent>
</Card>
)
}
/**
* Admin deliberation console for a DELIBERATION round: create the per-category
* sessions from the round's jury group, drive voting open/close, tally,
* resolve ties, override manually (the "jury went analog" path) and finalize.
*/
export function DeliberationControlPanel({
roundId,
competitionId,
}: {
roundId: string
competitionId: string
}) {
const utils = trpc.useUtils()
const { data: round } = trpc.round.getById.useQuery({ id: roundId })
const juryGroupId = round?.juryGroupId ?? ''
const { data: juryGroup } = trpc.juryGroup.getById.useQuery(
{ id: juryGroupId },
{ enabled: !!juryGroupId }
)
const { data: sessions } = trpc.deliberation.listSessions.useQuery(
{ competitionId },
{ refetchInterval: 15_000 }
)
const [mode, setMode] = useState<'FULL_RANKING' | 'SINGLE_WINNER_VOTE'>('FULL_RANKING')
const createSession = trpc.deliberation.createSession.useMutation({
onSuccess: () => {
utils.deliberation.listSessions.invalidate({ competitionId })
toast.success('Deliberation session created')
},
onError: (err) => toast.error(err.message),
})
const roundSessions = (sessions ?? []).filter((s: any) => s.roundId === roundId)
const existingCategories = new Set(roundSessions.map((s: any) => s.category))
const votingMembers = (juryGroup?.members ?? []).filter((m: any) => m.role !== 'OBSERVER')
const handleCreate = (category: 'STARTUP' | 'BUSINESS_CONCEPT') => {
if (votingMembers.length === 0) {
toast.error('The round has no jury group members to deliberate')
return
}
createSession.mutate({
competitionId,
roundId,
category,
mode,
tieBreakMethod: 'TIE_ADMIN_DECIDES',
showPriorJuryData: true,
participantUserIds: votingMembers.map((m: any) => m.id),
})
}
return (
<div className="space-y-4">
{(['BUSINESS_CONCEPT', 'STARTUP'] as const).some((c) => !existingCategories.has(c)) && (
<Card>
<CardHeader>
<CardTitle>Create Deliberation Sessions</CardTitle>
<CardDescription>
One session per category · participants come from the round&apos;s jury group (
{votingMembers.length} voting member{votingMembers.length === 1 ? '' : 's'})
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-end gap-3">
<div className="space-y-1">
<Label className="text-xs">Mode</Label>
<Select value={mode} onValueChange={(v) => setMode(v as typeof mode)}>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FULL_RANKING">Full ranking (Borda)</SelectItem>
<SelectItem value="SINGLE_WINNER_VOTE">Single winner pick</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
{(['BUSINESS_CONCEPT', 'STARTUP'] as const)
.filter((c) => !existingCategories.has(c))
.map((category) => (
<Button
key={category}
variant="outline"
onClick={() => handleCreate(category)}
disabled={createSession.isPending || !juryGroupId}
>
<Plus className="mr-2 h-4 w-4" />
{CATEGORY_LABEL[category]}
</Button>
))}
</div>
</div>
{!juryGroupId && (
<p className="text-xs text-destructive">
Assign a jury group to this round first (Config tab).
</p>
)}
</CardContent>
</Card>
)}
{roundSessions.map((s: any) => (
<SessionCard key={s.id} session={s} competitionId={competitionId} />
))}
{roundSessions.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No deliberation sessions yet create one per category above.
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,278 @@
'use client'
import { useEffect, useState } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { formatClock } from '@/lib/live-timer'
import { ChevronDown, QrCode, Users, Vote, XCircle } from 'lucide-react'
import { toast } from 'sonner'
const WINDOW_LABEL: Record<string, string> = {
'CATEGORY:BUSINESS_CONCEPT': 'Business Concepts',
'CATEGORY:STARTUP': 'Startups',
OVERALL: 'Overall favorite',
}
/**
* Audience favorite-vote control: open a per-category (or overall) window for
* N minutes, watch the live vote count, close early, and project the QR code.
*/
export function AudienceWindowPanel({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data: session } = trpc.liveVoting.getSession.useQuery(
{ roundId },
{ refetchInterval: 3000 }
)
const { data: tallies } = trpc.liveVoting.getFavoriteTallies.useQuery(
{ sessionId: session?.id ?? '' },
{ enabled: !!session?.id, refetchInterval: 3000 }
)
const [durationMin, setDurationMin] = useState('5')
const [talliesOpen, setTalliesOpen] = useState(false)
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
const invalidate = () => {
utils.liveVoting.getSession.invalidate({ roundId })
if (session?.id) utils.liveVoting.getFavoriteTallies.invalidate({ sessionId: session.id })
}
const onError = (err: { message: string }) => toast.error(err.message)
const openWindow = trpc.liveVoting.openAudienceWindow.useMutation({
onSuccess: invalidate,
onError,
})
const closeWindow = trpc.liveVoting.closeAudienceWindow.useMutation({
onSuccess: () => {
invalidate()
toast.success('Audience voting closed')
},
onError,
})
const updateConfig = trpc.liveVoting.updateSessionConfig.useMutation({
onSuccess: invalidate,
onError,
})
if (!session) return null
const closesAt = session.audienceWindowClosesAt ? new Date(session.audienceWindowClosesAt) : null
const secondsLeft = closesAt ? Math.floor((closesAt.getTime() - Date.now()) / 1000) : null
const isOpen = session.audiencePhase === 'OPEN' && secondsLeft !== null && secondsLeft > 0
const openKey = isOpen ? session.audienceWindowKey : null
const currentWindow = tallies?.windows.find((w) => w.windowKey === openKey)
const voteUrl =
typeof window !== 'undefined' ? `${window.location.origin}/vote/competition/${roundId}` : ''
const duration = Math.max(1, parseInt(durationMin, 10) || 5)
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Vote className="h-5 w-5" />
Audience Vote
</CardTitle>
<CardDescription>
{session.allowAudienceVotes
? 'Favorite-pick windows, one vote per phone per window'
: 'Audience voting is disabled in session config'}
</CardDescription>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<QrCode className="mr-2 h-4 w-4" />
Show QR
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="text-center">Scan to vote</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
<div className="rounded-3xl bg-white p-6 shadow-lg">
{voteUrl && <QRCodeSVG value={voteUrl} size={420} />}
</div>
<p className="select-all break-all text-center text-sm text-muted-foreground">
{voteUrl}
</p>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isOpen ? (
<div className="space-y-3 rounded-lg border border-[#de0f1e]/30 bg-[#de0f1e]/5 p-4">
<div className="flex items-center justify-between">
<Badge className="bg-[#de0f1e] hover:bg-[#de0f1e]">
OPEN {WINDOW_LABEL[openKey ?? ''] ?? openKey}
</Badge>
<span className="text-2xl font-bold tabular-nums">
{formatClock(Math.max(0, secondsLeft ?? 0))}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
<span className="font-semibold text-foreground">
{currentWindow?.totalVotes ?? 0}
</span>
votes cast
</div>
<Button
variant="destructive"
className="w-full"
onClick={() => closeWindow.mutate({ sessionId: session.id })}
disabled={closeWindow.isPending}
>
<XCircle className="mr-2 h-4 w-4" />
Close voting now
</Button>
</div>
) : (
<div className="space-y-3">
<div className="flex items-end gap-3">
<div className="space-y-1">
<Label className="text-xs">Duration (min)</Label>
<Input
type="number"
min="1"
max="120"
value={durationMin}
onChange={(e) => setDurationMin(e.target.value)}
className="w-24"
/>
</div>
<p className="pb-2 text-xs text-muted-foreground">
Voting closes automatically server-enforced
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<Button
variant="outline"
disabled={openWindow.isPending || !session.allowAudienceVotes}
onClick={() =>
openWindow.mutate({
sessionId: session.id,
windowKey: 'CATEGORY:BUSINESS_CONCEPT',
durationMinutes: duration,
})
}
>
Open vote Business Concepts
</Button>
<Button
variant="outline"
disabled={openWindow.isPending || !session.allowAudienceVotes}
onClick={() =>
openWindow.mutate({
sessionId: session.id,
windowKey: 'CATEGORY:STARTUP',
durationMinutes: duration,
})
}
>
Open vote Startups
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Overall favorite (across both categories)</p>
<p className="text-xs text-muted-foreground">Decide day-of off by default</p>
</div>
<div className="flex items-center gap-3">
<Switch
checked={!!session.allowOverallFavorite}
onCheckedChange={(checked) =>
updateConfig.mutate({ sessionId: session.id, allowOverallFavorite: checked })
}
/>
<Button
variant="outline"
size="sm"
disabled={
openWindow.isPending || !session.allowOverallFavorite || !session.allowAudienceVotes
}
onClick={() =>
openWindow.mutate({
sessionId: session.id,
windowKey: 'OVERALL',
durationMinutes: duration,
})
}
>
Open
</Button>
</div>
</div>
{!session.allowAudienceVotes && (
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() =>
updateConfig.mutate({ sessionId: session.id, allowAudienceVotes: true })
}
disabled={updateConfig.isPending}
>
Enable audience voting for this session
</Button>
)}
</div>
)}
{/* Tallies — admin eyes only */}
{tallies && tallies.windows.length > 0 && (
<Collapsible open={talliesOpen} onOpenChange={setTalliesOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full justify-between">
Tallies (admin only)
<ChevronDown
className={`h-4 w-4 transition-transform ${talliesOpen ? 'rotate-180' : ''}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
{tallies.windows.map((w) => (
<div key={w.windowKey} className="rounded-lg border p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">
{WINDOW_LABEL[w.windowKey] ?? w.windowKey}
</p>
<span className="text-xs text-muted-foreground">{w.totalVotes} votes</span>
</div>
<div className="space-y-1">
{w.projects.map((p) => (
<div key={p.projectId} className="flex items-center justify-between text-sm">
<span className="truncate">{p.teamName ?? p.title}</span>
<span className="font-semibold tabular-nums">{p.count}</span>
</div>
))}
</div>
</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,238 +1,159 @@
'use client'; 'use client'
import { useState, useEffect } from 'react'; import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'; import Link from 'next/link'
import { Button } from '@/components/ui/button'; import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ChevronLeft, ChevronRight, Play, Square, Pause, Timer } from 'lucide-react'; import { Badge } from '@/components/ui/badge'
import { toast } from 'sonner'; import { PhaseControls } from './phase-controls'
import { RunOrderList } from './run-order-list'
import { AudienceWindowPanel } from './audience-window-panel'
import { TimingLogCard } from './timing-log-card'
import { RevealPanel } from './reveal-panel'
import { Coffee, ExternalLink, Hand, MonitorPlay, PartyPopper, Play, Scale, X } from 'lucide-react'
import { toast } from 'sonner'
interface LiveControlPanelProps { interface LiveControlPanelProps {
roundId: string; roundId: string
competitionId: string; competitionId: string
} }
const OVERRIDE_SLIDES = [
{ value: 'welcome', label: 'Welcome', icon: Hand },
{ value: 'break', label: 'Break', icon: Coffee },
{ value: 'deliberation', label: 'Deliberation', icon: Scale },
{ value: 'thanks', label: 'Thank you', icon: PartyPopper },
] as const
/**
* Grand-finale ceremony console. Everything an admin touches during the
* event lives here: run order, phase driver with real timers, audience vote
* windows + QR, big-screen override slides, timing log, and the results
* reveal stepper.
*/
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) { export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
const utils = trpc.useUtils(); const utils = trpc.useUtils()
const [timerSeconds, setTimerSeconds] = useState(300); const { data: cursor, isLoading } = trpc.live.getCursor.useQuery(
const [isTimerRunning, setIsTimerRunning] = useState(false);
const { data: cursor } = trpc.live.getCursor.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 5000 } { refetchInterval: 2000 }
); )
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery(
{ roundId },
{ enabled: !cursor && !isLoading }
)
const [starting, setStarting] = useState(false)
const jumpMutation = trpc.live.jump.useMutation({ const startMutation = trpc.live.start.useMutation({
onSuccess: () => { onSuccess: () => {
utils.live.getCursor.invalidate({ roundId }); utils.live.getCursor.invalidate({ roundId })
toast.success('Ceremony session started')
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}); onSettled: () => setStarting(false),
})
const pauseMutation = trpc.live.pause.useMutation({ const overrideMutation = trpc.live.setOverrideSlide.useMutation({
onSuccess: () => { onSuccess: () => utils.live.getCursor.invalidate({ roundId }),
utils.live.getCursor.invalidate({ roundId });
toast.success('Live session paused');
},
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}); })
const resumeMutation = trpc.live.resume.useMutation({ const handleStart = () => {
onSuccess: () => { // Default run order: Business Concepts block first, then Startups
utils.live.getCursor.invalidate({ roundId }); const projects = (projectStates ?? [])
toast.success('Live session resumed'); .map((ps: any) => ps.project)
}, .filter(Boolean)
onError: (err) => toast.error(err.message), const order = [
}); ...projects.filter((p: any) => p.competitionCategory === 'BUSINESS_CONCEPT'),
...projects.filter((p: any) => p.competitionCategory === 'STARTUP'),
useEffect(() => { ...projects.filter(
if (!isTimerRunning) return; (p: any) => p.competitionCategory !== 'BUSINESS_CONCEPT' && p.competitionCategory !== 'STARTUP'
),
const interval = setInterval(() => { ].map((p: any) => p.id)
setTimerSeconds((prev) => { if (order.length === 0) {
if (prev <= 1) { toast.error('No projects in this round yet')
setIsTimerRunning(false); return
return 0;
} }
return prev - 1; setStarting(true)
}); startMutation.mutate({ roundId, projectOrder: order })
}, 1000);
return () => clearInterval(interval);
}, [isTimerRunning]);
const currentIndex = cursor?.activeOrderIndex ?? 0;
const totalProjects = cursor?.totalProjects ?? 0;
const isNavigating = jumpMutation.isPending;
const handlePrevious = () => {
if (currentIndex <= 0) {
toast.info('Already at the first project');
return;
} }
jumpMutation.mutate({ roundId, index: currentIndex - 1 });
};
const handleNext = () => {
if (currentIndex >= totalProjects - 1) {
toast.info('Already at the last project');
return;
}
jumpMutation.mutate({ roundId, index: currentIndex + 1 });
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// ── Not started yet ───────────────────────────────────────────────────────
if (!cursor) {
return ( return (
<div className="space-y-6">
{/* Current Project Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Current Project</CardTitle>
<div className="flex items-center gap-2">
{cursor && (
<span className="text-sm text-muted-foreground tabular-nums">
{currentIndex + 1} / {totalProjects}
</span>
)}
<Button
variant="outline"
size="icon"
onClick={handlePrevious}
disabled={isNavigating || currentIndex <= 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleNext}
disabled={isNavigating || currentIndex >= totalProjects - 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{cursor?.activeProject ? (
<div className="space-y-4">
<div>
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
{cursor.activeProject.teamName && (
<p className="text-muted-foreground">{cursor.activeProject.teamName}</p>
)}
</div>
{cursor.activeProject.tags && (cursor.activeProject.tags as string[]).length > 0 && (
<div className="flex flex-wrap gap-1">
{(cursor.activeProject.tags as string[]).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</div>
) : (
<p className="text-muted-foreground">
{cursor ? 'No project selected' : 'No live session active for this round'}
</p>
)}
</CardContent>
</Card>
{/* Timer Card */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Timer className="h-5 w-5" /> <MonitorPlay className="h-5 w-5" />
Timer Ceremony Console
</CardTitle> </CardTitle>
<CardDescription>
Start the live session when the event begins it creates the presentation cursor
every screen follows. The set start time is indicative; nothing moves until you click.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-3">
<div className="text-center"> <Button size="lg" className="w-full" onClick={handleStart} disabled={isLoading || starting}>
<div className="text-5xl font-bold tabular-nums">{formatTime(timerSeconds)}</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{!isTimerRunning ? (
<Button
className="flex-1"
onClick={() => setIsTimerRunning(true)}
disabled={timerSeconds === 0}
>
<Play className="mr-2 h-4 w-4" /> <Play className="mr-2 h-4 w-4" />
Start Timer {starting ? 'Starting…' : 'Start ceremony session'}
</Button> </Button>
) : ( <p className="text-center text-xs text-muted-foreground">
<Button className="flex-1" onClick={() => setIsTimerRunning(false)} variant="destructive"> Run order defaults to Business Concepts Startups; reorder anytime after starting.
<Square className="mr-2 h-4 w-4" /> </p>
Stop Timer </CardContent>
</Button> </Card>
)} )
}
return (
<div className="space-y-4">
{/* Big-screen quick bar */}
<Card>
<CardContent className="flex flex-wrap items-center gap-2 py-3">
<span className="mr-1 text-sm font-medium">Big screen:</span>
{OVERRIDE_SLIDES.map((slide) => {
const active = cursor.overrideSlide === slide.value
const SlideIcon = slide.icon
return (
<Button <Button
variant="outline" key={slide.value}
onClick={() => { variant={active ? 'default' : 'outline'}
setTimerSeconds(300); size="sm"
setIsTimerRunning(false); onClick={() =>
}} overrideMutation.mutate({ roundId, slide: active ? null : slide.value })
}
disabled={overrideMutation.isPending}
> >
Reset (5:00) <SlideIcon className="mr-1.5 h-3.5 w-3.5" />
{slide.label}
{active && <X className="ml-1.5 h-3 w-3" />}
</Button>
)
})}
{cursor.overrideSlide && (
<Badge variant="destructive" className="ml-auto">
Override active live content hidden
</Badge>
)}
<Button asChild variant="ghost" size="sm" className={cursor.overrideSlide ? '' : 'ml-auto'}>
<Link href={`/live/ceremony/${roundId}`} target="_blank">
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
Open big screen
</Link>
</Button> </Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Session Controls */} <div className="grid gap-4 lg:grid-cols-2">
<Card> <div className="space-y-4">
<CardHeader> <PhaseControls roundId={roundId} />
<CardTitle>Session Controls</CardTitle> <RunOrderList roundId={roundId} />
<CardDescription>Pause or resume the live presentation</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{cursor?.isPaused ? (
<Button
className="w-full"
onClick={() => resumeMutation.mutate({ roundId })}
disabled={resumeMutation.isPending}
>
<Play className="mr-2 h-4 w-4" />
{resumeMutation.isPending ? 'Resuming...' : 'Resume Session'}
</Button>
) : (
<Button
className="w-full"
variant="outline"
onClick={() => pauseMutation.mutate({ roundId })}
disabled={pauseMutation.isPending || !cursor}
>
<Pause className="mr-2 h-4 w-4" />
{pauseMutation.isPending ? 'Pausing...' : 'Pause Session'}
</Button>
)}
{cursor?.isPaused && (
<Badge variant="destructive" className="w-full justify-center py-1">
Session Paused
</Badge>
)}
{cursor?.openCohorts && cursor.openCohorts.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium mb-2">Open Voting Windows</p>
{cursor.openCohorts.map((cohort: any) => (
<div key={cohort.id} className="flex items-center justify-between text-sm">
<span>{cohort.name}</span>
<Badge variant="outline">{cohort.votingMode}</Badge>
</div> </div>
))} <div className="space-y-4">
<AudienceWindowPanel roundId={roundId} />
<RevealPanel roundId={roundId} competitionId={competitionId} />
<TimingLogCard roundId={roundId} />
</div> </div>
)}
</CardContent>
</Card>
</div> </div>
); </div>
)
} }

View File

@@ -0,0 +1,228 @@
'use client'
import { useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { remainingSeconds, formatClock } from '@/lib/live-timer'
import {
Mic2,
MessageCircleQuestion,
PenLine,
Pause,
Play,
SkipForward,
MonitorUp,
} from 'lucide-react'
import { toast } from 'sonner'
const PHASE_LABEL: Record<string, string> = {
ON_DECK: 'On deck',
PRESENTING: 'Presenting',
QA: 'Q&A',
SCORING: 'Scoring',
}
/**
* The ceremony driver: one primary button for the next phase transition, a
* server-derived countdown that goes red past zero, pause/resume, and
* per-run duration overrides.
*/
export function PhaseControls({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 2000 })
const [presentationMin, setPresentationMin] = useState<string>('')
const [qaMin, setQaMin] = useState<string>('')
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
const invalidate = () => utils.live.getCursor.invalidate({ roundId })
const onError = (err: { message: string }) => toast.error(err.message)
const startPresentation = trpc.live.startPresentation.useMutation({ onSuccess: invalidate, onError })
const startQA = trpc.live.startQA.useMutation({ onSuccess: invalidate, onError })
const openScoring = trpc.live.openScoring.useMutation({ onSuccess: invalidate, onError })
const sendToScreens = trpc.live.sendToScreens.useMutation({ onSuccess: invalidate, onError })
const pausePhase = trpc.live.pausePhase.useMutation({ onSuccess: invalidate, onError })
const resumePhase = trpc.live.resumePhase.useMutation({ onSuccess: invalidate, onError })
if (!cursor) {
return null
}
const phase = cursor.projectPhase
const remaining = remainingSeconds(cursor)
const over = remaining !== null && remaining < 0
const paused = !!cursor.phasePausedAt
const busy =
startPresentation.isPending ||
startQA.isPending ||
openScoring.isPending ||
sendToScreens.isPending
const durationSeconds = (raw: string) => {
const min = parseFloat(raw)
return Number.isFinite(min) && min > 0 ? Math.round(min * 60) : undefined
}
const nextProject = (() => {
const order = cursor.orderedProjects ?? []
const idx = order.findIndex((p) => p.id === cursor.activeProjectId)
return idx >= 0 && idx < order.length - 1 ? order[idx + 1] : null
})()
const primaryAction = (() => {
switch (phase) {
case 'ON_DECK':
return {
label: 'Start presentation',
icon: Mic2,
run: () =>
startPresentation.mutate({
roundId,
durationSeconds: durationSeconds(presentationMin),
}),
disabled: !cursor.activeProjectId,
}
case 'PRESENTING':
return {
label: 'Start Q&A',
icon: MessageCircleQuestion,
run: () => startQA.mutate({ roundId, durationSeconds: durationSeconds(qaMin) }),
disabled: false,
}
case 'QA':
return {
label: 'Open scoring',
icon: PenLine,
run: () => openScoring.mutate({ roundId }),
disabled: false,
}
case 'SCORING':
default:
return nextProject
? {
label: `Send next: ${nextProject.teamName ?? nextProject.title}`,
icon: MonitorUp,
run: () => sendToScreens.mutate({ roundId, projectId: nextProject.id }),
disabled: false,
}
: {
label: 'End of run order',
icon: SkipForward,
run: () => undefined,
disabled: true,
}
}
})()
const PrimaryIcon = primaryAction.icon
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Ceremony Control</CardTitle>
<CardDescription>
{cursor.activeProject
? `${cursor.activeProject.title}${cursor.activeProject.teamName ? `${cursor.activeProject.teamName}` : ''}`
: 'No project on screens yet'}
</CardDescription>
</div>
<Badge variant={phase === 'SCORING' ? 'default' : 'secondary'}>
{PHASE_LABEL[phase] ?? phase}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
{/* Server-derived countdown */}
<div className="text-center">
<div
className={`text-6xl font-bold tabular-nums ${
over ? 'animate-pulse text-[#de0f1e]' : remaining === null ? 'text-muted-foreground/40' : ''
}`}
>
{remaining === null ? ':' : formatClock(remaining)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{remaining === null
? 'No timer running'
: over
? `Over time${paused ? ' · paused' : ''} — noted, not penalized`
: paused
? 'Paused'
: phase === 'PRESENTING'
? 'Presentation time remaining'
: 'Q&A time remaining'}
</p>
</div>
{/* Primary transition + pause */}
<div className="flex gap-2">
<Button
className="flex-1"
size="lg"
onClick={primaryAction.run}
disabled={primaryAction.disabled || busy}
>
<PrimaryIcon className="mr-2 h-4 w-4" />
{primaryAction.label}
</Button>
{remaining !== null &&
(paused ? (
<Button
variant="outline"
size="lg"
onClick={() => resumePhase.mutate({ roundId })}
disabled={resumePhase.isPending}
>
<Play className="mr-2 h-4 w-4" />
Resume
</Button>
) : (
<Button
variant="outline"
size="lg"
onClick={() => pausePhase.mutate({ roundId })}
disabled={pausePhase.isPending}
>
<Pause className="mr-2 h-4 w-4" />
Pause
</Button>
))}
</div>
{/* Duration overrides for the NEXT start */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Presentation (min, override)</Label>
<Input
type="number"
min="0.5"
step="0.5"
placeholder="config default"
value={presentationMin}
onChange={(e) => setPresentationMin(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Q&A (min, override)</Label>
<Input
type="number"
min="0.5"
step="0.5"
placeholder="config default"
value={qaMin}
onChange={(e) => setQaMin(e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,332 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ArrowDown, ArrowUp, PartyPopper, Play, RotateCcw, Sparkles, Trash2, Wand2 } from 'lucide-react'
import { toast } from 'sonner'
type RevealStep = {
kind: 'category-intro' | 'place' | 'audience-award' | 'overall-favorite' | 'thanks'
category?: 'STARTUP' | 'BUSINESS_CONCEPT'
place?: number
projectId?: string
title?: string
subtitle?: string
}
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
const PLACE_LABEL: Record<number, string> = { 1: 'Winner', 2: '2nd place', 3: '3rd place' }
function describeStep(step: RevealStep): string {
switch (step.kind) {
case 'category-intro':
return `${CATEGORY_LABEL[step.category ?? ''] ?? 'Category'}`
case 'place':
return `${PLACE_LABEL[step.place ?? 0] ?? `${step.place}th`} · ${step.title ?? '?'} (${CATEGORY_LABEL[step.category ?? ''] ?? ''})`
case 'audience-award':
return `Audience Choice (${CATEGORY_LABEL[step.category ?? ''] ?? ''}) · ${step.title ?? '?'}`
case 'overall-favorite':
return `Audience Favorite Overall · ${step.title ?? '?'}`
case 'thanks':
return 'Thank-you slide'
}
}
/**
* Results reveal builder + stepper. Compose privately from deliberation
* results / jury scores / audience tallies, preview every step, then arm the
* big screen and fire one step at a time. Nothing reaches the projector
* before "Arm".
*/
export function RevealPanel({ roundId, competitionId }: { roundId: string; competitionId: string }) {
const utils = trpc.useUtils()
const { data: session } = trpc.liveVoting.getSession.useQuery({ roundId })
const sessionId = session?.id ?? ''
const { data: reveal } = trpc.liveVoting.getRevealAdmin.useQuery(
{ sessionId },
{ enabled: !!sessionId, refetchInterval: 3000 }
)
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId })
const { data: results } = trpc.liveVoting.getResults.useQuery(
{ sessionId },
{ enabled: !!sessionId }
)
const { data: tallies } = trpc.liveVoting.getFavoriteTallies.useQuery(
{ sessionId },
{ enabled: !!sessionId }
)
const { data: delibSessions } = trpc.deliberation.listSessions.useQuery({ competitionId })
const [draftSteps, setDraftSteps] = useState<RevealStep[] | null>(null)
const invalidate = () => utils.liveVoting.getRevealAdmin.invalidate({ sessionId })
const onError = (err: { message: string }) => toast.error(err.message)
const saveReveal = trpc.liveVoting.saveReveal.useMutation({
onSuccess: () => {
invalidate()
toast.success('Reveal draft saved')
},
onError,
})
const armReveal = trpc.liveVoting.armReveal.useMutation({ onSuccess: invalidate, onError })
const revealNext = trpc.liveVoting.revealNext.useMutation({ onSuccess: invalidate, onError })
const resetReveal = trpc.liveVoting.resetReveal.useMutation({ onSuccess: invalidate, onError })
if (!session) return null
const savedSteps = (reveal?.stepsJson as RevealStep[] | undefined) ?? []
const steps = draftSteps ?? savedSteps
const status = reveal?.status ?? 'DRAFT'
const currentIndex = reveal?.currentStepIndex ?? -1
const categoryOf = (projectId: string) =>
cursor?.orderedProjects?.find((p) => p.id === projectId)?.competitionCategory ?? null
const displayName = (projectId: string) => {
const p = cursor?.orderedProjects?.find((p) => p.id === projectId)
return p?.teamName ?? p?.title ?? 'Unknown'
}
const compose = () => {
const composed: RevealStep[] = []
const categories: Array<'BUSINESS_CONCEPT' | 'STARTUP'> = ['BUSINESS_CONCEPT', 'STARTUP']
for (const category of categories) {
// Prefer finalized deliberation results; fall back to jury score order
const delib = (delibSessions ?? []).find(
(s: any) => s.category === category && s.roundId && s._count?.votes !== undefined
)
let rankedProjectIds: string[] = []
// listSessions has no results — use jury results ordered by weightedTotal as base
const categoryResults = (results?.results ?? []).filter(
(r: any) => r.project?.id && categoryOf(r.project.id) === category
)
rankedProjectIds = categoryResults.map((r: any) => r.project.id)
void delib // deliberation results are applied via "adjust manually" — see note below
if (rankedProjectIds.length === 0) continue
composed.push({
kind: 'category-intro',
category,
title: CATEGORY_LABEL[category],
})
const top = rankedProjectIds.slice(0, 3)
// Reverse order: 3rd → 2nd → 1st
top
.map((projectId, idx) => ({ projectId, place: idx + 1 }))
.reverse()
.forEach(({ projectId, place }) => {
composed.push({
kind: 'place',
category,
place,
projectId,
title: displayName(projectId),
subtitle: `${PLACE_LABEL[place] ?? `${place}th place`}${CATEGORY_LABEL[category]}`,
})
})
const audienceWindow = tallies?.windows.find((w) => w.windowKey === `CATEGORY:${category}`)
const audienceTop = audienceWindow?.projects[0]
if (audienceTop) {
composed.push({
kind: 'audience-award',
category,
projectId: audienceTop.projectId,
title: audienceTop.teamName ?? audienceTop.title,
subtitle: `Audience Choice — ${CATEGORY_LABEL[category]}`,
})
}
}
const overallWindow = tallies?.windows.find((w) => w.windowKey === 'OVERALL')
const overallTop = overallWindow?.projects[0]
if (overallTop) {
composed.push({
kind: 'overall-favorite',
projectId: overallTop.projectId,
title: overallTop.teamName ?? overallTop.title,
subtitle: 'Audience Favorite — Overall',
})
}
composed.push({ kind: 'thanks', title: 'Thank you' })
if (composed.length <= 1) {
toast.info('No results to compose from yet — scores and votes are still empty')
return
}
setDraftSteps(composed)
}
const moveStep = (index: number, delta: -1 | 1) => {
const next = [...steps]
const target = index + delta
if (target < 0 || target >= next.length) return
;[next[index], next[target]] = [next[target], next[index]]
setDraftSteps(next)
}
const removeStep = (index: number) => {
setDraftSteps(steps.filter((_, i) => i !== index))
}
const isDraft = status === 'DRAFT'
const isLive = status === 'REVEALING' || status === 'DONE'
const nextStep = steps[currentIndex + 1]
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<PartyPopper className="h-5 w-5" />
Results Reveal
</CardTitle>
<CardDescription>
Compose privately, arm the big screen, reveal step by step
</CardDescription>
</div>
<Badge
variant={isDraft ? 'secondary' : 'default'}
className={isLive ? 'bg-[#de0f1e] hover:bg-[#de0f1e]' : undefined}
>
{status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isDraft && (
<>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={compose}>
<Wand2 className="mr-2 h-4 w-4" />
Compose from results
</Button>
<Button
className="flex-1"
disabled={!draftSteps || saveReveal.isPending}
onClick={() => sessionId && saveReveal.mutate({ sessionId, steps })}
>
Save draft
</Button>
</div>
<p className="text-xs text-muted-foreground">
Composed from jury scores (top 3 per category, revealed 3rd 1st) + audience
tallies. If deliberation changed the order, adjust the steps below before saving.
</p>
</>
)}
{steps.length > 0 && (
<div className="space-y-1">
{steps.map((step, i) => {
const revealed = i <= currentIndex
return (
<div
key={i}
className={`flex items-center gap-2 rounded-lg border p-2 text-sm ${
revealed
? 'border-green-600/30 bg-green-600/5'
: i === currentIndex + 1 && !isDraft
? 'border-[#de0f1e]/40 bg-[#de0f1e]/5'
: ''
}`}
>
<span className="w-5 text-center text-xs tabular-nums text-muted-foreground">
{i + 1}
</span>
<span className="min-w-0 flex-1 truncate">{describeStep(step)}</span>
{revealed && <Badge variant="outline" className="text-xs">revealed</Badge>}
{isDraft && (
<div className="flex shrink-0 gap-0.5">
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === 0} onClick={() => moveStep(i, -1)}>
<ArrowUp className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === steps.length - 1} onClick={() => moveStep(i, 1)}>
<ArrowDown className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeStep(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
)
})}
</div>
)}
{isDraft && savedSteps.length > 0 && !draftSteps && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full" size="lg">
<Sparkles className="mr-2 h-4 w-4" />
Arm reveal
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Arm the results reveal?</AlertDialogTitle>
<AlertDialogDescription>
The big screen switches to the Results splash immediately. Nothing is revealed
until you press Reveal next.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => sessionId && armReveal.mutate({ sessionId })}>
Arm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{!isDraft && (
<div className="space-y-2">
<Button
className="w-full bg-[#de0f1e] hover:bg-[#c00d1a]"
size="lg"
disabled={revealNext.isPending || status === 'DONE'}
onClick={() => sessionId && revealNext.mutate({ sessionId })}
>
<Play className="mr-2 h-4 w-4" />
{status === 'DONE'
? 'All revealed'
: `Reveal next (${currentIndex + 2}/${steps.length})`}
</Button>
{nextStep && status !== 'DONE' && (
<p className="text-center text-xs text-muted-foreground">
Next on screen: <span className="font-medium">{describeStep(nextStep)}</span>
</p>
)}
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={() => sessionId && resetReveal.mutate({ sessionId })}
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
Reset to draft (leaves reveal mode)
</Button>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowDown, ArrowUp, MonitorUp } from 'lucide-react'
import { toast } from 'sonner'
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
/**
* The ceremony run order, grouped by category, with quick reorder (▲▼) and a
* "Send to screens" action per project — built for last-minute schedule
* shuffles without leaving the console.
*/
export function RunOrderList({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 5000 })
const reorderMutation = trpc.live.reorder.useMutation({
onSuccess: () => utils.live.getCursor.invalidate({ roundId }),
onError: (err) => toast.error(err.message),
})
const sendMutation = trpc.live.sendToScreens.useMutation({
onSuccess: (_d, vars) => {
utils.live.getCursor.invalidate({ roundId })
const p = cursor?.orderedProjects?.find((p) => p.id === vars.projectId)
toast.success(`${p?.teamName ?? p?.title ?? 'Project'} is now on screens (up next)`)
},
onError: (err) => toast.error(err.message),
})
const projects = cursor?.orderedProjects ?? []
if (!cursor || projects.length === 0) {
return null
}
const move = (index: number, delta: -1 | 1) => {
const order = projects.map((p) => p.id)
const target = index + delta
if (target < 0 || target >= order.length) return
;[order[index], order[target]] = [order[target], order[index]]
reorderMutation.mutate({ roundId, projectOrder: order })
}
// Group rows under category headings while preserving the global order
const rows: Array<{ type: 'heading'; label: string } | { type: 'project'; index: number }> = []
let lastCategory: string | null = null
projects.forEach((p, index) => {
const cat = p.competitionCategory ?? 'OTHER'
if (cat !== lastCategory) {
rows.push({ type: 'heading', label: CATEGORY_LABEL[cat] ?? 'Other' })
lastCategory = cat
}
rows.push({ type: 'project', index })
})
return (
<Card>
<CardHeader>
<CardTitle>Run Order</CardTitle>
<CardDescription>
Reorder presentations on the fly · Send to screens puts a team up next everywhere
</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
{rows.map((row, i) => {
if (row.type === 'heading') {
return (
<p
key={`h-${i}`}
className="pt-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground first:pt-0"
>
{row.label}
</p>
)
}
const project = projects[row.index]
const isActive = project.id === cursor.activeProjectId
return (
<div
key={project.id}
className={`flex items-center gap-2 rounded-lg border p-2.5 ${
isActive ? 'border-[#de0f1e]/40 bg-[#de0f1e]/5' : 'border-transparent hover:bg-muted/40'
}`}
>
<span className="w-6 text-center text-sm tabular-nums text-muted-foreground">
{row.index + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{project.title}</p>
{project.teamName && (
<p className="truncate text-xs text-muted-foreground">{project.teamName}</p>
)}
</div>
{isActive && (
<Badge className="shrink-0 bg-[#de0f1e] hover:bg-[#de0f1e]">
{cursor.projectPhase === 'ON_DECK' ? 'on deck' : 'live'}
</Badge>
)}
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={reorderMutation.isPending || row.index === 0}
onClick={() => move(row.index, -1)}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={reorderMutation.isPending || row.index === projects.length - 1}
onClick={() => move(row.index, 1)}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2 text-xs"
disabled={sendMutation.isPending || isActive}
onClick={() => sendMutation.mutate({ roundId, projectId: project.id })}
>
<MonitorUp className="h-3.5 w-3.5" />
Send to screens
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { formatClock } from '@/lib/live-timer'
import { Timer } from 'lucide-react'
type TimingEntry = {
projectId: string
phase: 'PRESENTING' | 'QA'
startedAt: string
endedAt: string
configuredSeconds: number | null
elapsedSeconds: number
overranSeconds: number
}
/**
* Factual per-project timing record: configured vs actual, with overruns
* highlighted (noted, never penalized).
*/
export function TimingLogCard({ roundId }: { roundId: string }) {
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 5000 })
const log = (cursor?.timingLogJson as TimingEntry[] | null) ?? []
if (!cursor || log.length === 0) return null
const titleFor = (projectId: string) => {
const p = cursor.orderedProjects?.find((p) => p.id === projectId)
return p?.teamName ?? p?.title ?? 'Unknown'
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Timer className="h-5 w-5" />
Timing Log
</CardTitle>
<CardDescription>Configured vs actual overruns are noted, not penalized</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{log.map((entry, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border p-2.5 text-sm">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{titleFor(entry.projectId)}</p>
<p className="text-xs text-muted-foreground">
{entry.phase === 'PRESENTING' ? 'Presentation' : 'Q&A'}
</p>
</div>
<span className="text-xs tabular-nums text-muted-foreground">
{entry.configuredSeconds != null ? formatClock(entry.configuredSeconds) : ''} planned
{' · '}
{formatClock(entry.elapsedSeconds ?? 0)} actual
</span>
{entry.overranSeconds > 0 ? (
<Badge variant="destructive" className="shrink-0 tabular-nums">
+{formatClock(entry.overranSeconds).replace('+', '')} over
</Badge>
) : (
<Badge variant="secondary" className="shrink-0">
on time
</Badge>
)}
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,11 +1,12 @@
'use client' 'use client'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -16,7 +17,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { CheckCircle2 } from 'lucide-react' import { CheckCircle2, Pencil } from 'lucide-react'
interface LiveVotingCriterion { interface LiveVotingCriterion {
id: string id: string
@@ -30,12 +31,19 @@ interface LiveVotingFormProps {
projectId: string projectId: string
votingMode?: 'simple' | 'criteria' votingMode?: 'simple' | 'criteria'
criteria?: LiveVotingCriterion[] criteria?: LiveVotingCriterion[]
onVoteSubmit: (vote: { score: number; criterionScores?: Record<string, number> }) => void onVoteSubmit: (vote: {
score: number
criterionScores?: Record<string, number>
comment?: string
}) => void
disabled?: boolean disabled?: boolean
existingVote?: { existingVote?: {
score: number score: number
criterionScoresJson?: Record<string, number> criterionScoresJson?: Record<string, number>
comment?: string | null
} | null } | null
/** Visual emphasis when the admin opens the scoring phase */
highlighted?: boolean
} }
export function LiveVotingForm({ export function LiveVotingForm({
@@ -45,73 +53,113 @@ export function LiveVotingForm({
onVoteSubmit, onVoteSubmit,
disabled = false, disabled = false,
existingVote, existingVote,
highlighted = false,
}: LiveVotingFormProps) { }: LiveVotingFormProps) {
const [score, setScore] = useState(existingVote?.score ?? 50) const [score, setScore] = useState(existingVote?.score ?? 5)
const [criterionScores, setCriterionScores] = useState<Record<string, number>>( const [criterionScores, setCriterionScores] = useState<Record<string, number>>(
existingVote?.criterionScoresJson ?? {} existingVote?.criterionScoresJson ?? {}
) )
const [comment, setComment] = useState(existingVote?.comment ?? '')
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false) const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
const [hasSubmitted, setHasSubmitted] = useState(!!existingVote) const [editing, setEditing] = useState(!existingVote)
const handleSubmit = () => { // When the ceremony cursor moves to a new project, reset the form state
setConfirmDialogOpen(true) useEffect(() => {
} setScore(existingVote?.score ?? 5)
setCriterionScores(existingVote?.criterionScoresJson ?? {})
setComment(existingVote?.comment ?? '')
setEditing(!existingVote)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId])
const handleConfirm = () => { const handleConfirm = () => {
if (votingMode === 'criteria' && criteria) { if (votingMode === 'criteria' && criteria) {
// Compute weighted score for display // The server recomputes the weighted score from criterionScores; this
// 1-10 value is only a fallback and MUST stay within the API's range.
let weightedSum = 0 let weightedSum = 0
for (const c of criteria) { for (const c of criteria) {
const normalizedScore = (criterionScores[c.id] / c.scale) * 10 const normalizedScore = ((criterionScores[c.id] ?? 0) / c.scale) * 10
weightedSum += normalizedScore * c.weight weightedSum += normalizedScore * c.weight
} }
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum))) * 10 // Scale to 100 for display const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
onVoteSubmit({ onVoteSubmit({
score: computedScore, score: computedScore,
criterionScores, criterionScores,
comment: comment.trim() || undefined,
}) })
} else { } else {
onVoteSubmit({ score }) onVoteSubmit({ score, comment: comment.trim() || undefined })
} }
setEditing(false)
setHasSubmitted(true)
setConfirmDialogOpen(false) setConfirmDialogOpen(false)
} }
if (hasSubmitted || disabled) { if (!editing) {
return ( return (
<Card> <Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-10">
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" /> <CheckCircle2 className="mb-3 h-12 w-12 text-green-600" />
<p className="font-medium">Vote Submitted</p> <p className="font-medium">Vote Submitted</p>
{votingMode === 'simple' && ( {votingMode === 'criteria' && criteria ? (
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/100</p>
)}
{votingMode === 'criteria' && criteria && (
<div className="mt-3 text-sm text-muted-foreground space-y-1"> <div className="mt-3 text-sm text-muted-foreground space-y-1">
{criteria.map((c) => ( {criteria.map((c) => (
<div key={c.id} className="flex justify-between gap-4"> <div key={c.id} className="flex justify-between gap-6">
<span>{c.label}:</span> <span>{c.label}:</span>
<span className="font-medium">{criterionScores[c.id] ?? 0}/{c.scale}</span> <span className="font-medium">
{criterionScores[c.id] ?? 0}/{c.scale}
</span>
</div> </div>
))} ))}
</div> </div>
) : (
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/10</p>
)} )}
{comment.trim() && (
<p className="mt-3 max-w-md text-center text-xs italic text-muted-foreground">
{comment.trim()}
</p>
)}
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setEditing(true)}
disabled={disabled}
>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit vote
</Button>
<p className="mt-2 text-xs text-muted-foreground">
You can revise your vote until the session closes
</p>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
const commentField = (
<div className="space-y-2">
<Label className="text-sm">Comment (optional)</Label>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Visible to admins alongside your scores…"
rows={2}
/>
</div>
)
// Criteria-based voting // Criteria-based voting
if (votingMode === 'criteria' && criteria && criteria.length > 0) { if (votingMode === 'criteria' && criteria && criteria.length > 0) {
const allScored = criteria.every((c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0) const allScored = criteria.every(
(c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0
)
return ( return (
<> <>
<Card> <Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardHeader> <CardHeader>
<CardTitle>Criteria-Based Voting</CardTitle> <CardTitle>Score This Project</CardTitle>
<CardDescription>Score each criterion individually</CardDescription> <CardDescription>Score each criterion individually</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
@@ -153,7 +201,7 @@ export function LiveVotingForm({
onValueChange={(values) => onValueChange={(values) =>
setCriterionScores({ setCriterionScores({
...criterionScores, ...criterionScores,
[criterion.id]: values[0], [criterion.id]: Math.max(1, values[0]),
}) })
} }
min={0} min={0}
@@ -164,13 +212,15 @@ export function LiveVotingForm({
</div> </div>
))} ))}
{commentField}
<Button <Button
onClick={handleSubmit} onClick={() => setConfirmDialogOpen(true)}
disabled={!allScored} disabled={!allScored || disabled}
className="w-full" className="w-full"
size="lg" size="lg"
> >
Submit Vote {existingVote ? 'Update Vote' : 'Submit Vote'}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -179,17 +229,19 @@ export function LiveVotingForm({
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle> <AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription asChild>
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2">
<p className="font-medium">Your scores:</p> <p className="font-medium">Your scores:</p>
{criteria.map((c) => ( {criteria.map((c) => (
<div key={c.id} className="flex justify-between text-sm"> <div key={c.id} className="flex justify-between text-sm">
<span>{c.label}:</span> <span>{c.label}:</span>
<span className="font-semibold">{criterionScores[c.id]}/{c.scale}</span> <span className="font-semibold">
{criterionScores[c.id]}/{c.scale}
</span>
</div> </div>
))} ))}
<p className="text-xs text-muted-foreground mt-3"> <p className="text-xs text-muted-foreground mt-3">
This action cannot be undone. Are you sure? You can still revise your vote until the session closes.
</p> </p>
</div> </div>
</AlertDialogDescription> </AlertDialogDescription>
@@ -204,13 +256,13 @@ export function LiveVotingForm({
) )
} }
// Simple voting (0-100 slider) // Simple voting (1-10 slider — matches the API contract)
return ( return (
<> <>
<Card> <Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardHeader> <CardHeader>
<CardTitle>Live Voting</CardTitle> <CardTitle>Score This Project</CardTitle>
<CardDescription>Rate this project on a scale of 0-100</CardDescription> <CardDescription>Rate this project on a scale of 1-10</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
@@ -219,10 +271,12 @@ export function LiveVotingForm({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Input <Input
type="number" type="number"
min="0" min="1"
max="100" max="10"
value={score} value={score}
onChange={(e) => setScore(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))} onChange={(e) =>
setScore(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))
}
className="w-20 text-center" className="w-20 text-center"
/> />
<span className="text-2xl font-bold text-primary">{score}</span> <span className="text-2xl font-bold text-primary">{score}</span>
@@ -231,34 +285,35 @@ export function LiveVotingForm({
<Slider <Slider
value={[score]} value={[score]}
onValueChange={(values) => setScore(values[0])} onValueChange={(values) => setScore(Math.max(1, values[0]))}
min={0} min={1}
max={100} max={10}
step={1} step={1}
className="w-full" className="w-full"
/> />
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span>Poor (0)</span> <span>Poor (1)</span>
<span>Average (50)</span> <span>Average (5)</span>
<span>Excellent (100)</span> <span>Excellent (10)</span>
</div> </div>
</div> </div>
<Button onClick={handleSubmit} className="w-full" size="lg"> {commentField}
Submit Vote
<Button onClick={() => setConfirmDialogOpen(true)} className="w-full" size="lg" disabled={disabled}>
{existingVote ? 'Update Vote' : 'Submit Vote'}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{/* Confirmation Dialog */}
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}> <AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle> <AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
You are about to submit a score of <strong>{score}/100</strong>. This action cannot You are about to submit a score of <strong>{score}/10</strong>. You can still revise
be undone. Are you sure? it until the session closes.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { BookOpen, Home, Trophy, ClipboardList, FileText } from 'lucide-react' import { BookOpen, Home, Trophy, ClipboardList, FileText, Radio, Scale } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav' import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -50,6 +50,11 @@ export function JuryNav({ user }: JuryNavProps) {
// Only Grand-Final jury members (and admins) can open the finalist documents // Only Grand-Final jury members (and admins) can open the finalist documents
// review — hide the link from everyone else so they don't hit a dead "No access" page. // review — hide the link from everyone else so they don't hit a dead "No access" page.
const { data: canReviewFinals } = trpc.finalist.canReviewDocuments.useQuery() const { data: canReviewFinals } = trpc.finalist.canReviewDocuments.useQuery()
// Ceremony-day links: live scoring page while a ceremony cursor is active,
// plus any deliberation sessions the juror participates in.
const { data: ceremony } = trpc.live.getMyCeremonyContext.useQuery(undefined, {
refetchInterval: 30000,
})
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
@@ -73,6 +78,20 @@ export function JuryNav({ user }: JuryNavProps) {
}, },
] ]
: []), : []),
...(ceremony?.liveRoundId
? [
{
name: 'Live Ceremony',
href: `/jury/competitions/${ceremony.liveRoundId}/live`,
icon: Radio,
},
]
: []),
...(ceremony?.deliberations ?? []).map((d) => ({
name: `Deliberation — ${d.category === 'STARTUP' ? 'Startups' : 'Business Concepts'}`,
href: `/jury/competitions/deliberation/${d.id}`,
icon: Scale,
})),
...(myAwards && myAwards.length > 0 ...(myAwards && myAwards.length > 0
? [ ? [
{ {

View File

@@ -12,6 +12,7 @@ type TimingEntry = {
startedAt: string startedAt: string
endedAt: string endedAt: string
configuredSeconds: number | null configuredSeconds: number | null
elapsedSeconds: number // pause-adjusted actual duration
overranSeconds: number overranSeconds: number
} }
@@ -34,6 +35,7 @@ function closedOutTiming(cursor: LiveProgressCursor, now: Date): Prisma.InputJso
startedAt: cursor.phaseStartedAt.toISOString(), startedAt: cursor.phaseStartedAt.toISOString(),
endedAt: now.toISOString(), endedAt: now.toISOString(),
configuredSeconds: cursor.phaseDurationSeconds, configuredSeconds: cursor.phaseDurationSeconds,
elapsedSeconds: elapsedSec,
overranSeconds: overranSeconds:
cursor.phaseDurationSeconds == null cursor.phaseDurationSeconds == null
? 0 ? 0
@@ -714,6 +716,31 @@ export const liveRouter = router({
}) })
}), }),
/**
* Ceremony navigation context for jurors: the active live round (if a
* ceremony cursor exists) and any deliberation sessions they participate in.
*/
getMyCeremonyContext: protectedProcedure.query(async ({ ctx }) => {
const cursor = await ctx.prisma.liveProgressCursor.findFirst({
where: { round: { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' } },
select: { roundId: true },
orderBy: { updatedAt: 'desc' },
})
const participations = await ctx.prisma.deliberationParticipant.findMany({
where: {
user: { userId: ctx.user.id },
session: { status: { in: ['DELIB_OPEN', 'VOTING', 'RUNOFF'] } },
},
select: {
session: { select: { id: true, category: true, status: true } },
},
})
return {
liveRoundId: cursor?.roundId ?? null,
deliberations: participations.map((p) => p.session),
}
}),
/** /**
* Get current cursor state (for all users, including audience) * Get current cursor state (for all users, including audience)
*/ */