Change rematch event to change game

This commit is contained in:
2025-12-15 23:50:37 +07:00
parent 07e02ce32d
commit 339a212e57
14 changed files with 428 additions and 257 deletions

View File

@@ -1,5 +1,5 @@
import client from './client'
import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult } from '@/types'
import type { ActiveEvent, MarathonEvent, EventCreate, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult, GameChoiceChallenges } from '@/types'
export const eventsApi = {
getActive: async (marathonId: number): Promise<ActiveEvent> => {
@@ -46,12 +46,18 @@ export const eventsApi = {
await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`)
},
rematch: async (marathonId: number, assignmentId: number): Promise<void> => {
await client.post(`/marathons/${marathonId}/rematch/${assignmentId}`)
// Game Choice event
getGameChoiceChallenges: async (marathonId: number, gameId: number): Promise<GameChoiceChallenges> => {
const response = await client.get<GameChoiceChallenges>(`/marathons/${marathonId}/game-choice/challenges`, {
params: { game_id: gameId },
})
return response.data
},
getDroppedAssignments: async (marathonId: number): Promise<DroppedAssignment[]> => {
const response = await client.get<DroppedAssignment[]>(`/marathons/${marathonId}/dropped-assignments`)
selectGameChoiceChallenge: async (marathonId: number, challengeId: number): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/game-choice/select`, {
challenge_id: challengeId,
})
return response.data
},

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Clock } from 'lucide-react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock } from 'lucide-react'
import type { ActiveEvent, EventType } from '@/types'
import { EVENT_INFO } from '@/types'
@@ -14,7 +14,7 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
double_risk: <Shield className="w-5 h-5" />,
jackpot: <Gift className="w-5 h-5" />,
swap: <ArrowLeftRight className="w-5 h-5" />,
rematch: <RotateCcw className="w-5 h-5" />,
game_choice: <Gamepad2 className="w-5 h-5" />,
}
const EVENT_COLORS: Record<EventType, string> = {
@@ -23,7 +23,7 @@ const EVENT_COLORS: Record<EventType, string> = {
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400',
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400',
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400',
rematch: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
game_choice: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
}
function formatTime(seconds: number): string {

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Play, Square } from 'lucide-react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square } from 'lucide-react'
import { Button } from '@/components/ui'
import { eventsApi } from '@/api'
import type { ActiveEvent, EventType, Challenge } from '@/types'
@@ -17,7 +17,7 @@ const EVENT_TYPES: EventType[] = [
'double_risk',
'jackpot',
'swap',
'rematch',
'game_choice',
'common_enemy',
]
@@ -27,7 +27,7 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
double_risk: <Shield className="w-4 h-4" />,
jackpot: <Gift className="w-4 h-4" />,
swap: <ArrowLeftRight className="w-4 h-4" />,
rematch: <RotateCcw className="w-4 h-4" />,
game_choice: <Gamepad2 className="w-4 h-4" />,
}
export function EventControl({

View File

@@ -1,11 +1,11 @@
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, EventAssignment } from '@/types'
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, RotateCcw, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react'
export function PlayPage() {
const { id } = useParams<{ id: string }>()
@@ -26,10 +26,11 @@ export function PlayPage() {
// Drop state
const [isDropping, setIsDropping] = useState(false)
// Rematch state
const [droppedAssignments, setDroppedAssignments] = useState<DroppedAssignment[]>([])
const [isRematchLoading, setIsRematchLoading] = useState(false)
const [rematchingId, setRematchingId] = useState<number | null>(null)
// 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)
// Swap state
const [swapCandidates, setSwapCandidates] = useState<SwapCandidate[]>([])
@@ -59,12 +60,13 @@ export function PlayPage() {
loadData()
}, [id])
// Load dropped assignments when rematch event is active
// Reset game choice state when event changes or ends
useEffect(() => {
if (activeEvent?.event?.type === 'rematch' && !currentAssignment) {
loadDroppedAssignments()
if (activeEvent?.event?.type !== 'game_choice') {
setSelectedGameId(null)
setGameChoiceChallenges(null)
}
}, [activeEvent?.event?.type, currentAssignment])
}, [activeEvent?.event?.type])
// Load swap candidates and requests when swap event is active
useEffect(() => {
@@ -86,16 +88,17 @@ export function PlayPage() {
}
}, [activeEvent?.event?.type])
const loadDroppedAssignments = async () => {
const loadGameChoiceChallenges = async (gameId: number) => {
if (!id) return
setIsRematchLoading(true)
setIsLoadingChallenges(true)
try {
const dropped = await eventsApi.getDroppedAssignments(parseInt(id))
setDroppedAssignments(dropped)
const challenges = await eventsApi.getGameChoiceChallenges(parseInt(id), gameId)
setGameChoiceChallenges(challenges)
} catch (error) {
console.error('Failed to load dropped assignments:', error)
console.error('Failed to load game choice challenges:', error)
alert('Не удалось загрузить челленджи для этой игры')
} finally {
setIsRematchLoading(false)
setIsLoadingChallenges(false)
}
}
@@ -269,21 +272,33 @@ export function PlayPage() {
}
}
const handleRematch = async (assignmentId: number) => {
const handleGameSelect = async (gameId: number) => {
setSelectedGameId(gameId)
await loadGameChoiceChallenges(gameId)
}
const handleChallengeSelect = async (challengeId: number) => {
if (!id) return
if (!confirm('Начать реванш? Вы получите 50% от обычных очков за выполнение.')) return
const hasActiveAssignment = !!currentAssignment
const confirmMessage = hasActiveAssignment
? 'Выбрать этот челлендж? Текущее задание будет заменено без штрафа.'
: 'Выбрать этот челлендж?'
setRematchingId(assignmentId)
if (!confirm(confirmMessage)) return
setIsSelectingChallenge(true)
try {
await eventsApi.rematch(parseInt(id), assignmentId)
alert('Реванш начат! Выполните задание за 50% очков.')
const result = await eventsApi.selectGameChoiceChallenge(parseInt(id), challengeId)
alert(result.message)
setSelectedGameId(null)
setGameChoiceChallenges(null)
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось начать реванш')
alert(error.response?.data?.detail || 'Не удалось выбрать челлендж')
} finally {
setRematchingId(null)
setIsSelectingChallenge(false)
}
}
@@ -654,122 +669,56 @@ export function PlayPage() {
<>
{/* 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">
<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>
)}
{/* 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>
{/* Rematch section - show during rematch event */}
{activeEvent?.event?.type === 'rematch' && droppedAssignments.length > 0 && (
<Card className="mt-6">
<Card className="mb-6">
<CardContent>
<div className="flex items-center gap-2 mb-4">
<RotateCcw className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-bold text-white">Реванш</h3>
<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>
<p className="text-gray-400 text-sm mb-4">
Во время события "Реванш" вы можете повторить пропущенные задания за 50% очков
</p>
{isRematchLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
{commonEnemyLeaderboard.length === 0 ? (
<div className="text-center py-4 text-gray-500">
Пока никто не выполнил. Будь первым!
</div>
) : (
<div className="space-y-3">
{droppedAssignments.map((dropped) => (
<div className="space-y-2">
{commonEnemyLeaderboard.map((entry) => (
<div
key={dropped.id}
className="flex items-center justify-between p-3 bg-gray-900 rounded-lg"
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="flex-1 min-w-0">
<p className="text-white font-medium truncate">
{dropped.challenge.title}
</p>
<p className="text-gray-400 text-sm">
{dropped.challenge.game.title} {dropped.challenge.points * 0.5} очков
</p>
<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>
<Button
size="sm"
variant="secondary"
onClick={() => handleRematch(dropped.id)}
isLoading={rematchingId === dropped.id}
disabled={rematchingId !== null}
>
<RotateCcw className="w-4 h-4 mr-1" />
Реванш
</Button>
<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>
@@ -777,8 +726,121 @@ export function PlayPage() {
</CardContent>
</Card>
)}
</>
)}
{/* 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">
<CardContent>
<div className="flex items-center gap-2 mb-4">
<Gamepad2 className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-bold text-white">Выбор игры</h3>
</div>
<p className="text-gray-400 text-sm mb-4">
Выберите игру и один из 3 челленджей. {currentAssignment ? 'Текущее задание будет заменено без штрафа!' : ''}
</p>
{/* 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>
))}
</div>
)}
{/* 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)
}}
>
<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" />
</div>
) : 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>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* 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>
)}
{/* Active assignment */}
{currentAssignment && (

View File

@@ -290,7 +290,7 @@ export type EventType =
| 'double_risk'
| 'jackpot'
| 'swap'
| 'rematch'
| 'game_choice'
export interface MarathonEvent {
id: number
@@ -334,7 +334,7 @@ export const EVENT_INFO: Record<EventType, { name: string; description: string;
color: 'red',
},
double_risk: {
name: 'Двойной риск',
name: 'Безопасная игра',
description: 'Дропы бесплатны, но очки x0.5',
color: 'purple',
},
@@ -348,13 +348,31 @@ export const EVENT_INFO: Record<EventType, { name: string; description: string;
description: 'Можно поменяться заданием с другим участником',
color: 'blue',
},
rematch: {
name: 'Реванш',
description: 'Можно переделать проваленный челлендж за 50% очков',
game_choice: {
name: 'Выбор игры',
description: 'Выбери игру и один из 3 челленджей. Можно заменить задание без штрафа!',
color: 'orange',
},
}
// Game Choice types
export interface GameChoiceChallenge {
id: number
title: string
description: string
difficulty: Difficulty
points: number
estimated_time: number | null
proof_type: ProofType
proof_hint: string | null
}
export interface GameChoiceChallenges {
game_id: number
game_title: string
challenges: GameChoiceChallenge[]
}
// Admin types
export interface AdminUser {
id: number

View File

@@ -107,10 +107,10 @@ export function isEventActivity(type: ActivityType): boolean {
const EVENT_NAMES: Record<EventType, string> = {
golden_hour: 'Золотой час',
common_enemy: 'Общий враг',
double_risk: 'Двойной риск',
double_risk: 'Безопасная игра',
jackpot: 'Джекпот',
swap: 'Обмен',
rematch: 'Реванш',
game_choice: 'Выбор игры',
}
// Difficulty translation