Files
game-marathon/frontend/src/pages/PlayPage.tsx
2025-12-17 20:59:47 +07:00

1197 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi } from '@/api'
import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target } from 'lucide-react'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
const MAX_IMAGE_SIZE = 15 * 1024 * 1024
const MAX_VIDEO_SIZE = 30 * 1024 * 1024
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov']
export function PlayPage() {
const { id } = useParams<{ id: string }>()
const toast = useToast()
const confirm = useConfirm()
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
const [games, setGames] = useState<Game[]>([])
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [proofFile, setProofFile] = useState<File | null>(null)
const [proofUrl, setProofUrl] = useState('')
const [comment, setComment] = useState('')
const [isCompleting, setIsCompleting] = useState(false)
const [isDropping, setIsDropping] = useState(false)
const [selectedGameId, setSelectedGameId] = useState<number | null>(null)
const [gameChoiceChallenges, setGameChoiceChallenges] = useState<GameChoiceChallenges | null>(null)
const [isLoadingChallenges, setIsLoadingChallenges] = useState(false)
const [isSelectingChallenge, setIsSelectingChallenge] = useState(false)
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)
const [commonEnemyLeaderboard, setCommonEnemyLeaderboard] = useState<CommonEnemyLeaderboardEntry[]>([])
type PlayTab = 'spin' | 'event'
const [activeTab, setActiveTab] = useState<PlayTab>('spin')
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)
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const eventFileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadData()
}, [id])
useEffect(() => {
if (activeEvent?.event?.type !== 'game_choice') {
setSelectedGameId(null)
setGameChoiceChallenges(null)
}
}, [activeEvent?.event?.type])
useEffect(() => {
if (activeEvent?.event?.type === 'swap') {
loadSwapRequests()
if (currentAssignment) {
loadSwapCandidates()
}
}
}, [activeEvent?.event?.type, currentAssignment])
useEffect(() => {
if (activeEvent?.event?.type === 'common_enemy') {
loadCommonEnemyLeaderboard()
const interval = setInterval(loadCommonEnemyLeaderboard, 10000)
return () => clearInterval(interval)
}
}, [activeEvent?.event?.type])
const loadGameChoiceChallenges = async (gameId: number) => {
if (!id) return
setIsLoadingChallenges(true)
try {
const challenges = await eventsApi.getGameChoiceChallenges(parseInt(id), gameId)
setGameChoiceChallenges(challenges)
} catch (error) {
console.error('Failed to load game choice challenges:', error)
toast.error('Не удалось загрузить челленджи для этой игры')
} finally {
setIsLoadingChallenges(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 validateAndSetFile = (
file: File | null,
setFile: (file: File | null) => void,
inputRef: React.RefObject<HTMLInputElement | null>
) => {
if (!file) {
setFile(null)
return
}
const ext = file.name.split('.').pop()?.toLowerCase() || ''
const isImage = IMAGE_EXTENSIONS.includes(ext)
const isVideo = VIDEO_EXTENSIONS.includes(ext)
if (!isImage && !isVideo) {
toast.error('Неподдерживаемый формат файла')
if (inputRef.current) inputRef.current.value = ''
return
}
const maxSize = isImage ? MAX_IMAGE_SIZE : MAX_VIDEO_SIZE
const maxSizeMB = isImage ? 15 : 30
if (file.size > maxSize) {
toast.error(`Файл слишком большой. Максимум ${maxSizeMB} МБ для ${isImage ? 'изображений' : 'видео'}`)
if (inputRef.current) inputRef.current.value = ''
return
}
setFile(file)
}
const loadData = async () => {
if (!id) return
try {
const [marathonData, assignment, gamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.list(parseInt(id), 'approved'),
eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)),
assignmentsApi.getReturnedAssignments(parseInt(id)),
])
setMarathon(marathonData)
setCurrentAssignment(assignment)
setGames(gamesData)
setActiveEvent(eventData)
setEventAssignment(eventAssignmentData)
setReturnedAssignments(returnedData)
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setIsLoading(false)
}
}
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
try {
const result = await wheelApi.spin(parseInt(id))
return result.game
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось крутить')
return null
}
}
const handleSpinComplete = async () => {
setTimeout(async () => {
await loadData()
}, 500)
}
const handleComplete = async () => {
if (!currentAssignment) return
if (!proofFile && !proofUrl) {
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
}
setIsCompleting(true)
try {
const result = await wheelApi.complete(currentAssignment.id, {
proof_file: proofFile || undefined,
proof_url: proofUrl || undefined,
comment: comment || undefined,
})
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
setProofFile(null)
setProofUrl('')
setComment('')
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
} finally {
setIsCompleting(false)
}
}
const handleDrop = async () => {
if (!currentAssignment) return
const penalty = currentAssignment.drop_penalty
const confirmed = await confirm({
title: 'Пропустить задание?',
message: `Вы потеряете ${penalty} очков.`,
confirmText: 'Пропустить',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsDropping(true)
try {
const result = await wheelApi.drop(currentAssignment.id)
toast.info(`Пропущено. Штраф: -${result.penalty} очков`)
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось пропустить')
} finally {
setIsDropping(false)
}
}
const handleEventComplete = async () => {
if (!eventAssignment?.assignment) return
if (!eventProofFile && !eventProofUrl) {
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
}
setIsEventCompleting(true)
try {
const result = await eventsApi.completeEventAssignment(eventAssignment.assignment.id, {
proof_file: eventProofFile || undefined,
proof_url: eventProofUrl || undefined,
comment: eventComment || undefined,
})
toast.success(`Выполнено! +${result.points_earned} очков`)
setEventProofFile(null)
setEventProofUrl('')
setEventComment('')
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
} finally {
setIsEventCompleting(false)
}
}
const handleGameSelect = async (gameId: number) => {
setSelectedGameId(gameId)
await loadGameChoiceChallenges(gameId)
}
const handleChallengeSelect = async (challengeId: number) => {
if (!id) return
const hasActiveAssignment = !!currentAssignment
const confirmed = await confirm({
title: 'Выбрать челлендж?',
message: hasActiveAssignment
? 'Текущее задание будет заменено без штрафа.'
: 'Вы уверены, что хотите выбрать этот челлендж?',
confirmText: 'Выбрать',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setIsSelectingChallenge(true)
try {
const result = await eventsApi.selectGameChoiceChallenge(parseInt(id), challengeId)
toast.success(result.message)
setSelectedGameId(null)
setGameChoiceChallenges(null)
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось выбрать челлендж')
} finally {
setIsSelectingChallenge(false)
}
}
const handleSendSwapRequest = async (participantId: number, participantName: string, theirChallenge: string) => {
if (!id) return
const confirmed = await confirm({
title: 'Отправить запрос на обмен?',
message: `Вы предлагаете обменяться с ${participantName} на:\n"${theirChallenge}"\n\n${participantName} должен будет подтвердить обмен.`,
confirmText: 'Отправить',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setSendingRequestTo(participantId)
try {
await eventsApi.createSwapRequest(parseInt(id), participantId)
toast.success('Запрос на обмен отправлен!')
await loadSwapRequests()
await loadSwapCandidates()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось отправить запрос')
} finally {
setSendingRequestTo(null)
}
}
const handleAcceptSwapRequest = async (requestId: number) => {
if (!id) return
const confirmed = await confirm({
title: 'Принять обмен?',
message: 'Задания будут обменяны сразу после подтверждения.',
confirmText: 'Принять',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setProcessingRequestId(requestId)
try {
await eventsApi.acceptSwapRequest(parseInt(id), requestId)
toast.success('Обмен выполнен!')
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(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 } } }
toast.error(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 } } }
toast.error(error.response?.data?.detail || 'Не удалось отменить запрос')
} finally {
setProcessingRequestId(null)
}
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка...</p>
</div>
)
}
if (!marathon) {
return (
<div className="text-center py-12">
<p className="text-gray-400">Марафон не найден</p>
</div>
)
}
const marathonEndDate = marathon.end_date ? new Date(marathon.end_date) : null
const isMarathonExpired = marathonEndDate && new Date() > marathonEndDate
const isMarathonEnded = marathon.status === 'finished' || isMarathonExpired
if (isMarathonEnded) {
return (
<div className="max-w-2xl mx-auto">
<Link
to={`/marathons/${id}`}
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
К марафону
</Link>
<GlassCard className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-yellow-500/20 border border-yellow-500/30 flex items-center justify-center">
<Trophy className="w-10 h-10 text-yellow-400" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Марафон завершён</h2>
<p className="text-gray-400 mb-6">
{marathon.status === 'finished'
? 'Этот марафон был завершён организатором.'
: 'Этот марафон завершился по истечении срока.'}
</p>
<Link to={`/marathons/${id}/leaderboard`}>
<NeonButton icon={<Trophy className="w-4 h-4" />}>
Посмотреть итоговый рейтинг
</NeonButton>
</Link>
</GlassCard>
</div>
)
}
const participation = marathon.my_participation
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-neon-400 mb-6 transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
К марафону
</Link>
{/* Header stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
<StatsCard
label="Очков"
value={participation?.total_points || 0}
icon={<Zap className="w-6 h-6" />}
color="neon"
/>
<StatsCard
label="Серия"
value={participation?.current_streak || 0}
icon={<Flame className="w-6 h-6" />}
color="purple"
/>
<StatsCard
label="Пропусков"
value={participation?.drop_count || 0}
icon={<Target className="w-6 h-6" />}
color="default"
/>
</div>
{/* Active event banner */}
{activeEvent?.event && (
<div className="mb-6">
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
</div>
)}
{/* Returned assignments warning */}
{returnedAssignments.length > 0 && (
<GlassCard className="mb-6 border-orange-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-orange-400" />
</div>
<div>
<h3 className="font-semibold text-orange-400">Возвращённые задания</h3>
<p className="text-sm text-gray-400">{returnedAssignments.length} заданий</p>
</div>
</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-xl"
>
<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-lg border border-green-500/30">
+{ra.challenge.points}
</span>
</div>
<p className="text-orange-300 text-xs mt-2">
Причина: {ra.dispute_reason}
</p>
</div>
))}
</div>
</GlassCard>
)}
{/* Tabs for Common Enemy event */}
{activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6">
<NeonButton
variant={activeTab === 'spin' ? 'primary' : 'outline'}
onClick={() => setActiveTab('spin')}
className="flex-1"
>
Мой прокрут
</NeonButton>
<NeonButton
variant={activeTab === 'event' ? 'primary' : 'outline'}
onClick={() => setActiveTab('event')}
className="flex-1 relative"
color="purple"
>
Общий враг
{eventAssignment?.assignment && !eventAssignment.is_completed && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse" />
)}
</NeonButton>
</div>
)}
{/* Event tab content (Common Enemy) */}
{activeTab === 'event' && activeEvent?.event?.type === 'common_enemy' && (
<>
{/* Common Enemy Leaderboard */}
<GlassCard className="mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-red-500/20 flex items-center justify-center">
<Users className="w-5 h-5 text-red-400" />
</div>
<div>
<h3 className="font-semibold text-white">Выполнили челлендж</h3>
<p className="text-sm text-gray-400">{commonEnemyLeaderboard.length} чел.</p>
</div>
</div>
{commonEnemyLeaderboard.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">Пока никто не выполнил. Будь первым!</p>
</div>
) : (
<div className="space-y-2">
{commonEnemyLeaderboard.map((entry) => (
<div
key={entry.participant_id}
className={`
flex items-center gap-3 p-3 rounded-xl border
${entry.rank === 1 ? 'bg-yellow-500/10 border-yellow-500/30' :
entry.rank === 2 ? 'bg-gray-400/10 border-gray-400/30' :
entry.rank === 3 ? 'bg-orange-600/10 border-orange-600/30' :
'bg-dark-700/50 border-dark-600'}
`}
>
<div className={`
w-8 h-8 rounded-lg 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-dark-600 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-semibold">
+{entry.bonus_points} бонус
</span>
)}
</div>
))}
</div>
)}
</GlassCard>
{/* Event Assignment Card */}
{eventAssignment?.assignment && !eventAssignment.is_completed ? (
<GlassCard variant="neon">
<div className="text-center mb-6">
<span className="px-4 py-1.5 bg-red-500/20 text-red-400 rounded-full text-sm font-medium border border-red-500/30">
Задание события "Общий враг"
</span>
</div>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Игра</p>
<p className="text-xl font-bold text-white">
{eventAssignment.assignment.challenge.game.title}
</p>
</div>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задание</p>
<p className="text-xl font-bold text-neon-400 mb-2">
{eventAssignment.assignment.challenge.title}
</p>
<p className="text-gray-300">
{eventAssignment.assignment.challenge.description}
</p>
</div>
<div className="flex items-center gap-3 mb-6 flex-wrap">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
+{eventAssignment.assignment.challenge.points} очков
</span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{eventAssignment.assignment.challenge.difficulty}
</span>
{eventAssignment.assignment.challenge.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
~{eventAssignment.assignment.challenge.estimated_time} мин
</span>
)}
</div>
{eventAssignment.assignment.challenge.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
</p>
</div>
)}
<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>
<input
ref={eventFileInputRef}
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setEventProofFile, eventFileInputRef)}
/>
{eventProofFile ? (
<div className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<span className="text-white flex-1 truncate">{eventProofFile.name}</span>
<button
onClick={() => setEventProofFile(null)}
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div>
<NeonButton
variant="outline"
className="w-full"
onClick={() => eventFileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />}
>
Выбрать файл
</NeonButton>
<p className="text-xs text-gray-500 mt-2 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео
</p>
</div>
)}
</div>
<div className="text-center text-gray-500">или</div>
<input
type="text"
className="input"
placeholder="Вставьте ссылку (YouTube, профиль Steam и т.д.)"
value={eventProofUrl}
onChange={(e) => setEventProofUrl(e.target.value)}
/>
<textarea
className="input min-h-[80px] resize-none"
placeholder="Комментарий (необязательно)"
value={eventComment}
onChange={(e) => setEventComment(e.target.value)}
/>
</div>
<NeonButton
className="w-full"
onClick={handleEventComplete}
isLoading={isEventCompleting}
disabled={!eventProofFile && !eventProofUrl}
icon={<Check className="w-4 h-4" />}
>
Выполнено
</NeonButton>
</GlassCard>
) : eventAssignment?.is_completed ? (
<GlassCard className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-green-500/20 border border-green-500/30 flex items-center justify-center">
<Check className="w-10 h-10 text-green-400" />
</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 font-semibold mt-2">
+{eventAssignment.assignment.points_earned} очков
</p>
)}
</GlassCard>
) : (
<GlassCard className="text-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-neon-400 mx-auto mb-4" />
<p className="text-gray-400">Загрузка задания события...</p>
</GlassCard>
)}
</>
)}
{/* Spin tab content */}
{(activeTab === 'spin' || activeEvent?.event?.type !== 'common_enemy') && (
<>
{/* Game Choice section */}
{activeEvent?.event?.type === 'game_choice' && (
<GlassCard className="mb-6 border-orange-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
<Gamepad2 className="w-5 h-5 text-orange-400" />
</div>
<div>
<h3 className="font-semibold text-orange-400">Выбор игры</h3>
<p className="text-sm text-gray-400">
{currentAssignment ? 'Текущее задание будет заменено без штрафа!' : 'Выберите игру и челлендж'}
</p>
</div>
</div>
{!selectedGameId ? (
<div className="grid grid-cols-2 gap-2">
{games.map((game) => (
<button
key={game.id}
onClick={() => handleGameSelect(game.id)}
className="p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-orange-500/30"
>
<p className="text-white font-medium truncate">{game.title}</p>
<p className="text-gray-400 text-xs">{game.challenges_count} челленджей</p>
</button>
))}
</div>
) : (
<div>
<div className="flex items-center justify-between mb-4">
<h4 className="text-white font-medium">
{gameChoiceChallenges?.game_title || 'Загрузка...'}
</h4>
<NeonButton
variant="outline"
size="sm"
onClick={() => {
setSelectedGameId(null)
setGameChoiceChallenges(null)
}}
icon={<ArrowLeft className="w-4 h-4" />}
>
Назад
</NeonButton>
</div>
{isLoadingChallenges ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-orange-400" />
</div>
) : gameChoiceChallenges?.challenges.length ? (
<div className="space-y-3">
{gameChoiceChallenges.challenges.map((challenge) => (
<div
key={challenge.id}
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
>
<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 flex-wrap">
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 rounded-lg border border-green-500/30">
+{challenge.points} очков
</span>
<span className="px-2 py-0.5 bg-dark-600 text-gray-300 rounded-lg">
{challenge.difficulty}
</span>
{challenge.estimated_time && (
<span className="text-gray-500">~{challenge.estimated_time} мин</span>
)}
</div>
</div>
<NeonButton
size="sm"
onClick={() => handleChallengeSelect(challenge.id)}
isLoading={isSelectingChallenge}
disabled={isSelectingChallenge}
>
Выбрать
</NeonButton>
</div>
</div>
))}
</div>
) : (
<p className="text-center text-gray-500 py-8">
Нет доступных челленджей
</p>
)}
</div>
)}
</GlassCard>
)}
{/* No active assignment - show spin wheel */}
{!currentAssignment && (
<GlassCard>
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-white mb-2">Крутите колесо!</h2>
<p className="text-gray-400">
Получите случайную игру и задание для выполнения
</p>
</div>
<SpinWheel
games={games}
onSpin={handleSpin}
onSpinComplete={handleSpinComplete}
/>
</GlassCard>
)}
{/* Active assignment */}
{currentAssignment && (
<>
<GlassCard variant="neon">
<div className="text-center mb-6">
<span className="px-4 py-1.5 bg-neon-500/20 text-neon-400 rounded-full text-sm font-medium border border-neon-500/30">
Активное задание
</span>
</div>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Игра</p>
<p className="text-xl font-bold text-white">
{currentAssignment.challenge.game.title}
</p>
</div>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задание</p>
<p className="text-xl font-bold text-neon-400 mb-2">
{currentAssignment.challenge.title}
</p>
<p className="text-gray-300">
{currentAssignment.challenge.description}
</p>
</div>
<div className="flex items-center gap-3 mb-6 flex-wrap">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
+{currentAssignment.challenge.points} очков
</span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{currentAssignment.challenge.difficulty}
</span>
{currentAssignment.challenge.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
~{currentAssignment.challenge.estimated_time} мин
</span>
)}
</div>
{currentAssignment.challenge.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint}
</p>
</div>
)}
<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>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setProofFile, fileInputRef)}
/>
{proofFile ? (
<div className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<span className="text-white flex-1 truncate">{proofFile.name}</span>
<button
onClick={() => setProofFile(null)}
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div>
<NeonButton
variant="outline"
className="w-full"
onClick={() => fileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />}
>
Выбрать файл
</NeonButton>
<p className="text-xs text-gray-500 mt-2 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео
</p>
</div>
)}
</div>
<div className="text-center text-gray-500">или</div>
<input
type="text"
className="input"
placeholder="Вставьте ссылку (YouTube, профиль Steam и т.д.)"
value={proofUrl}
onChange={(e) => setProofUrl(e.target.value)}
/>
<textarea
className="input min-h-[80px] resize-none"
placeholder="Комментарий (необязательно)"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
<div className="flex gap-3">
<NeonButton
className="flex-1"
onClick={handleComplete}
isLoading={isCompleting}
disabled={!proofFile && !proofUrl}
icon={<Check className="w-4 h-4" />}
>
Выполнено
</NeonButton>
<NeonButton
variant="danger"
onClick={handleDrop}
isLoading={isDropping}
icon={<XCircle className="w-4 h-4" />}
>
Пропустить (-{currentAssignment.drop_penalty})
</NeonButton>
</div>
</GlassCard>
{/* Swap section */}
{activeEvent?.event?.type === 'swap' && (
<GlassCard className="mt-6 border-neon-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
<ArrowLeftRight className="w-5 h-5 text-neon-400" />
</div>
<div>
<h3 className="font-semibold text-neon-400">Обмен заданиями</h3>
<p className="text-sm text-gray-400">Требует подтверждения с обеих сторон</p>
</div>
</div>
{/* 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-4 bg-yellow-500/10 border border-yellow-500/30 rounded-xl"
>
<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>
</div>
<div className="flex flex-col gap-2">
<NeonButton
size="sm"
onClick={() => handleAcceptSwapRequest(request.id)}
isLoading={processingRequestId === request.id}
disabled={processingRequestId !== null}
icon={<Check className="w-4 h-4" />}
>
Принять
</NeonButton>
<NeonButton
size="sm"
variant="outline"
className="border-red-500/30 text-red-400"
onClick={() => handleDeclineSwapRequest(request.id)}
isLoading={processingRequestId === request.id}
disabled={processingRequestId !== null}
icon={<XCircle className="w-4 h-4" />}
>
Отклонить
</NeonButton>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Outgoing swap requests */}
{swapRequests.outgoing.length > 0 && (
<div className="mb-6">
<h4 className="text-sm font-medium text-accent-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-4 bg-accent-500/10 border border-accent-500/30 rounded-xl"
>
<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-accent-400 text-sm mt-1">
Вы получите: <span className="font-medium">{request.to_challenge.title}</span>
</p>
<p className="text-gray-500 text-xs mt-1">
Ожидание подтверждения...
</p>
</div>
<NeonButton
size="sm"
variant="outline"
onClick={() => handleCancelSwapRequest(request.id)}
isLoading={processingRequestId === request.id}
disabled={processingRequestId !== null}
icon={<X className="w-4 h-4" />}
>
Отменить
</NeonButton>
</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-8">
<Loader2 className="w-8 h-8 animate-spin text-neon-400" />
</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-8 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-4 bg-dark-700/50 rounded-xl border border-dark-600"
>
<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-neon-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>
<NeonButton
size="sm"
variant="outline"
onClick={() => handleSendSwapRequest(
candidate.participant_id,
candidate.user.nickname,
candidate.challenge_title
)}
isLoading={sendingRequestTo === candidate.participant_id}
disabled={sendingRequestTo !== null}
icon={<ArrowLeftRight className="w-4 h-4" />}
>
Предложить
</NeonButton>
</div>
</div>
))}
</div>
)}
</div>
</GlassCard>
)}
</>
)}
</>
)}
</div>
)
}