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