Common enemy rework

This commit is contained in:
2025-12-15 23:03:59 +07:00
parent 9a037cb34f
commit 07e02ce32d
11 changed files with 731 additions and 29 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api'
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, DroppedAssignment, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types'
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, DroppedAssignment, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner'
@@ -41,7 +41,19 @@ export function PlayPage() {
// Common Enemy leaderboard state
const [commonEnemyLeaderboard, setCommonEnemyLeaderboard] = useState<CommonEnemyLeaderboardEntry[]>([])
// 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)
const fileInputRef = useRef<HTMLInputElement>(null)
const eventFileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadData()
@@ -123,16 +135,18 @@ export function PlayPage() {
const loadData = async () => {
if (!id) return
try {
const [marathonData, assignment, gamesData, eventData] = await Promise.all([
const [marathonData, assignment, gamesData, eventData, eventAssignmentData] = await Promise.all([
marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.list(parseInt(id), 'approved'),
eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)),
])
setMarathon(marathonData)
setCurrentAssignment(assignment)
setGames(gamesData)
setActiveEvent(eventData)
setEventAssignment(eventAssignmentData)
} catch (error) {
console.error('Failed to load data:', error)
} finally {
@@ -224,6 +238,37 @@ export function PlayPage() {
}
}
const handleEventComplete = async () => {
if (!eventAssignment?.assignment) return
if (!eventProofFile && !eventProofUrl) {
alert('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
}
setIsEventCompleting(true)
try {
const result = await eventsApi.completeEventAssignment(eventAssignment.assignment.id, {
proof_file: eventProofFile || undefined,
proof_url: eventProofUrl || undefined,
comment: eventComment || undefined,
})
alert(`Выполнено! +${result.points_earned} очков`)
// Reset form
setEventProofFile(null)
setEventProofUrl('')
setEventComment('')
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось выполнить')
} finally {
setIsEventCompleting(false)
}
}
const handleRematch = async (assignmentId: number) => {
if (!id) return
@@ -367,8 +412,248 @@ export function PlayPage() {
</div>
)}
{/* Common Enemy Leaderboard */}
{/* Tabs for Common Enemy event */}
{activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6">
<Button
variant={activeTab === 'spin' ? 'primary' : 'secondary'}
onClick={() => setActiveTab('spin')}
className="flex-1"
>
Мой прокрут
</Button>
<Button
variant={activeTab === 'event' ? 'primary' : 'secondary'}
onClick={() => setActiveTab('event')}
className="flex-1 relative"
>
Общий враг
{eventAssignment?.assignment && !eventAssignment.is_completed && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse" />
)}
</Button>
</div>
)}
{/* Event tab content (Common Enemy) */}
{activeTab === 'event' && activeEvent?.event?.type === 'common_enemy' && (
<>
{/* Common Enemy Leaderboard */}
<Card className="mb-6">
<CardContent>
<div className="flex items-center gap-2 mb-4">
<Users className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-bold text-white">Выполнили челлендж</h3>
{commonEnemyLeaderboard.length > 0 && (
<span className="ml-auto text-gray-400 text-sm">
{commonEnemyLeaderboard.length} чел.
</span>
)}
</div>
{commonEnemyLeaderboard.length === 0 ? (
<div className="text-center py-4 text-gray-500">
Пока никто не выполнил. Будь первым!
</div>
) : (
<div className="space-y-2">
{commonEnemyLeaderboard.map((entry) => (
<div
key={entry.participant_id}
className={`
flex items-center gap-3 p-3 rounded-lg
${entry.rank === 1 ? 'bg-yellow-500/20 border border-yellow-500/30' :
entry.rank === 2 ? 'bg-gray-400/20 border border-gray-400/30' :
entry.rank === 3 ? 'bg-orange-600/20 border border-orange-600/30' :
'bg-gray-800'}
`}
>
<div className={`
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm
${entry.rank === 1 ? 'bg-yellow-500 text-black' :
entry.rank === 2 ? 'bg-gray-400 text-black' :
entry.rank === 3 ? 'bg-orange-600 text-white' :
'bg-gray-700 text-gray-300'}
`}>
{entry.rank && entry.rank <= 3 ? (
<Trophy className="w-4 h-4" />
) : (
entry.rank
)}
</div>
<div className="flex-1">
<p className="text-white font-medium">{entry.user.nickname}</p>
</div>
{entry.bonus_points > 0 && (
<span className="text-green-400 text-sm font-medium">
+{entry.bonus_points} бонус
</span>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Event Assignment Card */}
{eventAssignment?.assignment && !eventAssignment.is_completed ? (
<Card>
<CardContent>
<div className="text-center mb-6">
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm">
Задание события "Общий враг"
</span>
</div>
{/* Game */}
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-400 mb-1">Игра</h3>
<p className="text-xl font-bold text-white">
{eventAssignment.assignment.challenge.game.title}
</p>
</div>
{/* Challenge */}
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-400 mb-1">Задание</h3>
<p className="text-xl font-bold text-white mb-2">
{eventAssignment.assignment.challenge.title}
</p>
<p className="text-gray-300">
{eventAssignment.assignment.challenge.description}
</p>
</div>
{/* Points */}
<div className="flex items-center gap-4 mb-6 text-sm">
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full">
+{eventAssignment.assignment.challenge.points} очков
</span>
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full">
{eventAssignment.assignment.challenge.difficulty}
</span>
{eventAssignment.assignment.challenge.estimated_time && (
<span className="text-gray-400">
~{eventAssignment.assignment.challenge.estimated_time} мин
</span>
)}
</div>
{/* Proof hint */}
{eventAssignment.assignment.challenge.proof_hint && (
<div className="mb-6 p-3 bg-gray-900 rounded-lg">
<p className="text-sm text-gray-400">
<strong>Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
</p>
</div>
)}
{/* Proof upload */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Загрузить доказательство ({eventAssignment.assignment.challenge.proof_type})
</label>
{/* File upload */}
<input
ref={eventFileInputRef}
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => setEventProofFile(e.target.files?.[0] || null)}
/>
{eventProofFile ? (
<div className="flex items-center gap-2 p-3 bg-gray-900 rounded-lg">
<span className="text-white flex-1 truncate">{eventProofFile.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setEventProofFile(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<Button
variant="secondary"
className="w-full"
onClick={() => eventFileInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
Выбрать файл
</Button>
)}
</div>
<div className="text-center text-gray-500">или</div>
{/* URL input */}
<input
type="text"
className="input"
placeholder="Вставьте ссылку (YouTube, профиль Steam и т.д.)"
value={eventProofUrl}
onChange={(e) => setEventProofUrl(e.target.value)}
/>
{/* Comment */}
<textarea
className="input min-h-[80px] resize-none"
placeholder="Комментарий (необязательно)"
value={eventComment}
onChange={(e) => setEventComment(e.target.value)}
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
className="flex-1"
onClick={handleEventComplete}
isLoading={isEventCompleting}
disabled={!eventProofFile && !eventProofUrl}
>
Выполнено
</Button>
</div>
</CardContent>
</Card>
) : eventAssignment?.is_completed ? (
<Card>
<CardContent className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Check className="w-8 h-8 text-green-500" />
</div>
<h3 className="text-xl font-bold text-white mb-2">Задание выполнено!</h3>
<p className="text-gray-400">
Вы уже завершили челлендж события "Общий враг"
</p>
{eventAssignment.assignment && (
<p className="text-green-400 mt-2">
+{eventAssignment.assignment.points_earned} очков
</p>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="text-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-gray-500 mx-auto mb-4" />
<p className="text-gray-400">Загрузка задания события...</p>
</CardContent>
</Card>
)}
</>
)}
{/* Spin tab content - only show when spin tab is active or no common_enemy event */}
{(activeTab === 'spin' || activeEvent?.event?.type !== 'common_enemy') && (
<>
{/* Common Enemy Leaderboard - show on spin tab too for context */}
{activeEvent?.event?.type === 'common_enemy' && activeTab === 'spin' && commonEnemyLeaderboard.length > 0 && (
<Card className="mb-6">
<CardContent>
<div className="flex items-center gap-2 mb-4">
@@ -425,10 +710,10 @@ export function PlayPage() {
)}
</CardContent>
</Card>
)}
)}
{/* No active assignment - show spin wheel */}
{!currentAssignment && (
{/* No active assignment - show spin wheel */}
{!currentAssignment && (
<>
<Card>
<CardContent className="py-8">
@@ -807,6 +1092,8 @@ export function PlayPage() {
)}
</>
)}
</>
)}
</div>
)
}