Compare commits
2 Commits
2874b64481
...
e63d6c8489
| Author | SHA1 | Date | |
|---|---|---|---|
| e63d6c8489 | |||
| 1751c4dd4c |
@@ -450,13 +450,13 @@ def upgrade() -> None:
|
|||||||
'item_type': 'consumable',
|
'item_type': 'consumable',
|
||||||
'code': 'boost',
|
'code': 'boost',
|
||||||
'name': 'Буст x1.5',
|
'name': 'Буст x1.5',
|
||||||
'description': 'Множитель очков x1.5 на следующие 2 часа',
|
'description': 'Множитель очков x1.5 на текущее задание',
|
||||||
'price': 200,
|
'price': 200,
|
||||||
'rarity': 'rare',
|
'rarity': 'rare',
|
||||||
'asset_data': {
|
'asset_data': {
|
||||||
'effect': 'boost',
|
'effect': 'boost',
|
||||||
'multiplier': 1.5,
|
'multiplier': 1.5,
|
||||||
'duration_hours': 2,
|
'one_time': True,
|
||||||
'icon': 'zap'
|
'icon': 'zap'
|
||||||
},
|
},
|
||||||
'is_active': True,
|
'is_active': True,
|
||||||
|
|||||||
46
backend/alembic/versions/026_update_boost_description.py
Normal file
46
backend/alembic/versions/026_update_boost_description.py
Normal 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'
|
||||||
|
""")
|
||||||
83
backend/alembic/versions/027_consumables_redesign.py
Normal file
83
backend/alembic/versions/027_consumables_redesign.py
Normal 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}')
|
||||||
|
""")
|
||||||
58
backend/alembic/versions/028_add_promo_codes.py
Normal file
58
backend/alembic/versions/028_add_promo_codes.py
Normal 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')
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
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")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -17,3 +17,4 @@ router.include_router(assignments.router)
|
|||||||
router.include_router(telegram.router)
|
router.include_router(telegram.router)
|
||||||
router.include_router(content.router)
|
router.include_router(content.router)
|
||||||
router.include_router(shop.router)
|
router.include_router(shop.router)
|
||||||
|
router.include_router(promo.router)
|
||||||
|
|||||||
299
backend/app/api/v1/promo.py
Normal file
299
backend/app/api/v1/promo.py
Normal 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
|
||||||
|
]
|
||||||
@@ -10,7 +10,7 @@ from app.api.deps import CurrentUser, DbSession, require_participant, require_ad
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
User, Marathon, Participant, Assignment, AssignmentStatus,
|
User, Marathon, Participant, Assignment, AssignmentStatus,
|
||||||
ShopItem, UserInventory, CoinTransaction, ShopItemType,
|
ShopItem, UserInventory, CoinTransaction, ShopItemType,
|
||||||
CertificationStatus,
|
CertificationStatus, Challenge, Game,
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
|
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
|
||||||
@@ -19,8 +19,9 @@ from app.schemas import (
|
|||||||
EquipItemRequest, EquipItemResponse,
|
EquipItemRequest, EquipItemResponse,
|
||||||
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
|
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
|
||||||
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
|
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
|
||||||
ConsumablesStatusResponse, MessageResponse,
|
ConsumablesStatusResponse, MessageResponse, SwapCandidate,
|
||||||
)
|
)
|
||||||
|
from app.schemas.user import UserPublic
|
||||||
from app.services.shop import shop_service
|
from app.services.shop import shop_service
|
||||||
from app.services.coins import coins_service
|
from app.services.coins import coins_service
|
||||||
from app.services.consumables import consumables_service
|
from app.services.consumables import consumables_service
|
||||||
@@ -181,18 +182,29 @@ async def use_consumable(
|
|||||||
# Get participant
|
# Get participant
|
||||||
participant = await require_participant(db, current_user.id, data.marathon_id)
|
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
|
assignment = None
|
||||||
if data.item_code in ["skip", "reroll"]:
|
if data.item_code in ["skip", "wild_card", "copycat"]:
|
||||||
if not data.assignment_id:
|
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(
|
# For copycat, we need bonus_assignments to properly handle playthrough
|
||||||
select(Assignment).where(
|
if data.item_code == "copycat":
|
||||||
Assignment.id == data.assignment_id,
|
result = await db.execute(
|
||||||
Assignment.participant_id == participant.id,
|
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()
|
assignment = result.scalar_one_or_none()
|
||||||
if not assignment:
|
if not assignment:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
@@ -201,15 +213,29 @@ async def use_consumable(
|
|||||||
if data.item_code == "skip":
|
if data.item_code == "skip":
|
||||||
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
|
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
|
||||||
effect_description = "Assignment skipped without penalty"
|
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":
|
elif data.item_code == "boost":
|
||||||
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
|
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
|
||||||
effect_description = f"Boost x{effect['multiplier']} activated for next complete"
|
effect_description = f"Boost x{effect['multiplier']} activated for current assignment"
|
||||||
elif data.item_code == "reroll":
|
elif data.item_code == "wild_card":
|
||||||
effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment)
|
if data.game_id is None:
|
||||||
effect_description = "Assignment rerolled - you can spin again"
|
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:
|
else:
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
|
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
|
# Get inventory counts for all consumables
|
||||||
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
|
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
|
||||||
shields_available = await consumables_service.get_consumable_count(db, current_user.id, "shield")
|
|
||||||
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
|
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
|
# Calculate remaining skips for this marathon
|
||||||
skips_remaining = None
|
skips_remaining = None
|
||||||
@@ -256,15 +284,104 @@ async def get_consumables_status(
|
|||||||
skips_available=skips_available,
|
skips_available=skips_available,
|
||||||
skips_used=participant.skips_used,
|
skips_used=participant.skips_used,
|
||||||
skips_remaining=skips_remaining,
|
skips_remaining=skips_remaining,
|
||||||
shields_available=shields_available,
|
|
||||||
has_shield=participant.has_shield,
|
|
||||||
boosts_available=boosts_available,
|
boosts_available=boosts_available,
|
||||||
has_active_boost=participant.has_active_boost,
|
has_active_boost=participant.has_active_boost,
|
||||||
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
|
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 ===
|
# === Coins ===
|
||||||
|
|
||||||
@router.get("/balance", response_model=CoinsBalanceResponse)
|
@router.get("/balance", response_model=CoinsBalanceResponse)
|
||||||
|
|||||||
@@ -621,10 +621,12 @@ async def complete_assignment(
|
|||||||
if ba.status == BonusAssignmentStatus.COMPLETED.value:
|
if ba.status == BonusAssignmentStatus.COMPLETED.value:
|
||||||
ba.points_earned = int(ba.challenge.points * multiplier)
|
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)
|
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
|
||||||
if boost_multiplier > 1.0:
|
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
|
||||||
total_points = int(total_points * boost_multiplier)
|
combined_multiplier = boost_multiplier * lucky_dice_multiplier
|
||||||
|
if combined_multiplier != 1.0:
|
||||||
|
total_points = int(total_points * combined_multiplier)
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.COMPLETED.value
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
@@ -666,6 +668,8 @@ async def complete_assignment(
|
|||||||
activity_data["is_redo"] = True
|
activity_data["is_redo"] = True
|
||||||
if boost_multiplier > 1.0:
|
if boost_multiplier > 1.0:
|
||||||
activity_data["boost_multiplier"] = boost_multiplier
|
activity_data["boost_multiplier"] = boost_multiplier
|
||||||
|
if lucky_dice_multiplier != 1.0:
|
||||||
|
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
|
||||||
if coins_earned > 0:
|
if coins_earned > 0:
|
||||||
activity_data["coins_earned"] = coins_earned
|
activity_data["coins_earned"] = coins_earned
|
||||||
if playthrough_event:
|
if playthrough_event:
|
||||||
@@ -728,10 +732,12 @@ async def complete_assignment(
|
|||||||
total_points += common_enemy_bonus
|
total_points += common_enemy_bonus
|
||||||
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
|
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
|
||||||
|
|
||||||
# Apply boost multiplier from consumable
|
# Apply boost and lucky dice multipliers from consumables
|
||||||
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
|
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
|
||||||
if boost_multiplier > 1.0:
|
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
|
||||||
total_points = int(total_points * boost_multiplier)
|
combined_multiplier = boost_multiplier * lucky_dice_multiplier
|
||||||
|
if combined_multiplier != 1.0:
|
||||||
|
total_points = int(total_points * combined_multiplier)
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.COMPLETED.value
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
@@ -772,6 +778,8 @@ async def complete_assignment(
|
|||||||
activity_data["is_redo"] = True
|
activity_data["is_redo"] = True
|
||||||
if boost_multiplier > 1.0:
|
if boost_multiplier > 1.0:
|
||||||
activity_data["boost_multiplier"] = boost_multiplier
|
activity_data["boost_multiplier"] = boost_multiplier
|
||||||
|
if lucky_dice_multiplier != 1.0:
|
||||||
|
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
|
||||||
if coins_earned > 0:
|
if coins_earned > 0:
|
||||||
activity_data["coins_earned"] = coins_earned
|
activity_data["coins_earned"] = coins_earned
|
||||||
if assignment.event_type == EventType.JACKPOT.value:
|
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
|
participant.drop_count, game.playthrough_points, playthrough_event
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for shield - if active, no penalty
|
# Save drop data for potential undo
|
||||||
shield_used = False
|
consumables_service.save_drop_for_undo(
|
||||||
if consumables_service.consume_shield(participant):
|
participant, penalty, participant.current_streak
|
||||||
penalty = 0
|
)
|
||||||
shield_used = True
|
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.DROPPED.value
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
@@ -921,8 +928,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
|||||||
"penalty": penalty,
|
"penalty": penalty,
|
||||||
"lost_bonuses": completed_bonuses_count,
|
"lost_bonuses": completed_bonuses_count,
|
||||||
}
|
}
|
||||||
if shield_used:
|
|
||||||
activity_data["shield_used"] = True
|
|
||||||
if playthrough_event:
|
if playthrough_event:
|
||||||
activity_data["event_type"] = playthrough_event.type
|
activity_data["event_type"] = playthrough_event.type
|
||||||
activity_data["free_drop"] = True
|
activity_data["free_drop"] = True
|
||||||
@@ -941,7 +946,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
|||||||
penalty=penalty,
|
penalty=penalty,
|
||||||
total_points=participant.total_points,
|
total_points=participant.total_points,
|
||||||
new_drop_count=participant.drop_count,
|
new_drop_count=participant.drop_count,
|
||||||
shield_used=shield_used,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regular challenge drop
|
# 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)
|
# Calculate penalty (0 if double_risk event is active)
|
||||||
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
|
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
|
||||||
|
|
||||||
# Check for shield - if active, no penalty
|
# Save drop data for potential undo
|
||||||
shield_used = False
|
consumables_service.save_drop_for_undo(
|
||||||
if consumables_service.consume_shield(participant):
|
participant, penalty, participant.current_streak
|
||||||
penalty = 0
|
)
|
||||||
shield_used = True
|
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.DROPPED.value
|
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,
|
"difficulty": assignment.challenge.difficulty,
|
||||||
"penalty": penalty,
|
"penalty": penalty,
|
||||||
}
|
}
|
||||||
if shield_used:
|
|
||||||
activity_data["shield_used"] = True
|
|
||||||
if active_event:
|
if active_event:
|
||||||
activity_data["event_type"] = active_event.type
|
activity_data["event_type"] = active_event.type
|
||||||
if active_event.type == EventType.DOUBLE_RISK.value:
|
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,
|
penalty=penalty,
|
||||||
total_points=participant.total_points,
|
total_points=participant.total_points,
|
||||||
new_drop_count=participant.drop_count,
|
new_drop_count=participant.drop_count,
|
||||||
shield_used=shield_used,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType
|
|||||||
from app.models.inventory import UserInventory
|
from app.models.inventory import UserInventory
|
||||||
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
|
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
|
||||||
from app.models.consumable_usage import ConsumableUsage
|
from app.models.consumable_usage import ConsumableUsage
|
||||||
|
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -62,4 +63,6 @@ __all__ = [
|
|||||||
"CoinTransaction",
|
"CoinTransaction",
|
||||||
"CoinTransactionType",
|
"CoinTransactionType",
|
||||||
"ConsumableUsage",
|
"ConsumableUsage",
|
||||||
|
"PromoCode",
|
||||||
|
"PromoCodeRedemption",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CoinTransactionType(str, Enum):
|
|||||||
REFUND = "refund"
|
REFUND = "refund"
|
||||||
ADMIN_GRANT = "admin_grant"
|
ADMIN_GRANT = "admin_grant"
|
||||||
ADMIN_DEDUCT = "admin_deduct"
|
ADMIN_DEDUCT = "admin_deduct"
|
||||||
|
PROMO_CODE = "promo_code"
|
||||||
|
|
||||||
|
|
||||||
class CoinTransaction(Base):
|
class CoinTransaction(Base):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean, Float
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -32,7 +32,15 @@ class Participant(Base):
|
|||||||
# Shop: consumables state
|
# Shop: consumables state
|
||||||
skips_used: Mapped[int] = mapped_column(Integer, default=0)
|
skips_used: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
|
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
|
# Relationships
|
||||||
user: Mapped["User"] = relationship("User", back_populates="participations")
|
user: Mapped["User"] = relationship("User", back_populates="participations")
|
||||||
|
|||||||
67
backend/app/models/promo_code.py
Normal file
67
backend/app/models/promo_code.py
Normal 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")
|
||||||
@@ -28,9 +28,11 @@ class ItemRarity(str, Enum):
|
|||||||
|
|
||||||
class ConsumableType(str, Enum):
|
class ConsumableType(str, Enum):
|
||||||
SKIP = "skip"
|
SKIP = "skip"
|
||||||
SHIELD = "shield"
|
|
||||||
BOOST = "boost"
|
BOOST = "boost"
|
||||||
REROLL = "reroll"
|
WILD_CARD = "wild_card"
|
||||||
|
LUCKY_DICE = "lucky_dice"
|
||||||
|
COPYCAT = "copycat"
|
||||||
|
UNDO = "undo"
|
||||||
|
|
||||||
|
|
||||||
class ShopItem(Base):
|
class ShopItem(Base):
|
||||||
|
|||||||
@@ -124,6 +124,15 @@ from app.schemas.shop import (
|
|||||||
CertificationStatusResponse,
|
CertificationStatusResponse,
|
||||||
ConsumablesStatusResponse,
|
ConsumablesStatusResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.promo_code import (
|
||||||
|
PromoCodeCreate,
|
||||||
|
PromoCodeUpdate,
|
||||||
|
PromoCodeResponse,
|
||||||
|
PromoCodeRedeemRequest,
|
||||||
|
PromoCodeRedeemResponse,
|
||||||
|
PromoCodeRedemptionResponse,
|
||||||
|
PromoCodeRedemptionUser,
|
||||||
|
)
|
||||||
from app.schemas.user import ShopItemPublic
|
from app.schemas.user import ShopItemPublic
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -243,4 +252,12 @@ __all__ = [
|
|||||||
"CertificationReviewRequest",
|
"CertificationReviewRequest",
|
||||||
"CertificationStatusResponse",
|
"CertificationStatusResponse",
|
||||||
"ConsumablesStatusResponse",
|
"ConsumablesStatusResponse",
|
||||||
|
# Promo
|
||||||
|
"PromoCodeCreate",
|
||||||
|
"PromoCodeUpdate",
|
||||||
|
"PromoCodeResponse",
|
||||||
|
"PromoCodeRedeemRequest",
|
||||||
|
"PromoCodeRedeemResponse",
|
||||||
|
"PromoCodeRedemptionResponse",
|
||||||
|
"PromoCodeRedemptionUser",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ class DropResult(BaseModel):
|
|||||||
penalty: int
|
penalty: int
|
||||||
total_points: int
|
total_points: int
|
||||||
new_drop_count: int
|
new_drop_count: int
|
||||||
shield_used: bool = False # Whether shield consumable was used to prevent penalty
|
|
||||||
|
|
||||||
|
|
||||||
class EventAssignmentResponse(BaseModel):
|
class EventAssignmentResponse(BaseModel):
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ class ParticipantInfo(BaseModel):
|
|||||||
# Shop: coins and consumables status
|
# Shop: coins and consumables status
|
||||||
coins_earned: int = 0
|
coins_earned: int = 0
|
||||||
skips_used: int = 0
|
skips_used: int = 0
|
||||||
has_shield: bool = False
|
|
||||||
has_active_boost: bool = False
|
has_active_boost: bool = False
|
||||||
boost_multiplier: float | None = None
|
has_lucky_dice: bool = False
|
||||||
boost_expires_at: datetime | None = None
|
lucky_dice_multiplier: float | None = None
|
||||||
|
can_undo: bool = False
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
74
backend/app/schemas/promo_code.py
Normal file
74
backend/app/schemas/promo_code.py
Normal 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
|
||||||
@@ -94,9 +94,11 @@ class PurchaseResponse(BaseModel):
|
|||||||
|
|
||||||
class UseConsumableRequest(BaseModel):
|
class UseConsumableRequest(BaseModel):
|
||||||
"""Schema for using a consumable"""
|
"""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
|
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):
|
class UseConsumableResponse(BaseModel):
|
||||||
@@ -192,9 +194,13 @@ class ConsumablesStatusResponse(BaseModel):
|
|||||||
skips_available: int # From inventory
|
skips_available: int # From inventory
|
||||||
skips_used: int # In this marathon
|
skips_used: int # In this marathon
|
||||||
skips_remaining: int | None # Based on marathon limit
|
skips_remaining: int | None # Based on marathon limit
|
||||||
shields_available: int # From inventory
|
|
||||||
has_shield: bool # Currently activated
|
|
||||||
boosts_available: int # From inventory
|
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
|
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
|
||||||
|
|||||||
@@ -14,19 +14,19 @@ class CoinsService:
|
|||||||
|
|
||||||
# Coins awarded per challenge difficulty (only in certified marathons)
|
# Coins awarded per challenge difficulty (only in certified marathons)
|
||||||
CHALLENGE_COINS = {
|
CHALLENGE_COINS = {
|
||||||
Difficulty.EASY.value: 5,
|
Difficulty.EASY.value: 10,
|
||||||
Difficulty.MEDIUM.value: 12,
|
Difficulty.MEDIUM.value: 20,
|
||||||
Difficulty.HARD.value: 25,
|
Difficulty.HARD.value: 35,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Coins for playthrough = points * this ratio
|
# 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
|
# Coins awarded for marathon placements
|
||||||
MARATHON_PLACE_COINS = {
|
MARATHON_PLACE_COINS = {
|
||||||
1: 100, # 1st place
|
1: 500, # 1st place
|
||||||
2: 50, # 2nd place
|
2: 250, # 2nd place
|
||||||
3: 30, # 3rd place
|
3: 150, # 3rd place
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bonus coins for Common Enemy event winners
|
# Bonus coins for Common Enemy event winners
|
||||||
|
|||||||
@@ -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 datetime import datetime
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
User, Participant, Marathon, Assignment, AssignmentStatus,
|
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 settings
|
||||||
BOOST_MULTIPLIER = 1.5
|
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(
|
async def use_skip(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -85,53 +98,6 @@ class ConsumablesService:
|
|||||||
"streak_preserved": True,
|
"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(
|
async def use_boost(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -140,10 +106,10 @@ class ConsumablesService:
|
|||||||
marathon: Marathon,
|
marathon: Marathon,
|
||||||
) -> dict:
|
) -> 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
|
- Points for completed challenge are multiplied by BOOST_MULTIPLIER
|
||||||
- One-time use (consumed on next complete)
|
- One-time use (consumed on complete)
|
||||||
|
|
||||||
Returns: dict with result info
|
Returns: dict with result info
|
||||||
|
|
||||||
@@ -181,41 +147,71 @@ class ConsumablesService:
|
|||||||
"multiplier": self.BOOST_MULTIPLIER,
|
"multiplier": self.BOOST_MULTIPLIER,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def use_reroll(
|
async def use_wild_card(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user: User,
|
user: User,
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
marathon: Marathon,
|
marathon: Marathon,
|
||||||
assignment: Assignment,
|
assignment: Assignment,
|
||||||
|
game_id: int,
|
||||||
) -> dict:
|
) -> 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)
|
- Current assignment is replaced
|
||||||
- User can spin the wheel again
|
- New challenge is randomly selected from the chosen game
|
||||||
- No penalty
|
- Game must be in the marathon
|
||||||
|
|
||||||
Returns: dict with result info
|
Returns: dict with new assignment info
|
||||||
|
|
||||||
Raises:
|
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:
|
if not marathon.allow_consumables:
|
||||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||||
|
|
||||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
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
|
# Verify game is in this marathon
|
||||||
item = await self._consume_item(db, user, ConsumableType.REROLL.value)
|
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
|
if not game:
|
||||||
old_challenge_id = assignment.challenge_id
|
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
|
old_game_id = assignment.game_id
|
||||||
assignment.status = AssignmentStatus.DROPPED.value
|
old_challenge_id = assignment.challenge_id
|
||||||
assignment.completed_at = datetime.utcnow()
|
|
||||||
# Note: We do NOT increase drop_count (this is a reroll, not a real drop)
|
# 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
|
# Log usage
|
||||||
usage = ConsumableUsage(
|
usage = ConsumableUsage(
|
||||||
@@ -224,17 +220,275 @@ class ConsumablesService:
|
|||||||
marathon_id=marathon.id,
|
marathon_id=marathon.id,
|
||||||
assignment_id=assignment.id,
|
assignment_id=assignment.id,
|
||||||
effect_data={
|
effect_data={
|
||||||
"type": "reroll",
|
"type": "wild_card",
|
||||||
"rerolled_from_challenge_id": old_challenge_id,
|
"old_game_id": old_game_id,
|
||||||
"rerolled_from_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)
|
db.add(usage)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"rerolled": True,
|
"game_id": game_id,
|
||||||
"can_spin_again": True,
|
"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(
|
async def _consume_item(
|
||||||
@@ -292,17 +546,6 @@ class ConsumablesService:
|
|||||||
quantity = result.scalar_one_or_none()
|
quantity = result.scalar_one_or_none()
|
||||||
return quantity or 0
|
return quantity or 0
|
||||||
|
|
||||||
def consume_shield(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:
|
def consume_boost_on_complete(self, participant: Participant) -> float:
|
||||||
"""
|
"""
|
||||||
Consume boost when completing assignment (called from wheel.py).
|
Consume boost when completing assignment (called from wheel.py).
|
||||||
@@ -315,6 +558,33 @@ class ConsumablesService:
|
|||||||
return self.BOOST_MULTIPLIER
|
return self.BOOST_MULTIPLIER
|
||||||
return 1.0
|
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
|
# Singleton instance
|
||||||
consumables_service = ConsumablesService()
|
consumables_service = ConsumablesService()
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
AdminLogsPage,
|
AdminLogsPage,
|
||||||
AdminBroadcastPage,
|
AdminBroadcastPage,
|
||||||
AdminContentPage,
|
AdminContentPage,
|
||||||
|
AdminPromoCodesPage,
|
||||||
} from '@/pages/admin'
|
} from '@/pages/admin'
|
||||||
|
|
||||||
// Protected route wrapper
|
// Protected route wrapper
|
||||||
@@ -229,6 +230,7 @@ function App() {
|
|||||||
<Route index element={<AdminDashboardPage />} />
|
<Route index element={<AdminDashboardPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||||
|
<Route path="promo" element={<AdminPromoCodesPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||||
<Route path="content" element={<AdminContentPage />} />
|
<Route path="content" element={<AdminContentPage />} />
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export { assignmentsApi } from './assignments'
|
|||||||
export { usersApi } from './users'
|
export { usersApi } from './users'
|
||||||
export { telegramApi } from './telegram'
|
export { telegramApi } from './telegram'
|
||||||
export { shopApi } from './shop'
|
export { shopApi } from './shop'
|
||||||
|
export { promoApi } from './promo'
|
||||||
|
|||||||
34
frontend/src/api/promo.ts
Normal file
34
frontend/src/api/promo.ts
Normal 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`),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
CoinTransaction,
|
CoinTransaction,
|
||||||
ConsumablesStatus,
|
ConsumablesStatus,
|
||||||
UserCosmetics,
|
UserCosmetics,
|
||||||
|
SwapCandidate,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
export const shopApi = {
|
export const shopApi = {
|
||||||
@@ -84,6 +85,12 @@ export const shopApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Получить кандидатов для Copycat (участники с активными заданиями)
|
||||||
|
getCopycatCandidates: async (marathonId: number): Promise<SwapCandidate[]> => {
|
||||||
|
const response = await client.get<SwapCandidate[]>(`/shop/copycat-candidates/${marathonId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// === Монеты ===
|
// === Монеты ===
|
||||||
|
|
||||||
// Получить баланс и последние транзакции
|
// Получить баланс и последние транзакции
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useToast } from '@/store/toast'
|
|||||||
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||||
import {
|
import {
|
||||||
Loader2, Package, ShoppingBag, Coins, Check,
|
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'
|
} from 'lucide-react'
|
||||||
import type { InventoryItem, ShopItemType } from '@/types'
|
import type { InventoryItem, ShopItemType } from '@/types'
|
||||||
import { RARITY_COLORS, RARITY_NAMES, ITEM_TYPE_NAMES } 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> = {
|
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||||
skip: <SkipForward className="w-8 h-8" />,
|
skip: <SkipForward className="w-8 h-8" />,
|
||||||
shield: <Shield className="w-8 h-8" />,
|
|
||||||
boost: <Zap 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 {
|
interface InventoryItemCardProps {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
|
|||||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||||
import { SpinWheel } from '@/components/SpinWheel'
|
import { SpinWheel } from '@/components/SpinWheel'
|
||||||
import { EventBanner } from '@/components/EventBanner'
|
import { EventBanner } from '@/components/EventBanner'
|
||||||
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, 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 { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
import { useShopStore } from '@/store/shop'
|
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 () => {
|
const handleUseBoost = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
setIsUsingConsumable('boost')
|
setIsUsingConsumable('boost')
|
||||||
@@ -541,7 +502,7 @@ export function PlayPage() {
|
|||||||
item_code: 'boost',
|
item_code: 'boost',
|
||||||
marathon_id: parseInt(id),
|
marathon_id: parseInt(id),
|
||||||
})
|
})
|
||||||
toast.success('Boost активирован! x1.5 очков за следующее выполнение.')
|
toast.success('Boost активирован! x1.5 очков за текущее задание.')
|
||||||
await loadData()
|
await loadData()
|
||||||
useShopStore.getState().loadBalance()
|
useShopStore.getState().loadBalance()
|
||||||
} catch (err: unknown) {
|
} 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-24">
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
@@ -710,18 +784,18 @@ export function PlayPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active effects */}
|
{/* 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">
|
<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>
|
<p className="text-green-400 text-sm font-medium mb-2">Активные эффекты:</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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 && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -752,52 +826,6 @@ export function PlayPage() {
|
|||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</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 */}
|
{/* Boost */}
|
||||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
<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 justify-between mb-2">
|
||||||
@@ -821,10 +849,180 @@ export function PlayPage() {
|
|||||||
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
|
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</GlassCard>
|
</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 */}
|
{/* Tabs for Common Enemy event */}
|
||||||
{activeEvent?.event?.type === 'common_enemy' && (
|
{activeEvent?.event?.type === 'common_enemy' && (
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
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 type { UserStats, ShopItemPublic } from '@/types'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
User, Camera, Trophy, Target, CheckCircle, Flame,
|
User, Camera, Trophy, Target, CheckCircle, Flame,
|
||||||
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
||||||
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
|
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
|
||||||
AlertTriangle, FileCheck, Backpack, Edit3
|
AlertTriangle, FileCheck, Backpack, Edit3, Gift
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@@ -289,6 +289,10 @@ export function ProfilePage() {
|
|||||||
const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true)
|
const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true)
|
||||||
const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null)
|
const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Promo code state
|
||||||
|
const [promoCode, setPromoCode] = useState('')
|
||||||
|
const [isRedeemingPromo, setIsRedeemingPromo] = useState(false)
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Forms
|
// 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 isLinked = !!user?.telegram_id
|
||||||
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
||||||
|
|
||||||
@@ -773,6 +798,37 @@ export function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Telegram */}
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useConfirm } from '@/store/confirm'
|
|||||||
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||||
import {
|
import {
|
||||||
Loader2, Coins, ShoppingBag, Package, Sparkles,
|
Loader2, Coins, ShoppingBag, Package, Sparkles,
|
||||||
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward,
|
Frame, Type, Palette, Image, Zap, SkipForward,
|
||||||
Minus, Plus
|
Minus, Plus, Shuffle, Dice5, Copy, Undo2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
|
import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
|
||||||
import { RARITY_COLORS, RARITY_NAMES } 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> = {
|
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||||
skip: <SkipForward className="w-8 h-8" />,
|
skip: <SkipForward className="w-8 h-8" />,
|
||||||
shield: <Shield className="w-8 h-8" />,
|
|
||||||
boost: <Zap 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 {
|
interface ShopItemCardProps {
|
||||||
@@ -176,7 +178,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'p-4 border transition-all duration-300',
|
'p-4 border transition-all duration-300',
|
||||||
rarityColors.border,
|
rarityColors.border,
|
||||||
item.is_owned && 'opacity-60'
|
item.is_owned && !isConsumable && 'opacity-60'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Rarity badge */}
|
{/* Rarity badge */}
|
||||||
@@ -196,7 +198,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Quantity selector for consumables */}
|
{/* 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">
|
<div className="flex items-center justify-center gap-2 mb-3">
|
||||||
<button
|
<button
|
||||||
onClick={decrementQuantity}
|
onClick={decrementQuantity}
|
||||||
@@ -236,7 +238,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
|
|||||||
) : (
|
) : (
|
||||||
<NeonButton
|
<NeonButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onPurchase(item, quantity)}
|
onClick={() => onPurchase(item, isConsumable ? quantity : 1)}
|
||||||
disabled={isPurchasing || !item.is_available}
|
disabled={isPurchasing || !item.is_available}
|
||||||
>
|
>
|
||||||
{isPurchasing ? (
|
{isPurchasing ? (
|
||||||
@@ -460,9 +462,9 @@ export function ShopPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
<ul className="text-gray-400 text-sm space-y-1">
|
<ul className="text-gray-400 text-sm space-y-1">
|
||||||
<li>• Выполняй задания в <span className="text-neon-400">сертифицированных</span> марафонах</li>
|
<li>• Выполняй задания в <span className="text-neon-400">сертифицированных</span> марафонах</li>
|
||||||
<li>• Easy задание — 5 монет, Medium — 12 монет, Hard — 25 монет</li>
|
<li>• Easy задание — 10 монет, Medium — 20 монет, Hard — 35 монет</li>
|
||||||
<li>• Playthrough — ~5% от заработанных очков</li>
|
<li>• Playthrough — ~10% от заработанных очков</li>
|
||||||
<li>• Топ-3 места в марафоне: 1-е — 100, 2-е — 50, 3-е — 30 монет</li>
|
<li>• Топ-3 места в марафоне: 1-е — 500, 2-е — 250, 3-е — 150 монет</li>
|
||||||
</ul>
|
</ul>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Lock
|
Lock,
|
||||||
|
Gift
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
||||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||||
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
||||||
|
{ to: '/admin/promo', icon: Gift, label: 'Промокоды' },
|
||||||
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
||||||
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
||||||
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
||||||
|
|||||||
681
frontend/src/pages/admin/AdminPromoCodesPage.tsx
Normal file
681
frontend/src/pages/admin/AdminPromoCodesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ export { AdminMarathonsPage } from './AdminMarathonsPage'
|
|||||||
export { AdminLogsPage } from './AdminLogsPage'
|
export { AdminLogsPage } from './AdminLogsPage'
|
||||||
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
||||||
export { AdminContentPage } from './AdminContentPage'
|
export { AdminContentPage } from './AdminContentPage'
|
||||||
|
export { AdminPromoCodesPage } from './AdminPromoCodesPage'
|
||||||
|
|||||||
@@ -718,7 +718,7 @@ export interface PasswordChangeData {
|
|||||||
|
|
||||||
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
|
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
|
||||||
export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
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 {
|
export interface ShopItemPublic {
|
||||||
id: number
|
id: number
|
||||||
@@ -776,6 +776,8 @@ export interface UseConsumableRequest {
|
|||||||
item_code: ConsumableType
|
item_code: ConsumableType
|
||||||
marathon_id: number
|
marathon_id: number
|
||||||
assignment_id?: number
|
assignment_id?: number
|
||||||
|
game_id?: number // Required for wild_card
|
||||||
|
target_participant_id?: number // Required for copycat
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseConsumableResponse {
|
export interface UseConsumableResponse {
|
||||||
@@ -805,12 +807,16 @@ export interface ConsumablesStatus {
|
|||||||
skips_available: number
|
skips_available: number
|
||||||
skips_used: number
|
skips_used: number
|
||||||
skips_remaining: number | null
|
skips_remaining: number | null
|
||||||
shields_available: number
|
|
||||||
has_shield: boolean
|
|
||||||
boosts_available: number
|
boosts_available: number
|
||||||
has_active_boost: boolean
|
has_active_boost: boolean
|
||||||
boost_multiplier: number | null
|
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 {
|
export interface UserCosmetics {
|
||||||
@@ -857,3 +863,49 @@ export const ITEM_TYPE_NAMES: Record<ShopItemType, string> = {
|
|||||||
background: 'Фон профиля',
|
background: 'Фон профиля',
|
||||||
consumable: 'Расходуемое',
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user