Init.
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user