Add events

This commit is contained in:
2025-12-15 03:22:29 +07:00
parent 1a882fb2e0
commit 4239ea8516
31 changed files with 7288 additions and 75 deletions

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi } from '@/api'
import type { Marathon, Assignment, SpinResult, Game } from '@/types'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api'
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, DroppedAssignment, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel'
import { Loader2, Upload, X } from 'lucide-react'
import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, RotateCcw, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react'
export function PlayPage() {
const { id } = useParams<{ id: string }>()
@@ -13,6 +14,7 @@ export function PlayPage() {
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
const [games, setGames] = useState<Game[]>([])
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Complete state
@@ -24,23 +26,113 @@ export function PlayPage() {
// Drop state
const [isDropping, setIsDropping] = useState(false)
// Rematch state
const [droppedAssignments, setDroppedAssignments] = useState<DroppedAssignment[]>([])
const [isRematchLoading, setIsRematchLoading] = useState(false)
const [rematchingId, setRematchingId] = useState<number | null>(null)
// 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[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadData()
}, [id])
// Load dropped assignments when rematch event is active
useEffect(() => {
if (activeEvent?.event?.type === 'rematch' && !currentAssignment) {
loadDroppedAssignments()
}
}, [activeEvent?.event?.type, currentAssignment])
// 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])
const loadDroppedAssignments = async () => {
if (!id) return
setIsRematchLoading(true)
try {
const dropped = await eventsApi.getDroppedAssignments(parseInt(id))
setDroppedAssignments(dropped)
} catch (error) {
console.error('Failed to load dropped assignments:', error)
} finally {
setIsRematchLoading(false)
}
}
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)
}
}
const loadData = async () => {
if (!id) return
try {
const [marathonData, assignment, gamesData] = await Promise.all([
const [marathonData, assignment, gamesData, eventData] = await Promise.all([
marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.list(parseInt(id), 'approved'),
eventsApi.getActive(parseInt(id)),
])
setMarathon(marathonData)
setCurrentAssignment(assignment)
setGames(gamesData)
setActiveEvent(eventData)
} catch (error) {
console.error('Failed to load data:', error)
} finally {
@@ -48,6 +140,16 @@ export function PlayPage() {
}
}
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)
}
}
const handleSpin = async (): Promise<Game | null> => {
if (!id) return null
@@ -122,6 +224,92 @@ export function PlayPage() {
}
}
const handleRematch = async (assignmentId: number) => {
if (!id) return
if (!confirm('Начать реванш? Вы получите 50% от обычных очков за выполнение.')) return
setRematchingId(assignmentId)
try {
await eventsApi.rematch(parseInt(id), assignmentId)
alert('Реванш начат! Выполните задание за 50% очков.')
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось начать реванш')
} finally {
setRematchingId(null)
}
}
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)
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
@@ -138,8 +326,14 @@ export function PlayPage() {
return (
<div className="max-w-2xl mx-auto">
{/* 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>
{/* Header stats */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="grid grid-cols-3 gap-4 mb-6">
<Card>
<CardContent className="text-center py-3">
<div className="text-xl font-bold text-primary-500">
@@ -166,25 +360,144 @@ export function PlayPage() {
</Card>
</div>
{/* 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}
/>
{/* Active event banner */}
{activeEvent?.event && (
<div className="mb-6">
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
</div>
)}
{/* Common Enemy Leaderboard */}
{activeEvent?.event?.type === 'common_enemy' && (
<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>
)}
{/* 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>
{/* Rematch section - show during rematch event */}
{activeEvent?.event?.type === 'rematch' && droppedAssignments.length > 0 && (
<Card className="mt-6">
<CardContent>
<div className="flex items-center gap-2 mb-4">
<RotateCcw className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-bold text-white">Реванш</h3>
</div>
<p className="text-gray-400 text-sm mb-4">
Во время события "Реванш" вы можете повторить пропущенные задания за 50% очков
</p>
{isRematchLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
</div>
) : (
<div className="space-y-3">
{droppedAssignments.map((dropped) => (
<div
key={dropped.id}
className="flex items-center justify-between p-3 bg-gray-900 rounded-lg"
>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">
{dropped.challenge.title}
</p>
<p className="text-gray-400 text-sm">
{dropped.challenge.game.title} {dropped.challenge.points * 0.5} очков
</p>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => handleRematch(dropped.id)}
isLoading={rematchingId === dropped.id}
disabled={rematchingId !== null}
>
<RotateCcw className="w-4 h-4 mr-1" />
Реванш
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</>
)}
{/* Active assignment */}
{currentAssignment && (
<>
<Card>
<CardContent>
<div className="text-center mb-6">
@@ -315,6 +628,184 @@ export function PlayPage() {
</div>
</CardContent>
</Card>
{/* 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>
)}
</>
)}
</div>
)