Bug fixes

This commit is contained in:
2026-01-08 06:51:15 +07:00
parent 4488a13808
commit 2874b64481
12 changed files with 434 additions and 54 deletions

View File

@@ -0,0 +1,52 @@
"""Simplify boost consumable - make it one-time instead of timed
Revision ID: 025_simplify_boost
Revises: 024_seed_shop_items
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '025_simplify_boost'
down_revision: Union[str, None] = '024_seed_shop_items'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c['name'] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# Add new boolean column for one-time boost
if not column_exists('participants', 'has_active_boost'):
op.add_column('participants', sa.Column('has_active_boost', sa.Boolean(), nullable=False, server_default='false'))
# Remove old timed boost columns
if column_exists('participants', 'active_boost_multiplier'):
op.drop_column('participants', 'active_boost_multiplier')
if column_exists('participants', 'active_boost_expires_at'):
op.drop_column('participants', 'active_boost_expires_at')
def downgrade() -> None:
# Restore old columns
if not column_exists('participants', 'active_boost_multiplier'):
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
if not column_exists('participants', 'active_boost_expires_at'):
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
# Remove new column
if column_exists('participants', 'has_active_boost'):
op.drop_column('participants', 'has_active_boost')

View File

@@ -452,6 +452,8 @@ async def force_finish_marathon(
db: DbSession, db: DbSession,
): ):
"""Force finish a marathon. Admin only.""" """Force finish a marathon. Admin only."""
from app.services.coins import coins_service
require_admin_with_2fa(current_user) require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
@@ -465,6 +467,24 @@ async def force_finish_marathon(
old_status = marathon.status old_status = marathon.status
marathon.status = MarathonStatus.FINISHED.value marathon.status = MarathonStatus.FINISHED.value
marathon.end_date = datetime.utcnow() marathon.end_date = datetime.utcnow()
# Award coins for top 3 places (only in certified marathons)
if marathon.is_certified:
top_result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
.limit(3)
)
top_participants = top_result.scalars().all()
for place, participant in enumerate(top_participants, start=1):
if participant.total_points > 0:
await coins_service.award_marathon_place(
db, participant.user, marathon, place
)
await db.commit() await db.commit()
# Log action # Log action

View File

@@ -353,6 +353,8 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
@router.post("/{marathon_id}/finish", response_model=MarathonResponse) @router.post("/{marathon_id}/finish", response_model=MarathonResponse)
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
from app.services.coins import coins_service
# Require organizer role # Require organizer role
await require_organizer(db, current_user, marathon_id) await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id) marathon = await get_marathon_or_404(db, marathon_id)
@@ -362,6 +364,24 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
marathon.status = MarathonStatus.FINISHED.value marathon.status = MarathonStatus.FINISHED.value
# Award coins for top 3 places (only in certified marathons)
if marathon.is_certified:
# Get top 3 participants by total_points
top_result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
.limit(3)
)
top_participants = top_result.scalars().all()
for place, participant in enumerate(top_participants, start=1):
if participant.total_points > 0: # Only award if they have points
await coins_service.award_marathon_place(
db, participant.user, marathon, place
)
# Log activity # Log activity
activity = Activity( activity = Activity(
marathon_id=marathon_id, marathon_id=marathon_id,

View File

@@ -206,7 +206,7 @@ async def use_consumable(
effect_description = "Shield activated - next drop will be free" effect_description = "Shield activated - next drop will be free"
elif data.item_code == "boost": elif data.item_code == "boost":
effect = await consumables_service.use_boost(db, current_user, participant, marathon) effect = await consumables_service.use_boost(db, current_user, participant, marathon)
effect_description = f"Boost x{effect['multiplier']} activated until {effect['expires_at']}" effect_description = f"Boost x{effect['multiplier']} activated for next complete"
elif data.item_code == "reroll": elif data.item_code == "reroll":
effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment) effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment)
effect_description = "Assignment rerolled - you can spin again" effect_description = "Assignment rerolled - you can spin again"
@@ -241,8 +241,10 @@ async def get_consumables_status(
participant = await require_participant(db, current_user.id, marathon_id) participant = await require_participant(db, current_user.id, marathon_id)
# Get inventory counts # Get inventory counts for all consumables
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip") skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
shields_available = await consumables_service.get_consumable_count(db, current_user.id, "shield")
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
rerolls_available = await consumables_service.get_consumable_count(db, current_user.id, "reroll") rerolls_available = await consumables_service.get_consumable_count(db, current_user.id, "reroll")
# Calculate remaining skips for this marathon # Calculate remaining skips for this marathon
@@ -254,10 +256,11 @@ async def get_consumables_status(
skips_available=skips_available, skips_available=skips_available,
skips_used=participant.skips_used, skips_used=participant.skips_used,
skips_remaining=skips_remaining, skips_remaining=skips_remaining,
shields_available=shields_available,
has_shield=participant.has_shield, has_shield=participant.has_shield,
boosts_available=boosts_available,
has_active_boost=participant.has_active_boost, has_active_boost=participant.has_active_boost,
boost_multiplier=participant.active_boost_multiplier if participant.has_active_boost else None, boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
boost_expires_at=participant.active_boost_expires_at if participant.has_active_boost else None,
rerolls_available=rerolls_available, rerolls_available=rerolls_available,
) )

View File

@@ -622,7 +622,7 @@ async def complete_assignment(
ba.points_earned = int(ba.challenge.points * multiplier) ba.points_earned = int(ba.challenge.points * multiplier)
# Apply boost multiplier from consumable # Apply boost multiplier from consumable
boost_multiplier = consumables_service.get_active_boost_multiplier(participant) boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0: if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier) total_points = int(total_points * boost_multiplier)
@@ -729,7 +729,7 @@ async def complete_assignment(
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}") print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Apply boost multiplier from consumable # Apply boost multiplier from consumable
boost_multiplier = consumables_service.get_active_boost_multiplier(participant) boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0: if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier) total_points = int(total_points * boost_multiplier)

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean, Float from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -31,8 +31,7 @@ class Participant(Base):
# Shop: consumables state # Shop: consumables state
skips_used: Mapped[int] = mapped_column(Integer, default=0) skips_used: Mapped[int] = mapped_column(Integer, default=0)
active_boost_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True) has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
active_boost_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
has_shield: Mapped[bool] = mapped_column(Boolean, default=False) has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships # Relationships
@@ -47,16 +46,3 @@ class Participant(Base):
@property @property
def is_organizer(self) -> bool: def is_organizer(self) -> bool:
return self.role == ParticipantRole.ORGANIZER.value return self.role == ParticipantRole.ORGANIZER.value
@property
def has_active_boost(self) -> bool:
"""Check if participant has an active boost"""
if self.active_boost_multiplier is None or self.active_boost_expires_at is None:
return False
return datetime.utcnow() < self.active_boost_expires_at
def get_boost_multiplier(self) -> float:
"""Get current boost multiplier (1.0 if no active boost)"""
if self.has_active_boost:
return self.active_boost_multiplier or 1.0
return 1.0

View File

@@ -192,8 +192,9 @@ class ConsumablesStatusResponse(BaseModel):
skips_available: int # From inventory skips_available: int # From inventory
skips_used: int # In this marathon skips_used: int # In this marathon
skips_remaining: int | None # Based on marathon limit skips_remaining: int | None # Based on marathon limit
has_shield: bool shields_available: int # From inventory
has_active_boost: bool has_shield: bool # Currently activated
boost_multiplier: float | None boosts_available: int # From inventory
boost_expires_at: datetime | None has_active_boost: bool # Currently activated (one-time for next complete)
boost_multiplier: float | None # 1.5 if boost active
rerolls_available: int # From inventory rerolls_available: int # From inventory

View File

@@ -1,7 +1,7 @@
""" """
Consumables Service - handles consumable items usage (skip, shield, boost, reroll) Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
""" """
from datetime import datetime, timedelta from datetime import datetime
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,7 +17,6 @@ class ConsumablesService:
"""Service for consumable items""" """Service for consumable items"""
# Boost settings # Boost settings
BOOST_DURATION_HOURS = 2
BOOST_MULTIPLIER = 1.5 BOOST_MULTIPLIER = 1.5
async def use_skip( async def use_skip(
@@ -141,10 +140,10 @@ class ConsumablesService:
marathon: Marathon, marathon: Marathon,
) -> dict: ) -> dict:
""" """
Activate a Boost - multiplies points for next 2 hours. Activate a Boost - multiplies points for NEXT complete only.
- Points for completed challenges are multiplied by BOOST_MULTIPLIER - Points for next completed challenge are multiplied by BOOST_MULTIPLIER
- Duration: BOOST_DURATION_HOURS - One-time use (consumed on next complete)
Returns: dict with result info Returns: dict with result info
@@ -155,17 +154,13 @@ class ConsumablesService:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon") raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_active_boost: if participant.has_active_boost:
raise HTTPException( raise HTTPException(status_code=400, detail="Boost is already activated")
status_code=400,
detail=f"Boost already active until {participant.active_boost_expires_at}"
)
# Consume boost from inventory # Consume boost from inventory
item = await self._consume_item(db, user, ConsumableType.BOOST.value) item = await self._consume_item(db, user, ConsumableType.BOOST.value)
# Activate boost # Activate boost (one-time use)
participant.active_boost_multiplier = self.BOOST_MULTIPLIER participant.has_active_boost = True
participant.active_boost_expires_at = datetime.utcnow() + timedelta(hours=self.BOOST_DURATION_HOURS)
# Log usage # Log usage
usage = ConsumableUsage( usage = ConsumableUsage(
@@ -175,8 +170,7 @@ class ConsumablesService:
effect_data={ effect_data={
"type": "boost", "type": "boost",
"multiplier": self.BOOST_MULTIPLIER, "multiplier": self.BOOST_MULTIPLIER,
"duration_hours": self.BOOST_DURATION_HOURS, "one_time": True,
"expires_at": participant.active_boost_expires_at.isoformat(),
}, },
) )
db.add(usage) db.add(usage)
@@ -185,7 +179,6 @@ class ConsumablesService:
"success": True, "success": True,
"boost_activated": True, "boost_activated": True,
"multiplier": self.BOOST_MULTIPLIER, "multiplier": self.BOOST_MULTIPLIER,
"expires_at": participant.active_boost_expires_at,
} }
async def use_reroll( async def use_reroll(
@@ -299,7 +292,7 @@ class ConsumablesService:
quantity = result.scalar_one_or_none() quantity = result.scalar_one_or_none()
return quantity or 0 return quantity or 0
def consume_shield_on_drop(self, participant: Participant) -> bool: def consume_shield(self, participant: Participant) -> bool:
""" """
Consume shield when dropping (called from wheel.py). Consume shield when dropping (called from wheel.py).
@@ -310,13 +303,17 @@ class ConsumablesService:
return True return True
return False return False
def get_active_boost_multiplier(self, participant: Participant) -> float: def consume_boost_on_complete(self, participant: Participant) -> float:
""" """
Get current boost multiplier for participant. Consume boost when completing assignment (called from wheel.py).
One-time use - boost is consumed after single complete.
Returns: Multiplier value (1.0 if no active boost) Returns: Multiplier value (BOOST_MULTIPLIER if boost was active, 1.0 otherwise)
""" """
return participant.get_boost_multiplier() if participant.has_active_boost:
participant.has_active_boost = False
return self.BOOST_MULTIPLIER
return 1.0
# Singleton instance # Singleton instance

View File

@@ -76,6 +76,14 @@ export const adminApi = {
await client.post(`/admin/marathons/${id}/force-finish`) await client.post(`/admin/marathons/${id}/force-finish`)
}, },
certifyMarathon: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/certify`)
},
revokeCertification: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/revoke-certification`)
},
// Stats // Stats
getStats: async (): Promise<PlatformStats> => { getStats: async (): Promise<PlatformStats> => {
const response = await client.get<PlatformStats>('/admin/stats') const response = await client.get<PlatformStats>('/admin/stats')

View File

@@ -1,13 +1,14 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi } from '@/api' import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi, shopApi } from '@/api'
import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment } from '@/types' import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment, ConsumablesStatus, ConsumableType } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel' import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download } from 'lucide-react' import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, Shield, RefreshCw, SkipForward, Package } from 'lucide-react'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { useShopStore } from '@/store/shop'
const MAX_IMAGE_SIZE = 15 * 1024 * 1024 const MAX_IMAGE_SIZE = 15 * 1024 * 1024
const MAX_VIDEO_SIZE = 30 * 1024 * 1024 const MAX_VIDEO_SIZE = 30 * 1024 * 1024
@@ -55,6 +56,10 @@ export function PlayPage() {
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([]) const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
// Consumables
const [consumablesStatus, setConsumablesStatus] = useState<ConsumablesStatus | null>(null)
const [isUsingConsumable, setIsUsingConsumable] = useState<ConsumableType | null>(null)
// Bonus challenge completion // Bonus challenge completion
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null) const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([]) const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
@@ -177,13 +182,14 @@ export function PlayPage() {
const loadData = async () => { const loadData = async () => {
if (!id) return if (!id) return
try { try {
const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([ const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData, consumablesData] = await Promise.all([
marathonsApi.get(parseInt(id)), marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)), wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.getAvailableGames(parseInt(id)), gamesApi.getAvailableGames(parseInt(id)),
eventsApi.getActive(parseInt(id)), eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)), eventsApi.getEventAssignment(parseInt(id)),
assignmentsApi.getReturnedAssignments(parseInt(id)), assignmentsApi.getReturnedAssignments(parseInt(id)),
shopApi.getConsumablesStatus(parseInt(id)).catch(() => null),
]) ])
setMarathon(marathonData) setMarathon(marathonData)
setCurrentAssignment(assignment) setCurrentAssignment(assignment)
@@ -191,6 +197,7 @@ export function PlayPage() {
setActiveEvent(eventData) setActiveEvent(eventData)
setEventAssignment(eventAssignmentData) setEventAssignment(eventAssignmentData)
setReturnedAssignments(returnedData) setReturnedAssignments(returnedData)
setConsumablesStatus(consumablesData)
} catch (error) { } catch (error) {
console.error('Failed to load data:', error) console.error('Failed to load data:', error)
} finally { } finally {
@@ -255,6 +262,8 @@ export function PlayPage() {
setProofUrl('') setProofUrl('')
setComment('') setComment('')
await loadData() await loadData()
// Refresh coins balance
useShopStore.getState().loadBalance()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось выполнить') toast.error(error.response?.data?.detail || 'Не удалось выполнить')
@@ -464,6 +473,85 @@ export function PlayPage() {
} }
} }
// Consumable handlers
const handleUseSkip = async () => {
if (!currentAssignment || !id) return
setIsUsingConsumable('skip')
try {
await shopApi.useConsumable({
item_code: 'skip',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
})
toast.success('Задание пропущено без штрафа!')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Skip')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseReroll = async () => {
if (!currentAssignment || !id) return
setIsUsingConsumable('reroll')
try {
await shopApi.useConsumable({
item_code: 'reroll',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
})
toast.success('Задание отменено! Можно крутить заново.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Reroll')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseShield = async () => {
if (!id) return
setIsUsingConsumable('shield')
try {
await shopApi.useConsumable({
item_code: 'shield',
marathon_id: parseInt(id),
})
toast.success('Shield активирован! Следующий пропуск будет бесплатным.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось активировать Shield')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseBoost = async () => {
if (!id) return
setIsUsingConsumable('boost')
try {
await shopApi.useConsumable({
item_code: 'boost',
marathon_id: parseInt(id),
})
toast.success('Boost активирован! x1.5 очков за следующее выполнение.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось активировать Boost')
} finally {
setIsUsingConsumable(null)
}
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center py-24"> <div className="flex flex-col items-center justify-center py-24">
@@ -608,6 +696,135 @@ export function PlayPage() {
</GlassCard> </GlassCard>
)} )}
{/* Consumables Panel */}
{consumablesStatus && marathon?.allow_consumables && (
<GlassCard className="mb-6 border-purple-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
<Package className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-purple-400">Расходники</h3>
<p className="text-sm text-gray-400">Используйте для облегчения задания</p>
</div>
</div>
{/* Active effects */}
{(consumablesStatus.has_shield || consumablesStatus.has_active_boost) && (
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/30 rounded-xl">
<p className="text-green-400 text-sm font-medium mb-2">Активные эффекты:</p>
<div className="flex flex-wrap gap-2">
{consumablesStatus.has_shield && (
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg border border-blue-500/30 flex items-center gap-1">
<Shield className="w-3 h-3" /> Shield (следующий drop бесплатный)
</span>
)}
{consumablesStatus.has_active_boost && (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg border border-yellow-500/30 flex items-center gap-1">
<Zap className="w-3 h-3" /> Boost x1.5 (следующий complete)
</span>
)}
</div>
</div>
)}
{/* Consumables grid */}
<div className="grid grid-cols-2 gap-3">
{/* Skip */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<SkipForward className="w-4 h-4 text-orange-400" />
<span className="text-white font-medium">Skip</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.skips_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Пропустить без штрафа</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseSkip}
disabled={consumablesStatus.skips_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'skip'}
className="w-full"
>
Использовать
</NeonButton>
</div>
{/* Reroll */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-cyan-400" />
<span className="text-white font-medium">Reroll</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.rerolls_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Переспинить задание</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseReroll}
disabled={consumablesStatus.rerolls_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'reroll'}
className="w-full"
>
Использовать
</NeonButton>
</div>
{/* Shield */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" />
<span className="text-white font-medium">Shield</span>
</div>
<span className="text-gray-400 text-sm">
{consumablesStatus.has_shield ? 'Активен' : `${consumablesStatus.shields_available} шт.`}
</span>
</div>
<p className="text-gray-500 text-xs mb-2">Защита от штрафа</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseShield}
disabled={consumablesStatus.has_shield || consumablesStatus.shields_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'shield'}
className="w-full"
>
{consumablesStatus.has_shield ? 'Активен' : 'Активировать'}
</NeonButton>
</div>
{/* Boost */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-yellow-400" />
<span className="text-white font-medium">Boost</span>
</div>
<span className="text-gray-400 text-sm">
{consumablesStatus.has_active_boost ? 'Активен' : `${consumablesStatus.boosts_available} шт.`}
</span>
</div>
<p className="text-gray-500 text-xs mb-2">x1.5 очков</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseBoost}
disabled={consumablesStatus.has_active_boost || consumablesStatus.boosts_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'boost'}
className="w-full"
>
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
</NeonButton>
</div>
</div>
</GlassCard>
)}
{/* Tabs for Common Enemy event */} {/* Tabs for Common Enemy event */}
{activeEvent?.event?.type === 'common_enemy' && ( {activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6"> <div className="flex gap-2 mb-6">

View File

@@ -4,7 +4,7 @@ import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui' import { NeonButton } from '@/components/ui'
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2 } from 'lucide-react' import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2, BadgeCheck, BadgeX } from 'lucide-react'
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = { const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
preparing: { preparing: {
@@ -108,6 +108,47 @@ export function AdminMarathonsPage() {
} }
} }
const handleCertify = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Верифицировать марафон',
message: `Верифицировать марафон "${marathon.title}"? Участники смогут зарабатывать монетки.`,
confirmText: 'Верифицировать',
})
if (!confirmed) return
try {
await adminApi.certifyMarathon(marathon.id)
setMarathons(marathons.map(m =>
m.id === marathon.id ? { ...m, is_certified: true, certification_status: 'certified' } : m
))
toast.success('Марафон верифицирован')
} catch (err) {
console.error('Failed to certify marathon:', err)
toast.error('Ошибка верификации')
}
}
const handleRevokeCertification = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Отозвать верификацию',
message: `Отозвать верификацию марафона "${marathon.title}"? Участники больше не смогут зарабатывать монетки.`,
confirmText: 'Отозвать',
variant: 'warning',
})
if (!confirmed) return
try {
await adminApi.revokeCertification(marathon.id)
setMarathons(marathons.map(m =>
m.id === marathon.id ? { ...m, is_certified: false, certification_status: 'none' } : m
))
toast.success('Верификация отозвана')
} catch (err) {
console.error('Failed to revoke certification:', err)
toast.error('Ошибка отзыва верификации')
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -145,6 +186,7 @@ export function AdminMarathonsPage() {
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Верификация</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th>
@@ -154,13 +196,13 @@ export function AdminMarathonsPage() {
<tbody className="divide-y divide-dark-600"> <tbody className="divide-y divide-dark-600">
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={8} className="px-4 py-8 text-center"> <td colSpan={9} className="px-4 py-8 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" /> <div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
</td> </td>
</tr> </tr>
) : marathons.length === 0 ? ( ) : marathons.length === 0 ? (
<tr> <tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400"> <td colSpan={9} className="px-4 py-8 text-center text-gray-400">
Марафоны не найдены Марафоны не найдены
</td> </td>
</tr> </tr>
@@ -179,6 +221,19 @@ export function AdminMarathonsPage() {
{statusConfig.label} {statusConfig.label}
</span> </span>
</td> </td>
<td className="px-4 py-3">
{marathon.is_certified ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
<BadgeCheck className="w-3 h-3" />
Верифицирован
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-dark-600/50 text-gray-400 border border-dark-500">
<BadgeX className="w-3 h-3" />
Нет
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td> <td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td> <td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td>
<td className="px-4 py-3 text-sm text-gray-400"> <td className="px-4 py-3 text-sm text-gray-400">
@@ -188,6 +243,23 @@ export function AdminMarathonsPage() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{marathon.is_certified ? (
<button
onClick={() => handleRevokeCertification(marathon)}
className="p-2 text-yellow-400 hover:bg-yellow-500/20 rounded-lg transition-colors"
title="Отозвать верификацию"
>
<BadgeX className="w-4 h-4" />
</button>
) : (
<button
onClick={() => handleCertify(marathon)}
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
title="Верифицировать"
>
<BadgeCheck className="w-4 h-4" />
</button>
)}
{marathon.status !== 'finished' && ( {marathon.status !== 'finished' && (
<button <button
onClick={() => handleForceFinish(marathon)} onClick={() => handleForceFinish(marathon)}

View File

@@ -85,6 +85,7 @@ export interface Marathon {
is_public: boolean is_public: boolean
game_proposal_mode: GameProposalMode game_proposal_mode: GameProposalMode
auto_events_enabled: boolean auto_events_enabled: boolean
allow_consumables: boolean
cover_url: string | null cover_url: string | null
start_date: string | null start_date: string | null
end_date: string | null end_date: string | null
@@ -512,6 +513,8 @@ export interface AdminMarathon {
start_date: string | null start_date: string | null
end_date: string | null end_date: string | null
created_at: string created_at: string
certification_status: string
is_certified: boolean
} }
export interface PlatformStats { export interface PlatformStats {
@@ -802,10 +805,11 @@ export interface ConsumablesStatus {
skips_available: number skips_available: number
skips_used: number skips_used: number
skips_remaining: number | null skips_remaining: number | null
shields_available: number
has_shield: boolean has_shield: boolean
boosts_available: number
has_active_boost: boolean has_active_boost: boolean
boost_multiplier: number | null boost_multiplier: number | null
boost_expires_at: string | null
rerolls_available: number rerolls_available: number
} }