2025-12-14 02:38:35 +07:00
import { useState , useEffect , useRef } from 'react'
2025-12-15 03:22:29 +07:00
import { useParams , Link } from 'react-router-dom'
2025-12-16 00:33:50 +07:00
import { marathonsApi , wheelApi , gamesApi , eventsApi , assignmentsApi } from '@/api'
import type { Marathon , Assignment , SpinResult , Game , ActiveEvent , SwapCandidate , MySwapRequests , CommonEnemyLeaderboardEntry , EventAssignment , GameChoiceChallenges , ReturnedAssignment } from '@/types'
2025-12-14 02:38:35 +07:00
import { Button , Card , CardContent } from '@/components/ui'
2025-12-14 21:41:49 +07:00
import { SpinWheel } from '@/components/SpinWheel'
2025-12-15 03:22:29 +07:00
import { EventBanner } from '@/components/EventBanner'
2025-12-16 00:33:50 +07:00
import { Loader2 , Upload , X , Gamepad2 , ArrowLeftRight , Check , XCircle , Clock , Send , Trophy , Users , ArrowLeft , AlertTriangle } from 'lucide-react'
2025-12-14 02:38:35 +07:00
export function PlayPage() {
const { id } = useParams < { id : string } > ( )
const [ marathon , setMarathon ] = useState < Marathon | null > ( null )
const [ currentAssignment , setCurrentAssignment ] = useState < Assignment | null > ( null )
const [ spinResult , setSpinResult ] = useState < SpinResult | null > ( null )
2025-12-14 21:41:49 +07:00
const [ games , setGames ] = useState < Game [ ] > ( [ ] )
2025-12-15 03:22:29 +07:00
const [ activeEvent , setActiveEvent ] = useState < ActiveEvent | null > ( null )
2025-12-14 02:38:35 +07:00
const [ isLoading , setIsLoading ] = useState ( true )
// Complete state
const [ proofFile , setProofFile ] = useState < File | null > ( null )
const [ proofUrl , setProofUrl ] = useState ( '' )
const [ comment , setComment ] = useState ( '' )
const [ isCompleting , setIsCompleting ] = useState ( false )
// Drop state
const [ isDropping , setIsDropping ] = useState ( false )
2025-12-15 23:50:37 +07:00
// Game Choice state
const [ selectedGameId , setSelectedGameId ] = useState < number | null > ( null )
const [ gameChoiceChallenges , setGameChoiceChallenges ] = useState < GameChoiceChallenges | null > ( null )
const [ isLoadingChallenges , setIsLoadingChallenges ] = useState ( false )
const [ isSelectingChallenge , setIsSelectingChallenge ] = useState ( false )
2025-12-15 03:22:29 +07:00
// Swap state
const [ swapCandidates , setSwapCandidates ] = useState < SwapCandidate [ ] > ( [ ] )
const [ swapRequests , setSwapRequests ] = useState < MySwapRequests > ( { incoming : [ ] , outgoing : [ ] } )
const [ isSwapLoading , setIsSwapLoading ] = useState ( false )
const [ sendingRequestTo , setSendingRequestTo ] = useState < number | null > ( null )
const [ processingRequestId , setProcessingRequestId ] = useState < number | null > ( null )
// Common Enemy leaderboard state
const [ commonEnemyLeaderboard , setCommonEnemyLeaderboard ] = useState < CommonEnemyLeaderboardEntry [ ] > ( [ ] )
2025-12-15 23:03:59 +07:00
// Tab state for Common Enemy
type PlayTab = 'spin' | 'event'
const [ activeTab , setActiveTab ] = useState < PlayTab > ( 'spin' )
// Event assignment state (Common Enemy)
const [ eventAssignment , setEventAssignment ] = useState < EventAssignment | null > ( null )
const [ eventProofFile , setEventProofFile ] = useState < File | null > ( null )
const [ eventProofUrl , setEventProofUrl ] = useState ( '' )
const [ eventComment , setEventComment ] = useState ( '' )
const [ isEventCompleting , setIsEventCompleting ] = useState ( false )
2025-12-16 00:33:50 +07:00
// Returned assignments state
const [ returnedAssignments , setReturnedAssignments ] = useState < ReturnedAssignment [ ] > ( [ ] )
2025-12-14 02:38:35 +07:00
const fileInputRef = useRef < HTMLInputElement > ( null )
2025-12-15 23:03:59 +07:00
const eventFileInputRef = useRef < HTMLInputElement > ( null )
2025-12-14 02:38:35 +07:00
useEffect ( ( ) = > {
loadData ( )
} , [ id ] )
2025-12-15 23:50:37 +07:00
// Reset game choice state when event changes or ends
2025-12-15 03:22:29 +07:00
useEffect ( ( ) = > {
2025-12-15 23:50:37 +07:00
if ( activeEvent ? . event ? . type !== 'game_choice' ) {
setSelectedGameId ( null )
setGameChoiceChallenges ( null )
2025-12-15 03:22:29 +07:00
}
2025-12-15 23:50:37 +07:00
} , [ activeEvent ? . event ? . type ] )
2025-12-15 03:22:29 +07:00
// Load swap candidates and requests when swap event is active
useEffect ( ( ) = > {
if ( activeEvent ? . event ? . type === 'swap' ) {
loadSwapRequests ( )
if ( currentAssignment ) {
loadSwapCandidates ( )
}
}
} , [ activeEvent ? . event ? . type , currentAssignment ] )
// Load common enemy leaderboard when common_enemy event is active
useEffect ( ( ) = > {
if ( activeEvent ? . event ? . type === 'common_enemy' ) {
loadCommonEnemyLeaderboard ( )
// Poll for updates every 10 seconds
const interval = setInterval ( loadCommonEnemyLeaderboard , 10000 )
return ( ) = > clearInterval ( interval )
}
} , [ activeEvent ? . event ? . type ] )
2025-12-15 23:50:37 +07:00
const loadGameChoiceChallenges = async ( gameId : number ) = > {
2025-12-15 03:22:29 +07:00
if ( ! id ) return
2025-12-15 23:50:37 +07:00
setIsLoadingChallenges ( true )
2025-12-15 03:22:29 +07:00
try {
2025-12-15 23:50:37 +07:00
const challenges = await eventsApi . getGameChoiceChallenges ( parseInt ( id ) , gameId )
setGameChoiceChallenges ( challenges )
2025-12-15 03:22:29 +07:00
} catch ( error ) {
2025-12-15 23:50:37 +07:00
console . error ( 'Failed to load game choice challenges:' , error )
alert ( 'Н е удалось загрузить челленджи для этой игры' )
2025-12-15 03:22:29 +07:00
} finally {
2025-12-15 23:50:37 +07:00
setIsLoadingChallenges ( false )
2025-12-15 03:22:29 +07:00
}
}
const loadSwapCandidates = async ( ) = > {
if ( ! id ) return
setIsSwapLoading ( true )
try {
const candidates = await eventsApi . getSwapCandidates ( parseInt ( id ) )
setSwapCandidates ( candidates )
} catch ( error ) {
console . error ( 'Failed to load swap candidates:' , error )
} finally {
setIsSwapLoading ( false )
}
}
const loadSwapRequests = async ( ) = > {
if ( ! id ) return
try {
const requests = await eventsApi . getSwapRequests ( parseInt ( id ) )
setSwapRequests ( requests )
} catch ( error ) {
console . error ( 'Failed to load swap requests:' , error )
}
}
const loadCommonEnemyLeaderboard = async ( ) = > {
if ( ! id ) return
try {
const leaderboard = await eventsApi . getCommonEnemyLeaderboard ( parseInt ( id ) )
setCommonEnemyLeaderboard ( leaderboard )
} catch ( error ) {
console . error ( 'Failed to load common enemy leaderboard:' , error )
}
}
2025-12-14 02:38:35 +07:00
const loadData = async ( ) = > {
if ( ! id ) return
try {
2025-12-16 00:33:50 +07:00
const [ marathonData , assignment , gamesData , eventData , eventAssignmentData , returnedData ] = await Promise . all ( [
2025-12-14 02:38:35 +07:00
marathonsApi . get ( parseInt ( id ) ) ,
wheelApi . getCurrentAssignment ( parseInt ( id ) ) ,
2025-12-14 21:41:49 +07:00
gamesApi . list ( parseInt ( id ) , 'approved' ) ,
2025-12-15 03:22:29 +07:00
eventsApi . getActive ( parseInt ( id ) ) ,
2025-12-15 23:03:59 +07:00
eventsApi . getEventAssignment ( parseInt ( id ) ) ,
2025-12-16 00:33:50 +07:00
assignmentsApi . getReturnedAssignments ( parseInt ( id ) ) ,
2025-12-14 02:38:35 +07:00
] )
setMarathon ( marathonData )
setCurrentAssignment ( assignment )
2025-12-14 21:41:49 +07:00
setGames ( gamesData )
2025-12-15 03:22:29 +07:00
setActiveEvent ( eventData )
2025-12-15 23:03:59 +07:00
setEventAssignment ( eventAssignmentData )
2025-12-16 00:33:50 +07:00
setReturnedAssignments ( returnedData )
2025-12-14 02:38:35 +07:00
} catch ( error ) {
console . error ( 'Failed to load data:' , error )
} finally {
setIsLoading ( false )
}
}
2025-12-15 03:22:29 +07:00
const refreshEvent = async ( ) = > {
if ( ! id ) return
try {
const eventData = await eventsApi . getActive ( parseInt ( id ) )
setActiveEvent ( eventData )
} catch ( error ) {
console . error ( 'Failed to refresh event:' , error )
}
}
2025-12-14 21:41:49 +07:00
const handleSpin = async ( ) : Promise < Game | null > = > {
if ( ! id ) return null
2025-12-14 02:38:35 +07:00
try {
const result = await wheelApi . spin ( parseInt ( id ) )
setSpinResult ( result )
2025-12-14 21:41:49 +07:00
return result . game
2025-12-14 02:38:35 +07:00
} catch ( err : unknown ) {
const error = err as { response ? : { data ? : { detail? : string } } }
alert ( error . response ? . data ? . detail || 'Н е удалось крутить' )
2025-12-14 21:41:49 +07:00
return null
2025-12-14 02:38:35 +07:00
}
}
2025-12-14 21:41:49 +07:00
const handleSpinComplete = async ( ) = > {
// Small delay then reload data to show the assignment
setTimeout ( async ( ) = > {
await loadData ( )
} , 500 )
}
2025-12-14 02:38:35 +07:00
const handleComplete = async ( ) = > {
if ( ! currentAssignment ) return
if ( ! proofFile && ! proofUrl ) {
alert ( 'Пожалуйста, предоставьте доказательство (файл или ссылку)' )
return
}
setIsCompleting ( true )
try {
const result = await wheelApi . complete ( currentAssignment . id , {
proof_file : proofFile || undefined ,
proof_url : proofUrl || undefined ,
comment : comment || undefined ,
} )
alert ( ` Выполнено! + ${ result . points_earned } очков (бонус серии: + ${ result . streak_bonus } ) ` )
// Reset form
setProofFile ( null )
setProofUrl ( '' )
setComment ( '' )
setSpinResult ( null )
await loadData ( )
} catch ( err : unknown ) {
const error = err as { response ? : { data ? : { detail? : string } } }
alert ( error . response ? . data ? . detail || 'Н е удалось выполнить' )
} finally {
setIsCompleting ( false )
}
}
const handleDrop = async ( ) = > {
if ( ! currentAssignment ) return
const penalty = spinResult ? . drop_penalty || 0
if ( ! confirm ( ` Пропустить это задание? Вы потеряете ${ penalty } очков. ` ) ) return
setIsDropping ( true )
try {
const result = await wheelApi . drop ( currentAssignment . id )
alert ( ` Пропущено. Штраф: - ${ result . penalty } очков ` )
setSpinResult ( null )
await loadData ( )
} catch ( err : unknown ) {
const error = err as { response ? : { data ? : { detail? : string } } }
alert ( error . response ? . data ? . detail || 'Н е удалось пропустить' )
} finally {
setIsDropping ( false )
}
}
2025-12-15 23:03:59 +07:00
const handleEventComplete = async ( ) = > {
if ( ! eventAssignment ? . assignment ) return
if ( ! eventProofFile && ! eventProofUrl ) {
alert ( 'Пожалуйста, предоставьте доказательство (файл или ссылку)' )
return
}
setIsEventCompleting ( true )
try {
const result = await eventsApi . completeEventAssignment ( eventAssignment . assignment . id , {
proof_file : eventProofFile || undefined ,
proof_url : eventProofUrl || undefined ,
comment : eventComment || undefined ,
} )
alert ( ` Выполнено! + ${ result . points_earned } очков ` )
// Reset form
setEventProofFile ( null )
setEventProofUrl ( '' )
setEventComment ( '' )
await loadData ( )
} catch ( err : unknown ) {
const error = err as { response ? : { data ? : { detail? : string } } }
alert ( error . response ? . data ? . detail || 'Н е удалось выполнить' )
} finally {
setIsEventCompleting ( false )
}
}
2025-12-15 23:50:37 +07:00
const handleGameSelect = async ( gameId : number ) = > {
setSelectedGameId ( gameId )
await loadGameChoiceChallenges ( gameId )
}
const handleChallengeSelect = async ( challengeId : number ) = > {
2025-12-15 03:22:29 +07:00
if ( ! id ) return
2025-12-15 23:50:37 +07:00
const hasActiveAssignment = ! ! currentAssignment
const confirmMessage = hasActiveAssignment
? 'Выбрать этот челлендж? Текущее задание будет заменено без штрафа.'
: 'Выбрать этот челлендж?'
if ( ! confirm ( confirmMessage ) ) return
2025-12-15 03:22:29 +07:00
2025-12-15 23:50:37 +07:00
setIsSelectingChallenge ( true )
2025-12-15 03:22:29 +07:00
try {
2025-12-15 23:50:37 +07:00
const result = await eventsApi . selectGameChoiceChallenge ( parseInt ( id ) , challengeId )
alert ( result . message )
setSelectedGameId ( null )
setGameChoiceChallenges ( null )
2025-12-15 03:22:29 +07:00
await loadData ( )
} catch ( err : unknown ) {
const error = err as { response ? : { data ? : { detail? : string } } }
2025-12-15 23:50:37 +07:00
alert ( error . response ? . data ? . detail || 'Н е удалось выбрать челлендж' )
2025-12-15 03:22:29 +07:00
} finally {
2025-12-15 23:50:37 +07:00
setIsSelectingChallenge ( false )
2025-12-15 03:22:29 +07:00
}
}
const handleSendSwapRequest = async ( participantId : number , participantName : string , theirChallenge : string ) = > {
if ( ! id ) return
if ( ! confirm ( ` Отправить запрос на обмен с ${ participantName } ? \ n \ nВ ы предлагаете обменяться на: " ${ theirChallenge } " \ n \ n ${ participantName } должен будет подтвердить обмен. ` ) ) return
setSendingRequestTo ( participantId )
try {
await eventsApi . createSwapRequest ( parseInt ( id ) , participantId )
alert ( 'Запрос на обмен отправлен! Ожидайте подтверждения.' )
await loadSwapRequests ( )
await loadSwapCandidates ( )
} catch ( err : unknown ) {
const error = err as { response ? : { data ? : { detail? : string } } }
alert ( error . response ? . data ? . detail || 'Н е удалось отправить запрос' )
} finally {
setSendingRequestTo ( null )
}
}
const handleAcceptSwapRequest = async ( requestId : number ) = > {
if ( ! id ) return
if ( ! confirm ( 'Принять обмен? Задания будут обменяны сразу после подтверждения.' ) ) return
setProcessingRequestId ( requestId )
try {
await eventsApi . acceptSwapRequest ( parseInt ( id ) , requestId )
alert ( 'Обмен выполнен!' )
await loadData ( )
} catch ( err : unknown ) {
const error = err as { response ? : { data ? : { detail? : string } } }
alert ( error . response ? . data ? . detail || 'Н е удалось выполнить обмен' )
} finally {
setProcessingRequestId ( null )
}
}
const handleDeclineSwapRequest = async ( requestId : number ) = > {
if ( ! id ) return
setProcessingRequestId ( requestId )
try {
await eventsApi . declineSwapRequest ( parseInt ( id ) , requestId )
await loadSwapRequests ( )
} catch ( err : unknown ) {
const error = err as { response ? : { data ? : { detail? : string } } }
alert ( error . response ? . data ? . detail || 'Н е удалось отклонить запрос' )
} finally {
setProcessingRequestId ( null )
}
}
const handleCancelSwapRequest = async ( requestId : number ) = > {
if ( ! id ) return
setProcessingRequestId ( requestId )
try {
await eventsApi . cancelSwapRequest ( parseInt ( id ) , requestId )
await loadSwapRequests ( )
await loadSwapCandidates ( )
} catch ( err : unknown ) {
const error = err as { response ? : { data ? : { detail? : string } } }
alert ( error . response ? . data ? . detail || 'Н е удалось отменить запрос' )
} finally {
setProcessingRequestId ( null )
}
}
2025-12-14 02:38:35 +07:00
if ( isLoading ) {
return (
< div className = "flex justify-center py-12" >
< Loader2 className = "w-8 h-8 animate-spin text-primary-500" / >
< / div >
)
}
if ( ! marathon ) {
return < div > М а р а ф о н н е н а й д е н < / div >
}
const participation = marathon . my_participation
return (
< div className = "max-w-2xl mx-auto" >
2025-12-15 03:22:29 +07:00
{ /* Back button */ }
< Link to = { ` /marathons/ ${ id } ` } className = "inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors" >
< ArrowLeft className = "w-4 h-4" / >
К м а р а ф о н у
< / Link >
2025-12-14 02:38:35 +07:00
{ /* Header stats */ }
2025-12-15 03:22:29 +07:00
< div className = "grid grid-cols-3 gap-4 mb-6" >
2025-12-14 02:38:35 +07:00
< Card >
< CardContent className = "text-center py-3" >
< div className = "text-xl font-bold text-primary-500" >
{ participation ? . total_points || 0 }
< / div >
< div className = "text-xs text-gray-400" > О ч к о в < / div >
< / CardContent >
< / Card >
< Card >
< CardContent className = "text-center py-3" >
< div className = "text-xl font-bold text-yellow-500" >
{ participation ? . current_streak || 0 }
< / div >
< div className = "text-xs text-gray-400" > С е р и я < / div >
< / CardContent >
< / Card >
< Card >
< CardContent className = "text-center py-3" >
< div className = "text-xl font-bold text-gray-400" >
{ participation ? . drop_count || 0 }
< / div >
< div className = "text-xs text-gray-400" > П р о п у с к о в < / div >
< / CardContent >
< / Card >
< / div >
2025-12-15 03:22:29 +07:00
{ /* Active event banner */ }
{ activeEvent ? . event && (
< div className = "mb-6" >
< EventBanner activeEvent = { activeEvent } onRefresh = { refreshEvent } / >
< / div >
) }
2025-12-16 00:33:50 +07:00
{ /* Returned assignments warning */ }
{ returnedAssignments . length > 0 && (
< Card className = "mb-6 border-orange-500/50" >
< CardContent >
< div className = "flex items-center gap-2 mb-3" >
< AlertTriangle className = "w-5 h-5 text-orange-500" / >
< h3 className = "text-lg font-bold text-orange-400" > В о з в р а щ ё н н ы е з а д а н и я < / h3 >
< span className = "ml-auto px-2 py-0.5 bg-orange-500/20 text-orange-400 text-sm rounded" >
{ returnedAssignments . length }
< / span >
< / div >
< p className = "text-gray-400 text-sm mb-4" >
Э т и з а д а н и я б ы л и о с п о р е н ы . П о с л е т е к у щ е г о з а д а н и я в а м н у ж н о б у д е т и х п е р е д е л а т ь .
< / p >
< div className = "space-y-2" >
{ returnedAssignments . map ( ( ra ) = > (
< div
key = { ra . id }
className = "p-3 bg-orange-500/10 border border-orange-500/20 rounded-lg"
>
< div className = "flex items-start justify-between" >
< div >
< p className = "text-white font-medium" > { ra . challenge . title } < / p >
< p className = "text-gray-400 text-sm" > { ra . challenge . game . title } < / p >
< / div >
< span className = "px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded" >
+ { ra . challenge . points }
< / span >
< / div >
< p className = "text-orange-300 text-xs mt-2" >
П р и ч и н а : { ra . dispute_reason }
< / p >
< / div >
) ) }
< / div >
< / CardContent >
< / Card >
) }
2025-12-15 23:03:59 +07:00
{ /* Tabs for Common Enemy event */ }
2025-12-15 03:22:29 +07:00
{ activeEvent ? . event ? . type === 'common_enemy' && (
2025-12-15 23:03:59 +07:00
< div className = "flex gap-2 mb-6" >
< Button
variant = { activeTab === 'spin' ? 'primary' : 'secondary' }
onClick = { ( ) = > setActiveTab ( 'spin' ) }
className = "flex-1"
>
М о й п р о к р у т
< / Button >
< Button
variant = { activeTab === 'event' ? 'primary' : 'secondary' }
onClick = { ( ) = > setActiveTab ( 'event' ) }
className = "flex-1 relative"
>
О б щ и й в р а г
{ eventAssignment ? . assignment && ! eventAssignment . is_completed && (
< span className = "absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse" / >
) }
< / Button >
< / div >
) }
{ /* Event tab content (Common Enemy) */ }
{ activeTab === 'event' && activeEvent ? . event ? . type === 'common_enemy' && (
< >
{ /* Common Enemy Leaderboard */ }
< Card className = "mb-6" >
< CardContent >
< div className = "flex items-center gap-2 mb-4" >
< Users className = "w-5 h-5 text-red-500" / >
< h3 className = "text-lg font-bold text-white" > В ы п о л н и л и ч е л л е н д ж < / h3 >
{ commonEnemyLeaderboard . length > 0 && (
< span className = "ml-auto text-gray-400 text-sm" >
{ commonEnemyLeaderboard . length } ч е л .
< / span >
) }
< / div >
{ commonEnemyLeaderboard . length === 0 ? (
< div className = "text-center py-4 text-gray-500" >
П о к а н и к т о н е в ы п о л н и л . Б у д ь п е р в ы м !
< / div >
) : (
< div className = "space-y-2" >
{ commonEnemyLeaderboard . map ( ( entry ) = > (
< div
key = { entry . participant_id }
className = { `
flex items - center gap - 3 p - 3 rounded - lg
$ { entry . rank === 1 ? 'bg-yellow-500/20 border border-yellow-500/30' :
entry . rank === 2 ? 'bg-gray-400/20 border border-gray-400/30' :
entry . rank === 3 ? 'bg-orange-600/20 border border-orange-600/30' :
'bg-gray-800' }
` }
>
< div className = { `
w - 8 h - 8 rounded - full flex items - center justify - center font - bold text - sm
$ { entry . rank === 1 ? 'bg-yellow-500 text-black' :
entry . rank === 2 ? 'bg-gray-400 text-black' :
entry . rank === 3 ? 'bg-orange-600 text-white' :
'bg-gray-700 text-gray-300' }
` }>
{ entry . rank && entry . rank <= 3 ? (
< Trophy className = "w-4 h-4" / >
) : (
entry . rank
) }
< / div >
< div className = "flex-1" >
< p className = "text-white font-medium" > { entry . user . nickname } < / p >
< / div >
{ entry . bonus_points > 0 && (
< span className = "text-green-400 text-sm font-medium" >
+ { entry . bonus_points } б о н у с
< / span >
) }
< / div >
) ) }
< / div >
) }
< / CardContent >
< / Card >
{ /* Event Assignment Card */ }
{ eventAssignment ? . assignment && ! eventAssignment . is_completed ? (
< Card >
< CardContent >
< div className = "text-center mb-6" >
< span className = "px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm" >
З а д а н и е с о б ы т и я "Общий враг"
< / span >
< / div >
{ /* Game */ }
< div className = "mb-4" >
< h3 className = "text-lg font-medium text-gray-400 mb-1" > И г р а < / h3 >
< p className = "text-xl font-bold text-white" >
{ eventAssignment . assignment . challenge . game . title }
< / p >
< / div >
{ /* Challenge */ }
< div className = "mb-4" >
< h3 className = "text-lg font-medium text-gray-400 mb-1" > З а д а н и е < / h3 >
< p className = "text-xl font-bold text-white mb-2" >
{ eventAssignment . assignment . challenge . title }
< / p >
< p className = "text-gray-300" >
{ eventAssignment . assignment . challenge . description }
< / p >
< / div >
{ /* Points */ }
< div className = "flex items-center gap-4 mb-6 text-sm" >
< span className = "px-3 py-1 bg-green-500/20 text-green-400 rounded-full" >
+ { eventAssignment . assignment . challenge . points } о ч к о в
< / span >
< span className = "px-3 py-1 bg-gray-700 text-gray-300 rounded-full" >
{ eventAssignment . assignment . challenge . difficulty }
< / span >
{ eventAssignment . assignment . challenge . estimated_time && (
< span className = "text-gray-400" >
~ { eventAssignment . assignment . challenge . estimated_time } м и н
< / span >
) }
< / div >
{ /* Proof hint */ }
{ eventAssignment . assignment . challenge . proof_hint && (
< div className = "mb-6 p-3 bg-gray-900 rounded-lg" >
< p className = "text-sm text-gray-400" >
< strong > Н у ж н о д о к а з а т е л ь с т в о : < / strong > { eventAssignment . assignment . challenge . proof_hint }
< / p >
< / div >
) }
{ /* Proof upload */ }
< div className = "space-y-4 mb-6" >
< div >
< label className = "block text-sm font-medium text-gray-300 mb-2" >
З а г р у з и т ь д о к а з а т е л ь с т в о ( { eventAssignment . assignment . challenge . proof_type } )
< / label >
{ /* File upload */ }
< input
ref = { eventFileInputRef }
type = "file"
accept = "image/*,video/*"
className = "hidden"
onChange = { ( e ) = > setEventProofFile ( e . target . files ? . [ 0 ] || null ) }
/ >
{ eventProofFile ? (
< div className = "flex items-center gap-2 p-3 bg-gray-900 rounded-lg" >
< span className = "text-white flex-1 truncate" > { eventProofFile . name } < / span >
< Button
variant = "ghost"
size = "sm"
onClick = { ( ) = > setEventProofFile ( null ) }
>
< X className = "w-4 h-4" / >
< / Button >
< / div >
) : (
< Button
variant = "secondary"
className = "w-full"
onClick = { ( ) = > eventFileInputRef . current ? . click ( ) }
>
< Upload className = "w-4 h-4 mr-2" / >
В ы б р а т ь ф а й л
< / Button >
) }
< / div >
< div className = "text-center text-gray-500" > и л и < / div >
{ /* URL input */ }
< input
type = "text"
className = "input"
placeholder = "Вставьте ссылку (YouTube, профиль Steam и т.д.)"
value = { eventProofUrl }
onChange = { ( e ) = > setEventProofUrl ( e . target . value ) }
/ >
{ /* Comment */ }
< textarea
className = "input min-h-[80px] resize-none"
placeholder = "Комментарий (необязательно)"
value = { eventComment }
onChange = { ( e ) = > setEventComment ( e . target . value ) }
/ >
< / div >
{ /* Actions */ }
< div className = "flex gap-3" >
< Button
className = "flex-1"
onClick = { handleEventComplete }
isLoading = { isEventCompleting }
disabled = { ! eventProofFile && ! eventProofUrl }
>
В ы п о л н е н о
< / Button >
< / div >
< / CardContent >
< / Card >
) : eventAssignment ? . is_completed ? (
< Card >
< CardContent className = "text-center py-8" >
< div className = "w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4" >
< Check className = "w-8 h-8 text-green-500" / >
< / div >
< h3 className = "text-xl font-bold text-white mb-2" > З а д а н и е в ы п о л н е н о ! < / h3 >
< p className = "text-gray-400" >
В ы у ж е з а в е р ш и л и ч е л л е н д ж с о б ы т и я "Общий враг"
< / p >
{ eventAssignment . assignment && (
< p className = "text-green-400 mt-2" >
+ { eventAssignment . assignment . points_earned } о ч к о в
< / p >
) }
< / CardContent >
< / Card >
) : (
< Card >
< CardContent className = "text-center py-8" >
< Loader2 className = "w-8 h-8 animate-spin text-gray-500 mx-auto mb-4" / >
< p className = "text-gray-400" > З а г р у з к а з а д а н и я с о б ы т и я . . . < / p >
< / CardContent >
< / Card >
) }
< / >
) }
{ /* Spin tab content - only show when spin tab is active or no common_enemy event */ }
{ ( activeTab === 'spin' || activeEvent ? . event ? . type !== 'common_enemy' ) && (
< >
{ /* Common Enemy Leaderboard - show on spin tab too for context */ }
{ activeEvent ? . event ? . type === 'common_enemy' && activeTab === 'spin' && commonEnemyLeaderboard . length > 0 && (
2025-12-15 23:50:37 +07:00
< Card className = "mb-6" >
< CardContent >
< div className = "flex items-center gap-2 mb-4" >
< Users className = "w-5 h-5 text-red-500" / >
< h3 className = "text-lg font-bold text-white" > В ы п о л н и л и ч е л л е н д ж < / h3 >
{ commonEnemyLeaderboard . length > 0 && (
< span className = "ml-auto text-gray-400 text-sm" >
{ commonEnemyLeaderboard . length } ч е л .
< / span >
) }
< / div >
2025-12-15 03:22:29 +07:00
2025-12-15 23:50:37 +07:00
{ commonEnemyLeaderboard . length === 0 ? (
< div className = "text-center py-4 text-gray-500" >
П о к а н и к т о н е в ы п о л н и л . Б у д ь п е р в ы м !
2025-12-15 03:22:29 +07:00
< / div >
2025-12-15 23:50:37 +07:00
) : (
< div className = "space-y-2" >
{ commonEnemyLeaderboard . map ( ( entry ) = > (
< div
key = { entry . participant_id }
className = { `
flex items - center gap - 3 p - 3 rounded - lg
$ { entry . rank === 1 ? 'bg-yellow-500/20 border border-yellow-500/30' :
entry . rank === 2 ? 'bg-gray-400/20 border border-gray-400/30' :
entry . rank === 3 ? 'bg-orange-600/20 border border-orange-600/30' :
'bg-gray-800' }
` }
>
< div className = { `
w - 8 h - 8 rounded - full flex items - center justify - center font - bold text - sm
$ { entry . rank === 1 ? 'bg-yellow-500 text-black' :
entry . rank === 2 ? 'bg-gray-400 text-black' :
entry . rank === 3 ? 'bg-orange-600 text-white' :
'bg-gray-700 text-gray-300' }
` }>
{ entry . rank && entry . rank <= 3 ? (
< Trophy className = "w-4 h-4" / >
) : (
entry . rank
) }
< / div >
< div className = "flex-1" >
< p className = "text-white font-medium" > { entry . user . nickname } < / p >
< / div >
{ entry . bonus_points > 0 && (
< span className = "text-green-400 text-sm font-medium" >
+ { entry . bonus_points } б о н у с
< / span >
) }
< / div >
) ) }
< / div >
) }
< / CardContent >
< / Card >
2025-12-15 23:03:59 +07:00
) }
2025-12-14 02:38:35 +07:00
2025-12-15 23:50:37 +07:00
{ /* Game Choice section - show ABOVE spin wheel during game_choice event (works with or without assignment) */ }
{ activeEvent ? . event ? . type === 'game_choice' && (
< Card className = "mb-6" >
2025-12-15 03:22:29 +07:00
< CardContent >
< div className = "flex items-center gap-2 mb-4" >
2025-12-15 23:50:37 +07:00
< Gamepad2 className = "w-5 h-5 text-orange-500" / >
< h3 className = "text-lg font-bold text-white" > В ы б о р и г р ы < / h3 >
2025-12-15 03:22:29 +07:00
< / div >
< p className = "text-gray-400 text-sm mb-4" >
2025-12-15 23:50:37 +07:00
В ы б е р и т е и г р у и о д и н и з 3 ч е л л е н д ж е й . { currentAssignment ? 'Текущее задание будет заменено без штрафа!' : '' }
2025-12-15 03:22:29 +07:00
< / p >
2025-12-15 23:50:37 +07:00
{ /* Game selection */ }
{ ! selectedGameId && (
< div className = "grid grid-cols-2 gap-2" >
{ games . map ( ( game ) = > (
< button
key = { game . id }
onClick = { ( ) = > handleGameSelect ( game . id ) }
className = "p-3 bg-gray-900 hover:bg-gray-800 rounded-lg text-left transition-colors"
>
< p className = "text-white font-medium truncate" > { game . title } < / p >
< p className = "text-gray-400 text-xs" > { game . challenges_count } ч е л л е н д ж е й < / p >
< / button >
) ) }
2025-12-15 03:22:29 +07:00
< / div >
2025-12-15 23:50:37 +07:00
) }
{ /* Challenge selection */ }
{ selectedGameId && (
< div >
< div className = "flex items-center justify-between mb-4" >
< h4 className = "text-white font-medium" >
{ gameChoiceChallenges ? . game_title || 'Загрузка...' }
< / h4 >
< Button
variant = "ghost"
size = "sm"
onClick = { ( ) = > {
setSelectedGameId ( null )
setGameChoiceChallenges ( null )
} }
2025-12-15 03:22:29 +07:00
>
2025-12-15 23:50:37 +07:00
< ArrowLeft className = "w-4 h-4 mr-1" / >
Н а з а д
< / Button >
< / div >
{ isLoadingChallenges ? (
< div className = "flex justify-center py-4" >
< Loader2 className = "w-6 h-6 animate-spin text-gray-500" / >
2025-12-15 03:22:29 +07:00
< / div >
2025-12-15 23:50:37 +07:00
) : gameChoiceChallenges ? . challenges . length ? (
< div className = "space-y-3" >
{ gameChoiceChallenges . challenges . map ( ( challenge ) = > (
< div
key = { challenge . id }
className = "p-4 bg-gray-900 rounded-lg"
>
< div className = "flex items-start justify-between gap-3" >
< div className = "flex-1 min-w-0" >
< p className = "text-white font-medium" > { challenge . title } < / p >
< p className = "text-gray-400 text-sm mt-1" > { challenge . description } < / p >
< div className = "flex items-center gap-2 mt-2 text-xs" >
< span className = "px-2 py-0.5 bg-green-500/20 text-green-400 rounded" >
+ { challenge . points } о ч к о в
< / span >
< span className = "px-2 py-0.5 bg-gray-700 text-gray-300 rounded" >
{ challenge . difficulty }
< / span >
{ challenge . estimated_time && (
< span className = "text-gray-500" > ~ { challenge . estimated_time } м и н < / span >
) }
< / div >
< / div >
< Button
size = "sm"
onClick = { ( ) = > handleChallengeSelect ( challenge . id ) }
isLoading = { isSelectingChallenge }
disabled = { isSelectingChallenge }
>
В ы б р а т ь
< / Button >
< / div >
< / div >
) ) }
< / div >
) : (
< p className = "text-center text-gray-500 py-4" >
Н е т д о с т у п н ы х ч е л л е н д ж е й д л я э т о й и г р ы
< / p >
) }
2025-12-15 03:22:29 +07:00
< / div >
) }
< / CardContent >
< / Card >
) }
2025-12-15 23:50:37 +07:00
{ /* No active assignment - show spin wheel */ }
{ ! currentAssignment && (
< Card >
< CardContent className = "py-8" >
< h2 className = "text-2xl font-bold text-white mb-2 text-center" > К р у т и т е к о л е с о ! < / h2 >
< p className = "text-gray-400 mb-6 text-center" >
П о л у ч и т е с л у ч а й н у ю и г р у и з а д а н и е д л я в ы п о л н е н и я
< / p >
< SpinWheel
games = { games }
onSpin = { handleSpin }
onSpinComplete = { handleSpinComplete }
/ >
< / CardContent >
< / Card >
) }
2025-12-15 03:22:29 +07:00
2025-12-14 02:38:35 +07:00
{ /* Active assignment */ }
{ currentAssignment && (
2025-12-15 03:22:29 +07:00
< >
2025-12-14 02:38:35 +07:00
< Card >
< CardContent >
< div className = "text-center mb-6" >
< span className = "px-3 py-1 bg-primary-500/20 text-primary-400 rounded-full text-sm" >
А к т и в н о е з а д а н и е
< / span >
< / div >
{ /* Game */ }
< div className = "mb-4" >
< h3 className = "text-lg font-medium text-gray-400 mb-1" > И г р а < / h3 >
< p className = "text-xl font-bold text-white" >
{ currentAssignment . challenge . game . title }
< / p >
< / div >
{ /* Challenge */ }
< div className = "mb-4" >
< h3 className = "text-lg font-medium text-gray-400 mb-1" > З а д а н и е < / h3 >
< p className = "text-xl font-bold text-white mb-2" >
{ currentAssignment . challenge . title }
< / p >
< p className = "text-gray-300" >
{ currentAssignment . challenge . description }
< / p >
< / div >
{ /* Points */ }
< div className = "flex items-center gap-4 mb-6 text-sm" >
< span className = "px-3 py-1 bg-green-500/20 text-green-400 rounded-full" >
+ { currentAssignment . challenge . points } о ч к о в
< / span >
< span className = "px-3 py-1 bg-gray-700 text-gray-300 rounded-full" >
{ currentAssignment . challenge . difficulty }
< / span >
{ currentAssignment . challenge . estimated_time && (
< span className = "text-gray-400" >
~ { currentAssignment . challenge . estimated_time } м и н
< / span >
) }
< / div >
{ /* Proof hint */ }
{ currentAssignment . challenge . proof_hint && (
< div className = "mb-6 p-3 bg-gray-900 rounded-lg" >
< p className = "text-sm text-gray-400" >
< strong > Н у ж н о д о к а з а т е л ь с т в о : < / strong > { currentAssignment . challenge . proof_hint }
< / p >
< / div >
) }
{ /* Proof upload */ }
< div className = "space-y-4 mb-6" >
< div >
< label className = "block text-sm font-medium text-gray-300 mb-2" >
З а г р у з и т ь д о к а з а т е л ь с т в о ( { currentAssignment . challenge . proof_type } )
< / label >
{ /* File upload */ }
< input
ref = { fileInputRef }
type = "file"
accept = "image/*,video/*"
className = "hidden"
onChange = { ( e ) = > setProofFile ( e . target . files ? . [ 0 ] || null ) }
/ >
{ proofFile ? (
< div className = "flex items-center gap-2 p-3 bg-gray-900 rounded-lg" >
< span className = "text-white flex-1 truncate" > { proofFile . name } < / span >
< Button
variant = "ghost"
size = "sm"
onClick = { ( ) = > setProofFile ( null ) }
>
< X className = "w-4 h-4" / >
< / Button >
< / div >
) : (
< Button
variant = "secondary"
className = "w-full"
onClick = { ( ) = > fileInputRef . current ? . click ( ) }
>
< Upload className = "w-4 h-4 mr-2" / >
В ы б р а т ь ф а й л
< / Button >
) }
< / div >
< div className = "text-center text-gray-500" > и л и < / div >
{ /* URL input */ }
< input
type = "text"
className = "input"
placeholder = "Вставьте ссылку (YouTube, профиль Steam и т.д.)"
value = { proofUrl }
onChange = { ( e ) = > setProofUrl ( e . target . value ) }
/ >
{ /* Comment */ }
< textarea
className = "input min-h-[80px] resize-none"
placeholder = "Комментарий (необязательно)"
value = { comment }
onChange = { ( e ) = > setComment ( e . target . value ) }
/ >
< / div >
{ /* Actions */ }
< div className = "flex gap-3" >
< Button
className = "flex-1"
onClick = { handleComplete }
isLoading = { isCompleting }
disabled = { ! proofFile && ! proofUrl }
>
В ы п о л н е н о
< / Button >
< Button
variant = "danger"
onClick = { handleDrop }
isLoading = { isDropping }
>
П р о п у с т и т ь ( - { spinResult ? . drop_penalty || 0 } )
< / Button >
< / div >
< / CardContent >
< / Card >
2025-12-15 03:22:29 +07:00
{ /* Swap section - show during swap event when user has active assignment */ }
{ activeEvent ? . event ? . type === 'swap' && (
< Card className = "mt-6" >
< CardContent >
< div className = "flex items-center gap-2 mb-4" >
< ArrowLeftRight className = "w-5 h-5 text-blue-500" / >
< h3 className = "text-lg font-bold text-white" > О б м е н з а д а н и я м и < / h3 >
< / div >
< p className = "text-gray-400 text-sm mb-4" >
О б м е н т р е б у е т п о д т в е р ж д е н и я с о б е и х с т о р о н
< / p >
{ /* Incoming swap requests */ }
{ swapRequests . incoming . length > 0 && (
< div className = "mb-6" >
< h4 className = "text-sm font-medium text-yellow-400 mb-3 flex items-center gap-2" >
< Clock className = "w-4 h-4" / >
В х о д я щ и е з а п р о с ы ( { swapRequests . incoming . length } )
< / h4 >
< div className = "space-y-3" >
{ swapRequests . incoming . map ( ( request ) = > (
< div
key = { request . id }
className = "p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg"
>
< div className = "flex items-start justify-between gap-3" >
< div className = "flex-1 min-w-0" >
< p className = "text-white font-medium" >
{ request . from_user . nickname } п р е д л а г а е т о б м е н
< / p >
< p className = "text-yellow-400 text-sm mt-1" >
В ы п о л у ч и т е : < span className = "font-medium" > { request . from_challenge . title } < / span >
< / p >
< p className = "text-gray-400 text-xs" >
{ request . from_challenge . game_title } • { request . from_challenge . points } о ч к о в
< / p >
< p className = "text-gray-500 text-sm mt-1" >
В з а м е н н а : < span className = "font-medium" > { request . to_challenge . title } < / span >
< / p >
< / div >
< div className = "flex flex-col gap-2" >
< Button
size = "sm"
onClick = { ( ) = > handleAcceptSwapRequest ( request . id ) }
isLoading = { processingRequestId === request . id }
disabled = { processingRequestId !== null }
>
< Check className = "w-4 h-4 mr-1" / >
П р и н я т ь
< / Button >
< Button
size = "sm"
variant = "danger"
onClick = { ( ) = > handleDeclineSwapRequest ( request . id ) }
isLoading = { processingRequestId === request . id }
disabled = { processingRequestId !== null }
>
< XCircle className = "w-4 h-4 mr-1" / >
О т к л о н и т ь
< / Button >
< / div >
< / div >
< / div >
) ) }
< / div >
< / div >
) }
{ /* Outgoing swap requests */ }
{ swapRequests . outgoing . length > 0 && (
< div className = "mb-6" >
< h4 className = "text-sm font-medium text-blue-400 mb-3 flex items-center gap-2" >
< Send className = "w-4 h-4" / >
О т п р а в л е н н ы е з а п р о с ы ( { swapRequests . outgoing . length } )
< / h4 >
< div className = "space-y-3" >
{ swapRequests . outgoing . map ( ( request ) = > (
< div
key = { request . id }
className = "p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg"
>
< div className = "flex items-start justify-between gap-3" >
< div className = "flex-1 min-w-0" >
< p className = "text-white font-medium" >
З а п р о с к { request . to_user . nickname }
< / p >
< p className = "text-blue-400 text-sm mt-1" >
В ы п о л у ч и т е : < span className = "font-medium" > { request . to_challenge . title } < / span >
< / p >
< p className = "text-gray-400 text-xs" >
{ request . to_challenge . game_title } • { request . to_challenge . points } о ч к о в
< / p >
< p className = "text-gray-500 text-xs mt-1" >
О ж и д а н и е п о д т в е р ж д е н и я . . .
< / p >
< / div >
< Button
size = "sm"
variant = "secondary"
onClick = { ( ) = > handleCancelSwapRequest ( request . id ) }
isLoading = { processingRequestId === request . id }
disabled = { processingRequestId !== null }
>
< X className = "w-4 h-4 mr-1" / >
О т м е н и т ь
< / Button >
< / div >
< / div >
) ) }
< / div >
< / div >
) }
{ /* Swap candidates */ }
< div >
< h4 className = "text-sm font-medium text-gray-300 mb-3" >
Д о с т у п н ы е д л я о б м е н а
< / h4 >
{ isSwapLoading ? (
< div className = "flex justify-center py-4" >
< Loader2 className = "w-6 h-6 animate-spin text-gray-500" / >
< / div >
) : swapCandidates . filter ( c = >
! swapRequests . outgoing . some ( r = > r . to_user . id === c . user . id ) &&
! swapRequests . incoming . some ( r = > r . from_user . id === c . user . id )
) . length === 0 ? (
< div className = "text-center py-4 text-gray-500" >
Н е т у ч а с т н и к о в д л я о б м е н а
< / div >
) : (
< div className = "space-y-3" >
{ swapCandidates
. filter ( c = >
! swapRequests . outgoing . some ( r = > r . to_user . id === c . user . id ) &&
! swapRequests . incoming . some ( r = > r . from_user . id === c . user . id )
)
. map ( ( candidate ) = > (
< div
key = { candidate . participant_id }
className = "p-3 bg-gray-900 rounded-lg"
>
< div className = "flex items-start justify-between gap-3" >
< div className = "flex-1 min-w-0" >
< p className = "text-white font-medium" >
{ candidate . user . nickname }
< / p >
< p className = "text-blue-400 text-sm font-medium truncate" >
{ candidate . challenge_title }
< / p >
< p className = "text-gray-400 text-xs mt-1" >
{ candidate . game_title } • { candidate . challenge_points } о ч к о в • { candidate . challenge_difficulty }
< / p >
< / div >
< Button
size = "sm"
variant = "secondary"
onClick = { ( ) = > handleSendSwapRequest (
candidate . participant_id ,
candidate . user . nickname ,
candidate . challenge_title
) }
isLoading = { sendingRequestTo === candidate . participant_id }
disabled = { sendingRequestTo !== null }
>
< ArrowLeftRight className = "w-4 h-4 mr-1" / >
П р е д л о ж и т ь
< / Button >
< / div >
< / div >
) ) }
< / div >
) }
< / div >
< / CardContent >
< / Card >
) }
< / >
2025-12-14 02:38:35 +07:00
) }
2025-12-15 23:03:59 +07:00
< / >
) }
2025-12-14 02:38:35 +07:00
< / div >
)
}