This commit is contained in:
2026-01-02 10:43:20 -06:00
commit 14d9af3036
112 changed files with 14274 additions and 0 deletions

89
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,89 @@
import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
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 { BetMarketplace } from './pages/BetMarketplace'
import { BetDetails } from './pages/BetDetails'
import { MyBets } from './pages/MyBets'
import { Wallet } from './pages/Wallet'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
})
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore()
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />
}
function App() {
const { loadUser } = useAuthStore()
useEffect(() => {
loadUser()
}, [loadUser])
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/dashboard"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route
path="/marketplace"
element={
<PrivateRoute>
<BetMarketplace />
</PrivateRoute>
}
/>
<Route
path="/bets/:id"
element={
<PrivateRoute>
<BetDetails />
</PrivateRoute>
}
/>
<Route
path="/my-bets"
element={
<PrivateRoute>
<MyBets />
</PrivateRoute>
}
/>
<Route
path="/wallet"
element={
<PrivateRoute>
<Wallet />
</PrivateRoute>
}
/>
</Routes>
</BrowserRouter>
</QueryClientProvider>
)
}
export default App

24
frontend/src/api/auth.ts Normal file
View File

@ -0,0 +1,24 @@
import { apiClient } from './client'
import type { User, LoginData, RegisterData, TokenResponse } from '@/types'
export const authApi = {
register: async (data: RegisterData): Promise<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/api/v1/auth/register', data)
return response.data
},
login: async (data: LoginData): Promise<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/api/v1/auth/login', data)
return response.data
},
getCurrentUser: async (): Promise<User> => {
const response = await apiClient.get<User>('/api/v1/auth/me')
return response.data
},
refreshToken: async (refreshToken: string): Promise<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/api/v1/auth/refresh', { token: refreshToken })
return response.data
},
}

53
frontend/src/api/bets.ts Normal file
View File

@ -0,0 +1,53 @@
import { apiClient } from './client'
import type { Bet, BetDetail, CreateBetData, BetCategory } from '@/types'
export const betsApi = {
getBets: async (params?: { skip?: number; limit?: number; category?: BetCategory }): Promise<Bet[]> => {
const response = await apiClient.get<Bet[]>('/api/v1/bets', { params })
return response.data
},
getBet: async (id: number): Promise<BetDetail> => {
const response = await apiClient.get<BetDetail>(`/api/v1/bets/${id}`)
return response.data
},
createBet: async (data: CreateBetData): Promise<Bet> => {
const response = await apiClient.post<Bet>('/api/v1/bets', data)
return response.data
},
acceptBet: async (id: number): Promise<Bet> => {
const response = await apiClient.post<Bet>(`/api/v1/bets/${id}/accept`)
return response.data
},
settleBet: async (id: number, winnerId: number): Promise<BetDetail> => {
const response = await apiClient.post<BetDetail>(`/api/v1/bets/${id}/settle`, { winner_id: winnerId })
return response.data
},
cancelBet: async (id: number): Promise<void> => {
await apiClient.delete(`/api/v1/bets/${id}`)
},
getMyCreatedBets: async (): Promise<Bet[]> => {
const response = await apiClient.get<Bet[]>('/api/v1/bets/my/created')
return response.data
},
getMyAcceptedBets: async (): Promise<Bet[]> => {
const response = await apiClient.get<Bet[]>('/api/v1/bets/my/accepted')
return response.data
},
getMyActiveBets: async (): Promise<Bet[]> => {
const response = await apiClient.get<Bet[]>('/api/v1/bets/my/active')
return response.data
},
getMyHistory: async (): Promise<BetDetail[]> => {
const response = await apiClient.get<BetDetail[]>('/api/v1/bets/my/history')
return response.data
},
}

View File

@ -0,0 +1,32 @@
import axios from 'axios'
import { API_URL } from '@/utils/constants'
export const apiClient = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
})
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)

View File

@ -0,0 +1,26 @@
import { apiClient } from './client'
import type { Wallet, Transaction } from '@/types'
export const walletApi = {
getWallet: async (): Promise<Wallet> => {
const response = await apiClient.get<Wallet>('/api/v1/wallet')
return response.data
},
deposit: async (amount: string): Promise<Wallet> => {
const response = await apiClient.post<Wallet>('/api/v1/wallet/deposit', { amount })
return response.data
},
withdraw: async (amount: string): Promise<Wallet> => {
const response = await apiClient.post<Wallet>('/api/v1/wallet/withdraw', { amount })
return response.data
},
getTransactions: async (limit = 50, offset = 0): Promise<Transaction[]> => {
const response = await apiClient.get<Transaction[]>('/api/v1/wallet/transactions', {
params: { limit, offset },
})
return response.data
},
}

View File

@ -0,0 +1,138 @@
/**
* BlockchainBadge Component
*
* Visual indicator showing that a bet is backed by blockchain.
*
* Features:
* - Shows "On-Chain ⛓️" badge
* - Links to blockchain explorer (Etherscan, Polygonscan)
* - Different states: confirmed, pending, failed
* - Tooltip with transaction details
*/
import { FC } from 'react'
interface BlockchainBadgeProps {
status?: 'confirmed' | 'pending' | 'failed'
txHash?: string
chainId?: number
variant?: 'default' | 'compact'
}
export const BlockchainBadge: FC<BlockchainBadgeProps> = ({
status = 'confirmed',
txHash,
chainId = 11155111, // Sepolia default
variant = 'default'
}) => {
/**
* Get block explorer URL based on chain ID
*/
const getExplorerUrl = (hash: string) => {
const explorers: Record<number, string> = {
1: 'https://etherscan.io',
11155111: 'https://sepolia.etherscan.io',
137: 'https://polygonscan.com',
80001: 'https://mumbai.polygonscan.com'
}
const baseUrl = explorers[chainId] || explorers[11155111]
return `${baseUrl}/tx/${hash}`
}
/**
* Render badge based on status
*/
if (status === 'pending') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-300">
<svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{variant === 'compact' ? 'Pending' : 'Transaction Pending'}
</span>
)
}
if (status === 'failed') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 border border-red-300">
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
Failed
</span>
)
}
// Confirmed status
return (
<div className="inline-flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-300">
{variant === 'compact' ? (
<>
<span></span>
</>
) : (
<>
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span>On-Chain </span>
</>
)}
</span>
{txHash && (
<a
href={getExplorerUrl(txHash)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 underline"
onClick={(e) => e.stopPropagation()} // Prevent parent click events
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{variant === 'compact' ? 'View' : 'View on Explorer'}
</a>
)}
</div>
)
}
/**
* Compact version for use in lists
*/
export const BlockchainBadgeCompact: FC<Omit<BlockchainBadgeProps, 'variant'>> = (props) => {
return <BlockchainBadge {...props} variant="compact" />
}
/**
* Truncate transaction hash for display
*/
export const truncateHash = (hash: string, startChars = 6, endChars = 4): string => {
if (hash.length <= startChars + endChars) {
return hash
}
return `${hash.substring(0, startChars)}...${hash.substring(hash.length - endChars)}`
}
/**
* Badge with transaction hash display
*/
export const BlockchainBadgeWithHash: FC<BlockchainBadgeProps> = (props) => {
if (!props.txHash) {
return <BlockchainBadge {...props} />
}
return (
<div className="inline-flex items-center gap-2">
<BlockchainBadge {...props} />
<code className="text-xs text-gray-600 bg-gray-100 px-2 py-0.5 rounded font-mono">
{truncateHash(props.txHash)}
</code>
</div>
)
}

View File

