Added h2h communication.

This commit is contained in:
2026-01-11 11:25:33 -06:00
parent 174abb7f56
commit e0af183086
12 changed files with 743 additions and 3 deletions

View File

@ -19,6 +19,7 @@ import { NewBets } from './pages/NewBets'
import { Watchlist } from './pages/Watchlist'
import { HowItWorks } from './pages/HowItWorks'
import { EventDetail } from './pages/EventDetail'
import { MatchDetail } from './pages/MatchDetail'
import {
RewardsIndex,
LeaderboardPage,
@ -76,6 +77,7 @@ function App() {
<Route path="/watchlist" element={<Watchlist />} />
<Route path="/how-it-works" element={<HowItWorks />} />
<Route path="/events/:id" element={<EventDetail />} />
<Route path="/matches/:id" element={<MatchDetail />} />
{/* Rewards Routes */}
<Route path="/rewards" element={<RewardsIndex />} />

View File

@ -0,0 +1,19 @@
import { apiClient } from './client'
import type { MatchDetailResponse, MatchComment, MatchCommentCreate } from '@/types/match'
export const matchesApi = {
getMatch: async (betId: number): Promise<MatchDetailResponse> => {
const response = await apiClient.get<MatchDetailResponse>(`/api/v1/matches/${betId}`)
return response.data
},
getComments: async (betId: number): Promise<MatchComment[]> => {
const response = await apiClient.get<MatchComment[]>(`/api/v1/matches/${betId}/comments`)
return response.data
},
addComment: async (betId: number, data: MatchCommentCreate): Promise<MatchComment> => {
const response = await apiClient.post<MatchComment>(`/api/v1/matches/${betId}/comments`, data)
return response.data
},
}

View File

@ -5,7 +5,7 @@ import { useAuthStore } from '@/store'
import { spreadBetsApi } from '@/api/spread-bets'
import type { SpreadBetDetail } from '@/types/spread-bet'
import { SpreadBetStatus } from '@/types/spread-bet'
import { ExternalLink, Trophy, XCircle } from 'lucide-react'
import { ExternalLink, Trophy, XCircle, MessageCircle } from 'lucide-react'
interface MyOtherBetsProps {
currentEventId?: number
@ -125,6 +125,17 @@ export const MyOtherBets = ({ currentEventId }: MyOtherBetsProps) => {
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-sm text-center">
{(bet.status === SpreadBetStatus.MATCHED || bet.status === SpreadBetStatus.COMPLETED) && (
<Link
to={`/matches/${bet.id}`}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-700 text-xs font-medium"
>
<MessageCircle size={14} />
Chat
</Link>
)}
</td>
</tr>
)
}
@ -202,6 +213,7 @@ export const MyOtherBets = ({ currentEventId }: MyOtherBetsProps) => {
<th className="px-4 py-3 text-right">Stake</th>
<th className="px-4 py-3 text-center">Status</th>
<th className="px-4 py-3 text-right">Result</th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">

View File

@ -0,0 +1,310 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/store'
import { matchesApi } from '@/api/matches'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { Header } from '@/components/layout/Header'
import { ChevronLeft, Send, User } from 'lucide-react'
import { format } from 'date-fns'
import toast from 'react-hot-toast'
import type { MatchComment } from '@/types/match'
import { TeamSide, SpreadBetStatus } from '@/types/spread-bet'
import { WS_URL } from '@/utils/constants'
export const MatchDetail = () => {
const { id } = useParams<{ id: string }>()
const betId = parseInt(id || '0', 10)
const { isAuthenticated, user } = useAuthStore()
const queryClient = useQueryClient()
const [newComment, setNewComment] = useState('')
const commentsEndRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
const { data: match, isLoading, error } = useQuery({
queryKey: ['match', betId],
queryFn: () => matchesApi.getMatch(betId),
enabled: betId > 0,
})
const addCommentMutation = useMutation({
mutationFn: (content: string) => matchesApi.addComment(betId, { content }),
onSuccess: () => {
setNewComment('')
queryClient.invalidateQueries({ queryKey: ['match', betId] })
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to add comment')
},
})
// WebSocket for real-time comments
useEffect(() => {
if (!betId) return
const token = localStorage.getItem('token') || 'guest'
const ws = new WebSocket(`${WS_URL}/api/v1/ws?token=${token}&match_id=${betId}`)
ws.onopen = () => {
console.log('[MatchDetail] WebSocket connected for match', betId)
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'new_comment' && msg.data.match_id === betId) {
// Refetch comments on new message
queryClient.invalidateQueries({ queryKey: ['match', betId] })
}
} catch (e) {
console.error('[MatchDetail] Failed to parse WebSocket message:', e)
}
}
ws.onerror = (e) => {
console.error('[MatchDetail] WebSocket error:', e)
}
ws.onclose = () => {
console.log('[MatchDetail] WebSocket disconnected')
}
wsRef.current = ws
return () => {
ws.close()
}
}, [betId, queryClient])
// Scroll to bottom when comments change
useEffect(() => {
commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [match?.comments])
const handleSubmitComment = (e: React.FormEvent) => {
e.preventDefault()
if (newComment.trim()) {
addCommentMutation.mutate(newComment.trim())
}
}
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="px-4 sm:px-6 lg:px-8 py-8">
<Loading />
</div>
</div>
)
}
if (error || !match) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="px-4 sm:px-6 lg:px-8 py-8">
<Link to="/" className="inline-flex items-center text-blue-600 hover:text-blue-700 mb-6">
<ChevronLeft className="w-4 h-4 mr-1" />
Back
</Link>
<div className="text-center py-12 bg-white rounded-lg shadow">
<p className="text-gray-500">Match not found or not yet matched</p>
</div>
</div>
</div>
)
}
const { bet, comments, can_comment } = match
const isCreator = user?.id === bet.creator.id
const isTaker = bet.taker && user?.id === bet.taker.id
const getStatusColor = (status: SpreadBetStatus) => {
switch (status) {
case SpreadBetStatus.MATCHED:
return 'bg-blue-100 text-blue-800'
case SpreadBetStatus.COMPLETED:
return 'bg-green-100 text-green-800'
case SpreadBetStatus.DISPUTED:
return 'bg-red-100 text-red-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
const formatSpread = (spread: number, team: TeamSide) => {
const sign = spread > 0 ? '+' : ''
const teamName = team === TeamSide.HOME ? bet.home_team : bet.away_team
return `${teamName} ${sign}${spread}`
}
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="px-4 sm:px-6 lg:px-8 py-8 max-w-4xl mx-auto">
<Link to="/" className="inline-flex items-center text-blue-600 hover:text-blue-700 mb-6">
<ChevronLeft className="w-4 h-4 mr-1" />
Back to Events
</Link>
{/* Match Header */}
<div className="bg-white rounded-xl shadow-sm border p-6 mb-6">
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">
{bet.home_team} vs {bet.away_team}
</h1>
<p className="text-sm text-gray-500">
{format(new Date(bet.game_time), 'PPpp')}
</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(bet.status)}`}>
{bet.status.charAt(0).toUpperCase() + bet.status.slice(1)}
</span>
</div>
{/* Bet Details */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500 mb-1">Spread</p>
<p className="text-lg font-semibold">{formatSpread(bet.spread, bet.team)}</p>
<p className="text-xs text-gray-400">Official: {bet.official_spread}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500 mb-1">Stake (each)</p>
<p className="text-lg font-semibold">${Number(bet.stake_amount).toFixed(2)}</p>
<p className="text-xs text-gray-400">Total pot: ${(Number(bet.stake_amount) * 2).toFixed(2)}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500 mb-1">Winner Payout</p>
<p className="text-lg font-semibold text-green-600">
${(Number(bet.stake_amount) * 2 * (1 - Number(bet.house_commission_percent) / 100)).toFixed(2)}
</p>
<p className="text-xs text-gray-400">{bet.house_commission_percent}% commission</p>
</div>
</div>
</div>
{/* Participants */}
<div className="bg-white rounded-xl shadow-sm border p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Participants</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Creator */}
<div className={`rounded-lg p-4 border-2 ${isCreator ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-gray-50'}`}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium text-gray-900">
{bet.creator.username}
{isCreator && <span className="ml-2 text-xs text-blue-600">(You)</span>}
</p>
<p className="text-sm text-gray-500">Creator - {formatSpread(bet.spread, bet.team)}</p>
</div>
</div>
</div>
{/* Taker */}
<div className={`rounded-lg p-4 border-2 ${isTaker ? 'border-green-500 bg-green-50' : 'border-gray-200 bg-gray-50'}`}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium text-gray-900">
{bet.taker?.username || 'Pending...'}
{isTaker && <span className="ml-2 text-xs text-green-600">(You)</span>}
</p>
<p className="text-sm text-gray-500">
Taker - {formatSpread(-bet.spread, bet.team === TeamSide.HOME ? TeamSide.AWAY : TeamSide.HOME)}
</p>
</div>
</div>
</div>
</div>
</div>
{/* Comments Section */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b bg-gray-50">
<h2 className="text-lg font-semibold text-gray-900">Comments</h2>
<p className="text-sm text-gray-500">
{can_comment ? 'Chat with your opponent' : 'Only participants can comment'}
</p>
</div>
{/* Comments List */}
<div className="h-80 overflow-y-auto p-4 space-y-4">
{comments.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No comments yet. {can_comment && 'Start the conversation!'}
</div>
) : (
comments.map((comment: MatchComment) => {
const isOwnComment = user?.id === comment.user_id
return (
<div
key={comment.id}
className={`flex ${isOwnComment ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] rounded-lg p-3 ${
isOwnComment
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-medium ${isOwnComment ? 'text-blue-100' : 'text-gray-500'}`}>
{comment.username}
</span>
<span className={`text-xs ${isOwnComment ? 'text-blue-200' : 'text-gray-400'}`}>
{format(new Date(comment.created_at), 'p')}
</span>
</div>
<p className="text-sm">{comment.content}</p>
</div>
</div>
)
})
)}
<div ref={commentsEndRef} />
</div>
{/* Comment Input */}
{can_comment ? (
<form onSubmit={handleSubmitComment} className="p-4 border-t bg-gray-50">
<div className="flex gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Type a message..."
maxLength={500}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<Button
type="submit"
disabled={!newComment.trim() || addCommentMutation.isPending}
>
<Send className="w-4 h-4" />
</Button>
</div>
</form>
) : isAuthenticated ? (
<div className="p-4 border-t bg-gray-50 text-center text-sm text-gray-500">
Only bet participants can post comments
</div>
) : (
<div className="p-4 border-t bg-gray-50 text-center text-sm text-gray-500">
<Link to="/login" className="text-blue-600 hover:underline">Log in</Link> to view if you can comment
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,48 @@
import { TeamSide, SpreadBetStatus } from './spread-bet'
export interface MatchUser {
id: number
username: string
}
export interface MatchComment {
id: number
spread_bet_id: number
user_id: number
username: string
content: string
created_at: string
}
export interface MatchCommentCreate {
content: string
}
export interface MatchBetDetail {
id: number
event_id: number
spread: number
team: TeamSide
stake_amount: number
house_commission_percent: number
status: SpreadBetStatus
payout_amount: number | null
winner_id: number | null
created_at: string
matched_at: string | null
completed_at: string | null
// Event info
home_team: string
away_team: string
game_time: string
official_spread: number
// User info
creator: MatchUser
taker: MatchUser | null
}
export interface MatchDetailResponse {
bet: MatchBetDetail
comments: MatchComment[]
can_comment: boolean
}