Files
game-marathon/frontend/src/pages/PlayPage.tsx

1922 lines
85 KiB
TypeScript
Raw Normal View History

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'
2026-01-08 06:51:15 +07:00
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi, shopApi } from '@/api'
import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment, ConsumablesStatus, ConsumableType } from '@/types'
2025-12-17 02:03:33 +07:00
import { NeonButton, GlassCard, StatsCard } 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'
2026-01-08 08:49:51 +07:00
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, SkipForward, Package, Dice5, Copy, Undo2, Shuffle } from 'lucide-react'
2025-12-16 01:50:40 +07:00
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
2026-01-08 06:51:15 +07:00
import { useShopStore } from '@/store/shop'
2025-12-14 02:38:35 +07:00
2025-12-17 02:03:33 +07:00
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']
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)
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)
2026-01-03 00:12:07 +07:00
const [proofFiles, setProofFiles] = useState<File[]>([])
2025-12-14 02:38:35 +07:00
const [proofUrl, setProofUrl] = useState('')
const [comment, setComment] = useState('')
const [isCompleting, setIsCompleting] = useState(false)
const [isDropping, setIsDropping] = useState(false)
2025-12-15 23:50:37 +07:00
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
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[]>([])
2025-12-15 23:03:59 +07:00
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)
2025-12-16 00:33:50 +07:00
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
2026-01-08 06:51:15 +07:00
// Consumables
const [consumablesStatus, setConsumablesStatus] = useState<ConsumablesStatus | null>(null)
const [isUsingConsumable, setIsUsingConsumable] = useState<ConsumableType | null>(null)
// Bonus challenge completion
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
2026-01-03 00:12:07 +07:00
const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
const [bonusProofUrl, setBonusProofUrl] = useState('')
const [bonusComment, setBonusComment] = useState('')
const [isCompletingBonus, setIsCompletingBonus] = useState(false)
const bonusFileInputRef = useRef<HTMLInputElement>(null)
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 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
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])
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)
}
}
const validateAndSetFile = (
file: File | null,
setFile: (file: File | null) => void,
2025-12-17 02:03:33 +07:00
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)
}
2025-12-14 02:38:35 +07:00
const loadData = async () => {
if (!id) return
try {
2026-01-08 06:51:15 +07:00
const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData, consumablesData] = await Promise.all([
2025-12-14 02:38:35 +07:00
marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.getAvailableGames(parseInt(id)),
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)),
2026-01-08 06:51:15 +07:00
shopApi.getConsumablesStatus(parseInt(id)).catch(() => null),
2025-12-14 02:38:35 +07:00
])
setMarathon(marathonData)
setCurrentAssignment(assignment)
setGames(availableGamesData)
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)
2026-01-08 06:51:15 +07:00
setConsumablesStatus(consumablesData)
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))
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 () => {
setTimeout(async () => {
await loadData()
}, 500)
}
2025-12-14 02:38:35 +07:00
const handleComplete = async () => {
if (!currentAssignment) return
// For playthrough: allow file, URL, or comment
// For challenges: require file or URL
if (currentAssignment.is_playthrough) {
2026-01-03 00:12:07 +07:00
if (proofFiles.length === 0 && !proofUrl && !comment) {
toast.warning('Пожалуйста, предоставьте доказательство (файл, ссылку или комментарий)')
return
}
} else {
2026-01-03 00:12:07 +07:00
if (proofFiles.length === 0 && !proofUrl) {
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
}
2025-12-14 02:38:35 +07:00
}
setIsCompleting(true)
try {
const result = await wheelApi.complete(currentAssignment.id, {
2026-01-03 00:12:07 +07:00
proof_files: proofFiles.length > 0 ? proofFiles : undefined,
2025-12-14 02:38:35 +07:00
proof_url: proofUrl || undefined,
comment: comment || undefined,
})
2025-12-16 01:50:40 +07:00
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
2026-01-03 00:12:07 +07:00
setProofFiles([])
2025-12-14 02:38:35 +07:00
setProofUrl('')
setComment('')
await loadData()
2026-01-08 06:51:15 +07:00
// Refresh coins balance
useShopStore.getState().loadBalance()
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 02:38:35 +07:00
} finally {
setIsCompleting(false)
}
}
const handleDrop = async () => {
if (!currentAssignment) return
2025-12-16 02:22:12 +07:00
const penalty = currentAssignment.drop_penalty
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
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)
}
}
const handleBonusComplete = async (bonusId: number) => {
if (!currentAssignment) return
2026-01-03 00:12:07 +07:00
if (bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment) {
toast.warning('Прикрепите файл, ссылку или комментарий')
return
}
setIsCompletingBonus(true)
try {
const result = await assignmentsApi.completeBonusAssignment(
currentAssignment.id,
bonusId,
{
2026-01-03 00:12:07 +07:00
proof_files: bonusProofFiles.length > 0 ? bonusProofFiles : undefined,
proof_url: bonusProofUrl || undefined,
comment: bonusComment || undefined,
}
)
toast.success(`Бонус отмечен! +${result.points_earned} очков начислится при завершении игры`)
2026-01-03 00:12:07 +07:00
setBonusProofFiles([])
setBonusProofUrl('')
setBonusComment('')
setExpandedBonusId(null)
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось выполнить бонус')
} finally {
setIsCompletingBonus(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
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-17 02:03:33 +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)
}
}
2026-01-08 06:51:15 +07:00
// Consumable handlers
const handleUseSkip = async () => {
if (!currentAssignment || !id) return
setIsUsingConsumable('skip')
try {
await shopApi.useConsumable({
item_code: 'skip',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
})
toast.success('Задание пропущено без штрафа!')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Skip')
} finally {
setIsUsingConsumable(null)
}
}
2026-01-08 08:49:51 +07:00
const handleUseBoost = async () => {
if (!id) return
setIsUsingConsumable('boost')
2026-01-08 06:51:15 +07:00
try {
await shopApi.useConsumable({
2026-01-08 08:49:51 +07:00
item_code: 'boost',
marathon_id: parseInt(id),
})
toast.success('Boost активирован! x1.5 очков за текущее задание.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось активировать Boost')
} finally {
setIsUsingConsumable(null)
}
}
// Wild Card modal state
const [showWildCardModal, setShowWildCardModal] = useState(false)
const handleUseWildCard = async (gameId: number) => {
if (!currentAssignment || !id) return
setIsUsingConsumable('wild_card')
try {
const result = await shopApi.useConsumable({
item_code: 'wild_card',
2026-01-08 06:51:15 +07:00
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
2026-01-08 08:49:51 +07:00
game_id: gameId,
2026-01-08 06:51:15 +07:00
})
2026-01-08 08:49:51 +07:00
toast.success(result.effect_description)
setShowWildCardModal(false)
2026-01-08 06:51:15 +07:00
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
2026-01-08 08:49:51 +07:00
toast.error(error.response?.data?.detail || 'Не удалось использовать Wild Card')
2026-01-08 06:51:15 +07:00
} finally {
setIsUsingConsumable(null)
}
}
2026-01-08 08:49:51 +07:00
const handleUseLuckyDice = async () => {
2026-01-08 06:51:15 +07:00
if (!id) return
2026-01-08 08:49:51 +07:00
setIsUsingConsumable('lucky_dice')
2026-01-08 06:51:15 +07:00
try {
2026-01-08 08:49:51 +07:00
const result = await shopApi.useConsumable({
item_code: 'lucky_dice',
2026-01-08 06:51:15 +07:00
marathon_id: parseInt(id),
})
2026-01-08 08:49:51 +07:00
const multiplier = result.effect_data?.multiplier as number
toast.success(`Lucky Dice: x${multiplier} множитель!`)
2026-01-08 06:51:15 +07:00
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
2026-01-08 08:49:51 +07:00
toast.error(error.response?.data?.detail || 'Не удалось использовать Lucky Dice')
2026-01-08 06:51:15 +07:00
} finally {
setIsUsingConsumable(null)
}
}
2026-01-08 08:49:51 +07:00
// Copycat modal state
const [showCopycatModal, setShowCopycatModal] = useState(false)
const [copycatCandidates, setCopycatCandidates] = useState<SwapCandidate[]>([])
const [isLoadingCopycatCandidates, setIsLoadingCopycatCandidates] = useState(false)
const loadCopycatCandidates = async () => {
2026-01-08 06:51:15 +07:00
if (!id) return
2026-01-08 08:49:51 +07:00
setIsLoadingCopycatCandidates(true)
2026-01-08 06:51:15 +07:00
try {
2026-01-08 08:49:51 +07:00
const candidates = await eventsApi.getSwapCandidates(parseInt(id))
setCopycatCandidates(candidates)
} catch (error) {
console.error('Failed to load copycat candidates:', error)
} finally {
setIsLoadingCopycatCandidates(false)
}
}
const handleUseCopycat = async (targetParticipantId: number) => {
if (!currentAssignment || !id) return
setIsUsingConsumable('copycat')
try {
const result = await shopApi.useConsumable({
item_code: 'copycat',
2026-01-08 06:51:15 +07:00
marathon_id: parseInt(id),
2026-01-08 08:49:51 +07:00
assignment_id: currentAssignment.id,
target_participant_id: targetParticipantId,
2026-01-08 06:51:15 +07:00
})
2026-01-08 08:49:51 +07:00
toast.success(result.effect_description)
setShowCopycatModal(false)
2026-01-08 06:51:15 +07:00
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
2026-01-08 08:49:51 +07:00
toast.error(error.response?.data?.detail || 'Не удалось использовать Copycat')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseUndo = async () => {
if (!id) return
const confirmed = await confirm({
title: 'Использовать Undo?',
message: 'Это вернёт очки и серию от последнего пропуска.',
confirmText: 'Использовать',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setIsUsingConsumable('undo')
try {
const result = await shopApi.useConsumable({
item_code: 'undo',
marathon_id: parseInt(id),
})
toast.success(result.effect_description)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Undo')
2026-01-08 06:51:15 +07:00
} finally {
setIsUsingConsumable(null)
}
}
2025-12-14 02:38:35 +07:00
if (isLoading) {
return (
2025-12-17 02:03:33 +07:00
<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>
2025-12-14 02:38:35 +07:00
</div>
)
}
if (!marathon) {
2025-12-17 02:03:33 +07:00
return (
<div className="text-center py-12">
<p className="text-gray-400">Марафон не найден</p>
</div>
)
2025-12-14 02:38:35 +07:00
}
2025-12-16 02:22:12 +07:00
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">
2025-12-17 02:03:33 +07:00
<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" />
2025-12-16 02:22:12 +07:00
К марафону
</Link>
2025-12-17 02:03:33 +07:00
<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>
2025-12-16 02:22:12 +07:00
</div>
)
}
2025-12-14 02:38:35 +07:00
const participation = marathon.my_participation
return (
<div className="max-w-2xl mx-auto">
2025-12-15 03:22:29 +07:00
{/* Back button */}
2025-12-17 02:03:33 +07:00
<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" />
2025-12-15 03:22:29 +07:00
К марафону
</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-17 02:03:33 +07:00
<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"
/>
2025-12-14 02:38:35 +07:00
</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 && (
2025-12-17 02:03:33 +07:00
<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" />
2025-12-16 00:33:50 +07:00
</div>
2025-12-17 02:03:33 +07:00
<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>
{ra.is_playthrough ? (
<>
<p className="text-white font-medium">Прохождение: {ra.game_title}</p>
<p className="text-gray-400 text-sm">Прохождение игры</p>
</>
) : ra.challenge ? (
<>
<p className="text-white font-medium">{ra.challenge.title}</p>
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
</>
) : null}
2025-12-16 00:33:50 +07:00
</div>
{!ra.is_playthrough && ra.challenge && (
<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>
)}
2025-12-16 00:33:50 +07:00
</div>
2025-12-17 02:03:33 +07:00
<p className="text-orange-300 text-xs mt-2">
Причина: {ra.dispute_reason}
</p>
</div>
))}
</div>
</GlassCard>
2025-12-16 00:33:50 +07:00
)}
2026-01-08 06:51:15 +07:00
{/* Consumables Panel */}
{consumablesStatus && marathon?.allow_consumables && (
<GlassCard className="mb-6 border-purple-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
<Package className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-purple-400">Расходники</h3>
<p className="text-sm text-gray-400">Используйте для облегчения задания</p>
</div>
</div>
{/* Active effects */}
2026-01-08 08:49:51 +07:00
{(consumablesStatus.has_active_boost || consumablesStatus.has_lucky_dice) && (
2026-01-08 06:51:15 +07:00
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/30 rounded-xl">
<p className="text-green-400 text-sm font-medium mb-2">Активные эффекты:</p>
<div className="flex flex-wrap gap-2">
{consumablesStatus.has_active_boost && (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg border border-yellow-500/30 flex items-center gap-1">
2026-01-08 08:49:51 +07:00
<Zap className="w-3 h-3" /> Boost x1.5
</span>
)}
{consumablesStatus.has_lucky_dice && (
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 text-xs rounded-lg border border-purple-500/30 flex items-center gap-1">
<Dice5 className="w-3 h-3" /> Lucky Dice x{consumablesStatus.lucky_dice_multiplier}
2026-01-08 06:51:15 +07:00
</span>
)}
</div>
</div>
)}
{/* Consumables grid */}
<div className="grid grid-cols-2 gap-3">
{/* Skip */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<SkipForward className="w-4 h-4 text-orange-400" />
<span className="text-white font-medium">Skip</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.skips_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Пропустить без штрафа</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseSkip}
disabled={consumablesStatus.skips_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'skip'}
className="w-full"
>
Использовать
</NeonButton>
</div>
2026-01-08 08:49:51 +07:00
{/* Boost */}
2026-01-08 06:51:15 +07:00
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
2026-01-08 08:49:51 +07:00
<Zap className="w-4 h-4 text-yellow-400" />
<span className="text-white font-medium">Boost</span>
2026-01-08 06:51:15 +07:00
</div>
2026-01-08 08:49:51 +07:00
<span className="text-gray-400 text-sm">
{consumablesStatus.has_active_boost ? 'Активен' : `${consumablesStatus.boosts_available} шт.`}
</span>
2026-01-08 06:51:15 +07:00
</div>
2026-01-08 08:49:51 +07:00
<p className="text-gray-500 text-xs mb-2">x1.5 очков</p>
2026-01-08 06:51:15 +07:00
<NeonButton
size="sm"
variant="outline"
2026-01-08 08:49:51 +07:00
onClick={handleUseBoost}
disabled={consumablesStatus.has_active_boost || consumablesStatus.boosts_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'boost'}
2026-01-08 06:51:15 +07:00
className="w-full"
>
2026-01-08 08:49:51 +07:00
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
2026-01-08 06:51:15 +07:00
</NeonButton>
</div>
2026-01-08 08:49:51 +07:00
{/* Wild Card */}
2026-01-08 06:51:15 +07:00
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
2026-01-08 08:49:51 +07:00
<Shuffle className="w-4 h-4 text-green-400" />
<span className="text-white font-medium">Wild Card</span>
2026-01-08 06:51:15 +07:00
</div>
2026-01-08 08:49:51 +07:00
<span className="text-gray-400 text-sm">{consumablesStatus.wild_cards_available} шт.</span>
2026-01-08 06:51:15 +07:00
</div>
2026-01-08 08:49:51 +07:00
<p className="text-gray-500 text-xs mb-2">Выбрать игру</p>
2026-01-08 06:51:15 +07:00
<NeonButton
size="sm"
variant="outline"
2026-01-08 08:49:51 +07:00
onClick={() => setShowWildCardModal(true)}
disabled={consumablesStatus.wild_cards_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'wild_card'}
2026-01-08 06:51:15 +07:00
className="w-full"
>
2026-01-08 08:49:51 +07:00
Выбрать
2026-01-08 06:51:15 +07:00
</NeonButton>
</div>
2026-01-08 08:49:51 +07:00
{/* Lucky Dice */}
2026-01-08 06:51:15 +07:00
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
2026-01-08 08:49:51 +07:00
<Dice5 className="w-4 h-4 text-purple-400" />
<span className="text-white font-medium">Lucky Dice</span>
2026-01-08 06:51:15 +07:00
</div>
<span className="text-gray-400 text-sm">
2026-01-08 08:49:51 +07:00
{consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : `${consumablesStatus.lucky_dice_available} шт.`}
2026-01-08 06:51:15 +07:00
</span>
</div>
2026-01-08 08:49:51 +07:00
<p className="text-gray-500 text-xs mb-2">Случайный множитель</p>
2026-01-08 06:51:15 +07:00
<NeonButton
size="sm"
variant="outline"
2026-01-08 08:49:51 +07:00
onClick={handleUseLuckyDice}
disabled={consumablesStatus.has_lucky_dice || consumablesStatus.lucky_dice_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'lucky_dice'}
2026-01-08 06:51:15 +07:00
className="w-full"
>
2026-01-08 08:49:51 +07:00
{consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : 'Бросить'}
</NeonButton>
</div>
{/* Copycat */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Copy className="w-4 h-4 text-cyan-400" />
<span className="text-white font-medium">Copycat</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.copycats_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Скопировать задание</p>
<NeonButton
size="sm"
variant="outline"
onClick={() => {
setShowCopycatModal(true)
loadCopycatCandidates()
}}
disabled={consumablesStatus.copycats_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'copycat'}
className="w-full"
>
Выбрать
</NeonButton>
</div>
{/* Undo */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Undo2 className="w-4 h-4 text-red-400" />
<span className="text-white font-medium">Undo</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.undos_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Отменить дроп</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseUndo}
disabled={!consumablesStatus.can_undo || consumablesStatus.undos_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'undo'}
className="w-full"
>
{consumablesStatus.can_undo ? 'Отменить' : 'Нет дропа'}
2026-01-08 06:51:15 +07:00
</NeonButton>
</div>
</div>
</GlassCard>
)}
2026-01-08 08:49:51 +07:00
{/* Wild Card Modal */}
{showWildCardModal && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<GlassCard className="w-full max-w-md max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-white">Выберите игру</h3>
<button
onClick={() => setShowWildCardModal(false)}
className="p-2 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
Вы получите случайное задание из выбранной игры
</p>
<div className="space-y-2">
{games.map((game) => (
<button
key={game.id}
onClick={() => handleUseWildCard(game.id)}
disabled={isUsingConsumable === 'wild_card'}
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-green-500/30 disabled:opacity-50"
>
<p className="text-white font-medium">{game.title}</p>
<p className="text-gray-400 text-xs">{game.challenges_count} челленджей</p>
</button>
))}
</div>
</GlassCard>
</div>
)}
{/* Copycat Modal */}
{showCopycatModal && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<GlassCard className="w-full max-w-md max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-white">Скопировать задание</h3>
<button
onClick={() => setShowCopycatModal(false)}
className="p-2 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
Выберите участника, чьё задание хотите скопировать
</p>
{isLoadingCopycatCandidates ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
</div>
) : copycatCandidates.length === 0 ? (
<p className="text-center text-gray-500 py-8">Нет доступных заданий для копирования</p>
) : (
<div className="space-y-2">
{copycatCandidates.map((candidate) => (
<button
key={candidate.participant_id}
onClick={() => handleUseCopycat(candidate.participant_id)}
disabled={isUsingConsumable === 'copycat'}
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-cyan-500/30 disabled:opacity-50"
>
<p className="text-white font-medium">{candidate.user.nickname}</p>
<p className="text-cyan-400 text-sm">{candidate.challenge_title}</p>
<p className="text-gray-500 text-xs">
{candidate.game_title} {candidate.challenge_points} очков {candidate.challenge_difficulty}
</p>
</button>
))}
</div>
)}
</GlassCard>
</div>
)}
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">
2025-12-17 02:03:33 +07:00
<NeonButton
variant={activeTab === 'spin' ? 'primary' : 'outline'}
2025-12-15 23:03:59 +07:00
onClick={() => setActiveTab('spin')}
className="flex-1"
>
Мой прокрут
2025-12-17 02:03:33 +07:00
</NeonButton>
<NeonButton
variant={activeTab === 'event' ? 'primary' : 'outline'}
2025-12-15 23:03:59 +07:00
onClick={() => setActiveTab('event')}
className="flex-1 relative"
2025-12-17 02:03:33 +07:00
color="purple"
2025-12-15 23:03:59 +07:00
>
Общий враг
{eventAssignment?.assignment && !eventAssignment.is_completed && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse" />
)}
2025-12-17 02:03:33 +07:00
</NeonButton>
2025-12-15 23:03:59 +07:00
</div>
)}
{/* Event tab content (Common Enemy) */}
{activeTab === 'event' && activeEvent?.event?.type === 'common_enemy' && (
<>
{/* Common Enemy Leaderboard */}
2025-12-17 02:03:33 +07:00
<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}
2025-12-17 02:03:33 +07:00
</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}
2025-12-17 02:03:33 +07:00
</p>
<p className="text-gray-300">
{eventAssignment.assignment.challenge?.description}
2025-12-17 02:03:33 +07:00
</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} очков
2025-12-17 02:03:33 +07:00
</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}
2025-12-17 02:03:33 +07:00
</span>
{eventAssignment.assignment.challenge?.estimated_time && (
2025-12-17 02:03:33 +07:00
<span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
~{eventAssignment.assignment.challenge.estimated_time} мин
2025-12-15 23:03:59 +07:00
</span>
)}
</div>
{eventAssignment.assignment.challenge?.proof_hint && (
2025-12-17 02:03:33 +07:00
<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})
2025-12-17 02:03:33 +07:00
</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>
))}
2025-12-15 23:03:59 +07:00
</div>
) : (
2025-12-17 02:03:33 +07:00
<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" />}
2025-12-15 23:03:59 +07:00
>
2025-12-17 02:03:33 +07:00
Назад
</NeonButton>
</div>
{isLoadingChallenges ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-orange-400" />
2025-12-15 23:03:59 +07:00
</div>
2025-12-17 02:03:33 +07:00
) : 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>
)}
2025-12-15 23:03:59 +07:00
</div>
)}
2025-12-17 02:03:33 +07:00
</GlassCard>
)}
2025-12-15 23:03:59 +07:00
2025-12-17 02:03:33 +07:00
{/* 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">
2025-12-15 23:03:59 +07:00
<div className="text-center mb-6">
<span className={`px-4 py-1.5 rounded-full text-sm font-medium border ${
currentAssignment.is_playthrough
? 'bg-accent-500/20 text-accent-400 border-accent-500/30'
: 'bg-neon-500/20 text-neon-400 border-neon-500/30'
}`}>
{currentAssignment.is_playthrough ? 'Прохождение игры' : 'Активное задание'}
2025-12-15 23:03:59 +07:00
</span>
</div>
<div className="mb-4">
2025-12-17 02:03:33 +07:00
<p className="text-gray-400 text-sm mb-1">Игра</p>
2026-01-03 00:12:07 +07:00
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="text-xl font-bold text-white">
{currentAssignment.is_playthrough
? currentAssignment.game?.title
: currentAssignment.challenge?.game.title}
</p>
{(currentAssignment.is_playthrough
? currentAssignment.game?.download_url
: currentAssignment.challenge?.game.download_url) && (
<a
href={currentAssignment.is_playthrough
? currentAssignment.game?.download_url
: currentAssignment.challenge?.game.download_url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5 hover:bg-neon-500/30 transition-colors"
>
<Download className="w-4 h-4" />
Скачать игру
</a>
)}
</div>
2025-12-15 23:03:59 +07:00
</div>
{currentAssignment.is_playthrough ? (
// Playthrough task
<>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задача</p>
<p className="text-xl font-bold text-accent-400 mb-2">
Пройти игру
</p>
<p className="text-gray-300">
{currentAssignment.playthrough_info?.description}
</p>
</div>
2025-12-15 23:03:59 +07:00
<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.playthrough_info?.points} очков
</span>
</div>
2025-12-15 23:03:59 +07:00
{currentAssignment.playthrough_info?.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.playthrough_info.proof_hint}
</p>
</div>
)}
{/* Bonus challenges */}
{currentAssignment.bonus_challenges && currentAssignment.bonus_challenges.length > 0 && (
<div className="mb-6 p-4 bg-accent-500/10 rounded-xl border border-accent-500/20">
<p className="text-accent-400 font-medium mb-3">
Бонусные челленджи (опционально) {currentAssignment.bonus_challenges.filter(b => b.status === 'completed').length}/{currentAssignment.bonus_challenges.length}
</p>
<div className="space-y-2">
{currentAssignment.bonus_challenges.map((bonus) => (
<div
key={bonus.id}
className={`rounded-lg border overflow-hidden ${
bonus.status === 'completed'
? 'bg-green-500/10 border-green-500/30'
: 'bg-dark-700/50 border-dark-600'
}`}
>
{/* Bonus header */}
<div
className={`p-3 flex items-center justify-between ${
bonus.status === 'pending' ? 'cursor-pointer hover:bg-dark-600/50' : ''
}`}
onClick={() => {
if (bonus.status === 'pending') {
setExpandedBonusId(expandedBonusId === bonus.id ? null : bonus.id)
2026-01-03 00:12:07 +07:00
setBonusProofFiles([])
setBonusProofUrl('')
setBonusComment('')
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
}
}}
>
<div className="flex-1">
<div className="flex items-center gap-2">
{bonus.status === 'completed' && (
<Check className="w-4 h-4 text-green-400" />
)}
<p className="text-white font-medium text-sm">{bonus.challenge.title}</p>
</div>
<p className="text-gray-400 text-xs mt-0.5">{bonus.challenge.description}</p>
</div>
<div className="text-right shrink-0 ml-2">
{bonus.status === 'completed' ? (
<span className="text-green-400 text-sm font-medium">+{bonus.points_earned}</span>
) : (
<span className="text-accent-400 text-sm">+{bonus.challenge.points}</span>
)}
</div>
</div>
{/* Expanded form for completing */}
{expandedBonusId === bonus.id && bonus.status === 'pending' && (
<div className="p-3 border-t border-dark-600 bg-dark-800/50 space-y-3">
{bonus.challenge.proof_hint && (
<p className="text-xs text-gray-400">
<strong className="text-white">Пруф:</strong> {bonus.challenge.proof_hint}
</p>
)}
{/* File upload */}
<input
ref={bonusFileInputRef}
type="file"
accept="image/*,video/*"
2026-01-03 00:12:07 +07:00
multiple
className="hidden"
onChange={(e) => {
e.stopPropagation()
2026-01-03 00:12:07 +07:00
const files = Array.from(e.target.files || [])
setBonusProofFiles(prev => [...prev, ...files])
e.target.value = ''
}}
/>
2026-01-03 00:12:07 +07:00
{bonusProofFiles.length > 0 ? (
<div className="space-y-2">
{bonusProofFiles.map((file, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-dark-700/50 rounded-lg border border-dark-600">
<span className="text-white text-sm flex-1 truncate">{file.name}</span>
<button
onClick={(e) => {
e.stopPropagation()
setBonusProofFiles(prev => prev.filter((_, i) => i !== index))
}}
className="p-1 rounded text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<button
onClick={(e) => {
e.stopPropagation()
2026-01-03 00:12:07 +07:00
bonusFileInputRef.current?.click()
}}
2026-01-03 00:12:07 +07:00
className="w-full p-2 border border-dashed border-neon-500/30 rounded-lg text-neon-400 hover:border-neon-500/50 hover:bg-neon-500/5 transition-all text-sm flex items-center justify-center gap-2"
>
2026-01-03 00:12:07 +07:00
<Upload className="w-4 h-4" />
Добавить еще файл
</button>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
bonusFileInputRef.current?.click()
}}
className="w-full p-2 border border-dashed border-dark-500 rounded-lg text-gray-400 text-sm hover:border-accent-400 hover:text-accent-400 transition-colors flex items-center justify-center gap-2"
>
<Upload className="w-4 h-4" />
Загрузить файл
</button>
)}
<div className="text-center text-gray-500 text-xs">или</div>
<input
type="text"
className="input text-sm"
placeholder="Ссылка на пруф (YouTube, Steam и т.д.)"
value={bonusProofUrl}
onChange={(e) => setBonusProofUrl(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
<textarea
className="input text-sm resize-none"
placeholder="Комментарий (необязательно)"
rows={2}
value={bonusComment}
onChange={(e) => setBonusComment(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex gap-2">
<NeonButton
size="sm"
onClick={(e) => {
e.stopPropagation()
handleBonusComplete(bonus.id)
}}
isLoading={isCompletingBonus}
2026-01-03 00:12:07 +07:00
disabled={bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment}
icon={<Check className="w-3 h-3" />}
>
Выполнено
</NeonButton>
<NeonButton
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
2026-01-03 00:12:07 +07:00
setBonusProofFiles([])
setBonusProofUrl('')
setBonusComment('')
setExpandedBonusId(null)
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
}}
>
Отмена
</NeonButton>
</div>
</div>
)}
</div>
))}
</div>
<p className="text-xs text-gray-500 mt-2">
Нажмите на бонус, чтобы отметить. Очки начислятся при завершении игры.
</p>
</div>
)}
</>
) : (
// Regular challenge
<>
<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>
)}
</>
2025-12-15 23:03:59 +07:00
)}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Загрузить доказательство ({currentAssignment.is_playthrough
? currentAssignment.playthrough_info?.proof_type
: currentAssignment.challenge?.proof_type})
2025-12-15 23:03:59 +07:00
</label>
<input
2025-12-17 02:03:33 +07:00
ref={fileInputRef}
2025-12-15 23:03:59 +07:00
type="file"
accept="image/*,video/*"
2026-01-03 00:12:07 +07:00
multiple
2025-12-15 23:03:59 +07:00
className="hidden"
2026-01-03 00:12:07 +07:00
onChange={(e) => {
const files = Array.from(e.target.files || [])
setProofFiles(prev => [...prev, ...files])
// Reset input to allow selecting same files again
e.target.value = ''
}}
2025-12-15 23:03:59 +07:00
/>
2026-01-03 00:12:07 +07:00
{proofFiles.length > 0 ? (
<div className="space-y-2">
{proofFiles.map((file, index) => (
<div key={index} 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">{file.name}</span>
<button
onClick={() => setProofFiles(proofFiles.filter((_, i) => i !== index))}
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>
))}
<NeonButton
variant="outline"
className="w-full"
onClick={() => fileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />}
2025-12-15 23:03:59 +07:00
>
2026-01-03 00:12:07 +07:00
Добавить ещё файлы
</NeonButton>
2025-12-15 23:03:59 +07:00
</div>
) : (
<div>
2025-12-17 02:03:33 +07:00
<NeonButton
variant="outline"
className="w-full"
2025-12-17 02:03:33 +07:00
onClick={() => fileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />}
>
2026-01-03 00:12:07 +07:00
Выбрать файлы
2025-12-17 02:03:33 +07:00
</NeonButton>
<p className="text-xs text-gray-500 mt-2 text-center">
2026-01-03 00:12:07 +07:00
Можно выбрать несколько файлов. Макс. 15 МБ для изображений, 30 МБ для видео
</p>
</div>
2025-12-15 23:03:59 +07:00
)}
</div>
<div className="text-center text-gray-500">или</div>
<input
type="text"
className="input"
placeholder="Вставьте ссылку (YouTube, профиль Steam и т.д.)"
2025-12-17 02:03:33 +07:00
value={proofUrl}
onChange={(e) => setProofUrl(e.target.value)}
2025-12-15 23:03:59 +07:00
/>
<textarea
className="input min-h-[80px] resize-none"
placeholder="Комментарий (необязательно)"
2025-12-17 02:03:33 +07:00
value={comment}
onChange={(e) => setComment(e.target.value)}
2025-12-15 23:03:59 +07:00
/>
</div>
<div className="flex gap-3">
2025-12-17 02:03:33 +07:00
<NeonButton
2025-12-15 23:03:59 +07:00
className="flex-1"
2025-12-17 02:03:33 +07:00
onClick={handleComplete}
isLoading={isCompleting}
disabled={currentAssignment.is_playthrough
2026-01-03 00:12:07 +07:00
? (proofFiles.length === 0 && !proofUrl && !comment)
: (proofFiles.length === 0 && !proofUrl)
}
2025-12-17 02:03:33 +07:00
icon={<Check className="w-4 h-4" />}
2025-12-15 23:03:59 +07:00
>
Выполнено
2025-12-17 02:03:33 +07:00
</NeonButton>
<NeonButton
2025-12-17 20:59:47 +07:00
variant="danger"
2025-12-17 02:03:33 +07:00
onClick={handleDrop}
isLoading={isDropping}
icon={<XCircle className="w-4 h-4" />}
>
Пропустить (-{currentAssignment.drop_penalty})
</NeonButton>
2025-12-15 03:22:29 +07:00
</div>
2025-12-17 02:03:33 +07:00
</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>
2025-12-15 03:22:29 +07:00
</div>
2025-12-15 23:50:37 +07:00
2025-12-17 02:03:33 +07:00
{/* 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})
2025-12-15 23:50:37 +07:00
</h4>
2025-12-17 02:03:33 +07:00
<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>
2025-12-15 23:50:37 +07:00
</div>
2025-12-17 02:03:33 +07:00
)}
2025-12-15 23:50:37 +07:00
2025-12-17 02:03:33 +07:00
{/* 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>
2025-12-15 23:50:37 +07:00
<div className="space-y-3">
2025-12-17 02:03:33 +07:00
{swapRequests.outgoing.map((request) => (
2025-12-15 23:50:37 +07:00
<div
2025-12-17 02:03:33 +07:00
key={request.id}
className="p-4 bg-accent-500/10 border border-accent-500/30 rounded-xl"
2025-12-15 23:50:37 +07:00
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
2025-12-17 02:03:33 +07:00
<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>
2025-12-15 23:50:37 +07:00
</div>
2025-12-17 02:03:33 +07:00
<NeonButton
2025-12-15 23:50:37 +07:00
size="sm"
2025-12-17 02:03:33 +07:00
variant="outline"
onClick={() => handleCancelSwapRequest(request.id)}
isLoading={processingRequestId === request.id}
disabled={processingRequestId !== null}
icon={<X className="w-4 h-4" />}
2025-12-15 23:50:37 +07:00
>
2025-12-17 02:03:33 +07:00
Отменить
</NeonButton>
2025-12-15 23:50:37 +07:00
</div>
</div>
))}
</div>
2025-12-17 02:03:33 +07:00
</div>
)}
2025-12-14 02:38:35 +07:00
2025-12-17 02:03:33 +07:00
{/* Swap candidates */}
<div>
2025-12-17 02:03:33 +07:00
<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" />
2025-12-15 03:22:29 +07:00
</div>
2025-12-17 02:03:33 +07:00
) : swapCandidates.filter(c =>
2025-12-15 03:22:29 +07:00
!swapRequests.outgoing.some(r => r.to_user.id === c.user.id) &&
!swapRequests.incoming.some(r => r.from_user.id === c.user.id)
2025-12-17 02:03:33 +07:00
).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"
2025-12-15 03:22:29 +07:00
>
2025-12-17 02:03:33 +07:00
<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>
))}
2025-12-15 03:22:29 +07:00
</div>
2025-12-17 02:03:33 +07:00
)}
2025-12-15 03:22:29 +07:00
</div>
2025-12-17 02:03:33 +07:00
</GlassCard>
)}
</>
)}
2025-12-15 23:03:59 +07:00
</>
)}
2025-12-14 02:38:35 +07:00
</div>
)
}