Common enemy rework
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import client from './client'
|
||||
import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types'
|
||||
import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult } from '@/types'
|
||||
|
||||
export const eventsApi = {
|
||||
getActive: async (marathonId: number): Promise<ActiveEvent> => {
|
||||
@@ -64,4 +64,27 @@ export const eventsApi = {
|
||||
const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Event Assignment (Common Enemy)
|
||||
getEventAssignment: async (marathonId: number): Promise<EventAssignment> => {
|
||||
const response = await client.get<EventAssignment>(`/marathons/${marathonId}/event-assignment`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
completeEventAssignment: async (
|
||||
assignmentId: number,
|
||||
data: { proof_url?: string; comment?: string; proof_file?: File }
|
||||
): Promise<CompleteResult> => {
|
||||
const formData = new FormData()
|
||||
if (data.proof_url) formData.append('proof_url', data.proof_url)
|
||||
if (data.comment) formData.append('comment', data.comment)
|
||||
if (data.proof_file) formData.append('proof_file', data.proof_file)
|
||||
|
||||
const response = await client.post<CompleteResult>(
|
||||
`/event-assignments/${assignmentId}/complete`,
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,6 +245,14 @@ export interface CommonEnemyLeaderboardEntry {
|
||||
bonus_points: number
|
||||
}
|
||||
|
||||
// Event Assignment (Common Enemy)
|
||||
export interface EventAssignment {
|
||||
assignment: Assignment | null
|
||||
event_id: number | null
|
||||
challenge_id: number | null
|
||||
is_completed: boolean
|
||||
}
|
||||
|
||||
// Activity types
|
||||
export type ActivityType =
|
||||
| 'join'
|
||||
|
||||
Reference in New Issue
Block a user