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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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