add RoomLayout, BottomNav components; update BingoCard (readonly + redraw), Leaderboard (lobbyMode)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 11:48:09 +01:00
parent f3d407ee21
commit 971f4110c1
4 changed files with 167 additions and 23 deletions

View File

@@ -1,13 +1,16 @@
import type { BingoCard as BingoCardType } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
interface BingoCardProps {
card: BingoCardType
onTap: (tropeId: string) => void
readonly?: boolean
onRequestNewCard?: () => void
}
export function BingoCard({ card, onTap }: BingoCardProps) {
export function BingoCard({ card, onTap, readonly, onRequestNewCard }: BingoCardProps) {
return (
<Card>
<CardHeader className="pb-2">
@@ -20,23 +23,31 @@ export function BingoCard({ card, onTap }: BingoCardProps) {
)}
</div>
</CardHeader>
<CardContent>
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-4 gap-1.5">
{card.squares.map((square) => (
<button
key={square.tropeId}
type="button"
onClick={() => onTap(square.tropeId)}
onClick={() => !readonly && onTap(square.tropeId)}
disabled={readonly}
className={`flex aspect-square items-center justify-center rounded-md border p-1.5 text-center text-sm leading-snug transition-colors ${
square.tapped
? "border-primary bg-primary/20 font-medium text-primary"
: "border-muted hover:bg-muted/50"
}`}
: readonly
? "border-muted text-muted-foreground"
: "border-muted hover:bg-muted/50"
} ${readonly ? "cursor-default" : "cursor-pointer"}`}
>
{square.label}
</button>
))}
</div>
{card.hasBingo && onRequestNewCard && (
<Button onClick={onRequestNewCard} variant="outline" className="w-full">
Draw New Card
</Button>
)}
</CardContent>
</Card>
)

View File

@@ -0,0 +1,96 @@
import { Link, useMatchRoute } from "@tanstack/react-router"
interface BottomNavProps {
basePath: "/play/$roomCode" | "/host/$roomCode"
roomCode: string
isHost: boolean
}
function GameIcon({ active }: { active: boolean }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<circle cx="12" cy="12" r="10" />
<polygon points="10,8 16,12 10,16" />
</svg>
)
}
function BingoIcon({ active }: { active: boolean }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
)
}
function TrophyIcon({ active }: { active: boolean }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg>
)
}
function WrenchIcon({ active }: { active: boolean }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
)
}
interface TabConfig {
to: string
label: string
icon: (props: { active: boolean }) => React.ReactNode
}
export function BottomNav({ basePath, roomCode, isHost }: BottomNavProps) {
const matchRoute = useMatchRoute()
const tabs: TabConfig[] = [
{ to: `${basePath}/game`, label: "Game", icon: GameIcon },
{ to: `${basePath}/bingo`, label: "Bingo", icon: BingoIcon },
{ to: `${basePath}/board`, label: isHost ? "Board" : "Leaderboard", icon: TrophyIcon },
]
if (isHost) {
tabs.push({ to: `${basePath}/host`, label: "Host", icon: WrenchIcon })
}
return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex">
{tabs.map((tab) => {
const active = !!matchRoute({ to: tab.to, params: { roomCode } })
return (
<Link
key={tab.to}
to={tab.to}
params={{ roomCode }}
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-xs transition-colors ${
active
? "text-primary"
: "text-muted-foreground"
}`}
>
<tab.icon active={active} />
<span>{tab.label}</span>
</Link>
)
})}
</div>
</nav>
)
}

View File

@@ -4,15 +4,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface LeaderboardProps {
entries: LeaderboardEntry[]
resultsEntered?: boolean
lobbyMode?: boolean
}
export function Leaderboard({ entries, resultsEntered }: LeaderboardProps) {
export function Leaderboard({ entries, resultsEntered, lobbyMode }: LeaderboardProps) {
if (entries.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle>Leaderboard</CardTitle>
<CardTitle>{lobbyMode ? `Players (${entries.length})` : "Leaderboard"}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
@@ -24,25 +25,29 @@ export function Leaderboard({ entries, resultsEntered }: LeaderboardProps) {
</span>
<span className="text-sm font-medium">{entry.displayName}</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
<span title="Jury points">J:{entry.juryPoints}</span>
<span title="Bingo points">B:{entry.bingoPoints}</span>
<span title="Quiz points">Q:{entry.quizPoints}</span>
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
</div>
{!lobbyMode && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
<span title="Jury points">J:{entry.juryPoints}</span>
<span title="Bingo points">B:{entry.bingoPoints}</span>
<span title="Quiz points">Q:{entry.quizPoints}</span>
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
</div>
)}
</div>
))}
</div>
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
<p className="mb-1 font-medium">How scoring works</p>
<ul className="flex flex-col gap-0.5">
<li><strong>P</strong> = Prediction points 25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
<li><strong>J</strong> = Jury points rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
<li><strong>B</strong> = Bingo points 2 pts per tapped trope + 10 bonus for a full bingo line</li>
<li><strong>Q</strong> = Quiz points 5 easy, 10 medium, 15 hard</li>
</ul>
</div>
{!lobbyMode && (
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
<p className="mb-1 font-medium">How scoring works</p>
<ul className="flex flex-col gap-0.5">
<li><strong>P</strong> = Prediction points 25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
<li><strong>J</strong> = Jury points rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
<li><strong>B</strong> = Bingo points 2 pts per tapped trope + 10 bonus for a full bingo line</li>
<li><strong>Q</strong> = Quiz points 5 easy, 10 medium, 15 hard</li>
</ul>
</div>
)}
</CardContent>
</Card>
)

View File

@@ -0,0 +1,32 @@
import { Outlet } from "@tanstack/react-router"
interface RoomLayoutProps {
roomCode: string
connectionStatus: "disconnected" | "connecting" | "connected"
}
export function RoomLayout({ roomCode, connectionStatus }: RoomLayoutProps) {
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-50 flex items-center justify-between border-b bg-background px-4 py-3" style={{ paddingTop: "max(0.75rem, env(safe-area-inset-top))" }}>
<span className="text-lg font-bold">IESC</span>
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-bold tracking-widest">{roomCode}</span>
<span
className={`h-2 w-2 rounded-full ${
connectionStatus === "connected"
? "bg-green-500"
: connectionStatus === "connecting"
? "bg-yellow-500"
: "bg-red-500"
}`}
title={connectionStatus}
/>
</div>
</header>
<main className="flex-1 pb-20">
<Outlet />
</main>
</div>
)
}