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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
10
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
535
src/app/(public)/live/ceremony/[roundId]/page.tsx
Normal file
535
src/app/(public)/live/ceremony/[roundId]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
260
src/components/admin/deliberation/deliberation-control-panel.tsx
Normal file
260
src/components/admin/deliberation/deliberation-control-panel.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
278
src/components/admin/live/audience-window-panel.tsx
Normal file
278
src/components/admin/live/audience-window-panel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
228
src/components/admin/live/phase-controls.tsx
Normal file
228
src/components/admin/live/phase-controls.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
332
src/components/admin/live/reveal-panel.tsx
Normal file
332
src/components/admin/live/reveal-panel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
src/components/admin/live/run-order-list.tsx
Normal file
141
src/components/admin/live/run-order-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
src/components/admin/live/timing-log-card.tsx
Normal file
73
src/components/admin/live/timing-log-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user