Best landing page yet, lost logged in links to lists of bets
This commit is contained in:
@ -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
60
frontend/src/api/admin.ts
Normal 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
|
||||
},
|
||||
}
|
||||
36
frontend/src/api/sport-events.ts
Normal file
36
frontend/src/api/sport-events.ts
Normal 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
|
||||
},
|
||||
}
|
||||
24
frontend/src/api/spread-bets.ts
Normal file
24
frontend/src/api/spread-bets.ts
Normal 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
|
||||
},
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
151
frontend/src/components/bets/CreateSpreadBetModal.tsx
Normal file
151
frontend/src/components/bets/CreateSpreadBetModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
406
frontend/src/components/bets/SpreadGrid.tsx
Normal file
406
frontend/src/components/bets/SpreadGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
frontend/src/components/bets/TakeBetModal.tsx
Normal file
134
frontend/src/components/bets/TakeBetModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
frontend/src/components/common/Input.tsx
Normal file
25
frontend/src/components/common/Input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
284
frontend/src/pages/Admin.tsx
Normal file
284
frontend/src/pages/Admin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
123
frontend/src/pages/HowItWorks.tsx
Normal file
123
frontend/src/pages/HowItWorks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
frontend/src/pages/Live.tsx
Normal file
50
frontend/src/pages/Live.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
85
frontend/src/pages/NewBets.tsx
Normal file
85
frontend/src/pages/NewBets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
frontend/src/pages/Profile.tsx
Normal file
192
frontend/src/pages/Profile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
frontend/src/pages/SportEvents.tsx
Normal file
153
frontend/src/pages/SportEvents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
frontend/src/pages/Sports.tsx
Normal file
61
frontend/src/pages/Sports.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
frontend/src/pages/Watchlist.tsx
Normal file
60
frontend/src/pages/Watchlist.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -10,6 +10,7 @@ export interface User {
|
||||
losses: number
|
||||
win_rate: number
|
||||
status: 'active' | 'suspended' | 'pending_verification'
|
||||
is_admin: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
53
frontend/src/types/sport-event.ts
Normal file
53
frontend/src/types/sport-event.ts
Normal 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;
|
||||
}
|
||||
45
frontend/src/types/spread-bet.ts
Normal file
45
frontend/src/types/spread-bet.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user