const { useState, useEffect, useCallback, useRef } = React;
// Reusable Chart.js wrapper — takes a config and re-renders on data change.
function ChartCanvas({ config, height = 220 }) {
const canvasRef = useRef(null);
const chartRef = useRef(null);
useEffect(() => {
if (!canvasRef.current || !window.Chart) return;
if (chartRef.current) chartRef.current.destroy();
chartRef.current = new window.Chart(canvasRef.current, config);
return () => { if (chartRef.current) { chartRef.current.destroy(); chartRef.current = null; } };
}, [JSON.stringify(config)]);
return
;
}
const CHART_COLORS = {
grid: "rgba(38, 50, 65, 0.6)",
text: "#8b9aaf",
accent: "#4ade80",
accentFill: "rgba(74, 222, 128, 0.15)",
alt: "#60a5fa",
altFill: "rgba(96, 165, 250, 0.2)",
warn: "#fbbf24",
danger: "#f87171",
};
function baseChartOpts(extra = {}) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: CHART_COLORS.text } },
tooltip: { backgroundColor: "#131a22", borderColor: "#263241", borderWidth: 1 },
},
scales: {
x: { ticks: { color: CHART_COLORS.text }, grid: { color: CHART_COLORS.grid } },
y: { ticks: { color: CHART_COLORS.text }, grid: { color: CHART_COLORS.grid } },
},
...extra,
};
}
// The Go server now serves both the SPA and the API on the same origin,
// so relative URLs just work — regardless of whether we're on 8080 or 8001.
const API_BASE = "";
const PLAYER_ID_KEY = "marketbrawl.playerId";
function api(path, opts = {}) {
const playerId = localStorage.getItem(PLAYER_ID_KEY);
const headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
// Send X-Player-ID for guests. The server prefers the session cookie over
// this header, so it's safely ignored once the user signs in with Google.
if (playerId) headers["X-Player-ID"] = playerId;
return fetch(`${API_BASE}${path}`, {
...opts,
headers,
credentials: "same-origin", // ensure the session cookie travels with every request
}).then(async (r) => {
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
return body;
});
}
function fmtVP(n) {
if (n == null) return "—";
return `${Number(n).toFixed(2)} VP`;
}
function Login({ onLogin }) {
const [name, setName] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const submit = async (e) => {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
setErr("");
try {
const user = await api("/api/players", {
method: "POST",
body: JSON.stringify({ display_name: name.trim() }),
});
localStorage.setItem(PLAYER_ID_KEY, user.id);
onLogin(user);
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
};
return (
Predict the markets. Win Victory Points. No real money.
);
}
function Dashboard({ player }) {
const [season, setSeason] = useState(null);
const [round, setRound] = useState(null);
const [portfolios, setPortfolios] = useState([]);
const [markets, setMarkets] = useState([]);
const [leaderboard, setLeaderboard] = useState([]);
const [history, setHistory] = useState(null);
const [err, setErr] = useState("");
const load = useCallback(async () => {
try {
const [s, r, p, m, lb] = await Promise.all([
api("/api/seasons/current").catch(() => null),
api("/api/rounds/current").catch(() => null),
api("/api/portfolios").catch(() => []),
api("/api/markets").catch(() => []),
api("/api/leaderboard").catch(() => []),
]);
setSeason(s);
setRound(r);
setPortfolios(p || []);
setMarkets(m || []);
setLeaderboard(lb || []);
if (p && p[0]) {
api(`/api/portfolios/${p[0].id}/history`).then(setHistory).catch(() => setHistory(null));
}
} catch (e) {
setErr(e.message);
}
}, []);
useEffect(() => { load(); }, [load]);
const portfolio = portfolios[0];
return (
{err &&
{err}
}
Season
{season ? (
<>
{season.name}
{season.status} · {season.num_rounds} rounds
>
) :
No active season. Create one from Admin.
}
Current Round
{round ? (
<>
Round #{round.round_number}
{round.status}
>
) :
No round yet.
}
Your Portfolio
{portfolio ? (
<>
{fmtVP(portfolio.balance)}
Wagered: {fmtVP(portfolio.total_wagered)} · Won: {fmtVP(portfolio.total_won)} · Lost: {fmtVP(portfolio.total_lost)}
>
) :
No portfolio yet. Season must be active.
}
Portfolio Valuation
Leaderboard — Current Balances
Market Prices
Open Markets
{markets.length === 0 ? (
No markets available. Ask admin to open a round.
) : (
{markets.map((m) => (
{m.name || m.ticker}
{m.category}
{m.current_price ? `${m.unit || "$"}${Number(m.current_price).toFixed(2)}` : "—"}
Min {fmtVP(m.min_bet)} · Max {fmtVP(m.max_bet)}
))}
)}
);
}
function Markets({ player, onBetPlaced }) {
const [markets, setMarkets] = useState([]);
const [portfolios, setPortfolios] = useState([]);
const [err, setErr] = useState("");
const load = useCallback(async () => {
try {
const [m, p] = await Promise.all([
api("/api/markets").catch(() => []),
api("/api/portfolios").catch(() => []),
]);
setMarkets(m || []);
setPortfolios(p || []);
} catch (e) { setErr(e.message); }
}, []);
useEffect(() => { load(); }, [load]);
return (
{err &&
{err}
}
{markets.length === 0 &&
No markets available.
}
{markets.map((m) => (
{ load(); onBetPlaced?.(); }} />
))}
);
}
function MarketBet({ market, portfolio, onPlaced }) {
const [direction, setDirection] = useState("over");
const [target, setTarget] = useState((market.current_price * 1.05).toFixed(2));
const [wager, setWager] = useState("10");
const [status, setStatus] = useState({});
const [submitting, setSubmitting] = useState(false);
const placeBet = async (e) => {
e.preventDefault();
if (!portfolio) { setStatus({ err: "No portfolio — enrol in active season first." }); return; }
setSubmitting(true);
setStatus({});
try {
await api("/api/bets", {
method: "POST",
body: JSON.stringify({
market_id: market.id,
portfolio_id: portfolio.id,
direction,
target_price: Number(target),
wager_amount: Number(wager),
}),
});
setStatus({ ok: `Bet placed: ${direction.toUpperCase()} ${target} for ${wager} VP` });
onPlaced?.();
} catch (e) { setStatus({ err: e.message }); }
finally { setSubmitting(false); }
};
return (
{market.name || market.ticker}
{market.category} · {market.ticker}
{market.current_price ? `${market.unit || "$"}${Number(market.current_price).toFixed(2)}` : "—"}
Current price
);
}
function Portfolio({ player }) {
const [bets, setBets] = useState([]);
const [portfolios, setPortfolios] = useState([]);
const [history, setHistory] = useState(null);
const [err, setErr] = useState("");
const load = useCallback(async () => {
try {
const [b, p] = await Promise.all([
api("/api/bets").catch(() => []),
api("/api/portfolios").catch(() => []),
]);
setBets(b || []);
setPortfolios(p || []);
if (p && p[0]) {
api(`/api/portfolios/${p[0].id}/history`).then(setHistory).catch(() => setHistory(null));
}
} catch (e) { setErr(e.message); }
}, []);
useEffect(() => { load(); }, [load]);
const portfolio = portfolios[0];
return (
{err &&
{err}
}
{portfolio && (
Balance
{fmtVP(portfolio.balance)}
Total Wagered
{fmtVP(portfolio.total_wagered)}
Total Won
{fmtVP(portfolio.total_won)}
Total Lost
{fmtVP(portfolio.total_lost)}
)}
Balance Over Time
Round P&L
Your Bets (Current Round)
{bets.length === 0 ?
No bets placed yet.
: (
| Direction | Target | Wager | Odds | Status | Payout |
{bets.map((b) => (
|
{b.direction.toUpperCase()}
|
{Number(b.target_price).toFixed(2)} |
{fmtVP(b.wager_amount)} |
{Number(b.odds_multiplier).toFixed(2)}x |
{b.status} |
{b.payout ? fmtVP(b.payout) : "—"} |
))}
)}
);
}
function Leaderboard() {
const [entries, setEntries] = useState([]);
const [err, setErr] = useState("");
useEffect(() => {
api("/api/leaderboard").then(setEntries).catch((e) => setErr(e.message));
}, []);
return (
Season Leaderboard
{err &&
{err}
}
{entries.length === 0 ?
No entries yet.
: (
| Rank | Player | Balance | Total Won | Total Lost |
{entries.map((e, i) => (
| #{i + 1} |
{e.display_name}
|
{fmtVP(e.balance)} |
{fmtVP(e.total_won)} |
{fmtVP(e.total_lost)} |
))}
)}
);
}
function Admin({ onChange }) {
const [msg, setMsg] = useState({});
const [seasonName, setSeasonName] = useState("Season 1");
const [numRounds, setNumRounds] = useState(4);
const post = async (path, body) => {
setMsg({});
try {
await api(path, { method: "POST", body: body ? JSON.stringify(body) : undefined });
setMsg({ ok: `${path} succeeded` });
onChange?.();
} catch (e) { setMsg({ err: e.message }); }
};
return (
Admin Controls
Auth is disabled in closed beta — anyone can drive the game loop.
{msg.err &&
{msg.err}
}
{msg.ok &&
{msg.ok}
}
Create + Activate Season
setSeasonName(e.target.value)} placeholder="Season name" />
setNumRounds(Number(e.target.value))} />
Round Lifecycle
);
}
function ValuationChart({ history }) {
if (!history || !history.points || history.points.length === 0) {
return No round history yet — valuation chart appears after the first round resolves.
;
}
const initial = history.initial_balance || 10000;
let running = initial;
const labels = ["Start"];
const values = [initial];
for (const p of history.points) {
running = running + (Number(p.net_pnl) || 0) + (Number(p.bonus_earned) || 0);
labels.push(`R${p.round_number}`);
values.push(Number(running.toFixed(2)));
}
const config = {
type: "line",
data: {
labels,
datasets: [{
label: "Balance (VP)",
data: values,
borderColor: CHART_COLORS.accent,
backgroundColor: CHART_COLORS.accentFill,
fill: true,
tension: 0.25,
pointRadius: 4,
pointBackgroundColor: CHART_COLORS.accent,
}],
},
options: baseChartOpts({
plugins: {
legend: { labels: { color: CHART_COLORS.text } },
tooltip: {
callbacks: { label: (ctx) => `${Number(ctx.parsed.y).toFixed(2)} VP` },
backgroundColor: "#131a22", borderColor: "#263241", borderWidth: 1,
},
},
}),
};
return ;
}
function LeaderboardChart({ entries, currentUserId }) {
if (!entries || entries.length === 0) {
return No players yet.
;
}
const top = entries.slice(0, 10);
const config = {
type: "bar",
data: {
labels: top.map((e) => e.display_name + (e.is_bot ? " 🤖" : "")),
datasets: [{
label: "Balance (VP)",
data: top.map((e) => Number(e.balance)),
backgroundColor: top.map((e) =>
e.user_id === currentUserId ? CHART_COLORS.accent : e.is_bot ? CHART_COLORS.altFill : CHART_COLORS.alt
),
borderColor: top.map((e) =>
e.user_id === currentUserId ? CHART_COLORS.accent : CHART_COLORS.alt
),
borderWidth: 1,
}],
},
options: baseChartOpts({
indexAxis: "y",
plugins: { legend: { display: false } },
}),
};
return ;
}
function RoundPnLChart({ history }) {
if (!history || !history.points || history.points.length === 0) {
return No resolved rounds yet.
;
}
const points = history.points;
const pnl = points.map((p) => Number(p.net_pnl) + Number(p.bonus_earned));
const config = {
type: "bar",
data: {
labels: points.map((p) => `R${p.round_number}`),
datasets: [{
label: "Net P&L (VP)",
data: pnl,
backgroundColor: pnl.map((v) => v >= 0 ? CHART_COLORS.accentFill : "rgba(248, 113, 113, 0.2)"),
borderColor: pnl.map((v) => v >= 0 ? CHART_COLORS.accent : CHART_COLORS.danger),
borderWidth: 1,
}],
},
options: baseChartOpts({ plugins: { legend: { display: false } } }),
};
return ;
}
function MarketPriceChart({ markets }) {
if (!markets || markets.length === 0) {
return No markets yet.
;
}
const config = {
type: "bar",
data: {
labels: markets.map((m) => m.name || m.ticker),
datasets: [
{
label: "Opening Price",
data: markets.map((m) => Number(m.opening_price) || 0),
backgroundColor: CHART_COLORS.altFill,
borderColor: CHART_COLORS.alt,
borderWidth: 1,
},
{
label: "Current Price",
data: markets.map((m) => Number(m.current_price) || 0),
backgroundColor: CHART_COLORS.accentFill,
borderColor: CHART_COLORS.accent,
borderWidth: 1,
},
],
},
options: baseChartOpts(),
};
return ;
}
function App() {
const [player, setPlayer] = useState(null);
const [checking, setChecking] = useState(true);
const [tab, setTab] = useState("dashboard");
const [refresh, setRefresh] = useState(0);
useEffect(() => {
// Session cookie takes priority: if Google OAuth set one, use that user.
api("/api/auth/me")
.then((res) => {
if (res && res.user) {
setPlayer(res.user);
setChecking(false);
return;
}
// Fallback: guest/local account via X-Player-ID header.
const id = localStorage.getItem(PLAYER_ID_KEY);
if (!id) { setChecking(false); return; }
api(`/api/players/${id}`)
.then((u) => setPlayer(u))
.catch(() => localStorage.removeItem(PLAYER_ID_KEY))
.finally(() => setChecking(false));
})
.catch(() => {
// /api/auth/me errored — try local fallback anyway.
const id = localStorage.getItem(PLAYER_ID_KEY);
if (!id) { setChecking(false); return; }
api(`/api/players/${id}`)
.then((u) => setPlayer(u))
.catch(() => localStorage.removeItem(PLAYER_ID_KEY))
.finally(() => setChecking(false));
});
}, []);
if (checking) return ;
if (!player) return ;
const logout = async () => {
// Clear both the local guest identity and the server-side session cookie.
localStorage.removeItem(PLAYER_ID_KEY);
try {
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
} catch (_) { /* best effort */ }
setPlayer(null);
};
const tabs = [
["dashboard", "Dashboard"],
["markets", "Markets"],
["portfolio", "Portfolio"],
["leaderboard", "Leaderboard"],
["admin", "Admin"],
];
return (
VICTORY POINTS ONLY — NOT REAL CURRENCY
{tab === "dashboard" &&
}
{tab === "markets" &&
setRefresh((r) => r + 1)} />}
{tab === "portfolio" && }
{tab === "leaderboard" && }
{tab === "admin" && setRefresh((r) => r + 1)} />}
);
}
ReactDOM.createRoot(document.getElementById("root")).render();