Init.
This commit is contained in:
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_WS_URL=ws://localhost:8000
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@ -0,0 +1,12 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>H2H - Peer-to-Peer Betting Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3003
frontend/package-lock.json
generated
Normal file
3003
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "h2h-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"zustand": "^4.4.7",
|
||||
"axios": "^1.6.5",
|
||||
"date-fns": "^3.2.0",
|
||||
"lucide-react": "^0.303.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
89
frontend/src/App.tsx
Normal file
89
frontend/src/App.tsx
Normal 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
24
frontend/src/api/auth.ts
Normal 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
53
frontend/src/api/bets.ts
Normal 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
|
||||
},
|
||||
}
|
||||
32
frontend/src/api/client.ts
Normal file
32
frontend/src/api/client.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
26
frontend/src/api/wallet.ts
Normal file
26
frontend/src/api/wallet.ts
Normal 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
|
||||
},
|
||||
}
|
||||
138
frontend/src/blockchain/components/BlockchainBadge.tsx
Normal file
138
frontend/src/blockchain/components/BlockchainBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
289
frontend/src/blockchain/components/TransactionModal.tsx
Normal file
289
frontend/src/blockchain/components/TransactionModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
310
frontend/src/blockchain/hooks/useBlockchainBet.ts
Normal file
310
frontend/src/blockchain/hooks/useBlockchainBet.ts
Normal 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
|
||||
}
|
||||
}
|
||||
191
frontend/src/blockchain/hooks/useGasEstimate.ts
Normal file
191
frontend/src/blockchain/hooks/useGasEstimate.ts
Normal 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
|
||||
}
|
||||
323
frontend/src/blockchain/hooks/useWeb3Wallet.ts
Normal file
323
frontend/src/blockchain/hooks/useWeb3Wallet.ts
Normal 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]
|
||||
}
|
||||
70
frontend/src/components/auth/LoginForm.tsx
Normal file
70
frontend/src/components/auth/LoginForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
frontend/src/components/auth/RegisterForm.tsx
Normal file
101
frontend/src/components/auth/RegisterForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
frontend/src/components/bets/BetCard.tsx
Normal file
66
frontend/src/components/bets/BetCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/bets/BetList.tsx
Normal file
24
frontend/src/components/bets/BetList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
272
frontend/src/components/bets/CreateBetModal.tsx
Normal file
272
frontend/src/components/bets/CreateBetModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
frontend/src/components/common/Button.tsx
Normal file
39
frontend/src/components/common/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
frontend/src/components/common/Card.tsx
Normal file
14
frontend/src/components/common/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/common/Loading.tsx
Normal file
7
frontend/src/components/common/Loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
frontend/src/components/common/Modal.tsx
Normal file
37
frontend/src/components/common/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
frontend/src/components/layout/Header.tsx
Normal file
72
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
frontend/src/components/layout/Layout.tsx
Normal file
17
frontend/src/components/layout/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
frontend/src/components/wallet/DepositModal.tsx
Normal file
65
frontend/src/components/wallet/DepositModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
frontend/src/components/wallet/TransactionHistory.tsx
Normal file
54
frontend/src/components/wallet/TransactionHistory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
frontend/src/components/wallet/WalletBalance.tsx
Normal file
94
frontend/src/components/wallet/WalletBalance.tsx
Normal 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
17
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
238
frontend/src/pages/BetDetails.tsx
Normal file
238
frontend/src/pages/BetDetails.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
frontend/src/pages/BetMarketplace.tsx
Normal file
75
frontend/src/pages/BetMarketplace.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
frontend/src/pages/Dashboard.tsx
Normal file
112
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
frontend/src/pages/Home.tsx
Normal file
61
frontend/src/pages/Home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
frontend/src/pages/Login.tsx
Normal file
29
frontend/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
frontend/src/pages/MyBets.tsx
Normal file
78
frontend/src/pages/MyBets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
frontend/src/pages/Register.tsx
Normal file
29
frontend/src/pages/Register.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
frontend/src/pages/Wallet.tsx
Normal file
36
frontend/src/pages/Wallet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
frontend/src/store/index.ts
Normal file
74
frontend/src/store/index.ts
Normal 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
112
frontend/src/types/index.ts
Normal 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
|
||||
}
|
||||
30
frontend/src/utils/constants.ts
Normal file
30
frontend/src/utils/constants.ts
Normal 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
|
||||
25
frontend/src/utils/formatters.ts
Normal file
25
frontend/src/utils/formatters.ts
Normal 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)}%`
|
||||
}
|
||||
18
frontend/tailwind.config.js
Normal file
18
frontend/tailwind.config.js
Normal file
@ -0,0 +1,18 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#3B82F6',
|
||||
success: '#10B981',
|
||||
warning: '#F59E0B',
|
||||
error: '#EF4444',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user