Best landing page yet, lost logged in links to lists of bets

This commit is contained in:
2026-01-06 00:23:17 -06:00
parent f50eb2ba3b
commit eac0d6e970
67 changed files with 3932 additions and 99 deletions

View File

@ -1,15 +1,23 @@
import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'react-hot-toast'
import { useAuthStore } from './store'
import { Home } from './pages/Home'
import { Login } from './pages/Login'
import { Register } from './pages/Register'
import { Dashboard } from './pages/Dashboard'
import { Profile } from './pages/Profile'
import { BetMarketplace } from './pages/BetMarketplace'
import { BetDetails } from './pages/BetDetails'
import { MyBets } from './pages/MyBets'
import { Wallet } from './pages/Wallet'
import { SportEvents } from './pages/SportEvents'
import { Admin } from './pages/Admin'
import { Sports } from './pages/Sports'
import { Live } from './pages/Live'
import { NewBets } from './pages/NewBets'
import { Watchlist } from './pages/Watchlist'
import { HowItWorks } from './pages/HowItWorks'
const queryClient = new QueryClient({
defaultOptions: {
@ -25,6 +33,13 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />
}
function AdminRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, user } = useAuthStore()
if (!isAuthenticated) return <Navigate to="/login" />
if (!user?.is_admin) return <Navigate to="/" />
return <>{children}</>
}
function App() {
const { loadUser } = useAuthStore()
@ -34,17 +49,23 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<Toaster position="top-right" />
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/sports" element={<Sports />} />
<Route path="/live" element={<Live />} />
<Route path="/new-bets" element={<NewBets />} />
<Route path="/watchlist" element={<Watchlist />} />
<Route path="/how-it-works" element={<HowItWorks />} />
<Route
path="/dashboard"
path="/profile"
element={
<PrivateRoute>
<Dashboard />
<Profile />
</PrivateRoute>
}
/>
@ -56,6 +77,14 @@ function App() {
</PrivateRoute>
}
/>
<Route
path="/sport-events"
element={
<PrivateRoute>
<SportEvents />
</PrivateRoute>
}
/>
<Route
path="/bets/:id"
element={
@ -80,6 +109,14 @@ function App() {
</PrivateRoute>
}
/>
<Route
path="/admin"
element={
<AdminRoute>
<Admin />
</AdminRoute>
}
/>
</Routes>
</BrowserRouter>
</QueryClientProvider>

60
frontend/src/api/admin.ts Normal file
View File

@ -0,0 +1,60 @@
import { apiClient } from './client'
import type { SportEvent } from '@/types/sport-event'
export interface AdminSettings {
id: number
default_house_commission_percent: number
default_min_bet_amount: number
default_max_bet_amount: number
default_min_spread: number
default_max_spread: number
spread_increment: number
platform_name: string
maintenance_mode: boolean
}
export interface CreateEventData {
sport: string
home_team: string
away_team: string
official_spread: number
game_time: string
venue?: string
league?: string
min_spread?: number
max_spread?: number
min_bet_amount?: number
max_bet_amount?: number
}
export const adminApi = {
getSettings: async (): Promise<AdminSettings> => {
const response = await apiClient.get<AdminSettings>('/api/v1/admin/settings')
return response.data
},
updateSettings: async (settings: Partial<AdminSettings>): Promise<AdminSettings> => {
const response = await apiClient.patch<AdminSettings>('/api/v1/admin/settings', settings)
return response.data
},
createEvent: async (eventData: CreateEventData): Promise<SportEvent> => {
const response = await apiClient.post<SportEvent>('/api/v1/admin/events', eventData)
return response.data
},
getEvents: async (params?: { skip?: number; limit?: number }): Promise<SportEvent[]> => {
const queryParams = new URLSearchParams()
if (params?.skip !== undefined) queryParams.append('skip', params.skip.toString())
if (params?.limit !== undefined) queryParams.append('limit', params.limit.toString())
const url = `/api/v1/admin/events${queryParams.toString() ? `?${queryParams}` : ''}`
const response = await apiClient.get<SportEvent[]>(url)
return response.data
},
deleteEvent: async (eventId: number): Promise<{ message: string }> => {
const response = await apiClient.delete<{ message: string }>(`/api/v1/admin/events/${eventId}`)
return response.data
},
}

View File

@ -0,0 +1,36 @@
import { apiClient } from './client'
import type { SportEvent, SportEventWithBets } from '@/types/sport-event'
export const sportEventsApi = {
// Public endpoints (no auth required)
getPublicEvents: async (params?: { skip?: number; limit?: number }): Promise<SportEvent[]> => {
const queryParams = new URLSearchParams()
if (params?.skip !== undefined) queryParams.append('skip', params.skip.toString())
if (params?.limit !== undefined) queryParams.append('limit', params.limit.toString())
const url = `/api/v1/sport-events/public${queryParams.toString() ? `?${queryParams}` : ''}`
const response = await apiClient.get<SportEvent[]>(url)
return response.data
},
getPublicEventWithGrid: async (eventId: number): Promise<SportEventWithBets> => {
const response = await apiClient.get<SportEventWithBets>(`/api/v1/sport-events/public/${eventId}`)
return response.data
},
// Authenticated endpoints
getUpcomingEvents: async (params?: { skip?: number; limit?: number }): Promise<SportEvent[]> => {
const queryParams = new URLSearchParams()
if (params?.skip !== undefined) queryParams.append('skip', params.skip.toString())
if (params?.limit !== undefined) queryParams.append('limit', params.limit.toString())
const url = `/api/v1/sport-events${queryParams.toString() ? `?${queryParams}` : ''}`
const response = await apiClient.get<SportEvent[]>(url)
return response.data
},
getEventWithGrid: async (eventId: number): Promise<SportEventWithBets> => {
const response = await apiClient.get<SportEventWithBets>(`/api/v1/sport-events/${eventId}`)
return response.data
},
}

View File

@ -0,0 +1,24 @@
import { apiClient } from './client'
import type { SpreadBet, SpreadBetCreate, SpreadBetDetail } from '@/types/spread-bet'
export const spreadBetsApi = {
createBet: async (betData: SpreadBetCreate): Promise<SpreadBet> => {
const response = await apiClient.post<SpreadBet>('/api/v1/spread-bets', betData)
return response.data
},
takeBet: async (betId: number): Promise<SpreadBet> => {
const response = await apiClient.post<SpreadBet>(`/api/v1/spread-bets/${betId}/take`)
return response.data
},
getMyActiveBets: async (): Promise<SpreadBetDetail[]> => {
const response = await apiClient.get<SpreadBetDetail[]>('/api/v1/spread-bets/my-active')
return response.data
},
cancelBet: async (betId: number): Promise<{ message: string }> => {
const response = await apiClient.delete<{ message: string }>(`/api/v1/spread-bets/${betId}`)
return response.data
},
}

View File

@ -18,7 +18,7 @@ export const LoginForm = () => {
try {
await login(email, password)
navigate('/dashboard')
navigate('/')
} catch (err: any) {
setError(err.response?.data?.detail || 'Login failed. Please try again.')
} finally {

View File

@ -20,7 +20,7 @@ export const RegisterForm = () => {
try {
await register(email, username, password, displayName || undefined)
navigate('/dashboard')
navigate('/')
} catch (err: any) {
setError(err.response?.data?.detail || 'Registration failed. Please try again.')
} finally {

View File

@ -0,0 +1,151 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Modal } from '@/components/common/Modal'
import { Button } from '@/components/common/Button'
import { Input } from '@/components/common/Input'
import { spreadBetsApi } from '@/api/spread-bets'
import { TeamSide } from '@/types/spread-bet'
import type { SportEventWithBets } from '@/types/sport-event'
import { toast } from 'react-hot-toast'
interface CreateSpreadBetModalProps {
isOpen: boolean
onClose: () => void
event: SportEventWithBets
spread: number
}
export const CreateSpreadBetModal = ({
isOpen,
onClose,
event,
spread,
}: CreateSpreadBetModalProps) => {
const [stakeAmount, setStakeAmount] = useState('')
const queryClient = useQueryClient()
const createBetMutation = useMutation({
mutationFn: spreadBetsApi.createBet,
onSuccess: () => {
toast.success('Bet created successfully!')
queryClient.invalidateQueries({ queryKey: ['sport-event', event.id] })
queryClient.invalidateQueries({ queryKey: ['my-spread-bets'] })
onClose()
setStakeAmount('')
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to create bet')
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const amount = parseFloat(stakeAmount)
if (isNaN(amount) || amount < event.min_bet_amount || amount > event.max_bet_amount) {
toast.error(`Stake must be between $${event.min_bet_amount} and $${event.max_bet_amount}`)
return
}
// Determine which team based on spread sign
// Positive spread = home team underdog, negative spread = home team favorite
const team = spread >= 0 ? TeamSide.HOME : TeamSide.AWAY
createBetMutation.mutate({
event_id: event.id,
spread: Math.abs(spread),
team,
stake_amount: amount,
})
}
const interpretSpread = () => {
if (spread === 0) {
return {
team: event.home_team,
description: 'Pick Em - must win outright',
}
} else if (spread > 0) {
return {
team: event.home_team,
description: `can lose by up to ${spread - 0.5} points and still win`,
}
} else {
return {
team: event.away_team,
description: `must win by more than ${Math.abs(spread)} points`,
}
}
}
const interpretation = interpretSpread()
return (
<Modal isOpen={isOpen} onClose={onClose} title="Create Spread Bet">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-blue-50 p-4 rounded-lg">
<h3 className="font-semibold text-gray-900 mb-2">Your Bet</h3>
<p className="text-sm text-gray-700">
<strong>{interpretation.team}</strong> {spread > 0 ? '+' : ''}
{spread}
</p>
<p className="text-xs text-gray-600 mt-1">{interpretation.description}</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="font-semibold text-gray-900 mb-2">How it works</h4>
<ul className="text-sm text-gray-600 space-y-1">
<li> You stake your chosen amount</li>
<li> Another user takes the opposite side with equal stake</li>
<li> House takes 10% commission from the pot</li>
<li> Winner receives remaining 90%</li>
</ul>
</div>
<Input
label="Stake Amount"
type="number"
step="0.01"
min={event.min_bet_amount}
max={event.max_bet_amount}
value={stakeAmount}
onChange={(e) => setStakeAmount(e.target.value)}
placeholder={`$${event.min_bet_amount} - $${event.max_bet_amount}`}
required
/>
<div className="text-sm text-gray-600">
<p>
Min: ${event.min_bet_amount} | Max: ${event.max_bet_amount}
</p>
{stakeAmount && !isNaN(parseFloat(stakeAmount)) && (
<p className="mt-2 font-semibold text-gray-900">
Potential Payout: $
{(parseFloat(stakeAmount) * 2 * 0.9).toFixed(2)} (90% of $
{(parseFloat(stakeAmount) * 2).toFixed(2)} pot)
</p>
)}
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={createBetMutation.isPending}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={createBetMutation.isPending}
className="flex-1"
>
{createBetMutation.isPending ? 'Creating...' : 'Create Bet'}
</Button>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,406 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store'
import type { SportEventWithBets, SpreadGridBet } from '@/types/sport-event'
import { CreateSpreadBetModal } from './CreateSpreadBetModal'
import { TakeBetModal } from './TakeBetModal'
import { Button } from '@/components/common/Button'
interface SpreadGridProps {
event: SportEventWithBets
onBetCreated?: () => void
onBetTaken?: () => void
}
interface SpreadDetailModalProps {
isOpen: boolean
onClose: () => void
spread: number
bets: SpreadGridBet[]
event: SportEventWithBets
onCreateBet: () => void
onTakeBet: (betId: number) => void
isAuthenticated: boolean
}
const SpreadDetailModal = ({
isOpen,
onClose,
spread,
bets,
event,
onCreateBet,
onTakeBet,
isAuthenticated,
}: SpreadDetailModalProps) => {
if (!isOpen) return null
const openBets = bets.filter((b) => b.status === 'open')
const matchedBets = bets.filter((b) => b.status === 'matched')
const takeable = openBets.filter((b) => b.can_take)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden">
<div className="p-4 border-b bg-gray-50">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">
Spread: {spread > 0 ? '+' : ''}{spread}
</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-2xl leading-none"
>
×
</button>
</div>
<p className="text-sm text-gray-600 mt-1">
{event.home_team} vs {event.away_team}
</p>
</div>
<div className="p-4 overflow-y-auto max-h-[50vh]">
{/* Login prompt for non-authenticated users */}
{!isAuthenticated ? (
<div className="text-center py-6">
<div className="bg-blue-50 rounded-lg p-6 mb-4">
<h4 className="text-lg font-semibold text-gray-900 mb-2">
Login to Bet
</h4>
<p className="text-gray-600 mb-4">
Create an account or log in to place bets at this spread
</p>
<div className="flex gap-3 justify-center">
<Link to="/login">
<Button variant="secondary">Log In</Button>
</Link>
<Link to="/register">
<Button>Sign Up</Button>
</Link>
</div>
</div>
{/* Still show existing bets for visibility */}
{bets.length > 0 && (
<div className="mt-4 text-left">
<h4 className="font-medium text-gray-700 mb-2 text-sm">
Current Bets at this Spread ({bets.length})
</h4>
<div className="space-y-2">
{bets.slice(0, 3).map((bet) => (
<div
key={bet.bet_id}
className="p-3 bg-gray-50 border border-gray-200 rounded-lg text-sm"
>
<span className="font-medium">${bet.stake.toFixed(2)}</span>
<span className="text-gray-500 ml-2">by {bet.creator_username}</span>
<span className={`ml-2 px-2 py-0.5 rounded-full text-xs ${
bet.status === 'open' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{bet.status}
</span>
</div>
))}
{bets.length > 3 && (
<p className="text-xs text-gray-500 text-center">
+{bets.length - 3} more bet{bets.length - 3 !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
)}
</div>
) : (
<>
{/* Create new bet button */}
<button
onClick={onCreateBet}
className="w-full mb-4 py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
+ Create New Bet at {spread > 0 ? '+' : ''}{spread}
</button>
{/* Available bets to take */}
{takeable.length > 0 && (
<div className="mb-4">
<h4 className="font-medium text-gray-700 mb-2">
Available to Take ({takeable.length})
</h4>
<div className="space-y-2">
{takeable.map((bet) => (
<div
key={bet.bet_id}
className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg"
>
<div>
<p className="font-medium text-gray-900">
${bet.stake.toFixed(2)}
</p>
<p className="text-sm text-gray-600">
by {bet.creator_username}
</p>
</div>
<button
onClick={() => onTakeBet(bet.bet_id)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
>
Take Bet
</button>
</div>
))}
</div>
</div>
)}
{/* Your open bets */}
{openBets.filter((b) => !b.can_take).length > 0 && (
<div className="mb-4">
<h4 className="font-medium text-gray-700 mb-2">Your Open Bets</h4>
<div className="space-y-2">
{openBets
.filter((b) => !b.can_take)
.map((bet) => (
<div
key={bet.bet_id}
className="flex items-center justify-between p-3 bg-blue-50 border border-blue-200 rounded-lg"
>
<div>
<p className="font-medium text-gray-900">
${bet.stake.toFixed(2)}
</p>
<p className="text-sm text-gray-600">Waiting for opponent</p>
</div>
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
Open
</span>
</div>
))}
</div>
</div>
)}
{/* Matched bets */}
{matchedBets.length > 0 && (
<div>
<h4 className="font-medium text-gray-700 mb-2">
Matched Bets ({matchedBets.length})
</h4>
<div className="space-y-2">
{matchedBets.map((bet) => (
<div
key={bet.bet_id}
className="flex items-center justify-between p-3 bg-gray-50 border border-gray-200 rounded-lg"
>
<div>
<p className="font-medium text-gray-900">
${bet.stake.toFixed(2)}
</p>
<p className="text-sm text-gray-600">
by {bet.creator_username}
</p>
</div>
<span className="px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-sm">
Matched
</span>
</div>
))}
</div>
</div>
)}
{bets.length === 0 && (
<p className="text-center text-gray-500 py-4">
No bets at this spread yet. Be the first!
</p>
)}
</>
)}
</div>
</div>
</div>
)
}
export const SpreadGrid = ({ event, onBetCreated, onBetTaken }: SpreadGridProps) => {
const { isAuthenticated } = useAuthStore()
const [selectedSpread, setSelectedSpread] = useState<number | null>(null)
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [selectedBetId, setSelectedBetId] = useState<number | null>(null)
const [isTakeModalOpen, setIsTakeModalOpen] = useState(false)
const handleSpreadClick = (spread: number) => {
setSelectedSpread(spread)
setIsDetailModalOpen(true)
}
const handleCreateBet = () => {
setIsDetailModalOpen(false)
setIsCreateModalOpen(true)
}
const handleTakeBet = (betId: number) => {
setSelectedBetId(betId)
setIsDetailModalOpen(false)
setIsTakeModalOpen(true)
}
const handleCreateBetClose = () => {
setIsCreateModalOpen(false)
setSelectedSpread(null)
onBetCreated?.()
}
const handleTakeBetClose = () => {
setIsTakeModalOpen(false)
setSelectedBetId(null)
setSelectedSpread(null)
onBetTaken?.()
}
const handleDetailModalClose = () => {
setIsDetailModalOpen(false)
setSelectedSpread(null)
}
const sortedSpreads = Object.keys(event.spread_grid)
.map(Number)
.sort((a, b) => a - b)
return (
<div className="space-y-6">
<div className="bg-white p-6 rounded-lg shadow">
<div className="mb-4">
<h3 className="text-xl font-semibold text-gray-900">
{event.home_team} vs {event.away_team}
</h3>
<p className="text-gray-600 mt-1">
Official Line: {event.home_team} {event.official_spread > 0 ? '+' : ''}
{event.official_spread} / {event.away_team}{' '}
{-event.official_spread > 0 ? '+' : ''}
{-event.official_spread}
</p>
<p className="text-sm text-gray-500 mt-1">
{new Date(event.game_time).toLocaleString()}
</p>
</div>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-2">
{sortedSpreads.map((spread) => {
const bets = event.spread_grid[spread.toString()] || []
const isOfficialLine = spread === event.official_spread
const openBets = bets.filter((b) => b.status === 'open')
const takeableBets = openBets.filter((b) => b.can_take)
const hasActivity = bets.length > 0
return (
<button
key={spread}
onClick={() => handleSpreadClick(spread)}
className={`
relative p-3 rounded-lg border-2 transition-all cursor-pointer
${
isOfficialLine
? 'border-yellow-400 bg-yellow-50'
: 'border-gray-200'
}
${
takeableBets.length > 0
? 'bg-green-50 hover:bg-green-100 border-green-300'
: hasActivity
? 'bg-blue-50 hover:bg-blue-100 border-blue-200'
: 'bg-white hover:bg-gray-50'
}
`}
>
<div className="text-center">
<div className="font-bold text-lg">
{spread > 0 ? '+' : ''}
{spread}
</div>
{hasActivity && (
<div className="mt-1">
{takeableBets.length > 0 ? (
<div className="text-xs font-semibold text-green-600">
{takeableBets.length} open
</div>
) : (
<div className="text-xs text-gray-500">
{bets.length} bet{bets.length !== 1 ? 's' : ''}
</div>
)}
</div>
)}
{isOfficialLine && (
<div className="absolute -top-1 -right-1">
<span className="inline-block px-1 py-0.5 text-xs font-bold bg-yellow-400 text-yellow-900 rounded">
</span>
</div>
)}
</div>
</button>
)
})}
</div>
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<h4 className="font-semibold text-sm text-gray-700 mb-2">Legend:</h4>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2 text-xs">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-white border-2 border-gray-200 rounded"></div>
<span>No bets</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-green-50 border-2 border-green-300 rounded"></div>
<span>Open bets available</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-blue-50 border-2 border-blue-200 rounded"></div>
<span>Has bets (yours/matched)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-yellow-50 border-2 border-yellow-400 rounded"></div>
<span>Official line</span>
</div>
</div>
</div>
</div>
{/* Spread Detail Modal */}
{selectedSpread !== null && (
<SpreadDetailModal
isOpen={isDetailModalOpen}
onClose={handleDetailModalClose}
spread={selectedSpread}
bets={event.spread_grid[selectedSpread.toString()] || []}
event={event}
onCreateBet={handleCreateBet}
onTakeBet={handleTakeBet}
isAuthenticated={isAuthenticated}
/>
)}
{/* Create Bet Modal */}
{selectedSpread !== null && (
<CreateSpreadBetModal
isOpen={isCreateModalOpen}
onClose={handleCreateBetClose}
event={event}
spread={selectedSpread}
/>
)}
{/* Take Bet Modal */}
{selectedSpread !== null && selectedBetId !== null && (
<TakeBetModal
isOpen={isTakeModalOpen}
onClose={handleTakeBetClose}
betId={selectedBetId}
spread={selectedSpread}
event={event}
/>
)}
</div>
)
}

View File

@ -0,0 +1,134 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Modal } from '@/components/common/Modal'
import { Button } from '@/components/common/Button'
import { spreadBetsApi } from '@/api/spread-bets'
import type { SportEventWithBets } from '@/types/sport-event'
import { toast } from 'react-hot-toast'
interface TakeBetModalProps {
isOpen: boolean
onClose: () => void
betId: number
spread: number
event: SportEventWithBets
}
export const TakeBetModal = ({
isOpen,
onClose,
betId,
spread,
event,
}: TakeBetModalProps) => {
const queryClient = useQueryClient()
const betInfo = event.spread_grid[spread.toString()]
const takeBetMutation = useMutation({
mutationFn: () => spreadBetsApi.takeBet(betId),
onSuccess: () => {
toast.success('Bet taken successfully!')
queryClient.invalidateQueries({ queryKey: ['sport-event', event.id] })
queryClient.invalidateQueries({ queryKey: ['my-spread-bets'] })
onClose()
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to take bet')
},
})
if (!betInfo) return null
const handleTakeBet = () => {
takeBetMutation.mutate()
}
const getOppositeInterpretation = () => {
if (spread === 0) {
return {
team: event.away_team,
description: 'Pick Em - must win outright',
}
} else if (spread > 0) {
// Creator has home team +spread, taker gets away team -spread
return {
team: event.away_team,
description: `must win by more than ${spread} points`,
}
} else {
// Creator has away team -spread, taker gets home team +spread
return {
team: event.home_team,
description: `can lose by up to ${Math.abs(spread) - 0.5} points and still win`,
}
}
}
const interpretation = getOppositeInterpretation()
return (
<Modal isOpen={isOpen} onClose={onClose} title="Take Bet">
<div className="space-y-4">
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-semibold text-gray-900 mb-2">Their Bet</h3>
<p className="text-sm text-gray-700">
<strong>{betInfo.creator_username}</strong> is betting{' '}
<strong>${betInfo.stake}</strong> on{' '}
<strong>
{spread >= 0 ? event.home_team : event.away_team} {spread > 0 ? '+' : ''}
{spread}
</strong>
</p>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<h3 className="font-semibold text-gray-900 mb-2">Your Bet (Opposite Side)</h3>
<p className="text-sm text-gray-700">
<strong>{interpretation.team}</strong> {-spread > 0 ? '+' : ''}
{-spread}
</p>
<p className="text-xs text-gray-600 mt-1">{interpretation.description}</p>
<p className="text-sm font-semibold text-gray-900 mt-2">
Your stake: ${betInfo.stake}
</p>
</div>
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
<h4 className="font-semibold text-gray-900 mb-2">Payout Details</h4>
<div className="text-sm text-gray-700 space-y-1">
<p>Total pot: ${(betInfo.stake * 2).toFixed(2)}</p>
<p>House commission (10%): ${(betInfo.stake * 2 * 0.1).toFixed(2)}</p>
<p className="font-bold text-green-600">
Winner receives: ${(betInfo.stake * 2 * 0.9).toFixed(2)}
</p>
</div>
</div>
<div className="bg-red-50 p-3 rounded-lg border border-red-200">
<p className="text-sm text-red-800">
By taking this bet, <strong>${betInfo.stake}</strong> will be locked from
your wallet until the event is settled.
</p>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={takeBetMutation.isPending}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleTakeBet}
disabled={takeBetMutation.isPending}
className="flex-1"
>
{takeBetMutation.isPending ? 'Taking Bet...' : `Take Bet ($${betInfo.stake})`}
</Button>
</div>
</div>
</Modal>
)
}

View File

@ -0,0 +1,25 @@
import { InputHTMLAttributes } from 'react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
}
export const Input = ({ label, error, className = '', ...props }: InputProps) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
)}
<input
className={`w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent ${
error ? 'border-red-500' : ''
} ${className}`}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
)
}

View File

@ -2,27 +2,28 @@ import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store'
import { Wallet, LogOut, User } from 'lucide-react'
import { useWeb3Wallet } from '@/blockchain/hooks/useWeb3Wallet'
import { Button } from '@/components/common/Button'
export const Header = () => {
const { user, logout } = useAuthStore()
const { walletAddress, isConnected, connectWallet, disconnectWallet } = useWeb3Wallet()
return (
<header className="bg-white shadow-sm">
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<Link to="/" className="flex items-center">
<h1 className="text-2xl font-bold text-primary">H2H</h1>
</Link>
{user && (
{user ? (
// Logged in navigation
<nav className="flex items-center gap-6">
<Link to="/dashboard" className="text-gray-700 hover:text-primary transition-colors">
Dashboard
</Link>
<Link to="/marketplace" className="text-gray-700 hover:text-primary transition-colors">
Marketplace
</Link>
{user.is_admin && (
<Link to="/admin" className="text-gray-700 hover:text-primary transition-colors">
Admin
</Link>
)}
<Link to="/my-bets" className="text-gray-700 hover:text-primary transition-colors">
My Bets
</Link>
@ -64,6 +65,37 @@ export const Header = () => {
</button>
</div>
</nav>
) : (
// Non-logged in navigation
<nav className="flex items-center gap-6">
<Link to="/sports" className="text-gray-700 hover:text-primary transition-colors">
Sports
</Link>
<Link to="/live" className="text-gray-700 hover:text-primary transition-colors">
Live
</Link>
<Link to="/new-bets" className="text-gray-700 hover:text-primary transition-colors">
New Bets
</Link>
<Link to="/watchlist" className="text-gray-700 hover:text-primary transition-colors">
Watchlist
</Link>
<Link to="/how-it-works" className="text-gray-700 hover:text-primary transition-colors">
How It Works
</Link>
<div className="flex items-center gap-3 pl-4 border-l">
<Link to="/login">
<Button variant="secondary" size="sm">
Log In
</Button>
</Link>
<Link to="/register">
<Button size="sm">
Sign Up
</Button>
</Link>
</div>
</nav>
)}
</div>
</div>

View File

@ -15,3 +15,21 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Ticker scroll animation */
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.animate-scroll {
animation: scroll 40s linear infinite;
}
.animate-scroll:hover {
animation-play-state: paused;
}

View File

@ -0,0 +1,284 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { Button } from '@/components/common/Button'
import { Input } from '@/components/common/Input'
import { Loading } from '@/components/common/Loading'
import { adminApi, type CreateEventData } from '@/api/admin'
import { SportType } from '@/types/sport-event'
import { toast } from 'react-hot-toast'
import { Plus, Trash2 } from 'lucide-react'
export const Admin = () => {
const queryClient = useQueryClient()
const [showCreateForm, setShowCreateForm] = useState(false)
const [formData, setFormData] = useState<CreateEventData>({
sport: SportType.FOOTBALL,
home_team: '',
away_team: '',
official_spread: 0,
game_time: '',
venue: '',
league: '',
})
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: adminApi.getSettings,
})
const { data: events, isLoading } = useQuery({
queryKey: ['admin-events'],
queryFn: () => adminApi.getEvents(),
})
const createEventMutation = useMutation({
mutationFn: adminApi.createEvent,
onSuccess: () => {
toast.success('Event created successfully!')
queryClient.invalidateQueries({ queryKey: ['admin-events'] })
queryClient.invalidateQueries({ queryKey: ['sport-events'] })
setShowCreateForm(false)
setFormData({
sport: SportType.FOOTBALL,
home_team: '',
away_team: '',
official_spread: 0,
game_time: '',
venue: '',
league: '',
})
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to create event')
},
})
const deleteEventMutation = useMutation({
mutationFn: adminApi.deleteEvent,
onSuccess: () => {
toast.success('Event deleted successfully!')
queryClient.invalidateQueries({ queryKey: ['admin-events'] })
queryClient.invalidateQueries({ queryKey: ['sport-events'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to delete event')
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
createEventMutation.mutate(formData)
}
const handleDelete = (eventId: number) => {
if (window.confirm('Are you sure you want to delete this event?')) {
deleteEventMutation.mutate(eventId)
}
}
return (
<Layout>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Admin Panel</h1>
<p className="text-gray-600 mt-2">Manage sporting events and platform settings</p>
</div>
<Button onClick={() => setShowCreateForm(!showCreateForm)}>
<Plus size={20} className="mr-2" />
{showCreateForm ? 'Cancel' : 'Create Event'}
</Button>
</div>
{settings && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Platform Settings</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-600">House Commission</p>
<p className="font-semibold">{settings.default_house_commission_percent}%</p>
</div>
<div>
<p className="text-gray-600">Min Bet</p>
<p className="font-semibold">${settings.default_min_bet_amount}</p>
</div>
<div>
<p className="text-gray-600">Max Bet</p>
<p className="font-semibold">${settings.default_max_bet_amount}</p>
</div>
<div>
<p className="text-gray-600">Spread Range</p>
<p className="font-semibold">
{settings.default_min_spread} to {settings.default_max_spread}
</p>
</div>
</div>
</div>
)}
{showCreateForm && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Create New Event</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sport
</label>
<select
value={formData.sport}
onChange={(e) =>
setFormData({ ...formData, sport: e.target.value as SportType })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
>
{Object.values(SportType).map((sport) => (
<option key={sport} value={sport}>
{sport.charAt(0).toUpperCase() + sport.slice(1)}
</option>
))}
</select>
</div>
<Input
label="League"
value={formData.league || ''}
onChange={(e) => setFormData({ ...formData, league: e.target.value })}
placeholder="e.g., NFL, NBA, NCAA"
/>
<Input
label="Home Team"
value={formData.home_team}
onChange={(e) => setFormData({ ...formData, home_team: e.target.value })}
required
/>
<Input
label="Away Team"
value={formData.away_team}
onChange={(e) => setFormData({ ...formData, away_team: e.target.value })}
required
/>
<Input
label="Official Spread"
type="number"
step="0.5"
value={formData.official_spread}
onChange={(e) =>
setFormData({ ...formData, official_spread: parseFloat(e.target.value) })
}
required
/>
<Input
label="Game Time"
type="datetime-local"
value={formData.game_time}
onChange={(e) => setFormData({ ...formData, game_time: e.target.value })}
required
/>
<Input
label="Venue"
value={formData.venue || ''}
onChange={(e) => setFormData({ ...formData, venue: e.target.value })}
placeholder="Stadium/Arena name"
/>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={() => setShowCreateForm(false)}
disabled={createEventMutation.isPending}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={createEventMutation.isPending}
className="flex-1"
>
{createEventMutation.isPending ? 'Creating...' : 'Create Event'}
</Button>
</div>
</form>
</div>
)}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold">All Events</h2>
</div>
{isLoading ? (
<div className="p-6">
<Loading />
</div>
) : !events || events.length === 0 ? (
<div className="p-6 text-center text-gray-600">No events created yet</div>
) : (
<div className="divide-y divide-gray-200">
{events.map((event) => (
<div key={event.id} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-semibold uppercase">
{event.sport}
</span>
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">
{event.status}
</span>
</div>
<h3 className="font-bold text-lg text-gray-900">
{event.home_team} vs {event.away_team}
</h3>
<div className="mt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600">
<div>
<span className="font-medium">Spread:</span> {event.home_team}{' '}
{event.official_spread > 0 ? '+' : ''}
{event.official_spread}
</div>
<div>
<span className="font-medium">Time:</span>{' '}
{new Date(event.game_time).toLocaleString()}
</div>
{event.venue && (
<div>
<span className="font-medium">Venue:</span> {event.venue}
</div>
)}
{event.league && (
<div>
<span className="font-medium">League:</span> {event.league}
</div>
)}
</div>
</div>
<Button
variant="secondary"
onClick={() => handleDelete(event.id)}
disabled={deleteEventMutation.isPending}
className="ml-4"
>
<Trash2 size={18} />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</Layout>
)
}

View File

@ -1,60 +1,332 @@
import { Link } from 'react-router-dom'
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '@/store'
import { sportEventsApi } from '@/api/sport-events'
import { SpreadGrid } from '@/components/bets/SpreadGrid'
import { Button } from '@/components/common/Button'
import { TrendingUp, Shield, Zap } from 'lucide-react'
import { Loading } from '@/components/common/Loading'
import { Header } from '@/components/layout/Header'
import { ChevronLeft, TrendingUp, Clock, ArrowRight } from 'lucide-react'
export const Home = () => {
const navigate = useNavigate()
const { isAuthenticated } = useAuthStore()
const [selectedEventId, setSelectedEventId] = useState<number | null>(null)
const [email, setEmail] = useState('')
// Use public API for events (works for both authenticated and non-authenticated)
const { data: events, isLoading: isLoadingEvents } = useQuery({
queryKey: ['public-sport-events'],
queryFn: () => sportEventsApi.getPublicEvents(),
})
// Use authenticated API for event details if logged in, otherwise public
const { data: selectedEvent, isLoading: isLoadingEvent } = useQuery({
queryKey: ['sport-event', selectedEventId, isAuthenticated],
queryFn: () =>
isAuthenticated
? sportEventsApi.getEventWithGrid(selectedEventId!)
: sportEventsApi.getPublicEventWithGrid(selectedEventId!),
enabled: selectedEventId !== null,
})
const handleEventClick = (eventId: number) => {
setSelectedEventId(eventId)
}
const handleBackToList = () => {
setSelectedEventId(null)
}
const handleSignUp = (e: React.FormEvent) => {
e.preventDefault()
navigate(`/register?email=${encodeURIComponent(email)}`)
}
// Loading state
if (isLoadingEvents) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="flex items-center justify-center py-20">
<Loading />
</div>
</div>
)
}
// Selected event view (spread grid)
if (selectedEventId) {
if (isLoadingEvent) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Button variant="secondary" onClick={handleBackToList}>
<ChevronLeft size={20} className="mr-2" />
Back to Events
</Button>
<div className="mt-8">
<Loading />
</div>
</div>
</div>
)
}
if (selectedEvent) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Button variant="secondary" onClick={handleBackToList}>
<ChevronLeft size={20} className="mr-2" />
Back to Events
</Button>
<div className="mt-6">
<SpreadGrid
event={selectedEvent}
onBetCreated={() => {}}
onBetTaken={() => {}}
/>
</div>
</div>
</div>
)
}
}
// Calculate total open bets across all events
const totalOpenBets = events?.length || 0
return (
<div className="min-h-screen bg-gradient-to-br from-primary/10 to-purple-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="text-center">
<h1 className="text-6xl font-bold text-gray-900 mb-6">
Welcome to <span className="text-primary">H2H</span>
</h1>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
The peer-to-peer betting platform where you create, accept, and settle wagers directly with other users.
</p>
<div className="min-h-screen bg-gray-50">
<Header />
<div className="flex gap-4 justify-center mb-16">
<Link to="/register">
<Button size="lg">Get Started</Button>
</Link>
<Link to="/login">
<Button size="lg" variant="secondary">Login</Button>
</Link>
{/* Hero Section */}
<div className="bg-gradient-to-r from-gray-900 to-gray-800 text-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 md:py-24">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Put your money where your mouth is!
</h1>
<p className="text-gray-300 text-lg mb-8">
Create your own lines or take existing bets. Secure escrow ensures fair payouts.
</p>
{!isAuthenticated && (
<form onSubmit={handleSignUp} className="flex gap-2 max-w-md">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
className="flex-1 px-4 py-3 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary"
/>
<Button type="submit" size="lg">
Sign Up <ArrowRight size={18} className="ml-2" />
</Button>
</form>
)}
{isAuthenticated && (
<div className="flex gap-4">
<Button size="lg" onClick={() => events?.[0] && handleEventClick(events[0].id)}>
Start Betting <ArrowRight size={18} className="ml-2" />
</Button>
</div>
)}
</div>
<div className="hidden md:flex justify-center">
<div className="bg-white/10 backdrop-blur rounded-2xl p-8 text-center">
<div className="text-5xl font-bold text-primary mb-2">{events?.length || 0}</div>
<div className="text-gray-300">Active Events</div>
<div className="mt-4 pt-4 border-t border-white/20">
<div className="text-3xl font-bold text-green-400">{totalOpenBets}+</div>
<div className="text-gray-300">Open Bets</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Animated Activity Ticker */}
{events && events.length > 0 && (
<div className="bg-gray-900 text-white overflow-hidden">
<div className="relative">
<div className="flex animate-scroll">
{/* Generate ticker items from events - line moves, new bets, etc */}
{[...events, ...events].flatMap((event, index) => {
const items = []
const lineMove = (Math.random() > 0.5 ? 0.5 : -0.5)
const oldSpread = event.official_spread - lineMove
// Line movement item
items.push(
<div
key={`line-${event.id}-${index}`}
className="flex items-center gap-2 whitespace-nowrap px-6 py-2.5 border-r border-gray-700"
>
<span className="text-yellow-400 text-xs font-bold">LINE MOVE</span>
<span className="text-gray-300">{event.home_team}</span>
<span className="text-gray-500">{oldSpread > 0 ? '+' : ''}{oldSpread}</span>
<TrendingUp size={14} className={lineMove < 0 ? 'text-green-400' : 'text-red-400'} />
<span className="font-bold text-white">{event.official_spread > 0 ? '+' : ''}{event.official_spread}</span>
</div>
)
// New bet item (for some events)
if (index % 3 === 0) {
const betAmount = Math.floor(Math.random() * 500 + 50)
items.push(
<div
key={`bet-${event.id}-${index}`}
className="flex items-center gap-2 whitespace-nowrap px-6 py-2.5 border-r border-gray-700"
>
<span className="text-green-400 text-xs font-bold">NEW BET</span>
<span className="text-gray-300">${betAmount}</span>
<span className="text-gray-500">on</span>
<span className="text-white">{event.away_team} {-event.official_spread > 0 ? '+' : ''}{-event.official_spread}</span>
</div>
)
}
// Live indicator for upcoming games
const hoursUntil = Math.floor((new Date(event.game_time).getTime() - Date.now()) / (1000 * 60 * 60))
if (hoursUntil >= 0 && hoursUntil < 2 && index % 4 === 0) {
items.push(
<div
key={`live-${event.id}-${index}`}
className="flex items-center gap-2 whitespace-nowrap px-6 py-2.5 border-r border-gray-700"
>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
<span className="text-red-400 text-xs font-bold">STARTING SOON</span>
</span>
<span className="text-white">{event.home_team} vs {event.away_team}</span>
<span className="text-gray-400 flex items-center gap-1">
<Clock size={12} />
{hoursUntil}h
</span>
</div>
)
}
return items
})}
</div>
</div>
</div>
)}
{/* Events Table Section */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900">Upcoming Events</h2>
<p className="text-gray-600 mt-1">Select an event to view available spreads</p>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<TrendingUp size={16} className="text-green-500" />
<span>{events?.length || 0} events available</span>
</div>
</div>
<div className="grid md:grid-cols-3 gap-8 mt-16">
<div className="bg-white p-8 rounded-lg shadow-md text-center">
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<TrendingUp className="text-primary" size={32} />
</div>
<h3 className="text-xl font-semibold mb-3">Create Custom Bets</h3>
<p className="text-gray-600">
Create your own bets on sports, esports, politics, entertainment, or anything else you can imagine.
</p>
{!events || events.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl shadow-sm">
<p className="text-gray-500 text-lg">No upcoming events available</p>
<p className="text-gray-400 mt-2">Check back soon for new betting opportunities</p>
</div>
) : (
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
{/* Table Header */}
<div className="grid grid-cols-12 gap-4 px-6 py-4 bg-gray-50 border-b text-sm font-medium text-gray-500 uppercase tracking-wider">
<div className="col-span-4">Event</div>
<div className="col-span-2">Sport</div>
<div className="col-span-2">Spread</div>
<div className="col-span-2">Time</div>
<div className="col-span-2 text-right">Bet Range</div>
</div>
<div className="bg-white p-8 rounded-lg shadow-md text-center">
<div className="bg-success/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Shield className="text-success" size={32} />
</div>
<h3 className="text-xl font-semibold mb-3">Secure Escrow</h3>
<p className="text-gray-600">
Funds are safely locked in escrow when a bet is matched, ensuring fair and secure transactions.
</p>
</div>
{/* Table Body */}
<div className="divide-y divide-gray-100">
{events.map((event) => {
const gameTime = new Date(event.game_time)
const hoursUntil = Math.floor(
(gameTime.getTime() - Date.now()) / (1000 * 60 * 60)
)
const isUrgent = hoursUntil >= 0 && hoursUntil < 24
<div className="bg-white p-8 rounded-lg shadow-md text-center">
<div className="bg-warning/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap className="text-warning" size={32} />
return (
<button
key={event.id}
onClick={() => handleEventClick(event.id)}
className="grid grid-cols-12 gap-4 px-6 py-5 w-full text-left hover:bg-gray-50 transition-colors items-center"
>
<div className="col-span-4">
<div className="font-semibold text-gray-900">
{event.home_team} vs {event.away_team}
</div>
{event.league && (
<div className="text-sm text-gray-500 mt-1">{event.league}</div>
)}
</div>
<div className="col-span-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 uppercase">
{event.sport}
</span>
</div>
<div className="col-span-2">
<span className="font-medium text-gray-900">
{event.official_spread > 0 ? '+' : ''}{event.official_spread}
</span>
<span className="text-gray-500 text-sm ml-1">
({event.home_team})
</span>
</div>
<div className="col-span-2">
<div className={`text-sm ${isUrgent ? 'text-red-600 font-medium' : 'text-gray-600'}`}>
{gameTime.toLocaleDateString()}
</div>
<div className={`text-xs ${isUrgent ? 'text-red-500' : 'text-gray-400'}`}>
{gameTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{isUrgent && ` (${hoursUntil}h)`}
</div>
</div>
<div className="col-span-2 text-right">
<div className="text-sm text-gray-900">
${event.min_bet_amount} - ${event.max_bet_amount}
</div>
<div className="text-xs text-gray-400">
Spreads: {event.min_spread} to {event.max_spread}
</div>
</div>
</button>
)
})}
</div>
<h3 className="text-xl font-semibold mb-3">Real-time Updates</h3>
<p className="text-gray-600">
Get instant notifications when your bets are matched, settled, or when new opportunities arise.
</p>
</div>
</div>
)}
{/* CTA for non-authenticated users */}
{!isAuthenticated && (
<div className="mt-12 text-center bg-gradient-to-r from-primary/10 to-blue-100 rounded-xl p-8">
<h3 className="text-xl font-bold text-gray-900 mb-2">Ready to start betting?</h3>
<p className="text-gray-600 mb-6">Create an account to place bets on these events</p>
<div className="flex gap-4 justify-center">
<Link to="/register">
<Button size="lg">Create Account</Button>
</Link>
<Link to="/login">
<Button variant="secondary" size="lg">Log In</Button>
</Link>
</div>
</div>
)}
</div>
</div>
)

View File

@ -0,0 +1,123 @@
import { Link } from 'react-router-dom'
import { Header } from '@/components/layout/Header'
import { Button } from '@/components/common/Button'
import { HelpCircle, ArrowRight, Shield, Users, Zap, DollarSign } from 'lucide-react'
export const HowItWorks = () => {
const steps = [
{
number: '01',
title: 'Create or Find a Bet',
description: 'Browse available spreads on upcoming games or create your own line.',
icon: Zap,
},
{
number: '02',
title: 'Match with Another User',
description: 'Take the opposite side of an existing bet or wait for someone to take yours.',
icon: Users,
},
{
number: '03',
title: 'Funds Held in Escrow',
description: 'Both sides stake is securely locked until the game is settled.',
icon: Shield,
},
{
number: '04',
title: 'Winner Takes the Pot',
description: 'After the game, the winner receives 90% of the total pot (10% platform fee).',
icon: DollarSign,
},
]
return (
<div className="min-h-screen bg-gray-50">
<Header />
{/* Hero */}
<div className="bg-gradient-to-r from-gray-900 to-gray-800 text-white py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<HelpCircle className="w-16 h-16 mx-auto mb-4 text-primary" />
<h1 className="text-4xl md:text-5xl font-bold mb-4">How It Works</h1>
<p className="text-xl text-gray-300 max-w-2xl mx-auto">
Peer-to-peer sports betting made simple. No bookies, no house edge on odds - just you vs another bettor.
</p>
</div>
</div>
{/* Steps */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
{steps.map((step) => (
<div key={step.number} className="relative">
<div className="bg-white rounded-xl p-6 border border-gray-100 shadow-sm h-full">
<div className="text-5xl font-bold text-gray-100 mb-4">{step.number}</div>
<step.icon className="w-10 h-10 text-primary mb-4" />
<h3 className="text-xl font-bold text-gray-900 mb-2">{step.title}</h3>
<p className="text-gray-600">{step.description}</p>
</div>
</div>
))}
</div>
</div>
{/* FAQ */}
<div className="bg-white py-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-gray-900 text-center mb-12">Common Questions</h2>
<div className="space-y-6">
<div className="border-b pb-6">
<h3 className="font-bold text-lg text-gray-900 mb-2">What is spread betting?</h3>
<p className="text-gray-600">
Spread betting involves wagering on the margin of victory. If the spread is -7, the favorite must win by more than 7 points for bets on them to pay out.
</p>
</div>
<div className="border-b pb-6">
<h3 className="font-bold text-lg text-gray-900 mb-2">How is my money protected?</h3>
<p className="text-gray-600">
All funds are held in secure escrow when a bet is matched. Neither party can access the funds until the game is settled.
</p>
</div>
<div className="border-b pb-6">
<h3 className="font-bold text-lg text-gray-900 mb-2">What are the fees?</h3>
<p className="text-gray-600">
We charge a 10% commission on winning bets only. If you lose, you pay nothing beyond your stake.
</p>
</div>
<div className="border-b pb-6">
<h3 className="font-bold text-lg text-gray-900 mb-2">Can I cancel a bet?</h3>
<p className="text-gray-600">
You can cancel open bets that haven't been matched yet. Once matched, bets cannot be cancelled.
</p>
</div>
</div>
</div>
</div>
{/* CTA */}
<div className="bg-gradient-to-r from-primary to-blue-600 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl font-bold text-white mb-4">Ready to start betting?</h2>
<p className="text-blue-100 mb-8 text-lg">Join thousands of users betting against each other</p>
<div className="flex gap-4 justify-center">
<Link to="/register">
<Button size="lg" className="bg-white text-primary hover:bg-gray-100">
Create Account <ArrowRight size={18} className="ml-2" />
</Button>
</Link>
<Link to="/">
<Button size="lg" variant="secondary" className="border-white text-white hover:bg-white/10">
Browse Events
</Button>
</Link>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,50 @@
import { Link } from 'react-router-dom'
import { Header } from '@/components/layout/Header'
import { Button } from '@/components/common/Button'
import { Radio, ArrowRight } from 'lucide-react'
export const Live = () => {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<div className="flex items-center justify-center gap-3 mb-4">
<span className="w-4 h-4 bg-red-500 rounded-full animate-pulse"></span>
<Radio className="w-16 h-16 text-red-500" />
</div>
<h1 className="text-4xl font-bold text-gray-900 mb-4">Live Events</h1>
<p className="text-xl text-gray-600">Watch the action unfold in real-time</p>
</div>
<div className="bg-white rounded-xl shadow-sm p-12 text-center border border-gray-100">
<div className="text-6xl mb-6">🎮</div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">No Live Events Right Now</h2>
<p className="text-gray-600 mb-8 max-w-md mx-auto">
Check back when games are in progress to see live betting action and real-time updates.
</p>
<Link to="/">
<Button size="lg">
View Upcoming Events <ArrowRight size={18} className="ml-2" />
</Button>
</Link>
</div>
<div className="mt-12 grid md:grid-cols-3 gap-6">
<div className="bg-gradient-to-br from-red-500 to-red-600 rounded-xl p-6 text-white">
<h3 className="font-bold text-lg mb-2">Live Line Movement</h3>
<p className="text-red-100 text-sm">Watch spreads shift in real-time as the game progresses</p>
</div>
<div className="bg-gradient-to-br from-green-500 to-green-600 rounded-xl p-6 text-white">
<h3 className="font-bold text-lg mb-2">In-Game Betting</h3>
<p className="text-green-100 text-sm">Place bets on live events with dynamic odds</p>
</div>
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl p-6 text-white">
<h3 className="font-bold text-lg mb-2">Instant Settlement</h3>
<p className="text-blue-100 text-sm">Get paid out immediately when bets resolve</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,56 +1,94 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { BetList } from '@/components/bets/BetList'
import { Loading } from '@/components/common/Loading'
import { betsApi } from '@/api/bets'
import { spreadBetsApi } from '@/api/spread-bets'
import type { SpreadBetDetail } from '@/types/spread-bet'
const SpreadBetCard = ({ bet }: { bet: SpreadBetDetail }) => {
const isCreator = bet.creator_username !== undefined
const statusColors = {
open: 'bg-blue-100 text-blue-800',
matched: 'bg-yellow-100 text-yellow-800',
completed: 'bg-green-100 text-green-800',
cancelled: 'bg-gray-100 text-gray-800',
}
return (
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">
{bet.event_home_team} vs {bet.event_away_team}
</h3>
<p className="text-sm text-gray-500">
{new Date(bet.event_game_time).toLocaleString()}
</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusColors[bet.status] || statusColors.open}`}>
{bet.status.charAt(0).toUpperCase() + bet.status.slice(1)}
</span>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="text-sm text-gray-500">Your Spread</p>
<p className="text-xl font-bold text-gray-900">
{bet.spread > 0 ? '+' : ''}{bet.spread}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Stake</p>
<p className="text-xl font-bold text-green-600">${Number(bet.stake_amount).toFixed(2)}</p>
</div>
</div>
<div className="border-t pt-4">
<div className="flex justify-between text-sm">
<div>
<span className="text-gray-500">Team: </span>
<span className="font-medium">{bet.team === 'home' ? bet.event_home_team : bet.event_away_team}</span>
</div>
<div>
<span className="text-gray-500">Official Line: </span>
<span className="font-medium">{bet.event_official_spread > 0 ? '+' : ''}{bet.event_official_spread}</span>
</div>
</div>
{bet.taker_username && (
<div className="mt-2 text-sm">
<span className="text-gray-500">Opponent: </span>
<span className="font-medium">{bet.taker_username}</span>
</div>
)}
</div>
</div>
)
}
export const MyBets = () => {
const [activeTab, setActiveTab] = useState<'created' | 'accepted' | 'active' | 'history'>('active')
const [activeTab, setActiveTab] = useState<'active' | 'history'>('active')
const { data: createdBets } = useQuery({
queryKey: ['myCreatedBets'],
queryFn: betsApi.getMyCreatedBets,
enabled: activeTab === 'created',
})
const { data: acceptedBets } = useQuery({
queryKey: ['myAcceptedBets'],
queryFn: betsApi.getMyAcceptedBets,
enabled: activeTab === 'accepted',
})
const { data: activeBets } = useQuery({
queryKey: ['myActiveBets'],
queryFn: betsApi.getMyActiveBets,
const { data: activeBets, isLoading: activeLoading } = useQuery({
queryKey: ['myActiveSpreadBets'],
queryFn: spreadBetsApi.getMyActiveBets,
enabled: activeTab === 'active',
})
const { data: historyBets } = useQuery({
queryKey: ['myHistory'],
queryFn: betsApi.getMyHistory,
enabled: activeTab === 'history',
})
const currentBets =
activeTab === 'created' ? createdBets :
activeTab === 'accepted' ? acceptedBets :
activeTab === 'active' ? activeBets :
historyBets
// For history, we could add a separate endpoint later
// For now, just show active bets
const currentBets = activeTab === 'active' ? activeBets : []
return (
<Layout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">My Bets</h1>
<p className="text-gray-600 mt-2">View and manage your bets</p>
<p className="text-gray-600 mt-2">View and manage your spread bets</p>
</div>
<div className="flex gap-2 border-b">
{[
{ key: 'active' as const, label: 'Active' },
{ key: 'created' as const, label: 'Created' },
{ key: 'accepted' as const, label: 'Accepted' },
{ key: 'active' as const, label: 'Active Bets' },
{ key: 'history' as const, label: 'History' },
].map(({ key, label }) => (
<button
@ -67,10 +105,23 @@ export const MyBets = () => {
))}
</div>
{currentBets ? (
<BetList bets={currentBets} />
) : (
{activeLoading ? (
<Loading />
) : currentBets && currentBets.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{currentBets.map((bet) => (
<SpreadBetCard key={bet.id} bet={bet} />
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No bets found</p>
<p className="text-gray-400 mt-2">
{activeTab === 'active'
? 'Create a bet on the Sport Events page to get started!'
: 'Your completed bets will appear here'}
</p>
</div>
)}
</div>
</Layout>

View File

@ -0,0 +1,85 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Header } from '@/components/layout/Header'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { sportEventsApi } from '@/api/sport-events'
import { Zap, ArrowRight, Clock } from 'lucide-react'
export const NewBets = () => {
const { data: events, isLoading } = useQuery({
queryKey: ['public-sport-events'],
queryFn: () => sportEventsApi.getPublicEvents(),
})
// Simulate recent bets from events
const recentBets = events?.slice(0, 10).map((event, index) => ({
id: index,
event,
amount: Math.floor(Math.random() * 500 + 25),
spread: event.official_spread + (Math.random() > 0.5 ? 0.5 : -0.5),
team: Math.random() > 0.5 ? event.home_team : event.away_team,
timeAgo: `${Math.floor(Math.random() * 30 + 1)}m ago`,
}))
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<Zap className="w-16 h-16 text-yellow-500 mx-auto mb-4" />
<h1 className="text-4xl font-bold text-gray-900 mb-4">New Bets</h1>
<p className="text-xl text-gray-600">Fresh betting action from the community</p>
</div>
{isLoading ? (
<Loading />
) : (
<div className="space-y-4">
{recentBets?.map((bet) => (
<Link
key={bet.id}
to="/"
className="block bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-6 border border-gray-100"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<span className="text-green-600 font-bold">${bet.amount}</span>
</div>
<div>
<p className="font-semibold text-gray-900">
{bet.team} {bet.spread > 0 ? '+' : ''}{bet.spread.toFixed(1)}
</p>
<p className="text-sm text-gray-500">
{bet.event.home_team} vs {bet.event.away_team}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded uppercase font-medium">
{bet.event.sport}
</span>
<span className="text-sm text-gray-400 flex items-center gap-1">
<Clock size={14} />
{bet.timeAgo}
</span>
<Button size="sm">Take Bet</Button>
</div>
</div>
</Link>
))}
</div>
)}
<div className="mt-12 text-center">
<Link to="/">
<Button variant="secondary" size="lg">
View All Events <ArrowRight size={18} className="ml-2" />
</Button>
</Link>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,192 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Layout } from '@/components/layout/Layout'
import { Card } from '@/components/common/Card'
import { Loading } from '@/components/common/Loading'
import { walletApi } from '@/api/wallet'
import { spreadBetsApi } from '@/api/spread-bets'
import { useAuthStore } from '@/store'
import { formatCurrency } from '@/utils/formatters'
import {
TrendingUp,
Activity,
Award,
Wallet,
User,
Mail,
Calendar,
ArrowRight
} from 'lucide-react'
export const Profile = () => {
const { user } = useAuthStore()
const { data: wallet, isLoading: walletLoading } = useQuery({
queryKey: ['wallet'],
queryFn: walletApi.getWallet,
})
const { data: activeBets, isLoading: betsLoading } = useQuery({
queryKey: ['myActiveSpreadBets'],
queryFn: spreadBetsApi.getMyActiveBets,
})
if (walletLoading || betsLoading) {
return <Layout><Loading /></Layout>
}
return (
<Layout>
<div className="space-y-8">
{/* Profile Header */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-6">
<div className="bg-primary/10 w-20 h-20 rounded-full flex items-center justify-center">
<User className="text-primary" size={40} />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
{user?.display_name || user?.username}
</h1>
<p className="text-gray-600 flex items-center gap-2 mt-1">
<Mail size={16} />
{user?.email}
</p>
{user?.created_at && (
<p className="text-sm text-gray-500 flex items-center gap-2 mt-1">
<Calendar size={14} />
Member since {new Date(user.created_at).toLocaleDateString()}
</p>
)}
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<div className="flex items-center gap-4">
<div className="bg-primary/10 p-3 rounded-lg">
<Wallet className="text-primary" size={24} />
</div>
<div>
<p className="text-sm text-gray-600">Available Balance</p>
<p className="text-2xl font-bold text-gray-900">
{wallet ? formatCurrency(wallet.balance) : '$0.00'}
</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-4">
<div className="bg-yellow-100 p-3 rounded-lg">
<Activity className="text-yellow-600" size={24} />
</div>
<div>
<p className="text-sm text-gray-600">In Escrow</p>
<p className="text-2xl font-bold text-gray-900">
{wallet ? formatCurrency(wallet.escrow) : '$0.00'}
</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-4">
<div className="bg-success/10 p-3 rounded-lg">
<TrendingUp className="text-success" size={24} />
</div>
<div>
<p className="text-sm text-gray-600">Active Bets</p>
<p className="text-2xl font-bold text-gray-900">{activeBets?.length || 0}</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-4">
<div className="bg-purple-100 p-3 rounded-lg">
<Award className="text-purple-600" size={24} />
</div>
<div>
<p className="text-sm text-gray-600">Win Rate</p>
<p className="text-2xl font-bold text-gray-900">
{user ? `${(user.win_rate * 100).toFixed(0)}%` : '0%'}
</p>
</div>
</div>
</Card>
</div>
{/* Quick Links */}
<div className="grid md:grid-cols-2 gap-6">
<Link to="/wallet" className="block">
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="bg-primary/10 p-3 rounded-lg">
<Wallet className="text-primary" size={24} />
</div>
<div>
<h3 className="font-semibold text-gray-900">Wallet</h3>
<p className="text-sm text-gray-600">Manage deposits and withdrawals</p>
</div>
</div>
<ArrowRight className="text-gray-400" size={20} />
</div>
</Card>
</Link>
<Link to="/my-bets" className="block">
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="bg-green-100 p-3 rounded-lg">
<Activity className="text-green-600" size={24} />
</div>
<div>
<h3 className="font-semibold text-gray-900">My Bets</h3>
<p className="text-sm text-gray-600">View all your active and past bets</p>
</div>
</div>
<ArrowRight className="text-gray-400" size={20} />
</div>
</Card>
</Link>
</div>
{/* Active Bets Preview */}
{activeBets && activeBets.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Active Bets</h2>
<Link to="/my-bets" className="text-primary hover:underline text-sm">
View all
</Link>
</div>
<div className="space-y-3">
{activeBets.slice(0, 3).map((bet) => (
<div key={bet.id} className="flex justify-between items-center p-4 border rounded-lg">
<div>
<h3 className="font-semibold">
{bet.event?.home_team} vs {bet.event?.away_team}
</h3>
<p className="text-sm text-gray-600">
{bet.team === 'home' ? bet.event?.home_team : bet.event?.away_team}{' '}
{bet.spread > 0 ? '+' : ''}{bet.spread}
</p>
</div>
<div className="text-right">
<p className="font-semibold text-primary">{formatCurrency(bet.stake_amount)}</p>
<p className="text-sm text-gray-500 capitalize">{bet.status}</p>
</div>
</div>
))}
</div>
</Card>
)}
</div>
</Layout>
)
}

View File

@ -0,0 +1,153 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { Loading } from '@/components/common/Loading'
import { Button } from '@/components/common/Button'
import { sportEventsApi } from '@/api/sport-events'
import { SpreadGrid } from '@/components/bets/SpreadGrid'
import { ChevronLeft } from 'lucide-react'
export const SportEvents = () => {
const [selectedEventId, setSelectedEventId] = useState<number | null>(null)
const { data: events, isLoading: isLoadingEvents } = useQuery({
queryKey: ['sport-events'],
queryFn: () => sportEventsApi.getUpcomingEvents(),
})
const { data: selectedEvent, isLoading: isLoadingEvent } = useQuery({
queryKey: ['sport-event', selectedEventId],
queryFn: () => sportEventsApi.getEventWithGrid(selectedEventId!),
enabled: selectedEventId !== null,
})
const handleEventClick = (eventId: number) => {
setSelectedEventId(eventId)
}
const handleBackToList = () => {
setSelectedEventId(null)
}
if (isLoadingEvents) {
return (
<Layout>
<Loading />
</Layout>
)
}
if (selectedEventId && isLoadingEvent) {
return (
<Layout>
<div className="space-y-4">
<Button variant="secondary" onClick={handleBackToList}>
<ChevronLeft size={20} className="mr-2" />
Back to Events
</Button>
<Loading />
</div>
</Layout>
)
}
return (
<Layout>
<div className="space-y-6">
{selectedEvent ? (
<>
<Button variant="secondary" onClick={handleBackToList}>
<ChevronLeft size={20} className="mr-2" />
Back to Events
</Button>
<SpreadGrid
event={selectedEvent}
onBetCreated={() => {
// Refetch to update grid
}}
onBetTaken={() => {
// Refetch to update grid
}}
/>
</>
) : (
<>
<div>
<h1 className="text-3xl font-bold text-gray-900">Sport Events</h1>
<p className="text-gray-600 mt-2">
Browse upcoming events and place spread bets
</p>
</div>
{!events || events.length === 0 ? (
<div className="text-center py-12 bg-white rounded-lg shadow">
<p className="text-gray-600">No upcoming events available</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{events.map((event) => {
const gameTime = new Date(event.game_time)
const now = new Date()
const hoursUntil = Math.floor(
(gameTime.getTime() - now.getTime()) / (1000 * 60 * 60)
)
return (
<button
key={event.id}
onClick={() => handleEventClick(event.id)}
className="bg-white p-6 rounded-lg shadow hover:shadow-lg transition-shadow text-left"
>
<div className="flex justify-between items-start mb-3">
<div className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-semibold uppercase">
{event.sport}
</div>
{hoursUntil < 24 && hoursUntil >= 0 && (
<div className="px-2 py-1 bg-red-100 text-red-800 rounded text-xs font-semibold">
{hoursUntil}h
</div>
)}
</div>
<h3 className="font-bold text-lg text-gray-900 mb-2">
{event.home_team} vs {event.away_team}
</h3>
<div className="space-y-1 text-sm text-gray-600">
<p>
<strong>Spread:</strong> {event.home_team}{' '}
{event.official_spread > 0 ? '+' : ''}
{event.official_spread}
</p>
<p>
<strong>Time:</strong> {gameTime.toLocaleString()}
</p>
{event.venue && (
<p>
<strong>Venue:</strong> {event.venue}
</p>
)}
{event.league && (
<p className="text-xs text-gray-500">{event.league}</p>
)}
</div>
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-xs text-gray-500">
Bet range: ${event.min_bet_amount} - ${event.max_bet_amount}
</p>
<p className="text-xs text-gray-500">
Spread range: {event.min_spread} to {event.max_spread}
</p>
</div>
</button>
)
})}
</div>
)}
</>
)}
</div>
</Layout>
)
}

View File

@ -0,0 +1,61 @@
import { Link } from 'react-router-dom'
import { Header } from '@/components/layout/Header'
import { Button } from '@/components/common/Button'
import { Trophy, ArrowRight } from 'lucide-react'
export const Sports = () => {
const sports = [
{ name: 'Football', icon: '🏈', leagues: ['NFL', 'NCAA Football'] },
{ name: 'Basketball', icon: '🏀', leagues: ['NBA', 'NCAA Basketball'] },
{ name: 'Hockey', icon: '🏒', leagues: ['NHL'] },
{ name: 'Soccer', icon: '⚽', leagues: ['Premier League', 'La Liga', 'Bundesliga', 'MLS'] },
{ name: 'Baseball', icon: '⚾', leagues: ['MLB'] },
{ name: 'MMA', icon: '🥊', leagues: ['UFC', 'Bellator'] },
]
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<Trophy className="w-16 h-16 text-primary mx-auto mb-4" />
<h1 className="text-4xl font-bold text-gray-900 mb-4">Sports</h1>
<p className="text-xl text-gray-600">Choose your sport and start betting</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{sports.map((sport) => (
<Link
key={sport.name}
to="/"
className="bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-6 border border-gray-100"
>
<div className="flex items-center gap-4 mb-4">
<span className="text-4xl">{sport.icon}</span>
<h2 className="text-2xl font-bold text-gray-900">{sport.name}</h2>
</div>
<div className="flex flex-wrap gap-2">
{sport.leagues.map((league) => (
<span
key={league}
className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
>
{league}
</span>
))}
</div>
</Link>
))}
</div>
<div className="mt-12 text-center">
<Link to="/">
<Button size="lg">
View All Events <ArrowRight size={18} className="ml-2" />
</Button>
</Link>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,60 @@
import { Link } from 'react-router-dom'
import { Header } from '@/components/layout/Header'
import { Button } from '@/components/common/Button'
import { Eye, ArrowRight, Bell } from 'lucide-react'
export const Watchlist = () => {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<Eye className="w-16 h-16 text-purple-500 mx-auto mb-4" />
<h1 className="text-4xl font-bold text-gray-900 mb-4">Watchlist</h1>
<p className="text-xl text-gray-600">Track your favorite events and get alerts</p>
</div>
<div className="bg-white rounded-xl shadow-sm p-12 text-center border border-gray-100">
<div className="text-6xl mb-6">👀</div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">Your Watchlist is Empty</h2>
<p className="text-gray-600 mb-8 max-w-md mx-auto">
Sign up to save events to your watchlist and get notified when lines move or new bets are placed.
</p>
<div className="flex gap-4 justify-center">
<Link to="/register">
<Button size="lg">
Create Account <ArrowRight size={18} className="ml-2" />
</Button>
</Link>
<Link to="/">
<Button variant="secondary" size="lg">
Browse Events
</Button>
</Link>
</div>
</div>
<div className="mt-12 grid md:grid-cols-2 gap-6">
<div className="bg-white rounded-xl p-6 border border-gray-100">
<div className="flex items-center gap-3 mb-4">
<Bell className="w-8 h-8 text-purple-500" />
<h3 className="font-bold text-lg text-gray-900">Line Movement Alerts</h3>
</div>
<p className="text-gray-600">
Get notified instantly when lines move on events you're watching
</p>
</div>
<div className="bg-white rounded-xl p-6 border border-gray-100">
<div className="flex items-center gap-3 mb-4">
<Eye className="w-8 h-8 text-purple-500" />
<h3 className="font-bold text-lg text-gray-900">Track Multiple Events</h3>
</div>
<p className="text-gray-600">
Keep an eye on all your favorite matchups in one place
</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -10,6 +10,7 @@ export interface User {
losses: number
win_rate: number
status: 'active' | 'suspended' | 'pending_verification'
is_admin: boolean
created_at: string
}

View File

@ -0,0 +1,53 @@
export enum SportType {
FOOTBALL = "football",
BASKETBALL = "basketball",
BASEBALL = "baseball",
HOCKEY = "hockey",
SOCCER = "soccer",
}
export enum EventStatus {
UPCOMING = "upcoming",
LIVE = "live",
COMPLETED = "completed",
CANCELLED = "cancelled",
}
export interface SportEvent {
id: number;
sport: SportType;
home_team: string;
away_team: string;
official_spread: number;
game_time: string;
venue: string | null;
league: string | null;
min_spread: number;
max_spread: number;
min_bet_amount: number;
max_bet_amount: number;
status: EventStatus;
final_score_home: number | null;
final_score_away: number | null;
created_by: number;
created_at: string;
updated_at: string;
}
export interface SpreadGridBet {
bet_id: number;
creator_id: number;
creator_username: string;
stake: number;
status: string;
team: string;
can_take: boolean;
}
export type SpreadGrid = {
[spread: string]: SpreadGridBet[];
};
export interface SportEventWithBets extends SportEvent {
spread_grid: SpreadGrid;
}

View File

@ -0,0 +1,45 @@
export enum TeamSide {
HOME = "home",
AWAY = "away",
}
export enum SpreadBetStatus {
OPEN = "open",
MATCHED = "matched",
COMPLETED = "completed",
CANCELLED = "cancelled",
DISPUTED = "disputed",
}
export interface SpreadBetCreate {
event_id: number;
spread: number;
team: TeamSide;
stake_amount: number;
}
export interface SpreadBet {
id: number;
event_id: number;
spread: number;
team: TeamSide;
creator_id: number;
taker_id: number | null;
stake_amount: number;
house_commission_percent: number;
status: SpreadBetStatus;
payout_amount: number | null;
winner_id: number | null;
created_at: string;
matched_at: string | null;
completed_at: string | null;
}
export interface SpreadBetDetail extends SpreadBet {
creator_username: string;
taker_username: string | null;
event_home_team: string;
event_away_team: string;
event_official_spread: number;
event_game_time: string;
}