@ -0,0 +1,289 @@
/**
* TransactionModal Component
*
* Modal that shows transaction progress during blockchain operations.
*
* States:
* - Pending: Waiting for user to sign in MetaMask
* - Confirming: Transaction submitted, waiting for block confirmation
* - Success: Transaction confirmed
* - Error: Transaction failed
*
* Features:
* - Animated loading states
* - Transaction hash with explorer link
* - Retry button on error
* - Auto-close on success (optional)
*/
import { FC, useEffect } from 'react'
import { Modal } from '@/components/common/Modal'
interface TransactionModalProps {
isOpen: boolean
status: 'idle' | 'pending' | 'confirming' | 'success' | 'error'
message?: string
txHash?: string | null
chainId?: number
onClose: () => void
onRetry?: () => void
autoCloseOnSuccess?: boolean
autoCloseDelay?: number // milliseconds
}
export const TransactionModal: FC<TransactionModalProps> = ({
isOpen,
status,
message,
txHash,
chainId = 11155111,
onClose,
onRetry,
autoCloseOnSuccess = false,
autoCloseDelay = 3000
}) => {
/**
* Auto-close on success
*/
useEffect(() => {
if (status === 'success' && autoCloseOnSuccess) {
const timer = setTimeout(() => {
onClose()
}, autoCloseDelay)
return () => clearTimeout(timer)
}
}, [status, autoCloseOnSuccess, autoCloseDelay, onClose])
/**
* Get block explorer URL
*/
const getExplorerUrl = (hash: string) => {
const explorers: Record<number, string> = {
1: 'https://etherscan.io',
11155111: 'https://sepolia.etherscan.io',
137: 'https://polygonscan.com',
80001: 'https://mumbai.polygonscan.com'
}
const baseUrl = explorers[chainId] || explorers[11155111]
return `${baseUrl}/tx/${hash}`
}
/**
* Render content based on status
*/
const renderContent = () => {
switch (status) {
case 'pending':
return (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-blue-100">
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Confirm Transaction
</h3>
<p className="text-sm text-gray-600 mb-4">
{message || 'Please confirm the transaction in your wallet'}
</p>
<div className="text-xs text-gray-500">
Waiting for wallet confirmation...
</div>
</div>
)
case 'confirming':
return (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 mb-4">
<svg className="animate-spin h-12 w-12 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Confirming Transaction
</h3>
<p className="text-sm text-gray-600 mb-4">
{message || 'Your transaction is being processed on the blockchain'}
</p>
<div className="space-y-2">
<div className="flex items-center justify-center gap-2 text-xs text-gray-600">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-pulse"></div>
<span>Waiting for block confirmation...</span>
</div>
{txHash && (
<a
href={getExplorerUrl(txHash)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 underline"
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
View on Explorer
</a>
)}
</div>
<p className="text-xs text-gray-500 mt-4">
Do not close this window
</p>
</div>
)
case 'success':
return (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-green-100">
<svg className="w-10 h-10 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Transaction Confirmed!
</h3>
<p className="text-sm text-gray-600 mb-4">
{message || 'Your transaction has been successfully processed'}
</p>
{txHash && (
<div className="space-y-2 mb-6">
<div className="text-xs text-gray-500">Transaction Hash</div>
<code className="block text-xs bg-gray-100 px-3 py-2 rounded font-mono break-all">
{txHash}
</code>
<a
href={getExplorerUrl(txHash)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 underline"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
View on Block Explorer
</a>
</div>
)}
<button
onClick={onClose}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Close
</button>
</div>
)
case 'error':
return (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-red-100">
<svg className="w-10 h-10 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Transaction Failed
</h3>
<p className="text-sm text-gray-600 mb-6">
{message || 'Your transaction could not be processed'}
</p>
<div className="flex gap-3 justify-center">
{onRetry && (
<button
onClick={onRetry}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try Again
</button>
)}
<button
onClick={onClose}
className="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
>
Close
</button>
</div>
</div>
)
default:
return null
}
}
return (
<Modal
isOpen={isOpen && status !== 'idle'}
onClose={status === 'confirming' ? () => {} : onClose} // Prevent closing during confirmation
title=""
>
{renderContent()}
</Modal>
)
}
/**
* Compact transaction status indicator (not a modal)
*/
export const TransactionStatusIndicator: FC<{
status: TransactionModalProps['status']
txHash?: string
chainId?: number
}> = ({ status, txHash, chainId }) => {
const getExplorerUrl = (hash: string) => {
const explorers: Record<number, string> = {
1: 'https://etherscan.io',
11155111: 'https://sepolia.etherscan.io',
137: 'https://polygonscan.com',
80001: 'https://mumbai.polygonscan.com'
}
return `${explorers[chainId || 11155111]}/tx/${hash}`
}
if (status === 'idle') return null
const statusConfig = {
pending: { color: 'yellow', icon: '⏳', text: 'Pending' },
confirming: { color: 'blue', icon: '🔄', text: 'Confirming' },
success: { color: 'green', icon: '✓', text: 'Confirmed' },
error: { color: 'red', icon: '✗', text: 'Failed' }
}
const config = statusConfig[status]
return (
<div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-${config.color}-100 text-${config.color}-800`}>
<span>{config.icon}</span>
<span className="text-sm font-medium">{config.text}</span>
{txHash && status !== 'pending' && (
<a
href={getExplorerUrl(txHash)}
target="_blank"
rel="noopener noreferrer"
className="text-xs underline"
>
View
</a>
)}
</div>
)
}

View File

@ -0,0 +1,310 @@
/**
* useBlockchainBet Hook
*
* Handles bet-related blockchain transactions.
*
* Features:
* - Create bet on blockchain
* - Accept bet (user signs with MetaMask)
* - Request oracle settlement
* - Monitor transaction status
*
* NOTE: This is pseudocode/skeleton showing the architecture.
* In production, you would use ethers.js or web3.js with the contract ABIs.
*/
import { useState, useCallback } from 'react'
import { useWeb3Wallet } from './useWeb3Wallet'
import type { CreateBetData } from '@/types'
interface TransactionStatus {
hash: string | null
status: 'idle' | 'pending' | 'confirming' | 'success' | 'error'
error: string | null
}
interface UseBlockchainBetReturn {
createBet: (betData: CreateBetData) => Promise<{ betId: number; txHash: string } | null>
acceptBet: (betId: number, stakeAmount: number) => Promise<string | null>
settleBet: (betId: number, winnerId: number) => Promise<string | null>
txStatus: TransactionStatus
isProcessing: boolean
}
export const useBlockchainBet = (): UseBlockchainBetReturn => {
const { walletAddress, isConnected } = useWeb3Wallet()
const [txStatus, setTxStatus] = useState<TransactionStatus>({
hash: null,
status: 'idle',
error: null
})
/**
* Create a bet on the blockchain
*
* Flow:
* 1. Create bet in backend database (gets local ID)
* 2. User signs transaction to create bet on blockchain
* 3. Wait for confirmation
* 4. Update backend with blockchain bet ID and tx hash
*/
const createBet = useCallback(async (betData: CreateBetData) => {
if (!isConnected || !walletAddress) {
setTxStatus({
hash: null,
status: 'error',
error: 'Please connect your wallet first'
})
return null
}
setTxStatus({ hash: null, status: 'pending', error: null })
try {
// Step 1: Create bet in backend (for metadata storage)
// Pseudocode:
// const backendResponse = await fetch('/api/v1/bets', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(betData)
// })
// const { id: localBetId } = await backendResponse.json()
const localBetId = 123 // Placeholder
// Step 2: Create bet on blockchain
setTxStatus(prev => ({ ...prev, status: 'confirming' }))
// Pseudocode: Call smart contract
// const contract = new ethers.Contract(
// BET_ESCROW_ADDRESS,
// BET_ESCROW_ABI,
// signer
// )
// const tx = await contract.createBet(
// ethers.utils.parseEther(betData.stake_amount.toString()),
// Math.floor(betData.creator_odds! * 100),
// Math.floor(betData.opponent_odds! * 100),
// Math.floor(new Date(betData.event_date!).getTime() / 1000),
// ethers.utils.formatBytes32String(betData.event_name)
// )
// setTxStatus(prev => ({ ...prev, hash: tx.hash }))
// // Wait for confirmation
// const receipt = await tx.wait()
// // Parse BetCreated event to get blockchain bet ID
// const event = receipt.events?.find(e => e.event === 'BetCreated')
// const blockchainBetId = event?.args?.betId.toNumber()
// Placeholder for pseudocode
const mockTxHash = '0xabc123def456...'
const blockchainBetId = 42
setTxStatus({
hash: mockTxHash,
status: 'success',
error: null
})
// Step 3: Update backend with blockchain data
// await fetch(`/api/v1/bets/${localBetId}`, {
// method: 'PATCH',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// blockchain_bet_id: blockchainBetId,
// blockchain_tx_hash: tx.hash
// })
// })
console.log('[Blockchain] Bet created:', {
localBetId,
blockchainBetId,
txHash: mockTxHash
})
return {
betId: blockchainBetId,
txHash: mockTxHash
}
} catch (error: any) {
console.error('[Blockchain] Create bet failed:', error)
setTxStatus({
hash: null,
status: 'error',
error: error.message || 'Transaction failed'
})
return null
}
}, [isConnected, walletAddress])
/**
* Accept a bet on the blockchain
*
* Flow:
* 1. User signs transaction with stake amount
* 2. Smart contract locks both parties' funds
* 3. Wait for confirmation
* 4. Backend indexer picks up BetMatched event
*/
const acceptBet = useCallback(async (betId: number, stakeAmount: number) => {
if (!isConnected || !walletAddress) {
setTxStatus({
hash: null,
status: 'error',
error: 'Please connect your wallet first'
})
return null
}
setTxStatus({ hash: null, status: 'pending', error: null })
try {
// Pseudocode: Call smart contract
// const contract = new ethers.Contract(
// BET_ESCROW_ADDRESS,
// BET_ESCROW_ABI,
// signer
// )
// const tx = await contract.acceptBet(betId, {
// value: ethers.utils.parseEther(stakeAmount.toString())
// })
// setTxStatus(prev => ({ ...prev, hash: tx.hash, status: 'confirming' }))
// // Wait for confirmation
// await tx.wait()
// Placeholder for pseudocode
const mockTxHash = '0xdef456ghi789...'
setTxStatus({
hash: mockTxHash,
status: 'confirming',
error: null
})
// Simulate confirmation delay
await new Promise(resolve => setTimeout(resolve, 2000))
setTxStatus({
hash: mockTxHash,
status: 'success',
error: null
})
console.log('[Blockchain] Bet accepted:', {
betId,
stakeAmount,
txHash: mockTxHash
})
// Notify backend
// await fetch(`/api/v1/bets/${betId}/accept`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ tx_hash: tx.hash })
// })
return mockTxHash
} catch (error: any) {
console.error('[Blockchain] Accept bet failed:', error)
// Handle user rejection
if (error.code === 4001) {
setTxStatus({
hash: null,
status: 'error',
error: 'Transaction rejected by user'
})
} else {
setTxStatus({
hash: null,
status: 'error',
error: error.message || 'Transaction failed'
})
}
return null
}
}, [isConnected, walletAddress])
/**
* Request oracle settlement for a bet
*
* This is typically called automatically after the event ends,
* but can also be manually triggered by participants.
*/
const settleBet = useCallback(async (betId: number, winnerId: number) => {
if (!isConnected || !walletAddress) {
setTxStatus({
hash: null,
status: 'error',
error: 'Please connect your wallet first'
})
return null
}
setTxStatus({ hash: null, status: 'pending', error: null })
try {
// For automatic settlement, backend calls the oracle
// For manual settlement (after timeout), users can call directly
// Pseudocode: Request settlement via backend
// const response = await fetch(`/api/v1/bets/${betId}/request-settlement`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ winner_id: winnerId })
// })
// const { request_id } = await response.json()
// Placeholder for pseudocode
const mockRequestId = 7
setTxStatus({
hash: null,
status: 'confirming',
error: null
})
console.log('[Blockchain] Settlement requested:', {
betId,
winnerId,
requestId: mockRequestId
})
// Settlement happens asynchronously via oracle network
// Frontend can poll for status or listen to WebSocket events
setTxStatus({
hash: null,
status: 'success',
error: null
})
return `request-${mockRequestId}`
} catch (error: any) {
console.error('[Blockchain] Settle bet failed:', error)
setTxStatus({
hash: null,
status: 'error',
error: error.message || 'Settlement request failed'
})
return null
}
}, [isConnected, walletAddress])
const isProcessing = txStatus.status === 'pending' || txStatus.status === 'confirming'
return {
createBet,
acceptBet,
settleBet,
txStatus,
isProcessing
}
}

View File

@ -0,0 +1,191 @@
/**
* useGasEstimate Hook
*
* Estimates gas costs for blockchain transactions before execution.
*
* Features:
* - Fetch current gas price
* - Estimate gas limit for specific operations
* - Calculate total cost in ETH and USD
* - Real-time gas price updates
*
* NOTE: This is pseudocode/skeleton showing the architecture.
*/
import { useState, useEffect, useCallback } from 'react'
import { useWeb3Wallet } from './useWeb3Wallet'
interface GasEstimate {
gasLimit: number
gasPrice: number // in wei
gasPriceGwei: number
costEth: string
costUsd: string
isLoading: boolean
error: string | null
}
interface UseGasEstimateReturn extends GasEstimate {
refresh: () => Promise<void>
}
type TransactionType = 'create_bet' | 'accept_bet' | 'settle_bet' | 'dispute_bet'
/**
* Hook to estimate gas costs for transactions
*/
export const useGasEstimate = (
transactionType: TransactionType,
params?: any
): UseGasEstimateReturn => {
const { isConnected, chainId } = useWeb3Wallet()
const [estimate, setEstimate] = useState<GasEstimate>({
gasLimit: 0,
gasPrice: 0,
gasPriceGwei: 0,
costEth: '0',
costUsd: '0',
isLoading: true,
error: null
})
/**
* Fetch gas estimate from backend
*/
const fetchEstimate = useCallback(async () => {
if (!isConnected) {
setEstimate(prev => ({
...prev,
isLoading: false,
error: 'Wallet not connected'
}))
return
}
setEstimate(prev => ({ ...prev, isLoading: true, error: null }))
try {
// Pseudocode: Call backend API for gas estimate
// const response = await fetch('/api/v1/blockchain/estimate-gas', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// transaction_type: transactionType,
// params: params
// })
// })
// const data = await response.json()
// setEstimate({
// gasLimit: data.gas_limit,
// gasPrice: data.gas_price,
// gasPriceGwei: data.gas_price / 1e9,
// costEth: data.cost_eth,
// costUsd: data.cost_usd,
// isLoading: false,
// error: null
// })
// Placeholder estimates for different transaction types
const estimates: Record<TransactionType, { gasLimit: number; gasPriceGwei: number }> = {
'create_bet': { gasLimit: 120000, gasPriceGwei: 50 },
'accept_bet': { gasLimit: 180000, gasPriceGwei: 50 },
'settle_bet': { gasLimit: 150000, gasPriceGwei: 50 },
'dispute_bet': { gasLimit: 100000, gasPriceGwei: 50 }
}
const { gasLimit, gasPriceGwei } = estimates[transactionType]
const gasPrice = gasPriceGwei * 1e9 // Convert to wei
const costWei = gasLimit * gasPrice
const costEth = (costWei / 1e18).toFixed(6)
const ethPriceUsd = 2000 // Placeholder - would fetch from price oracle
const costUsd = (parseFloat(costEth) * ethPriceUsd).toFixed(2)
setEstimate({
gasLimit,
gasPrice,
gasPriceGwei,
costEth,
costUsd,
isLoading: false,
error: null
})
console.log('[Gas] Estimate for', transactionType, ':', { costEth, costUsd })
} catch (error: any) {
console.error('[Gas] Failed to fetch estimate:', error)
setEstimate(prev => ({
...prev,
isLoading: false,
error: 'Failed to estimate gas'
}))
}
}, [transactionType, params, isConnected])
/**
* Fetch estimate on mount and when params change
*/
useEffect(() => {
fetchEstimate()
}, [fetchEstimate])
/**
* Refresh estimate every 30 seconds (gas prices change frequently)
*/
useEffect(() => {
if (!isConnected) return
const interval = setInterval(() => {
fetchEstimate()
}, 30000) // 30 seconds
return () => clearInterval(interval)
}, [fetchEstimate, isConnected])
return {
...estimate,
refresh: fetchEstimate
}
}
/**
* Hook to get current gas price (without specific transaction estimate)
*/
export const useGasPrice = () => {
const [gasPrice, setGasPrice] = useState<{
gwei: number
wei: number
isLoading: boolean
}>({
gwei: 0,
wei: 0,
isLoading: true
})
useEffect(() => {
const fetchGasPrice = async () => {
// Pseudocode: Fetch from RPC
// const provider = new ethers.providers.Web3Provider(window.ethereum)
// const price = await provider.getGasPrice()
// const gwei = parseFloat(ethers.utils.formatUnits(price, 'gwei'))
// Placeholder
const gwei = 50
const wei = gwei * 1e9
setGasPrice({
gwei,
wei,
isLoading: false
})
}
fetchGasPrice()
const interval = setInterval(fetchGasPrice, 30000)
return () => clearInterval(interval)
}, [])
return gasPrice
}

View File

@ -0,0 +1,323 @@
/**
* useWeb3Wallet Hook
*
* Manages MetaMask wallet connection and account state.
*
* Features:
* - Connect/disconnect wallet
* - Listen for account changes
* - Listen for network changes
* - Link wallet address to backend user account
*
* NOTE: This is pseudocode/skeleton showing the architecture.
* In production, you would use wagmi, ethers.js, or web3.js libraries.
*/
import { useState, useEffect, useCallback } from 'react'
interface Web3WalletState {
walletAddress: string | null
chainId: number | null
isConnected: boolean
isConnecting: boolean
error: string | null
}
interface Web3WalletHook extends Web3WalletState {
connectWallet: () => Promise<void>
disconnectWallet: () => void
switchNetwork: (chainId: number) => Promise<void>
}
// Declare window.ethereum type
declare global {
interface Window {
ethereum?: {
request: (args: { method: string; params?: any[] }) => Promise<any>
on: (event: string, callback: (...args: any[]) => void) => void
removeListener: (event: string, callback: (...args: any[]) => void) => void
selectedAddress: string | null
}
}
}
export const useWeb3Wallet = (): Web3WalletHook => {
const [state, setState] = useState<Web3WalletState>({
walletAddress: null,
chainId: null,
isConnected: false,
isConnecting: false,
error: null,
})
/**
* Check if MetaMask is installed
*/
const isMetaMaskInstalled = useCallback(() => {
return typeof window !== 'undefined' && typeof window.ethereum !== 'undefined'
}, [])
/**
* Connect to MetaMask wallet
*/
const connectWallet = useCallback(async () => {
if (!isMetaMaskInstalled()) {
setState(prev => ({
...prev,
error: 'Please install MetaMask to use blockchain features'
}))
return
}
setState(prev => ({ ...prev, isConnecting: true, error: null }))
try {
// Pseudocode: Request account access
// const accounts = await window.ethereum!.request({
// method: 'eth_requestAccounts'
// })
// const address = accounts[0]
// // Get current chain ID
// const chainId = await window.ethereum!.request({
// method: 'eth_chainId'
// })
// setState({
// walletAddress: address,
// chainId: parseInt(chainId, 16),
// isConnected: true,
// isConnecting: false,
// error: null
// })
// // Link wallet to backend user account
// await linkWalletToAccount(address)
// console.log('Wallet connected:', address)
// Placeholder for pseudocode
const mockAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'
const mockChainId = 11155111 // Sepolia
setState({
walletAddress: mockAddress,
chainId: mockChainId,
isConnected: true,
isConnecting: false,
error: null
})
console.log('[Web3] Wallet connected:', mockAddress)
} catch (error: any) {
console.error('Failed to connect wallet:', error)
setState(prev => ({
...prev,
isConnecting: false,
error: error.message || 'Failed to connect wallet'
}))
}
}, [isMetaMaskInstalled])
/**
* Disconnect wallet
*/
const disconnectWallet = useCallback(() => {
setState({
walletAddress: null,
chainId: null,
isConnected: false,
isConnecting: false,
error: null
})
console.log('[Web3] Wallet disconnected')
}, [])
/**
* Switch to a different network
*/
const switchNetwork = useCallback(async (targetChainId: number) => {
if (!isMetaMaskInstalled()) {
return
}
try {
// Pseudocode: Switch network
// await window.ethereum!.request({
// method: 'wallet_switchEthereumChain',
// params: [{ chainId: `0x${targetChainId.toString(16)}` }]
// })
console.log(`[Web3] Switched to chain ID: ${targetChainId}`)
} catch (error: any) {
// If network doesn't exist, add it
if (error.code === 4902) {
console.log('[Web3] Network not added, prompting user to add it')
// Pseudocode: Add network
// await window.ethereum!.request({
// method: 'wallet_addEthereumChain',
// params: [getNetworkConfig(targetChainId)]
// })
}
console.error('Failed to switch network:', error)
}
}, [isMetaMaskInstalled])
/**
* Link wallet address to backend user account
*/
const linkWalletToAccount = async (address: string) => {
// Pseudocode: Call backend API
// try {
// await fetch('/api/v1/users/link-wallet', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// 'Authorization': `Bearer ${getAccessToken()}`
// },
// body: JSON.stringify({ wallet_address: address })
// })
// console.log('[Web3] Wallet linked to user account')
// } catch (error) {
// console.error('[Web3] Failed to link wallet:', error)
// }
console.log('[Web3] Wallet linked to account:', address)
}
/**
* Listen for account changes
*/
useEffect(() => {
if (!isMetaMaskInstalled()) {
return
}
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
// User disconnected
disconnectWallet()
} else if (accounts[0] !== state.walletAddress) {
// User switched accounts
setState(prev => ({
...prev,
walletAddress: accounts[0]
}))
console.log('[Web3] Account changed to:', accounts[0])
}
}
// Pseudocode: Add event listener
// window.ethereum!.on('accountsChanged', handleAccountsChanged)
// return () => {
// window.ethereum!.removeListener('accountsChanged', handleAccountsChanged)
// }
}, [isMetaMaskInstalled, state.walletAddress, disconnectWallet])
/**
* Listen for network changes
*/
useEffect(() => {
if (!isMetaMaskInstalled()) {
return
}
const handleChainChanged = (chainId: string) => {
const newChainId = parseInt(chainId, 16)
setState(prev => ({
...prev,
chainId: newChainId
}))
console.log('[Web3] Network changed to chain ID:', newChainId)
// Refresh page on network change (recommended by MetaMask)
// window.location.reload()
}
// Pseudocode: Add event listener
// window.ethereum!.on('chainChanged', handleChainChanged)
// return () => {
// window.ethereum!.removeListener('chainChanged', handleChainChanged)
// }
}, [isMetaMaskInstalled])
/**
* Check if already connected on mount
*/
useEffect(() => {
const checkConnection = async () => {
if (!isMetaMaskInstalled()) {
return
}
// Pseudocode: Check if already connected
// const accounts = await window.ethereum!.request({
// method: 'eth_accounts'
// })
// if (accounts.length > 0) {
// const chainId = await window.ethereum!.request({
// method: 'eth_chainId'
// })
// setState({
// walletAddress: accounts[0],
// chainId: parseInt(chainId, 16),
// isConnected: true,
// isConnecting: false,
// error: null
// })
// }
}
checkConnection()
}, [isMetaMaskInstalled])
return {
...state,
connectWallet,
disconnectWallet,
switchNetwork,
}
}
/**
* Network configuration for wallet_addEthereumChain
*/
const getNetworkConfig = (chainId: number) => {
const configs: Record<number, any> = {
1: {
chainId: '0x1',
chainName: 'Ethereum Mainnet',
nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://eth-mainnet.alchemyapi.io/v2/YOUR-API-KEY'],
blockExplorerUrls: ['https://etherscan.io']
},
11155111: {
chainId: '0xaa36a7',
chainName: 'Sepolia Testnet',
nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://rpc.sepolia.org'],
blockExplorerUrls: ['https://sepolia.etherscan.io']
},
137: {
chainId: '0x89',
chainName: 'Polygon Mainnet',
nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 },
rpcUrls: ['https://polygon-rpc.com'],
blockExplorerUrls: ['https://polygonscan.com']
},
80001: {
chainId: '0x13881',
chainName: 'Polygon Mumbai Testnet',
nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 },
rpcUrls: ['https://rpc-mumbai.maticvigil.com'],
blockExplorerUrls: ['https://mumbai.polygonscan.com']
}
}
return configs[chainId]
}

View File

@ -0,0 +1,70 @@
import { useState, FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store'
import { Button } from '@/components/common/Button'
export const LoginForm = () => {
const navigate = useNavigate()
const { login } = useAuthStore()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
await login(email, password)
navigate('/dashboard')
} catch (err: any) {
setError(err.response?.data?.detail || 'Login failed. Please try again.')
} finally {
setIsLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-error/10 border border-error text-error px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
)
}

View File

@ -0,0 +1,101 @@
import { useState, FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store'
import { Button } from '@/components/common/Button'
export const RegisterForm = () => {
const navigate = useNavigate()
const { register } = useAuthStore()
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
await register(email, username, password, displayName || undefined)
navigate('/dashboard')
} catch (err: any) {
setError(err.response?.data?.detail || 'Registration failed. Please try again.')
} finally {
setIsLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-error/10 border border-error text-error px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label htmlFor="displayName" className="block text-sm font-medium text-gray-700 mb-1">
Display Name (optional)
</label>
<input
id="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Registering...' : 'Register'}
</Button>
</form>
)
}

View File

@ -0,0 +1,66 @@
import { Link } from 'react-router-dom'
import type { Bet } from '@/types'
import { Card } from '@/components/common/Card'
import { formatCurrency, formatRelativeTime } from '@/utils/formatters'
import { BET_CATEGORIES, BET_STATUS_COLORS } from '@/utils/constants'
import { Calendar, DollarSign, TrendingUp } from 'lucide-react'
import { BlockchainBadgeCompact } from '@/blockchain/components/BlockchainBadge'
interface BetCardProps {
bet: Bet
}
export const BetCard = ({ bet }: BetCardProps) => {
return (
<Link to={`/bets/${bet.id}`}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
<div className="space-y-3">
<div className="flex items-start justify-between">
<h3 className="text-lg font-semibold text-gray-900">{bet.title}</h3>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${BET_STATUS_COLORS[bet.status]}`}>
{bet.status}
</span>
{bet.blockchain_tx_hash && (
<BlockchainBadgeCompact
status="confirmed"
txHash={bet.blockchain_tx_hash}
/>
)}
</div>
</div>
<p className="text-sm text-gray-600 line-clamp-2">{bet.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-1">
<Calendar size={16} />
<span>{bet.event_name}</span>
</div>
<span className="px-2 py-0.5 bg-gray-100 rounded text-xs">
{BET_CATEGORIES[bet.category]}
</span>
</div>
<div className="pt-3 border-t space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-primary font-semibold">
<DollarSign size={18} />
{formatCurrency(bet.stake_amount)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<TrendingUp size={16} />
<span>{bet.creator_odds}x / {bet.opponent_odds}x</span>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Created by {bet.creator.display_name || bet.creator.username}</span>
<span className="text-gray-500">{formatRelativeTime(bet.created_at)}</span>
</div>
</div>
</div>
</Card>
</Link>
)
}

View File

@ -0,0 +1,24 @@
import type { Bet } from '@/types'
import { BetCard } from './BetCard'
interface BetListProps {
bets: Bet[]
}
export const BetList = ({ bets }: BetListProps) => {
if (bets.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
No bets found
</div>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{bets.map((bet) => (
<BetCard key={bet.id} bet={bet} />
))}
</div>
)
}

View File

@ -0,0 +1,272 @@
import { useState, FormEvent } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { betsApi } from '@/api/bets'
import { Modal } from '@/components/common/Modal'
import { Button } from '@/components/common/Button'
import type { BetCategory } from '@/types'
import { BET_CATEGORIES } from '@/utils/constants'
import { useBlockchainBet } from '@/blockchain/hooks/useBlockchainBet'
import { useGasEstimate } from '@/blockchain/hooks/useGasEstimate'
import { TransactionModal } from '@/blockchain/components/TransactionModal'
interface CreateBetModalProps {
isOpen: boolean
onClose: () => void
}
export const CreateBetModal = ({ isOpen, onClose }: CreateBetModalProps) => {
const queryClient = useQueryClient()
const [formData, setFormData] = useState({
title: '',
description: '',
category: 'sports' as BetCategory,
event_name: '',
creator_position: '',
opponent_position: '',
stake_amount: '',
creator_odds: '1',
opponent_odds: '1',
})
// Blockchain integration
const { createBet: createBlockchainBet, txStatus, txHash } = useBlockchainBet()
const gasEstimate = useGasEstimate(
'create_bet',
{ stakeAmount: parseFloat(formData.stake_amount) || 0 }
)
const createMutation = useMutation({
mutationFn: betsApi.createBet,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bets'] })
onClose()
setFormData({
title: '',
description: '',
category: 'sports',
event_name: '',
creator_position: '',
opponent_position: '',
stake_amount: '',
creator_odds: '1',
opponent_odds: '1',
})
},
})
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
const betData = {
...formData,
stake_amount: parseFloat(formData.stake_amount),
creator_odds: parseFloat(formData.creator_odds),
opponent_odds: parseFloat(formData.opponent_odds),
}
// Create bet with blockchain integration
// This will: 1) Create in backend, 2) Sign transaction, 3) Update with blockchain ID
try {
await createBlockchainBet(betData)
queryClient.invalidateQueries({ queryKey: ['bets'] })
onClose()
setFormData({
title: '',
description: '',
category: 'sports',
event_name: '',
creator_position: '',
opponent_position: '',
stake_amount: '',
creator_odds: '1',
opponent_odds: '1',
})
} catch (error) {
console.error('Failed to create bet:', error)
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Create New Bet">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Title
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
maxLength={200}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
required
rows={3}
maxLength={2000}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value as BetCategory })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
{Object.entries(BET_CATEGORIES).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Event Name
</label>
<input
type="text"
value={formData.event_name}
onChange={(e) => setFormData({ ...formData, event_name: e.target.value })}
required
maxLength={200}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Your Position
</label>
<input
type="text"
value={formData.creator_position}
onChange={(e) => setFormData({ ...formData, creator_position: e.target.value })}
required
maxLength={500}
placeholder="What are you betting on?"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Opponent Position
</label>
<input
type="text"
value={formData.opponent_position}
onChange={(e) => setFormData({ ...formData, opponent_position: e.target.value })}
required
maxLength={500}
placeholder="What is the opposing position?"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Stake Amount ($)
</label>
<input
type="number"
value={formData.stake_amount}
onChange={(e) => setFormData({ ...formData, stake_amount: e.target.value })}
required
min="0.01"
max="10000"
step="0.01"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Your Odds
</label>
<input
type="number"
value={formData.creator_odds}
onChange={(e) => setFormData({ ...formData, creator_odds: e.target.value })}
min="0.01"
step="0.1"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Opponent Odds
</label>
<input
type="number"
value={formData.opponent_odds}
onChange={(e) => setFormData({ ...formData, opponent_odds: e.target.value })}
min="0.01"
step="0.1"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Gas Estimate */}
{formData.stake_amount && parseFloat(formData.stake_amount) > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="text-xs font-medium text-blue-800 mb-1">Estimated Gas Cost</div>
{gasEstimate.isLoading ? (
<div className="text-sm text-blue-600">Calculating...</div>
) : gasEstimate.error ? (
<div className="text-sm text-red-600">{gasEstimate.error}</div>
) : (
<div className="flex items-center justify-between text-sm">
<span className="text-blue-900">{gasEstimate.costEth} ETH</span>
<span className="text-blue-700"> ${gasEstimate.costUsd}</span>
</div>
)}
</div>
)}
<div className="flex gap-4 pt-4">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={txStatus === 'pending' || txStatus === 'confirming'}>
{txStatus === 'pending' || txStatus === 'confirming' ? 'Processing...' : 'Create Bet'}
</Button>
</div>
</form>
{/* Transaction Status Modal */}
<TransactionModal
isOpen={txStatus !== 'idle'}
status={txStatus}
txHash={txHash}
message={
txStatus === 'success'
? 'Your bet has been created on the blockchain!'
: txStatus === 'error'
? 'Failed to create bet on blockchain'
: undefined
}
onClose={() => {}}
autoCloseOnSuccess={true}
autoCloseDelay={2000}
/>
</Modal>
)
}

