# Frontend Guide Complete guide to the BetTrack dashboard frontend - React, Redux, components, and features. ## Table of Contents - [Architecture Overview](#architecture-overview) - [Technology Stack](#technology-stack) - [Project Structure](#project-structure) - [State Management](#state-management) - [Components](#components) - [Charts & Visualization](#charts--visualization) - [API Integration](#api-integration) - [Styling](#styling) - [Development](#development) - [Building & Deployment](#building--deployment) --- ## Architecture Overview The BetTrack frontend is a modern React SPA (Single Page Application) built with Vite for fast development and optimized production builds. ### Key Features - โš›๏ธ **React 18** with hooks and functional components - ๐Ÿ”„ **Redux Toolkit** for state management - ๐Ÿ“Š **Recharts** for odds movement visualization - ๐ŸŽจ **Tailwind CSS** for utility-first styling - ๐Ÿš€ **Vite** for lightning-fast HMR - ๐Ÿงช **Vitest** for unit testing - ๐Ÿ“ฑ **Responsive design** (mobile-first approach) --- ## Technology Stack ```json { "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.21.0", "@reduxjs/toolkit": "^2.0.1", "react-redux": "^9.0.4", "recharts": "^2.10.3", "date-fns": "^3.0.6", "axios": "^1.6.5" }, "devDependencies": { "vite": "^5.0.10", "vitest": "^1.1.0", "@vitejs/plugin-react": "^4.2.1", "tailwindcss": "^3.4.0", "autoprefixer": "^10.4.16", "postcss": "^8.4.32" } } ``` --- ## Project Structure ``` dashboard/frontend/ โ”œโ”€โ”€ src/ โ”‚ โ”œโ”€โ”€ components/ # React components โ”‚ โ”‚ โ”œโ”€โ”€ BetSlip.jsx # Floating bet slip widget โ”‚ โ”‚ โ”œโ”€โ”€ GameCard.jsx # Individual game display โ”‚ โ”‚ โ”œโ”€โ”€ OddsTable.jsx # Odds comparison table โ”‚ โ”‚ โ””โ”€โ”€ LineChart.jsx # Line movement chart โ”‚ โ”œโ”€โ”€ store/ # Redux store and slices โ”‚ โ”‚ โ”œโ”€โ”€ store.js # Store configuration โ”‚ โ”‚ โ””โ”€โ”€ betSlipSlice.js # Bet slip state management โ”‚ โ”œโ”€โ”€ hooks/ # Custom React hooks โ”‚ โ”‚ โ”œโ”€โ”€ useGames.js # Fetch games from API โ”‚ โ”‚ โ”œโ”€โ”€ useOdds.js # Fetch odds data โ”‚ โ”‚ โ””โ”€โ”€ useTimezone.js # Timezone utilities โ”‚ โ”œโ”€โ”€ utils/ # Utility functions โ”‚ โ”‚ โ”œโ”€โ”€ api.js # Axios instance โ”‚ โ”‚ โ”œโ”€โ”€ formatters.js # Display formatters โ”‚ โ”‚ โ””โ”€โ”€ calculations.js # Odds calculations โ”‚ โ”œโ”€โ”€ pages/ # Page components โ”‚ โ”‚ โ”œโ”€โ”€ HomePage.jsx # Main dashboard โ”‚ โ”‚ โ”œโ”€โ”€ BetsPage.jsx # Bet history โ”‚ โ”‚ โ””โ”€โ”€ GamesPage.jsx # Game browser โ”‚ โ”œโ”€โ”€ App.jsx # Root component โ”‚ โ”œโ”€โ”€ main.jsx # Entry point โ”‚ โ””โ”€โ”€ index.css # Global styles โ”œโ”€โ”€ public/ # Static assets โ”œโ”€โ”€ tests/ # Test files โ”œโ”€โ”€ vite.config.js # Vite configuration โ”œโ”€โ”€ tailwind.config.js # Tailwind configuration โ””โ”€โ”€ package.json ``` --- ## State Management ### Redux Toolkit Setup **Store Configuration** (`src/store/store.js`): ```javascript import { configureStore } from '@reduxjs/toolkit'; import betSlipReducer from './betSlipSlice'; export const store = configureStore({ reducer: { betSlip: betSlipReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { // Ignore date objects in actions/state ignoredActions: ['betSlip/addBet'], ignoredPaths: ['betSlip.bets'], }, }), }); ``` ### Bet Slip Slice **State Structure** (`src/store/betSlipSlice.js`): ```javascript import { createSlice } from '@reduxjs/toolkit'; const betSlipSlice = createSlice({ name: 'betSlip', initialState: { bets: [], // Array of bet objects isOpen: false, // Bet slip visibility totalStake: 0, // Sum of all stakes potentialPayout: 0, // Calculated payout }, reducers: { addBet: (state, action) => { const { gameId, betType, odds, team } = action.payload; // Prevent duplicates const exists = state.bets.find( bet => bet.gameId === gameId && bet.betType === betType ); if (!exists) { state.bets.push({ id: crypto.randomUUID(), gameId, betType, odds, team, stake: 0, addedAt: new Date().toISOString(), }); } }, removeBet: (state, action) => { state.bets = state.bets.filter(bet => bet.id !== action.payload); }, updateStake: (state, action) => { const { id, stake } = action.payload; const bet = state.bets.find(bet => bet.id === id); if (bet) { bet.stake = parseFloat(stake) || 0; } // Recalculate totals state.totalStake = state.bets.reduce((sum, bet) => sum + bet.stake, 0); state.potentialPayout = state.bets.reduce( (sum, bet) => sum + calculatePayout(bet.stake, bet.odds), 0 ); }, clearBets: (state) => { state.bets = []; state.totalStake = 0; state.potentialPayout = 0; }, toggleBetSlip: (state) => { state.isOpen = !state.isOpen; }, }, }); export const { addBet, removeBet, updateStake, clearBets, toggleBetSlip } = betSlipSlice.actions; export default betSlipSlice.reducer; ``` ### Using Redux in Components ```javascript import { useSelector, useDispatch } from 'react-redux'; import { addBet, updateStake } from '../store/betSlipSlice'; function GameCard({ game }) { const dispatch = useDispatch(); const bets = useSelector(state => state.betSlip.bets); const handleAddBet = (betType, odds, team) => { dispatch(addBet({ gameId: game.id, betType, odds, team, })); }; return (
); } ``` --- ## Components ### GameCard Component Displays individual game with odds and betting options. ```jsx // src/components/GameCard.jsx import { format } from 'date-fns'; import { useDispatch } from 'react-redux'; import { addBet } from '../store/betSlipSlice'; export function GameCard({ game }) { const dispatch = useDispatch(); const handleBetClick = (betType, odds, team) => { dispatch(addBet({ gameId: game.id, betType, odds, team, })); }; return (
{/* Game header */}
{format(new Date(game.commenceTime), 'EEE, MMM d โ€ข h:mm a')}
{game.sport}
{/* Teams */}
{/* Away team */}
{game.awayTeam} {game.awayTeam}
{/* Home team */}
{game.homeTeam} {game.homeTeam}
{/* More odds button */}
); } ``` ### BetSlip Component Floating widget for managing bets before submission. ```jsx // src/components/BetSlip.jsx import { useSelector, useDispatch } from 'react-redux'; import { removeBet, updateStake, clearBets, toggleBetSlip } from '../store/betSlipSlice'; import { calculatePayout, calculateImpliedProbability } from '../utils/calculations'; export function BetSlip() { const dispatch = useDispatch(); const { bets, isOpen, totalStake, potentialPayout } = useSelector( state => state.betSlip ); if (!isOpen) { return ( ); } return (
{/* Header */}

