Frontend Pinnacle UI Design Document
Research output for integrating Pinnacle (bookmaker) data alongside existing Betfair (exchange) data in the Hannibal frontend.
1. Current Frontend Architecture Summary
App Router Structure (frontend/src/app/)
| Route | File | Purpose |
|---|---|---|
/ | page.tsx | Landing page — hero, auth-gated features |
/sports | sports/page.tsx | Main sports listing — sport tabs, time filters, tournament groups, fixture cards |
/live | live/page.tsx | Live in-play events — sport tabs, real-time WebSocket |
/fixture/[id] | fixture/[id]/page.tsx | Fixture detail — markets, ExchangeGrid, QuickOddsButton, cricket panels |
/my-bets | my-bets/page.tsx | Order history |
/wallet | wallet/page.tsx | Wallet / balance |
/watchlists | watchlists/page.tsx | Saved watchlists |
/agents | agents/page.tsx | AI agents listing |
/admin/* | admin/... | Admin panel (16 sub-pages) |
/agent/* | agent/... | Agent portal (5 sub-pages) |
/reports/* | reports/... | Statement, PnL, results, activity |
/settings | settings/page.tsx | User settings |
/join | join/page.tsx | Referral join |
Layout Hierarchy
layout.tsx (root)
-> Providers (TanStack Query, Web3Auth, etc.)
-> OfflineIndicator
-> ErrorBoundary
-> AuthGuard
-> {children} <- page content
-> BottomNav <- mobile bottom nav (Sports, Live, Watchlists, My Bets, More)
-> AIChat <- floating AI chat panel
-> AIAnalysisDockManager
-> ToastContainer
Component Organization (frontend/src/components/)
| Directory | Purpose | Key Components |
|---|---|---|
betting/ | Core betting UI | BetSlip.tsx, ExchangeGrid.tsx, FixtureCard.tsx, OddsButton.tsx, FixtureExposure.tsx |
sports/ | Sports browsing | SportsSidebar.tsx, TournamentFixtures.tsx, SmartSearchBar.tsx, SortDropdown.tsx, WatchlistToggleButton.tsx |
cricket/ | Cricket-specific | BallByBallPanel.tsx, CricketAnalysisPanel.tsx, SessionPanel.tsx, TossAnalysisPanel.tsx |
ai/ | AI analysis | AIFixtureButton.tsx, AIFixturePanel.tsx, AITournamentButton.tsx, etc. |
ai-chat/ | Conversational AI | AIChatPanel.tsx, CommandChat.tsx, MarkdownMessage.tsx |
auth/ | Authentication | AuthGuard.tsx, ConnectButton.tsx, LoginModal.tsx |
layout/ | Shell chrome | Header.tsx, BottomNav.tsx |
ui/ | Primitives | ErrorBoundary.tsx, Toast.tsx, TeamLogo.tsx, PlayerImage.tsx |
providers/ | Context providers | Web3AuthProvider.tsx, WebSocketProvider.tsx |
Zustand Stores (frontend/src/store/)
| Store | File | Purpose | Key State |
|---|---|---|---|
| Auth | authStore.ts | User session | user: User, isAuthenticated, balancePoints |
| BetSlip | betSlipStore.ts | Bet selections | items: BetSlipItem[], isOpen, addBet(), removeBet() |
| AI Analysis | aiAnalysisStore.ts | AI panel state | Analysis panels, docked panels |
| AI Chat | aiChatStore.ts | Chat state | Messages, conversation |
| AI Command | aiCommandStore.ts | Command mode | Command execution state |
| Notifications | notificationStore.ts | Toasts & alerts | Toast queue, notification center |
| Ball-by-Ball | ballByBallStore.ts | Cricket live | Ball updates, match context |
Custom Hooks (frontend/src/hooks/)
| Hook | Purpose | API Calls |
|---|---|---|
useFixtures.ts | Fixture data fetching | fixturesApi.getLive(), fixturesApi.getUpcoming(), fixturesApi.getById() |
useSports.ts | Sports & tournament lists | sportsApi.getAll(), tournamentsApi.getBySport() |
useWebSocket.ts | Real-time updates | Socket.IO: odds:update, score:update, balance:update, settlement:update |
useAuth.ts | Auth state | Token management, Web3Auth |
useCurrency.ts | Currency formatting | Agent display currency |
useAI.ts | AI analysis & watchlists | AI endpoints, watchlist CRUD |
API Layer (frontend/src/lib/)
| File | Purpose | Endpoints |
|---|---|---|
api.ts | Axios instance, JWT interceptor, token refresh | Base client, authApi, userApi |
oddsApi.ts | Odds/fixtures/sports | GET /odds/sports/all, GET /odds/tournaments/:sportId, GET /fixtures/live, GET /fixtures/upcoming, GET /fixtures/:id, GET /odds/delta/:seconds |
cricketApi.ts | Cricket data | Cricket-specific endpoints |
aiApi.ts | AI analysis | Fixture analysis, tournament analysis |
aiChatApi.ts | Chat AI | Chat messages |
adminApi.ts | Admin endpoints | User mgmt, orders, settlements |
agentApi.ts | Agent endpoints | Agent portal |
2. Current Sports/Odds Display Patterns
Fixture Listing Flow
/sportspage selects sport (default: Cricket27) and time filter (default:next7days)- Calls
useUpcomingFixtures(sportId, sort)->GET /fixtures/upcoming?sportId=27&sort=featured - Calls
useLiveFixtures(sportId, sort)->GET /fixtures/live?sportId=27&sort=liveFirst - Combines, deduplicates, filters by time, groups by tournament ->
TournamentFixtures - Each fixture rendered as
FixtureCardwith inline exchange odds
FixtureCard Odds Display (Exchange Model)
The FixtureCard (components/betting/FixtureCard.tsx:284) renders Betfair exchange-style odds:
[1] [X] [2]
Back | Lay Back | Lay Back | Lay
2.10 | 2.12 3.40 | 3.50 3.80 | 3.90
$2.3k $1.8k $800 $600 $1.5k $1.2k
- Uses
ExchangeOddsButtoninternally (back = blue, lay = pink) - Shows
backDepth[0].priceandlayDepth[0].price - Shows available size beneath price
- Flash animation on odds changes (green up, red down)
- Click adds to BetSlip with specific
betType(back/lay)
Fixture Detail Page (/fixture/[id])
- Header: Team names, scores (live), tournament, volume
- QuickOddsButton: Back|Lay pairs for main 1X2 market
- FixtureExposure: User's current position
- Cricket panels: Toss analysis, session intelligence, ball-by-ball (sport-specific)
- Markets section: Grouped by category (Popular, Goals, Handicaps, etc.)
- Each market rendered as
ExchangeGrid— 3-column layout:[Outcome Name | Back | Lay] - Back column (blue header), Lay column (pink header)
- Each cell is
OddsCellwith price + size
- Each market rendered as
ExchangeGrid Layout (components/betting/ExchangeGrid.tsx:162)
┌─────────────────────────────────────┐
│ Market Name │ Back │ Lay │
├─────────────────────────────────────┤
│ Home Team │ 2.10 │ 2.12 │
│ │ $2.3k │ $1.8k│
├─────────────────────────────────────┤
│ Draw │ 3.40 │ 3.50 │
│ │ $800 │ $600 │
├─────────────────────────────────────┤
│ Away Team │ 3.80 │ 3.90 │
│ │ $1.5k │ $1.2k│
└─────────────────────────────────────┘
Grid columns: grid-cols-[1fr_60px_60px] (or 52px compact).
BetSlip (components/betting/BetSlip.tsx)
- Zustand store:
betSlipStore.tswithpersistmiddleware (localStorage) addBet(fixture, market, outcome, betType, odds)— betType is'back' | 'lay'- BetSlipItem stores:
fixture,market,outcome,betType,stake,odds,trueOdds - Places orders via
POST /orders(punter) orPOST /agent/bets(agent) - Shows back/lay badge, liability calculation for lay bets
- Quick stakes: [1, 5, 10, 20, 50] points
- Minimum stake:
$0.10 / pointValueUsd(very low for Betfair) - Confirmation modal before placement
3. Data Flow (API -> Store -> Components)
Fixture List Data Flow
API: GET /fixtures/upcoming?sportId=10&sort=featured
-> Backend: exchangeCoordinator.getFixtures({ sportId: 10, status: 'upcoming' })
-> Backend: marginService.transformFixtureBasic(fixture) // applies margin
-> Response: { success: true, data: Fixture[], meta: { source: 'PAL/Betfair' } }
Frontend: useUpcomingFixtures(sportId, sort) [TanStack Query]
-> fixturesApi.getUpcoming(sportId, sort)
-> staleTime: 30s, refetchInterval: 60s
-> Returns: Fixture[] with bestOdds, markets (Match Odds only in list)
Component: SportsPage -> TournamentFixtures -> FixtureCard
-> Groups fixtures by tournament, sorts by category
-> FixtureCard renders ExchangeOddsButton for each outcome
Fixture Detail Data Flow
API: GET /fixtures/:fixtureId?sportId=27
-> Backend: exchangeCoordinator.getFixture(fixtureId)
-> Backend: exchangeCoordinator.getOdds({ fixtureId })
-> Backend: marginService.transformFixtureWithOdds(fixture, markets)
-> Response: { success: true, data: Fixture (with full markets), meta: { marginPercent } }
Frontend: useFixtureDetails(fixtureId, sportId) [TanStack Query]
-> fixturesApi.getById(fixtureId, sportId)
-> staleTime: 3s, refetchInterval: 5s (aggressive for live odds)
Component: FixturePage -> ExchangeGrid per market
-> QuickOddsButton in header (main market)
-> MarketGroup -> ExchangeGrid per market in body
WebSocket Real-Time Updates
Socket.IO events:
odds:update -> invalidateQueries(fixtureKeys.detailPrefix(fixtureId))
score:update -> setQueriesData(update scores in cache)
balance:update -> updateBalance in authStore
settlement:update -> toast + sound + haptics + invalidate orders
Type Shapes
Fixture (from types/betting.ts):
{
id: string;
sportId: string; // "27", "10", "6", "1"
tournamentId: string;
tournamentName: string;
homeTeam: string;
awayTeam: string;
startTime: string;
status: 'not_started' | 'live' | 'finished' | 'cancelled' | 'suspended';
markets?: Market[]; // Full markets (detail page only)
bestOdds?: { home?, draw?, away? }; // Quick odds (list view)
totalVolume?: number;
marketCount?: number;
liquidityScore?: number;
featuredPriority?: number;
}
Market:
{
id: number | string; // Betfair: "1.253197471"
name: string; // "Match Odds", "Over/Under 2.5 Goals"
type?: string; // "match_odds"
outcomes: Outcome[];
}
Outcome:
{
id: number | string;
name: string;
odds: number; // Best back price
trueOdds?: number; // Pre-margin odds
backDepth?: { price: number; size: number }[]; // Exchange depth
layDepth?: { price: number; size: number }[]; // Exchange depth
bookmakers: BookmakerOdds[];
}
4. Provider Toggle Design
Concept: Segment Control Toggle
A segment control (pill toggle) that lets users switch between Betfair (exchange) and Pinnacle (bookmaker) data sources. This is the cleanest pattern for a binary choice.
Where It Appears
The toggle appears in three locations:
-
Sports Page (
/sports) — In the header area, near the sort dropdown. Only visible when viewing Soccer (10), Tennis (6), or Basketball (1). -
Fixture Detail Page (
/fixture/[id]) — In the fixture header, only for the 3 supported sports. -
Live Page (
/live) — Also renders toggle for supported sports.
It does NOT appear on:
- Cricket fixtures (always Betfair — sport not in
PINNACLE_SPORT_IDS) - Other sports without Pinnacle coverage
Deviation from plan: Original plan excluded toggle from /live page. Actual implementation includes it on /live for consistency.
Visual Design
┌──────────────────────────────────┐
│ ┌──────────┐ ┌─────────────┐ │
│ │ Pinnacle │ │ Betfair │ │
│ └──────────┘ └─────────────┘ │
└──────────────────────────────────┘
Actual implementation uses provider names ("Pinnacle" / "Betfair") as labels, not generic "Exchange" / "Bookmaker".
Active state: Solid fill (bg-blue-600 for both Pinnacle and Betfair). Original plan used amber-500 for Bookmaker — simplified to uniform blue for consistency.
Inactive state: bg-slate-800 text-gray-400
Container: bg-slate-800/50 border border-slate-700 rounded-lg p-0.5
Toggle behavior on switch:
- Invalidates all TanStack Query fixture/odds caches (
queryClient.invalidateQueries) - Clears betslip if it has items (odds may differ between providers)
- Shows toast notification: "Provider changed — Betslip cleared"
Mobile Design
On mobile, use a more compact version:
┌─────────────────────────┐
│ ┌──────────┐┌──────────┐│
│ │ Pinnacle ││ Betfair ││
│ └──────────┘└──────────┘│
└─────────────────────────┘
Font size: text-xs on mobile, text-sm on desktop.
Width: min-w-[200px] on desktop, full-width pill on mobile (below sport tabs, above time filters).
State Management
New Zustand store: providerStore.ts
export type Provider = 'pinnacle' | 'betfair';
export const PINNACLE_SPORT_IDS = ['10', '6', '1']; // Soccer, Tennis, Basketball
const DEFAULT_PROVIDERS: Record<string, Provider> = {
'10': 'pinnacle', // Soccer defaults to Pinnacle
'6': 'pinnacle', // Tennis defaults to Pinnacle
'1': 'pinnacle', // Basketball defaults to Pinnacle
};
interface ProviderState {
selectedProviders: Record<string, Provider>;
setProvider: (sportId: string, provider: Provider) => void;
getProvider: (sportId: string) => Provider;
}
export function getEffectiveProvider(
sportId: string | undefined,
selectedProviders: Record<string, Provider>
): Provider {
if (!sportId) return 'betfair';
if (!PINNACLE_SPORT_IDS.includes(sportId)) return 'betfair';
return selectedProviders[sportId] ?? DEFAULT_PROVIDERS[sportId] ?? 'betfair';
}
Key design decisions:
- Provider preference is per-sport, not global. User might want Pinnacle for Soccer but Betfair for Tennis.
- Default:
'pinnacle'for supported sports (Soccer, Tennis, Basketball). Betfair for everything else. Changed from original plan which defaulted to Betfair — Pinnacle is the primary provider for these sports. - Cricket (
27) always returns'betfair'regardless of setting. - Persisted to
localStoragevia Zustandpersistmiddleware. - Store name:
'hannibal-provider' PINNACLE_SPORT_IDSexported as constant for use byProviderToggle(only renders for these sports) anduseProviderhook.getEffectiveProvider()exported as standalone helper for use outside React components.
UX Rationale
- Segment control (not dropdown) because there are only 2 options. Segment controls are immediately scannable and require only 1 tap.
- Per-sport because a Soccer user might prefer Pinnacle's sharp lines while a Tennis user prefers Betfair's exchange liquidity.
- Default to Pinnacle for supported sports (Soccer, Tennis, Basketball) since it's the primary provider. Betfair for all other sports. Changed from original "default to Betfair" plan.
- Uniform blue color: Both toggle options use
bg-blue-600when active. Original amber/gold plan was simplified to uniform blue — the lay column's absence provides sufficient visual differentiation.
5. Component Impact Map
Components That Need Provider Awareness
| Component | File | Change Required |
|---|---|---|
| FixtureCard | betting/FixtureCard.tsx | Uses useProvider(fixture.sportId) hook. Conditionally hides lay ExchangeOddsButton via {!isPinnacle && <ExchangeOddsButton betType="lay" />}. |
| ExchangeGrid | betting/ExchangeGrid.tsx | Data-driven — checks hasLayData = market.outcomes.some(o => o.layDepth?.length > 0). Grid auto-adapts: 3-col (back+lay) when data has lay depth, 2-col (back only) when no lay data. No mode prop — purely data-driven. Back header stays "Back", not renamed to "Price". |
| OddsCell | (inside ExchangeGrid) | Lay cells wrapped in {hasLayData && ...} conditional. No explicit Pinnacle check. |
| QuickOddsButton | (inside fixture/[id]) | Uses useProvider(fixture.sportId). Back button always shown, lay button conditionally hidden via {!isPinnacle && <button>}. Not a separate "single price" component. |
| BetSlip | betting/BetSlip.tsx | Provider is NOT stored in BetSlipItem — implicit from fixture data. No provider badge shown. Min stake uses unified value ($0.10/pointValue) — Pinnacle's $20 min handled backend-side in orderService.ts. Quick stakes same for both providers: [1, 5, 10, 20, 50]. |
| BetSlipItem | (inside BetSlip) | No provider field, no provider badge. Pinnacle bets naturally have betType: 'back' since lay buttons are hidden. |
| SportsPage | app/sports/page.tsx | Render provider toggle for supported sports. Pass provider to fixture fetching hooks. |
| FixturePage | app/fixture/[id]/page.tsx | Render provider toggle. Adjust market display based on provider. |
| LivePage | app/live/page.tsx | Renders ProviderToggle for supported sports (deviation from plan which excluded toggle from live page). Reads provider from store. |
| TournamentFixtures | sports/TournamentFixtures.tsx | No direct change needed. FixtureCard handles it. |
| SportsSidebar | sports/SportsSidebar.tsx | No change needed. |
| Header | layout/Header.tsx | No change needed. |
| BottomNav | layout/BottomNav.tsx | No change needed. |
Components That Do NOT Need Changes
SmartSearchBar.tsx— searches by team/tournament name, provider-agnosticSortDropdown.tsx— sorting is data-agnosticWatchlistToggleButton.tsx— watchlists are provider-agnostic- All
cricket/components — cricket is always Betfair - All
ai/components — AI analysis is provider-agnostic - All
auth/components — no relation to provider - All
admin/components — admin sees both providers
New Components Created
| Component | Purpose |
|---|---|
ProviderToggle | Segment control for switching between Pinnacle and Betfair. Located at components/betting/ProviderToggle.tsx. Only renders for sports in PINNACLE_SPORT_IDS. |
BookmakerGrid and BookmakerOddsButton were NOT created. The recommendation to extend existing components was followed, but using a data-driven approach instead of a mode prop:
ExchangeGriddetectshasLayDatafrom outcome data — no explicit mode prop neededFixtureCardandQuickOddsButtonuseuseProvider(sportId)hook to conditionally hide lay buttons- No new
OddsButtonvariant — existingExchangeOddsButtonused for both providers
6. Odds Display Adaptation
Exchange View (Betfair) — Current
┌─────────────────────────────────────┐
│ Match Odds │ Back │ Lay │
├─────────────────────────────────────┤
│ Arsenal │ 2.10 │ 2.14│
│ │ $12k │ $8k │
├─────────────────────────────────────┤
│ Draw │ 3.40 │ 3.60│
│ │ $3k │ $2k │
├─────────────────────────────────────┤
│ Chelsea │ 3.80 │ 3.95│
│ │ $5k │ $4k │
└─────────────────────────────────────┘
- 3-column grid:
grid-cols-[1fr_60px_60px] - Back (blue) + Lay (pink) columns
- Size shown beneath price
- "Make Offer" button when no liquidity
Bookmaker View (Pinnacle) — Actual Implementation
┌──────────────────────────────┐
│ Match Odds │ Back │
├──────────────────────────────┤
│ Arsenal │ 2.05 │
│ │ │
├──────────────────────────────┤
│ Draw │ 3.35 │
│ │ │
├──────────────────────────────┤
│ Chelsea │ 3.70 │
│ │ │
└──────────────────────────────┘
- 2-column grid:
grid-cols-[1fr_60px](compact:grid-cols-[1fr_52px]) - Header says "Back" (not "Price" — kept consistent with exchange view). Original plan used "Price" with amber styling — simplified to reuse existing blue back styling.
- No size display (Pinnacle data has no
layDepth) - No "Make Offer" — Pinnacle prices are fixed
- Click adds to BetSlip with
betType: 'back'(the only option) - Data-driven:
ExchangeGridcheckshasLayData = market.outcomes.some(o => o.layDepth?.length > 0)— Pinnacle data naturally has no lay depth, so lay column is auto-hidden without any provider flag
FixtureCard Adaptation
Exchange mode (Betfair):
HomeTeam v AwayTeam [1: B|L] [X: B|L] [2: B|L]
Bookmaker mode (Pinnacle):
HomeTeam v AwayTeam [1: 2.05] [X: 3.35] [2: 3.70]
- Single back price per outcome (blue background, same as exchange back buttons). Original plan used amber — kept blue for consistency.
- Lay buttons conditionally hidden via
{!isPinnacle && <ExchangeOddsButton betType="lay" />} - Uses
useProvider(fixture.sportId)hook forisPinnacledetection
Color Scheme — Unified (No Amber)
| Element | Exchange (Betfair) | Bookmaker (Pinnacle) |
|---|---|---|
| Back price bg | bg-blue-500/20 | bg-blue-500/20 (same) |
| Back price text | text-blue-300 | text-blue-300 (same) |
| Back price border | border-blue-500/30 | border-blue-500/30 (same) |
| Lay price bg | bg-pink-500/20 | N/A (hidden — no lay data) |
| Selected bg | bg-blue-600 | bg-blue-600 (same) |
| Header label | "Back" / "Lay" | "Back" (lay column hidden) |
| Grid header bg | bg-blue-500/10 / bg-pink-500/10 | bg-blue-500/10 (lay header hidden) |
| Toggle active | bg-blue-600 | bg-blue-600 (same for both options) |
The original plan used amber-500 for Pinnacle to visually distinguish providers. Actual implementation uses uniform blue for simplicity — the lay column's absence is the visual differentiator. Amber is used only for exposure warning indicators (border-amber-500/30, text-amber-400).
7. Betslip Integration
Provider-Aware BetSlip
The BetSlip must know which provider a bet came from to:
- Enforce min stake: Pinnacle = 20 pts ($20), Betfair = 0.1 pts ($0.10)
- Route to correct API: Different order placement endpoints per provider
- Display correct badge: "Exchange" or "Bookmaker" label
- Restrict bet types: Pinnacle = back only, Betfair = back + lay
BetSlipItem — No Provider Field
Deviation from plan: provider field was NOT added to BetSlipItem. Provider is implicit from fixture data — the backend infers provider from the PAL routing strategy based on sportId.
interface BetSlipItem {
id: string;
fixture: Fixture;
market: Market;
outcome: Outcome;
betType: 'back' | 'lay';
stake: number;
odds: number; // Display odds (with margin applied)
trueOdds?: number; // Original odds before margin
// NO provider field — provider inferred from fixture.sportId by backend PAL
}
addBet() — Accepts Optional Odds
The addBet action accepts an optional odds parameter (used when clicking odds buttons that have a specific price):
addBet: (fixture, market, outcome, betType, odds?) => {
const actualOdds = odds ?? outcome.odds;
// ... create BetSlipItem
}
Pinnacle bets naturally have betType: 'back' because lay buttons are hidden in the UI. No explicit force-to-back logic needed.
BetSlip UI — No Provider Badge
- No provider badge shown. Original plan showed
[Pinnacle]/[Exchange]badges — not implemented. Bet type badge (BACK/LAY) is sufficient since Pinnacle bets only show BACK. - Quick stakes:
[1, 5, 10, 20, 50]for ALL providers (original plan had[20, 50, 100, 200, 500]for Pinnacle — not implemented) - Min stake: Unified
$0.10 / pointValueUsdfor frontend validation. Pinnacle's $20 minimum is enforced backend-side inorderService.tsviaBOOKMAKER_MIN_STAKES.pinnacle = 20.
Order Placement
The POST /orders payload does NOT include a provider field. The backend PAL routing strategy determines the provider from sportId:
{
"fixtureId": "...",
"marketId": "pin_1623781857_p0_ml",
"outcomeId": "pin_1623781857_p0_ml_home",
"stake": 50,
"odds": 2.05,
"betType": "back"
}
The pin_ prefix in marketId/outcomeId identifies Pinnacle orders. PAL routes based on sport routing config.
Mixed Provider BetSlip
Users CAN have Betfair and Pinnacle bets in the same slip. However, the planned features (provider grouping, provider-specific totals, parallel placement) were not implemented for MVP. The betslip clears on provider toggle switch to avoid mixed-provider scenarios.
8. Route/Navigation Changes
No New Routes Needed
The provider toggle does not require new routes. It's a UI-layer concern that:
- Lives in Zustand state (persisted)
- Affects which API endpoints are called
- Affects which UI variant is rendered
URL Parameter Support — Not Implemented
Deviation from plan: Provider is NOT encoded in the browser URL. Originally planned ?provider=pinnacle for shareability.
Actual behavior: Provider is stored only in Zustand state (persisted to localStorage). The provider parameter is passed as a query param in backend API calls (e.g., GET /fixtures/upcoming?sportId=10&provider=pinnacle) but NOT reflected in the browser URL bar.
API Route Changes (Backend)
The frontend's API calls need to support provider selection. Two approaches:
Option A: Query parameter (recommended)
GET /fixtures/upcoming?sportId=10&provider=pinnacle
GET /fixtures/live?sportId=10&provider=pinnacle
GET /fixtures/:id?sportId=10&provider=pinnacle
The backend PAL coordinator already has the concept of providers. Adding a provider query param is natural.
Option B: Separate API endpoints
GET /pinnacle/fixtures/upcoming?sportId=10
GET /pinnacle/fixtures/:id
Option A is better — it keeps the API surface clean and lets the PAL handle routing internally.
oddsApi.ts Changes — Implemented
// All fixture API methods accept optional provider:
fixturesApi.getLive(sportId, sort, provider)
fixturesApi.getUpcoming(sportId, sort, provider)
fixturesApi.getById(fixtureId, sportId, provider)
// -> GET /fixtures/upcoming?sportId=10&sort=featured&provider=pinnacle
The provider parameter flows through:
useProvider(sportId)hook returnsproviderfromproviderStoreuseLiveFixtures/useUpcomingFixtures/useFixtureDetailshooks read provider from store automatically viagetEffectiveProvider(sportId, selectedProviders)— no explicit param needed from callerfixturesApi.getUpcoming(sportId, sort, provider)— adds to query params- Backend
GET /fixtures/upcoming— readsproviderparam, routes through PAL
useProvider.ts Hook — New
export function useProvider(sportId?: string) {
const { selectedProviders } = useProviderStore();
const effectiveProvider = getEffectiveProvider(sportId, selectedProviders);
const isPinnacle = effectiveProvider === 'pinnacle';
const supportsPinnacle = !!sportId && PINNACLE_SPORT_IDS.includes(sportId);
return {
provider: effectiveProvider, // 'pinnacle' | 'betfair'
isPinnacle, // boolean convenience
isBetfair: !isPinnacle, // boolean convenience
supportsPinnacle, // whether sport supports toggle
};
}
Used by FixtureCard, QuickOddsButton, and any component needing provider-aware rendering.
TanStack Query Key Changes
Provider must be part of the query key to prevent cache collisions:
// Base keys unchanged:
fixtureKeys.live() // ['fixtures', 'live']
fixtureKeys.upcoming() // ['fixtures', 'upcoming']
fixtureKeys.detail(id) // ['fixtures', 'detail', id, sportId]
// Provider appended in hook usage (not in key factory):
useQuery({ queryKey: [...fixtureKeys.live(), sportId, sort, provider] })
useQuery({ queryKey: [...fixtureKeys.upcoming(), sportId, sort, provider] })
useQuery({ queryKey: [...fixtureKeys.detail(fixtureId, sportId), provider] })
Provider is appended at the hook call site, not built into the key factory functions. This keeps the key factory generic. Same for fixtureKeys.live(), fixtureKeys.detail(), etc.
9. Key Design Decisions
Decision 1: Extend ExchangeGrid — Data-Driven, No Mode Prop
Decision: Extend ExchangeGrid with data-driven detection, not a mode prop.
Rationale: Instead of an explicit mode: 'exchange' | 'bookmaker' prop, ExchangeGrid checks hasLayData = market.outcomes.some(o => o.layDepth?.length > 0). Pinnacle data naturally has no layDepth, so the grid auto-adapts without any provider awareness. This is more robust — if a future provider also lacks lay data, the grid handles it automatically. Grid template: grid-cols-[1fr_60px_60px] (with lay) vs grid-cols-[1fr_60px] (without lay).
Decision 2: Provider Toggle Placement
Decision: Below sport tabs, inline with the sort dropdown on desktop. Below time filters on mobile.
Rationale: The toggle is sport-contextual. It only appears for supported sports and should be near the sport selection. Placing it in the header would be too prominent for a setting that only affects 3 sports.
Decision 3: Per-Sport vs Global Provider
Decision: Per-sport provider preference, defaulting to Pinnacle for supported sports.
Rationale: Users have different preferences per sport. A Soccer user might want Pinnacle lines, while the same user prefers Betfair exchange for Tennis. The cost of per-sport state is minimal (3 keys in a Record). Default changed from Betfair to Pinnacle for supported sports since Pinnacle is the primary provider for soccer/basketball/tennis.
Decision 4: No Provider in BetSlipItem
Decision: Do NOT store provider in BetSlipItem. Provider is implicit from fixture data.
Rationale: Changed from original plan. Backend PAL routing determines provider from sportId via routing strategy. The pin_ prefix in marketId/outcomeId identifies Pinnacle orders. Storing provider in BetSlipItem would be redundant and create sync issues if routing changes. Betslip is cleared on provider toggle switch to prevent stale data.
Decision 5: Pinnacle Odds Display Color — Uniform Blue
Decision: Standard blue for both Pinnacle and Betfair back prices. No amber color scheme.
Rationale: Changed from original plan (which used amber-500). Uniform blue simplifies the codebase — the absence of the lay column is sufficient visual differentiation. Users identify the provider from the toggle state, not from odds button colors. Amber is reserved for warning indicators (exposure warnings).
Decision 6: No Lay for Pinnacle
Decision: Pinnacle bets are always betType: 'back'. No lay option in UI or BetSlip.
Rationale: Pinnacle is a traditional bookmaker. The concept of "laying" (betting against) doesn't exist. The UI must hide lay buttons and the BetSlip must enforce this.
Decision 7: Cricket is Always Betfair
Decision: Sport ID 27 (Cricket) never shows the provider toggle. Always uses Betfair.
Rationale: Pinnacle doesn't offer cricket markets. The toggle would be confusing with no data behind it. Cricket is also Hannibal's primary sport and has deep Betfair integration (ball-by-ball, toss analysis, session intelligence).
10. Open Questions — Resolved
-
Pinnacle fixture IDs: RESOLVED. Yes, Pinnacle has different IDs (
pin_1623781857). No fixture mapping layer — each provider returns its own fixtures. User sees whichever provider they've selected via toggle. No cross-provider deduplication. -
Market type parity: RESOLVED. Pinnacle offers Moneyline, Spread, Over/Under, Team Total. Mapped to canonical market types:
match_odds,moneyline,spread,over_under. ExchangeGrid renders them identically — market type names are the same in canonical format. -
WebSocket for Pinnacle: RESOLVED. No WebSocket for Pinnacle — poll-based only. Frontend uses TanStack Query
refetchInterval: 5s for fixture detail, 10s for live fixtures, 60s for upcoming. Same polling intervals as Betfair views. -
Pinnacle limits: RESOLVED. Pinnacle
minRiskStake= $20,maxRiskStakevaries per event. Frontend does NOT enforce Pinnacle min stake — uses unified $0.10 min. Backend enforces the $20 minimum inorderService.tsviaBOOKMAKER_MIN_STAKES.pinnacle = 20. -
Mixed provider orders: RESOLVED. Not possible in practice — betslip is cleared when user switches provider toggle. The same fixture from different providers has different IDs, so there's no cross-provider exposure aggregation.
-
Settlement: RESOLVED. Three background jobs in
pinnacleSettlementJob.ts: settled check (60s), pending resolution (15s), running sync (30s). UsesGET /v3/bets?betIds=Xper-order. See PINNACLE_INTEGRATION_PLAN.md Phase 6. -
Margin handling: RESOLVED.
PINNACLE_MARGIN_PERCENTenv var (default0). Pinnacle odds pass through as-is for MVP. BackendmarginService.applyMargin()can apply additional Hannibal margin on top if configured. -
Fixture deduplication: RESOLVED. No deduplication. User sees one provider's fixtures at a time based on toggle selection. Provider toggle switches all data sources — not a side-by-side comparison view.
-
AI analysis context: NOT YET IMPLEMENTED. AI analysis is provider-agnostic for MVP. Future enhancement: include provider-specific odds context.
-
Agent considerations: NOT YET IMPLEMENTED. Agents operate on whatever provider PAL routes to based on sport. No agent-specific provider preference for MVP.