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-16 01:50:40 +07:00
|
|
|
|
import { useToast } from '@/store/toast'
|
|
|
|
|
|
import { useConfirm } from '@/store/confirm'
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
2025-12-16 02:01:03 +07:00
|
|
|
|
// File size limits
|
|
|
|
|
|
const MAX_IMAGE_SIZE = 15 * 1024 * 1024 // 15 MB
|
|
|
|
|
|
const MAX_VIDEO_SIZE = 30 * 1024 * 1024 // 30 MB
|
|
|
|
|
|
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
|
|
|
|
|
|
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov']
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
|
export function PlayPage() {
|
|
|
|
|
|
const { id } = useParams<{ id: string }>()
|
2025-12-16 01:50:40 +07:00
|
|
|
|
const toast = useToast()
|
|
|
|
|
|
const confirm = useConfirm()
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
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)
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.error('Не удалось загрузить челленджи для этой игры')
|
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-16 02:01:03 +07:00
|
|
|
|
const validateAndSetFile = (
|
|
|
|
|
|
file: File | null,
|
|
|
|
|
|
setFile: (file: File | null) => void,
|
|
|
|
|
|
inputRef: React.RefObject<HTMLInputElement>
|
|
|
|
|
|
) => {
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 } } }
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.error(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) {
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
2025-12-14 02:38:35 +07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsCompleting(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await wheelApi.complete(currentAssignment.id, {
|
|
|
|
|
|
proof_file: proofFile || undefined,
|
|
|
|
|
|
proof_url: proofUrl || undefined,
|
|
|
|
|
|
comment: comment || undefined,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
// Reset form
|
|
|
|
|
|
setProofFile(null)
|
|
|
|
|
|
setProofUrl('')
|
|
|
|
|
|
setComment('')
|
|
|
|
|
|
setSpinResult(null)
|
|
|
|
|
|
|
|
|
|
|
|
await loadData()
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
const error = err as { response?: { data?: { detail?: string } } }
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
|
2025-12-14 02:38:35 +07:00
|
|
|
|
} finally {
|
|
|
|
|
|
setIsCompleting(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleDrop = async () => {
|
|
|
|
|
|
if (!currentAssignment) return
|
|
|
|
|
|
|
|
|
|
|
|
const penalty = spinResult?.drop_penalty || 0
|
2025-12-16 01:50:40 +07:00
|
|
|
|
const confirmed = await confirm({
|
|
|
|
|
|
title: 'Пропустить задание?',
|
|
|
|
|
|
message: `Вы потеряете ${penalty} очков.`,
|
|
|
|
|
|
confirmText: 'Пропустить',
|
|
|
|
|
|
cancelText: 'Отмена',
|
|
|
|
|
|
variant: 'warning',
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!confirmed) return
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
setIsDropping(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await wheelApi.drop(currentAssignment.id)
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.info(`Пропущено. Штраф: -${result.penalty} очков`)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
setSpinResult(null)
|
|
|
|
|
|
await loadData()
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
const error = err as { response?: { data?: { detail?: string } } }
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.error(error.response?.data?.detail || 'Не удалось пропустить')
|
2025-12-14 02:38:35 +07:00
|
|
|
|
} finally {
|
|
|
|
|
|
setIsDropping(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 23:03:59 +07:00
|
|
|
|
const handleEventComplete = async () => {
|
|
|
|
|
|
if (!eventAssignment?.assignment) return
|
|
|
|
|
|
if (!eventProofFile && !eventProofUrl) {
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
2025-12-15 23:03:59 +07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsEventCompleting(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await eventsApi.completeEventAssignment(eventAssignment.assignment.id, {
|
|
|
|
|
|
proof_file: eventProofFile || undefined,
|
|
|
|
|
|
proof_url: eventProofUrl || undefined,
|
|
|
|
|
|
comment: eventComment || undefined,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.success(`Выполнено! +${result.points_earned} очков`)
|
2025-12-15 23:03:59 +07:00
|
|
|
|
|
|
|
|
|
|
// Reset form
|
|
|
|
|
|
setEventProofFile(null)
|
|
|
|
|
|
setEventProofUrl('')
|
|
|
|
|
|
setEventComment('')
|
|
|
|
|
|
|
|
|
|
|
|
await loadData()
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
const error = err as { response?: { data?: { detail?: string } } }
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
|
2025-12-15 23:03:59 +07:00
|
|
|
|
} 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
|
2025-12-16 01:50:40 +07:00
|
|
|
|
const confirmed = await confirm({
|
|
|
|
|
|
title: 'Выбрать челлендж?',
|
|
|
|
|
|
message: hasActiveAssignment
|
|
|
|
|
|
? 'Текущее задание будет заменено без штрафа.'
|
|
|
|
|
|
: 'Вы уверены, что хотите выбрать этот челлендж?',
|
|
|
|
|
|
confirmText: 'Выбрать',
|
|
|
|
|
|
cancelText: 'Отмена',
|
|
|
|
|
|
variant: 'info',
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!confirmed) 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)
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.success(result.message)
|
2025-12-15 23:50:37 +07:00
|
|
|
|
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-16 01:50:40 +07:00
|
|
|
|
toast.error(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
|
|
|
|
|
|
|
2025-12-16 01:50:40 +07:00
|
|
|
|
const confirmed = await confirm({
|
|
|
|
|
|
title: 'Отправить запрос на обмен?',
|
|
|
|
|
|
message: `Вы предлагаете обменяться с ${participantName} на:\n"${theirChallenge}"\n\n${participantName} должен будет подтвердить обмен.`,
|
|
|
|
|
|
confirmText: 'Отправить',
|
|
|
|
|
|
cancelText: 'Отмена',
|
|
|
|
|
|
variant: 'info',
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!confirmed) return
|
2025-12-15 03:22:29 +07:00
|
|
|
|
|
|
|
|
|
|
setSendingRequestTo(participantId)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await eventsApi.createSwapRequest(parseInt(id), participantId)
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.success('Запрос на обмен отправлен! Ожидайте подтверждения.')
|
2025-12-15 03:22:29 +07:00
|
|
|
|
await loadSwapRequests()
|
|
|
|
|
|
await loadSwapCandidates()
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
const error = err as { response?: { data?: { detail?: string } } }
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.error(error.response?.data?.detail || 'Не удалось отправить запрос')
|
2025-12-15 03:22:29 +07:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSendingRequestTo(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleAcceptSwapRequest = async (requestId: number) => {
|
|
|
|
|
|
if (!id) return
|
|
|
|
|
|
|
2025-12-16 01:50:40 +07:00
|
|
|
|
const confirmed = await confirm({
|
|
|
|
|
|
title: 'Принять обмен?',
|
|
|
|
|
|
message: 'Задания будут обменяны сразу после подтверждения.',
|
|
|
|
|
|
confirmText: 'Принять',
|
|
|
|
|
|
cancelText: 'Отмена',
|
|
|
|
|
|
variant: 'info',
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!confirmed) return
|
2025-12-15 03:22:29 +07:00
|
|
|
|
|
|
|
|
|
|
setProcessingRequestId(requestId)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await eventsApi.acceptSwapRequest(parseInt(id), requestId)
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.success('Обмен выполнен!')
|
2025-12-15 03:22:29 +07:00
|
|
|
|
await loadData()
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
const error = err as { response?: { data?: { detail?: string } } }
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.error(error.response?.data?.detail || 'Не удалось выполнить обмен')
|
2025-12-15 03:22:29 +07:00
|
|
|
|
} 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 } } }
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.error(error.response?.data?.detail || 'Не удалось отклонить запрос')
|
2025-12-15 03:22:29 +07:00
|
|
|
|
} 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 } } }
|
2025-12-16 01:50:40 +07:00
|
|
|
|
toast.error(error.response?.data?.detail || 'Не удалось отменить запрос')
|
2025-12-15 03:22:29 +07:00
|
|
|
|
} 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"
|
2025-12-16 02:01:03 +07:00
|
|
|
|
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setEventProofFile, eventFileInputRef)}
|
2025-12-15 23:03:59 +07:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
|
) : (
|
2025-12-16 02:01:03 +07:00
|
|
|
|
<div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
onClick={() => eventFileInputRef.current?.click()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Upload className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Выбрать файл
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<p className="text-xs text-gray-500 mt-1 text-center">
|
|
|
|
|
|
Макс. 15 МБ для изображений, 30 МБ для видео
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-12-15 23:03:59 +07:00
|
|
|
|
)}
|
|
|
|
|
|
</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"
|
2025-12-16 02:01:03 +07:00
|
|
|
|
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setProofFile, fileInputRef)}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
|
) : (
|
2025-12-16 02:01:03 +07:00
|
|
|
|
<div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Upload className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Выбрать файл
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<p className="text-xs text-gray-500 mt-1 text-center">
|
|
|
|
|
|
Макс. 15 МБ для изображений, 30 МБ для видео
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-12-14 02:38:35 +07:00
|
|
|
|
)}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|