View File

@ -0,0 +1,39 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'success'
size?: 'sm' | 'md' | 'lg'
children: ReactNode
}
export const Button = ({
variant = 'primary',
size = 'md',
className = '',
children,
...props
}: ButtonProps) => {
const baseClasses = 'font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
const variants = {
primary: 'bg-primary text-white hover:bg-blue-600',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
danger: 'bg-error text-white hover:bg-red-600',
success: 'bg-success text-white hover:bg-green-600',
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
}
return (
<button
className={`${baseClasses} ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</button>
)
}

View File

@ -0,0 +1,14 @@
import { ReactNode } from 'react'
interface CardProps {
children: ReactNode
className?: string
}
export const Card = ({ children, className = '' }: CardProps) => {
return (
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
{children}
</div>
)
}

View File

@ -0,0 +1,7 @@
export const Loading = () => {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
)
}

View File

@ -0,0 +1,37 @@
import { ReactNode } from 'react'
import { X } from 'lucide-react'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: ReactNode
}
export const Modal = ({ isOpen, onClose, title, children }: ModalProps) => {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
<div className="relative bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-2xl font-bold">{title}</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={24} />
</button>
</div>
<div className="p-6">
{children}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,72 @@
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store'
import { Wallet, LogOut, User } from 'lucide-react'
import { useWeb3Wallet } from '@/blockchain/hooks/useWeb3Wallet'
export const Header = () => {
const { user, logout } = useAuthStore()
const { walletAddress, isConnected, connectWallet, disconnectWallet } = useWeb3Wallet()
return (
<header className="bg-white shadow-sm">
<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 && (
<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>
<Link to="/my-bets" className="text-gray-700 hover:text-primary transition-colors">
My Bets
</Link>
<Link to="/wallet" className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
<Wallet size={18} />
Wallet
</Link>
{/* Web3 Wallet Connection */}
{isConnected ? (
<button
onClick={disconnectWallet}
className="flex items-center gap-2 px-3 py-1.5 bg-green-100 text-green-800 rounded-lg hover:bg-green-200 transition-colors text-sm font-medium"
>
<span></span>
<span>{walletAddress?.substring(0, 6)}...{walletAddress?.substring(38)}</span>
</button>
) : (
<button
onClick={connectWallet}
className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-800 rounded-lg hover:bg-blue-200 transition-colors text-sm font-medium"
>
<span></span>
<span>Connect Wallet</span>
</button>
)}
<div className="flex items-center gap-4 pl-6 border-l">
<Link to="/profile" className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
<User size={18} />
{user.display_name || user.username}
</Link>
<button
onClick={logout}
className="flex items-center gap-2 text-gray-700 hover:text-error transition-colors"
>
<LogOut size={18} />
Logout
</button>
</div>
</nav>
)}
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,17 @@
import { ReactNode } from 'react'
import { Header } from './Header'
interface LayoutProps {
children: ReactNode
}
export const Layout = ({ children }: LayoutProps) => {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</div>
)
}

View File

@ -0,0 +1,65 @@
import { useState, FormEvent } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { walletApi } from '@/api/wallet'
import { Modal } from '@/components/common/Modal'
import { Button } from '@/components/common/Button'
interface DepositModalProps {
isOpen: boolean
onClose: () => void
}
export const DepositModal = ({ isOpen, onClose }: DepositModalProps) => {
const queryClient = useQueryClient()
const [amount, setAmount] = useState('')
const depositMutation = useMutation({
mutationFn: walletApi.deposit,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wallet'] })
queryClient.invalidateQueries({ queryKey: ['transactions'] })
setAmount('')
onClose()
},
})
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
depositMutation.mutate(amount)
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Deposit Funds">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Amount ($)
</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
min="0.01"
max="10000"
step="0.01"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Enter amount"
/>
<p className="mt-1 text-sm text-gray-500">
This is a simulated deposit for MVP testing purposes.
</p>
</div>
<div className="flex gap-4">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={depositMutation.isPending}>
{depositMutation.isPending ? 'Processing...' : 'Deposit'}
</Button>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,54 @@
import { useQuery } from '@tanstack/react-query'
import { walletApi } from '@/api/wallet'
import { Card } from '@/components/common/Card'
import { formatCurrency, formatDateTime } from '@/utils/formatters'
import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
import { Loading } from '@/components/common/Loading'
export const TransactionHistory = () => {
const { data: transactions, isLoading } = useQuery({
queryKey: ['transactions'],
queryFn: () => walletApi.getTransactions(),
})
if (isLoading) return <Loading />
return (
<Card>
<h2 className="text-xl font-bold mb-4">Transaction History</h2>
<div className="space-y-3">
{transactions && transactions.length > 0 ? (
transactions.map((tx) => (
<div key={tx.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${
parseFloat(tx.amount) >= 0 ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
}`}>
{parseFloat(tx.amount) >= 0 ? <ArrowDownRight size={20} /> : <ArrowUpRight size={20} />}
</div>
<div>
<p className="font-medium">{tx.description}</p>
<p className="text-sm text-gray-500">{formatDateTime(tx.created_at)}</p>
</div>
</div>
<div className="text-right">
<p className={`font-semibold ${
parseFloat(tx.amount) >= 0 ? 'text-success' : 'text-error'
}`}>
{parseFloat(tx.amount) >= 0 ? '+' : ''}{formatCurrency(tx.amount)}
</p>
<p className="text-sm text-gray-500">
Balance: {formatCurrency(tx.balance_after)}
</p>
</div>
</div>
))
) : (
<p className="text-center text-gray-500 py-8">No transactions yet</p>
)}
</div>
</Card>
)
}

