add shared tab components: GameTab, BingoTab, BoardTab, HostTab, BingoClaims
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
44
packages/client/src/components/bingo-claims.tsx
Normal file
44
packages/client/src/components/bingo-claims.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
packages/client/src/components/bingo-tab.tsx
Normal file
31
packages/client/src/components/bingo-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
packages/client/src/components/board-tab.tsx
Normal file
19
packages/client/src/components/board-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
packages/client/src/components/game-tab.tsx
Normal file
85
packages/client/src/components/game-tab.tsx
Normal 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
|
||||
}
|
||||
141
packages/client/src/components/host-tab.tsx
Normal file
141
packages/client/src/components/host-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user