+
+ {!isSeated && "Tap a seat to join the table. "}
+
+ {seated.length} of {MIN_PLAYERS}
+ {" "}
+ {/* "players" always: the noun agrees with the target, as in "1 of 2 players". */}
+ players seated
+ {canStart
+ ? isSeated
+ ? " — press the green button to deal."
+ : " — take a seat, then anyone seated can deal."
+ : " — at least two are needed to start."}
+
+
+ {waiting.length > 0 && (
+
+ {formatNames(waiting.map((player) => player.name))}{" "}
+ {waiting.length === 1 ? "has" : "have"} joined but{" "}
+ {waiting.length === 1 ? "has" : "have"} not taken a seat yet.
+
+ )}
+
+
+
+ );
+}
+
+export default Lobby;
diff --git a/src/components/NameForm/NameForm.tsx b/src/components/NameForm/NameForm.tsx
new file mode 100644
index 0000000..a742348
--- /dev/null
+++ b/src/components/NameForm/NameForm.tsx
@@ -0,0 +1,54 @@
+import { type FormEvent, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { normalizeName } from "@/game/players";
+
+interface NameFormProps {
+ submitLabel: string;
+ isBusy: boolean;
+ onSubmit: (name: string) => void;
+}
+
+/**
+ * Asks for a display name before creating or joining a game.
+ *
+ * The input is controlled and validated through `normalizeName`, so a name of
+ * three spaces no longer passes the length check and land in Firestore blank.
+ * Wrapping it in a real `
+ {SEATS.map(({ position, team }) => {
+ const occupant = bySeat.get(position);
+
+ // Once play starts, empty seats are just noise.
+ if (!occupant && isGameActive) return null;
+
+ if (!occupant) {
+ const isPending = pendingSeat === position;
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {occupant.name}
+
+ {occupant.isActive && (their turn)}
+
+
+ );
+ })}
+
+ );
+}
+
+export default Seats;
diff --git a/src/components/Sequence/Sequence.jsx b/src/components/Sequence/Sequence.jsx
deleted file mode 100644
index 00b8aef..0000000
--- a/src/components/Sequence/Sequence.jsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import React, { useMemo } from "react";
-import * as db from "../../services/firestore";
-import { uniqueId } from "lodash";
-import { Card } from "../Card";
-import { get, values, sortBy, indexOf } from "lodash";
-import classNames from "classnames";
-import "./Sequence.scss";
-
-const Sequence = (props) => {
- const { game, gameId, userId } = props;
-
- const { board: moves = {}, players } = useMemo(() => game || {}, [game]);
- const me = useMemo(() => players[userId], [players, userId]);
- const myCards = useMemo(() => get(me, "cards") || [], [me]);
-
- // Two Eyed Jacks are Wild
- const hasTwoEyedJack = useMemo(
- () => myCards.includes("J♣") || myCards.includes("J♦"),
- [myCards]
- );
- // One Eyed Jacks Remove
- const hasOneEyedJack = useMemo(
- () => myCards.includes("J♠") || myCards.includes("J♥"),
- [myCards]
- );
- const playersByPosition = useMemo(() => sortBy(values(players), "position"), [
- players,
- ]);
- const currentPlayerId = useMemo(
- () =>
- get(
- values(players).find((p) => p.isActive),
- "id"
- ),
- [players]
- );
-
- const nextPlayerId = useMemo(() => {
- const playerOrder = playersByPosition.map((p) => p.id);
- const currentPlayerIndex = indexOf(playerOrder, currentPlayerId);
- const nextPlayerIndex = (currentPlayerIndex + 1) % playerOrder.length;
- return playerOrder[nextPlayerIndex];
- }, [playersByPosition, currentPlayerId]);
-
- const board = [
- ["JB", "2♠", "3♠", "4♠", "5♠", "6♠", "7♠", "8♠", "9♠", "JL"],
- ["6♣", "5♣", "4♣", "3♣", "2♣", "A♥", "K♥", "Q♥", "T♥", "T♠"],
- ["7♣", "A♠", "2♦", "3♦", "4♦", "5♦", "6♦", "7♦", "9♥", "Q♠"],
- ["8♣", "K♠", "6♣", "5♣", "4♣", "3♣", "2♣", "8♦", "8♥", "K♠"],
- ["9♣", "Q♠", "7♣", "6♥", "5♥", "4♥", "A♥", "9♦", "7♥", "A♠"],
- ["T♣", "T♠", "8♣", "7♥", "2♥", "3♥", "K♥", "T♦", "6♥", "2♦"],
- ["Q♣", "9♠", "9♣", "8♥", "9♥", "T♥", "Q♥", "Q♦", "5♥", "3♦"],
- ["K♣", "8♠", "T♣", "Q♣", "K♣", "A♣", "A♦", "K♦", "4♥", "4♦"],
- ["A♣", "7♠", "6♠", "5♠", "4♠", "3♠", "2♠", "2♥", "3♥", "5♦"],
- ["JL", "A♦", "K♦", "Q♦", "T♦", "9♦", "8♦", "7♦", "6♦", "JB"],
- ];
-
- const handlePlaceToken = (coord, card) => {
- if (!me.isActive) return;
- db.placeToken(
- gameId,
- game,
- coord,
- me.team,
- currentPlayerId,
- nextPlayerId,
- card
- );
- };
-
- const handleRemoveToken = (coord, card) => {
- if (!me.isActive || !hasOneEyedJack) return;
- db.removeToken(gameId, game, coord, currentPlayerId, nextPlayerId, card);
- };
-
- return (
-