Bet Slip ({bets.length})

{/* Bets list */}
{bets.length === 0 ? (

No bets added yet

) : ( bets.map(bet => (
{bet.team}
{bet.betType}
{/* Odds display */}
{bet.odds > 0 ? '+' : ''}{bet.odds} ({calculateImpliedProbability(bet.odds).toFixed(1)}% probability)
{/* Stake input */}
dispatch(updateStake({ id: bet.id, stake: e.target.value }))} placeholder="Enter stake" className="w-full border rounded px-3 py-2 mt-1" />
{/* Potential payout */} {bet.stake > 0 && (
Potential win: ${calculatePayout(bet.stake, bet.odds).toFixed(2)}
)}
)) )}
{/* Footer */} {bets.length > 0 && (
Total Stake: ${totalStake.toFixed(2)}
Potential Payout: ${potentialPayout.toFixed(2)}
)}
); } ``` ### LineChart Component Visualizes odds movement over time using Recharts. ```jsx // src/components/LineChart.jsx import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts'; import { format } from 'date-fns'; export function OddsLineChart({ data, bookmakers }) { // Transform data for Recharts const chartData = data.map(snapshot => ({ timestamp: new Date(snapshot.timestamp).getTime(), ...Object.fromEntries( snapshot.bookmakers.map(bm => [bm.name, bm.price]) ), })); return ( format(timestamp, 'HH:mm')} label={{ value: 'Time', position: 'insideBottom', offset: -5 }} /> format(timestamp, 'MMM d, HH:mm')} formatter={(value, name) => [`${value > 0 ? '+' : ''}${value}`, name]} /> {bookmakers.map((bookmaker, index) => ( ))} ); } const COLORS = ['#2563eb', '#dc2626', '#059669', '#d97706', '#7c3aed']; ``` --- ## API Integration ### Axios Instance **Configuration** (`src/utils/api.js`): ```javascript import axios from 'axios'; const api = axios.create({ baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api', timeout: 10000, headers: { 'Content-Type': 'application/json', }, }); // Request interceptor api.interceptors.request.use( config => { // Add timezone offset to all requests const timezoneOffset = new Date().getTimezoneOffset(); config.params = { ...config.params, timezoneOffset, }; return config; }, error => Promise.reject(error) ); // Response interceptor api.interceptors.response.use( response => response, error => { console.error('API Error:', error.response?.data || error.message); return Promise.reject(error); } ); export default api; ``` ### Custom Hooks **Fetch Games** (`src/hooks/useGames.js`): ```javascript import { useState, useEffect } from 'react'; import api from '../utils/api'; export function useGames(sport, date) { const [games, setGames] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchGames = async () => { try { setLoading(true); const response = await api.get('/games', { params: { sport, date }, }); setGames(response.data); setError(null); } catch (err) { setError(err.message); } finally { setLoading(false); } }; fetchGames(); }, [sport, date]); return { games, loading, error }; } ``` **Fetch Odds** (`src/hooks/useOdds.js`): ```javascript import { useState, useEffect } from 'react'; import api from '../utils/api'; export function useOdds(gameId) { const [odds, setOdds] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { if (!gameId) return; const fetchOdds = async () => { try { const response = await api.get(`/odds/${gameId}`); setOdds(response.data); } catch (err) { console.error('Failed to fetch odds:', err); } finally { setLoading(false); } }; fetchOdds(); // Poll every 30 seconds for live odds const interval = setInterval(fetchOdds, 30000); return () => clearInterval(interval); }, [gameId]); return { odds, loading }; } ``` --- ## Styling ### Tailwind CSS Configuration **tailwind.config.js**: ```javascript /** @type {import('tailwindcss').Config} */ export default { content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ], theme: { extend: { colors: { primary: { 50: '#eff6ff', 100: '#dbeafe', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', }, }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], }, }, }, plugins: [], } ``` ### Global Styles **src/index.css**: ```css @tailwind base; @tailwind components; @tailwind utilities; @layer base { body { @apply bg-gray-50 text-gray-900 font-sans; } } @layer components { .btn-primary { @apply px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200; } .card { @apply bg-white rounded-lg shadow-md p-4; } } ``` --- ## Development ### Local Setup ```bash # Navigate to frontend directory cd dashboard/frontend # Install dependencies npm install # Start development server npm run dev # Server runs on http://localhost:5173 ``` ### Development Server Features - โšก **Hot Module Replacement (HMR)**: Instant updates without full reload - ๐Ÿ” **Source maps**: Easy debugging in browser DevTools - ๐Ÿ“ฆ **Fast refresh**: Preserves component state during edits ### Environment Variables **Create `.env` file**: ```bash VITE_API_URL=http://localhost:3001/api VITE_ENABLE_ANALYTICS=false ``` **Access in code**: ```javascript const apiUrl = import.meta.env.VITE_API_URL; ``` --- ## Building & Deployment ### Production Build ```bash # Build optimized bundle npm run build # Output in dist/ directory # Preview build locally npm run preview ``` ### Build Output ``` dist/ โ”œโ”€โ”€ index.html โ”œโ”€โ”€ assets/ โ”‚ โ”œโ”€โ”€ index-a1b2c3d4.js # Main bundle (minified) โ”‚ โ”œโ”€โ”€ index-e5f6g7h8.css # Styles (minified) โ”‚ โ””โ”€โ”€ logo-i9j0k1l2.svg # Static assets โ””โ”€โ”€ vite.svg ``` ### Docker Build **Dockerfile** (multi-stage build): ```dockerfile # Build stage FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # Production stage FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] ``` ### Nginx Configuration **nginx.conf**: ```nginx server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # SPA routing location / { try_files $uri $uri/ /index.html; } # API proxy location /api { proxy_pass http://backend:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } # Gzip compression gzip on; gzip_types text/plain text/css application/json application/javascript; } ``` --- ## Testing ### Vitest Configuration **vite.config.js**: ```javascript import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { globals: true, environment: 'jsdom', setupFiles: './tests/setup.js', }, }); ``` ### Example Tests ```javascript // tests/components/GameCard.test.jsx import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { GameCard } from '../../src/components/GameCard'; describe('GameCard', () => { const mockGame = { id: '1', homeTeam: 'Lakers', awayTeam: 'Celtics', homeOdds: -150, awayOdds: 130, commenceTime: '2026-01-15T19:30:00Z', }; it('renders team names', () => { render(); expect(screen.getByText('Lakers')).toBeInTheDocument(); expect(screen.getByText('Celtics')).toBeInTheDocument(); }); it('displays odds correctly', () => { render(); expect(screen.getByText('-150')).toBeInTheDocument(); expect(screen.getByText('+130')).toBeInTheDocument(); }); }); ``` ### Run Tests ```bash # Run all tests npm run test # Watch mode npm run test:watch # Coverage report npm run test:coverage ``` --- ## Next Steps - [Backend API Guide](Backend-Guide) - [Database Schema](Database-Guide) - [MCP Server Guide](MCP-Server-Guide)