add shared tab components: GameTab, BingoTab, BoardTab, HostTab, BingoClaims

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 11:50:57 +01:00
parent 971f4110c1
commit 998ac07867
5 changed files with 320 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import type { CompletedBingoCard } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface BingoClaimsProps {
completedCards: CompletedBingoCard[]
}
export function BingoClaims({ completedCards }: BingoClaimsProps) {
if (completedCards.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle>Bingo Claims ({completedCards.length})</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{completedCards.map((claim, i) => (
<div key={`${claim.playerId}-${i}`} className="flex flex-col gap-1.5 border-b pb-3 last:border-0 last:pb-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{claim.displayName}</span>
<span className="text-xs text-muted-foreground">
{new Date(claim.completedAt).toLocaleTimeString()}
</span>
</div>
<div className="grid grid-cols-4 gap-0.5">
{claim.card.squares.map((square) => (
<div
key={square.tropeId}
className={`rounded px-1 py-0.5 text-center text-xs ${
square.tapped
? "bg-primary/20 text-primary"
: "bg-muted/50 text-muted-foreground"
}`}
>
{square.label}
</div>
))}
</div>
</div>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,31 @@
import type { ClientMessage, GameState, Act } from "@celebrate-esc/shared"
import { BingoCard } from "@/components/bingo-card"
interface BingoTabProps {
currentAct: Act
gameState: GameState
send: (message: ClientMessage) => void
}
export function BingoTab({ currentAct, gameState, send }: BingoTabProps) {
const isLiveEvent = currentAct === "live-event"
if (!gameState.myBingoCard) {
return (
<div className="flex flex-col items-center gap-4 p-4 py-8">
<p className="text-muted-foreground">No bingo card yet. Cards are dealt when you join.</p>
</div>
)
}
return (
<div className="flex flex-col gap-4 p-4">
<BingoCard
card={gameState.myBingoCard}
onTap={(tropeId) => send({ type: "tap_bingo_square", tropeId })}
readonly={!isLiveEvent}
onRequestNewCard={isLiveEvent ? () => send({ type: "request_new_bingo_card" }) : undefined}
/>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import type { GameState, Act } from "@celebrate-esc/shared"
import { Leaderboard } from "@/components/leaderboard"
interface BoardTabProps {
currentAct: Act
gameState: GameState
}
export function BoardTab({ currentAct, gameState }: BoardTabProps) {
return (
<div className="flex flex-col gap-4 p-4">
<Leaderboard
entries={gameState.leaderboard}
resultsEntered={!!gameState.actualResults}
lobbyMode={currentAct === "lobby"}
/>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import type { ClientMessage, GameState, Act } from "@celebrate-esc/shared"
import { PredictionsForm } from "@/components/predictions-form"
import { JuryVoting } from "@/components/jury-voting"
import { QuizBuzzer } from "@/components/quiz-buzzer"
interface GameTabProps {
currentAct: Act
gameState: GameState
send: (message: ClientMessage) => void
}
export function GameTab({ currentAct, gameState, send }: GameTabProps) {
if (currentAct === "lobby" || currentAct === "pre-show") {
return (
<div className="flex flex-col gap-4 p-4">
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={gameState.predictionsLocked}
onSubmit={(prediction) => send({ type: "submit_prediction", ...prediction })}
/>
</div>
)
}
if (currentAct === "live-event") {
return (
<div className="flex flex-col gap-4 p-4">
{gameState.currentJuryRound ? (
<JuryVoting
round={gameState.currentJuryRound}
myVote={gameState.myJuryVote}
onVote={(rating) => send({ type: "submit_jury_vote", rating })}
/>
) : (
<div className="py-8 text-center text-muted-foreground">
Waiting for host to open voting...
</div>
)}
</div>
)
}
if (currentAct === "scoring") {
return (
<div className="flex flex-col gap-4 p-4">
{gameState.myPrediction && (
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={true}
actualResults={gameState.actualResults}
onSubmit={() => {}}
/>
)}
{gameState.currentQuizQuestion && (
<QuizBuzzer
question={gameState.currentQuizQuestion}
buzzStatus={gameState.myQuizBuzzStatus}
onBuzz={() => send({ type: "buzz" })}
/>
)}
</div>
)
}
if (currentAct === "ended") {
return (
<div className="flex flex-col items-center gap-4 p-4 py-8">
<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
{gameState.myPrediction && (
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={true}
actualResults={gameState.actualResults}
onSubmit={() => {}}
/>
)}
</div>
)
}
return null
}

View File

@@ -0,0 +1,141 @@
import { useState } from "react"
import type { ClientMessage, GameState, Act } from "@celebrate-esc/shared"
import { JuryHost } from "@/components/jury-host"
import { QuizHost } from "@/components/quiz-host"
import { ActualResultsForm } from "@/components/actual-results-form"
import { BingoClaims } from "@/components/bingo-claims"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const nextActLabels: Partial<Record<Act, string>> = {
lobby: "Start Pre-Show",
"pre-show": "Start Live Event",
"live-event": "Start Scoring",
}
const prevActLabels: Partial<Record<Act, string>> = {
"pre-show": "Back to Lobby",
"live-event": "Back to Pre-Show",
scoring: "Back to Live Event",
ended: "Back to Scoring",
}
interface HostTabProps {
roomCode: string
currentAct: Act
gameState: GameState
send: (message: ClientMessage) => void
}
export function HostTab({ roomCode, currentAct, gameState, send }: HostTabProps) {
const [copied, setCopied] = useState(false)
const base = import.meta.env.BASE_URL.replace(/\/$/, "")
const displayUrl = `${window.location.origin}${base}/display/${roomCode}`
function copyDisplayUrl() {
navigator.clipboard.writeText(displayUrl).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="flex flex-col gap-4 p-4">
{/* Act Controls */}
<Card>
<CardHeader>
<CardTitle>Room Controls</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{currentAct !== "ended" && (
<div className="flex gap-2">
{currentAct !== "lobby" && (
<Button
variant="outline"
onClick={() => send({ type: "revert_act" })}
className="flex-1"
>
{prevActLabels[currentAct] ?? "Back"}
</Button>
)}
{nextActLabels[currentAct] && (
<Button onClick={() => send({ type: "advance_act" })} className="flex-1">
{nextActLabels[currentAct]}
</Button>
)}
</div>
)}
{currentAct === "ended" && (
<Button variant="outline" onClick={() => send({ type: "revert_act" })}>
{prevActLabels[currentAct] ?? "Back"}
</Button>
)}
</CardContent>
</Card>
{/* Display View Link */}
<Card>
<CardHeader>
<CardTitle>Display View</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Project this on a TV for everyone to see.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded bg-muted px-2 py-1 text-xs">{displayUrl}</code>
<Button variant="outline" size="sm" onClick={copyDisplayUrl}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</CardContent>
</Card>
{/* Jury Host (live-event) */}
{currentAct === "live-event" && (
<JuryHost
entries={gameState.lineup.entries}
currentRound={gameState.currentJuryRound}
results={gameState.juryResults}
onOpenVote={(countryCode) => send({ type: "open_jury_vote", countryCode })}
onCloseVote={() => send({ type: "close_jury_vote" })}
/>
)}
{/* Quiz Host (scoring) */}
{currentAct === "scoring" && (
<QuizHost
question={gameState.currentQuizQuestion}
onStartQuestion={() => send({ type: "start_quiz_question" })}
onJudge={(correct) => send({ type: "judge_quiz_answer", correct })}
onSkip={() => send({ type: "skip_quiz_question" })}
/>
)}
{/* Actual Results Form (scoring/ended) */}
{(currentAct === "scoring" || currentAct === "ended") && (
<ActualResultsForm
entries={gameState.lineup.entries}
existingResults={gameState.actualResults}
onSubmit={(results) => send({ type: "submit_actual_results", ...results })}
/>
)}
{/* Bingo Claims */}
{gameState.completedBingoCards.length > 0 && (
<BingoClaims completedCards={gameState.completedBingoCards} />
)}
{/* End Party (destructive) */}
{currentAct !== "ended" && (
<Button
variant="destructive"
onClick={() => send({ type: "end_room" })}
className="w-full"
>
End Party
</Button>
)}
</div>
)
}