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,
):
"""Force finish a marathon. Admin only."""
from app.services.coins import coins_service
require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
@@ -465,6 +467,24 @@ async def force_finish_marathon(
old_status = marathon.status
marathon.status = MarathonStatus.FINISHED.value
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()
# 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)
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
from app.services.coins import coins_service
# Require organizer role
await require_organizer(db, current_user, 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
# 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
activity = Activity(
marathon_id=marathon_id,

View File

@@ -206,7 +206,7 @@ async def use_consumable(
effect_description = "Shield activated - next drop will be free"
elif data.item_code == "boost":
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":
effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment)
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)
# Get inventory counts
# Get inventory counts for all consumables
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")
# Calculate remaining skips for this marathon
@@ -254,10 +256,11 @@ async def get_consumables_status(
skips_available=skips_available,
skips_used=participant.skips_used,
skips_remaining=skips_remaining,
shields_available=shields_available,
has_shield=participant.has_shield,
boosts_available=boosts_available,
has_active_boost=participant.has_active_boost,
boost_multiplier=participant.active_boost_multiplier if participant.has_active_boost else None,
boost_expires_at=participant.active_boost_expires_at if participant.has_active_boost else None,
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
rerolls_available=rerolls_available,
)

View File

@@ -622,7 +622,7 @@ async def complete_assignment(
ba.points_earned = int(ba.challenge.points * multiplier)
# 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:
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}")
# 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:
total_points = int(total_points * boost_multiplier)

View File

@@ -1,6 +1,6 @@
from datetime import datetime
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 app.core.database import Base
@@ -31,8 +31,7 @@ class Participant(Base):
# Shop: consumables state
skips_used: Mapped[int] = mapped_column(Integer, default=0)
active_boost_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True)
active_boost_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
@@ -47,16 +46,3 @@ class Participant(Base):
@property
def is_organizer(self) -> bool:
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_used: int # In this marathon
skips_remaining: int | None # Based on marathon limit
has_shield: bool
has_active_boost: bool
boost_multiplier: float | None
boost_expires_at: datetime | None
shields_available: int # From inventory
has_shield: bool # Currently activated
boosts_available: int # From inventory
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

View File

@@ -1,7 +1,7 @@
"""
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
"""
from datetime import datetime, timedelta
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,7 +17,6 @@ class ConsumablesService:
"""Service for consumable items"""
# Boost settings
BOOST_DURATION_HOURS = 2
BOOST_MULTIPLIER = 1.5
async def use_skip(
@@ -141,10 +140,10 @@ class ConsumablesService:
marathon: Marathon,
) -> 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
- Duration: BOOST_DURATION_HOURS
- Points for next completed challenge are multiplied by BOOST_MULTIPLIER
- One-time use (consumed on next complete)
Returns: dict with result info
@@ -155,17 +154,13 @@ class ConsumablesService:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_active_boost:
raise HTTPException(
status_code=400,
detail=f"Boost already active until {participant.active_boost_expires_at}"
)
raise HTTPException(status_code=400, detail="Boost is already activated")
# Consume boost from inventory
item = await self._consume_item(db, user, ConsumableType.BOOST.value)
# Activate boost
participant.active_boost_multiplier = self.BOOST_MULTIPLIER
participant.active_boost_expires_at = datetime.utcnow() + timedelta(hours=self.BOOST_DURATION_HOURS)
# Activate boost (one-time use)
participant.has_active_boost = True
# Log usage
usage = ConsumableUsage(
@@ -175,8 +170,7 @@ class ConsumablesService:
effect_data={
"type": "boost",
"multiplier": self.BOOST_MULTIPLIER,
"duration_hours": self.BOOST_DURATION_HOURS,
"expires_at": participant.active_boost_expires_at.isoformat(),
"one_time": True,
},
)
db.add(usage)
@@ -185,7 +179,6 @@ class ConsumablesService:
"success": True,
"boost_activated": True,
"multiplier": self.BOOST_MULTIPLIER,
"expires_at": participant.active_boost_expires_at,
}
async def use_reroll(
@@ -299,7 +292,7 @@ class ConsumablesService:
quantity = result.scalar_one_or_none()
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).
@@ -310,13 +303,17 @@ class ConsumablesService:
return True
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

View File

@@ -76,6 +76,14 @@ export const adminApi = {
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
getStats: async (): Promise<PlatformStats> => {
const response = await client.get<PlatformStats>('/admin/stats')

View File

@@ -1,13 +1,14 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi } from '@/api'
import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment } from '@/types'
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi, shopApi } from '@/api'
import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment, ConsumablesStatus, ConsumableType } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel'
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 { useConfirm } from '@/store/confirm'
import { useShopStore } from '@/store/shop'
const MAX_IMAGE_SIZE = 15 * 1024 * 1024
const MAX_VIDEO_SIZE = 30 * 1024 * 1024
@@ -55,6 +56,10 @@ export function PlayPage() {
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
// Consumables
const [consumablesStatus, setConsumablesStatus] = useState<ConsumablesStatus | null>(null)
const [isUsingConsumable, setIsUsingConsumable] = useState<ConsumableType | null>(null)
// Bonus challenge completion
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
@@ -177,13 +182,14 @@ export function PlayPage() {
const loadData = async () => {
if (!id) return
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)),
wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.getAvailableGames(parseInt(id)),
eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)),
assignmentsApi.getReturnedAssignments(parseInt(id)),
shopApi.getConsumablesStatus(parseInt(id)).catch(() => null),
])
setMarathon(marathonData)
setCurrentAssignment(assignment)
@@ -191,6 +197,7 @@ export function PlayPage() {
setActiveEvent(eventData)
setEventAssignment(eventAssignmentData)
setReturnedAssignments(returnedData)
setConsumablesStatus(consumablesData)
} catch (error) {
console.error('Failed to load data:', error)
} finally {
@@ -255,6 +262,8 @@ export function PlayPage() {
setProofUrl('')
setComment('')
await loadData()
// Refresh coins balance
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
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) {
return (
<div className="flex flex-col items-center justify-center py-24">
@@ -608,6 +696,135 @@ export function PlayPage() {
</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 */}
{activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6">

View File

@@ -4,7 +4,7 @@ import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
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 }> = {
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 (
<div className="space-y-6">
{/* 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>
@@ -154,13 +196,13 @@ export function AdminMarathonsPage() {
<tbody className="divide-y divide-dark-600">
{loading ? (
<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" />
</td>
</tr>
) : marathons.length === 0 ? (
<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>
</tr>
@@ -179,6 +221,19 @@ export function AdminMarathonsPage() {
{statusConfig.label}
</span>
</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.games_count}</td>
<td className="px-4 py-3 text-sm text-gray-400">
@@ -188,6 +243,23 @@ export function AdminMarathonsPage() {
</td>
<td className="px-4 py-3">
<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' && (
<button
onClick={() => handleForceFinish(marathon)}

View File

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