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 { try { const raw = localStorage.getItem(getChatReadKey(sessionId, playerId)); if (!raw) return {}; return JSON.parse(raw) as Record; } catch { return {}; } } function writeChatReadState(sessionId: string, playerId: string, state: Record) { 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["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["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(null); const [error, setError] = useState(null); const [pendingTakeoverId, setPendingTakeoverId] = useState(null); const [connectionState, setConnectionState] = useState< "idle" | "connecting" | "open" | "error" >("idle"); const [tick, setTick] = useState(0); const [chatReadState, setChatReadState] = useState>({}); const wsRef = useRef(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) { 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) { 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 ( ); } function EntryPage({ manager }: { manager: ReturnType }) { 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(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 (

{t("app.name")}

{t("entry.tagline")}
{t("entry.liveSessions")} {t("entry.bankerControlled")}

{t("entry.createTitle")}

{t("entry.createSubtitle")}

setCreateName(event.target.value)} placeholder={t("entry.bankerName")} />

{t("entry.joinTitle")}

{t("entry.joinSubtitle")}

setJoinCode(event.target.value.toUpperCase())} placeholder={t("entry.codePlaceholder")} /> {joinStep === "code" && ( )}
{joinStep === "choice" && joinPreview && (
setJoinName(event.target.value)} placeholder={t("entry.playerName")} />
{takeoverDisabled && (

{t("entry.alreadyConnected")}

)} {!takeoverDisabled && ( <> setTakeoverName(event.target.value)} placeholder={t("entry.yourNameOptional")} /> )} {!takeoverDisabled && dummyOptions.length === 0 && (

{t("entry.noDummies")}

)}
)}
{manager.error && (
{t("common.notice")} {manager.error}
)}
); } function LobbyPage({ manager }: { manager: ReturnType }) { useRouteSessionSync(manager); const navigate = useNavigate(); const { t } = useI18n(); const { sessionId, playerId, setError } = manager; const [joinName, setJoinName] = useState(""); const [qrCode, setQrCode] = useState(""); const [joinPreview, setJoinPreview] = useState(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 ; } if (!playerId) { const dummyOptions = joinPreview?.players.filter((player) => player.isDummy) ?? []; return (

{t("lobby.title")}

{t("lobby.sessionLabel", { id: manager.sessionId })}

{t("lobby.joinTitle")}

{!joinPreview &&

{t("lobby.loadingInfo")}

} {joinPreview && (
setJoinName(event.target.value)} placeholder={t("entry.playerName")} />
setTakeoverName(event.target.value)} placeholder={t("entry.yourNameOptional")} /> {dummyOptions.length === 0 && (

{t("entry.noDummies")}

)}
)}
{manager.error && (
{t("common.notice")} {manager.error}
)}
); } if (!manager.session || !manager.me) { return (

{t("lobby.title")}

{t("common.connecting", { id: manager.sessionId })}
{formatConnectionState(manager.connectionState, t)}

{t("lobby.waitingState")}

); } const players = manager.players; const pendingRequests = manager.session?.takeoverRequests.filter( (request) => request.status === "pending", ); return (

{t("lobby.header", { code: manager.session.code })}

{t("lobby.statusLine", { status: formatStatus(manager.session.status, t), count: players.length, })}
{manager.me.name} {manager.isBanker ? t("common.banker") : t("common.player")}

{t("lobby.roster")}

{players.map((player) => (
{player.name} {player.role === "banker" ? t("common.banker") : t("common.player")}
{player.isDummy && {t("common.dummy")}} {!player.connected && ( {t("common.offline")} )}
))}
{manager.isBanker && manager.session.status !== "active" && ( )} {!manager.isBanker && (

{t("lobby.waitingBanker")}

)} {manager.session.status === "ended" && (

{t("lobby.sessionClosed")}

)}
{manager.isBanker && (pendingRequests ?? []).length > 0 && (

{t("banker.takeoverApprovals")}

{(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 (
{requesterName} {t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
); })}
)} {manager.isBanker && ( <>

{t("lobby.inviteQr")}

{t("lobby.scanToJoin")}

{qrCode ? {t("lobby.inviteQr")} : ""}

{t("lobby.addDummyTitle")}

{t("lobby.addDummySubtitle")}

setDummyName(event.target.value)} placeholder={t("banker.dummyName")} /> setDummyBalance(event.target.value)} placeholder={t("common.startingBalance")} />
)}
{manager.error && (
{t("common.notice")} {manager.error}
)}
); } function BankerPage({ manager }: { manager: ReturnType }) { 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(null); const [loadStatus, setLoadStatus] = useState(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 { 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 (

{t("banker.consoleTitle")}

{t("common.connecting", { id: manager.sessionId })}
); } if (!manager.isBanker) { return ; } const unreadThreadIds = getUnreadThreadIds( manager.session, manager.me.id, manager.isBanker, manager.chatReadState, ); const hasUnread = unreadThreadIds.size > 0; return (
{showBlackout && (

{t("blackout.title")}

{t("blackout.active")} ·{" "} {manager.session.blackoutReason || t("blackout.defaultReason")}
)}

{t("banker.consoleTitle")}

{t("common.sessionLive", { code: manager.session.code })}
{manager.me.name} {t("common.banker")}
{(() => { const validTabs = ["dashboard", "tools"]; const currentTab = tab ?? "dashboard"; if (!validTabs.includes(currentTab)) { return ; } if (currentTab === "tools") { return (
{[ { id: "players", label: t("banker.tools.playersTab") }, { id: "admin", label: t("banker.tools.adminTab") }, ].map((item) => ( ))}
{toolsTab === "players" ? (

{t("banker.playersTitle")}

{eligiblePlayers.length === 0 ? ( {t("banker.noPlayers")} ) : (
{eligiblePlayers.map((player) => { const active = player.id === selectedPlayerId; return ( ); })}
)}

{t("banker.playerOverview")}

{selectedPlayer ? ( <>
{selectedPlayer.name} {selectedPlayer.isDummy ? t("common.dummy") : t("common.player")} ·{" "} {selectedPlayer.connected ? t("common.online") : t("common.offline")}
{formatMoney(selectedPlayer.balance)}
{playerTransactions.length === 0 && ( {t("common.noActivity")} )} {playerTransactions.map((transaction) => { const display = getTransactionDisplay( transaction, selectedPlayerId, manager.session?.players ?? [], t, ); return (
{display.label} {display.subtitle}
{display.amount}
); })}
) : ( {t("banker.noPlayers")} )}

{t("banker.controlsTitle")}

setAdjustAmount(event.target.value)} placeholder={t("banker.adjustAmountPlaceholder")} /> setAdjustNote(event.target.value)} placeholder={t("common.reason")} />
setForceAmount(event.target.value)} placeholder={t("common.amount")} /> setForceNote(event.target.value)} placeholder={t("common.note")} />

{t("banker.createDummy")}

setDummyName(event.target.value)} placeholder={t("banker.dummyName")} /> setDummyBalance(event.target.value)} placeholder={t("common.startingBalance")} />
) : (

{t("banker.adminControls")}

setBlackoutReason(event.target.value)} placeholder={t("banker.blackoutReason")} />

{t("banker.stateTitle")}

{t("banker.stateSubtitle")}

{autoSaveStatus ? {autoSaveStatus} : null}
{ 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 ? {loadStatus} : null}
{autoSaveEntries.length === 0 ? ( {t("banker.noAutosaves")} ) : ( autoSaveEntries.map((entry) => (
{t("banker.savedAt", { time: new Date(entry.savedAt).toLocaleString(), })}
)) )}

{t("banker.autosaveTitle")}

{t("banker.autosaveSubtitle")}

setAutoSaveInterval(event.target.value)} placeholder={t("banker.autosaveMinutes")} />
setAutoSaveLimit(event.target.value)} placeholder={t("banker.autosaveCount")} />
{showPending && (

{t("banker.takeoverApprovals")}

{(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 (
{requesterName} {t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
); })}
)}
)}
); } return (

{t("common.transactions")}

{manager.session.transactions.length === 0 && ( {t("common.noActivity")} )} {manager.session.transactions.slice(0, 8).map((transaction) => { const display = getTransactionDisplay( transaction, null, manager.session?.players ?? [], t, ); return (
{display.label} {display.subtitle}
{display.amount}
); })}
); })()}
{manager.error && (
{t("common.notice")} {manager.error}
)}
); } function PlayerPage({ manager }: { manager: ReturnType }) { 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 (

{t("player.deskTitle")}

{t("common.connecting", { id: manager.sessionId })}
); } if (manager.isBanker) { return ; } const unreadThreadIds = getUnreadThreadIds( manager.session, manager.me.id, manager.isBanker, manager.chatReadState, ); const hasUnread = unreadThreadIds.size > 0; return (
{showBlackout && (

{t("blackout.title")}

{t("blackout.active")} ·{" "} {manager.session.blackoutReason || t("blackout.defaultReason")}
)}

{t("player.deskTitle")}

{t("common.sessionLive", { code: manager.session.code })}
{manager.me.name} {t("common.player")}
{(() => { const validTabs = ["home", "transfers"]; const currentTab = tab ?? "home"; if (!validTabs.includes(currentTab)) { return ; } if (currentTab === "transfers") { return (

{t("player.quickTransfer")}

setTransferAmount(event.target.value)} placeholder={t("common.amount")} /> setTransferNote(event.target.value)} placeholder={t("player.notePlaceholder")} />
); } return (

{t("home.balance")}

{formatMoney(manager.me.balance)} {t("player.lastUpdated", { time: formatTime(manager.me.lastActiveAt) })}

{t("common.transactions")}

{(visibleTransactions ?? []).length === 0 && ( {t("common.noActivity")} )} {(visibleTransactions ?? []).slice(0, 6).map((transaction) => { const display = getTransactionDisplay( transaction, manager.me?.id, manager.session?.players ?? [], t, ); return (
{display.label} {display.subtitle}
{display.amount}
); })}
); })()}
{manager.error && (
{t("common.notice")} {manager.error}
)}
); } function ChatListRoute({ manager }: { manager: ReturnType }) { 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 (

{t("chat.title")}

{t("common.connecting", { id: manager.sessionId })}
); } 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 && (

{t("blackout.title")}

{t("blackout.active")} ·{" "} {manager.session.blackoutReason || t("blackout.defaultReason")}
)}
); } function ChatThreadRoute({ manager }: { manager: ReturnType }) { 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 ; } if (!manager.session || !manager.me) { return (

{t("tabs.chat")}

{t("common.connecting", { id: manager.sessionId })}
); } 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 && (

{t("blackout.title")}

{t("blackout.active")} ·{" "} {manager.session.blackoutReason || t("blackout.defaultReason")}
)}
manager.sendMessage({ type: "chat_send", sessionId: manager.sessionId, playerId: manager.me.id, body, groupId, }) } />
); } function ChatNewRoute({ manager }: { manager: ReturnType }) { 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 (

{t("chat.newTitle")}

{t("common.connecting", { id: manager.sessionId })}
); } 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 && (

{t("blackout.title")}

{t("blackout.active")} ·{" "} {manager.session.blackoutReason || t("blackout.defaultReason")}
)}
{ manager.sendMessage({ type: "group_create", sessionId: manager.sessionId, playerId: manager.me.id, name, memberIds, }); navigate(`/play/${manager.sessionId}/chat`); }} />
); } function PlayRouter() { const manager = useSessionManager(); return ( } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> ); } function RedirectToPlayerDefault() { const params = useParams(); const sessionId = params.sessionId ? decodeURIComponent(params.sessionId) : ""; if (!sessionId) { return ; } return ; } function RedirectToBankerDefault() { const params = useParams(); const sessionId = params.sessionId ? decodeURIComponent(params.sessionId) : ""; if (!sessionId) { return ; } return ; } function RedirectToLobby() { const params = useParams(); const sessionId = params.sessionId ? decodeURIComponent(params.sessionId) : ""; if (!sessionId) { return ; } return ; } export default PlayRouter;