CoompanionApp/front/play/app.tsx

2324 lines
84 KiB
TypeScript
Raw Normal View History

2026-02-03 13:48:56 +01:00
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
BrowserRouter,
Navigate,
NavLink,
Route,
Routes,
useNavigate,
useParams,
} from "react-router-dom";
import ChatListScreen from "./chat/ChatListScreen";
import ChatNewScreen from "./chat/ChatNewScreen";
import ChatThreadScreen from "./chat/ChatThreadScreen";
import { getLatestThreadTimestamp, getUnreadThreadIds } from "./chat/utils";
import {
formatConnectionState,
formatStatus,
formatTransactionKind,
getLocale,
tStatic,
useI18n,
} from "./i18n";
import QRCode from "qrcode";
import type { SessionSnapshot, Transaction } from "../../shared/types";
const STORAGE_KEY = "negopoly:lastSession";
const CHAT_READ_KEY = "negopoly:chatRead";
type StoredSession = {
sessionId: string;
playerId: string;
};
type JoinResponse = {
sessionId: string;
sessionCode: string;
playerId: string;
role: string;
status: string;
};
type SessionPreviewPlayer = {
id: string;
name: string;
role: string;
isDummy: boolean;
connected: boolean;
};
type SessionPreview = {
sessionId: string;
code: string;
status: string;
players: SessionPreviewPlayer[];
};
function readStoredSession(): StoredSession | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as StoredSession;
} catch {
return null;
}
}
function writeStoredSession(sessionId: string, playerId: string) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ sessionId, playerId }));
}
function clearStoredSession() {
localStorage.removeItem(STORAGE_KEY);
}
function getChatReadKey(sessionId: string, playerId: string) {
return `${CHAT_READ_KEY}:${sessionId}:${playerId}`;
}
function readChatReadState(sessionId: string, playerId: string): Record<string, number> {
try {
const raw = localStorage.getItem(getChatReadKey(sessionId, playerId));
if (!raw) return {};
return JSON.parse(raw) as Record<string, number>;
} catch {
return {};
}
}
function writeChatReadState(sessionId: string, playerId: string, state: Record<string, number>) {
try {
localStorage.setItem(getChatReadKey(sessionId, playerId), JSON.stringify(state));
} catch {
// ignore write failures
}
}
function formatMoney(amount: number) {
const value = new Intl.NumberFormat(getLocale(), {
maximumFractionDigits: 0,
}).format(amount);
return `${value}`;
}
function formatTransactionTimestamp(value: number) {
const date = new Date(value);
const now = new Date();
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
const time = date.toLocaleTimeString(getLocale(), { hour: "2-digit", minute: "2-digit" });
if (sameDay) return time;
const day = date.toLocaleDateString(getLocale(), { month: "short", day: "numeric" });
return `${day} ${time}`;
}
function getTransactionLabel(
transaction: Transaction,
t: ReturnType<typeof useI18n>["t"],
) {
if (transaction.kind === "banker_adjust" || transaction.kind === "banker_force_transfer") {
const note = transaction.note?.trim();
return note || t("common.noReason");
}
return formatTransactionKind(transaction.kind, t);
}
function getTransactionDisplay(
transaction: Transaction,
viewerId: string | null | undefined,
players: SessionSnapshot["players"],
t: ReturnType<typeof useI18n>["t"],
) {
const absAmount = Math.abs(transaction.amount);
const label = getTransactionLabel(transaction, t);
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
const from = findPlayer(transaction.fromId);
const to = findPlayer(transaction.toId);
let outgoing = false;
let counterparty = t("common.bank");
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
if (transaction.kind === "banker_adjust") {
outgoing = transaction.amount < 0;
counterparty = t("common.bank");
} else if (transaction.kind === "transfer" || transaction.kind === "banker_force_transfer") {
if (viewerId && transaction.fromId === viewerId) {
outgoing = true;
counterparty = to?.name ?? t("common.player");
} else if (viewerId && transaction.toId === viewerId) {
outgoing = false;
counterparty = from?.name ?? t("common.player");
} else {
outgoing = true;
counterparty = to?.name ?? t("common.player");
}
}
return {
label,
subtitle: viewerId
? `${outgoing ? t("common.to") : t("common.from")} ${counterparty} · ${timeLabel}`
: transaction.kind === "banker_adjust"
? outgoing
? `${to?.name ?? t("common.player")}${t("common.bank")} · ${timeLabel}`
: `${t("common.bank")}${to?.name ?? t("common.player")} · ${timeLabel}`
: `${from?.name ?? t("common.player")}${to?.name ?? t("common.player")} · ${timeLabel}`,
amount: `${outgoing ? "-" : ""}${formatMoney(absAmount)}`,
tone: outgoing ? "negative" : "positive",
};
}
function formatTime(value: number) {
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function getWsUrl(sessionId: string, playerId: string) {
const base = window.location.origin.replace("http", "ws");
const params = new URLSearchParams({ sessionId, playerId });
return `${base}/ws?${params.toString()}`;
}
function getBlackoutState(session: SessionSnapshot | null, tick: number) {
tick;
return {
active: Boolean(session?.blackoutActive),
};
}
function useSessionManager() {
const stored = readStoredSession();
const [sessionId, setSessionId] = useState(stored?.sessionId ?? "");
const [playerId, setPlayerId] = useState(stored?.playerId ?? "");
const [session, setSession] = useState<SessionSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingTakeoverId, setPendingTakeoverId] = useState<string | null>(null);
const [connectionState, setConnectionState] = useState<
"idle" | "connecting" | "open" | "error"
>("idle");
const [tick, setTick] = useState(0);
const [chatReadState, setChatReadState] = useState<Record<string, number>>({});
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const timer = setInterval(() => setTick((value) => value + 1), 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (!sessionId || !playerId) {
setChatReadState({});
return;
}
setChatReadState(readChatReadState(sessionId, playerId));
}, [sessionId, playerId]);
useEffect(() => {
if (!sessionId || !playerId) {
setConnectionState("idle");
setSession(null);
return;
}
setConnectionState("connecting");
const ws = new WebSocket(getWsUrl(sessionId, playerId));
wsRef.current = ws;
ws.onopen = () => {
setConnectionState("open");
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === "state") {
setSession(message.session as SessionSnapshot);
}
if (message.type === "error") {
setError(message.message);
}
if (message.type === "takeover_approved") {
const assignedId = message.assignedPlayerId as string;
setPlayerId(assignedId);
writeStoredSession(sessionId, assignedId);
}
} catch {
setError(tStatic("error.parseResponse"));
}
};
ws.onerror = () => {
setConnectionState("error");
};
ws.onclose = () => {
setConnectionState("error");
};
const pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", sessionId, playerId }));
}
}, 15000);
return () => {
clearInterval(pingTimer);
ws.close();
};
}, [sessionId, playerId]);
useEffect(() => {
if (!pendingTakeoverId) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
if (!sessionId || !playerId) return;
wsRef.current.send(
JSON.stringify({
type: "takeover_request",
sessionId,
playerId,
dummyId: pendingTakeoverId,
}),
);
setPendingTakeoverId(null);
}, [pendingTakeoverId, sessionId, playerId, connectionState]);
const me = session?.players.find((player) => player.id === playerId) ?? null;
const isBanker = me?.role === "banker";
const players = useMemo(() => {
if (!session) return [];
return [...session.players].sort((a, b) => {
if (a.role === b.role) {
return a.name.localeCompare(b.name);
}
return a.role === "banker" ? -1 : 1;
});
}, [session]);
async function createSession(bankerName: string) {
setError(null);
setSession(null);
const response = await fetch("/api/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bankerName }),
});
if (!response.ok) {
setError(tStatic("error.createSession"));
return null;
}
const data = (await response.json()) as JoinResponse;
setSessionId(data.sessionId);
setPlayerId(data.playerId);
writeStoredSession(data.sessionId, data.playerId);
return data;
}
async function joinSession(code: string, name: string) {
setError(null);
setSession(null);
if (!code) {
setError(tStatic("entry.alert.enterCode"));
return null;
}
const storedNow = readStoredSession();
const reusePlayerId = storedNow?.sessionId === code ? storedNow.playerId : undefined;
const response = await fetch(`/api/session/${code}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, playerId: reusePlayerId }),
});
if (!response.ok) {
setError(tStatic("error.joinSession"));
return null;
}
const data = (await response.json()) as JoinResponse;
setSessionId(data.sessionId);
setPlayerId(data.playerId);
writeStoredSession(data.sessionId, data.playerId);
return data;
}
function sendMessage(payload: Record<string, unknown>) {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError(tStatic("error.connectionNotReady"));
return;
}
wsRef.current.send(JSON.stringify(payload));
}
function resetSession() {
clearStoredSession();
setSessionId("");
setPlayerId("");
setSession(null);
setError(null);
setPendingTakeoverId(null);
setChatReadState({});
}
function markChatRead(threadId: string, timestamp: number) {
if (!sessionId || !playerId) return;
setChatReadState((prev) => {
const next = {
...prev,
[threadId]: Math.max(prev[threadId] ?? 0, timestamp),
};
writeChatReadState(sessionId, playerId, next);
return next;
});
}
return {
sessionId,
playerId,
session,
chatReadState,
players,
me,
isBanker,
connectionState,
error,
setError,
tick,
setSessionId,
setPlayerId,
setSession,
createSession,
joinSession,
sendMessage,
resetSession,
setPendingTakeoverId,
markChatRead,
};
}
function useRouteSessionSync(manager: ReturnType<typeof useSessionManager>) {
const params = useParams();
const { sessionId, setSessionId, setSession, setPlayerId } = manager;
useEffect(() => {
const routeSessionId = params.sessionId ? decodeURIComponent(params.sessionId) : "";
if (!routeSessionId) return;
if (routeSessionId !== sessionId) {
setSessionId(routeSessionId);
setSession(null);
}
const stored = readStoredSession();
if (!stored || stored.sessionId !== routeSessionId) {
setPlayerId("");
}
}, [params.sessionId, sessionId, setSessionId, setSession, setPlayerId]);
}
function GameTabs({
role,
sessionId,
hasUnread,
}: {
role: "banker" | "player";
sessionId: string;
hasUnread?: boolean;
}) {
const { t } = useI18n();
return (
<nav className="tabs-nav">
{role === "banker" ? (
<>
<NavLink
to={`/play/${sessionId}/banker/dashboard`}
className={({ isActive }) => (isActive ? "active" : "")}
>
{t("tabs.dashboard")}
</NavLink>
<NavLink
to={`/play/${sessionId}/banker/tools`}
className={({ isActive }) => (isActive ? "active" : "")}
>
{t("tabs.tools")}
</NavLink>
</>
) : (
<>
<NavLink
to={`/play/${sessionId}/player/home`}
className={({ isActive }) => (isActive ? "active" : "")}
>
{t("tabs.home")}
</NavLink>
<NavLink
to={`/play/${sessionId}/player/transfers`}
className={({ isActive }) => (isActive ? "active" : "")}
>
{t("tabs.transfers")}
</NavLink>
</>
)}
<NavLink
to={`/play/${sessionId}/chat`}
className={({ isActive }) => (isActive ? "active" : "")}
>
{t("tabs.chat")}
{hasUnread && <span className="tab-unread" />}
</NavLink>
</nav>
);
}
function EntryPage({ manager }: { manager: ReturnType<typeof useSessionManager> }) {
const navigate = useNavigate();
const { t } = useI18n();
const [createName, setCreateName] = useState("");
const [joinCode, setJoinCode] = useState("");
const [joinName, setJoinName] = useState("");
const [joinStep, setJoinStep] = useState<"code" | "choice">("code");
const [joinPreview, setJoinPreview] = useState<SessionPreview | null>(null);
const [takeoverDummyId, setTakeoverDummyId] = useState("");
const [takeoverName, setTakeoverName] = useState("");
const stored = readStoredSession();
const storedPlayer = joinPreview?.players.find((player) => player.id === stored?.playerId);
const takeoverDisabled = storedPlayer?.connected === true;
const dummyOptions = joinPreview?.players.filter((player) => player.isDummy) ?? [];
useEffect(() => {
if (joinStep === "choice" && joinPreview && joinCode !== joinPreview.code) {
setJoinStep("code");
setJoinPreview(null);
setJoinName("");
setTakeoverDummyId("");
}
}, [joinCode, joinPreview, joinStep]);
return (
<div className="play-shell">
<header className="play-header">
<div className="brand">
<h1>{t("app.name")}</h1>
<span>{t("entry.tagline")}</span>
</div>
<div className="pills">
<span className="pill">{t("entry.liveSessions")}</span>
<span className="pill gold">{t("entry.bankerControlled")}</span>
</div>
</header>
<div className="entry-layout">
<section
className="card reveal entry-panel create"
style={{ "--delay": "0.05s" } as React.CSSProperties}
>
<h2>{t("entry.createTitle")}</h2>
<p>{t("entry.createSubtitle")}</p>
<div className="form">
<label>{t("entry.bankerName")}</label>
<input
value={createName}
onChange={(event) => setCreateName(event.target.value)}
placeholder={t("entry.bankerName")}
/>
<button
className="button"
type="button"
onClick={async () => {
const data = await manager.createSession(createName);
if (data) {
navigate(`/play/${data.sessionId}/lobby`);
}
}}
>
{t("entry.openVault")}
</button>
</div>
</section>
<section
className="card reveal entry-panel join"
style={{ "--delay": "0.1s" } as React.CSSProperties}
>
<h2>{t("entry.joinTitle")}</h2>
<p>{t("entry.joinSubtitle")}</p>
<div className="form">
<label>{t("entry.sessionCode")}</label>
<input
value={joinCode}
onChange={(event) => setJoinCode(event.target.value.toUpperCase())}
placeholder={t("entry.codePlaceholder")}
/>
{joinStep === "code" && (
<button
className="button secondary"
type="button"
onClick={async () => {
manager.setError(null);
if (!joinCode.trim()) {
manager.setError(t("entry.alert.enterCode"));
return;
}
const response = await fetch(`/api/session/${joinCode}/info`);
if (!response.ok) {
manager.setError(t("entry.alert.sessionNotFound"));
return;
}
const data = (await response.json()) as SessionPreview;
setJoinPreview(data);
setJoinStep("choice");
}}
>
{t("common.continue")}
</button>
)}
</div>
{joinStep === "choice" && joinPreview && (
<div className="card-grid">
<div className="form">
<label>{t("entry.newPlayerLabel")}</label>
<input
value={joinName}
onChange={(event) => setJoinName(event.target.value)}
placeholder={t("entry.playerName")}
/>
<button
className="button secondary"
type="button"
onClick={async () => {
const data = await manager.joinSession(joinPreview.code, joinName);
if (data) {
navigate(`/play/${data.sessionId}/lobby`);
}
}}
>
{t("entry.joinAsNew")}
</button>
</div>
<div className="form">
<label>{t("entry.takeoverTitle")}</label>
{takeoverDisabled && (
<p className="helper">{t("entry.alreadyConnected")}</p>
)}
{!takeoverDisabled && (
<>
<select
value={takeoverDummyId}
onChange={(event) => setTakeoverDummyId(event.target.value)}
>
<option value="">{t("entry.selectDummy")}</option>
{dummyOptions.map((dummy) => (
<option key={dummy.id} value={dummy.id}>
{dummy.name}
</option>
))}
</select>
<input
value={takeoverName}
onChange={(event) => setTakeoverName(event.target.value)}
placeholder={t("entry.yourNameOptional")}
/>
<button
className="button secondary"
type="button"
onClick={async () => {
if (!takeoverDummyId) {
manager.setError(t("entry.alert.selectDummy"));
return;
}
2026-02-03 16:35:01 +01:00
const selectedDummy = dummyOptions.find(
(dummy) => dummy.id === takeoverDummyId,
);
const fallbackName =
takeoverName.trim() || selectedDummy?.name || t("common.guest");
2026-02-03 13:48:56 +01:00
const data = await manager.joinSession(
joinPreview.code,
2026-02-03 16:35:01 +01:00
fallbackName,
2026-02-03 13:48:56 +01:00
);
if (data) {
manager.setPendingTakeoverId(takeoverDummyId);
navigate(`/play/${data.sessionId}/lobby`);
}
}}
>
{t("entry.requestTakeover")}
</button>
</>
)}
{!takeoverDisabled && dummyOptions.length === 0 && (
<p className="helper">{t("entry.noDummies")}</p>
)}
</div>
<button
className="button secondary"
type="button"
onClick={() => {
setJoinStep("code");
setJoinPreview(null);
setJoinName("");
setTakeoverDummyId("");
}}
>
{t("entry.changeCode")}
</button>
</div>
)}
</section>
</div>
{manager.error && (
<section className="card">
<strong>{t("common.notice")}</strong> {manager.error}
</section>
)}
</div>
);
}
function LobbyPage({ manager }: { manager: ReturnType<typeof useSessionManager> }) {
useRouteSessionSync(manager);
const navigate = useNavigate();
const { t } = useI18n();
const { sessionId, playerId, setError } = manager;
const [joinName, setJoinName] = useState("");
const [qrCode, setQrCode] = useState<string>("");
const [joinPreview, setJoinPreview] = useState<SessionPreview | null>(null);
const [takeoverDummyId, setTakeoverDummyId] = useState("");
const [takeoverName, setTakeoverName] = useState("");
const [dummyName, setDummyName] = useState("");
const [dummyBalance, setDummyBalance] = useState("1500");
useEffect(() => {
if (manager.session && manager.me && manager.session.status === "active") {
navigate(
manager.isBanker
? `/play/${manager.sessionId}/banker/dashboard`
: `/play/${manager.sessionId}/player/home`,
);
}
}, [manager.session, manager.me, manager.isBanker, manager.sessionId, navigate]);
useEffect(() => {
if (!sessionId) return;
const joinUrl = `${window.location.origin}/play/${sessionId}`;
QRCode.toDataURL(joinUrl, { width: 240, margin: 1 })
.then((url) => setQrCode(url))
.catch(() => setQrCode(""));
}, [sessionId]);
useEffect(() => {
if (!sessionId || playerId) return;
fetch(`/api/session/${sessionId}/info`)
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
if (data) {
setJoinPreview(data as SessionPreview);
}
})
.catch(() => {
setError(t("lobby.errorLoadInfo"));
});
}, [sessionId, playerId, setError, t]);
if (!sessionId) {
return <Navigate to="/play" replace />;
}
if (!playerId) {
const dummyOptions = joinPreview?.players.filter((player) => player.isDummy) ?? [];
return (
<div className="play-shell">
<header className="play-header">
<div className="brand">
<h1>{t("lobby.title")}</h1>
<span>{t("lobby.sessionLabel", { id: manager.sessionId })}</span>
</div>
</header>
<section className="card">
<h2>{t("lobby.joinTitle")}</h2>
{!joinPreview && <p className="helper">{t("lobby.loadingInfo")}</p>}
{joinPreview && (
<div className="card-grid">
<div className="form">
<label>{t("entry.newPlayerLabel")}</label>
<input
value={joinName}
onChange={(event) => setJoinName(event.target.value)}
placeholder={t("entry.playerName")}
/>
<button
className="button"
type="button"
onClick={async () => {
const data = await manager.joinSession(joinPreview.code, joinName);
if (data) {
navigate(`/play/${data.sessionId}/lobby`);
}
}}
>
{t("entry.joinAsNew")}
</button>
</div>
<div className="form">
<label>{t("entry.takeoverTitle")}</label>
<select
value={takeoverDummyId}
onChange={(event) => setTakeoverDummyId(event.target.value)}
>
<option value="">{t("entry.selectDummy")}</option>
{dummyOptions.map((dummy) => (
<option key={dummy.id} value={dummy.id}>
{dummy.name}
</option>
))}
</select>
<input
value={takeoverName}
onChange={(event) => setTakeoverName(event.target.value)}
placeholder={t("entry.yourNameOptional")}
/>
<button
className="button secondary"
type="button"
onClick={async () => {
if (!takeoverDummyId) {
manager.setError(t("entry.alert.selectDummy"));
return;
}
2026-02-03 16:35:01 +01:00
const selectedDummy = dummyOptions.find(
(dummy) => dummy.id === takeoverDummyId,
);
const fallbackName =
takeoverName.trim() || selectedDummy?.name || t("common.guest");
2026-02-03 13:48:56 +01:00
const data = await manager.joinSession(
joinPreview.code,
2026-02-03 16:35:01 +01:00
fallbackName,
2026-02-03 13:48:56 +01:00
);
if (data) {
manager.setPendingTakeoverId(takeoverDummyId);
navigate(`/play/${data.sessionId}/lobby`);
}
}}
>
{t("entry.requestTakeover")}
</button>
{dummyOptions.length === 0 && (
<p className="helper">{t("entry.noDummies")}</p>
)}
</div>
</div>
)}
</section>
{manager.error && (
<section className="card">
<strong>{t("common.notice")}</strong> {manager.error}
</section>
)}
</div>
);
}
if (!manager.session || !manager.me) {
return (
<div className="play-shell">
<header className="play-header">
<div className="brand">
<h1>{t("lobby.title")}</h1>
<span>{t("common.connecting", { id: manager.sessionId })}</span>
</div>
<div className="pills">
<span className="pill">{formatConnectionState(manager.connectionState, t)}</span>
</div>
</header>
<section className="card">
<p>{t("lobby.waitingState")}</p>
<div className="inline">
<button
className="button secondary"
type="button"
onClick={() => {
manager.resetSession();
navigate("/play");
}}
>
{t("common.reset")}
</button>
</div>
</section>
</div>
);
}
const players = manager.players;
2026-02-03 16:35:01 +01:00
const pendingRequests = manager.session?.takeoverRequests.filter(
(request) => request.status === "pending",
);
2026-02-03 13:48:56 +01:00
return (
<div className="play-shell">
<header className="play-header">
<div className="brand">
<h1>{t("lobby.header", { code: manager.session.code })}</h1>
<span>
{t("lobby.statusLine", {
status: formatStatus(manager.session.status, t),
count: players.length,
})}
</span>
</div>
<div className="pills">
<span className="pill">{manager.me.name}</span>
<span className="pill gold">
{manager.isBanker ? t("common.banker") : t("common.player")}
</span>
</div>
</header>
<div className="layout">
<section className="card reveal" style={{ "--delay": "0.05s" } as React.CSSProperties}>
<h2>{t("lobby.roster")}</h2>
<div className="list">
{players.map((player) => (
<div key={player.id} className="list-item">
<div>
<strong>{player.name}</strong>
<span>{player.role === "banker" ? t("common.banker") : t("common.player")}</span>
</div>
<div className="inline">
{player.isDummy && <span className="badge dummy">{t("common.dummy")}</span>}
{!player.connected && (
<span className="badge offline">{t("common.offline")}</span>
)}
</div>
</div>
))}
</div>
{manager.isBanker && manager.session.status !== "active" && (
<button
className="button"
type="button"
onClick={() =>
manager.sendMessage({
type: "banker_start",
sessionId: manager.sessionId,
bankerId: manager.me.id,
})
}
>
{t("lobby.startGame")}
</button>
)}
{!manager.isBanker && (
<p className="helper">{t("lobby.waitingBanker")}</p>
)}
{manager.session.status === "ended" && (
<p className="helper">{t("lobby.sessionClosed")}</p>
)}
</section>
2026-02-03 16:35:01 +01:00
{manager.isBanker && (pendingRequests ?? []).length > 0 && (
<section className="card reveal" style={{ "--delay": "0.08s" } as React.CSSProperties}>
<h2>{t("banker.takeoverApprovals")}</h2>
<div className="list">
{(pendingRequests ?? []).map((request) => {
const requester = manager.session?.players.find(
(player) => player.id === request.requesterId,
);
const dummy = manager.session?.players.find(
(player) => player.id === request.dummyId,
);
const requesterName =
requester?.name ?? request.requesterName ?? t("common.player");
return (
<div key={request.id} className="list-item">
<div>
<strong>{requesterName}</strong>
<span>
{t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
</span>
</div>
<button
className="button small"
type="button"
onClick={() =>
manager.sendMessage({
type: "banker_takeover_approve",
sessionId: manager.sessionId,
bankerId: manager.me.id,
dummyId: request.dummyId,
requesterId: request.requesterId,
})
}
>
{t("banker.approve")}
</button>
</div>
);
})}
</div>
</section>
)}
2026-02-03 13:48:56 +01:00
{manager.isBanker && (
<>
<section className="card reveal" style={{ "--delay": "0.1s" } as React.CSSProperties}>
<h2>{t("lobby.inviteQr")}</h2>
<p>{t("lobby.scanToJoin")}</p>
<div className="qr">
{qrCode ? <img src={qrCode} alt={t("lobby.inviteQr")} /> : ""}
</div>
</section>
<section className="card reveal" style={{ "--delay": "0.12s" } as React.CSSProperties}>
<h2>{t("lobby.addDummyTitle")}</h2>
<p>{t("lobby.addDummySubtitle")}</p>
<div className="form">
<label>{t("common.name")}</label>
<input
value={dummyName}
onChange={(event) => setDummyName(event.target.value)}
placeholder={t("banker.dummyName")}
/>
<label>{t("common.startingBalance")}</label>
<input
value={dummyBalance}
onChange={(event) => setDummyBalance(event.target.value)}
placeholder={t("common.startingBalance")}
/>
<button
className="button secondary"
type="button"
onClick={() => {
if (!dummyName.trim()) {
manager.setError(t("lobby.enterDummyName"));
return;
}
manager.sendMessage({
type: "banker_create_dummy",
sessionId: manager.sessionId,
bankerId: manager.me.id,
name: dummyName,
balance: Number(dummyBalance) || undefined,
});
setDummyName("");
setDummyBalance("1500");
}}
>
{t("lobby.addDummyButton")}
</button>
</div>
</section>
</>
)}
</div>
{manager.error && (
<section className="card">
<strong>{t("common.notice")}</strong> {manager.error}
</section>
)}
</div>
);
}
function BankerPage({ manager }: { manager: ReturnType<typeof useSessionManager> }) {
useRouteSessionSync(manager);
const navigate = useNavigate();
const params = useParams();
const { t } = useI18n();
const tab = params.tab ?? "dashboard";
const [adjustTarget, setAdjustTarget] = useState("");
const [adjustAmount, setAdjustAmount] = useState("");
const [adjustNote, setAdjustNote] = useState("");
const [forceFrom, setForceFrom] = useState("");
const [forceTo, setForceTo] = useState("");
const [forceAmount, setForceAmount] = useState("");
const [forceNote, setForceNote] = useState("");
const [dummyName, setDummyName] = useState("");
const [dummyBalance, setDummyBalance] = useState("1500");
const [blackoutReason, setBlackoutReason] = useState("");
const [toolsTab, setToolsTab] = useState<"players" | "admin">("players");
const [selectedPlayerId, setSelectedPlayerId] = useState("");
const [autoSaveEnabled, setAutoSaveEnabled] = useState(false);
const [autoSaveInterval, setAutoSaveInterval] = useState("3");
const [autoSaveLimit, setAutoSaveLimit] = useState("5");
const [autoSaveEntries, setAutoSaveEntries] = useState<
{ id: string; savedAt: number; state: SessionSnapshot }[]
>([]);
const [autoSaveStatus, setAutoSaveStatus] = useState<string | null>(null);
const [loadStatus, setLoadStatus] = useState<string | null>(null);
const autoSaveKey = useMemo(
() => (manager.sessionId ? `negopoly:autosave:${manager.sessionId}` : ""),
[manager.sessionId],
);
const autoSaveSettingsKey = useMemo(
() => (manager.sessionId ? `negopoly:autosave:${manager.sessionId}:settings` : ""),
[manager.sessionId],
);
useEffect(() => {
if (!autoSaveSettingsKey || !autoSaveKey) return;
try {
const settingsRaw = localStorage.getItem(autoSaveSettingsKey);
if (settingsRaw) {
const settings = JSON.parse(settingsRaw) as {
enabled?: boolean;
intervalMinutes?: number;
maxEntries?: number;
};
setAutoSaveEnabled(Boolean(settings.enabled));
if (settings.intervalMinutes) {
setAutoSaveInterval(String(settings.intervalMinutes));
}
if (settings.maxEntries) {
setAutoSaveLimit(String(settings.maxEntries));
}
}
} catch {
// ignore invalid settings
}
try {
const savedRaw = localStorage.getItem(autoSaveKey);
if (savedRaw) {
const entries = JSON.parse(savedRaw) as {
id: string;
savedAt: number;
state: SessionSnapshot;
}[];
setAutoSaveEntries(entries);
} else {
setAutoSaveEntries([]);
}
} catch {
setAutoSaveEntries([]);
}
}, [autoSaveKey, autoSaveSettingsKey]);
useEffect(() => {
if (!autoSaveSettingsKey) return;
const intervalMinutes = Math.max(1, Number(autoSaveInterval) || 3);
const maxEntries = Math.max(1, Number(autoSaveLimit) || 5);
localStorage.setItem(
autoSaveSettingsKey,
JSON.stringify({
enabled: autoSaveEnabled,
intervalMinutes,
maxEntries,
}),
);
}, [autoSaveEnabled, autoSaveInterval, autoSaveLimit, autoSaveSettingsKey]);
useEffect(() => {
if (!manager.sessionId) {
navigate("/play");
return;
}
if (!manager.playerId) {
navigate(`/play/${manager.sessionId}/lobby`);
return;
}
if (manager.session && manager.session.status !== "active") {
navigate(`/play/${manager.sessionId}/lobby`);
}
}, [manager.session, manager.sessionId, manager.playerId, navigate]);
const players = manager.players;
const eligiblePlayers = useMemo(
() => players.filter((player) => player.role !== "banker"),
[players],
);
const pendingRequests = manager.session?.takeoverRequests.filter(
(request) => request.status === "pending",
);
const showPending = (pendingRequests ?? []).length > 0;
const { active: blackoutActive } = getBlackoutState(
manager.session,
manager.tick,
);
const showBlackout = blackoutActive && !manager.isBanker;
const selectedPlayer =
eligiblePlayers.find((player) => player.id === selectedPlayerId) ?? null;
const playerTransactions =
manager.session?.transactions.filter(
(transaction) =>
transaction.fromId === selectedPlayerId || transaction.toId === selectedPlayerId,
) ?? [];
useEffect(() => {
if (eligiblePlayers.length === 0) {
if (selectedPlayerId) {
setSelectedPlayerId("");
}
return;
}
if (!selectedPlayerId || !eligiblePlayers.some((player) => player.id === selectedPlayerId)) {
setSelectedPlayerId(eligiblePlayers[0].id);
}
}, [eligiblePlayers, selectedPlayerId]);
useEffect(() => {
if (!selectedPlayerId) return;
setAdjustTarget(selectedPlayerId);
setForceFrom(selectedPlayerId);
if (!forceTo || forceTo === selectedPlayerId) {
const fallback = eligiblePlayers.find((player) => player.id !== selectedPlayerId);
setForceTo(fallback?.id ?? "");
}
}, [selectedPlayerId, eligiblePlayers, forceTo]);
async function fetchGameState(): Promise<SessionSnapshot | null> {
if (!manager.sessionId || !manager.me) return null;
const response = await fetch(
`/api/session/${manager.sessionId}/state?bankerId=${manager.me.id}`,
);
if (!response.ok) {
return null;
}
return (await response.json()) as SessionSnapshot;
}
function persistAutoSaves(entries: { id: string; savedAt: number; state: SessionSnapshot }[]) {
if (!autoSaveKey) return;
localStorage.setItem(autoSaveKey, JSON.stringify(entries));
}
async function handleDownloadState() {
setAutoSaveStatus(null);
const snapshot = await fetchGameState();
if (!snapshot) {
setAutoSaveStatus(t("banker.stateDownloadError"));
return;
}
const blob = new Blob([JSON.stringify(snapshot, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `negopoly-${snapshot.code}-${new Date().toISOString()}.json`;
anchor.click();
URL.revokeObjectURL(url);
setAutoSaveStatus(t("banker.stateDownloaded"));
}
async function handleLoadState(snapshot: SessionSnapshot) {
if (!manager.sessionId || !manager.me) return;
setLoadStatus(null);
const response = await fetch(`/api/session/${manager.sessionId}/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bankerId: manager.me.id, state: snapshot }),
});
if (!response.ok) {
setLoadStatus(t("banker.stateLoadError"));
return;
}
setLoadStatus(t("banker.stateLoaded"));
}
async function handleAutoSaveNow() {
if (!autoSaveKey) return;
const snapshot = await fetchGameState();
if (!snapshot) {
setAutoSaveStatus(t("banker.autosaveFailed"));
return;
}
const maxEntries = Math.max(1, Number(autoSaveLimit) || 5);
const id =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const nextEntry = { id, savedAt: Date.now(), state: snapshot };
setAutoSaveEntries((prev) => {
const nextEntries = [nextEntry, ...prev].slice(0, maxEntries);
persistAutoSaves(nextEntries);
return nextEntries;
});
setAutoSaveStatus(t("banker.autosaveSaved"));
}
useEffect(() => {
if (!autoSaveEnabled || !autoSaveKey) return;
const intervalMinutes = Math.max(1, Number(autoSaveInterval) || 3);
const intervalMs = intervalMinutes * 60 * 1000;
const timer = setInterval(() => {
handleAutoSaveNow();
}, intervalMs);
return () => clearInterval(timer);
}, [autoSaveEnabled, autoSaveInterval, autoSaveKey, autoSaveLimit]);
if (!manager.session || !manager.me) {
return (
<div className="play-shell">
<header className="play-header">
<div className="brand">
<h1>{t("banker.consoleTitle")}</h1>
<span>{t("common.connecting", { id: manager.sessionId })}</span>
</div>
</header>
</div>
);
}
if (!manager.isBanker) {
return <Navigate to={`/play/${manager.sessionId}/player/home`} replace />;
}
const unreadThreadIds = getUnreadThreadIds(
manager.session,
manager.me.id,
manager.isBanker,
manager.chatReadState,
);
const hasUnread = unreadThreadIds.size > 0;
return (
<div className="play-shell">
{showBlackout && (
<div className="blackout">
<div>
<h2>{t("blackout.title")}</h2>
<span>
{t("blackout.active")} ·{" "}
{manager.session.blackoutReason || t("blackout.defaultReason")}
</span>
</div>
</div>
)}
<header className="play-header">
<div className="brand">
<h1>{t("banker.consoleTitle")}</h1>
<span>{t("common.sessionLive", { code: manager.session.code })}</span>
</div>
<div className="pills">
<span className="pill">{manager.me.name}</span>
<span className="pill gold">{t("common.banker")}</span>
</div>
</header>
<div className="tabs-layout">
<GameTabs role="banker" sessionId={manager.sessionId} hasUnread={hasUnread} />
<div className="tabs-content">
{(() => {
const validTabs = ["dashboard", "tools"];
const currentTab = tab ?? "dashboard";
if (!validTabs.includes(currentTab)) {
return <Navigate to={`/play/${manager.sessionId}/banker/dashboard`} replace />;
}
if (currentTab === "tools") {
return (
<div className="tools-container">
<div className="tools-tabs">
{[
{ id: "players", label: t("banker.tools.playersTab") },
{ id: "admin", label: t("banker.tools.adminTab") },
].map((item) => (
<button
key={item.id}
className={`tools-tab ${toolsTab === item.id ? "active" : ""}`}
type="button"
onClick={() => setToolsTab(item.id as "players" | "admin")}
>
{item.label}
</button>
))}
</div>
{toolsTab === "players" ? (
<div className="tools-layout">
<section className="card reveal" style={{ "--delay": "0.05s" } as React.CSSProperties}>
<h2>{t("banker.playersTitle")}</h2>
{eligiblePlayers.length === 0 ? (
<span className="helper">{t("banker.noPlayers")}</span>
) : (
<div className="player-roster">
{eligiblePlayers.map((player) => {
const active = player.id === selectedPlayerId;
return (
<button
key={player.id}
type="button"
className={`player-roster-item ${active ? "active" : ""}`}
onClick={() => setSelectedPlayerId(player.id)}
>
<div className="player-roster-main">
<strong>{player.name}</strong>
<span>
{player.isDummy ? t("common.dummy") : t("common.player")} ·{" "}
{player.connected ? t("common.online") : t("common.offline")}
</span>
</div>
<div className="player-roster-meta">
{player.isDummy && <span className="badge dummy">{t("common.dummy")}</span>}
{!player.connected && (
<span className="badge offline">{t("common.offline")}</span>
)}
<span className="player-roster-balance">
{formatMoney(player.balance)}
</span>
</div>
</button>
);
})}
</div>
)}
</section>
<section className="card reveal" style={{ "--delay": "0.08s" } as React.CSSProperties}>
<h2>{t("banker.playerOverview")}</h2>
{selectedPlayer ? (
<>
<div className="player-summary">
<div>
<strong>{selectedPlayer.name}</strong>
<span>
{selectedPlayer.isDummy ? t("common.dummy") : t("common.player")} ·{" "}
{selectedPlayer.connected ? t("common.online") : t("common.offline")}
</span>
</div>
<div className="player-balance">{formatMoney(selectedPlayer.balance)}</div>
</div>
<div className="list scrollable">
{playerTransactions.length === 0 && (
<span className="helper">{t("common.noActivity")}</span>
)}
{playerTransactions.map((transaction) => {
const display = getTransactionDisplay(
transaction,
selectedPlayerId,
manager.session?.players ?? [],
t,
);
return (
<div key={transaction.id} className="list-item">
<div>
<strong>{display.label}</strong>
<span>{display.subtitle}</span>
</div>
<div className={`amount ${display.tone}`}>{display.amount}</div>
</div>
);
})}
</div>
</>
) : (
<span className="helper">{t("banker.noPlayers")}</span>
)}
</section>
<section className="card reveal" style={{ "--delay": "0.1s" } as React.CSSProperties}>
<h2>{t("banker.controlsTitle")}</h2>
<div className="split">
<div className="form">
<label>{t("banker.adjustBalance")}</label>
<select
value={adjustTarget}
onChange={(event) => setAdjustTarget(event.target.value)}
>
<option value="">{t("common.selectPlayer")}</option>
{eligiblePlayers.map((player) => (
<option key={player.id} value={player.id}>
{player.name}
</option>
))}
</select>
<input
value={adjustAmount}
onChange={(event) => setAdjustAmount(event.target.value)}
placeholder={t("banker.adjustAmountPlaceholder")}
/>
<input
value={adjustNote}
onChange={(event) => setAdjustNote(event.target.value)}
placeholder={t("common.reason")}
/>
<button
className="button"
type="button"
onClick={() =>
manager.sendMessage({
type: "banker_adjust",
sessionId: manager.sessionId,
bankerId: manager.me.id,
targetId: adjustTarget,
amount: Number(adjustAmount),
note: adjustNote,
})
}
>
{t("common.apply")}
</button>
</div>
<div className="form">
<label>{t("banker.forceTransfer")}</label>
<select
value={forceFrom}
onChange={(event) => setForceFrom(event.target.value)}
>
<option value="">{t("common.from")}</option>
{eligiblePlayers.map((player) => (
<option key={player.id} value={player.id}>
{player.name}
</option>
))}
</select>
<select value={forceTo} onChange={(event) => setForceTo(event.target.value)}>
<option value="">{t("common.to")}</option>
{eligiblePlayers.map((player) => (
<option key={player.id} value={player.id}>
{player.name}
</option>
))}
</select>
<input
value={forceAmount}
onChange={(event) => setForceAmount(event.target.value)}
placeholder={t("common.amount")}
/>
<input
value={forceNote}
onChange={(event) => setForceNote(event.target.value)}
placeholder={t("common.note")}
/>
<button
className="button secondary"
type="button"
onClick={() =>
manager.sendMessage({
type: "banker_force_transfer",
sessionId: manager.sessionId,
bankerId: manager.me.id,
fromId: forceFrom,
toId: forceTo,
amount: Number(forceAmount),
note: forceNote,
})
}
>
{t("common.force")}
</button>
</div>
</div>
</section>
<section className="card reveal" style={{ "--delay": "0.12s" } as React.CSSProperties}>
<h2>{t("banker.createDummy")}</h2>
<div className="form">
<input
value={dummyName}
onChange={(event) => setDummyName(event.target.value)}
placeholder={t("banker.dummyName")}
/>
<input
value={dummyBalance}
onChange={(event) => setDummyBalance(event.target.value)}
placeholder={t("common.startingBalance")}
/>
<button
className="button secondary"
type="button"
onClick={() => {
manager.sendMessage({
type: "banker_create_dummy",
sessionId: manager.sessionId,
bankerId: manager.me.id,
name: dummyName,
balance: Number(dummyBalance) || undefined,
});
setDummyName("");
setDummyBalance("1500");
}}
>
{t("banker.addDummy")}
</button>
</div>
</section>
</div>
) : (
<div className="tools-layout">
<section className="card reveal" style={{ "--delay": "0.05s" } as React.CSSProperties}>
<h2>{t("banker.adminControls")}</h2>
<div className="form">
<label>{t("banker.blackout")}</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={blackoutActive}
onChange={(event) =>
manager.sendMessage({
type: "banker_blackout",
sessionId: manager.sessionId,
bankerId: manager.me.id,
active: event.target.checked,
reason: event.target.checked ? blackoutReason : null,
})
}
/>
<span>{t("blackout.active")}</span>
</label>
<input
value={blackoutReason}
onChange={(event) => setBlackoutReason(event.target.value)}
placeholder={t("banker.blackoutReason")}
/>
<button
className="button danger"
type="button"
onClick={() =>
manager.sendMessage({
type: "banker_blackout",
sessionId: manager.sessionId,
bankerId: manager.me.id,
active: !blackoutActive,
reason: !blackoutActive ? blackoutReason : null,
})
}
>
{blackoutActive ? t("banker.blackoutDisable") : t("banker.blackoutEnable")}
</button>
</div>
<button
className="button danger"
type="button"
onClick={() =>
manager.sendMessage({
type: "banker_end",
sessionId: manager.sessionId,
bankerId: manager.me.id,
})
}
>
{t("banker.endSession")}
</button>
</section>
<section className="card reveal" style={{ "--delay": "0.08s" } as React.CSSProperties}>
<h2>{t("banker.stateTitle")}</h2>
<p>{t("banker.stateSubtitle")}</p>
<div className="split">
<div className="form">
<label>{t("banker.downloadState")}</label>
<button className="button" type="button" onClick={handleDownloadState}>
{t("common.download")}
</button>
{autoSaveStatus ? <span className="helper">{autoSaveStatus}</span> : null}
</div>
<div className="form">
<label>{t("banker.loadFromFile")}</label>
<input
type="file"
accept="application/json"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(String(reader.result));
handleLoadState(parsed as SessionSnapshot);
} catch {
setLoadStatus(t("banker.stateLoadInvalid"));
}
};
reader.readAsText(file);
}}
/>
{loadStatus ? <span className="helper">{loadStatus}</span> : null}
</div>
<div className="form">
<label>{t("banker.loadFromStorage")}</label>
<div className="autosave-list">
{autoSaveEntries.length === 0 ? (
<span className="helper">{t("banker.noAutosaves")}</span>
) : (
autoSaveEntries.map((entry) => (
<div key={entry.id} className="autosave-item">
<span className="autosave-meta">
{t("banker.savedAt", {
time: new Date(entry.savedAt).toLocaleString(),
})}
</span>
<button
className="button small"
type="button"
onClick={() => handleLoadState(entry.state)}
>
{t("common.load")}
</button>
</div>
))
)}
</div>
</div>
</div>
</section>
<section className="card reveal" style={{ "--delay": "0.1s" } as React.CSSProperties}>
<h2>{t("banker.autosaveTitle")}</h2>
<p>{t("banker.autosaveSubtitle")}</p>
<div className="split">
<div className="form">
<label>{t("banker.autosaveToggle")}</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={autoSaveEnabled}
onChange={(event) => setAutoSaveEnabled(event.target.checked)}
/>
<span>{t("banker.autosaveEnabled")}</span>
</label>
</div>
<div className="form">
<label>{t("banker.autosaveInterval")}</label>
<input
value={autoSaveInterval}
onChange={(event) => setAutoSaveInterval(event.target.value)}
placeholder={t("banker.autosaveMinutes")}
/>
</div>
<div className="form">
<label>{t("banker.autosaveKeep")}</label>
<input
value={autoSaveLimit}
onChange={(event) => setAutoSaveLimit(event.target.value)}
placeholder={t("banker.autosaveCount")}
/>
</div>
<div className="form">
<label>{t("banker.autosaveNow")}</label>
<button className="button secondary" type="button" onClick={handleAutoSaveNow}>
{t("common.save")}
</button>
</div>
</div>
</section>
{showPending && (
<section className="card reveal" style={{ "--delay": "0.12s" } as React.CSSProperties}>
<h2>{t("banker.takeoverApprovals")}</h2>
<div className="list">
{(pendingRequests ?? []).map((request) => {
const requester = manager.session?.players.find(
(player) => player.id === request.requesterId,
);
const dummy = manager.session?.players.find(
(player) => player.id === request.dummyId,
);
2026-02-03 16:35:01 +01:00
const requesterName =
requester?.name ?? request.requesterName ?? t("common.player");
2026-02-03 13:48:56 +01:00
return (
<div key={request.id} className="list-item">
<div>
2026-02-03 16:35:01 +01:00
<strong>{requesterName}</strong>
2026-02-03 13:48:56 +01:00
<span>
{t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
</span>
</div>
<button
className="button small"
type="button"
onClick={() =>
manager.sendMessage({
type: "banker_takeover_approve",
sessionId: manager.sessionId,
bankerId: manager.me.id,
dummyId: request.dummyId,
requesterId: request.requesterId,
})
}
>
{t("banker.approve")}
</button>
</div>
);
})}
</div>
</section>
)}
</div>
)}
</div>
);
}
return (
<div className="layout">
<div className="card-grid">
<section className="card reveal" style={{ "--delay": "0.08s" } as React.CSSProperties}>
<h2>{t("common.transactions")}</h2>
<div className="list">
{manager.session.transactions.length === 0 && (
<span className="helper">{t("common.noActivity")}</span>
)}
{manager.session.transactions.slice(0, 8).map((transaction) => {
const display = getTransactionDisplay(
transaction,
null,
manager.session?.players ?? [],
t,
);
return (
<div key={transaction.id} className="list-item">
<div>
<strong>{display.label}</strong>
<span>{display.subtitle}</span>
</div>
<div className={`amount ${display.tone}`}>{display.amount}</div>
</div>
);
})}
</div>
</section>
</div>
</div>
);
})()}
</div>
</div>
{manager.error && (
<section className="card">
<strong>{t("common.notice")}</strong> {manager.error}
</section>
)}
</div>
);
}
function PlayerPage({ manager }: { manager: ReturnType<typeof useSessionManager> }) {
useRouteSessionSync(manager);
const navigate = useNavigate();
const params = useParams();
const { t } = useI18n();
const tab = params.tab ?? "home";
const [transferTarget, setTransferTarget] = useState("");
const [transferAmount, setTransferAmount] = useState("");
const [transferNote, setTransferNote] = useState("");
useEffect(() => {
if (!manager.sessionId) {
navigate("/play");
return;
}
if (!manager.playerId) {
navigate(`/play/${manager.sessionId}/lobby`);
return;
}
if (manager.session && manager.session.status !== "active") {
navigate(`/play/${manager.sessionId}/lobby`);
}
}, [manager.session, manager.sessionId, manager.playerId, navigate]);
const players = manager.players;
const activePlayers = players.filter(
(player) => !player.isDummy && player.role !== "banker",
);
const visibleTransactions = manager.session?.transactions.filter(
(transaction) => transaction.fromId === manager.me?.id || transaction.toId === manager.me?.id,
);
const { active: blackoutActive } = getBlackoutState(manager.session, manager.tick);
const showBlackout = blackoutActive && !manager.isBanker;
if (!manager.session || !manager.me) {
return (
<div className="play-shell">
<header className="play-header">
<div className="brand">
<h1>{t("player.deskTitle")}</h1>
<span>{t("common.connecting", { id: manager.sessionId })}</span>
</div>
</header>
</div>
);
}
if (manager.isBanker) {
return <Navigate to={`/play/${manager.sessionId}/banker/dashboard`} replace />;
}
const unreadThreadIds = getUnreadThreadIds(
manager.session,
manager.me.id,
manager.isBanker,
manager.chatReadState,
);
const hasUnread = unreadThreadIds.size > 0;
return (
<div className="play-shell">
{showBlackout && (
<div className="blackout">
<div>
<h2>{t("blackout.title")}</h2>
<span>
{t("blackout.active")} ·{" "}
{manager.session.blackoutReason || t("blackout.defaultReason")}
</span>
</div>
</div>
)}
<header className="play-header">
<div className="brand">
<h1>{t("player.deskTitle")}</h1>
<span>{t("common.sessionLive", { code: manager.session.code })}</span>
</div>
<div className="pills">
<span className="pill">{manager.me.name}</span>
<span className="pill gold">{t("common.player")}</span>
</div>
</header>
<div className="tabs-layout">
<GameTabs role="player" sessionId={manager.sessionId} hasUnread={hasUnread} />
<div className="tabs-content">
{(() => {
const validTabs = ["home", "transfers"];
const currentTab = tab ?? "home";
if (!validTabs.includes(currentTab)) {
return <Navigate to={`/play/${manager.sessionId}/player/home`} replace />;
}
if (currentTab === "transfers") {
return (
<div className="layout">
<div className="card-grid">
<section className="card reveal" style={{ "--delay": "0.05s" } as React.CSSProperties}>
<h2>{t("player.quickTransfer")}</h2>
<div className="form">
<label>{t("player.sendTo")}</label>
<select
value={transferTarget}
onChange={(event) => setTransferTarget(event.target.value)}
>
<option value="">{t("common.selectPlayer")}</option>
{activePlayers
.filter((player) => player.id !== manager.me?.id)
.map((player) => (
<option key={player.id} value={player.id}>
{player.name}
</option>
))}
</select>
<label>{t("common.amount")}</label>
<input
value={transferAmount}
onChange={(event) => setTransferAmount(event.target.value)}
placeholder={t("common.amount")}
/>
<label>{t("player.noteOptional")}</label>
<input
value={transferNote}
onChange={(event) => setTransferNote(event.target.value)}
placeholder={t("player.notePlaceholder")}
/>
<button
className="button"
type="button"
onClick={() => {
if (!transferTarget || !transferAmount) {
manager.setError(t("transfers.error"));
return;
}
manager.sendMessage({
type: "transfer",
sessionId: manager.sessionId,
playerId: manager.me.id,
toPlayerId: transferTarget,
amount: Number(transferAmount),
note: transferNote,
});
setTransferAmount("");
setTransferNote("");
}}
>
{t("player.sendFunds")}
</button>
</div>
</section>
</div>
</div>
);
}
return (
<div className="layout">
<div className="card-grid">
<section className="card reveal" style={{ "--delay": "0.05s" } as React.CSSProperties}>
<h2>{t("home.balance")}</h2>
<div className="balance">
{formatMoney(manager.me.balance)}
<span>{t("player.lastUpdated", { time: formatTime(manager.me.lastActiveAt) })}</span>
</div>
</section>
<section className="card reveal" style={{ "--delay": "0.1s" } as React.CSSProperties}>
<h2>{t("common.transactions")}</h2>
<div className="list">
{(visibleTransactions ?? []).length === 0 && (
<span className="helper">{t("common.noActivity")}</span>
)}
{(visibleTransactions ?? []).slice(0, 6).map((transaction) => {
const display = getTransactionDisplay(
transaction,
manager.me?.id,
manager.session?.players ?? [],
t,
);
return (
<div key={transaction.id} className="list-item">
<div>
<strong>{display.label}</strong>
<span>{display.subtitle}</span>
</div>
<div className={`amount ${display.tone}`}>{display.amount}</div>
</div>
);
})}
</div>
</section>
</div>
</div>
);
})()}
</div>
</div>
{manager.error && (
<section className="card">
<strong>{t("common.notice")}</strong> {manager.error}
</section>
)}
</div>
);
}
function ChatListRoute({ manager }: { manager: ReturnType<typeof useSessionManager> }) {
useRouteSessionSync(manager);
const navigate = useNavigate();
const params = useParams();
const sessionId = params.sessionId ? decodeURIComponent(params.sessionId) : "";
const { t } = useI18n();
useEffect(() => {
if (!manager.sessionId) {
navigate("/play");
return;
}
if (!manager.playerId) {
navigate(`/play/${manager.sessionId}/lobby`);
return;
}
if (manager.session && manager.session.status !== "active" && !manager.isBanker) {
navigate(`/play/${manager.sessionId}/lobby`);
}
}, [manager.session, manager.sessionId, manager.playerId, manager.isBanker, navigate]);
if (!manager.session || !manager.me) {
return (
<div className="play-shell">
<header className="play-header">
<div className="brand">
<h1>{t("chat.title")}</h1>
<span>{t("common.connecting", { id: manager.sessionId })}</span>
</div>
</header>
</div>
);
}
const backHref = manager.isBanker
? `/play/${manager.sessionId}/banker/dashboard`
: `/play/${manager.sessionId}/player/home`;
const { active: blackoutActive } = getBlackoutState(manager.session, manager.tick);
const showBlackout = blackoutActive && !manager.isBanker;
const unreadThreadIds = getUnreadThreadIds(
manager.session,
manager.me.id,
manager.isBanker,
manager.chatReadState,
);
const hasUnread = unreadThreadIds.size > 0;
return (
<>
{showBlackout && (
<div className="blackout">
<div>
<h2>{t("blackout.title")}</h2>
<span>
{t("blackout.active")} ·{" "}
{manager.session.blackoutReason || t("blackout.defaultReason")}
</span>
</div>
</div>
)}
<div className="play-shell">
<div className="tabs-layout">
<GameTabs
role={manager.isBanker ? "banker" : "player"}
sessionId={sessionId || manager.sessionId}
hasUnread={hasUnread}
/>
<div className="tabs-content">
<ChatListScreen
session={manager.session}
meId={manager.me.id}
isBanker={manager.isBanker}
sessionId={sessionId || manager.sessionId}
backHref={backHref}
unreadIds={unreadThreadIds}
/>
</div>
</div>
</div>
</>
);
}
function ChatThreadRoute({ manager }: { manager: ReturnType<typeof useSessionManager> }) {
useRouteSessionSync(manager);
const navigate = useNavigate();
const params = useParams();
const sessionId = params.sessionId ? decodeURIComponent(params.sessionId) : "";
const chatId = params.chatId ? decodeURIComponent(params.chatId) : "";
const { t } = useI18n();
useEffect(() => {
if (!manager.sessionId) {
navigate("/play");
return;
}
if (!manager.playerId) {
navigate(`/play/${manager.sessionId}/lobby`);
return;
}
if (manager.session && manager.session.status !== "active" && !manager.isBanker) {
navigate(`/play/${manager.sessionId}/lobby`);
}
}, [manager.session, manager.sessionId, manager.playerId, manager.isBanker, navigate]);
useEffect(() => {
if (!manager.session || !manager.me || !chatId) return;
const latest = getLatestThreadTimestamp(manager.session, chatId);
manager.markChatRead(chatId, latest ?? Date.now());
}, [manager.session, manager.me, chatId]);
if (!chatId) {
return <Navigate to={`/play/${manager.sessionId}/chat`} replace />;
}
if (!manager.session || !manager.me) {
return (
<div className="play-shell">
<header className="play-header">
<div className="brand">
<h1>{t("tabs.chat")}</h1>
<span>{t("common.connecting", { id: manager.sessionId })}</span>
</div>
</header>
</div>
);
}
const { active: blackoutActive } = getBlackoutState(manager.session, manager.tick);
const showBlackout = blackoutActive && !manager.isBanker;
const unreadThreadIds = getUnreadThreadIds(
manager.session,
manager.me.id,
manager.isBanker,
manager.chatReadState,
);
const hasUnread = unreadThreadIds.size > 0;
return (
<>
{showBlackout && (
<div className="blackout">
<div>
<h2>{t("blackout.title")}</h2>
<span>
{t("blackout.active")} ·{" "}
{manager.session.blackoutReason || t("blackout.defaultReason")}
</span>
</div>
</div>
)}
<div className="play-shell">
<div className="tabs-layout">
<GameTabs
role={manager.isBanker ? "banker" : "player"}
sessionId={sessionId || manager.sessionId}
hasUnread={hasUnread}
/>
<div className="tabs-content">
<ChatThreadScreen
session={manager.session}
meId={manager.me.id}
isBanker={manager.isBanker}
sessionId={sessionId || manager.sessionId}
chatId={chatId}
onSend={(body, groupId) =>
manager.sendMessage({
type: "chat_send",
sessionId: manager.sessionId,
playerId: manager.me.id,
body,
groupId,
})
}
/>
</div>
</div>
</div>
</>
);
}
function ChatNewRoute({ manager }: { manager: ReturnType<typeof useSessionManager> }) {
useRouteSessionSync(manager);
const navigate = useNavigate();
const params = useParams();
const sessionId = params.sessionId ? decodeURIComponent(params.sessionId) : "";
const { t } = useI18n();
useEffect(() => {
if (!manager.sessionId) {
navigate("/play");
return;
}
if (!manager.playerId) {
navigate(`/play/${manager.sessionId}/lobby`);
return;
}
if (manager.session && manager.session.status !== "active" && !manager.isBanker) {
navigate(`/play/${manager.sessionId}/lobby`);
}
}, [manager.session, manager.sessionId, manager.playerId, manager.isBanker, navigate]);
if (!manager.session || !manager.me) {
return (
<div className="play-shell">
<header className="play-header">
<div className="brand">
<h1>{t("chat.newTitle")}</h1>
<span>{t("common.connecting", { id: manager.sessionId })}</span>
</div>
</header>
</div>
);
}
const { active: blackoutActive } = getBlackoutState(manager.session, manager.tick);
const showBlackout = blackoutActive && !manager.isBanker;
const unreadThreadIds = getUnreadThreadIds(
manager.session,
manager.me.id,
manager.isBanker,
manager.chatReadState,
);
const hasUnread = unreadThreadIds.size > 0;
return (
<>
{showBlackout && (
<div className="blackout">
<div>
<h2>{t("blackout.title")}</h2>
<span>
{t("blackout.active")} ·{" "}
{manager.session.blackoutReason || t("blackout.defaultReason")}
</span>
</div>
</div>
)}
<div className="play-shell">
<div className="tabs-layout">
<GameTabs
role={manager.isBanker ? "banker" : "player"}
sessionId={sessionId || manager.sessionId}
hasUnread={hasUnread}
/>
<div className="tabs-content">
<ChatNewScreen
session={manager.session}
meId={manager.me.id}
sessionId={sessionId || manager.sessionId}
onCreate={(name, memberIds) => {
manager.sendMessage({
type: "group_create",
sessionId: manager.sessionId,
playerId: manager.me.id,
name,
memberIds,
});
navigate(`/play/${manager.sessionId}/chat`);
}}
/>
</div>
</div>
</div>
</>
);
}
function PlayRouter() {
const manager = useSessionManager();
return (
<BrowserRouter>
<Routes>
<Route path="/play" element={<EntryPage manager={manager} />} />
<Route path="/play/:sessionId/lobby" element={<LobbyPage manager={manager} />} />
<Route path="/play/:sessionId/banker" element={<RedirectToBankerDefault />} />
<Route path="/play/:sessionId/banker/:tab" element={<BankerPage manager={manager} />} />
<Route path="/play/:sessionId/player" element={<RedirectToPlayerDefault />} />
<Route path="/play/:sessionId/player/:tab" element={<PlayerPage manager={manager} />} />
<Route path="/play/:sessionId/chat/new" element={<ChatNewRoute manager={manager} />} />
<Route path="/play/:sessionId/chat/:chatId" element={<ChatThreadRoute manager={manager} />} />
<Route path="/play/:sessionId/chat" element={<ChatListRoute manager={manager} />} />
<Route path="/play/:sessionId" element={<RedirectToLobby />} />
<Route path="*" element={<Navigate to="/play" replace />} />
</Routes>
</BrowserRouter>
);
}
function RedirectToPlayerDefault() {
const params = useParams();
const sessionId = params.sessionId ? decodeURIComponent(params.sessionId) : "";
if (!sessionId) {
return <Navigate to="/play" replace />;
}
return <Navigate to={`/play/${sessionId}/player/home`} replace />;
}
function RedirectToBankerDefault() {
const params = useParams();
const sessionId = params.sessionId ? decodeURIComponent(params.sessionId) : "";
if (!sessionId) {
return <Navigate to="/play" replace />;
}
return <Navigate to={`/play/${sessionId}/banker/dashboard`} replace />;
}
function RedirectToLobby() {
const params = useParams();
const sessionId = params.sessionId ? decodeURIComponent(params.sessionId) : "";
if (!sessionId) {
return <Navigate to="/play" replace />;
}
return <Navigate to={`/play/${sessionId}/lobby`} replace />;
}
export default PlayRouter;