Bug fixes
This commit is contained in:
52
backend/alembic/versions/025_simplify_boost_consumable.py
Normal file
52
backend/alembic/versions/025_simplify_boost_consumable.py
Normal 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')
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user