Skip to main content

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/)

RouteFilePurpose
/page.tsxLanding page — hero, auth-gated features
/sportssports/page.tsxMain sports listing — sport tabs, time filters, tournament groups, fixture cards
/livelive/page.tsxLive in-play events — sport tabs, real-time WebSocket
/fixture/[id]fixture/[id]/page.tsxFixture detail — markets, ExchangeGrid, QuickOddsButton, cricket panels
/my-betsmy-bets/page.tsxOrder history
/walletwallet/page.tsxWallet / balance
/watchlistswatchlists/page.tsxSaved watchlists
/agentsagents/page.tsxAI agents listing
/admin/*admin/...Admin panel (16 sub-pages)
/agent/*agent/...Agent portal (5 sub-pages)
/reports/*reports/...Statement, PnL, results, activity
/settingssettings/page.tsxUser settings
/joinjoin/page.tsxReferral 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/)

DirectoryPurposeKey Components
betting/Core betting UIBetSlip.tsx, ExchangeGrid.tsx, FixtureCard.tsx, OddsButton.tsx, FixtureExposure.tsx
sports/Sports browsingSportsSidebar.tsx, TournamentFixtures.tsx, SmartSearchBar.tsx, SortDropdown.tsx, WatchlistToggleButton.tsx
cricket/Cricket-specificBallByBallPanel.tsx, CricketAnalysisPanel.tsx, SessionPanel.tsx, TossAnalysisPanel.tsx
ai/AI analysisAIFixtureButton.tsx, AIFixturePanel.tsx, AITournamentButton.tsx, etc.
ai-chat/Conversational AIAIChatPanel.tsx, CommandChat.tsx, MarkdownMessage.tsx
auth/AuthenticationAuthGuard.tsx, ConnectButton.tsx, LoginModal.tsx
layout/Shell chromeHeader.tsx, BottomNav.tsx
ui/PrimitivesErrorBoundary.tsx, Toast.tsx, TeamLogo.tsx, PlayerImage.tsx
providers/Context providersWeb3AuthProvider.tsx, WebSocketProvider.tsx

Zustand Stores (frontend/src/store/)

StoreFilePurposeKey State
AuthauthStore.tsUser sessionuser: User, isAuthenticated, balancePoints
BetSlipbetSlipStore.tsBet selectionsitems: BetSlipItem[], isOpen, addBet(), removeBet()
AI AnalysisaiAnalysisStore.tsAI panel stateAnalysis panels, docked panels
AI ChataiChatStore.tsChat stateMessages, conversation
AI CommandaiCommandStore.tsCommand modeCommand execution state
NotificationsnotificationStore.tsToasts & alertsToast queue, notification center
Ball-by-BallballByBallStore.tsCricket liveBall updates, match context

Custom Hooks (frontend/src/hooks/)

HookPurposeAPI Calls
useFixtures.tsFixture data fetchingfixturesApi.getLive(), fixturesApi.getUpcoming(), fixturesApi.getById()
useSports.tsSports & tournament listssportsApi.getAll(), tournamentsApi.getBySport()
useWebSocket.tsReal-time updatesSocket.IO: odds:update, score:update, balance:update, settlement:update
useAuth.tsAuth stateToken management, Web3Auth
useCurrency.tsCurrency formattingAgent display currency
useAI.tsAI analysis & watchlistsAI endpoints, watchlist CRUD

API Layer (frontend/src/lib/)

FilePurposeEndpoints
api.tsAxios instance, JWT interceptor, token refreshBase client, authApi, userApi
oddsApi.tsOdds/fixtures/sportsGET /odds/sports/all, GET /odds/tournaments/:sportId, GET /fixtures/live, GET /fixtures/upcoming, GET /fixtures/:id, GET /odds/delta/:seconds
cricketApi.tsCricket dataCricket-specific endpoints
aiApi.tsAI analysisFixture analysis, tournament analysis
aiChatApi.tsChat AIChat messages
adminApi.tsAdmin endpointsUser mgmt, orders, settlements
agentApi.tsAgent endpointsAgent portal

2. Current Sports/Odds Display Patterns

Fixture Listing Flow

  1. /sports page selects sport (default: Cricket 27) and time filter (default: next7days)
  2. Calls useUpcomingFixtures(sportId, sort) -> GET /fixtures/upcoming?sportId=27&sort=featured
  3. Calls useLiveFixtures(sportId, sort) -> GET /fixtures/live?sportId=27&sort=liveFirst
  4. Combines, deduplicates, filters by time, groups by tournament -> TournamentFixtures
  5. Each fixture rendered as FixtureCard with 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 ExchangeOddsButton internally (back = blue, lay = pink)
  • Shows backDepth[0].price and layDepth[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])

  1. Header: Team names, scores (live), tournament, volume
  2. QuickOddsButton: Back|Lay pairs for main 1X2 market
  3. FixtureExposure: User's current position
  4. Cricket panels: Toss analysis, session intelligence, ball-by-ball (sport-specific)
  5. 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 OddsCell with price + size

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.ts with persist middleware (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) or POST /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:

  1. Sports Page (/sports) — In the header area, near the sort dropdown. Only visible when viewing Soccer (10), Tennis (6), or Basketball (1).

  2. Fixture Detail Page (/fixture/[id]) — In the fixture header, only for the 3 supported sports.

  3. 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 localStorage via Zustand persist middleware.
  • Store name: 'hannibal-provider'
  • PINNACLE_SPORT_IDS exported as constant for use by ProviderToggle (only renders for these sports) and useProvider hook.
  • 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-600 when 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

ComponentFileChange Required
FixtureCardbetting/FixtureCard.tsxUses useProvider(fixture.sportId) hook. Conditionally hides lay ExchangeOddsButton via {!isPinnacle && <ExchangeOddsButton betType="lay" />}.
ExchangeGridbetting/ExchangeGrid.tsxData-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.
BetSlipbetting/BetSlip.tsxProvider 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.
SportsPageapp/sports/page.tsxRender provider toggle for supported sports. Pass provider to fixture fetching hooks.
FixturePageapp/fixture/[id]/page.tsxRender provider toggle. Adjust market display based on provider.
LivePageapp/live/page.tsxRenders ProviderToggle for supported sports (deviation from plan which excluded toggle from live page). Reads provider from store.
TournamentFixturessports/TournamentFixtures.tsxNo direct change needed. FixtureCard handles it.
SportsSidebarsports/SportsSidebar.tsxNo change needed.
Headerlayout/Header.tsxNo change needed.
BottomNavlayout/BottomNav.tsxNo change needed.

Components That Do NOT Need Changes

  • SmartSearchBar.tsx — searches by team/tournament name, provider-agnostic
  • SortDropdown.tsx — sorting is data-agnostic
  • WatchlistToggleButton.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

ComponentPurpose
ProviderToggleSegment 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:

  • ExchangeGrid detects hasLayData from outcome data — no explicit mode prop needed
  • FixtureCard and QuickOddsButton use useProvider(sportId) hook to conditionally hide lay buttons
  • No new OddsButton variant — existing ExchangeOddsButton used 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: ExchangeGrid checks hasLayData = 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 for isPinnacle detection

Color Scheme — Unified (No Amber)

ElementExchange (Betfair)Bookmaker (Pinnacle)
Back price bgbg-blue-500/20bg-blue-500/20 (same)
Back price texttext-blue-300text-blue-300 (same)
Back price borderborder-blue-500/30border-blue-500/30 (same)
Lay price bgbg-pink-500/20N/A (hidden — no lay data)
Selected bgbg-blue-600bg-blue-600 (same)
Header label"Back" / "Lay""Back" (lay column hidden)
Grid header bgbg-blue-500/10 / bg-pink-500/10bg-blue-500/10 (lay header hidden)
Toggle activebg-blue-600bg-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:

  1. Enforce min stake: Pinnacle = 20 pts ($20), Betfair = 0.1 pts ($0.10)
  2. Route to correct API: Different order placement endpoints per provider
  3. Display correct badge: "Exchange" or "Bookmaker" label
  4. 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 / pointValueUsd for frontend validation. Pinnacle's $20 minimum is enforced backend-side in orderService.ts via BOOKMAKER_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:

  1. useProvider(sportId) hook returns provider from providerStore
  2. useLiveFixtures / useUpcomingFixtures / useFixtureDetails hooks read provider from store automatically via getEffectiveProvider(sportId, selectedProviders) — no explicit param needed from caller
  3. fixturesApi.getUpcoming(sportId, sort, provider) — adds to query params
  4. Backend GET /fixtures/upcoming — reads provider param, 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

  1. 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.

  2. 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.

  3. 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.

  4. Pinnacle limits: RESOLVED. Pinnacle minRiskStake = $20, maxRiskStake varies per event. Frontend does NOT enforce Pinnacle min stake — uses unified $0.10 min. Backend enforces the $20 minimum in orderService.ts via BOOKMAKER_MIN_STAKES.pinnacle = 20.

  5. 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.

  6. Settlement: RESOLVED. Three background jobs in pinnacleSettlementJob.ts: settled check (60s), pending resolution (15s), running sync (30s). Uses GET /v3/bets?betIds=X per-order. See PINNACLE_INTEGRATION_PLAN.md Phase 6.

  7. Margin handling: RESOLVED. PINNACLE_MARGIN_PERCENT env var (default 0). Pinnacle odds pass through as-is for MVP. Backend marginService.applyMargin() can apply additional Hannibal margin on top if configured.

  8. 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.

  9. AI analysis context: NOT YET IMPLEMENTED. AI analysis is provider-agnostic for MVP. Future enhancement: include provider-specific odds context.

  10. Agent considerations: NOT YET IMPLEMENTED. Agents operate on whatever provider PAL routes to based on sport. No agent-specific provider preference for MVP.