Compare commits

...

2 Commits

Author SHA1 Message Date
e63d6c8489 Promocode system 2026-01-08 10:02:15 +07:00
1751c4dd4c rework shop 2026-01-08 08:49:51 +07:00
32 changed files with 2355 additions and 266 deletions

View File

@@ -450,13 +450,13 @@ def upgrade() -> None:
'item_type': 'consumable',
'code': 'boost',
'name': 'Буст x1.5',
'description': 'Множитель очков x1.5 на следующие 2 часа',
'description': 'Множитель очков x1.5 на текущее задание',
'price': 200,
'rarity': 'rare',
'asset_data': {
'effect': 'boost',
'multiplier': 1.5,
'duration_hours': 2,
'one_time': True,
'icon': 'zap'
},
'is_active': True,

View File

@@ -0,0 +1,46 @@
"""Update boost description to one-time usage
Revision ID: 026_update_boost_desc
Revises: 025_simplify_boost
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '026_update_boost_desc'
down_revision: Union[str, None] = '025_simplify_boost'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Update boost description in shop_items table
op.execute("""
UPDATE shop_items
SET description = 'Множитель очков x1.5 на текущее задание',
asset_data = jsonb_set(
asset_data::jsonb - 'duration_hours',
'{one_time}',
'true'
)
WHERE code = 'boost' AND item_type = 'consumable'
""")
def downgrade() -> None:
# Revert boost description
op.execute("""
UPDATE shop_items
SET description = 'Множитель очков x1.5 на следующие 2 часа',
asset_data = jsonb_set(
asset_data::jsonb - 'one_time',
'{duration_hours}',
'2'
)
WHERE code = 'boost' AND item_type = 'consumable'
""")

View File

@@ -0,0 +1,83 @@
"""Consumables redesign: remove shield/reroll, add wild_card/lucky_dice/copycat/undo
Revision ID: 027_consumables_redesign
Revises: 026_update_boost_desc
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from datetime import datetime
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '027_consumables_redesign'
down_revision: Union[str, None] = '026_update_boost_desc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Remove has_shield column from participants
op.drop_column('participants', 'has_shield')
# 2. Add new columns for lucky_dice and undo
op.add_column('participants', sa.Column('has_lucky_dice', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('participants', sa.Column('lucky_dice_multiplier', sa.Float(), nullable=True))
op.add_column('participants', sa.Column('last_drop_points', sa.Integer(), nullable=True))
op.add_column('participants', sa.Column('last_drop_streak_before', sa.Integer(), nullable=True))
op.add_column('participants', sa.Column('can_undo', sa.Boolean(), nullable=False, server_default='false'))
# 3. Remove old consumables from shop
op.execute("DELETE FROM shop_items WHERE code IN ('reroll', 'shield')")
# 4. Update boost price from 200 to 150
op.execute("UPDATE shop_items SET price = 150 WHERE code = 'boost'")
# 5. Add new consumables to shop
now = datetime.utcnow().isoformat()
op.execute(f"""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
VALUES
('consumable', 'wild_card', 'Дикая карта', 'Выбери игру и получи случайное задание из неё', 150, 'uncommon',
'{{"effect": "wild_card", "icon": "shuffle"}}', true, '{now}'),
('consumable', 'lucky_dice', 'Счастливые кости', 'Случайный множитель очков (1.5x - 4.0x)', 250, 'rare',
'{{"effect": "lucky_dice", "multipliers": [1.5, 2.0, 2.5, 3.0, 3.5, 4.0], "icon": "dice"}}', true, '{now}'),
('consumable', 'copycat', 'Копикэт', 'Скопируй задание любого участника марафона', 300, 'epic',
'{{"effect": "copycat", "icon": "copy"}}', true, '{now}'),
('consumable', 'undo', 'Отмена', 'Отмени последний дроп и верни очки со стриком', 300, 'epic',
'{{"effect": "undo", "icon": "undo"}}', true, '{now}')
""")
def downgrade() -> None:
# 1. Remove new columns
op.drop_column('participants', 'can_undo')
op.drop_column('participants', 'last_drop_streak_before')
op.drop_column('participants', 'last_drop_points')
op.drop_column('participants', 'lucky_dice_multiplier')
op.drop_column('participants', 'has_lucky_dice')
# 2. Add back has_shield
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
# 3. Remove new consumables
op.execute("DELETE FROM shop_items WHERE code IN ('wild_card', 'lucky_dice', 'copycat', 'undo')")
# 4. Restore boost price back to 200
op.execute("UPDATE shop_items SET price = 200 WHERE code = 'boost'")
# 5. Add back old consumables
now = datetime.utcnow().isoformat()
op.execute(f"""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
VALUES
('consumable', 'shield', 'Щит', 'Защита от штрафа при следующем дропе. Streak сохраняется.', 150, 'uncommon',
'{{"effect": "shield", "icon": "shield"}}', true, '{now}'),
('consumable', 'reroll', 'Перекрут', 'Перекрутить колесо и получить новое задание', 80, 'common',
'{{"effect": "reroll", "icon": "refresh-cw"}}', true, '{now}')
""")

View File

@@ -0,0 +1,58 @@
"""Add promo codes system
Revision ID: 028_add_promo_codes
Revises: 027_consumables_redesign
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '028_add_promo_codes'
down_revision: Union[str, None] = '027_consumables_redesign'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create promo_codes table
op.create_table(
'promo_codes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(50), nullable=False),
sa.Column('coins_amount', sa.Integer(), nullable=False),
sa.Column('max_uses', sa.Integer(), nullable=True),
sa.Column('uses_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('valid_from', sa.DateTime(), nullable=True),
sa.Column('valid_until', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='CASCADE'),
)
op.create_index('ix_promo_codes_code', 'promo_codes', ['code'], unique=True)
# Create promo_code_redemptions table
op.create_table(
'promo_code_redemptions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('promo_code_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('coins_awarded', sa.Integer(), nullable=False),
sa.Column('redeemed_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['promo_code_id'], ['promo_codes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
)
op.create_index('ix_promo_code_redemptions_user_id', 'promo_code_redemptions', ['user_id'])
def downgrade() -> None:
op.drop_table('promo_code_redemptions')
op.drop_table('promo_codes')

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo
router = APIRouter(prefix="/api/v1")
@@ -17,3 +17,4 @@ router.include_router(assignments.router)
router.include_router(telegram.router)
router.include_router(content.router)
router.include_router(shop.router)
router.include_router(promo.router)

299
backend/app/api/v1/promo.py Normal file
View File

@@ -0,0 +1,299 @@
"""
Promo Code API endpoints - user redemption and admin management
"""
import secrets
import string
from datetime import datetime
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import CurrentUser, DbSession, require_admin_with_2fa
from app.models import User, CoinTransaction, CoinTransactionType
from app.models.promo_code import PromoCode, PromoCodeRedemption
from app.schemas.promo_code import (
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeResponse,
PromoCodeRedeemRequest,
PromoCodeRedeemResponse,
PromoCodeRedemptionResponse,
PromoCodeRedemptionUser,
)
from app.schemas.common import MessageResponse
router = APIRouter(prefix="/promo", tags=["promo"])
def generate_promo_code(length: int = 8) -> str:
"""Generate a random promo code"""
chars = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(chars) for _ in range(length))
# === User endpoints ===
@router.post("/redeem", response_model=PromoCodeRedeemResponse)
async def redeem_promo_code(
data: PromoCodeRedeemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Redeem a promo code to receive coins"""
# Find promo code
result = await db.execute(
select(PromoCode).where(PromoCode.code == data.code.upper().strip())
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
# Check if valid
if not promo.is_active:
raise HTTPException(status_code=400, detail="Промокод деактивирован")
now = datetime.utcnow()
if promo.valid_from and now < promo.valid_from:
raise HTTPException(status_code=400, detail="Промокод ещё не активен")
if promo.valid_until and now > promo.valid_until:
raise HTTPException(status_code=400, detail="Промокод истёк")
if promo.max_uses is not None and promo.uses_count >= promo.max_uses:
raise HTTPException(status_code=400, detail="Лимит использований исчерпан")
# Check if user already redeemed
result = await db.execute(
select(PromoCodeRedemption).where(
PromoCodeRedemption.promo_code_id == promo.id,
PromoCodeRedemption.user_id == current_user.id,
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Вы уже использовали этот промокод")
# Create redemption record
redemption = PromoCodeRedemption(
promo_code_id=promo.id,
user_id=current_user.id,
coins_awarded=promo.coins_amount,
)
db.add(redemption)
# Update uses count
promo.uses_count += 1
# Award coins
transaction = CoinTransaction(
user_id=current_user.id,
amount=promo.coins_amount,
transaction_type=CoinTransactionType.PROMO_CODE.value,
reference_type="promo_code",
reference_id=promo.id,
description=f"Промокод: {promo.code}",
)
db.add(transaction)
current_user.coins_balance += promo.coins_amount
await db.commit()
await db.refresh(current_user)
return PromoCodeRedeemResponse(
success=True,
coins_awarded=promo.coins_amount,
new_balance=current_user.coins_balance,
message=f"Вы получили {promo.coins_amount} монет!",
)
# === Admin endpoints ===
@router.get("/admin/list", response_model=list[PromoCodeResponse])
async def admin_list_promo_codes(
current_user: CurrentUser,
db: DbSession,
include_inactive: bool = False,
):
"""Get all promo codes (admin only)"""
require_admin_with_2fa(current_user)
query = select(PromoCode).options(selectinload(PromoCode.created_by))
if not include_inactive:
query = query.where(PromoCode.is_active == True)
query = query.order_by(PromoCode.created_at.desc())
result = await db.execute(query)
promos = result.scalars().all()
return [
PromoCodeResponse(
id=p.id,
code=p.code,
coins_amount=p.coins_amount,
max_uses=p.max_uses,
uses_count=p.uses_count,
is_active=p.is_active,
valid_from=p.valid_from,
valid_until=p.valid_until,
created_at=p.created_at,
created_by_nickname=p.created_by.nickname if p.created_by else None,
)
for p in promos
]
@router.post("/admin/create", response_model=PromoCodeResponse)
async def admin_create_promo_code(
data: PromoCodeCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a new promo code (admin only)"""
require_admin_with_2fa(current_user)
# Generate or use provided code
code = data.code.upper().strip() if data.code else generate_promo_code()
# Check uniqueness
result = await db.execute(
select(PromoCode).where(PromoCode.code == code)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"Промокод '{code}' уже существует")
promo = PromoCode(
code=code,
coins_amount=data.coins_amount,
max_uses=data.max_uses,
valid_from=data.valid_from,
valid_until=data.valid_until,
created_by_id=current_user.id,
)
db.add(promo)
await db.commit()
await db.refresh(promo)
return PromoCodeResponse(
id=promo.id,
code=promo.code,
coins_amount=promo.coins_amount,
max_uses=promo.max_uses,
uses_count=promo.uses_count,
is_active=promo.is_active,
valid_from=promo.valid_from,
valid_until=promo.valid_until,
created_at=promo.created_at,
created_by_nickname=current_user.nickname,
)
@router.put("/admin/{promo_id}", response_model=PromoCodeResponse)
async def admin_update_promo_code(
promo_id: int,
data: PromoCodeUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update a promo code (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(PromoCode)
.options(selectinload(PromoCode.created_by))
.where(PromoCode.id == promo_id)
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
if data.is_active is not None:
promo.is_active = data.is_active
if data.max_uses is not None:
promo.max_uses = data.max_uses
if data.valid_until is not None:
promo.valid_until = data.valid_until
await db.commit()
await db.refresh(promo)
return PromoCodeResponse(
id=promo.id,
code=promo.code,
coins_amount=promo.coins_amount,
max_uses=promo.max_uses,
uses_count=promo.uses_count,
is_active=promo.is_active,
valid_from=promo.valid_from,
valid_until=promo.valid_until,
created_at=promo.created_at,
created_by_nickname=promo.created_by.nickname if promo.created_by else None,
)
@router.delete("/admin/{promo_id}", response_model=MessageResponse)
async def admin_delete_promo_code(
promo_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete a promo code (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(PromoCode).where(PromoCode.id == promo_id)
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
await db.delete(promo)
await db.commit()
return MessageResponse(message=f"Промокод '{promo.code}' удалён")
@router.get("/admin/{promo_id}/redemptions", response_model=list[PromoCodeRedemptionResponse])
async def admin_get_promo_redemptions(
promo_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get list of users who redeemed a promo code (admin only)"""
require_admin_with_2fa(current_user)
# Check promo exists
result = await db.execute(
select(PromoCode).where(PromoCode.id == promo_id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Промокод не найден")
# Get redemptions
result = await db.execute(
select(PromoCodeRedemption)
.options(selectinload(PromoCodeRedemption.user))
.where(PromoCodeRedemption.promo_code_id == promo_id)
.order_by(PromoCodeRedemption.redeemed_at.desc())
)
redemptions = result.scalars().all()
return [
PromoCodeRedemptionResponse(
id=r.id,
user=PromoCodeRedemptionUser(
id=r.user.id,
nickname=r.user.nickname,
),
coins_awarded=r.coins_awarded,
redeemed_at=r.redeemed_at,
)
for r in redemptions
]

View File

@@ -10,7 +10,7 @@ from app.api.deps import CurrentUser, DbSession, require_participant, require_ad
from app.models import (
User, Marathon, Participant, Assignment, AssignmentStatus,
ShopItem, UserInventory, CoinTransaction, ShopItemType,
CertificationStatus,
CertificationStatus, Challenge, Game,
)
from app.schemas import (
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
@@ -19,8 +19,9 @@ from app.schemas import (
EquipItemRequest, EquipItemResponse,
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
ConsumablesStatusResponse, MessageResponse,
ConsumablesStatusResponse, MessageResponse, SwapCandidate,
)
from app.schemas.user import UserPublic
from app.services.shop import shop_service
from app.services.coins import coins_service
from app.services.consumables import consumables_service
@@ -181,18 +182,29 @@ async def use_consumable(
# Get participant
participant = await require_participant(db, current_user.id, data.marathon_id)
# For skip and reroll, we need the assignment
# For some consumables, we need the assignment
assignment = None
if data.item_code in ["skip", "reroll"]:
if data.item_code in ["skip", "wild_card", "copycat"]:
if not data.assignment_id:
raise HTTPException(status_code=400, detail="assignment_id is required for skip/reroll")
raise HTTPException(status_code=400, detail=f"assignment_id is required for {data.item_code}")
result = await db.execute(
select(Assignment).where(
Assignment.id == data.assignment_id,
Assignment.participant_id == participant.id,
# For copycat, we need bonus_assignments to properly handle playthrough
if data.item_code == "copycat":
result = await db.execute(
select(Assignment)
.options(selectinload(Assignment.bonus_assignments))
.where(
Assignment.id == data.assignment_id,
Assignment.participant_id == participant.id,
)
)
else:
result = await db.execute(
select(Assignment).where(
Assignment.id == data.assignment_id,
Assignment.participant_id == participant.id,
)
)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
@@ -201,15 +213,29 @@ async def use_consumable(
if data.item_code == "skip":
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
effect_description = "Assignment skipped without penalty"
elif data.item_code == "shield":
effect = await consumables_service.use_shield(db, current_user, participant, marathon)
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 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"
effect_description = f"Boost x{effect['multiplier']} activated for current assignment"
elif data.item_code == "wild_card":
if data.game_id is None:
raise HTTPException(status_code=400, detail="game_id is required for wild_card")
effect = await consumables_service.use_wild_card(
db, current_user, participant, marathon, assignment, data.game_id
)
effect_description = f"New challenge from {effect['game_name']}: {effect['challenge_title']}"
elif data.item_code == "lucky_dice":
effect = await consumables_service.use_lucky_dice(db, current_user, participant, marathon)
effect_description = f"Lucky Dice rolled: x{effect['multiplier']} multiplier"
elif data.item_code == "copycat":
if data.target_participant_id is None:
raise HTTPException(status_code=400, detail="target_participant_id is required for copycat")
effect = await consumables_service.use_copycat(
db, current_user, participant, marathon, assignment, data.target_participant_id
)
effect_description = f"Copied challenge: {effect['challenge_title']}"
elif data.item_code == "undo":
effect = await consumables_service.use_undo(db, current_user, participant, marathon)
effect_description = f"Restored {effect['points_restored']} points and streak {effect['streak_restored']}"
else:
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
@@ -243,9 +269,11 @@ async def get_consumables_status(
# 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")
wild_cards_available = await consumables_service.get_consumable_count(db, current_user.id, "wild_card")
lucky_dice_available = await consumables_service.get_consumable_count(db, current_user.id, "lucky_dice")
copycats_available = await consumables_service.get_consumable_count(db, current_user.id, "copycat")
undos_available = await consumables_service.get_consumable_count(db, current_user.id, "undo")
# Calculate remaining skips for this marathon
skips_remaining = None
@@ -256,15 +284,104 @@ 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=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
rerolls_available=rerolls_available,
wild_cards_available=wild_cards_available,
lucky_dice_available=lucky_dice_available,
has_lucky_dice=participant.has_lucky_dice,
lucky_dice_multiplier=participant.lucky_dice_multiplier,
copycats_available=copycats_available,
undos_available=undos_available,
can_undo=participant.can_undo,
)
@router.get("/copycat-candidates/{marathon_id}", response_model=list[SwapCandidate])
async def get_copycat_candidates(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get participants with active assignments available for copycat (no event required)"""
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
participant = await require_participant(db, current_user.id, marathon_id)
# Get all participants except current user with active assignments
# Support both challenge assignments and playthrough assignments
result = await db.execute(
select(Participant, Assignment, Challenge, Game)
.join(Assignment, Assignment.participant_id == Participant.id)
.outerjoin(Challenge, Assignment.challenge_id == Challenge.id)
.outerjoin(Game, Challenge.game_id == Game.id)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
rows = result.all()
candidates = []
for p, assignment, challenge, game in rows:
# For playthrough assignments, challenge is None
if assignment.is_playthrough:
# Need to get game info for playthrough
game_result = await db.execute(
select(Game).where(Game.id == assignment.game_id)
)
playthrough_game = game_result.scalar_one_or_none()
if playthrough_game:
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
nickname=p.user.nickname,
avatar_url=p.user.avatar_url,
role=p.user.role,
telegram_avatar_url=p.user.telegram_avatar_url,
created_at=p.user.created_at,
equipped_frame=None,
equipped_title=None,
equipped_name_color=None,
equipped_background=None,
),
challenge_title=f"Прохождение: {playthrough_game.title}",
challenge_description=playthrough_game.playthrough_description or "Прохождение игры",
challenge_points=playthrough_game.playthrough_points or 0,
challenge_difficulty="medium",
game_title=playthrough_game.title,
))
elif challenge and game:
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
nickname=p.user.nickname,
avatar_url=p.user.avatar_url,
role=p.user.role,
telegram_avatar_url=p.user.telegram_avatar_url,
created_at=p.user.created_at,
equipped_frame=None,
equipped_title=None,
equipped_name_color=None,
equipped_background=None,
),
challenge_title=challenge.title,
challenge_description=challenge.description,
challenge_points=challenge.points,
challenge_difficulty=challenge.difficulty,
game_title=game.title,
))
return candidates
# === Coins ===
@router.get("/balance", response_model=CoinsBalanceResponse)

View File

@@ -621,10 +621,12 @@ async def complete_assignment(
if ba.status == BonusAssignmentStatus.COMPLETED.value:
ba.points_earned = int(ba.challenge.points * multiplier)
# Apply boost multiplier from consumable
# Apply boost and lucky dice multipliers from consumables
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier)
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
combined_multiplier = boost_multiplier * lucky_dice_multiplier
if combined_multiplier != 1.0:
total_points = int(total_points * combined_multiplier)
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
@@ -666,6 +668,8 @@ async def complete_assignment(
activity_data["is_redo"] = True
if boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if lucky_dice_multiplier != 1.0:
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if playthrough_event:
@@ -728,10 +732,12 @@ async def complete_assignment(
total_points += common_enemy_bonus
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Apply boost multiplier from consumable
# Apply boost and lucky dice multipliers from consumables
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier)
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
combined_multiplier = boost_multiplier * lucky_dice_multiplier
if combined_multiplier != 1.0:
total_points = int(total_points * combined_multiplier)
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
@@ -772,6 +778,8 @@ async def complete_assignment(
activity_data["is_redo"] = True
if boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if lucky_dice_multiplier != 1.0:
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if assignment.event_type == EventType.JACKPOT.value:
@@ -887,11 +895,10 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
participant.drop_count, game.playthrough_points, playthrough_event
)
# Check for shield - if active, no penalty
shield_used = False
if consumables_service.consume_shield(participant):
penalty = 0
shield_used = True
# Save drop data for potential undo
consumables_service.save_drop_for_undo(
participant, penalty, participant.current_streak
)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
@@ -921,8 +928,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
"penalty": penalty,
"lost_bonuses": completed_bonuses_count,
}
if shield_used:
activity_data["shield_used"] = True
if playthrough_event:
activity_data["event_type"] = playthrough_event.type
activity_data["free_drop"] = True
@@ -941,7 +946,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
shield_used=shield_used,
)
# Regular challenge drop
@@ -953,11 +957,10 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
# Calculate penalty (0 if double_risk event is active)
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
# Check for shield - if active, no penalty
shield_used = False
if consumables_service.consume_shield(participant):
penalty = 0
shield_used = True
# Save drop data for potential undo
consumables_service.save_drop_for_undo(
participant, penalty, participant.current_streak
)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
@@ -975,8 +978,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
"difficulty": assignment.challenge.difficulty,
"penalty": penalty,
}
if shield_used:
activity_data["shield_used"] = True
if active_event:
activity_data["event_type"] = active_event.type
if active_event.type == EventType.DOUBLE_RISK.value:
@@ -996,7 +997,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
shield_used=shield_used,
)

View File

@@ -17,6 +17,7 @@ from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType
from app.models.inventory import UserInventory
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
from app.models.consumable_usage import ConsumableUsage
from app.models.promo_code import PromoCode, PromoCodeRedemption
__all__ = [
"User",
@@ -62,4 +63,6 @@ __all__ = [
"CoinTransaction",
"CoinTransactionType",
"ConsumableUsage",
"PromoCode",
"PromoCodeRedemption",
]

View File

@@ -20,6 +20,7 @@ class CoinTransactionType(str, Enum):
REFUND = "refund"
ADMIN_GRANT = "admin_grant"
ADMIN_DEDUCT = "admin_deduct"
PROMO_CODE = "promo_code"
class CoinTransaction(Base):

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -32,7 +32,15 @@ class Participant(Base):
# Shop: consumables state
skips_used: Mapped[int] = mapped_column(Integer, default=0)
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
# Lucky Dice state
has_lucky_dice: Mapped[bool] = mapped_column(Boolean, default=False)
lucky_dice_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True)
# Undo state - stores last drop data for potential rollback
last_drop_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_drop_streak_before: Mapped[int | None] = mapped_column(Integer, nullable=True)
can_undo: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="participations")

View File

@@ -0,0 +1,67 @@
"""
Promo Code models for coins distribution
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Boolean, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class PromoCode(Base):
"""Promo code for giving coins to users"""
__tablename__ = "promo_codes"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
coins_amount: Mapped[int] = mapped_column(Integer, nullable=False)
max_uses: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = unlimited
uses_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
valid_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
# Relationships
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_id])
redemptions: Mapped[list["PromoCodeRedemption"]] = relationship(
"PromoCodeRedemption", back_populates="promo_code", cascade="all, delete-orphan"
)
def is_valid(self) -> bool:
"""Check if promo code is currently valid"""
if not self.is_active:
return False
now = datetime.utcnow()
if self.valid_from and now < self.valid_from:
return False
if self.valid_until and now > self.valid_until:
return False
if self.max_uses is not None and self.uses_count >= self.max_uses:
return False
return True
class PromoCodeRedemption(Base):
"""Record of promo code redemption by a user"""
__tablename__ = "promo_code_redemptions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
promo_code_id: Mapped[int] = mapped_column(ForeignKey("promo_codes.id", ondelete="CASCADE"), nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
coins_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
redeemed_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
__table_args__ = (
UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
)
# Relationships
promo_code: Mapped["PromoCode"] = relationship("PromoCode", back_populates="redemptions")
user: Mapped["User"] = relationship("User")

View File

@@ -28,9 +28,11 @@ class ItemRarity(str, Enum):
class ConsumableType(str, Enum):
SKIP = "skip"
SHIELD = "shield"
BOOST = "boost"
REROLL = "reroll"
WILD_CARD = "wild_card"
LUCKY_DICE = "lucky_dice"
COPYCAT = "copycat"
UNDO = "undo"
class ShopItem(Base):

View File

@@ -124,6 +124,15 @@ from app.schemas.shop import (
CertificationStatusResponse,
ConsumablesStatusResponse,
)
from app.schemas.promo_code import (
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeResponse,
PromoCodeRedeemRequest,
PromoCodeRedeemResponse,
PromoCodeRedemptionResponse,
PromoCodeRedemptionUser,
)
from app.schemas.user import ShopItemPublic
__all__ = [
@@ -243,4 +252,12 @@ __all__ = [
"CertificationReviewRequest",
"CertificationStatusResponse",
"ConsumablesStatusResponse",
# Promo
"PromoCodeCreate",
"PromoCodeUpdate",
"PromoCodeResponse",
"PromoCodeRedeemRequest",
"PromoCodeRedeemResponse",
"PromoCodeRedemptionResponse",
"PromoCodeRedemptionUser",
]

View File

@@ -86,7 +86,6 @@ class DropResult(BaseModel):
penalty: int
total_points: int
new_drop_count: int
shield_used: bool = False # Whether shield consumable was used to prevent penalty
class EventAssignmentResponse(BaseModel):

View File

@@ -43,10 +43,10 @@ class ParticipantInfo(BaseModel):
# Shop: coins and consumables status
coins_earned: int = 0
skips_used: int = 0
has_shield: bool = False
has_active_boost: bool = False
boost_multiplier: float | None = None
boost_expires_at: datetime | None = None
has_lucky_dice: bool = False
lucky_dice_multiplier: float | None = None
can_undo: bool = False
class Config:
from_attributes = True

View File

@@ -0,0 +1,74 @@
"""
Promo Code schemas
"""
from datetime import datetime
from pydantic import BaseModel, Field
# === Create/Update ===
class PromoCodeCreate(BaseModel):
"""Schema for creating a promo code"""
code: str | None = Field(None, min_length=3, max_length=50) # None = auto-generate
coins_amount: int = Field(..., ge=1, le=100000)
max_uses: int | None = Field(None, ge=1) # None = unlimited
valid_from: datetime | None = None
valid_until: datetime | None = None
class PromoCodeUpdate(BaseModel):
"""Schema for updating a promo code"""
is_active: bool | None = None
max_uses: int | None = None
valid_until: datetime | None = None
# === Response ===
class PromoCodeResponse(BaseModel):
"""Schema for promo code in responses"""
id: int
code: str
coins_amount: int
max_uses: int | None
uses_count: int
is_active: bool
valid_from: datetime | None
valid_until: datetime | None
created_at: datetime
created_by_nickname: str | None = None
class Config:
from_attributes = True
class PromoCodeRedemptionUser(BaseModel):
"""User info for redemption"""
id: int
nickname: str
class PromoCodeRedemptionResponse(BaseModel):
"""Schema for redemption record"""
id: int
user: PromoCodeRedemptionUser
coins_awarded: int
redeemed_at: datetime
class Config:
from_attributes = True
# === Redeem ===
class PromoCodeRedeemRequest(BaseModel):
"""Schema for redeeming a promo code"""
code: str = Field(..., min_length=1, max_length=50)
class PromoCodeRedeemResponse(BaseModel):
"""Schema for redeem response"""
success: bool
coins_awarded: int
new_balance: int
message: str

View File

@@ -94,9 +94,11 @@ class PurchaseResponse(BaseModel):
class UseConsumableRequest(BaseModel):
"""Schema for using a consumable"""
item_code: str # 'skip', 'shield', 'boost', 'reroll'
item_code: str # 'skip', 'boost', 'wild_card', 'lucky_dice', 'copycat', 'undo'
marathon_id: int
assignment_id: int | None = None # Required for skip and reroll
assignment_id: int | None = None # Required for skip, wild_card, copycat
game_id: int | None = None # Required for wild_card
target_participant_id: int | None = None # Required for copycat
class UseConsumableResponse(BaseModel):
@@ -192,9 +194,13 @@ class ConsumablesStatusResponse(BaseModel):
skips_available: int # From inventory
skips_used: int # In this marathon
skips_remaining: int | None # Based on marathon limit
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)
has_active_boost: bool # Currently activated (one-time for current assignment)
boost_multiplier: float | None # 1.5 if boost active
rerolls_available: int # From inventory
wild_cards_available: int # From inventory
lucky_dice_available: int # From inventory
has_lucky_dice: bool # Currently activated
lucky_dice_multiplier: float | None # Rolled multiplier if active
copycats_available: int # From inventory
undos_available: int # From inventory
can_undo: bool # Has drop data to undo

View File

@@ -14,19 +14,19 @@ class CoinsService:
# Coins awarded per challenge difficulty (only in certified marathons)
CHALLENGE_COINS = {
Difficulty.EASY.value: 5,
Difficulty.MEDIUM.value: 12,
Difficulty.HARD.value: 25,
Difficulty.EASY.value: 10,
Difficulty.MEDIUM.value: 20,
Difficulty.HARD.value: 35,
}
# Coins for playthrough = points * this ratio
PLAYTHROUGH_COIN_RATIO = 0.05 # 5% of points
PLAYTHROUGH_COIN_RATIO = 0.10 # 10% of points
# Coins awarded for marathon placements
MARATHON_PLACE_COINS = {
1: 100, # 1st place
2: 50, # 2nd place
3: 30, # 3rd place
1: 500, # 1st place
2: 250, # 2nd place
3: 150, # 3rd place
}
# Bonus coins for Common Enemy event winners

View File

@@ -1,15 +1,25 @@
"""
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
Consumables Service - handles consumable items usage
Consumables:
- skip: Skip current assignment without penalty
- boost: x1.5 multiplier for current assignment
- wild_card: Choose a game, get random challenge from it
- lucky_dice: Random multiplier (0.5, 1.0, 1.5, 2.0, 2.5, 3.0)
- copycat: Copy another participant's assignment
- undo: Restore points and streak from last drop
"""
import random
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
User, Participant, Marathon, Assignment, AssignmentStatus,
ShopItem, UserInventory, ConsumableUsage, ConsumableType
ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge,
BonusAssignment
)
@@ -19,6 +29,9 @@ class ConsumablesService:
# Boost settings
BOOST_MULTIPLIER = 1.5
# Lucky Dice multipliers (equal probability, starts from 1.5x)
LUCKY_DICE_MULTIPLIERS = [1.5, 2.0, 2.5, 3.0, 3.5, 4.0]
async def use_skip(
self,
db: AsyncSession,
@@ -85,53 +98,6 @@ class ConsumablesService:
"streak_preserved": True,
}
async def use_shield(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Activate a Shield - protects from next drop penalty.
- Next drop will not cause point penalty
- Streak is preserved on next drop
Returns: dict with result info
Raises:
HTTPException: If consumables not allowed or shield already active
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_shield:
raise HTTPException(status_code=400, detail="Shield is already active")
# Consume shield from inventory
item = await self._consume_item(db, user, ConsumableType.SHIELD.value)
# Activate shield
participant.has_shield = True
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "shield",
"activated": True,
},
)
db.add(usage)
return {
"success": True,
"shield_activated": True,
}
async def use_boost(
self,
db: AsyncSession,
@@ -140,10 +106,10 @@ class ConsumablesService:
marathon: Marathon,
) -> dict:
"""
Activate a Boost - multiplies points for NEXT complete only.
Activate a Boost - multiplies points for current assignment on complete.
- Points for next completed challenge are multiplied by BOOST_MULTIPLIER
- One-time use (consumed on next complete)
- Points for completed challenge are multiplied by BOOST_MULTIPLIER
- One-time use (consumed on complete)
Returns: dict with result info
@@ -181,41 +147,71 @@ class ConsumablesService:
"multiplier": self.BOOST_MULTIPLIER,
}
async def use_reroll(
async def use_wild_card(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
game_id: int,
) -> dict:
"""
Use a Reroll - discard current assignment and spin again.
Use Wild Card - choose a game and get a random challenge from it.
- Current assignment is cancelled (not dropped)
- User can spin the wheel again
- No penalty
- Current assignment is replaced
- New challenge is randomly selected from the chosen game
- Game must be in the marathon
Returns: dict with result info
Returns: dict with new assignment info
Raises:
HTTPException: If consumables not allowed or assignment not active
HTTPException: If game not in marathon or no challenges available
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only reroll active assignments")
raise HTTPException(status_code=400, detail="Can only use wild card on active assignments")
# Consume reroll from inventory
item = await self._consume_item(db, user, ConsumableType.REROLL.value)
# Verify game is in this marathon
result = await db.execute(
select(Game)
.where(
Game.id == game_id,
Game.marathon_id == marathon.id,
)
)
game = result.scalar_one_or_none()
# Cancel current assignment
old_challenge_id = assignment.challenge_id
if not game:
raise HTTPException(status_code=400, detail="Game not found in this marathon")
# Get random challenge from this game
result = await db.execute(
select(Challenge)
.where(Challenge.game_id == game_id)
.order_by(func.random())
.limit(1)
)
new_challenge = result.scalar_one_or_none()
if not new_challenge:
raise HTTPException(status_code=400, detail="No challenges available for this game")
# Consume wild card from inventory
item = await self._consume_item(db, user, ConsumableType.WILD_CARD.value)
# Store old assignment info for logging
old_game_id = assignment.game_id
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Note: We do NOT increase drop_count (this is a reroll, not a real drop)
old_challenge_id = assignment.challenge_id
# Update assignment with new challenge
assignment.game_id = game_id
assignment.challenge_id = new_challenge.id
# Reset timestamps since it's a new challenge
assignment.started_at = datetime.utcnow()
assignment.deadline = None # Will be recalculated if needed
# Log usage
usage = ConsumableUsage(
@@ -224,17 +220,275 @@ class ConsumablesService:
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "reroll",
"rerolled_from_challenge_id": old_challenge_id,
"rerolled_from_game_id": old_game_id,
"type": "wild_card",
"old_game_id": old_game_id,
"old_challenge_id": old_challenge_id,
"new_game_id": game_id,
"new_challenge_id": new_challenge.id,
},
)
db.add(usage)
return {
"success": True,
"rerolled": True,
"can_spin_again": True,
"game_id": game_id,
"game_name": game.name,
"challenge_id": new_challenge.id,
"challenge_title": new_challenge.title,
}
async def use_lucky_dice(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Use Lucky Dice - get a random multiplier for current assignment.
- Random multiplier from [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
- Applied on next complete (stacks with boost if both active)
- One-time use
Returns: dict with rolled multiplier
Raises:
HTTPException: If consumables not allowed or lucky dice already active
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_lucky_dice:
raise HTTPException(status_code=400, detail="Lucky Dice is already active")
# Consume lucky dice from inventory
item = await self._consume_item(db, user, ConsumableType.LUCKY_DICE.value)
# Roll the dice
multiplier = random.choice(self.LUCKY_DICE_MULTIPLIERS)
# Activate lucky dice
participant.has_lucky_dice = True
participant.lucky_dice_multiplier = multiplier
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "lucky_dice",
"multiplier": multiplier,
},
)
db.add(usage)
return {
"success": True,
"lucky_dice_activated": True,
"multiplier": multiplier,
}
async def use_copycat(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
target_participant_id: int,
) -> dict:
"""
Use Copycat - copy another participant's assignment.
- Current assignment is replaced with target's current/last assignment
- Can copy even if target already completed theirs
- Cannot copy your own assignment
Returns: dict with copied assignment info
Raises:
HTTPException: If target not found or no assignment to copy
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only use copycat on active assignments")
if target_participant_id == participant.id:
raise HTTPException(status_code=400, detail="Cannot copy your own assignment")
# Find target participant
result = await db.execute(
select(Participant)
.where(
Participant.id == target_participant_id,
Participant.marathon_id == marathon.id,
)
)
target_participant = result.scalar_one_or_none()
if not target_participant:
raise HTTPException(status_code=400, detail="Target participant not found")
# Get target's most recent assignment (active or completed)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge),
selectinload(Assignment.game).selectinload(Game.challenges),
)
.where(
Assignment.participant_id == target_participant_id,
Assignment.status.in_([
AssignmentStatus.ACTIVE.value,
AssignmentStatus.COMPLETED.value
])
)
.order_by(Assignment.started_at.desc())
.limit(1)
)
target_assignment = result.scalar_one_or_none()
if not target_assignment:
raise HTTPException(status_code=400, detail="Target has no assignment to copy")
# Consume copycat from inventory
item = await self._consume_item(db, user, ConsumableType.COPYCAT.value)
# Store old assignment info for logging
old_game_id = assignment.game_id
old_challenge_id = assignment.challenge_id
old_is_playthrough = assignment.is_playthrough
# Copy the assignment - handle both challenge and playthrough
assignment.game_id = target_assignment.game_id
assignment.challenge_id = target_assignment.challenge_id
assignment.is_playthrough = target_assignment.is_playthrough
# Reset timestamps
assignment.started_at = datetime.utcnow()
assignment.deadline = None
# If copying a playthrough, recreate bonus assignments
if target_assignment.is_playthrough:
# Delete existing bonus assignments
for ba in assignment.bonus_assignments:
await db.delete(ba)
# Create new bonus assignments from target game's challenges
if target_assignment.game and target_assignment.game.challenges:
for ch in target_assignment.game.challenges:
bonus = BonusAssignment(
main_assignment_id=assignment.id,
challenge_id=ch.id,
)
db.add(bonus)
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "copycat",
"old_challenge_id": old_challenge_id,
"old_game_id": old_game_id,
"old_is_playthrough": old_is_playthrough,
"copied_from_participant_id": target_participant_id,
"new_challenge_id": target_assignment.challenge_id,
"new_game_id": target_assignment.game_id,
"new_is_playthrough": target_assignment.is_playthrough,
},
)
db.add(usage)
# Prepare response
if target_assignment.is_playthrough:
title = f"Прохождение: {target_assignment.game.title}" if target_assignment.game else "Прохождение"
else:
title = target_assignment.challenge.title if target_assignment.challenge else None
return {
"success": True,
"copied": True,
"game_id": target_assignment.game_id,
"challenge_id": target_assignment.challenge_id,
"is_playthrough": target_assignment.is_playthrough,
"challenge_title": title,
}
async def use_undo(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Use Undo - restore points and streak from last drop.
- Only works if there was a drop in this marathon
- Can only undo once per drop
- Restores both points and streak
Returns: dict with restored values
Raises:
HTTPException: If no drop to undo
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if not participant.can_undo:
raise HTTPException(status_code=400, detail="No drop to undo")
if participant.last_drop_points is None or participant.last_drop_streak_before is None:
raise HTTPException(status_code=400, detail="No drop data to restore")
# Consume undo from inventory
item = await self._consume_item(db, user, ConsumableType.UNDO.value)
# Store values for logging
points_restored = participant.last_drop_points
streak_restored = participant.last_drop_streak_before
current_points = participant.total_points
current_streak = participant.current_streak
# Restore points and streak
participant.total_points += points_restored
participant.current_streak = streak_restored
participant.drop_count = max(0, participant.drop_count - 1)
# Clear undo data
participant.can_undo = False
participant.last_drop_points = None
participant.last_drop_streak_before = None
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "undo",
"points_restored": points_restored,
"streak_restored_to": streak_restored,
"points_before": current_points,
"streak_before": current_streak,
},
)
db.add(usage)
return {
"success": True,
"undone": True,
"points_restored": points_restored,
"streak_restored": streak_restored,
"new_total_points": participant.total_points,
"new_streak": participant.current_streak,
}
async def _consume_item(
@@ -292,17 +546,6 @@ class ConsumablesService:
quantity = result.scalar_one_or_none()
return quantity or 0
def consume_shield(self, participant: Participant) -> bool:
"""
Consume shield when dropping (called from wheel.py).
Returns: True if shield was consumed, False otherwise
"""
if participant.has_shield:
participant.has_shield = False
return True
return False
def consume_boost_on_complete(self, participant: Participant) -> float:
"""
Consume boost when completing assignment (called from wheel.py).
@@ -315,6 +558,33 @@ class ConsumablesService:
return self.BOOST_MULTIPLIER
return 1.0
def consume_lucky_dice_on_complete(self, participant: Participant) -> float:
"""
Consume lucky dice when completing assignment (called from wheel.py).
One-time use - consumed after single complete.
Returns: Multiplier value (rolled multiplier if active, 1.0 otherwise)
"""
if participant.has_lucky_dice and participant.lucky_dice_multiplier is not None:
multiplier = participant.lucky_dice_multiplier
participant.has_lucky_dice = False
participant.lucky_dice_multiplier = None
return multiplier
return 1.0
def save_drop_for_undo(
self,
participant: Participant,
points_lost: int,
streak_before: int,
) -> None:
"""
Save drop data for potential undo (called from wheel.py before dropping).
"""
participant.last_drop_points = points_lost
participant.last_drop_streak_before = streak_before
participant.can_undo = True
# Singleton instance
consumables_service = ConsumablesService()

View File

@@ -37,6 +37,7 @@ import {
AdminLogsPage,
AdminBroadcastPage,
AdminContentPage,
AdminPromoCodesPage,
} from '@/pages/admin'
// Protected route wrapper
@@ -229,6 +230,7 @@ function App() {
<Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="marathons" element={<AdminMarathonsPage />} />
<Route path="promo" element={<AdminPromoCodesPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="broadcast" element={<AdminBroadcastPage />} />
<Route path="content" element={<AdminContentPage />} />

View File

@@ -10,3 +10,4 @@ export { assignmentsApi } from './assignments'
export { usersApi } from './users'
export { telegramApi } from './telegram'
export { shopApi } from './shop'
export { promoApi } from './promo'

34
frontend/src/api/promo.ts Normal file
View File

@@ -0,0 +1,34 @@
import client from './client'
import type {
PromoCode,
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeRedemption,
PromoCodeRedeemResponse,
} from '@/types'
export const promoApi = {
// User endpoint - redeem promo code
redeem: (code: string) =>
client.post<PromoCodeRedeemResponse>('/promo/redeem', { code }),
// Admin endpoints
admin: {
list: (includeInactive = false) =>
client.get<PromoCode[]>('/promo/admin/list', {
params: { include_inactive: includeInactive },
}),
create: (data: PromoCodeCreate) =>
client.post<PromoCode>('/promo/admin/create', data),
update: (id: number, data: PromoCodeUpdate) =>
client.put<PromoCode>(`/promo/admin/${id}`, data),
delete: (id: number) =>
client.delete<{ message: string }>(`/promo/admin/${id}`),
getRedemptions: (id: number) =>
client.get<PromoCodeRedemption[]>(`/promo/admin/${id}/redemptions`),
},
}

View File

@@ -10,6 +10,7 @@ import type {
CoinTransaction,
ConsumablesStatus,
UserCosmetics,
SwapCandidate,
} from '@/types'
export const shopApi = {
@@ -84,6 +85,12 @@ export const shopApi = {
return response.data
},
// Получить кандидатов для Copycat (участники с активными заданиями)
getCopycatCandidates: async (marathonId: number): Promise<SwapCandidate[]> => {
const response = await client.get<SwapCandidate[]>(`/shop/copycat-candidates/${marathonId}`)
return response.data
},
// === Монеты ===
// Получить баланс и последние транзакции

View File

@@ -5,7 +5,7 @@ import { useToast } from '@/store/toast'
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
import {
Loader2, Package, ShoppingBag, Coins, Check,
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward
Frame, Type, Palette, Image, Zap, SkipForward, Shuffle, Dice5, Copy, Undo2
} from 'lucide-react'
import type { InventoryItem, ShopItemType } from '@/types'
import { RARITY_COLORS, RARITY_NAMES, ITEM_TYPE_NAMES } from '@/types'
@@ -13,9 +13,11 @@ import clsx from 'clsx'
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
skip: <SkipForward className="w-8 h-8" />,
shield: <Shield className="w-8 h-8" />,
boost: <Zap className="w-8 h-8" />,
reroll: <RefreshCw className="w-8 h-8" />,
wild_card: <Shuffle className="w-8 h-8" />,
lucky_dice: <Dice5 className="w-8 h-8" />,
copycat: <Copy className="w-8 h-8" />,
undo: <Undo2 className="w-8 h-8" />,
}
interface InventoryItemCardProps {

View File

@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
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, Shield, RefreshCw, SkipForward, Package } from 'lucide-react'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, SkipForward, Package, Dice5, Copy, Undo2, Shuffle } from 'lucide-react'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { useShopStore } from '@/store/shop'
@@ -494,45 +494,6 @@ export function PlayPage() {
}
}
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')
@@ -541,7 +502,7 @@ export function PlayPage() {
item_code: 'boost',
marathon_id: parseInt(id),
})
toast.success('Boost активирован! x1.5 очков за следующее выполнение.')
toast.success('Boost активирован! x1.5 очков за текущее задание.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
@@ -552,6 +513,119 @@ export function PlayPage() {
}
}
// Wild Card modal state
const [showWildCardModal, setShowWildCardModal] = useState(false)
const handleUseWildCard = async (gameId: number) => {
if (!currentAssignment || !id) return
setIsUsingConsumable('wild_card')
try {
const result = await shopApi.useConsumable({
item_code: 'wild_card',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
game_id: gameId,
})
toast.success(result.effect_description)
setShowWildCardModal(false)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Wild Card')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseLuckyDice = async () => {
if (!id) return
setIsUsingConsumable('lucky_dice')
try {
const result = await shopApi.useConsumable({
item_code: 'lucky_dice',
marathon_id: parseInt(id),
})
const multiplier = result.effect_data?.multiplier as number
toast.success(`Lucky Dice: x${multiplier} множитель!`)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Lucky Dice')
} finally {
setIsUsingConsumable(null)
}
}
// Copycat modal state
const [showCopycatModal, setShowCopycatModal] = useState(false)
const [copycatCandidates, setCopycatCandidates] = useState<SwapCandidate[]>([])
const [isLoadingCopycatCandidates, setIsLoadingCopycatCandidates] = useState(false)
const loadCopycatCandidates = async () => {
if (!id) return
setIsLoadingCopycatCandidates(true)
try {
const candidates = await shopApi.getCopycatCandidates(parseInt(id))
setCopycatCandidates(candidates)
} catch (error) {
console.error('Failed to load copycat candidates:', error)
} finally {
setIsLoadingCopycatCandidates(false)
}
}
const handleUseCopycat = async (targetParticipantId: number) => {
if (!currentAssignment || !id) return
setIsUsingConsumable('copycat')
try {
const result = await shopApi.useConsumable({
item_code: 'copycat',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
target_participant_id: targetParticipantId,
})
toast.success(result.effect_description)
setShowCopycatModal(false)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Copycat')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseUndo = async () => {
if (!id) return
const confirmed = await confirm({
title: 'Использовать Undo?',
message: 'Это вернёт очки и серию от последнего пропуска.',
confirmText: 'Использовать',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setIsUsingConsumable('undo')
try {
const result = await shopApi.useConsumable({
item_code: 'undo',
marathon_id: parseInt(id),
})
toast.success(result.effect_description)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Undo')
} finally {
setIsUsingConsumable(null)
}
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
@@ -710,18 +784,18 @@ export function PlayPage() {
</div>
{/* Active effects */}
{(consumablesStatus.has_shield || consumablesStatus.has_active_boost) && (
{(consumablesStatus.has_active_boost || consumablesStatus.has_lucky_dice) && (
<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)
<Zap className="w-3 h-3" /> Boost x1.5
</span>
)}
{consumablesStatus.has_lucky_dice && (
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 text-xs rounded-lg border border-purple-500/30 flex items-center gap-1">
<Dice5 className="w-3 h-3" /> Lucky Dice x{consumablesStatus.lucky_dice_multiplier}
</span>
)}
</div>
@@ -752,52 +826,6 @@ export function PlayPage() {
</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">
@@ -821,10 +849,180 @@ export function PlayPage() {
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
</NeonButton>
</div>
{/* Wild Card */}
<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">
<Shuffle className="w-4 h-4 text-green-400" />
<span className="text-white font-medium">Wild Card</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.wild_cards_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Выбрать игру</p>
<NeonButton
size="sm"
variant="outline"
onClick={() => setShowWildCardModal(true)}
disabled={consumablesStatus.wild_cards_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'wild_card'}
className="w-full"
>
Выбрать
</NeonButton>
</div>
{/* Lucky Dice */}
<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">
<Dice5 className="w-4 h-4 text-purple-400" />
<span className="text-white font-medium">Lucky Dice</span>
</div>
<span className="text-gray-400 text-sm">
{consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : `${consumablesStatus.lucky_dice_available} шт.`}
</span>
</div>
<p className="text-gray-500 text-xs mb-2">Случайный множитель</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseLuckyDice}
disabled={consumablesStatus.has_lucky_dice || consumablesStatus.lucky_dice_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'lucky_dice'}
className="w-full"
>
{consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : 'Бросить'}
</NeonButton>
</div>
{/* Copycat */}
<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">
<Copy className="w-4 h-4 text-cyan-400" />
<span className="text-white font-medium">Copycat</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.copycats_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Скопировать задание</p>
<NeonButton
size="sm"
variant="outline"
onClick={() => {
setShowCopycatModal(true)
loadCopycatCandidates()
}}
disabled={consumablesStatus.copycats_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'copycat'}
className="w-full"
>
Выбрать
</NeonButton>
</div>
{/* Undo */}
<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">
<Undo2 className="w-4 h-4 text-red-400" />
<span className="text-white font-medium">Undo</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.undos_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Отменить дроп</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseUndo}
disabled={!consumablesStatus.can_undo || consumablesStatus.undos_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'undo'}
className="w-full"
>
{consumablesStatus.can_undo ? 'Отменить' : 'Нет дропа'}
</NeonButton>
</div>
</div>
</GlassCard>
)}
{/* Wild Card Modal */}
{showWildCardModal && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<GlassCard className="w-full max-w-md max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-white">Выберите игру</h3>
<button
onClick={() => setShowWildCardModal(false)}
className="p-2 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
Вы получите случайное задание из выбранной игры
</p>
<div className="space-y-2">
{games.map((game) => (
<button
key={game.id}
onClick={() => handleUseWildCard(game.id)}
disabled={isUsingConsumable === 'wild_card'}
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-green-500/30 disabled:opacity-50"
>
<p className="text-white font-medium">{game.title}</p>
<p className="text-gray-400 text-xs">{game.challenges_count} челленджей</p>
</button>
))}
</div>
</GlassCard>
</div>
)}
{/* Copycat Modal */}
{showCopycatModal && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<GlassCard className="w-full max-w-md max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-white">Скопировать задание</h3>
<button
onClick={() => setShowCopycatModal(false)}
className="p-2 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
Выберите участника, чьё задание хотите скопировать
</p>
{isLoadingCopycatCandidates ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
</div>
) : copycatCandidates.length === 0 ? (
<p className="text-center text-gray-500 py-8">Нет доступных заданий для копирования</p>
) : (
<div className="space-y-2">
{copycatCandidates.map((candidate) => (
<button
key={candidate.participant_id}
onClick={() => handleUseCopycat(candidate.participant_id)}
disabled={isUsingConsumable === 'copycat'}
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-cyan-500/30 disabled:opacity-50"
>
<p className="text-white font-medium">{candidate.user.nickname}</p>
<p className="text-cyan-400 text-sm">{candidate.challenge_title}</p>
<p className="text-gray-500 text-xs">
{candidate.game_title} {candidate.challenge_points} очков {candidate.challenge_difficulty}
</p>
</button>
))}
</div>
)}
</GlassCard>
</div>
)}
{/* Tabs for Common Enemy event */}
{activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6">

View File

@@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { usersApi, telegramApi, authApi } from '@/api'
import { usersApi, telegramApi, authApi, promoApi } from '@/api'
import type { UserStats, ShopItemPublic } from '@/types'
import { useToast } from '@/store/toast'
import {
@@ -14,7 +14,7 @@ import {
User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
AlertTriangle, FileCheck, Backpack, Edit3
AlertTriangle, FileCheck, Backpack, Edit3, Gift
} from 'lucide-react'
import clsx from 'clsx'
@@ -289,6 +289,10 @@ export function ProfilePage() {
const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true)
const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null)
// Promo code state
const [promoCode, setPromoCode] = useState('')
const [isRedeemingPromo, setIsRedeemingPromo] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Forms
@@ -526,6 +530,27 @@ export function ProfilePage() {
}
}
// Redeem promo code
const handleRedeemPromo = async (e: React.FormEvent) => {
e.preventDefault()
if (!promoCode.trim()) return
setIsRedeemingPromo(true)
try {
const response = await promoApi.redeem(promoCode.trim())
toast.success(response.data.message)
setPromoCode('')
// Update coin balance in store
updateUser({ coins_balance: response.data.new_balance })
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
const message = err.response?.data?.detail || 'Не удалось активировать промокод'
toast.error(message)
} finally {
setIsRedeemingPromo(false)
}
}
const isLinked = !!user?.telegram_id
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
@@ -773,6 +798,37 @@ export function ProfilePage() {
)}
</div>
{/* Promo Code */}
<GlassCard>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Gift className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Промокод</h2>
<p className="text-sm text-gray-400">Введите код для получения монет</p>
</div>
</div>
<form onSubmit={handleRedeemPromo} className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Введите промокод"
value={promoCode}
onChange={(e) => setPromoCode(e.target.value.toUpperCase())}
maxLength={50}
/>
</div>
<NeonButton
type="submit"
isLoading={isRedeemingPromo}
disabled={!promoCode.trim()}
icon={<Gift className="w-4 h-4" />}
>
Активировать
</NeonButton>
</form>
</GlassCard>
{/* Telegram */}
<GlassCard>
<div className="flex items-center gap-3 mb-6">

View File

@@ -6,8 +6,8 @@ import { useConfirm } from '@/store/confirm'
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
import {
Loader2, Coins, ShoppingBag, Package, Sparkles,
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward,
Minus, Plus
Frame, Type, Palette, Image, Zap, SkipForward,
Minus, Plus, Shuffle, Dice5, Copy, Undo2
} from 'lucide-react'
import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
import { RARITY_COLORS, RARITY_NAMES } from '@/types'
@@ -23,9 +23,11 @@ const ITEM_TYPE_ICONS: Record<ShopItemType, React.ReactNode> = {
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
skip: <SkipForward className="w-8 h-8" />,
shield: <Shield className="w-8 h-8" />,
boost: <Zap className="w-8 h-8" />,
reroll: <RefreshCw className="w-8 h-8" />,
wild_card: <Shuffle className="w-8 h-8" />,
lucky_dice: <Dice5 className="w-8 h-8" />,
copycat: <Copy className="w-8 h-8" />,
undo: <Undo2 className="w-8 h-8" />,
}
interface ShopItemCardProps {
@@ -176,7 +178,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
className={clsx(
'p-4 border transition-all duration-300',
rarityColors.border,
item.is_owned && 'opacity-60'
item.is_owned && !isConsumable && 'opacity-60'
)}
>
{/* Rarity badge */}
@@ -196,7 +198,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
</p>
{/* Quantity selector for consumables */}
{isConsumable && !item.is_owned && item.is_available && (
{isConsumable && item.is_available && (
<div className="flex items-center justify-center gap-2 mb-3">
<button
onClick={decrementQuantity}
@@ -236,7 +238,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
) : (
<NeonButton
size="sm"
onClick={() => onPurchase(item, quantity)}
onClick={() => onPurchase(item, isConsumable ? quantity : 1)}
disabled={isPurchasing || !item.is_available}
>
{isPurchasing ? (
@@ -460,9 +462,9 @@ export function ShopPage() {
</h3>
<ul className="text-gray-400 text-sm space-y-1">
<li> Выполняй задания в <span className="text-neon-400">сертифицированных</span> марафонах</li>
<li> Easy задание 5 монет, Medium 12 монет, Hard 25 монет</li>
<li> Playthrough ~5% от заработанных очков</li>
<li> Топ-3 места в марафоне: 1-е 100, 2-е 50, 3-е 30 монет</li>
<li> Easy задание 10 монет, Medium 20 монет, Hard 35 монет</li>
<li> Playthrough ~10% от заработанных очков</li>
<li> Топ-3 места в марафоне: 1-е 500, 2-е 250, 3-е 150 монет</li>
</ul>
</GlassCard>
</div>

View File

@@ -12,13 +12,15 @@ import {
Shield,
MessageCircle,
Sparkles,
Lock
Lock,
Gift
} from 'lucide-react'
const navItems = [
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
{ to: '/admin/promo', icon: Gift, label: 'Промокоды' },
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
{ to: '/admin/content', icon: FileText, label: 'Контент' },

View File

@@ -0,0 +1,681 @@
import { useState, useEffect, useCallback } from 'react'
import { promoApi } from '@/api'
import type { PromoCode, PromoCodeCreate, PromoCodeRedemption } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { NeonButton, Input, GlassCard } from '@/components/ui'
import {
Gift, Plus, Trash2, Edit, Users, Copy, Check, X,
Eye, Loader2, Coins
} from 'lucide-react'
import clsx from 'clsx'
// Format date for display
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// Format date for input
function formatDateForInput(dateStr: string | null): string {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toISOString().slice(0, 16)
}
export function AdminPromoCodesPage() {
const [promoCodes, setPromoCodes] = useState<PromoCode[]>([])
const [loading, setLoading] = useState(true)
const [includeInactive, setIncludeInactive] = useState(false)
// Create modal state
const [showCreateModal, setShowCreateModal] = useState(false)
const [createData, setCreateData] = useState<PromoCodeCreate>({
code: '',
coins_amount: 100,
max_uses: null,
valid_from: null,
valid_until: null,
})
const [autoGenerate, setAutoGenerate] = useState(true)
const [creating, setCreating] = useState(false)
// Edit modal state
const [editingPromo, setEditingPromo] = useState<PromoCode | null>(null)
const [editData, setEditData] = useState({
is_active: true,
max_uses: null as number | null,
valid_until: '',
})
const [saving, setSaving] = useState(false)
// Redemptions modal state
const [viewingRedemptions, setViewingRedemptions] = useState<PromoCode | null>(null)
const [redemptions, setRedemptions] = useState<PromoCodeRedemption[]>([])
const [loadingRedemptions, setLoadingRedemptions] = useState(false)
// Copied state for code
const [copiedCode, setCopiedCode] = useState<string | null>(null)
const toast = useToast()
const confirm = useConfirm()
const loadPromoCodes = useCallback(async () => {
setLoading(true)
try {
const response = await promoApi.admin.list(includeInactive)
setPromoCodes(response.data)
} catch {
toast.error('Ошибка загрузки промокодов')
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [includeInactive])
useEffect(() => {
loadPromoCodes()
}, [loadPromoCodes])
const handleCreate = async () => {
if (!autoGenerate && !createData.code?.trim()) {
toast.error('Введите код или включите автогенерацию')
return
}
if (createData.coins_amount < 1) {
toast.error('Количество монет должно быть больше 0')
return
}
setCreating(true)
try {
const response = await promoApi.admin.create({
...createData,
code: autoGenerate ? null : createData.code,
})
setPromoCodes([response.data, ...promoCodes])
toast.success(`Промокод ${response.data.code} создан`)
setShowCreateModal(false)
resetCreateForm()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
toast.error(err.response?.data?.detail || 'Ошибка создания промокода')
} finally {
setCreating(false)
}
}
const resetCreateForm = () => {
setCreateData({
code: '',
coins_amount: 100,
max_uses: null,
valid_from: null,
valid_until: null,
})
setAutoGenerate(true)
}
const handleEdit = (promo: PromoCode) => {
setEditingPromo(promo)
setEditData({
is_active: promo.is_active,
max_uses: promo.max_uses,
valid_until: formatDateForInput(promo.valid_until),
})
}
const handleSaveEdit = async () => {
if (!editingPromo) return
setSaving(true)
try {
const response = await promoApi.admin.update(editingPromo.id, {
is_active: editData.is_active,
max_uses: editData.max_uses,
valid_until: editData.valid_until ? new Date(editData.valid_until).toISOString() : null,
})
setPromoCodes(promoCodes.map(p => p.id === response.data.id ? response.data : p))
toast.success('Промокод обновлён')
setEditingPromo(null)
} catch {
toast.error('Ошибка обновления промокода')
} finally {
setSaving(false)
}
}
const handleDelete = async (promo: PromoCode) => {
const confirmed = await confirm({
title: 'Удалить промокод',
message: `Вы уверены, что хотите удалить промокод ${promo.code}?`,
confirmText: 'Удалить',
variant: 'danger',
})
if (!confirmed) return
try {
await promoApi.admin.delete(promo.id)
setPromoCodes(promoCodes.filter(p => p.id !== promo.id))
toast.success('Промокод удалён')
} catch {
toast.error('Ошибка удаления промокода')
}
}
const handleToggleActive = async (promo: PromoCode) => {
try {
const response = await promoApi.admin.update(promo.id, {
is_active: !promo.is_active,
})
setPromoCodes(promoCodes.map(p => p.id === response.data.id ? response.data : p))
toast.success(response.data.is_active ? 'Промокод активирован' : 'Промокод деактивирован')
} catch {
toast.error('Ошибка обновления промокода')
}
}
const handleViewRedemptions = async (promo: PromoCode) => {
setViewingRedemptions(promo)
setLoadingRedemptions(true)
try {
const response = await promoApi.admin.getRedemptions(promo.id)
setRedemptions(response.data)
} catch {
toast.error('Ошибка загрузки использований')
} finally {
setLoadingRedemptions(false)
}
}
const handleCopyCode = (code: string) => {
navigator.clipboard.writeText(code)
setCopiedCode(code)
setTimeout(() => setCopiedCode(null), 2000)
}
const isPromoValid = (promo: PromoCode): boolean => {
if (!promo.is_active) return false
const now = new Date()
if (promo.valid_from && new Date(promo.valid_from) > now) return false
if (promo.valid_until && new Date(promo.valid_until) < now) return false
if (promo.max_uses !== null && promo.uses_count >= promo.max_uses) return false
return true
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-yellow-500/20 border border-yellow-500/30">
<Gift className="w-6 h-6 text-yellow-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Промокоды</h1>
<p className="text-sm text-gray-400">Управление промокодами на монеты</p>
</div>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={includeInactive}
onChange={(e) => setIncludeInactive(e.target.checked)}
className="rounded border-dark-600 bg-dark-700 text-neon-500 focus:ring-neon-500"
/>
Показать неактивные
</label>
<NeonButton
onClick={() => setShowCreateModal(true)}
icon={<Plus className="w-4 h-4" />}
>
Создать
</NeonButton>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<GlassCard className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Gift className="w-5 h-5 text-yellow-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{promoCodes.length}</p>
<p className="text-sm text-gray-400">Всего кодов</p>
</div>
</div>
</GlassCard>
<GlassCard className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<Check className="w-5 h-5 text-green-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">
{promoCodes.filter(p => isPromoValid(p)).length}
</p>
<p className="text-sm text-gray-400">Активных</p>
</div>
</div>
</GlassCard>
<GlassCard className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Users className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">
{promoCodes.reduce((sum, p) => sum + p.uses_count, 0)}
</p>
<p className="text-sm text-gray-400">Использований</p>
</div>
</div>
</GlassCard>
<GlassCard className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Coins className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">
{promoCodes.reduce((sum, p) => sum + p.coins_amount * p.uses_count, 0)}
</p>
<p className="text-sm text-gray-400">Выдано монет</p>
</div>
</div>
</GlassCard>
</div>
{/* Table */}
<GlassCard className="overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
</div>
) : promoCodes.length === 0 ? (
<div className="text-center py-12">
<Gift className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">Промокоды не найдены</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left p-4 text-sm font-medium text-gray-400">Код</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Монет</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Лимит</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Использований</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Срок</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Статус</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Создан</th>
<th className="text-right p-4 text-sm font-medium text-gray-400">Действия</th>
</tr>
</thead>
<tbody>
{promoCodes.map((promo) => (
<tr key={promo.id} className="border-b border-dark-600/50 hover:bg-dark-700/30">
<td className="p-4">
<div className="flex items-center gap-2">
<code className="px-2 py-1 bg-dark-700 rounded text-yellow-400 font-mono text-sm">
{promo.code}
</code>
<button
onClick={() => handleCopyCode(promo.code)}
className="p-1 text-gray-400 hover:text-white transition-colors"
title="Копировать"
>
{copiedCode === promo.code ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</td>
<td className="p-4">
<span className="text-white font-medium">{promo.coins_amount}</span>
</td>
<td className="p-4">
<span className="text-gray-400">
{promo.max_uses !== null ? promo.max_uses : '∞'}
</span>
</td>
<td className="p-4">
<button
onClick={() => handleViewRedemptions(promo)}
className="text-neon-400 hover:text-neon-300 transition-colors"
>
{promo.uses_count}
</button>
</td>
<td className="p-4 text-sm text-gray-400">
{promo.valid_until ? formatDate(promo.valid_until) : '—'}
</td>
<td className="p-4">
{isPromoValid(promo) ? (
<span className="px-2 py-1 rounded-full bg-green-500/20 text-green-400 text-xs">
Активен
</span>
) : (
<span className="px-2 py-1 rounded-full bg-red-500/20 text-red-400 text-xs">
Неактивен
</span>
)}
</td>
<td className="p-4 text-sm text-gray-400">
{formatDate(promo.created_at)}
</td>
<td className="p-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleToggleActive(promo)}
className={clsx(
'p-1.5 rounded-lg transition-colors',
promo.is_active
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
: 'bg-gray-500/20 text-gray-400 hover:bg-gray-500/30'
)}
title={promo.is_active ? 'Деактивировать' : 'Активировать'}
>
{promo.is_active ? <Check className="w-4 h-4" /> : <X className="w-4 h-4" />}
</button>
<button
onClick={() => handleViewRedemptions(promo)}
className="p-1.5 rounded-lg bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
title="Использования"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(promo)}
className="p-1.5 rounded-lg bg-neon-500/20 text-neon-400 hover:bg-neon-500/30 transition-colors"
title="Редактировать"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(promo)}
className="p-1.5 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</GlassCard>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-dark-900/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-2xl p-6 w-full max-w-md border border-dark-600 shadow-xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">Создать промокод</h2>
<button
onClick={() => {
setShowCreateModal(false)
resetCreateForm()
}}
className="p-1 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{/* Auto generate toggle */}
<label className="flex items-center gap-3 p-3 bg-dark-700/50 rounded-xl cursor-pointer">
<input
type="checkbox"
checked={autoGenerate}
onChange={(e) => setAutoGenerate(e.target.checked)}
className="rounded border-dark-600 bg-dark-700 text-neon-500 focus:ring-neon-500"
/>
<div>
<p className="text-white font-medium">Автогенерация кода</p>
<p className="text-sm text-gray-400">Создать случайный код</p>
</div>
</label>
{/* Manual code input */}
{!autoGenerate && (
<div>
<label className="block text-sm text-gray-400 mb-1">Код</label>
<Input
placeholder="Введите код (A-Z, 0-9)"
value={createData.code || ''}
onChange={(e) => setCreateData({ ...createData, code: e.target.value.toUpperCase() })}
/>
</div>
)}
{/* Coins amount */}
<div>
<label className="block text-sm text-gray-400 mb-1">Количество монет</label>
<Input
type="number"
min={1}
max={100000}
value={createData.coins_amount}
onChange={(e) => setCreateData({ ...createData, coins_amount: parseInt(e.target.value) || 0 })}
/>
</div>
{/* Max uses */}
<div>
<label className="block text-sm text-gray-400 mb-1">Лимит использований (пусто = безлимит)</label>
<Input
type="number"
min={1}
placeholder="Без ограничений"
value={createData.max_uses || ''}
onChange={(e) => setCreateData({ ...createData, max_uses: e.target.value ? parseInt(e.target.value) : null })}
/>
</div>
{/* Valid until */}
<div>
<label className="block text-sm text-gray-400 mb-1">Действует до (пусто = бессрочно)</label>
<Input
type="datetime-local"
value={createData.valid_until || ''}
onChange={(e) => setCreateData({ ...createData, valid_until: e.target.value || null })}
/>
</div>
<div className="flex gap-3 pt-2">
<NeonButton
onClick={handleCreate}
isLoading={creating}
className="flex-1"
icon={<Plus className="w-4 h-4" />}
>
Создать
</NeonButton>
<NeonButton
variant="ghost"
onClick={() => {
setShowCreateModal(false)
resetCreateForm()
}}
>
Отмена
</NeonButton>
</div>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingPromo && (
<div className="fixed inset-0 bg-dark-900/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-2xl p-6 w-full max-w-md border border-dark-600 shadow-xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">Редактировать промокод</h2>
<button
onClick={() => setEditingPromo(null)}
className="p-1 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{/* Code display */}
<div className="p-3 bg-dark-700/50 rounded-xl">
<p className="text-sm text-gray-400 mb-1">Код</p>
<code className="text-yellow-400 font-mono text-lg">{editingPromo.code}</code>
</div>
{/* Active toggle */}
<label className="flex items-center justify-between p-3 bg-dark-700/50 rounded-xl cursor-pointer">
<div>
<p className="text-white font-medium">Активен</p>
<p className="text-sm text-gray-400">Код можно использовать</p>
</div>
<input
type="checkbox"
checked={editData.is_active}
onChange={(e) => setEditData({ ...editData, is_active: e.target.checked })}
className="rounded border-dark-600 bg-dark-700 text-neon-500 focus:ring-neon-500 w-5 h-5"
/>
</label>
{/* Max uses */}
<div>
<label className="block text-sm text-gray-400 mb-1">Лимит использований</label>
<Input
type="number"
min={1}
placeholder="Без ограничений"
value={editData.max_uses || ''}
onChange={(e) => setEditData({ ...editData, max_uses: e.target.value ? parseInt(e.target.value) : null })}
/>
</div>
{/* Valid until */}
<div>
<label className="block text-sm text-gray-400 mb-1">Действует до</label>
<Input
type="datetime-local"
value={editData.valid_until}
onChange={(e) => setEditData({ ...editData, valid_until: e.target.value })}
/>
</div>
<div className="flex gap-3 pt-2">
<NeonButton
onClick={handleSaveEdit}
isLoading={saving}
className="flex-1"
icon={<Check className="w-4 h-4" />}
>
Сохранить
</NeonButton>
<NeonButton
variant="ghost"
onClick={() => setEditingPromo(null)}
>
Отмена
</NeonButton>
</div>
</div>
</div>
</div>
)}
{/* Redemptions Modal */}
{viewingRedemptions && (
<div className="fixed inset-0 bg-dark-900/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-2xl p-6 w-full max-w-lg border border-dark-600 shadow-xl max-h-[80vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-white">Использования промокода</h2>
<p className="text-sm text-gray-400">
<code className="text-yellow-400">{viewingRedemptions.code}</code>
{' • '}
{viewingRedemptions.uses_count} использований
</p>
</div>
<button
onClick={() => {
setViewingRedemptions(null)
setRedemptions([])
}}
className="p-1 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{loadingRedemptions ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
</div>
) : redemptions.length === 0 ? (
<div className="text-center py-12">
<Users className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">Пока никто не использовал этот код</p>
</div>
) : (
<div className="space-y-2">
{redemptions.map((r) => (
<div
key={r.id}
className="flex items-center justify-between p-3 bg-dark-700/50 rounded-xl"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
<Users className="w-4 h-4 text-neon-400" />
</div>
<div>
<p className="text-white font-medium">{r.user.nickname}</p>
<p className="text-xs text-gray-400">{formatDate(r.redeemed_at)}</p>
</div>
</div>
<div className="text-yellow-400 font-medium">
+{r.coins_awarded}
</div>
</div>
))}
</div>
)}
</div>
<div className="pt-4 border-t border-dark-600 mt-4">
<NeonButton
variant="ghost"
onClick={() => {
setViewingRedemptions(null)
setRedemptions([])
}}
className="w-full"
>
Закрыть
</NeonButton>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -5,3 +5,4 @@ export { AdminMarathonsPage } from './AdminMarathonsPage'
export { AdminLogsPage } from './AdminLogsPage'
export { AdminBroadcastPage } from './AdminBroadcastPage'
export { AdminContentPage } from './AdminContentPage'
export { AdminPromoCodesPage } from './AdminPromoCodesPage'

View File

@@ -718,7 +718,7 @@ export interface PasswordChangeData {
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
export type ConsumableType = 'skip' | 'shield' | 'boost' | 'reroll'
export type ConsumableType = 'skip' | 'boost' | 'wild_card' | 'lucky_dice' | 'copycat' | 'undo'
export interface ShopItemPublic {
id: number
@@ -776,6 +776,8 @@ export interface UseConsumableRequest {
item_code: ConsumableType
marathon_id: number
assignment_id?: number
game_id?: number // Required for wild_card
target_participant_id?: number // Required for copycat
}
export interface UseConsumableResponse {
@@ -805,12 +807,16 @@ 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
rerolls_available: number
wild_cards_available: number
lucky_dice_available: number
has_lucky_dice: boolean
lucky_dice_multiplier: number | null
copycats_available: number
undos_available: number
can_undo: boolean
}
export interface UserCosmetics {
@@ -857,3 +863,49 @@ export const ITEM_TYPE_NAMES: Record<ShopItemType, string> = {
background: 'Фон профиля',
consumable: 'Расходуемое',
}
// === Promo Code types ===
export interface PromoCode {
id: number
code: string
coins_amount: number
max_uses: number | null
uses_count: number
is_active: boolean
valid_from: string | null
valid_until: string | null
created_at: string
created_by_nickname: string | null
}
export interface PromoCodeCreate {
code?: string | null // null = auto-generate
coins_amount: number
max_uses?: number | null
valid_from?: string | null
valid_until?: string | null
}
export interface PromoCodeUpdate {
is_active?: boolean
max_uses?: number | null
valid_until?: string | null
}
export interface PromoCodeRedemption {
id: number
user: {
id: number
nickname: string
}
coins_awarded: number
redeemed_at: string
}
export interface PromoCodeRedeemResponse {
success: boolean
coins_awarded: number
new_balance: number
message: string
}