1
0
Fork 0
polaris-task-force/src/components/frontend/minigames/poker/PokerTable.tsx
Z8MB1E 34dcefec54 feat(poker): felt UI popups, named table display, and dev controls
Table components, popup system, dev panel, and the actions/page wiring, with popup, table, and dev-control tests.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-14 15:37:19 -04:00

284 lines
9.1 KiB
TypeScript

"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { MIN_PLAYERS } from "@/lib/minigames/poker";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { PokerCardStyleProvider } from "./cardStyle";
import { PokerGameTable } from "./PokerGameTable";
import { RenameTableDialog } from "./RenameTableDialog";
import { usePokerPopups } from "./usePokerPopups";
import { usePokerSounds } from "./usePokerSounds";
import { seatPosition } from "./seatPosition";
interface PokerTableProps {
readonly snapshot: PokerSnapshot | null;
readonly currentUserId: number | string;
readonly loading: boolean;
readonly tableError: string | null;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly onJoin: () => Promise<void>;
readonly onSpectate: () => Promise<void>;
readonly onStart: () => Promise<void>;
readonly onLeave: () => Promise<void>;
readonly onBack: () => void;
readonly isSuperuser: boolean;
readonly superuserIds: readonly number[];
}
type SeatedSnapshot = PokerSnapshot & {
readonly you: NonNullable<PokerSnapshot["you"]>;
};
function isSeatedSnapshot(snapshot: PokerSnapshot): snapshot is SeatedSnapshot {
return snapshot.you !== null;
}
function WaitingTable({
snapshot,
currentUserId,
onSnapshot,
onJoin,
onSpectate,
onStart,
onLeave,
onBack,
}: {
readonly snapshot: PokerSnapshot;
readonly currentUserId: number | string;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly onJoin: () => Promise<void>;
readonly onSpectate: () => Promise<void>;
readonly onStart: () => Promise<void>;
readonly onLeave: () => Promise<void>;
readonly onBack: () => void;
}) {
const [starting, setStarting] = useState(false);
const [leaving, setLeaving] = useState(false);
const viewerSeat = snapshot.seats.findIndex((id) => String(id) === String(currentUserId));
const seated = viewerSeat >= 0;
const full = snapshot.seats.length > 0 && snapshot.seats.every((id) => id !== null);
const seatedCount = snapshot.seats.filter((id) => id !== null).length;
const enoughPlayers = seatedCount >= MIN_PLAYERS;
const isHost = seated && snapshot.hostSeatId === viewerSeat;
const opponentCount = snapshot.seats.length - (seated ? 1 : 0);
const start = async () => {
setStarting(true);
await onStart();
setStarting(false);
};
const leave = async () => {
setLeaving(true);
await onLeave();
setLeaving(false);
};
return (
<Card className="rounded-none">
<CardHeader className="flex-row items-start justify-between gap-4 border-b p-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<CardTitle className="text-base">
{snapshot.name ?? `Table ${snapshot.tableId}`}
</CardTitle>
{isHost && (
<RenameTableDialog
tableId={snapshot.tableId}
currentName={snapshot.name}
onSnapshot={onSnapshot}
/>
)}
</div>
<p className="text-sm text-muted-foreground">
{seated
? "You have a seat reserved."
: "Join an open seat to receive a private hand view."}
</p>
</div>
<Badge variant="outline">Waiting room</Badge>
</CardHeader>
<CardContent className="p-4">
<div className="poker-felt-table relative min-h-[30rem] overflow-hidden p-4 sm:min-h-[36rem]">
{snapshot.seats.map((userId, seatId) => (
<div
key={seatId}
className={cn(
"poker-waiting-seat absolute flex flex-col items-center gap-1 border px-3 py-2 text-xs",
seatId === viewerSeat
? "poker-waiting-seat-south"
: seatPosition(
snapshot.seats
.slice(0, seatId)
.filter((_, previousSeatId) => previousSeatId !== viewerSeat).length,
opponentCount,
),
userId !== null && "poker-waiting-seat-occupied",
)}
>
<span
className={cn(
"poker-seat-avatar",
userId === null ? "poker-seat-avatar-open" : "poker-seat-avatar-tone-0",
)}
aria-hidden="true"
>
{userId === null ? "?" : String(userId).charAt(0).toUpperCase()}
</span>
<span className="poker-waiting-name font-semibold">Seat {seatId + 1}</span>
<span className="poker-waiting-status text-white/70">
{userId === null
? "Open seat"
: String(userId) === String(currentUserId)
? "You"
: "Occupied"}
</span>
</div>
))}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2 text-center text-white">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/60">
Polaris Hold&apos;em
</span>
<span className="text-sm text-white/80">
{seated ? "Waiting for the host to start" : "Open seating"}
</span>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-wrap justify-end gap-2 border-t p-4">
{seated ? (
<>
{isHost && (
<Button
type="button"
onClick={() => void start()}
disabled={starting || !enoughPlayers}
>
{starting
? "Starting..."
: enoughPlayers
? "Start game"
: "You need at least two players to start"}
</Button>
)}
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
{leaving ? "Leaving..." : "Leave table"}
</Button>
</>
) : (
<>
<Button type="button" onClick={() => void onJoin()} disabled={full}>
{full ? "Table full" : "Join an open seat"}
</Button>
{snapshot.inGame && (
<Button type="button" variant="outline" onClick={() => void onSpectate()}>
Spectate
</Button>
)}
<Button type="button" variant="outline" onClick={onBack}>
Back to lobby
</Button>
</>
)}
</CardFooter>
</Card>
);
}
function PokerTableInner({
snapshot,
currentUserId,
loading,
tableError,
onSnapshot,
onJoin,
onSpectate,
onStart,
onLeave,
onBack,
isSuperuser,
superuserIds,
}: PokerTableProps) {
// Sound + popup transitions must live here, ABOVE the waiting/game split:
// the hooks have to observe the waiting-room-to-first-hand transition,
// which happens before the game table (and its seat view) mounts.
const sound = usePokerSounds(snapshot);
const { popups, actionLog } = usePokerPopups(snapshot);
if (loading && !snapshot) {
return (
<Card className="rounded-none">
<CardHeader className="gap-3 p-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="p-4 pt-0">
<Skeleton className="h-[28rem] w-full" />
</CardContent>
</Card>
);
}
if (!snapshot) {
return (
<Card className="rounded-none">
<CardHeader className="p-4">
<CardTitle className="text-base">Table unavailable</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<p className="text-sm text-destructive">
{tableError ?? "This table could not be loaded."}
</p>
</CardContent>
<CardFooter className="border-t p-4">
<Button type="button" variant="outline" onClick={onBack}>
Back to lobby
</Button>
</CardFooter>
</Card>
);
}
if (!isSeatedSnapshot(snapshot)) {
return (
<WaitingTable
snapshot={snapshot}
currentUserId={currentUserId}
onSnapshot={onSnapshot}
onJoin={onJoin}
onSpectate={onSpectate}
onStart={onStart}
onLeave={onLeave}
onBack={onBack}
/>
);
}
return (
<PokerGameTable
snapshot={snapshot}
onSnapshot={onSnapshot}
onStart={onStart}
onLeave={onLeave}
onJoin={onJoin}
sound={sound}
popups={popups}
actionLog={actionLog}
isSuperuser={isSuperuser}
superuserIds={superuserIds}
/>
);
}
export function PokerTable(props: PokerTableProps) {
return (
<PokerCardStyleProvider>
<PokerTableInner {...props} />
</PokerCardStyleProvider>
);
}