View File

@ -0,0 +1,94 @@
import { useQuery } from '@tanstack/react-query'
import { walletApi } from '@/api/wallet'
import { Card } from '@/components/common/Card'
import { formatCurrency } from '@/utils/formatters'
import { Wallet, Lock } from 'lucide-react'
import { Loading } from '@/components/common/Loading'
import { useWeb3Wallet } from '@/blockchain/hooks/useWeb3Wallet'
import { BlockchainBadge } from '@/blockchain/components/BlockchainBadge'
export const WalletBalance = () => {
const { data: wallet, isLoading } = useQuery({
queryKey: ['wallet'],
queryFn: walletApi.getWallet,
})
// Blockchain integration
const { walletAddress, isConnected, walletBalance } = useWeb3Wallet()
if (isLoading) return <Loading />
if (!wallet) return null
const totalFunds = parseFloat(wallet.balance) + parseFloat(wallet.escrow)
const onChainEscrow = wallet.blockchain_escrow || 0 // Placeholder for on-chain escrow amount
return (
<Card>
<div className="space-y-4">
<h2 className="text-xl font-bold flex items-center gap-2">
<Wallet size={24} />
Wallet Balance
</h2>
<div className="space-y-3">
<div className="flex justify-between items-center p-4 bg-primary/10 rounded-lg">
<span className="text-gray-700 font-medium">Available Balance</span>
<span className="text-2xl font-bold text-primary">{formatCurrency(wallet.balance)}</span>
</div>
<div className="flex justify-between items-center p-4 bg-warning/10 rounded-lg">
<span className="text-gray-700 font-medium flex items-center gap-2">
<Lock size={18} />
Locked in Escrow
</span>
<span className="text-xl font-semibold text-warning">{formatCurrency(wallet.escrow)}</span>
</div>
<div className="flex justify-between items-center p-4 bg-gray-100 rounded-lg">
<span className="text-gray-700 font-medium">Total Funds</span>
<span className="text-xl font-semibold text-gray-900">{formatCurrency(totalFunds)}</span>
</div>
</div>
{/* On-Chain Escrow Section */}
{isConnected && (
<div className="mt-6 pt-6 border-t">
<div className="flex items-center gap-2 mb-3">
<h3 className="text-lg font-semibold text-gray-900">On-Chain Escrow</h3>
<BlockchainBadge status="confirmed" variant="compact" />
</div>
<div className="space-y-3">
<div className="flex justify-between items-center p-4 bg-green-50 border border-green-200 rounded-lg">
<span className="text-gray-700 font-medium">Locked in Smart Contract</span>
<div className="text-right">
<div className="text-lg font-semibold text-green-800">
{onChainEscrow} ETH
</div>
<div className="text-sm text-green-600">
{formatCurrency(onChainEscrow * 2000)}
</div>
</div>
</div>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-600">Wallet Balance</span>
<span className="text-sm font-medium text-gray-900">{walletBalance} ETH</span>
</div>
{walletAddress && (
<div className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-600">Wallet Address</span>
<code className="text-xs text-gray-700 bg-gray-200 px-2 py-1 rounded font-mono">
{walletAddress.substring(0, 6)}...{walletAddress.substring(38)}
</code>
</div>
)}
</div>
</div>
)}
</div>
</Card>
)
}

