From 998ac078677f02cd6eb6fdf4bb408ff5cc185822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Fri, 13 Mar 2026 11:50:57 +0100 Subject: [PATCH] add shared tab components: GameTab, BingoTab, BoardTab, HostTab, BingoClaims Co-Authored-By: Claude Sonnet 4.6 --- .../client/src/components/bingo-claims.tsx | 44 ++++++ packages/client/src/components/bingo-tab.tsx | 31 ++++ packages/client/src/components/board-tab.tsx | 19 +++ packages/client/src/components/game-tab.tsx | 85 +++++++++++ packages/client/src/components/host-tab.tsx | 141 ++++++++++++++++++ 5 files changed, 320 insertions(+) create mode 100644 packages/client/src/components/bingo-claims.tsx create mode 100644 packages/client/src/components/bingo-tab.tsx create mode 100644 packages/client/src/components/board-tab.tsx create mode 100644 packages/client/src/components/game-tab.tsx create mode 100644 packages/client/src/components/host-tab.tsx diff --git a/packages/client/src/components/bingo-claims.tsx b/packages/client/src/components/bingo-claims.tsx new file mode 100644 index 0000000..f88e5b7 --- /dev/null +++ b/packages/client/src/components/bingo-claims.tsx @@ -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 ( + + + Bingo Claims ({completedCards.length}) + + + {completedCards.map((claim, i) => ( +
+
+ {claim.displayName} + + {new Date(claim.completedAt).toLocaleTimeString()} + +
+
+ {claim.card.squares.map((square) => ( +
+ {square.label} +
+ ))} +
+
+ ))} +
+
+ ) +} diff --git a/packages/client/src/components/bingo-tab.tsx b/packages/client/src/components/bingo-tab.tsx new file mode 100644 index 0000000..6ee9060 --- /dev/null +++ b/packages/client/src/components/bingo-tab.tsx @@ -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 ( +
+

No bingo card yet. Cards are dealt when you join.

+
+ ) + } + + return ( +
+ send({ type: "tap_bingo_square", tropeId })} + readonly={!isLiveEvent} + onRequestNewCard={isLiveEvent ? () => send({ type: "request_new_bingo_card" }) : undefined} + /> +
+ ) +} diff --git a/packages/client/src/components/board-tab.tsx b/packages/client/src/components/board-tab.tsx new file mode 100644 index 0000000..523f57a --- /dev/null +++ b/packages/client/src/components/board-tab.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/packages/client/src/components/game-tab.tsx b/packages/client/src/components/game-tab.tsx new file mode 100644 index 0000000..c0aa2c0 --- /dev/null +++ b/packages/client/src/components/game-tab.tsx @@ -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 ( +
+ send({ type: "submit_prediction", ...prediction })} + /> +
+ ) + } + + if (currentAct === "live-event") { + return ( +
+ {gameState.currentJuryRound ? ( + send({ type: "submit_jury_vote", rating })} + /> + ) : ( +
+ Waiting for host to open voting... +
+ )} +
+ ) + } + + if (currentAct === "scoring") { + return ( +
+ {gameState.myPrediction && ( + {}} + /> + )} + {gameState.currentQuizQuestion && ( + send({ type: "buzz" })} + /> + )} +
+ ) + } + + if (currentAct === "ended") { + return ( +
+

The party has ended. Thanks for playing!

+ {gameState.myPrediction && ( + {}} + /> + )} +
+ ) + } + + return null +} diff --git a/packages/client/src/components/host-tab.tsx b/packages/client/src/components/host-tab.tsx new file mode 100644 index 0000000..e0bad27 --- /dev/null +++ b/packages/client/src/components/host-tab.tsx @@ -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> = { + lobby: "Start Pre-Show", + "pre-show": "Start Live Event", + "live-event": "Start Scoring", +} + +const prevActLabels: Partial> = { + "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 ( +
+ {/* Act Controls */} + + + Room Controls + + + {currentAct !== "ended" && ( +
+ {currentAct !== "lobby" && ( + + )} + {nextActLabels[currentAct] && ( + + )} +
+ )} + {currentAct === "ended" && ( + + )} +
+
+ + {/* Display View Link */} + + + Display View + + +

+ Project this on a TV for everyone to see. +

+
+ {displayUrl} + +
+
+
+ + {/* Jury Host (live-event) */} + {currentAct === "live-event" && ( + send({ type: "open_jury_vote", countryCode })} + onCloseVote={() => send({ type: "close_jury_vote" })} + /> + )} + + {/* Quiz Host (scoring) */} + {currentAct === "scoring" && ( + 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") && ( + send({ type: "submit_actual_results", ...results })} + /> + )} + + {/* Bingo Claims */} + {gameState.completedBingoCards.length > 0 && ( + + )} + + {/* End Party (destructive) */} + {currentAct !== "ended" && ( + + )} +
+ ) +}