Add events

This commit is contained in:
2025-12-15 03:22:29 +07:00
parent 1a882fb2e0
commit 4239ea8516
31 changed files with 7288 additions and 75 deletions

View 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>
)
}

View 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>
)
}