/* ============================================================
components.jsx — presentational UI for GTO Heads-Up
Exposes components on window for app.jsx
============================================================ */
const { useState, useEffect, useRef } = React;
const E = window.Engine;
const fmt = (x) => {
const r = Math.round(x * 10) / 10;
return Number.isInteger(r) ? String(r) : r.toFixed(1);
};
/* ---------- Cards ---------- */
function Card({ c, back, muck, tiny }) {
if (back) return
;
if (c === null || c === undefined) return ;
const lab = E.cardLabel(c);
const red = lab.s === "h" || lab.s === "d";
const rank = lab.r === "T" ? "10" : lab.r;
return (
);
}
function Hole({ cards, hidden, muck, tiny }) {
return (
{hidden
? [0, 1].map((i) => )
: cards.map((c, i) => )}
);
}
/* ---------- Seat ---------- */
function Seat({ player, isHero, active, position, reveal, bet }) {
return (
{isHero ? "\u2665" : "\u25C6"}
{player.name}
{position}
{fmt(player.stack)} bb
{bet > 0 &&
{fmt(bet)} bb
}
);
}
/* ---------- Board + Pot ---------- */
function Board({ board }) {
const slots = [];
for (let i = 0; i < 5; i++) slots.push(i < board.length ? : );
return {slots}
;
}
function Pot({ pot }) {
return (
Pot
{fmt(pot)} bb
);
}
/* ---------- Action bar ---------- */
function targetForFrac(hand, i, frac) {
const myBet = E.streetInvest(hand, i);
const oppBet = E.streetInvest(hand, 1 - i);
const toCall = Math.max(0, oppBet - myBet);
const pot = E.potNow(hand);
let to;
if (toCall > 0) { const potAfter = pot + toCall; to = oppBet + frac * potAfter; }
else { to = myBet + frac * pot; }
return to;
}
function ActionBar({ hand, hero, onAction, busy, waitingNext, onNext, dealing, mode, sizeRef }) {
const i = hero;
const me = hand.players[i];
const myBet = E.streetInvest(hand, i);
const oppBet = E.streetInvest(hand, 1 - i);
const toCall = Math.max(0, oppBet - myBet);
const pot = E.potNow(hand);
const minTo = Math.min(E.minRaiseTo(hand), myBet + me.stack);
const maxTo = myBet + me.stack;
const canAggro = me.stack > toCall;
const presets = hand.street === "preflop" && toCall > 0 && oppBet <= E.BB_SIZE + 0.01
? [["2.5x", null, 2.5], ["3x", null, 3], ["3.5x", null, 3.5], ["Pot", 1, null], ["All-in", null, maxTo]]
: [["33%", 0.33], ["50%", 0.5], ["75%", 0.75], ["Pot", 1], ["All-in", null, maxTo]];
const [to, setTo] = useState(minTo);
const [activePreset, setActivePreset] = useState(null);
useEffect(() => {
// default sizing per street
let def;
if (hand.street === "preflop" && toCall > 0 && oppBet <= E.BB_SIZE + 0.01) def = Math.min(maxTo, 2.5 * E.BB_SIZE);
else def = Math.min(maxTo, Math.max(minTo, targetForFrac(hand, i, 0.66)));
setTo(Math.round(def * 10) / 10);
setActivePreset(null);
// eslint-disable-next-line
}, [hand.street, hand.history.length, toCall, hand.toAct]);
if (sizeRef) sizeRef.current = (!waitingNext && !busy && hand.toAct === hero && !hand.finished && canAggro) ? to : null;
if (waitingNext) {
return (
);
}
if (busy || hand.toAct !== hero || hand.finished) {
return (
{hand.finished ? "Hand complete" : `${(hand.players[1 - hero] && hand.players[1 - hero].name) || "Opponent"} is thinking\u2026`}
);
}
const applyPreset = (p) => {
let val;
if (p[2] != null && p[0] === "All-in") val = maxTo;
else if (hand.street === "preflop" && p[2] != null) val = Math.min(maxTo, p[2] * E.BB_SIZE);
else if (p[1] != null) val = targetForFrac(hand, i, p[1]);
else val = to;
val = Math.max(minTo, Math.min(maxTo, val));
setTo(Math.round(val * 10) / 10);
setActivePreset(p[0]);
};
const raiseType = toCall > 0 ? "raise" : "bet";
const raiseVerb = toCall > 0 ? "Raise" : "Bet";
return (
{canAggro && (
{presets.map((p) => (
))}
{ setTo(parseFloat(e.target.value)); setActivePreset(null); }} />
{fmt(to)} bb
)}
{toCall > 0
?
: }
{toCall > 0 && (
)}
{canAggro && (
)}
F {toCall > 0 ? "fold" : "check"} · C {toCall > 0 ? "call" : "check"}{canAggro && <>{" · "}R raise{" · "}A all-in>} · N next
);
}
/* ---------- Action colors + solver mix bar ---------- */
const ACT_COLOR = { raise: "var(--accent)", bet: "var(--accent)", call: "var(--ok)", check: "var(--text-3)", fold: "var(--bad)" };
function MixBar({ mix }) {
if (!mix || !mix.length) return null;
return (
);
}
/* ---------- Study-mode feedback overlay ---------- */
function FeedbackOverlay({ fb, onContinue }) {
if (!fb) return null;
const cls = fb.color === "good" ? "fb-good" : fb.color === "ok" ? "fb-ok" : fb.color === "warn" ? "fb-warn" : "fb-bad";
return (
e.stopPropagation()}>
{ratingIcon(fb.color)} {fb.ratingLabel}
{fb.evLoss > 0 ? `\u2212${fmt(fb.evLoss)} bb EV` : "0 bb lost"}
You played
{fb.chosenLabel}
GTO play
{fb.optimalLabel}
{fb.correct ? "✓" : "✦"}
{fb.correct ? "Best play — you nailed it: " : "Solution — you should: "}{fb.solution}
{fb.mixLabel &&
}
{fb.mixLabel &&
{fb.mixSource === "solver" ? "Solver mix" : "GTO mix"}{fb.mixLabel}
}
Your equity{fmt(fb.equity)}%
{fb.potOdds > 0 &&
Pot odds (need){fmt(fb.potOdds)}%
}
{fb.note}
);
}
function ratingIcon(color) {
if (color === "good") return "\u2713";
if (color === "ok") return "\u2248";
if (color === "warn") return "!";
return "\u2715";
}
/* ---------- Toast (play mode) ---------- */
function Toast({ fb }) {
if (!fb) return null;
const c = fb.color === "good" ? "var(--good)" : fb.color === "ok" ? "var(--ok)" : fb.color === "warn" ? "var(--warn)" : "var(--bad)";
return (
{ratingIcon(fb.color)} {fb.ratingLabel}
{fb.evLoss > 0 && −{fmt(fb.evLoss)} bb}
);
}
window.UI = { Card, Hole, Seat, Board, Pot, ActionBar, FeedbackOverlay, Toast, MixBar, ACT_COLOR, fmt, ratingIcon, targetForFrac };