Add events
This commit is contained in:
9
frontend/src/api/challenges.ts
Normal file
9
frontend/src/api/challenges.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import client from './client'
|
||||
import type { Challenge } from '@/types'
|
||||
|
||||
export const challengesApi = {
|
||||
list: async (marathonId: number): Promise<Challenge[]> => {
|
||||
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/challenges`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
67
frontend/src/api/events.ts
Normal file
67
frontend/src/api/events.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import client from './client'
|
||||
import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types'
|
||||
|
||||
export const eventsApi = {
|
||||
getActive: async (marathonId: number): Promise<ActiveEvent> => {
|
||||
const response = await client.get<ActiveEvent>(`/marathons/${marathonId}/event`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
list: async (marathonId: number): Promise<MarathonEvent[]> => {
|
||||
const response = await client.get<MarathonEvent[]>(`/marathons/${marathonId}/events`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
start: async (marathonId: number, data: EventCreate): Promise<MarathonEvent> => {
|
||||
const response = await client.post<MarathonEvent>(`/marathons/${marathonId}/events`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
stop: async (marathonId: number): Promise<void> => {
|
||||
await client.delete(`/marathons/${marathonId}/event`)
|
||||
},
|
||||
|
||||
// Swap requests (two-sided confirmation)
|
||||
createSwapRequest: async (marathonId: number, targetParticipantId: number): Promise<SwapRequestItem> => {
|
||||
const response = await client.post<SwapRequestItem>(`/marathons/${marathonId}/swap-requests`, {
|
||||
target_participant_id: targetParticipantId,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
getSwapRequests: async (marathonId: number): Promise<MySwapRequests> => {
|
||||
const response = await client.get<MySwapRequests>(`/marathons/${marathonId}/swap-requests`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
acceptSwapRequest: async (marathonId: number, requestId: number): Promise<void> => {
|
||||
await client.post(`/marathons/${marathonId}/swap-requests/${requestId}/accept`)
|
||||
},
|
||||
|
||||
declineSwapRequest: async (marathonId: number, requestId: number): Promise<void> => {
|
||||
await client.post(`/marathons/${marathonId}/swap-requests/${requestId}/decline`)
|
||||
},
|
||||
|
||||
cancelSwapRequest: async (marathonId: number, requestId: number): Promise<void> => {
|
||||
await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`)
|
||||
},
|
||||
|
||||
rematch: async (marathonId: number, assignmentId: number): Promise<void> => {
|
||||
await client.post(`/marathons/${marathonId}/rematch/${assignmentId}`)
|
||||
},
|
||||
|
||||
getDroppedAssignments: async (marathonId: number): Promise<DroppedAssignment[]> => {
|
||||
const response = await client.get<DroppedAssignment[]>(`/marathons/${marathonId}/dropped-assignments`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getSwapCandidates: async (marathonId: number): Promise<SwapCandidate[]> => {
|
||||
const response = await client.get<SwapCandidate[]>(`/marathons/${marathonId}/swap-candidates`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getCommonEnemyLeaderboard: async (marathonId: number): Promise<CommonEnemyLeaderboardEntry[]> => {
|
||||
const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -4,3 +4,5 @@ export { gamesApi } from './games'
|
||||
export { wheelApi } from './wheel'
|
||||
export { feedApi } from './feed'
|
||||
export { adminApi } from './admin'
|
||||
export { eventsApi } from './events'
|
||||
export { challengesApi } from './challenges'
|
||||
|
||||
110
frontend/src/components/EventBanner.tsx
Normal file
110
frontend/src/components/EventBanner.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Clock } from 'lucide-react'
|
||||
import type { ActiveEvent, EventType } from '@/types'
|
||||
import { EVENT_INFO } from '@/types'
|
||||
|
||||
interface EventBannerProps {
|
||||
activeEvent: ActiveEvent
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
||||
golden_hour: <Zap className="w-5 h-5" />,
|
||||
common_enemy: <Users className="w-5 h-5" />,
|
||||
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" />,
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Record<EventType, string> = {
|
||||
golden_hour: 'from-yellow-500/20 to-yellow-600/20 border-yellow-500/50 text-yellow-400',
|
||||
common_enemy: 'from-red-500/20 to-red-600/20 border-red-500/50 text-red-400',
|
||||
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',
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function EventBanner({ activeEvent, onRefresh }: EventBannerProps) {
|
||||
const [timeRemaining, setTimeRemaining] = useState(activeEvent.time_remaining_seconds)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeRemaining(activeEvent.time_remaining_seconds)
|
||||
}, [activeEvent.time_remaining_seconds])
|
||||
|
||||
useEffect(() => {
|
||||
if (timeRemaining === null || timeRemaining <= 0) return
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeRemaining((prev) => {
|
||||
if (prev === null || prev <= 0) {
|
||||
clearInterval(timer)
|
||||
onRefresh?.()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [timeRemaining, onRefresh])
|
||||
|
||||
if (!activeEvent.event) {
|
||||
return null
|
||||
}
|
||||
|
||||
const event = activeEvent.event
|
||||
const info = EVENT_INFO[event.type]
|
||||
const icon = EVENT_ICONS[event.type]
|
||||
const colorClass = EVENT_COLORS[event.type]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative overflow-hidden rounded-xl border p-4
|
||||
bg-gradient-to-r ${colorClass}
|
||||
animate-pulse-slow
|
||||
`}
|
||||
>
|
||||
{/* Animated background effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full animate-shimmer" />
|
||||
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-white/10">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{info.name}</h3>
|
||||
<p className="text-sm opacity-80">{info.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeRemaining !== null && timeRemaining > 0 && (
|
||||
<div className="flex items-center gap-2 text-lg font-mono font-bold">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatTime(timeRemaining)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeEvent.effects.points_multiplier !== 1.0 && (
|
||||
<div className="px-3 py-1 rounded-full bg-white/10 font-bold">
|
||||
x{activeEvent.effects.points_multiplier}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
frontend/src/components/EventControl.tsx
Normal file
161
frontend/src/components/EventControl.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Play, Square } from 'lucide-react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { eventsApi } from '@/api'
|
||||
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
||||
import { EVENT_INFO } from '@/types'
|
||||
|
||||
interface EventControlProps {
|
||||
marathonId: number
|
||||
activeEvent: ActiveEvent
|
||||
challenges?: Challenge[]
|
||||
onEventChange: () => void
|
||||
}
|
||||
|
||||
const EVENT_TYPES: EventType[] = [
|
||||
'golden_hour',
|
||||
'double_risk',
|
||||
'jackpot',
|
||||
'swap',
|
||||
'rematch',
|
||||
'common_enemy',
|
||||
]
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
||||
golden_hour: <Zap className="w-4 h-4" />,
|
||||
common_enemy: <Users className="w-4 h-4" />,
|
||||
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" />,
|
||||
}
|
||||
|
||||
export function EventControl({
|
||||
marathonId,
|
||||
activeEvent,
|
||||
challenges,
|
||||
onEventChange,
|
||||
}: EventControlProps) {
|
||||
const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
|
||||
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
|
||||
const handleStart = async () => {
|
||||
if (selectedType === 'common_enemy' && !selectedChallengeId) {
|
||||
alert('Выберите челлендж для события "Общий враг"')
|
||||
return
|
||||
}
|
||||
|
||||
setIsStarting(true)
|
||||
try {
|
||||
await eventsApi.start(marathonId, {
|
||||
type: selectedType,
|
||||
challenge_id: selectedType === 'common_enemy' ? selectedChallengeId ?? undefined : undefined,
|
||||
})
|
||||
onEventChange()
|
||||
} catch (error) {
|
||||
console.error('Failed to start event:', error)
|
||||
alert('Не удалось запустить событие')
|
||||
} finally {
|
||||
setIsStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!confirm('Остановить событие досрочно?')) return
|
||||
|
||||
setIsStopping(true)
|
||||
try {
|
||||
await eventsApi.stop(marathonId)
|
||||
onEventChange()
|
||||
} catch (error) {
|
||||
console.error('Failed to stop event:', error)
|
||||
} finally {
|
||||
setIsStopping(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeEvent.event) {
|
||||
return (
|
||||
<div className="p-4 bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{EVENT_ICONS[activeEvent.event.type]}
|
||||
<span className="font-medium">
|
||||
Активно: {EVENT_INFO[activeEvent.event.type].name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
isLoading={isStopping}
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1" />
|
||||
Остановить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-gray-800 rounded-xl space-y-4">
|
||||
<h3 className="font-bold text-white">Запустить событие</h3>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{EVENT_TYPES.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`
|
||||
p-3 rounded-lg border-2 transition-all text-left
|
||||
${selectedType === type
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 hover:border-gray-600'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{EVENT_ICONS[type]}
|
||||
<span className="font-medium text-sm">{EVENT_INFO[type].name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 line-clamp-2">
|
||||
{EVENT_INFO[type].description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedType === 'common_enemy' && challenges && challenges.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Выберите челлендж для всех
|
||||
</label>
|
||||
<select
|
||||
value={selectedChallengeId || ''}
|
||||
onChange={(e) => setSelectedChallengeId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white"
|
||||
>
|
||||
<option value="">— Выберите челлендж —</option>
|
||||
{challenges.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.game.title}: {c.title} ({c.points} очков)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
isLoading={isStarting}
|
||||
disabled={selectedType === 'common_enemy' && !selectedChallengeId}
|
||||
className="w-full"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Запустить {EVENT_INFO[selectedType].name}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { marathonsApi } from '@/api'
|
||||
import type { Marathon } from '@/types'
|
||||
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||
import { Button, Card, CardContent } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft } from 'lucide-react'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
import { EventControl } from '@/components/EventControl'
|
||||
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
export function MarathonPage() {
|
||||
@@ -12,10 +14,13 @@ export function MarathonPage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
||||
const [challenges, setChallenges] = useState<Challenge[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
const [showEventControl, setShowEventControl] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadMarathon()
|
||||
@@ -26,6 +31,22 @@ export function MarathonPage() {
|
||||
try {
|
||||
const data = await marathonsApi.get(parseInt(id))
|
||||
setMarathon(data)
|
||||
|
||||
// Load event data if marathon is active
|
||||
if (data.status === 'active' && data.my_participation) {
|
||||
const eventData = await eventsApi.getActive(parseInt(id))
|
||||
setActiveEvent(eventData)
|
||||
|
||||
// Load challenges for event control if organizer
|
||||
if (data.my_participation.role === 'organizer') {
|
||||
try {
|
||||
const challengesData = await challengesApi.list(parseInt(id))
|
||||
setChallenges(challengesData)
|
||||
} catch {
|
||||
// Ignore if no challenges
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load marathon:', error)
|
||||
navigate('/marathons')
|
||||
@@ -34,6 +55,16 @@ export function MarathonPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const getInviteLink = () => {
|
||||
if (!marathon) return ''
|
||||
return `${window.location.origin}/invite/${marathon.invite_code}`
|
||||
@@ -234,6 +265,42 @@ export function MarathonPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Active event banner */}
|
||||
{marathon.status === 'active' && activeEvent?.event && (
|
||||
<div className="mb-8">
|
||||
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event control for organizers */}
|
||||
{marathon.status === 'active' && isOrganizer && (
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-white flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-yellow-500" />
|
||||
Управление событиями
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowEventControl(!showEventControl)}
|
||||
>
|
||||
{showEventControl ? 'Скрыть' : 'Показать'}
|
||||
</Button>
|
||||
</div>
|
||||
{showEventControl && activeEvent && (
|
||||
<EventControl
|
||||
marathonId={marathon.id}
|
||||
activeEvent={activeEvent}
|
||||
challenges={challenges}
|
||||
onEventChange={refreshEvent}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Invite link */}
|
||||
{marathon.status !== 'finished' && (
|
||||
<Card className="mb-8">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { marathonsApi, wheelApi, gamesApi } from '@/api'
|
||||
import type { Marathon, Assignment, SpinResult, Game } from '@/types'
|
||||
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 { Button, Card, CardContent } from '@/components/ui'
|
||||
import { SpinWheel } from '@/components/SpinWheel'
|
||||
import { Loader2, Upload, X } from 'lucide-react'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
import { Loader2, Upload, X, RotateCcw, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react'
|
||||
|
||||
export function PlayPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -13,6 +14,7 @@ export function PlayPage() {
|
||||
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
|
||||
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
|
||||
const [games, setGames] = useState<Game[]>([])
|
||||
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Complete state
|
||||
@@ -24,23 +26,113 @@ 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)
|
||||
|
||||
// Swap state
|
||||
const [swapCandidates, setSwapCandidates] = useState<SwapCandidate[]>([])
|
||||
const [swapRequests, setSwapRequests] = useState<MySwapRequests>({ incoming: [], outgoing: [] })
|
||||
const [isSwapLoading, setIsSwapLoading] = useState(false)
|
||||
const [sendingRequestTo, setSendingRequestTo] = useState<number | null>(null)
|
||||
const [processingRequestId, setProcessingRequestId] = useState<number | null>(null)
|
||||
|
||||
// Common Enemy leaderboard state
|
||||
const [commonEnemyLeaderboard, setCommonEnemyLeaderboard] = useState<CommonEnemyLeaderboardEntry[]>([])
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [id])
|
||||
|
||||
// Load dropped assignments when rematch event is active
|
||||
useEffect(() => {
|
||||
if (activeEvent?.event?.type === 'rematch' && !currentAssignment) {
|
||||
loadDroppedAssignments()
|
||||
}
|
||||
}, [activeEvent?.event?.type, currentAssignment])
|
||||
|
||||
// Load swap candidates and requests when swap event is active
|
||||
useEffect(() => {
|
||||
if (activeEvent?.event?.type === 'swap') {
|
||||
loadSwapRequests()
|
||||
if (currentAssignment) {
|
||||
loadSwapCandidates()
|
||||
}
|
||||
}
|
||||
}, [activeEvent?.event?.type, currentAssignment])
|
||||
|
||||
// Load common enemy leaderboard when common_enemy event is active
|
||||
useEffect(() => {
|
||||
if (activeEvent?.event?.type === 'common_enemy') {
|
||||
loadCommonEnemyLeaderboard()
|
||||
// Poll for updates every 10 seconds
|
||||
const interval = setInterval(loadCommonEnemyLeaderboard, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [activeEvent?.event?.type])
|
||||
|
||||
const loadDroppedAssignments = async () => {
|
||||
if (!id) return
|
||||
setIsRematchLoading(true)
|
||||
try {
|
||||
const dropped = await eventsApi.getDroppedAssignments(parseInt(id))
|
||||
setDroppedAssignments(dropped)
|
||||
} catch (error) {
|
||||
console.error('Failed to load dropped assignments:', error)
|
||||
} finally {
|
||||
setIsRematchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 loadData = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const [marathonData, assignment, gamesData] = await Promise.all([
|
||||
const [marathonData, assignment, gamesData, eventData] = await Promise.all([
|
||||
marathonsApi.get(parseInt(id)),
|
||||
wheelApi.getCurrentAssignment(parseInt(id)),
|
||||
gamesApi.list(parseInt(id), 'approved'),
|
||||
eventsApi.getActive(parseInt(id)),
|
||||
])
|
||||
setMarathon(marathonData)
|
||||
setCurrentAssignment(assignment)
|
||||
setGames(gamesData)
|
||||
setActiveEvent(eventData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
} finally {
|
||||
@@ -48,6 +140,16 @@ export function PlayPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSpin = async (): Promise<Game | null> => {
|
||||
if (!id) return null
|
||||
|
||||
@@ -122,6 +224,92 @@ export function PlayPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRematch = async (assignmentId: number) => {
|
||||
if (!id) return
|
||||
|
||||
if (!confirm('Начать реванш? Вы получите 50% от обычных очков за выполнение.')) return
|
||||
|
||||
setRematchingId(assignmentId)
|
||||
try {
|
||||
await eventsApi.rematch(parseInt(id), assignmentId)
|
||||
alert('Реванш начат! Выполните задание за 50% очков.')
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось начать реванш')
|
||||
} finally {
|
||||
setRematchingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendSwapRequest = async (participantId: number, participantName: string, theirChallenge: string) => {
|
||||
if (!id) return
|
||||
|
||||
if (!confirm(`Отправить запрос на обмен с ${participantName}?\n\nВы предлагаете обменяться на: "${theirChallenge}"\n\n${participantName} должен будет подтвердить обмен.`)) return
|
||||
|
||||
setSendingRequestTo(participantId)
|
||||
try {
|
||||
await eventsApi.createSwapRequest(parseInt(id), participantId)
|
||||
alert('Запрос на обмен отправлен! Ожидайте подтверждения.')
|
||||
await loadSwapRequests()
|
||||
await loadSwapCandidates()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось отправить запрос')
|
||||
} finally {
|
||||
setSendingRequestTo(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAcceptSwapRequest = async (requestId: number) => {
|
||||
if (!id) return
|
||||
|
||||
if (!confirm('Принять обмен? Задания будут обменяны сразу после подтверждения.')) return
|
||||
|
||||
setProcessingRequestId(requestId)
|
||||
try {
|
||||
await eventsApi.acceptSwapRequest(parseInt(id), requestId)
|
||||
alert('Обмен выполнен!')
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось выполнить обмен')
|
||||
} 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 } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось отклонить запрос')
|
||||
} 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 } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось отменить запрос')
|
||||
} finally {
|
||||
setProcessingRequestId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
@@ -138,8 +326,14 @@ export function PlayPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Back button */}
|
||||
<Link to={`/marathons/${id}`} className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
К марафону
|
||||
</Link>
|
||||
|
||||
{/* Header stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="text-center py-3">
|
||||
<div className="text-xl font-bold text-primary-500">
|
||||
@@ -166,25 +360,144 @@ export function PlayPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
{/* Active event banner */}
|
||||
{activeEvent?.event && (
|
||||
<div className="mb-6">
|
||||
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Common Enemy Leaderboard */}
|
||||
{activeEvent?.event?.type === 'common_enemy' && (
|
||||
<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">
|
||||
<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>
|
||||
</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" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{droppedAssignments.map((dropped) => (
|
||||
<div
|
||||
key={dropped.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-900 rounded-lg"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Active assignment */}
|
||||
{currentAssignment && (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-center mb-6">
|
||||
@@ -315,6 +628,184 @@ export function PlayPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Swap section - show during swap event when user has active assignment */}
|
||||
{activeEvent?.event?.type === 'swap' && (
|
||||
<Card className="mt-6">
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ArrowLeftRight className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="text-lg font-bold text-white">Обмен заданиями</h3>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Обмен требует подтверждения с обеих сторон
|
||||
</p>
|
||||
|
||||
{/* Incoming swap requests */}
|
||||
{swapRequests.incoming.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-yellow-400 mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Входящие запросы ({swapRequests.incoming.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{swapRequests.incoming.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium">
|
||||
{request.from_user.nickname} предлагает обмен
|
||||
</p>
|
||||
<p className="text-yellow-400 text-sm mt-1">
|
||||
Вы получите: <span className="font-medium">{request.from_challenge.title}</span>
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs">
|
||||
{request.from_challenge.game_title} • {request.from_challenge.points} очков
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Взамен на: <span className="font-medium">{request.to_challenge.title}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAcceptSwapRequest(request.id)}
|
||||
isLoading={processingRequestId === request.id}
|
||||
disabled={processingRequestId !== null}
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
Принять
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => handleDeclineSwapRequest(request.id)}
|
||||
isLoading={processingRequestId === request.id}
|
||||
disabled={processingRequestId !== null}
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outgoing swap requests */}
|
||||
{swapRequests.outgoing.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-blue-400 mb-3 flex items-center gap-2">
|
||||
<Send className="w-4 h-4" />
|
||||
Отправленные запросы ({swapRequests.outgoing.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{swapRequests.outgoing.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium">
|
||||
Запрос к {request.to_user.nickname}
|
||||
</p>
|
||||
<p className="text-blue-400 text-sm mt-1">
|
||||
Вы получите: <span className="font-medium">{request.to_challenge.title}</span>
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs">
|
||||
{request.to_challenge.game_title} • {request.to_challenge.points} очков
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
Ожидание подтверждения...
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleCancelSwapRequest(request.id)}
|
||||
isLoading={processingRequestId === request.id}
|
||||
disabled={processingRequestId !== null}
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
Отменить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Swap candidates */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-300 mb-3">
|
||||
Доступные для обмена
|
||||
</h4>
|
||||
{isSwapLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : swapCandidates.filter(c =>
|
||||
!swapRequests.outgoing.some(r => r.to_user.id === c.user.id) &&
|
||||
!swapRequests.incoming.some(r => r.from_user.id === c.user.id)
|
||||
).length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
Нет участников для обмена
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{swapCandidates
|
||||
.filter(c =>
|
||||
!swapRequests.outgoing.some(r => r.to_user.id === c.user.id) &&
|
||||
!swapRequests.incoming.some(r => r.from_user.id === c.user.id)
|
||||
)
|
||||
.map((candidate) => (
|
||||
<div
|
||||
key={candidate.participant_id}
|
||||
className="p-3 bg-gray-900 rounded-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium">
|
||||
{candidate.user.nickname}
|
||||
</p>
|
||||
<p className="text-blue-400 text-sm font-medium truncate">
|
||||
{candidate.challenge_title}
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs mt-1">
|
||||
{candidate.game_title} • {candidate.challenge_points} очков • {candidate.challenge_difficulty}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleSendSwapRequest(
|
||||
candidate.participant_id,
|
||||
candidate.user.nickname,
|
||||
candidate.challenge_title
|
||||
)}
|
||||
isLoading={sendingRequestTo === candidate.participant_id}
|
||||
disabled={sendingRequestTo !== null}
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4 mr-1" />
|
||||
Предложить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface Marathon {
|
||||
invite_code: string
|
||||
is_public: boolean
|
||||
game_proposal_mode: GameProposalMode
|
||||
auto_events_enabled: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
participants_count: number
|
||||
@@ -192,6 +193,58 @@ export interface DropResult {
|
||||
new_drop_count: number
|
||||
}
|
||||
|
||||
export interface DroppedAssignment {
|
||||
id: number
|
||||
challenge: Challenge
|
||||
dropped_at: string
|
||||
}
|
||||
|
||||
export interface SwapCandidate {
|
||||
participant_id: number
|
||||
user: User
|
||||
challenge_title: string
|
||||
challenge_description: string
|
||||
challenge_points: number
|
||||
challenge_difficulty: Difficulty
|
||||
game_title: string
|
||||
}
|
||||
|
||||
// Two-sided swap confirmation types
|
||||
export type SwapRequestStatus = 'pending' | 'accepted' | 'declined' | 'cancelled'
|
||||
|
||||
export interface SwapRequestChallengeInfo {
|
||||
title: string
|
||||
description: string
|
||||
points: number
|
||||
difficulty: string
|
||||
game_title: string
|
||||
}
|
||||
|
||||
export interface SwapRequestItem {
|
||||
id: number
|
||||
status: SwapRequestStatus
|
||||
from_user: User
|
||||
to_user: User
|
||||
from_challenge: SwapRequestChallengeInfo
|
||||
to_challenge: SwapRequestChallengeInfo
|
||||
created_at: string
|
||||
responded_at: string | null
|
||||
}
|
||||
|
||||
export interface MySwapRequests {
|
||||
incoming: SwapRequestItem[]
|
||||
outgoing: SwapRequestItem[]
|
||||
}
|
||||
|
||||
// Common Enemy leaderboard
|
||||
export interface CommonEnemyLeaderboardEntry {
|
||||
participant_id: number
|
||||
user: User
|
||||
completed_at: string | null
|
||||
rank: number | null
|
||||
bonus_points: number
|
||||
}
|
||||
|
||||
// Activity types
|
||||
export type ActivityType =
|
||||
| 'join'
|
||||
@@ -218,6 +271,78 @@ export interface FeedResponse {
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
// Event types
|
||||
export type EventType =
|
||||
| 'golden_hour'
|
||||
| 'common_enemy'
|
||||
| 'double_risk'
|
||||
| 'jackpot'
|
||||
| 'swap'
|
||||
| 'rematch'
|
||||
|
||||
export interface MarathonEvent {
|
||||
id: number
|
||||
type: EventType
|
||||
start_time: string
|
||||
end_time: string | null
|
||||
is_active: boolean
|
||||
created_by: User | null
|
||||
data: Record<string, unknown> | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface EventEffects {
|
||||
points_multiplier: number
|
||||
drop_free: boolean
|
||||
special_action: string | null
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ActiveEvent {
|
||||
event: MarathonEvent | null
|
||||
effects: EventEffects
|
||||
time_remaining_seconds: number | null
|
||||
}
|
||||
|
||||
export interface EventCreate {
|
||||
type: EventType
|
||||
duration_minutes?: number
|
||||
challenge_id?: number
|
||||
}
|
||||
|
||||
export const EVENT_INFO: Record<EventType, { name: string; description: string; color: string }> = {
|
||||
golden_hour: {
|
||||
name: 'Золотой час',
|
||||
description: 'Все очки x1.5!',
|
||||
color: 'yellow',
|
||||
},
|
||||
common_enemy: {
|
||||
name: 'Общий враг',
|
||||
description: 'Все получают одинаковый челлендж. Первые 3 — бонус!',
|
||||
color: 'red',
|
||||
},
|
||||
double_risk: {
|
||||
name: 'Двойной риск',
|
||||
description: 'Дропы бесплатны, но очки x0.5',
|
||||
color: 'purple',
|
||||
},
|
||||
jackpot: {
|
||||
name: 'Джекпот',
|
||||
description: 'Следующий спин — сложный челлендж с x3 очками!',
|
||||
color: 'green',
|
||||
},
|
||||
swap: {
|
||||
name: 'Обмен',
|
||||
description: 'Можно поменяться заданием с другим участником',
|
||||
color: 'blue',
|
||||
},
|
||||
rematch: {
|
||||
name: 'Реванш',
|
||||
description: 'Можно переделать проваленный челлендж за 50% очков',
|
||||
color: 'orange',
|
||||
},
|
||||
}
|
||||
|
||||
// Admin types
|
||||
export interface AdminUser {
|
||||
id: number
|
||||
|
||||
Reference in New Issue
Block a user