// Counter bets + odds freshness UI (blackbook-api #274 / #275). Rules and API calls live in utils/counter-bets.js
// (window.BBCounter); this file is only the pieces the Admin queue, My Open Bets and the bet slip render:
// LineMovedFlag Admin queue card: "Line moved" + the current line/odds when the request is behind the market
// AdminCounterForm Admin queue card: counter the line/odds/stake, pre-filled from GET .../counter-quote
// AdminCounteredList Admin queue: counters waiting on the bettor, with Withdraw
// useCountered one poller for the bettor's open counters (GET /picks/proposals/countered)
// CounteredOffers My Open Bets: "Bookie countered" cards, asked vs offered side by side, Accept / Decline
// CounteredBanner any other tab: one-line "Bookie countered your bet" link to My Open Bets
// OddsAge "odds 2m old" from the odds payload's fetched_at
// Older API without these routes: everything here renders nothing (BBCounter reports "unavailable").
(function () {
const { useState, useEffect, useRef, useCallback } = React;
const C = window.BBCounter;
const money = (v) => window.BBMoney.formatMoney(v);
// Re-render every `ms` while `on` (countdowns, odds age).
function useNow(ms, on) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!on) return undefined;
setNow(Date.now());
const t = setInterval(() => setNow(Date.now()), ms);
return () => clearInterval(t);
}, [ms, on]);
return now;
}
function OddsAge({ at, className }) {
const now = useNow(30000, !!at);
const text = at ? C.oddsAge(at, now) : "";
if (!text) return null;
return {text} ;
}
function currentText(p) {
const c = p && p.current;
if (!c) return "";
const market = C.marketOf(p);
const line = C.lineText(market, c, C.sideOf(p));
const odds = C.oddsText(c.odds);
return [line, odds ? (line ? `(${odds})` : odds) : ""].filter(Boolean).join(" ");
}
function LineMovedFlag({ p }) {
if (!p || !p.line_moved) return null;
const now = currentText(p);
return (
Line moved
{now && Now {now} }
{p.current && p.current.fetched_at && }
);
}
function TermsCompare({ market, side, original, counter, leftLabel, rightLabel }) {
const rows = C.termsRows(market, original, counter, side);
return (
{leftLabel}
{rightLabel}
{rows.map((r) => (
{r.label}
{r.original}
{r.countered}
))}
);
}
const field = (v) => (v == null ? "" : String(v));
function AdminCounterForm({ p, ctx, onClose, onSent }) {
const market = C.marketOf(p);
const [form, setForm] = useState(() => {
const f = C.prefill(null, p);
return { line: field(f.line), total: field(f.total), odds: C.oddsText(f.odds), stake: field(f.stake) };
});
const [note, setNote] = useState("");
const [quote, setQuote] = useState({ state: "loading" });
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const touched = useRef(false);
useEffect(() => {
let live = true;
const c = ctx.current;
C.fetchQuote(c.apiBase, c.token, c.withCode, p.id).then((res) => {
if (!live) return;
setQuote(res);
if (res.state === "ready" && !touched.current) {
const f = C.prefill(res.quote, p);
setForm({ line: field(f.line), total: field(f.total), odds: C.oddsText(f.odds), stake: field(f.stake) });
}
});
return () => { live = false; };
}, [p.id]);
const set = (k) => (e) => { touched.current = true; setError(""); setForm({ ...form, [k]: e.target.value }); };
const odds = C.num(form.odds);
const win = window.BBToWin.singleToWin(form.stake, odds);
const q = quote.state === "ready" ? quote.quote : null;
const qNow = q && q.current ? currentText({ ...p, current: q.current }) : "";
async function submit(e) {
e.preventDefault();
if (busy) return;
const { body, error: err } = C.counterBody(p, form, note);
if (err) { setError(err); return; }
setBusy(true); setError("");
const c = ctx.current;
const res = await C.sendCounter(c.apiBase, c.token, c.withCode, p.id, body);
setBusy(false);
if (res.ok) { onSent(res.body.proposal || null); return; }
setError(res.unavailable ? "Counter offers aren't available on this server yet." : res.error);
}
return (
);
}
// Shared poller. kind: "admin" | "mine". -> {state, proposals, refresh}
function useCountered(kind, ctx, { enabled = true, intervalMs = 20000, refreshKey = 0 } = {}) {
const [data, setData] = useState({ state: "idle", proposals: [] });
const ctxRef = useRef(ctx);
ctxRef.current = ctx;
const seq = useRef(0);
const refresh = useCallback(async () => {
const c = ctxRef.current || {};
if (!c.apiBase || !c.token) return;
const n = ++seq.current;
const fn = kind === "admin" ? C.fetchAdminCountered : C.fetchMyCountered;
const res = await fn(c.apiBase, c.token, c.withCode);
if (n !== seq.current) return;
setData((prev) => (res.state === "error" ? { ...prev, state: prev.state === "ready" ? "ready" : "error" } : { proposals: [], ...res }));
}, [kind]);
const token = ctx && ctx.token, apiBase = ctx && ctx.apiBase;
useEffect(() => {
if (!enabled || !token || !apiBase) { setData({ state: "idle", proposals: [] }); return undefined; }
refresh();
let t = null;
const tick = () => { if (!document.hidden) refresh(); };
t = setInterval(tick, intervalMs);
const onVis = () => { if (!document.hidden) refresh(); };
document.addEventListener("visibilitychange", onVis);
window.addEventListener("focus", onVis);
return () => { clearInterval(t); document.removeEventListener("visibilitychange", onVis); window.removeEventListener("focus", onVis); };
}, [enabled, token, apiBase, intervalMs, refresh]);
useEffect(() => { if (refreshKey && enabled) refresh(); }, [refreshKey]);
// stop polling once the server says the feature isn't there
return { ...data, refresh };
}
function titleOf(p) {
const market = C.marketOf(p);
return C.baseSelection(p.selection || (p.original && p.original.selection), market);
}
function Countdown({ at, now }) {
const left = C.secondsLeft(at, now);
if (left == null) return null;
return {C.expiryText(at, now)} ;
}
function AdminCounteredList({ ctx, data, onChanged }) {
const list = data.state === "ready" ? data.proposals : [];
const now = useNow(1000, list.length > 0);
const [busy, setBusy] = useState(null);
const [msg, setMsg] = useState({});
if (!list.length) return null;
async function withdraw(p) {
setBusy(p.id);
const c = ctx.current;
const res = await C.withdrawCounter(c.apiBase, c.token, c.withCode, p.id);
setBusy(null);
if (!res.ok) setMsg((m) => ({ ...m, [p.id]: res.error }));
await data.refresh();
onChanged && onChanged();
}
return (
Countered · waiting on bettor ({list.length})
{list.map((p) => {
const market = C.marketOf(p);
const left = C.secondsLeft(p.counter && p.counter.expires_at, now);
return (
{p.handle} - {titleOf(p)}
{p.league} - {p.event}
Countered
{p.counter && p.counter.note &&
“{p.counter.note}”
}
withdraw(p)}>
{busy === p.id ? "Withdrawing…" : "Withdraw"}
{msg[p.id] &&
{msg[p.id]}
}
);
})}
);
}
function CounteredOffers({ ctx, data, onChanged }) {
const list = data.state === "ready" ? data.proposals : [];
const now = useNow(1000, list.length > 0);
const [busy, setBusy] = useState(null);
const [msg, setMsg] = useState({});
const [done, setDone] = useState(null);
const ctxRef = useRef(ctx);
ctxRef.current = ctx;
if (!list.length && !done) return null;
async function act(p, kind) {
if (busy) return;
setBusy(`${p.id}:${kind}`);
setMsg((m) => ({ ...m, [p.id]: "" }));
const c = ctxRef.current;
const res = kind === "accept"
? await C.acceptCounter(c.apiBase, c.token, c.withCode, p.id, p.counter)
: await C.declineCounter(c.apiBase, c.token, c.withCode, p.id);
setBusy(null);
if (res.ok) {
const c2 = p.counter || {};
setDone(kind === "accept"
? `Bet placed: ${titleOf(p)} ${C.lineText(C.marketOf(p), c2, C.sideOf(p))} ${C.oddsText(c2.odds)} for ${money(c2.stake)}.`.replace(/\s+/g, " ")
: `Declined the counter on ${titleOf(p)}.`);
} else {
setMsg((m) => ({ ...m, [p.id]: res.error }));
}
await data.refresh();
if (res.ok && onChanged) onChanged(kind);
}
return (
Bookie countered
{list.length > 0 && Accept or decline before the timer runs out }
{done && (
{done}
setDone(null)}>×
)}
{list.map((p) => {
const market = C.marketOf(p);
const left = C.secondsLeft(p.counter && p.counter.expires_at, now);
const expired = left === 0;
return (
{titleOf(p)}
{p.league} - {p.event}
{C.COUNTER_LABEL}
{p.counter && p.counter.note &&
“{p.counter.note}”
}
{expired ? "This counter expired" : }
act(p, "decline")}>
{busy === `${p.id}:decline` ? "Declining…" : "Decline"}
act(p, "accept")}>
{busy === `${p.id}:accept` ? "Accepting…" : "Accept"}
{msg[p.id] &&
{msg[p.id]}
}
);
})}
);
}
function CounteredBanner({ data, onOpen }) {
const n = data && data.state === "ready" ? data.proposals.length : 0;
if (!n) return null;
return (
{C.COUNTER_LABEL}
{n === 1 ? "The bookie countered your bet." : `The bookie countered ${n} of your bets.`}
Review
);
}
// Settled/open wager that came from an accepted counter (picks[].counter, api #275).
function CounterHistoryNote({ item }) {
const c = item && item.counter;
if (!c || !c.original) return null;
const market = C.marketOf(item);
const o = c.original;
const asked = [C.lineText(market, o, C.sideOf(item)), C.oddsText(o.odds), C.num(o.stake) != null ? money(o.stake) : ""].filter(Boolean).join(" · ");
return Bookie countered{asked ? ` · you asked ${asked}` : ""}
;
}
window.BBCounterUI = {
OddsAge, LineMovedFlag, TermsCompare, AdminCounterForm, AdminCounteredList, useCountered,
CounteredOffers, CounteredBanner, CounterHistoryNote,
};
})();