// 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 (
Counter offer {quote.state === "loading" ? "Getting the current odds…" : qNow ? <>Market now {qNow}{q.current.fetched_at ? <> · : null} : "No current odds; starting from the request."}
{market === "spread" && ( )} {market === "total" && ( )}
To win at these terms: {win == null ? "n/a" : money(win)} · bettor has 2 min to accept
{error &&
{error}
}
); } // 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}”
}
{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}
)}
{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" : }
{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 ( ); } // 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, }; })();