Bet Panel Redesign — Implementation Plan
Branch:
feat/bet-panel| Worktree:bet-panel/Goal: Replace floating bet slip with a permanent right-side Bet Panel (BETSLIP + OPEN BETS tabs), matching 1exch.com UX with Apple 2026 glassmorphism design. Updated: 2026-02-12 — implemented. Drifts from original plan noted inline.
1. Architecture Overview
Current State (Problems)
- BetSlip is a floating overlay (
position: fixed,z-50) that can be opened/closed - Each page imports
<BetSlip />independently — no layout-level integration - Pages use
max-w-4xl/max-w-7xlcentered containers — no 3-column grid - Live page and fixture detail page have different content widths (inconsistent panel sizes)
- No "Open Bets" tab — users must navigate to
/my-betspage - No batch bet placement API
- No Betfair tick/step size logic for odds +/- buttons
- Clicking same odds button multiple times can add duplicate entries
Target State
Desktop (>=1024px):
┌─────────────────────────────────────────────────────────────────┐
│ Header (sticky, z-50) │
├──────────┬──────────────────────────────┬───────────────────────┤
│ LEFT │ │ BET PANEL (340px) │
│ PANEL │ Main Content Area │ ┌─────────────────┐ │
│ (empty) │ (fills remaining width) │ │BETSLIP│OPEN BETS│ │
│ │ │ ├─────────────────┤ │
│ Reserved│ - Live page fixtures │ │ONE CLICK: OFF │ │
│ for AI │ - Fixture detail markets │ │Your Bets: 2 🗑 │ │
│ features│ │ │[Bet Card 1] │ │
│ (future)│ Same width on ALL pages │ │[Bet Card 2] │ │
│ │ Content adapts to avail. │ │ (scrollable) │ │
│ │ space (fluid, no max-w) │ ├─────────────────┤ │
│ [AI Chat │ │ │Total Payout: XX │ │
│ Button] │ │ │Pot. Risk: XX │ │
│ (bottom │ │ │[Place Bet ⏱ 5s] │ │
│ left) │ │ └─────────────────┘ │
├──────────┴──────────────────────────────┴───────────────────────┤
│ (no bottom nav on desktop) │
└─────────────────────────────────────────────────────────────────┘
Tablet (768-1023px):
- Left panel hidden. Content + bet panel 2-column grid.
- Bet panel stays visible (like 1exch at 768px+).
- AI chat button overlays bottom-left.
Mobile (<768px):
- Single column. Bet panel = bottom sheet with floating trigger button.
- AI chat button bottom-left, bet panel trigger bottom-right.
Note: Left panel is empty in this PR — reserved for future AI features. Only center content + bet panel are implemented. AI chat button left in its current position (not moved to bottom-left — deferred).
Addition: The bet panel is hideable. A PanelRight toggle in the Header lets the user collapse/expand the panel on desktop. State persisted in betPanelSettingsStore.panelVisible.
2. Implementation Phases
Phase 0: Foundation — Layout Restructure
Goal: 3-column grid at layout level, consistent content panel sizing
0.1 Root Layout Grid
layout.tsxis a Next.js server component (exportsmetadata), so the CSS grid is in a separateMainLayout.tsxclient component that wraps children.- Grid breakpoints:
Desktop (>=1024px): grid-cols-[1fr_340px]
Tablet (768-1023px): grid-cols-[1fr_300px] (narrower bet panel)
Mobile (<768px): grid-cols-1 (bet panel is bottom sheet) - Grid columns are conditional on
betPanelSettingsStore.panelVisible— when hidden, full-width single column. - Left panel for AI: NOT in this PR. Center content fills
1fr. AI features added later. - Bet Panel rendered ONCE at layout level — remove
<BetSlip />from all individual pages - Main content area:
overflow-y-auto, fills available height - Bet Panel:
sticky top-[64px] h-[calc(100vh-64px)]on desktop - AI chat button: left in current position (move to bottom-left deferred)
0.2 Page Layout Normalization
- Live page (
/live): Removemax-w-7xl, let content fill available grid cell - Fixture detail (
/fixture/[id]): Removemax-w-4xl, usemax-w-5xlor fluid - Both pages now have identical content width (the left grid column)
- Content scrolls independently from bet panel
0.3 Header Adjustments
- Mobile (
md:hidden):Receipticon button callingbetSlipStore.toggleSlip(), red badge with bet count - Desktop (
hidden md:flex):PanelRighticon button callingbetPanelSettingsStore.togglePanelVisible(), highlighted blue when panel visible, bet count badge when panel hidden and bets exist - Header width spans full page (above grid)
Files created:
frontend/src/components/layout/MainLayout.tsx— client component wrapping CSS grid + BetPanel aside
Files modified:
frontend/src/app/layout.tsx— wraps children in<MainLayout>frontend/src/app/live/page.tsx— remove max-w, remove BetSlip importfrontend/src/app/fixture/[id]/page.tsx— remove max-w (changed to max-w-5xl), remove BetSlip importfrontend/src/app/sports/page.tsx— remove BetSlip importfrontend/src/app/my-bets/page.tsx— remove BetSlip importfrontend/src/components/layout/Header.tsx— panel toggle buttons (mobile + desktop)frontend/src/app/globals.css— bet panel animations (shake, flash, pulse, slide, thin scrollbar)
Phase 1: Bet Panel Component — BETSLIP Tab
Goal: Fully functional BETSLIP tab matching 1exch.com feature set
1.1 BetPanel Container Component
New: frontend/src/components/betting/BetPanel.tsx
- Permanent sidebar on desktop (
hidden md:flex), bottom sheet on mobile with drag-to-dismiss - Two tabs: BETSLIP | OPEN BETS — tab state in
betSlipStore.activeTab - OneClickToggle rendered above tabs
- Mobile: floating trigger button (bottom-right, z-40) + overlay bottom sheet (max-h-[80vh]) with drag handle
- Auto-opens mobile sheet when bet added (
useEffectonitems.length) - Glassmorphism styling:
backdrop-blur-xl bg-slate-900/80 border-l border-white/5
New: frontend/src/components/betting/BetSlipTab.tsx — extracted BETSLIP tab content (header with count + clear, scrollable card list, sticky footer)
1.2 ONE CLICK Betting Toggle
- Positioned at top of bet panel, above tabs (like 1exch)
[OFF] ONE CLICK Betting [ⓘ]- OFF (default): Odds click adds to slip, user enters stake, clicks Place Bet
- ON: Clicking odds immediately places bet at
defaultStake(from settings) - Activation shows confirmation dialog: "Enable One Click? Bets placed instantly at {defaultStake} pts"
- Setting persisted in
hannibal-bet-panel-settingslocalStorage - Visual indicator: yellow/gold accent when ON
1.3 BETSLIP Tab Header
- "Your Bets: {count}" label
- Settings gear icon — opens settings dropdown
- Delete all icon — clears all bets with confirmation
- Settings options (Phase 2):
- General: "Show bet info" toggle
- Bets: "Consolidate" toggle, "Average Odds" toggle, "Order By Date" toggle
1.4 Individual Bet Card (BetSlipCard)
New: frontend/src/components/betting/BetSlipCard.tsx
Layout per 1exch reference:
┌─────────────────────────────────────┐
│ [LIVE 🟢] Match Name X │ ← Close button
│ │
│ Market Name (exchange) - Back/Lay │
│ [Logo] Selection Name [-] 5.6 [+]│ ← Odds with +/- buttons
│ │
│ Your Bet Min: 100 Max: 225,200 │ ← Betfair limits
│ Max. Mkt: 500,000+ │
│ │
│ ┌──────────────────┐ Liability: 2.00│ ← Stake input
│ │ Enter stake │ │
│ └──────────────────┘ │
│ [+500] [+2500] [+5000] [+12500] │ ← Quick stake buttons
│ [ Max ] │
└─────────────────────────────────────┘
Color coding:
- Back bets: Light blue/cyan border-left accent
- Lay bets: Light pink/rose border-left accent
Data displayed per card:
- Match:
homeTeam vs awayTeam(from fixture) - Live badge if fixture is live (green dot)
- Market name: e.g., "Match Odds (exchange) - Lay"
- Selection name: e.g., "Bayern Munich"
- Current odds with +/- stepper buttons (Betfair tick increments)
- Limits row: Your Bet min, Max (from depth size), Max. Mkt (totalMatched)
- Stake input with liability display
- Quick stake buttons: +10, +25, +50, +100, +500 (in points, configurable via settings)
- ADD behavior (not replace!) — pressing +25 twice = 50
- Max button: fills maximum available stake
- Close (X) button per card
1.5 Betfair Tick Size Logic
New: frontend/src/utils/betfairTicks.ts
// Betfair price ladder increments
const TICK_RANGES = [
{ min: 1.01, max: 2, step: 0.01 },
{ min: 2, max: 3, step: 0.02 },
{ min: 3, max: 4, step: 0.05 },
{ min: 4, max: 6, step: 0.1 },
{ min: 6, max: 10, step: 0.2 },
{ min: 10, max: 20, step: 0.5 },
{ min: 20, max: 30, step: 1 },
{ min: 30, max: 50, step: 2 },
{ min: 50, max: 100, step: 5 },
{ min: 100, max: 1000, step: 10 },
];
export function getNextTick(price: number): number { ... }
export function getPrevTick(price: number): number { ... }
export function isValidTick(price: number): boolean { ... }
export function nearestValidTick(price: number): number { ... }
Functions:
getNextTick(price)— increment odds by one tick stepgetPrevTick(price)— decrement odds by one tick stepisValidTick(price)— validate odds are on valid Betfair ticknearestValidTick(price)— snap to nearest valid tick
1.6 Bet Slip Footer (Totals + Place Bet)
┌─────────────────────────────────────┐
│ Total Payout: 478 │ ← Green text
│ Potential Risk: 35.00 │ ← White text
│ │
│ ┌──────────────────────────────────┐│
│ │ Place Bet 35.00 ⏱ 5s ││ ← Green button, countdown
│ │ Potential Payout 478 ││
│ └──────────────────────────────────┘│
└─────────────────────────────────────┘
Calculations:
- Total Payout = Sum of (stake × odds) for back bets + Sum of (stake) for lay bets
- Potential Risk = Sum of (stake) for back bets + Sum of (stake × (odds - 1)) for lay bets
- Place Bet button shows total risk amount
5-Second Countdown Timer:
- Shown on Place Bet button for live/in-play bets only
- Timer format:
Place Bet 35.00 ⏱ 5s - Purpose: Odds freshness indicator — odds change rapidly during live play
- After 5s, timer resets or odds auto-refresh
- For pre-match bets: no timer (odds are more stable)
- Matches Betfair's
betDelayconcept (typically 2-5s for in-play markets)
1.7 Duplicate Prevention
betSlipStore.addBet()checks: samefixtureId + marketId + outcomeId + betType- If duplicate: highlight existing card with flash animation, do NOT add new one
- If same match but different market/selection: allow (multiple bets on same match is fine)
1.8 Delete All Functionality
- Delete (🗑) button in header clears ALL bets in BETSLIP tab
- Double-tap confirm: first click shows "Confirm?" (auto-dismisses after 3s), second click clears + info toast
- Does not affect Open Bets tab
1.9 Place All Bets
- Validates ALL bets have valid stake (> 0)
- Submits bets sequentially via existing
/orders(punters) or/agent/bets(agents) - Per-bet toast on success/failure
- On success: clears entire slip after 1.2s success animation. On all failure: shows error state for 2s.
- Partial failure: clears all on any success (toast shows which failed). Full per-bet partial clear deferred — store lacks batch-remove.
Files created:
frontend/src/components/betting/BetPanel.tsxfrontend/src/components/betting/BetSlipTab.tsxfrontend/src/components/betting/BetSlipCard.tsxfrontend/src/components/betting/BetSlipFooter.tsxfrontend/src/components/betting/OneClickToggle.tsxfrontend/src/utils/betfairTicks.ts
Files modified:
frontend/src/store/betSlipStore.ts—activeTab,clearAll,addBetreturns'added'|'duplicate', flat display fieldsfrontend/src/types/betting.ts— extendedBetSlipItemwith flat display fields,Orderwithbookmakerfrontend/src/components/betting/ExchangeGrid.tsx— duplicate detection with shake + yellow ring flashfrontend/src/components/betting/FixtureCard.tsx— same duplicate detection
Files deleted:
frontend/src/components/betting/BetSlip.tsx— replaced by BetPanelfrontend/src/components/betting/OddsButton.tsx— unused standalone odds button (was already dead code)
Phase 2: OPEN BETS Tab
Goal: Show active orders with sub-tabs (Matched, Unmatched, Voided, Cashed Out)
2.1 Open Bets Store
New: frontend/src/store/openBetsStore.ts
interface OpenBetsState {
orders: Order[];
loading: boolean;
activeSubTab: 'matched' | 'unmatched' | 'voided' | 'cashed_out';
settings: {
showBetInfo: boolean;
consolidate: boolean;
averageOdds: boolean;
orderByDate: boolean;
};
}
2.2 Open Bets Sub-tabs
┌───────────────────────────────────┐
│ BETSLIP │ OPEN BETS │
├───────────────────────────────────┤
│ ⚙ Settings ▼ │
│ [Matched] [Unmatched] [Voided] │
│ [Cashed Out] │
├───────────────────────────────────┤
│ │
│ (Order cards or empty state) │
│ │
└───────────────────────────────────┘
Sub-tab filtering:
- Matched:
status === 'accepted' - Unmatched:
status === 'pending'— includes Cancel button (Betfair only, not Pinnacle) - Voided:
settlementOutcome === 'void' - Cashed Out: DEFERRED — omitted until cash-out feature is built. Show as disabled/grayed tab with tooltip "Coming soon".
2.3 Settings Panel (Collapsible)
- General: "Show bet info" — toggle expanded/compact order cards
- Bets: "Consolidate" — group same-selection bets
- Bets: "Average Odds" — show weighted average odds for consolidated bets
- Bets: "Order By Date" — sort newest first (checked) vs by match time
2.4 Order Card Component
New: frontend/src/components/betting/OpenBetCard.tsx
Shows: match name, market, selection, bet type, stake, odds, potential return, status badge, placed time.
Cancel Button (Unmatched tab only):
- Red "Cancel" button on each pending order card
- Betfair: calls
POST /api/orders/:id/cancel— refunds stake (back) or liability (lay) - Pinnacle: Cancel button hidden — Pinnacle does not support order cancellation
- Provider check:
order.bookmaker !== 'pinnacle'to show/hide cancel - Two-click confirm: first click shows "Confirm?", second fires mutation. Loading spinner during cancel, error message on failure. Query cache invalidated on success.
2.5 Data Fetching
- TanStack Query hook:
useOpenBets(status?)— fetchesGET /api/orders?status=pending,accepted - Poll every 10 seconds (
refetchInterval), stale after 5s useCancelOrder()mutation — invalidates query cache on success- WebSocket
order:updateevent deferred — polling only for now
Files to create:
frontend/src/store/openBetsStore.tsfrontend/src/components/betting/OpenBetsTab.tsxfrontend/src/components/betting/OpenBetCard.tsxfrontend/src/components/betting/BetPanelSettings.tsxfrontend/src/hooks/useOpenBets.ts
Phase 3: Backend — Batch Orders + Tick Validation
Goal: Support "Place All" and validate tick sizes server-side
3.1 Batch Order Endpoint
New route: POST /api/orders/batch
// Request
{
orders: Array<{
fixtureId: string;
marketId: string;
outcomeId: string;
stake: number;
odds: number;
trueOdds?: number;
betType: 'back' | 'lay';
acceptBetterOdds: boolean;
sportId: number;
homeTeam?: string;
awayTeam?: string;
tournamentName?: string;
marketName?: string;
selectionName?: string;
}>
}
// Response
{
success: boolean;
data: {
submitted: number;
failed: number;
results: Array<{
index: number;
success: boolean;
order?: Order;
error?: string;
}>
}
}
Logic:
- Validate ALL orders upfront (balance, limits, odds, tick validity)
- Group by marketId for Betfair batch API
- Best-effort: place as many as possible, return per-order status
- Atomic balance check: total stake across all orders <= available balance
- Each order creates its own Transaction record
3.2 Betfair Tick Validation (Backend)
New: backend/src/utils/betfairTicks.ts (same logic as frontend)
- Validate odds are on valid tick before submitting to Betfair
- Auto-snap to nearest valid tick with warning log
- Return
INVALID_ODDSerror if odds are completely out of range
3.3 Market Limits Endpoint Enhancement
Enhance: GET /api/odds/:fixtureId response
Add to each outcome:
{
// existing fields...
limits: {
minStake: number; // Bookmaker minimum (0.10 for Betfair, 20 for Pinnacle)
maxBackStake: number | null; // First back depth entry size (null if no depth)
maxLayStake: number | null; // First lay depth entry size (null if no depth)
}
}
Files to create:
backend/src/utils/betfairTicks.ts
Files to modify:
backend/src/routes/orders.ts— add batch endpointbackend/src/services/orderService.ts— batch placement logicbackend/src/routes/odds.ts— add limits to response
Phase 4: UI/UX Polish — Apple 2026 Glassmorphism
Goal: World-class visual design exceeding all competitors
4.1 Design System
- Background:
bg-slate-900/80 backdrop-blur-xl— frosted glass effect - Cards:
bg-white/5 border border-white/10 rounded-xl— subtle glass cards - Back accent:
border-l-2 border-sky-400/60 bg-sky-500/5 - Lay accent:
border-l-2 border-rose-400/60 bg-rose-500/5 - Active tab:
bg-white/10 text-whitewith subtle glow - Inactive tab:
text-slate-400 hover:text-slate-200 - Buttons: Rounded, subtle hover transitions (
transition-all duration-200) - Inputs:
bg-white/5 border-white/10 focus:border-sky-400/50 focus:ring-sky-400/20 - Scrollbar: Thin custom scrollbar (
scrollbar-thin scrollbar-track-transparent scrollbar-thumb-white/10)
4.2 Animations
- Bet card add: Slide in from right + fade in (
animate-slideInRight) - Bet card remove: Slide out left + fade out
- Tab switch: Smooth underline slide (
transition-[left,width]) - Odds change flash: Brief background pulse (green up, red down)
- Duplicate attempt: Shake + highlight existing card
- Place bet: Button ripple effect + loading spinner
- Success: Green checkmark animation + card slide out
4.3 Responsive Transitions (confirmed via Playwright on 1exch.com)
- Desktop (>=1024px): 2-column grid, bet panel always visible at 340px
- Tablet (768-1023px): 2-column grid, bet panel narrows to 300px but stays visible
- Mobile (<768px): Single column, bet panel becomes bottom sheet with floating trigger
- Breakpoint aligns with 1exch behavior: panel visible down to 768px
- All transitions use
transition-transform duration-300 ease-out - Floating trigger button: bottom-right corner, shows bet count badge
- AI chat button: bottom-left corner (both mobile and desktop)
4.4 Empty States
- BETSLIP empty: Illustration + "Add bets and start winning" message (like 1exch Image 6)
- OPEN BETS empty: Sport-specific illustration + "You have no {Matched} Bets" message
- Loading: Skeleton pulse animations matching card layout
3. Data Requirements from Betfair API
Currently Available (no backend changes needed)
| Data | Source | Used In |
|---|---|---|
| Best back/lay odds | backDepth[0].price | Bet card odds display |
| Depth (3 levels) | backDepth[], layDepth[] | Depth ladder, max stake calc |
| Volume at price | backDepth[0].size | "Max" stake calculation |
| Total matched | totalMatched on market | "Max. Mkt" display |
| Market status | status field | Disable betting on suspended |
| In-play flag | inplay field | LIVE badge on bet cards |
| Order statuses | GET /api/orders | Open Bets tab |
| Account balance | User balancePoints | Balance check before placement |
Needs Backend Enhancement
| Data | Gap | Solution |
|---|---|---|
| Min stake per bookmaker | Hardcoded in backend only | Expose in odds response |
| Max stake (depth-based) | Available but not returned | Sum best-price depth sizes |
| Betfair tick sizes | Not implemented anywhere | New utility (frontend + backend) |
| Batch order placement | No endpoint | New POST /api/orders/batch |
| Real-time order status | No WebSocket event | Add order:update event (Phase 2+) |
4. State Management Changes
Modified: betSlipStore.ts
interface BetSlipState {
items: BetSlipItem[];
isOpen: boolean; // Only relevant for tablet/mobile
activeTab: 'betslip' | 'open_bets';
}
interface BetSlipItem {
id: string;
fixtureId: string;
marketId: string;
outcomeId: string;
betType: 'back' | 'lay';
odds: number;
trueOdds?: number;
stake: number;
// Display fields (captured at add time):
homeTeam: string;
awayTeam: string;
selectionName: string;
marketName: string;
tournamentName: string;
sportId: number;
isLive: boolean;
// Market data for limits:
minStake: number;
maxStake: number; // From depth size
maxMarket: number; // From totalMatched
}
Key changes:
- Store flattened data (not full Fixture/Market/Outcome objects) — reduces serialization overhead
- Add
minStake,maxStake,maxMarketfrom odds API addBet()returns'added' | 'duplicate'for UI feedbackupdateOdds()validates against Betfair tick sizes
New: openBetsStore.ts
interface OpenBetsState {
activeSubTab: 'matched' | 'unmatched' | 'voided';
settings: BetPanelSettings;
}
interface BetPanelSettings {
showBetInfo: boolean;
consolidate: boolean;
averageOdds: boolean;
orderByDate: boolean;
}
Orders fetched via TanStack Query (not stored in Zustand) — query key: ['orders', 'open'].
New: betPanelSettingsStore.ts
interface BetPanelSettingsState {
oneClickMode: boolean;
defaultStake: number; // Used for ONE CLICK betting (default: 10)
quickStakes: number[]; // [10, 25, 50, 100, 500]
acceptBetterOdds: boolean;
panelVisible: boolean; // Hide/show bet panel (default: true)
}
Persisted to localStorage as hannibal-bet-panel-settings with version migration.
Follows existing Zustand + persist pattern (like hannibal-auth).
panelVisible consumed by MainLayout.tsx (grid columns) and Header.tsx (toggle button).
5. Component Hierarchy
layout.tsx
├── Header
│ ├── Receipt toggle (mobile, md:hidden) — toggles betSlipStore.isOpen
│ └── PanelRight toggle (desktop, hidden md:flex) — toggles betPanelSettingsStore.panelVisible
├── MainLayout (client component — CSS grid wrapper)
│ ├── <main> (CSS grid left column, overflow-y-auto)
│ │ └── {children} — page content
│ └── <aside> (CSS grid right column, conditional on panelVisible)
│ └── BetPanel
│ ├── OneClickToggle (OFF/ON + inline default stake input)
│ ├── Tab bar: BETSLIP | OPEN BETS (animated underline, bet count badge)
│ ├── BetSlipTab (when BETSLIP active)
│ │ ├── Header (count + Clear button with double-tap confirm)
│ │ ├── BetSlipCard list (scrollable, .bet-panel-scroll)
│ │ │ └── BetSlipCard (per bet)
│ │ │ ├── MatchInfo (teams, live badge, close X)
│ │ │ ├── SelectionInfo (market, selection, odds +/-)
│ │ │ ├── LimitsRow (min, max, avail)
│ │ │ ├── StakeInput (input + liability)
│ │ │ └── QuickStakeButtons (+10, +25, +50, +100, +500) + Max
│ │ ├── Empty state (MousePointerClick icon)
│ │ └── BetSlipFooter (totals + Place Bet button + 5s live timer)
│ └── OpenBetsTab (when OPEN BETS active)
│ ├── Settings toggle + BetPanelSettings (collapsible)
│ ├── Sub-tabs: Matched | Unmatched | Voided | [Cashed Out: disabled]
│ ├── OpenBetCard list (scrollable)
│ │ └── OpenBetCard (per order + Cancel for unmatched non-Pinnacle)
│ ├── LoadingSkeleton
│ └── EmptyState (per sub-tab)
├── BetPanel mobile bottom sheet (md:hidden, overlay with drag-to-dismiss)
│ └── Same BetPanel content as above
├── Floating trigger button (md:hidden, bottom-right, shows bet count)
└── AiChatButton (current position, not moved)
TODO (future): Agent users place bets via POST /agent/bets. Bet panel currently for punters only. Add agent support when agent roles are fully implemented — will be managed by user role flag.
6. Implementation Order (Tracer Bullet)
Tracer Bullet: Single bet → Place → See in Open Bets
- Layout grid + bet panel container (skeleton with tabs)
- Single bet card with odds +/- (using tick logic)
- Stake input + footer with Place Bet button
- Place single bet through existing API
- Switch to Open Bets tab → see placed bet
Then expand: 6. Multiple bets + delete all + totals calculation 7. Duplicate prevention + flash animation 8. ONE CLICK betting toggle + confirmation dialog 9. 5s countdown timer for live bets 10. Batch placement API (backend) 11. Open Bets sub-tabs (Matched/Unmatched/Voided) 12. Cancel button for unmatched orders 13. Settings panel + localStorage persistence 14. Glassmorphism polish + animations 15. Mobile/tablet responsive behavior 16. Market limits in odds API response 17. AI chat button moved to bottom-left
7. Key Technical Decisions
Q1: Batch placement — atomic or best-effort?
Decision: Best-effort. Place as many as possible, return per-order status. Frontend shows green/red per bet card. Reason: One failed bet shouldn't block others.
Q2: Open Bets — polling or WebSocket?
Decision: Start with TanStack Query polling (10s interval). Add WebSocket order:update event later as enhancement. Reason: Polling is simpler, WebSocket event doesn't exist yet.
Q3: Bet panel width?
Decision: 340px fixed on desktop (1exch uses ~320px). Below 1024px, the panel becomes an overlay. Reason: 340px fits all content including +/- buttons and limits row without cramping.
Q4: Store architecture — BetSlipItem as flat data or nested objects?
Decision: Flat data. Capture display fields at addBet() time. Reason: Reduces serialization to localStorage, avoids stale nested object references, simpler component props.
Q5: Glassmorphism — how heavy?
Decision: Subtle. backdrop-blur-xl on panel background only. Cards use bg-white/5 — no blur per card (performance). Reason: Excessive blur causes GPU issues on lower-end devices.
Q6: ONE CLICK — safety mechanism?
Decision: Confirmation dialog on first enable. Shows: "Enable One Click? Bets placed instantly at {defaultStake} pts. You can change default stake in settings." Persisted in localStorage — remembers across sessions. Yellow/gold accent when active as constant visual reminder.
Q7: Quick stakes — ADD or REPLACE?
Decision: ADD behavior (like 1exch). Pressing +25 twice = 50. Current Hannibal uses REPLACE. This is a UX change. Reason: ADD is faster for live betting — users build up stake incrementally.
Q8: Bet panel visible at 768px?
Decision: YES (confirmed via Playwright on 1exch). Keep panel visible down to 768px. Only switch to bottom sheet below 768px. This matches 1exch exactly.
8. Files Inventory
New Files (14)
| File | Purpose |
|---|---|
frontend/src/components/layout/MainLayout.tsx | Client component: CSS grid wrapper with conditional panel column |
frontend/src/components/betting/BetPanel.tsx | Main bet panel container (tabs, mobile bottom sheet) |
frontend/src/components/betting/BetSlipTab.tsx | BETSLIP tab content (header, card list, footer) |
frontend/src/components/betting/BetSlipCard.tsx | Individual bet card (odds +/-, stake, quick stakes) |
frontend/src/components/betting/BetSlipFooter.tsx | Totals + Place Bet + 5s live timer |
frontend/src/components/betting/OneClickToggle.tsx | ONE CLICK toggle + confirmation + inline stake input |
frontend/src/components/betting/OpenBetsTab.tsx | OPEN BETS tab (sub-tabs, settings, order cards) |
frontend/src/components/betting/OpenBetCard.tsx | Open order card + Cancel for unmatched non-Pinnacle |
frontend/src/components/betting/BetPanelSettings.tsx | Collapsible settings toggles |
frontend/src/utils/betfairTicks.ts | Betfair odds tick logic (10 ranges, precision-safe) |
frontend/src/hooks/useOpenBets.ts | TanStack Query: useOpenBets + useCancelOrder |
frontend/src/store/openBetsStore.ts | Open bets UI state (sub-tab, display settings) |
frontend/src/store/betPanelSettingsStore.ts | Persisted settings (ONE CLICK, quickStakes, panelVisible) |
backend/src/utils/betfairTicks.ts | Server-side tick validation + auto-snap |
Modified Files (12)
| File | Changes |
|---|---|
frontend/src/app/layout.tsx | Wraps children in <MainLayout> |
frontend/src/app/live/page.tsx | Remove max-w, remove BetSlip import |
frontend/src/app/fixture/[id]/page.tsx | Change to max-w-5xl, remove BetSlip import |
frontend/src/app/sports/page.tsx | Remove BetSlip import |
frontend/src/app/my-bets/page.tsx | Remove BetSlip import |
frontend/src/app/globals.css | Bet panel animations + thin scrollbar |
frontend/src/store/betSlipStore.ts | activeTab, clearAll, addBet returns added/duplicate, flat display fields |
frontend/src/types/betting.ts | BetSlipItem flat fields, Order.bookmaker |
frontend/src/components/betting/ExchangeGrid.tsx | Duplicate detection (shake + yellow flash) |
frontend/src/components/betting/FixtureCard.tsx | Duplicate detection (shake + yellow flash) |
frontend/src/components/layout/Header.tsx | Panel toggle buttons (mobile Receipt + desktop PanelRight) |
backend/src/routes/orders.ts | POST /api/orders/batch endpoint |
backend/src/services/orderService.ts | placeBatchOrders() method |
backend/src/routes/odds.ts | Limits (minStake, maxBackStake, maxLayStake) in odds response |
Deleted Files (2)
| File | Reason |
|---|---|
frontend/src/components/betting/BetSlip.tsx | Replaced by BetPanel + BetSlipTab |
frontend/src/components/betting/OddsButton.tsx | Dead code — unused standalone odds button |
9. Resolved Questions
| # | Question | Decision | Rationale |
|---|---|---|---|
| 1 | Quick stake values | +10, +25, +50, +100, +500 (points) | 1 point = 1 USD. 1exch uses INR values (500/2500/5000/12500). Our USD-equivalent values are proportionally smaller. Configurable via settings. |
| 2 | ONE CLICK betting | YES — implement | User confirmed. Toggle at top of panel. Confirmation dialog on first enable. Places bet at defaultStake on odds click. |
| 3 | 5s countdown timer | YES — for live bets only | Odds freshness indicator. Matches Betfair's betDelay (2-5s for in-play). No timer for pre-match. |
| 4 | Cashed Out sub-tab | DEFERRED | No cash-out feature exists. Show tab as disabled/grayed with "Coming soon" tooltip. |
| 5 | Agent bets | DEFERRED | Punters only for now. TODO added for future agent role support. |
| 6 | Settings persistence | localStorage | Industry standard. Key: hannibal-bet-panel-settings. Follows existing hannibal-{feature} pattern. Version migration included. |
| 7 | Mobile bet panel trigger | Floating button (bottom-right) | Matches 1exch mobile pattern. Shows bet count badge. AI chat button goes bottom-left. |
| 8 | Tick sizes | Hardcoded (frontend + backend) | Betfair does NOT expose via API. Fixed price ladder for 20+ years. Same util on both sides. |
| 9 | Cancel for unmatched | YES — Betfair only | Cancel button on unmatched order cards. Hidden for Pinnacle (no cancel support). Uses existing POST /api/orders/:id/cancel. |
| 10 | Left panel | Empty — reserved for AI | Not implemented in this PR. AI chat button moves to bottom-left corner. |
10. Remaining Open Items
- One-click immediate placement: Toggle UI is built, but odds click in ExchangeGrid/FixtureCard does not yet check
oneClickModeto place immediately — still adds to slip. Wire up the intercept. - Bet delay integration: Betfair's
betDelayfield exists but not surfaced. Could use to set the exact countdown timer per market (2-5s) instead of a static 5s. - WebSocket
order:updateevent: Currently no real-time order status push. Open Bets tab relies on polling (10s). Backend enhancement needed later. - Error message mapping: Betfair error codes (INVALID_BET_SIZE, INSUFFICIENT_FUNDS, etc.) need user-friendly messages. Not blocking for MVP.
- Partial failure in slip: On partial bet placement failure, entire slip is cleared (with toast showing which failed). Per-bet selective clearing needs a batch-remove action in betSlipStore.
- AI chat button: Plan called for moving to bottom-left corner — deferred, left in current position.
- Pinnacle no-cancel UX: BetSlip card doesn't show a "cannot cancel" indicator for Pinnacle bets (cancel button is just hidden in Open Bets).
- Pinnacle $20 min stake warning: BetSlipCard doesn't yet show a warning when stake is below Pinnacle minimum for Pinnacle-routed bets.