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;
|