17
frontend/src/index.css Normal file
View File

@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,238 @@
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { Card } from '@/components/common/Card'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { betsApi } from '@/api/bets'
import { useAuthStore } from '@/store'
import { formatCurrency, formatDateTime } from '@/utils/formatters'
import { BET_CATEGORIES, BET_STATUS_COLORS } from '@/utils/constants'
import { Calendar, User, DollarSign, TrendingUp } from 'lucide-react'
import { useBlockchainBet } from '@/blockchain/hooks/useBlockchainBet'
import { useGasEstimate } from '@/blockchain/hooks/useGasEstimate'
import { BlockchainBadge } from '@/blockchain/components/BlockchainBadge'
import { TransactionModal } from '@/blockchain/components/TransactionModal'
export const BetDetails = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { user } = useAuthStore()
const queryClient = useQueryClient()
const { data: bet, isLoading } = useQuery({
queryKey: ['bet', id],
queryFn: () => betsApi.getBet(Number(id)),
})
// Blockchain integration
const { acceptBet: acceptBlockchainBet, txStatus, txHash } = useBlockchainBet()
const gasEstimate = useGasEstimate('accept_bet', { stakeAmount: bet?.stake_amount || 0 })
const acceptMutation = useMutation({
mutationFn: () => betsApi.acceptBet(Number(id)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bet', id] })
queryClient.invalidateQueries({ queryKey: ['bets'] })
queryClient.invalidateQueries({ queryKey: ['wallet'] })
},
})
const settleMutation = useMutation({
mutationFn: (winnerId: number) => betsApi.settleBet(Number(id), winnerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bet', id] })
queryClient.invalidateQueries({ queryKey: ['wallet'] })
},
})
if (isLoading) return <Layout><Loading /></Layout>
if (!bet) return <Layout><div>Bet not found</div></Layout>
const isCreator = user?.id === bet.creator.id
const isOpponent = user?.id === bet.opponent?.id
const isParticipant = isCreator || isOpponent
const canAccept = bet.status === 'open' && !isCreator
const canSettle = bet.status === 'matched' && isParticipant
return (
<Layout>
<div className="max-w-4xl mx-auto space-y-6">
<Button variant="secondary" onClick={() => navigate(-1)}>
Back
</Button>
<Card>
<div className="space-y-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{bet.title}</h1>
<div className="flex items-center gap-3 text-sm text-gray-600">
<span className="px-2 py-0.5 bg-gray-100 rounded">
{BET_CATEGORIES[bet.category]}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${BET_STATUS_COLORS[bet.status]}`}>
{bet.status}
</span>
{bet.blockchain_tx_hash && (
<BlockchainBadge
status="confirmed"
txHash={bet.blockchain_tx_hash}
/>
)}
</div>
</div>
</div>
<p className="text-gray-700 text-lg">{bet.description}</p>
<div className="grid md:grid-cols-2 gap-6 pt-6 border-t">
<div className="space-y-4">
<h3 className="font-semibold text-lg">Bet Details</h3>
<div className="flex items-center gap-3">
<Calendar size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Event</p>
<p className="font-medium">{bet.event_name}</p>
</div>
</div>
{bet.event_date && (
<div className="flex items-center gap-3">
<Calendar size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Event Date</p>
<p className="font-medium">{formatDateTime(bet.event_date)}</p>
</div>
</div>
)}
<div className="flex items-center gap-3">
<DollarSign size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Stake Amount</p>
<p className="font-medium text-xl text-primary">{formatCurrency(bet.stake_amount)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<TrendingUp size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Odds</p>
<p className="font-medium">{bet.creator_odds}x / {bet.opponent_odds}x</p>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-lg">Participants</h3>
<div className="flex items-center gap-3">
<User size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Creator</p>
<p className="font-medium">{bet.creator.display_name || bet.creator.username}</p>
<p className="text-sm text-gray-600 mt-1">Position: {bet.creator_position}</p>
</div>
</div>
<div className="flex items-center gap-3">
<User size={20} className="text-gray-400" />
<div>
<p className="text-sm text-gray-600">Opponent</p>
{bet.opponent ? (
<>
<p className="font-medium">{bet.opponent.display_name || bet.opponent.username}</p>
<p className="text-sm text-gray-600 mt-1">Position: {bet.opponent_position}</p>
</>
) : (
<p className="font-medium text-gray-400">Waiting for opponent...</p>
)}
</div>
</div>
</div>
</div>
{canAccept && (
<div className="pt-6 border-t space-y-4">
{/* Gas Estimate */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-sm font-medium text-blue-800 mb-2">Estimated Gas Cost</div>
{gasEstimate.isLoading ? (
<div className="text-sm text-blue-600">Calculating...</div>
) : gasEstimate.error ? (
<div className="text-sm text-red-600">{gasEstimate.error}</div>
) : (
<div className="flex items-center justify-between">
<span className="text-blue-900 font-medium">{gasEstimate.costEth} ETH</span>
<span className="text-blue-700"> ${gasEstimate.costUsd}</span>
</div>
)}
</div>
<Button
onClick={async () => {
try {
await acceptBlockchainBet(Number(id), bet.stake_amount)
queryClient.invalidateQueries({ queryKey: ['bet', id] })
queryClient.invalidateQueries({ queryKey: ['bets'] })
queryClient.invalidateQueries({ queryKey: ['wallet'] })
} catch (error) {
console.error('Failed to accept bet:', error)
}
}}
disabled={txStatus === 'pending' || txStatus === 'confirming'}
className="w-full"
>
{txStatus === 'pending' || txStatus === 'confirming'
? 'Processing...'
: `Accept Bet - ${formatCurrency(bet.stake_amount)}`}
</Button>
</div>
)}
{canSettle && (
<div className="pt-6 border-t space-y-4">
<h3 className="font-semibold">Settle Bet</h3>
<div className="flex gap-4">
<Button
onClick={() => settleMutation.mutate(bet.creator.id)}
disabled={settleMutation.isPending}
className="flex-1"
>
{bet.creator.display_name || bet.creator.username} Won
</Button>
<Button
onClick={() => settleMutation.mutate(bet.opponent!.id)}
disabled={settleMutation.isPending}
className="flex-1"
>
{bet.opponent?.display_name || bet.opponent?.username} Won
</Button>
</div>
</div>
)}
</div>
</Card>
</div>
{/* Transaction Status Modal */}
<TransactionModal
isOpen={txStatus !== 'idle'}
status={txStatus}
txHash={txHash}
message={
txStatus === 'success'
? 'Bet accepted successfully on blockchain!'
: txStatus === 'error'
? 'Failed to accept bet on blockchain'
: undefined
}
onClose={() => {}}
autoCloseOnSuccess={true}
autoCloseDelay={2000}
/>
</Layout>
)
}

View File

@ -0,0 +1,75 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { BetList } from '@/components/bets/BetList'
import { CreateBetModal } from '@/components/bets/CreateBetModal'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { betsApi } from '@/api/bets'
import { Plus } from 'lucide-react'
import type { BetCategory } from '@/types'
import { BET_CATEGORIES } from '@/utils/constants'
export const BetMarketplace = () => {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [selectedCategory, setSelectedCategory] = useState<BetCategory | undefined>()
const { data: bets, isLoading } = useQuery({
queryKey: ['bets', selectedCategory],
queryFn: () => betsApi.getBets({ category: selectedCategory }),
})
return (
<Layout>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Bet Marketplace</h1>
<p className="text-gray-600 mt-2">Browse and accept open bets from other users</p>
</div>
<Button onClick={() => setIsCreateModalOpen(true)}>
<Plus size={20} className="mr-2" />
Create Bet
</Button>
</div>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setSelectedCategory(undefined)}
className={`px-4 py-2 rounded-lg transition-colors ${
selectedCategory === undefined
? 'bg-primary text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
All
</button>
{Object.entries(BET_CATEGORIES).map(([value, label]) => (
<button
key={value}
onClick={() => setSelectedCategory(value as BetCategory)}
className={`px-4 py-2 rounded-lg transition-colors ${
selectedCategory === value
? 'bg-primary text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{label}
</button>
))}
</div>
{isLoading ? (
<Loading />
) : bets ? (
<BetList bets={bets} />
) : null}
</div>
<CreateBetModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
/>
</Layout>
)
}

View File

@ -0,0 +1,112 @@
import { useQuery } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { Card } from '@/components/common/Card'
import { Loading } from '@/components/common/Loading'
import { betsApi } from '@/api/bets'
import { walletApi } from '@/api/wallet'
import { useAuthStore } from '@/store'
import { formatCurrency } from '@/utils/formatters'
import { TrendingUp, Activity, Award, Wallet } from 'lucide-react'
export const Dashboard = () => {
const { user } = useAuthStore()
const { data: wallet } = useQuery({
queryKey: ['wallet'],
queryFn: walletApi.getWallet,
})
const { data: activeBets, isLoading } = useQuery({
queryKey: ['myActiveBets'],
queryFn: betsApi.getMyActiveBets,
})
if (isLoading) return <Layout><Loading /></Layout>
return (
<Layout>
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">
Welcome back, {user?.display_name || user?.username}!
</h1>
<p className="text-gray-600 mt-2">Here's an overview of your betting activity</p>
</div>
<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">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-success/10 p-3 rounded-lg">
<Activity 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-warning/10 p-3 rounded-lg">
<TrendingUp className="text-warning" size={24} />
</div>
<div>
<p className="text-sm text-gray-600">Total Bets</p>
<p className="text-2xl font-bold text-gray-900">{user?.total_bets || 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>
<Card>
<h2 className="text-xl font-bold mb-4">Active Bets</h2>
{activeBets && activeBets.length > 0 ? (
<div className="space-y-3">
{activeBets.map((bet) => (
<div key={bet.id} className="flex justify-between items-center p-4 border rounded-lg">
<div>
<h3 className="font-semibold">{bet.title}</h3>
<p className="text-sm text-gray-600">{bet.event_name}</p>
</div>
<div className="text-right">
<p className="font-semibold text-primary">{formatCurrency(bet.stake_amount)}</p>
<p className="text-sm text-gray-500">{bet.status}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-center text-gray-500 py-8">No active bets. Check out the marketplace!</p>
)}
</Card>
</div>
</Layout>
)
}

View File

@ -0,0 +1,61 @@
import { Link } from 'react-router-dom'
import { Button } from '@/components/common/Button'
import { TrendingUp, Shield, Zap } from 'lucide-react'
export const Home = () => {
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="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>
</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>
</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>
<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} />
</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>
</div>
</div>
)
}

View File

@ -0,0 +1,29 @@
import { Link } from 'react-router-dom'
import { LoginForm } from '@/components/auth/LoginForm'
import { Card } from '@/components/common/Card'
export const Login = () => {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-primary mb-2">H2H</h1>
<h2 className="text-2xl font-semibold text-gray-900">Login to your account</h2>
</div>
<Card>
<LoginForm />
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Don't have an account?{' '}
<Link to="/register" className="text-primary hover:underline font-medium">
Register here
</Link>
</p>
</div>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,78 @@
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'
export const MyBets = () => {
const [activeTab, setActiveTab] = useState<'created' | 'accepted' | '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,
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
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>
</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: 'history' as const, label: 'History' },
].map(({ key, label }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`px-6 py-3 font-medium transition-colors ${
activeTab === key
? 'text-primary border-b-2 border-primary'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{label}
</button>
))}
</div>
{currentBets ? (
<BetList bets={currentBets} />
) : (
<Loading />
)}
</div>
</Layout>
)
}

View File

@ -0,0 +1,29 @@
import { Link } from 'react-router-dom'
import { RegisterForm } from '@/components/auth/RegisterForm'
import { Card } from '@/components/common/Card'
export const Register = () => {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4 py-8">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-primary mb-2">H2H</h1>
<h2 className="text-2xl font-semibold text-gray-900">Create your account</h2>
</div>
<Card>
<RegisterForm />
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Already have an account?{' '}
<Link to="/login" className="text-primary hover:underline font-medium">
Login here
</Link>
</p>
</div>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,36 @@
import { useState } from 'react'
import { Layout } from '@/components/layout/Layout'
import { WalletBalance } from '@/components/wallet/WalletBalance'
import { DepositModal } from '@/components/wallet/DepositModal'
import { TransactionHistory } from '@/components/wallet/TransactionHistory'
import { Button } from '@/components/common/Button'
import { Plus } from 'lucide-react'
export const Wallet = () => {
const [isDepositModalOpen, setIsDepositModalOpen] = useState(false)
return (
<Layout>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Wallet</h1>
<p className="text-gray-600 mt-2">Manage your funds and view transaction history</p>
</div>
<Button onClick={() => setIsDepositModalOpen(true)}>
<Plus size={20} className="mr-2" />
Deposit Funds
</Button>
</div>
<WalletBalance />
<TransactionHistory />
</div>
<DepositModal
isOpen={isDepositModalOpen}
onClose={() => setIsDepositModalOpen(false)}
/>
</Layout>
)
}

View File

@ -0,0 +1,74 @@
import { create } from 'zustand'
import type { User } from '@/types'
import { authApi } from '@/api/auth'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
login: (email: string, password: string) => Promise<void>
register: (email: string, username: string, password: string, displayName?: string) => Promise<void>
logout: () => void
loadUser: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: localStorage.getItem('access_token'),
isAuthenticated: !!localStorage.getItem('access_token'),
isLoading: false,
login: async (email, password) => {
set({ isLoading: true })
try {
const response = await authApi.login({ email, password })
localStorage.setItem('access_token', response.access_token)
localStorage.setItem('refresh_token', response.refresh_token)
const user = await authApi.getCurrentUser()
set({ user, token: response.access_token, isAuthenticated: true, isLoading: false })
} catch (error) {
set({ isLoading: false })
throw error
}
},
register: async (email, username, password, displayName) => {
set({ isLoading: true })
try {
const response = await authApi.register({ email, username, password, display_name: displayName })
localStorage.setItem('access_token', response.access_token)
localStorage.setItem('refresh_token', response.refresh_token)
const user = await authApi.getCurrentUser()
set({ user, token: response.access_token, isAuthenticated: true, isLoading: false })
} catch (error) {
set({ isLoading: false })
throw error
}
},
logout: () => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
set({ user: null, token: null, isAuthenticated: false })
},
loadUser: async () => {
const token = localStorage.getItem('access_token')
if (!token) {
set({ isAuthenticated: false })
return
}
try {
const user = await authApi.getCurrentUser()
set({ user, isAuthenticated: true })
} catch (error) {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
set({ user: null, token: null, isAuthenticated: false })
}
},
}))

