Best landing page yet, lost logged in links to lists of bets
This commit is contained in:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user