From 2874b644810f534267a3f39531d9323e61b2e53e Mon Sep 17 00:00:00 2001 From: Oronemu Date: Thu, 8 Jan 2026 06:51:15 +0700 Subject: [PATCH] Bug fixes --- .../versions/025_simplify_boost_consumable.py | 52 ++++ backend/app/api/v1/admin.py | 20 ++ backend/app/api/v1/marathons.py | 20 ++ backend/app/api/v1/shop.py | 11 +- backend/app/api/v1/wheel.py | 4 +- backend/app/models/participant.py | 18 +- backend/app/schemas/shop.py | 9 +- backend/app/services/consumables.py | 37 ++- frontend/src/api/admin.ts | 8 + frontend/src/pages/PlayPage.tsx | 225 +++++++++++++++++- .../src/pages/admin/AdminMarathonsPage.tsx | 78 +++++- frontend/src/types/index.ts | 6 +- 12 files changed, 434 insertions(+), 54 deletions(-) create mode 100644 backend/alembic/versions/025_simplify_boost_consumable.py diff --git a/backend/alembic/versions/025_simplify_boost_consumable.py b/backend/alembic/versions/025_simplify_boost_consumable.py new file mode 100644 index 0000000..c1ab543 --- /dev/null +++ b/backend/alembic/versions/025_simplify_boost_consumable.py @@ -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') diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index ccc3cd0..b99dae5 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -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 diff --git a/backend/app/api/v1/marathons.py b/backend/app/api/v1/marathons.py index b0797a7..c8b9e34 100644 --- a/backend/app/api/v1/marathons.py +++ b/backend/app/api/v1/marathons.py @@ -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, diff --git a/backend/app/api/v1/shop.py b/backend/app/api/v1/shop.py index c3707fe..3e3706e 100644 --- a/backend/app/api/v1/shop.py +++ b/backend/app/api/v1/shop.py @@ -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, ) diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index 467ebec..2f3177d 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -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) diff --git a/backend/app/models/participant.py b/backend/app/models/participant.py index 2e9c0f3..c869e92 100644 --- a/backend/app/models/participant.py +++ b/backend/app/models/participant.py @@ -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 diff --git a/backend/app/schemas/shop.py b/backend/app/schemas/shop.py index 367abda..a76c9b9 100644 --- a/backend/app/schemas/shop.py +++ b/backend/app/schemas/shop.py @@ -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 diff --git a/backend/app/services/consumables.py b/backend/app/services/consumables.py index 046a4ca..f0273ed 100644 --- a/backend/app/services/consumables.py +++ b/backend/app/services/consumables.py @@ -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 diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 8e938a2..f65cb20 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -76,6 +76,14 @@ export const adminApi = { await client.post(`/admin/marathons/${id}/force-finish`) }, + certifyMarathon: async (id: number): Promise => { + await client.post(`/admin/marathons/${id}/certify`) + }, + + revokeCertification: async (id: number): Promise => { + await client.post(`/admin/marathons/${id}/revoke-certification`) + }, + // Stats getStats: async (): Promise => { const response = await client.get('/admin/stats') diff --git a/frontend/src/pages/PlayPage.tsx b/frontend/src/pages/PlayPage.tsx index ee32fa4..7fcd393 100644 --- a/frontend/src/pages/PlayPage.tsx +++ b/frontend/src/pages/PlayPage.tsx @@ -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([]) + // Consumables + const [consumablesStatus, setConsumablesStatus] = useState(null) + const [isUsingConsumable, setIsUsingConsumable] = useState(null) + // Bonus challenge completion const [expandedBonusId, setExpandedBonusId] = useState(null) const [bonusProofFiles, setBonusProofFiles] = useState([]) @@ -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 (
@@ -608,6 +696,135 @@ export function PlayPage() { )} + {/* Consumables Panel */} + {consumablesStatus && marathon?.allow_consumables && ( + +
+
+ +
+
+

Расходники

+

Используйте для облегчения задания

+
+
+ + {/* Active effects */} + {(consumablesStatus.has_shield || consumablesStatus.has_active_boost) && ( +
+

Активные эффекты:

+
+ {consumablesStatus.has_shield && ( + + Shield (следующий drop бесплатный) + + )} + {consumablesStatus.has_active_boost && ( + + Boost x1.5 (следующий complete) + + )} +
+
+ )} + + {/* Consumables grid */} +
+ {/* Skip */} +
+
+
+ + Skip +
+ {consumablesStatus.skips_available} шт. +
+

Пропустить без штрафа

+ + Использовать + +
+ + {/* Reroll */} +
+
+
+ + Reroll +
+ {consumablesStatus.rerolls_available} шт. +
+

Переспинить задание

+ + Использовать + +
+ + {/* Shield */} +
+
+
+ + Shield +
+ + {consumablesStatus.has_shield ? 'Активен' : `${consumablesStatus.shields_available} шт.`} + +
+

Защита от штрафа

+ + {consumablesStatus.has_shield ? 'Активен' : 'Активировать'} + +
+ + {/* Boost */} +
+
+
+ + Boost +
+ + {consumablesStatus.has_active_boost ? 'Активен' : `${consumablesStatus.boosts_available} шт.`} + +
+

x1.5 очков

+ + {consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'} + +
+
+
+ )} + {/* Tabs for Common Enemy event */} {activeEvent?.event?.type === 'common_enemy' && (
diff --git a/frontend/src/pages/admin/AdminMarathonsPage.tsx b/frontend/src/pages/admin/AdminMarathonsPage.tsx index 5e1cd1e..558c6cc 100644 --- a/frontend/src/pages/admin/AdminMarathonsPage.tsx +++ b/frontend/src/pages/admin/AdminMarathonsPage.tsx @@ -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 = { 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 (
{/* Header */} @@ -145,6 +186,7 @@ export function AdminMarathonsPage() { Название Создатель Статус + Верификация Участники Игры Даты @@ -154,13 +196,13 @@ export function AdminMarathonsPage() { {loading ? ( - +
) : marathons.length === 0 ? ( - + Марафоны не найдены @@ -179,6 +221,19 @@ export function AdminMarathonsPage() { {statusConfig.label} + + {marathon.is_certified ? ( + + + Верифицирован + + ) : ( + + + Нет + + )} + {marathon.participants_count} {marathon.games_count} @@ -188,6 +243,23 @@ export function AdminMarathonsPage() {
+ {marathon.is_certified ? ( + + ) : ( + + )} {marathon.status !== 'finished' && (