112
frontend/src/types/index.ts Normal file
View File

@ -0,0 +1,112 @@
export interface User {
id: number
email: string
username: string
display_name: string | null
avatar_url: string | null
bio: string | null
total_bets: number
wins: number
losses: number
win_rate: number
status: 'active' | 'suspended' | 'pending_verification'
created_at: string
}
export interface UserSummary {
id: number
username: string
display_name: string | null
avatar_url: string | null
}
export interface Wallet {
id: number
user_id: number
balance: string
escrow: string
blockchain_escrow: string
currency: string
created_at: string
updated_at: string
}
export interface Transaction {
id: number
user_id: number
type: 'deposit' | 'withdrawal' | 'bet_placed' | 'bet_won' | 'bet_lost' | 'bet_cancelled' | 'escrow_lock' | 'escrow_release'
amount: string
balance_after: string
reference_id: number | null
description: string
status: 'pending' | 'completed' | 'failed'
created_at: string
}
export type BetCategory = 'sports' | 'esports' | 'politics' | 'entertainment' | 'custom'
export type BetStatus = 'open' | 'matched' | 'in_progress' | 'pending_result' | 'completed' | 'cancelled' | 'disputed'
export type BetVisibility = 'public' | 'private' | 'friends_only'
export interface Bet {
id: number
title: string
description: string
category: BetCategory
event_name: string
event_date: string | null
creator_position: string
opponent_position: string
creator_odds: number
opponent_odds: number
stake_amount: string
currency: string
status: BetStatus
visibility: BetVisibility
blockchain_bet_id: number | null
blockchain_tx_hash: string | null
blockchain_status: string | null
creator: UserSummary
opponent: UserSummary | null
expires_at: string | null
created_at: string
updated_at: string
}
export interface BetDetail extends Bet {
winner_id: number | null
settled_at: string | null
settled_by: string | null
}
export interface CreateBetData {
title: string
description: string
category: BetCategory
event_name: string
event_date?: string
creator_position: string
opponent_position: string
stake_amount: number
creator_odds?: number
opponent_odds?: number
visibility?: BetVisibility
expires_at?: string
}
export interface LoginData {
email: string
password: string
}
export interface RegisterData {
email: string
username: string
password: string
display_name?: string
}
export interface TokenResponse {
access_token: string
refresh_token: string
token_type: string
}

View File

@ -0,0 +1,30 @@
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
export const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000'
export const BET_CATEGORIES = {
sports: 'Sports',
esports: 'Esports',
politics: 'Politics',
entertainment: 'Entertainment',
custom: 'Custom',
} as const
export const BET_STATUS_LABELS = {
open: 'Open',
matched: 'Matched',
in_progress: 'In Progress',
pending_result: 'Pending Result',
completed: 'Completed',
cancelled: 'Cancelled',
disputed: 'Disputed',
} as const
export const BET_STATUS_COLORS = {
open: 'bg-blue-100 text-blue-800',
matched: 'bg-purple-100 text-purple-800',
in_progress: 'bg-yellow-100 text-yellow-800',
pending_result: 'bg-orange-100 text-orange-800',
completed: 'bg-green-100 text-green-800',
cancelled: 'bg-gray-100 text-gray-800',
disputed: 'bg-red-100 text-red-800',
} as const

View File

@ -0,0 +1,25 @@
import { format, formatDistanceToNow } from 'date-fns'
export const formatCurrency = (amount: string | number): string => {
const num = typeof amount === 'string' ? parseFloat(amount) : amount
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(num)
}
export const formatDate = (date: string): string => {
return format(new Date(date), 'MMM d, yyyy')
}
export const formatDateTime = (date: string): string => {
return format(new Date(date), 'MMM d, yyyy h:mm a')
}
export const formatRelativeTime = (date: string): string => {
return formatDistanceToNow(new Date(date), { addSuffix: true })
}
export const formatPercentage = (value: number): string => {
return `${(value * 100).toFixed(1)}%`
}