Compare commits
3 Commits
ca49e42f74
...
2874b64481
| Author | SHA1 | Date | |
|---|---|---|---|
| 2874b64481 | |||
| 4488a13808 | |||
| 6a7717a474 |
230
backend/alembic/versions/023_add_shop_system.py
Normal file
230
backend/alembic/versions/023_add_shop_system.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""Add shop system with coins, items, inventory, certification
|
||||||
|
|
||||||
|
Revision ID: 023_add_shop_system
|
||||||
|
Revises: 022_add_notification_settings
|
||||||
|
Create Date: 2025-01-05
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '023_add_shop_system'
|
||||||
|
down_revision: Union[str, None] = '022_add_notification_settings'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# === 1. Создаём таблицу shop_items ===
|
||||||
|
if not table_exists('shop_items'):
|
||||||
|
op.create_table(
|
||||||
|
'shop_items',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('item_type', sa.String(30), nullable=False, index=True),
|
||||||
|
sa.Column('code', sa.String(50), nullable=False, unique=True, index=True),
|
||||||
|
sa.Column('name', sa.String(100), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('price', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('rarity', sa.String(20), nullable=False, server_default='common'),
|
||||||
|
sa.Column('asset_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('available_from', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('available_until', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('stock_limit', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('stock_remaining', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# === 2. Создаём таблицу user_inventory ===
|
||||||
|
if not table_exists('user_inventory'):
|
||||||
|
op.create_table(
|
||||||
|
'user_inventory',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||||
|
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||||
|
sa.Column('quantity', sa.Integer(), nullable=False, server_default='1'),
|
||||||
|
sa.Column('equipped', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('purchased_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# === 3. Создаём таблицу coin_transactions ===
|
||||||
|
if not table_exists('coin_transactions'):
|
||||||
|
op.create_table(
|
||||||
|
'coin_transactions',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||||
|
sa.Column('amount', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('transaction_type', sa.String(30), nullable=False),
|
||||||
|
sa.Column('reference_type', sa.String(30), nullable=True),
|
||||||
|
sa.Column('reference_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# === 4. Создаём таблицу consumable_usages ===
|
||||||
|
if not table_exists('consumable_usages'):
|
||||||
|
op.create_table(
|
||||||
|
'consumable_usages',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||||
|
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('marathon_id', sa.Integer(), sa.ForeignKey('marathons.id', ondelete='CASCADE'), nullable=True),
|
||||||
|
sa.Column('assignment_id', sa.Integer(), sa.ForeignKey('assignments.id', ondelete='CASCADE'), nullable=True),
|
||||||
|
sa.Column('used_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('effect_data', sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# === 5. Добавляем поля в users ===
|
||||||
|
|
||||||
|
# coins_balance - баланс монет
|
||||||
|
if not column_exists('users', 'coins_balance'):
|
||||||
|
op.add_column('users', sa.Column('coins_balance', sa.Integer(), nullable=False, server_default='0'))
|
||||||
|
|
||||||
|
# equipped_frame_id - экипированная рамка
|
||||||
|
if not column_exists('users', 'equipped_frame_id'):
|
||||||
|
op.add_column('users', sa.Column('equipped_frame_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
|
||||||
|
|
||||||
|
# equipped_title_id - экипированный титул
|
||||||
|
if not column_exists('users', 'equipped_title_id'):
|
||||||
|
op.add_column('users', sa.Column('equipped_title_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
|
||||||
|
|
||||||
|
# equipped_name_color_id - экипированный цвет ника
|
||||||
|
if not column_exists('users', 'equipped_name_color_id'):
|
||||||
|
op.add_column('users', sa.Column('equipped_name_color_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
|
||||||
|
|
||||||
|
# equipped_background_id - экипированный фон
|
||||||
|
if not column_exists('users', 'equipped_background_id'):
|
||||||
|
op.add_column('users', sa.Column('equipped_background_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
|
||||||
|
|
||||||
|
# === 6. Добавляем поля сертификации в marathons ===
|
||||||
|
|
||||||
|
# certification_status - статус сертификации
|
||||||
|
if not column_exists('marathons', 'certification_status'):
|
||||||
|
op.add_column('marathons', sa.Column('certification_status', sa.String(20), nullable=False, server_default='none'))
|
||||||
|
|
||||||
|
# certification_requested_at - когда подана заявка
|
||||||
|
if not column_exists('marathons', 'certification_requested_at'):
|
||||||
|
op.add_column('marathons', sa.Column('certification_requested_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
# certified_at - когда сертифицирован
|
||||||
|
if not column_exists('marathons', 'certified_at'):
|
||||||
|
op.add_column('marathons', sa.Column('certified_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
# certified_by_id - кем сертифицирован
|
||||||
|
if not column_exists('marathons', 'certified_by_id'):
|
||||||
|
op.add_column('marathons', sa.Column('certified_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True))
|
||||||
|
|
||||||
|
# certification_rejection_reason - причина отказа
|
||||||
|
if not column_exists('marathons', 'certification_rejection_reason'):
|
||||||
|
op.add_column('marathons', sa.Column('certification_rejection_reason', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
# === 7. Добавляем настройки consumables в marathons ===
|
||||||
|
|
||||||
|
# allow_skips - разрешены ли скипы
|
||||||
|
if not column_exists('marathons', 'allow_skips'):
|
||||||
|
op.add_column('marathons', sa.Column('allow_skips', sa.Boolean(), nullable=False, server_default='true'))
|
||||||
|
|
||||||
|
# max_skips_per_participant - лимит скипов на участника
|
||||||
|
if not column_exists('marathons', 'max_skips_per_participant'):
|
||||||
|
op.add_column('marathons', sa.Column('max_skips_per_participant', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# allow_consumables - разрешены ли расходуемые
|
||||||
|
if not column_exists('marathons', 'allow_consumables'):
|
||||||
|
op.add_column('marathons', sa.Column('allow_consumables', sa.Boolean(), nullable=False, server_default='true'))
|
||||||
|
|
||||||
|
# === 8. Добавляем поля в participants ===
|
||||||
|
|
||||||
|
# coins_earned - заработано монет в марафоне
|
||||||
|
if not column_exists('participants', 'coins_earned'):
|
||||||
|
op.add_column('participants', sa.Column('coins_earned', sa.Integer(), nullable=False, server_default='0'))
|
||||||
|
|
||||||
|
# skips_used - использовано скипов
|
||||||
|
if not column_exists('participants', 'skips_used'):
|
||||||
|
op.add_column('participants', sa.Column('skips_used', sa.Integer(), nullable=False, server_default='0'))
|
||||||
|
|
||||||
|
# active_boost_multiplier - активный множитель буста
|
||||||
|
if not column_exists('participants', 'active_boost_multiplier'):
|
||||||
|
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
|
||||||
|
|
||||||
|
# active_boost_expires_at - когда истекает буст
|
||||||
|
if not column_exists('participants', 'active_boost_expires_at'):
|
||||||
|
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
# has_shield - есть ли активный щит
|
||||||
|
if not column_exists('participants', 'has_shield'):
|
||||||
|
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# === Удаляем поля из participants ===
|
||||||
|
if column_exists('participants', 'has_shield'):
|
||||||
|
op.drop_column('participants', 'has_shield')
|
||||||
|
if column_exists('participants', 'active_boost_expires_at'):
|
||||||
|
op.drop_column('participants', 'active_boost_expires_at')
|
||||||
|
if column_exists('participants', 'active_boost_multiplier'):
|
||||||
|
op.drop_column('participants', 'active_boost_multiplier')
|
||||||
|
if column_exists('participants', 'skips_used'):
|
||||||
|
op.drop_column('participants', 'skips_used')
|
||||||
|
if column_exists('participants', 'coins_earned'):
|
||||||
|
op.drop_column('participants', 'coins_earned')
|
||||||
|
|
||||||
|
# === Удаляем поля consumables из marathons ===
|
||||||
|
if column_exists('marathons', 'allow_consumables'):
|
||||||
|
op.drop_column('marathons', 'allow_consumables')
|
||||||
|
if column_exists('marathons', 'max_skips_per_participant'):
|
||||||
|
op.drop_column('marathons', 'max_skips_per_participant')
|
||||||
|
if column_exists('marathons', 'allow_skips'):
|
||||||
|
op.drop_column('marathons', 'allow_skips')
|
||||||
|
|
||||||
|
# === Удаляем поля сертификации из marathons ===
|
||||||
|
if column_exists('marathons', 'certification_rejection_reason'):
|
||||||
|
op.drop_column('marathons', 'certification_rejection_reason')
|
||||||
|
if column_exists('marathons', 'certified_by_id'):
|
||||||
|
op.drop_column('marathons', 'certified_by_id')
|
||||||
|
if column_exists('marathons', 'certified_at'):
|
||||||
|
op.drop_column('marathons', 'certified_at')
|
||||||
|
if column_exists('marathons', 'certification_requested_at'):
|
||||||
|
op.drop_column('marathons', 'certification_requested_at')
|
||||||
|
if column_exists('marathons', 'certification_status'):
|
||||||
|
op.drop_column('marathons', 'certification_status')
|
||||||
|
|
||||||
|
# === Удаляем поля из users ===
|
||||||
|
if column_exists('users', 'equipped_background_id'):
|
||||||
|
op.drop_column('users', 'equipped_background_id')
|
||||||
|
if column_exists('users', 'equipped_name_color_id'):
|
||||||
|
op.drop_column('users', 'equipped_name_color_id')
|
||||||
|
if column_exists('users', 'equipped_title_id'):
|
||||||
|
op.drop_column('users', 'equipped_title_id')
|
||||||
|
if column_exists('users', 'equipped_frame_id'):
|
||||||
|
op.drop_column('users', 'equipped_frame_id')
|
||||||
|
if column_exists('users', 'coins_balance'):
|
||||||
|
op.drop_column('users', 'coins_balance')
|
||||||
|
|
||||||
|
# === Удаляем таблицы ===
|
||||||
|
if table_exists('consumable_usages'):
|
||||||
|
op.drop_table('consumable_usages')
|
||||||
|
if table_exists('coin_transactions'):
|
||||||
|
op.drop_table('coin_transactions')
|
||||||
|
if table_exists('user_inventory'):
|
||||||
|
op.drop_table('user_inventory')
|
||||||
|
if table_exists('shop_items'):
|
||||||
|
op.drop_table('shop_items')
|
||||||
495
backend/alembic/versions/024_seed_shop_items.py
Normal file
495
backend/alembic/versions/024_seed_shop_items.py
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
"""Seed shop items (frames, titles, consumables)
|
||||||
|
|
||||||
|
Revision ID: 024_seed_shop_items
|
||||||
|
Revises: 023_add_shop_system
|
||||||
|
Create Date: 2025-01-05
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '024_seed_shop_items'
|
||||||
|
down_revision: Union[str, None] = '023_add_shop_system'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('shop_items'):
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Таблица shop_items
|
||||||
|
shop_items = sa.table(
|
||||||
|
'shop_items',
|
||||||
|
sa.column('id', sa.Integer),
|
||||||
|
sa.column('item_type', sa.String),
|
||||||
|
sa.column('code', sa.String),
|
||||||
|
sa.column('name', sa.String),
|
||||||
|
sa.column('description', sa.Text),
|
||||||
|
sa.column('price', sa.Integer),
|
||||||
|
sa.column('rarity', sa.String),
|
||||||
|
sa.column('asset_data', sa.JSON),
|
||||||
|
sa.column('is_active', sa.Boolean),
|
||||||
|
sa.column('created_at', sa.DateTime),
|
||||||
|
)
|
||||||
|
|
||||||
|
# === Рамки аватара ===
|
||||||
|
frames = [
|
||||||
|
{
|
||||||
|
'item_type': 'frame',
|
||||||
|
'code': 'frame_bronze',
|
||||||
|
'name': 'Бронзовая рамка',
|
||||||
|
'description': 'Простая бронзовая рамка для начинающих',
|
||||||
|
'price': 50,
|
||||||
|
'rarity': 'common',
|
||||||
|
'asset_data': {
|
||||||
|
'border_color': '#CD7F32',
|
||||||
|
'border_width': 3,
|
||||||
|
'border_style': 'solid'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'frame',
|
||||||
|
'code': 'frame_silver',
|
||||||
|
'name': 'Серебряная рамка',
|
||||||
|
'description': 'Элегантная серебряная рамка',
|
||||||
|
'price': 100,
|
||||||
|
'rarity': 'uncommon',
|
||||||
|
'asset_data': {
|
||||||
|
'border_color': '#C0C0C0',
|
||||||
|
'border_width': 3,
|
||||||
|
'border_style': 'solid'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'frame',
|
||||||
|
'code': 'frame_gold',
|
||||||
|
'name': 'Золотая рамка',
|
||||||
|
'description': 'Престижная золотая рамка',
|
||||||
|
'price': 200,
|
||||||
|
'rarity': 'rare',
|
||||||
|
'asset_data': {
|
||||||
|
'border_color': '#FFD700',
|
||||||
|
'border_width': 4,
|
||||||
|
'border_style': 'solid'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'frame',
|
||||||
|
'code': 'frame_diamond',
|
||||||
|
'name': 'Бриллиантовая рамка',
|
||||||
|
'description': 'Сверкающая бриллиантовая рамка для истинных ценителей',
|
||||||
|
'price': 500,
|
||||||
|
'rarity': 'epic',
|
||||||
|
'asset_data': {
|
||||||
|
'border_color': '#B9F2FF',
|
||||||
|
'border_width': 4,
|
||||||
|
'border_style': 'double',
|
||||||
|
'glow': True,
|
||||||
|
'glow_color': '#B9F2FF'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'frame',
|
||||||
|
'code': 'frame_fire',
|
||||||
|
'name': 'Огненная рамка',
|
||||||
|
'description': 'Анимированная рамка с эффектом пламени',
|
||||||
|
'price': 1000,
|
||||||
|
'rarity': 'legendary',
|
||||||
|
'asset_data': {
|
||||||
|
'border_style': 'gradient',
|
||||||
|
'gradient': ['#FF4500', '#FF8C00', '#FFD700'],
|
||||||
|
'animated': True,
|
||||||
|
'animation': 'fire-pulse'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'frame',
|
||||||
|
'code': 'frame_neon',
|
||||||
|
'name': 'Неоновая рамка',
|
||||||
|
'description': 'Яркая неоновая рамка с свечением',
|
||||||
|
'price': 800,
|
||||||
|
'rarity': 'epic',
|
||||||
|
'asset_data': {
|
||||||
|
'border_color': '#00FF00',
|
||||||
|
'border_width': 3,
|
||||||
|
'glow': True,
|
||||||
|
'glow_color': '#00FF00',
|
||||||
|
'glow_intensity': 10
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'frame',
|
||||||
|
'code': 'frame_rainbow',
|
||||||
|
'name': 'Радужная рамка',
|
||||||
|
'description': 'Переливающаяся радужная рамка',
|
||||||
|
'price': 1500,
|
||||||
|
'rarity': 'legendary',
|
||||||
|
'asset_data': {
|
||||||
|
'border_style': 'gradient',
|
||||||
|
'gradient': ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#9400D3'],
|
||||||
|
'animated': True,
|
||||||
|
'animation': 'rainbow-rotate'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# === Титулы ===
|
||||||
|
titles = [
|
||||||
|
{
|
||||||
|
'item_type': 'title',
|
||||||
|
'code': 'title_newcomer',
|
||||||
|
'name': 'Новичок',
|
||||||
|
'description': 'Первый шаг в мир марафонов',
|
||||||
|
'price': 30,
|
||||||
|
'rarity': 'common',
|
||||||
|
'asset_data': {
|
||||||
|
'text': 'Новичок',
|
||||||
|
'color': '#808080'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'title',
|
||||||
|
'code': 'title_runner',
|
||||||
|
'name': 'Марафонец',
|
||||||
|
'description': 'Опытный участник марафонов',
|
||||||
|
'price': 100,
|
||||||
|
'rarity': 'uncommon',
|
||||||
|
'asset_data': {
|
||||||
|
'text': 'Марафонец',
|
||||||
|
'color': '#4169E1'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'title',
|
||||||
|
'code': 'title_hunter',
|
||||||
|
'name': 'Охотник за челленджами',
|
||||||
|
'description': 'Мастер выполнения сложных заданий',
|
||||||
|
'price': 200,
|
||||||
|
'rarity': 'rare',
|
||||||
|
'asset_data': {
|
||||||
|
'text': 'Охотник за челленджами',
|
||||||
|
'color': '#228B22'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'title',
|
||||||
|
'code': 'title_veteran',
|
||||||
|
'name': 'Ветеран',
|
||||||
|
'description': 'Закаленный в боях участник',
|
||||||
|
'price': 300,
|
||||||
|
'rarity': 'rare',
|
||||||
|
'asset_data': {
|
||||||
|
'text': 'Ветеран',
|
||||||
|
'color': '#8B4513'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'title',
|
||||||
|
'code': 'title_champion',
|
||||||
|
'name': 'Чемпион',
|
||||||
|
'description': 'Победитель марафонов',
|
||||||
|
'price': 500,
|
||||||
|
'rarity': 'epic',
|
||||||
|
'asset_data': {
|
||||||
|
'text': 'Чемпион',
|
||||||
|
'color': '#FFD700',
|
||||||
|
'icon': 'trophy'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'title',
|
||||||
|
'code': 'title_legend',
|
||||||
|
'name': 'Легенда',
|
||||||
|
'description': 'Легендарный участник марафонов',
|
||||||
|
'price': 1000,
|
||||||
|
'rarity': 'legendary',
|
||||||
|
'asset_data': {
|
||||||
|
'text': 'Легенда',
|
||||||
|
'color': '#FF4500',
|
||||||
|
'glow': True,
|
||||||
|
'icon': 'star'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# === Цвета никнейма ===
|
||||||
|
name_colors = [
|
||||||
|
{
|
||||||
|
'item_type': 'name_color',
|
||||||
|
'code': 'color_red',
|
||||||
|
'name': 'Красный ник',
|
||||||
|
'description': 'Яркий красный цвет никнейма',
|
||||||
|
'price': 150,
|
||||||
|
'rarity': 'uncommon',
|
||||||
|
'asset_data': {
|
||||||
|
'style': 'solid',
|
||||||
|
'color': '#FF4444'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'name_color',
|
||||||
|
'code': 'color_blue',
|
||||||
|
'name': 'Синий ник',
|
||||||
|
'description': 'Глубокий синий цвет никнейма',
|
||||||
|
'price': 150,
|
||||||
|
'rarity': 'uncommon',
|
||||||
|
'asset_data': {
|
||||||
|
'style': 'solid',
|
||||||
|
'color': '#4444FF'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'name_color',
|
||||||
|
'code': 'color_green',
|
||||||
|
'name': 'Зеленый ник',
|
||||||
|
'description': 'Сочный зеленый цвет никнейма',
|
||||||
|
'price': 150,
|
||||||
|
'rarity': 'uncommon',
|
||||||
|
'asset_data': {
|
||||||
|
'style': 'solid',
|
||||||
|
'color': '#44FF44'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'name_color',
|
||||||
|
'code': 'color_purple',
|
||||||
|
'name': 'Фиолетовый ник',
|
||||||
|
'description': 'Королевский фиолетовый цвет',
|
||||||
|
'price': 200,
|
||||||
|
'rarity': 'rare',
|
||||||
|
'asset_data': {
|
||||||
|
'style': 'solid',
|
||||||
|
'color': '#9932CC'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'name_color',
|
||||||
|
'code': 'color_gold',
|
||||||
|
'name': 'Золотой ник',
|
||||||
|
'description': 'Престижный золотой цвет',
|
||||||
|
'price': 300,
|
||||||
|
'rarity': 'rare',
|
||||||
|
'asset_data': {
|
||||||
|
'style': 'solid',
|
||||||
|
'color': '#FFD700'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'name_color',
|
||||||
|
'code': 'color_gradient_sunset',
|
||||||
|
'name': 'Закат',
|
||||||
|
'description': 'Красивый градиент заката',
|
||||||
|
'price': 500,
|
||||||
|
'rarity': 'epic',
|
||||||
|
'asset_data': {
|
||||||
|
'style': 'gradient',
|
||||||
|
'gradient': ['#FF6B6B', '#FFE66D']
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'name_color',
|
||||||
|
'code': 'color_gradient_ocean',
|
||||||
|
'name': 'Океан',
|
||||||
|
'description': 'Градиент морских глубин',
|
||||||
|
'price': 500,
|
||||||
|
'rarity': 'epic',
|
||||||
|
'asset_data': {
|
||||||
|
'style': 'gradient',
|
||||||
|
'gradient': ['#4ECDC4', '#44A3FF']
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'name_color',
|
||||||
|
'code': 'color_rainbow',
|
||||||
|
'name': 'Радужный ник',
|
||||||
|
'description': 'Анимированный радужный цвет',
|
||||||
|
'price': 1000,
|
||||||
|
'rarity': 'legendary',
|
||||||
|
'asset_data': {
|
||||||
|
'style': 'animated',
|
||||||
|
'animation': 'rainbow-shift'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# === Фоны профиля ===
|
||||||
|
backgrounds = [
|
||||||
|
{
|
||||||
|
'item_type': 'background',
|
||||||
|
'code': 'bg_dark',
|
||||||
|
'name': 'Тёмный фон',
|
||||||
|
'description': 'Элегантный тёмный фон',
|
||||||
|
'price': 100,
|
||||||
|
'rarity': 'common',
|
||||||
|
'asset_data': {
|
||||||
|
'type': 'solid',
|
||||||
|
'color': '#1a1a2e'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'background',
|
||||||
|
'code': 'bg_gradient_purple',
|
||||||
|
'name': 'Фиолетовый градиент',
|
||||||
|
'description': 'Красивый фиолетовый градиент',
|
||||||
|
'price': 200,
|
||||||
|
'rarity': 'uncommon',
|
||||||
|
'asset_data': {
|
||||||
|
'type': 'gradient',
|
||||||
|
'gradient': ['#1a1a2e', '#4a0080']
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'background',
|
||||||
|
'code': 'bg_stars',
|
||||||
|
'name': 'Звёздное небо',
|
||||||
|
'description': 'Фон с мерцающими звёздами',
|
||||||
|
'price': 400,
|
||||||
|
'rarity': 'rare',
|
||||||
|
'asset_data': {
|
||||||
|
'type': 'pattern',
|
||||||
|
'pattern': 'stars',
|
||||||
|
'animated': True
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'background',
|
||||||
|
'code': 'bg_gaming',
|
||||||
|
'name': 'Игровой фон',
|
||||||
|
'description': 'Фон с игровыми элементами',
|
||||||
|
'price': 500,
|
||||||
|
'rarity': 'epic',
|
||||||
|
'asset_data': {
|
||||||
|
'type': 'pattern',
|
||||||
|
'pattern': 'gaming-icons'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'background',
|
||||||
|
'code': 'bg_fire',
|
||||||
|
'name': 'Огненный фон',
|
||||||
|
'description': 'Анимированный огненный фон',
|
||||||
|
'price': 800,
|
||||||
|
'rarity': 'legendary',
|
||||||
|
'asset_data': {
|
||||||
|
'type': 'animated',
|
||||||
|
'animation': 'fire-particles'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# === Расходуемые предметы ===
|
||||||
|
consumables = [
|
||||||
|
{
|
||||||
|
'item_type': 'consumable',
|
||||||
|
'code': 'skip',
|
||||||
|
'name': 'Пропуск',
|
||||||
|
'description': 'Пропустить текущее задание без штрафа и потери streak',
|
||||||
|
'price': 100,
|
||||||
|
'rarity': 'common',
|
||||||
|
'asset_data': {
|
||||||
|
'effect': 'skip',
|
||||||
|
'icon': 'skip-forward'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'consumable',
|
||||||
|
'code': 'shield',
|
||||||
|
'name': 'Щит',
|
||||||
|
'description': 'Защита от штрафа при следующем дропе. Streak сохраняется.',
|
||||||
|
'price': 150,
|
||||||
|
'rarity': 'uncommon',
|
||||||
|
'asset_data': {
|
||||||
|
'effect': 'shield',
|
||||||
|
'icon': 'shield'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'consumable',
|
||||||
|
'code': 'boost',
|
||||||
|
'name': 'Буст x1.5',
|
||||||
|
'description': 'Множитель очков x1.5 на следующие 2 часа',
|
||||||
|
'price': 200,
|
||||||
|
'rarity': 'rare',
|
||||||
|
'asset_data': {
|
||||||
|
'effect': 'boost',
|
||||||
|
'multiplier': 1.5,
|
||||||
|
'duration_hours': 2,
|
||||||
|
'icon': 'zap'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item_type': 'consumable',
|
||||||
|
'code': 'reroll',
|
||||||
|
'name': 'Перекрут',
|
||||||
|
'description': 'Перекрутить колесо и получить новое задание',
|
||||||
|
'price': 80,
|
||||||
|
'rarity': 'common',
|
||||||
|
'asset_data': {
|
||||||
|
'effect': 'reroll',
|
||||||
|
'icon': 'refresh-cw'
|
||||||
|
},
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Вставляем все товары
|
||||||
|
all_items = frames + titles + name_colors + backgrounds + consumables
|
||||||
|
|
||||||
|
# Добавляем created_at ко всем товарам
|
||||||
|
for item in all_items:
|
||||||
|
item['created_at'] = now
|
||||||
|
|
||||||
|
op.bulk_insert(shop_items, all_items)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Удаляем все seed-товары по коду
|
||||||
|
op.execute("DELETE FROM shop_items WHERE code LIKE 'frame_%'")
|
||||||
|
op.execute("DELETE FROM shop_items WHERE code LIKE 'title_%'")
|
||||||
|
op.execute("DELETE FROM shop_items WHERE code LIKE 'color_%'")
|
||||||
|
op.execute("DELETE FROM shop_items WHERE code LIKE 'bg_%'")
|
||||||
|
op.execute("DELETE FROM shop_items WHERE code IN ('skip', 'shield', 'boost', 'reroll')")
|
||||||
52
backend/alembic/versions/025_simplify_boost_consumable.py
Normal file
52
backend/alembic/versions/025_simplify_boost_consumable.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Simplify boost consumable - make it one-time instead of timed
|
||||||
|
|
||||||
|
Revision ID: 025_simplify_boost
|
||||||
|
Revises: 024_seed_shop_items
|
||||||
|
Create Date: 2026-01-08
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '025_simplify_boost'
|
||||||
|
down_revision: Union[str, None] = '024_seed_shop_items'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c['name'] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add new boolean column for one-time boost
|
||||||
|
if not column_exists('participants', 'has_active_boost'):
|
||||||
|
op.add_column('participants', sa.Column('has_active_boost', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
|
||||||
|
# Remove old timed boost columns
|
||||||
|
if column_exists('participants', 'active_boost_multiplier'):
|
||||||
|
op.drop_column('participants', 'active_boost_multiplier')
|
||||||
|
|
||||||
|
if column_exists('participants', 'active_boost_expires_at'):
|
||||||
|
op.drop_column('participants', 'active_boost_expires_at')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Restore old columns
|
||||||
|
if not column_exists('participants', 'active_boost_multiplier'):
|
||||||
|
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
|
||||||
|
|
||||||
|
if not column_exists('participants', 'active_boost_expires_at'):
|
||||||
|
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
# Remove new column
|
||||||
|
if column_exists('participants', 'has_active_boost'):
|
||||||
|
op.drop_column('participants', 'has_active_boost')
|
||||||
@@ -4,6 +4,7 @@ from datetime import datetime
|
|||||||
from fastapi import Depends, HTTPException, status, Header
|
from fastapi import Depends, HTTPException, status, Header
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -35,7 +36,16 @@ async def get_current_user(
|
|||||||
detail="Invalid token payload",
|
detail="Invalid token payload",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.where(User.id == int(user_id))
|
||||||
|
.options(
|
||||||
|
selectinload(User.equipped_frame),
|
||||||
|
selectinload(User.equipped_title),
|
||||||
|
selectinload(User.equipped_name_color),
|
||||||
|
selectinload(User.equipped_background),
|
||||||
|
)
|
||||||
|
)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|||||||
@@ -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
|
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -16,3 +16,4 @@ router.include_router(events.router)
|
|||||||
router.include_router(assignments.router)
|
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)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||||
from app.models import (
|
from app.models import (
|
||||||
User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
|
User, UserRole, Marathon, MarathonStatus, CertificationStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
|
||||||
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
|
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
@@ -37,6 +37,8 @@ class AdminMarathonResponse(BaseModel):
|
|||||||
start_date: str | None
|
start_date: str | None
|
||||||
end_date: str | None
|
end_date: str | None
|
||||||
created_at: str
|
created_at: str
|
||||||
|
certification_status: str = "none"
|
||||||
|
is_certified: bool = False
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -219,7 +221,12 @@ async def list_marathons(
|
|||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Marathon)
|
select(Marathon)
|
||||||
.options(selectinload(Marathon.creator))
|
.options(
|
||||||
|
selectinload(Marathon.creator).selectinload(User.equipped_frame),
|
||||||
|
selectinload(Marathon.creator).selectinload(User.equipped_title),
|
||||||
|
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Marathon.creator).selectinload(User.equipped_background),
|
||||||
|
)
|
||||||
.order_by(Marathon.created_at.desc())
|
.order_by(Marathon.created_at.desc())
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -248,6 +255,8 @@ async def list_marathons(
|
|||||||
start_date=marathon.start_date.isoformat() if marathon.start_date else None,
|
start_date=marathon.start_date.isoformat() if marathon.start_date else None,
|
||||||
end_date=marathon.end_date.isoformat() if marathon.end_date else None,
|
end_date=marathon.end_date.isoformat() if marathon.end_date else None,
|
||||||
created_at=marathon.created_at.isoformat(),
|
created_at=marathon.created_at.isoformat(),
|
||||||
|
certification_status=marathon.certification_status,
|
||||||
|
is_certified=marathon.is_certified,
|
||||||
))
|
))
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -443,6 +452,8 @@ async def force_finish_marathon(
|
|||||||
db: DbSession,
|
db: DbSession,
|
||||||
):
|
):
|
||||||
"""Force finish a marathon. Admin only."""
|
"""Force finish a marathon. Admin only."""
|
||||||
|
from app.services.coins import coins_service
|
||||||
|
|
||||||
require_admin_with_2fa(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
@@ -456,6 +467,24 @@ async def force_finish_marathon(
|
|||||||
old_status = marathon.status
|
old_status = marathon.status
|
||||||
marathon.status = MarathonStatus.FINISHED.value
|
marathon.status = MarathonStatus.FINISHED.value
|
||||||
marathon.end_date = datetime.utcnow()
|
marathon.end_date = datetime.utcnow()
|
||||||
|
|
||||||
|
# Award coins for top 3 places (only in certified marathons)
|
||||||
|
if marathon.is_certified:
|
||||||
|
top_result = await db.execute(
|
||||||
|
select(Participant)
|
||||||
|
.options(selectinload(Participant.user))
|
||||||
|
.where(Participant.marathon_id == marathon_id)
|
||||||
|
.order_by(Participant.total_points.desc())
|
||||||
|
.limit(3)
|
||||||
|
)
|
||||||
|
top_participants = top_result.scalars().all()
|
||||||
|
|
||||||
|
for place, participant in enumerate(top_participants, start=1):
|
||||||
|
if participant.total_points > 0:
|
||||||
|
await coins_service.award_marathon_place(
|
||||||
|
db, participant.user, marathon, place
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Log action
|
# Log action
|
||||||
@@ -1102,3 +1131,75 @@ async def resolve_dispute(
|
|||||||
return MessageResponse(
|
return MessageResponse(
|
||||||
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Marathon Certification ============
|
||||||
|
@router.post("/marathons/{marathon_id}/certify", response_model=MessageResponse)
|
||||||
|
async def certify_marathon(
|
||||||
|
request: Request,
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Certify (verify) a marathon. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
if marathon.certification_status == CertificationStatus.CERTIFIED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Marathon is already certified")
|
||||||
|
|
||||||
|
marathon.certification_status = CertificationStatus.CERTIFIED.value
|
||||||
|
marathon.certified_at = datetime.utcnow()
|
||||||
|
marathon.certified_by_id = current_user.id
|
||||||
|
marathon.certification_rejection_reason = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.MARATHON_CERTIFY.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon.title},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return MessageResponse(message="Marathon certified successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/marathons/{marathon_id}/revoke-certification", response_model=MessageResponse)
|
||||||
|
async def revoke_marathon_certification(
|
||||||
|
request: Request,
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Revoke certification from a marathon. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
if marathon.certification_status != CertificationStatus.CERTIFIED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Marathon is not certified")
|
||||||
|
|
||||||
|
marathon.certification_status = CertificationStatus.NONE.value
|
||||||
|
marathon.certified_at = None
|
||||||
|
marathon.certified_by_id = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.MARATHON_REVOKE_CERTIFICATION.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon.title},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return MessageResponse(message="Marathon certification revoked")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import secrets
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status, Request
|
from fastapi import APIRouter, HTTPException, status, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser
|
||||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||||
@@ -48,7 +49,16 @@ async def register(request: Request, data: UserRegister, db: DbSession):
|
|||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def login(request: Request, data: UserLogin, db: DbSession):
|
async def login(request: Request, data: UserLogin, db: DbSession):
|
||||||
# Find user
|
# Find user
|
||||||
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.where(User.login == data.login.lower())
|
||||||
|
.options(
|
||||||
|
selectinload(User.equipped_frame),
|
||||||
|
selectinload(User.equipped_title),
|
||||||
|
selectinload(User.equipped_name_color),
|
||||||
|
selectinload(User.equipped_background),
|
||||||
|
)
|
||||||
|
)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user or not verify_password(data.password, user.password_hash):
|
if not user or not verify_password(data.password, user.password_hash):
|
||||||
@@ -147,7 +157,16 @@ async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Get user
|
# Get user
|
||||||
result = await db.execute(select(User).where(User.id == session.user_id))
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.where(User.id == session.user_id)
|
||||||
|
.options(
|
||||||
|
selectinload(User.equipped_frame),
|
||||||
|
selectinload(User.equipped_title),
|
||||||
|
selectinload(User.equipped_name_color),
|
||||||
|
selectinload(User.equipped_background),
|
||||||
|
)
|
||||||
|
)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from sqlalchemy import select, func
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser
|
||||||
from app.models import Activity, Participant, Dispute, ActivityType
|
from app.models import Activity, Participant, Dispute, ActivityType, User
|
||||||
from app.models.dispute import DisputeStatus
|
from app.models.dispute import DisputeStatus
|
||||||
from app.schemas import FeedResponse, ActivityResponse, UserPublic
|
from app.schemas import FeedResponse, ActivityResponse, UserPublic
|
||||||
|
|
||||||
@@ -37,7 +37,12 @@ async def get_feed(
|
|||||||
# Get activities
|
# Get activities
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Activity)
|
select(Activity)
|
||||||
.options(selectinload(Activity.user))
|
.options(
|
||||||
|
selectinload(Activity.user).selectinload(User.equipped_frame),
|
||||||
|
selectinload(Activity.user).selectinload(User.equipped_title),
|
||||||
|
selectinload(Activity.user).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Activity.user).selectinload(User.equipped_background),
|
||||||
|
)
|
||||||
.where(Activity.marathon_id == marathon_id)
|
.where(Activity.marathon_id == marathon_id)
|
||||||
.order_by(Activity.created_at.desc())
|
.order_by(Activity.created_at.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from app.api.deps import (
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
|
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
|
||||||
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant
|
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User
|
||||||
)
|
)
|
||||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||||
from app.schemas.assignment import AvailableGamesCount
|
from app.schemas.assignment import AvailableGamesCount
|
||||||
@@ -23,8 +23,14 @@ async def get_game_or_404(db, game_id: int) -> Game:
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Game)
|
select(Game)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Game.proposed_by),
|
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||||
selectinload(Game.approved_by),
|
selectinload(Game.proposed_by).selectinload(User.equipped_title),
|
||||||
|
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Game.proposed_by).selectinload(User.equipped_background),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_frame),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_title),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_background),
|
||||||
)
|
)
|
||||||
.where(Game.id == game_id)
|
.where(Game.id == game_id)
|
||||||
)
|
)
|
||||||
@@ -73,8 +79,14 @@ async def list_games(
|
|||||||
select(Game, func.count(Challenge.id).label("challenges_count"))
|
select(Game, func.count(Challenge.id).label("challenges_count"))
|
||||||
.outerjoin(Challenge)
|
.outerjoin(Challenge)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Game.proposed_by),
|
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||||
selectinload(Game.approved_by),
|
selectinload(Game.proposed_by).selectinload(User.equipped_title),
|
||||||
|
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Game.proposed_by).selectinload(User.equipped_background),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_frame),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_title),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_background),
|
||||||
)
|
)
|
||||||
.where(Game.marathon_id == marathon_id)
|
.where(Game.marathon_id == marathon_id)
|
||||||
.group_by(Game.id)
|
.group_by(Game.id)
|
||||||
@@ -106,8 +118,14 @@ async def list_pending_games(marathon_id: int, current_user: CurrentUser, db: Db
|
|||||||
select(Game, func.count(Challenge.id).label("challenges_count"))
|
select(Game, func.count(Challenge.id).label("challenges_count"))
|
||||||
.outerjoin(Challenge)
|
.outerjoin(Challenge)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Game.proposed_by),
|
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||||
selectinload(Game.approved_by),
|
selectinload(Game.proposed_by).selectinload(User.equipped_title),
|
||||||
|
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Game.proposed_by).selectinload(User.equipped_background),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_frame),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_title),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Game.approved_by).selectinload(User.equipped_background),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Game.marathon_id == marathon_id,
|
Game.marathon_id == marathon_id,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ optional_auth = HTTPBearer(auto_error=False)
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||||
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus,
|
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
MarathonCreate,
|
MarathonCreate,
|
||||||
@@ -80,7 +80,12 @@ def generate_invite_code() -> str:
|
|||||||
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Marathon)
|
select(Marathon)
|
||||||
.options(selectinload(Marathon.creator))
|
.options(
|
||||||
|
selectinload(Marathon.creator).selectinload(User.equipped_frame),
|
||||||
|
selectinload(Marathon.creator).selectinload(User.equipped_title),
|
||||||
|
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Marathon.creator).selectinload(User.equipped_background),
|
||||||
|
)
|
||||||
.where(Marathon.id == marathon_id)
|
.where(Marathon.id == marathon_id)
|
||||||
)
|
)
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
@@ -348,6 +353,8 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
|
|||||||
|
|
||||||
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
|
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
|
||||||
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
from app.services.coins import coins_service
|
||||||
|
|
||||||
# Require organizer role
|
# Require organizer role
|
||||||
await require_organizer(db, current_user, marathon_id)
|
await require_organizer(db, current_user, marathon_id)
|
||||||
marathon = await get_marathon_or_404(db, marathon_id)
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
@@ -357,6 +364,24 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
|
|||||||
|
|
||||||
marathon.status = MarathonStatus.FINISHED.value
|
marathon.status = MarathonStatus.FINISHED.value
|
||||||
|
|
||||||
|
# Award coins for top 3 places (only in certified marathons)
|
||||||
|
if marathon.is_certified:
|
||||||
|
# Get top 3 participants by total_points
|
||||||
|
top_result = await db.execute(
|
||||||
|
select(Participant)
|
||||||
|
.options(selectinload(Participant.user))
|
||||||
|
.where(Participant.marathon_id == marathon_id)
|
||||||
|
.order_by(Participant.total_points.desc())
|
||||||
|
.limit(3)
|
||||||
|
)
|
||||||
|
top_participants = top_result.scalars().all()
|
||||||
|
|
||||||
|
for place, participant in enumerate(top_participants, start=1):
|
||||||
|
if participant.total_points > 0: # Only award if they have points
|
||||||
|
await coins_service.award_marathon_place(
|
||||||
|
db, participant.user, marathon, place
|
||||||
|
)
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
activity = Activity(
|
activity = Activity(
|
||||||
marathon_id=marathon_id,
|
marathon_id=marathon_id,
|
||||||
@@ -465,7 +490,12 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
|
|||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant)
|
select(Participant)
|
||||||
.options(selectinload(Participant.user))
|
.options(
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_title),
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_background),
|
||||||
|
)
|
||||||
.where(Participant.marathon_id == marathon_id)
|
.where(Participant.marathon_id == marathon_id)
|
||||||
.order_by(Participant.joined_at)
|
.order_by(Participant.joined_at)
|
||||||
)
|
)
|
||||||
@@ -504,7 +534,12 @@ async def set_participant_role(
|
|||||||
# Get participant
|
# Get participant
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant)
|
select(Participant)
|
||||||
.options(selectinload(Participant.user))
|
.options(
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_title),
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_background),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
Participant.marathon_id == marathon_id,
|
Participant.marathon_id == marathon_id,
|
||||||
Participant.user_id == user_id,
|
Participant.user_id == user_id,
|
||||||
@@ -569,7 +604,12 @@ async def get_leaderboard(
|
|||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant)
|
select(Participant)
|
||||||
.options(selectinload(Participant.user))
|
.options(
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_title),
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||||||
|
selectinload(Participant.user).selectinload(User.equipped_background),
|
||||||
|
)
|
||||||
.where(Participant.marathon_id == marathon_id)
|
.where(Participant.marathon_id == marathon_id)
|
||||||
.order_by(Participant.total_points.desc())
|
.order_by(Participant.total_points.desc())
|
||||||
)
|
)
|
||||||
|
|||||||
634
backend/app/api/v1/shop.py
Normal file
634
backend/app/api/v1/shop.py
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
"""
|
||||||
|
Shop API endpoints - catalog, purchases, inventory, cosmetics, consumables
|
||||||
|
"""
|
||||||
|
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_participant, require_admin_with_2fa
|
||||||
|
from app.models import (
|
||||||
|
User, Marathon, Participant, Assignment, AssignmentStatus,
|
||||||
|
ShopItem, UserInventory, CoinTransaction, ShopItemType,
|
||||||
|
CertificationStatus,
|
||||||
|
)
|
||||||
|
from app.schemas import (
|
||||||
|
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
|
||||||
|
InventoryItemResponse, PurchaseRequest, PurchaseResponse,
|
||||||
|
UseConsumableRequest, UseConsumableResponse,
|
||||||
|
EquipItemRequest, EquipItemResponse,
|
||||||
|
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
|
||||||
|
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
|
||||||
|
ConsumablesStatusResponse, MessageResponse,
|
||||||
|
)
|
||||||
|
from app.services.shop import shop_service
|
||||||
|
from app.services.coins import coins_service
|
||||||
|
from app.services.consumables import consumables_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/shop", tags=["shop"])
|
||||||
|
|
||||||
|
|
||||||
|
# === Catalog ===
|
||||||
|
|
||||||
|
@router.get("/items", response_model=list[ShopItemResponse])
|
||||||
|
async def get_shop_items(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
item_type: str | None = None,
|
||||||
|
include_unavailable: bool = False,
|
||||||
|
):
|
||||||
|
"""Get list of shop items"""
|
||||||
|
items = await shop_service.get_available_items(db, item_type, include_unavailable)
|
||||||
|
|
||||||
|
# Get user's inventory to mark owned/equipped items
|
||||||
|
user_inventory = await shop_service.get_user_inventory(db, current_user.id)
|
||||||
|
owned_ids = {inv.item_id for inv in user_inventory}
|
||||||
|
equipped_ids = {inv.item_id for inv in user_inventory if inv.equipped}
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for item in items:
|
||||||
|
item_dict = ShopItemResponse.model_validate(item).model_dump()
|
||||||
|
item_dict["is_owned"] = item.id in owned_ids
|
||||||
|
item_dict["is_equipped"] = item.id in equipped_ids
|
||||||
|
result.append(ShopItemResponse(**item_dict))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/{item_id}", response_model=ShopItemResponse)
|
||||||
|
async def get_shop_item(
|
||||||
|
item_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get single shop item by ID"""
|
||||||
|
item = await shop_service.get_item_by_id(db, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
is_owned = await shop_service.check_user_owns_item(db, current_user.id, item_id)
|
||||||
|
|
||||||
|
# Check if equipped
|
||||||
|
is_equipped = False
|
||||||
|
if is_owned:
|
||||||
|
inventory = await shop_service.get_user_inventory(db, current_user.id, item.item_type)
|
||||||
|
is_equipped = any(inv.equipped and inv.item_id == item_id for inv in inventory)
|
||||||
|
|
||||||
|
response = ShopItemResponse.model_validate(item)
|
||||||
|
response.is_owned = is_owned
|
||||||
|
response.is_equipped = is_equipped
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# === Purchases ===
|
||||||
|
|
||||||
|
@router.post("/purchase", response_model=PurchaseResponse)
|
||||||
|
async def purchase_item(
|
||||||
|
data: PurchaseRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Purchase an item from the shop"""
|
||||||
|
inv_item, total_cost = await shop_service.purchase_item(
|
||||||
|
db, current_user, data.item_id, data.quantity
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(current_user)
|
||||||
|
|
||||||
|
item = await shop_service.get_item_by_id(db, data.item_id)
|
||||||
|
|
||||||
|
return PurchaseResponse(
|
||||||
|
success=True,
|
||||||
|
item=ShopItemResponse.model_validate(item),
|
||||||
|
quantity=data.quantity,
|
||||||
|
total_cost=total_cost,
|
||||||
|
new_balance=current_user.coins_balance,
|
||||||
|
message=f"Successfully purchased {item.name} x{data.quantity}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Inventory ===
|
||||||
|
|
||||||
|
@router.get("/inventory", response_model=list[InventoryItemResponse])
|
||||||
|
async def get_my_inventory(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
item_type: str | None = None,
|
||||||
|
):
|
||||||
|
"""Get current user's inventory"""
|
||||||
|
inventory = await shop_service.get_user_inventory(db, current_user.id, item_type)
|
||||||
|
return [InventoryItemResponse.model_validate(inv) for inv in inventory]
|
||||||
|
|
||||||
|
|
||||||
|
# === Equip/Unequip ===
|
||||||
|
|
||||||
|
@router.post("/equip", response_model=EquipItemResponse)
|
||||||
|
async def equip_item(
|
||||||
|
data: EquipItemRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Equip a cosmetic item from inventory"""
|
||||||
|
item = await shop_service.equip_item(db, current_user, data.inventory_id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return EquipItemResponse(
|
||||||
|
success=True,
|
||||||
|
item_type=item.item_type,
|
||||||
|
equipped_item=ShopItemResponse.model_validate(item),
|
||||||
|
message=f"Equipped {item.name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unequip/{item_type}", response_model=EquipItemResponse)
|
||||||
|
async def unequip_item(
|
||||||
|
item_type: str,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Unequip item of specified type"""
|
||||||
|
valid_types = [ShopItemType.FRAME.value, ShopItemType.TITLE.value,
|
||||||
|
ShopItemType.NAME_COLOR.value, ShopItemType.BACKGROUND.value]
|
||||||
|
if item_type not in valid_types:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid item type: {item_type}")
|
||||||
|
|
||||||
|
await shop_service.unequip_item(db, current_user, item_type)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return EquipItemResponse(
|
||||||
|
success=True,
|
||||||
|
item_type=item_type,
|
||||||
|
equipped_item=None,
|
||||||
|
message=f"Unequipped {item_type}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Consumables ===
|
||||||
|
|
||||||
|
@router.post("/use", response_model=UseConsumableResponse)
|
||||||
|
async def use_consumable(
|
||||||
|
data: UseConsumableRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Use a consumable item"""
|
||||||
|
# Get marathon
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == data.marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Get participant
|
||||||
|
participant = await require_participant(db, current_user.id, data.marathon_id)
|
||||||
|
|
||||||
|
# For skip and reroll, we need the assignment
|
||||||
|
assignment = None
|
||||||
|
if data.item_code in ["skip", "reroll"]:
|
||||||
|
if not data.assignment_id:
|
||||||
|
raise HTTPException(status_code=400, detail="assignment_id is required for skip/reroll")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment).where(
|
||||||
|
Assignment.id == data.assignment_id,
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
|
# Use the consumable
|
||||||
|
if data.item_code == "skip":
|
||||||
|
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
|
||||||
|
effect_description = "Assignment skipped without penalty"
|
||||||
|
elif data.item_code == "shield":
|
||||||
|
effect = await consumables_service.use_shield(db, current_user, participant, marathon)
|
||||||
|
effect_description = "Shield activated - next drop will be free"
|
||||||
|
elif data.item_code == "boost":
|
||||||
|
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
|
||||||
|
effect_description = f"Boost x{effect['multiplier']} activated for next complete"
|
||||||
|
elif data.item_code == "reroll":
|
||||||
|
effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment)
|
||||||
|
effect_description = "Assignment rerolled - you can spin again"
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Get remaining quantity
|
||||||
|
remaining = await consumables_service.get_consumable_count(db, current_user.id, data.item_code)
|
||||||
|
|
||||||
|
return UseConsumableResponse(
|
||||||
|
success=True,
|
||||||
|
item_code=data.item_code,
|
||||||
|
remaining_quantity=remaining,
|
||||||
|
effect_description=effect_description,
|
||||||
|
effect_data=effect,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/consumables/{marathon_id}", response_model=ConsumablesStatusResponse)
|
||||||
|
async def get_consumables_status(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get consumables status for participant in marathon"""
|
||||||
|
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 inventory counts for all consumables
|
||||||
|
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
|
||||||
|
shields_available = await consumables_service.get_consumable_count(db, current_user.id, "shield")
|
||||||
|
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
|
||||||
|
rerolls_available = await consumables_service.get_consumable_count(db, current_user.id, "reroll")
|
||||||
|
|
||||||
|
# Calculate remaining skips for this marathon
|
||||||
|
skips_remaining = None
|
||||||
|
if marathon.max_skips_per_participant is not None:
|
||||||
|
skips_remaining = max(0, marathon.max_skips_per_participant - participant.skips_used)
|
||||||
|
|
||||||
|
return ConsumablesStatusResponse(
|
||||||
|
skips_available=skips_available,
|
||||||
|
skips_used=participant.skips_used,
|
||||||
|
skips_remaining=skips_remaining,
|
||||||
|
shields_available=shields_available,
|
||||||
|
has_shield=participant.has_shield,
|
||||||
|
boosts_available=boosts_available,
|
||||||
|
has_active_boost=participant.has_active_boost,
|
||||||
|
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
|
||||||
|
rerolls_available=rerolls_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Coins ===
|
||||||
|
|
||||||
|
@router.get("/balance", response_model=CoinsBalanceResponse)
|
||||||
|
async def get_coins_balance(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get current user's coins balance with recent transactions"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(CoinTransaction)
|
||||||
|
.where(CoinTransaction.user_id == current_user.id)
|
||||||
|
.order_by(CoinTransaction.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
transactions = result.scalars().all()
|
||||||
|
|
||||||
|
return CoinsBalanceResponse(
|
||||||
|
balance=current_user.coins_balance,
|
||||||
|
recent_transactions=[CoinTransactionResponse.model_validate(t) for t in transactions],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions", response_model=list[CoinTransactionResponse])
|
||||||
|
async def get_coin_transactions(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
):
|
||||||
|
"""Get user's coin transaction history"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(CoinTransaction)
|
||||||
|
.where(CoinTransaction.user_id == current_user.id)
|
||||||
|
.order_by(CoinTransaction.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(min(limit, 100))
|
||||||
|
)
|
||||||
|
transactions = result.scalars().all()
|
||||||
|
return [CoinTransactionResponse.model_validate(t) for t in transactions]
|
||||||
|
|
||||||
|
|
||||||
|
# === Certification (organizer endpoints) ===
|
||||||
|
|
||||||
|
@router.post("/certification/{marathon_id}/request", response_model=CertificationStatusResponse)
|
||||||
|
async def request_certification(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Request certification for a marathon (organizer only)"""
|
||||||
|
# Check user is organizer
|
||||||
|
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")
|
||||||
|
|
||||||
|
if marathon.creator_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Only the creator can request certification")
|
||||||
|
|
||||||
|
if marathon.certification_status != CertificationStatus.NONE.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Marathon already has certification status: {marathon.certification_status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
marathon.certification_status = CertificationStatus.PENDING.value
|
||||||
|
marathon.certification_requested_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(marathon)
|
||||||
|
|
||||||
|
return CertificationStatusResponse(
|
||||||
|
marathon_id=marathon.id,
|
||||||
|
certification_status=marathon.certification_status,
|
||||||
|
is_certified=marathon.is_certified,
|
||||||
|
certification_requested_at=marathon.certification_requested_at,
|
||||||
|
certified_at=marathon.certified_at,
|
||||||
|
certified_by_nickname=None,
|
||||||
|
rejection_reason=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/certification/{marathon_id}/request", response_model=MessageResponse)
|
||||||
|
async def cancel_certification_request(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Cancel certification request (organizer only)"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
if marathon.creator_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Only the creator can cancel certification request")
|
||||||
|
|
||||||
|
if marathon.certification_status != CertificationStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="No pending certification request to cancel")
|
||||||
|
|
||||||
|
marathon.certification_status = CertificationStatus.NONE.value
|
||||||
|
marathon.certification_requested_at = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(message="Certification request cancelled")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/certification/{marathon_id}", response_model=CertificationStatusResponse)
|
||||||
|
async def get_certification_status(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get certification status of a marathon"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon)
|
||||||
|
.options(selectinload(Marathon.certified_by))
|
||||||
|
.where(Marathon.id == marathon_id)
|
||||||
|
)
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
return CertificationStatusResponse(
|
||||||
|
marathon_id=marathon.id,
|
||||||
|
certification_status=marathon.certification_status,
|
||||||
|
is_certified=marathon.is_certified,
|
||||||
|
certification_requested_at=marathon.certification_requested_at,
|
||||||
|
certified_at=marathon.certified_at,
|
||||||
|
certified_by_nickname=marathon.certified_by.nickname if marathon.certified_by else None,
|
||||||
|
rejection_reason=marathon.certification_rejection_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Admin endpoints ===
|
||||||
|
|
||||||
|
@router.get("/admin/items", response_model=list[ShopItemResponse])
|
||||||
|
async def admin_get_all_items(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get all shop items including inactive (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
items = await shop_service.get_available_items(db, include_unavailable=True)
|
||||||
|
return [ShopItemResponse.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/items", response_model=ShopItemResponse)
|
||||||
|
async def admin_create_item(
|
||||||
|
data: ShopItemCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Create a new shop item (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Check code uniqueness
|
||||||
|
existing = await shop_service.get_item_by_code(db, data.code)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Item with code '{data.code}' already exists")
|
||||||
|
|
||||||
|
item = ShopItem(
|
||||||
|
item_type=data.item_type,
|
||||||
|
code=data.code,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
price=data.price,
|
||||||
|
rarity=data.rarity,
|
||||||
|
asset_data=data.asset_data,
|
||||||
|
is_active=data.is_active,
|
||||||
|
available_from=data.available_from,
|
||||||
|
available_until=data.available_until,
|
||||||
|
stock_limit=data.stock_limit,
|
||||||
|
stock_remaining=data.stock_limit, # Initialize remaining = limit
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(item)
|
||||||
|
|
||||||
|
return ShopItemResponse.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/items/{item_id}", response_model=ShopItemResponse)
|
||||||
|
async def admin_update_item(
|
||||||
|
item_id: int,
|
||||||
|
data: ShopItemUpdate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Update a shop item (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
item = await shop_service.get_item_by_id(db, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
if data.name is not None:
|
||||||
|
item.name = data.name
|
||||||
|
if data.description is not None:
|
||||||
|
item.description = data.description
|
||||||
|
if data.price is not None:
|
||||||
|
item.price = data.price
|
||||||
|
if data.rarity is not None:
|
||||||
|
item.rarity = data.rarity
|
||||||
|
if data.asset_data is not None:
|
||||||
|
item.asset_data = data.asset_data
|
||||||
|
if data.is_active is not None:
|
||||||
|
item.is_active = data.is_active
|
||||||
|
if data.available_from is not None:
|
||||||
|
item.available_from = data.available_from
|
||||||
|
if data.available_until is not None:
|
||||||
|
item.available_until = data.available_until
|
||||||
|
if data.stock_limit is not None:
|
||||||
|
# If increasing limit, also increase remaining
|
||||||
|
if item.stock_limit is not None and data.stock_limit > item.stock_limit:
|
||||||
|
diff = data.stock_limit - item.stock_limit
|
||||||
|
item.stock_remaining = (item.stock_remaining or 0) + diff
|
||||||
|
item.stock_limit = data.stock_limit
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(item)
|
||||||
|
|
||||||
|
return ShopItemResponse.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/items/{item_id}", response_model=MessageResponse)
|
||||||
|
async def admin_delete_item(
|
||||||
|
item_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Delete a shop item (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
item = await shop_service.get_item_by_id(db, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
await db.delete(item)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(message=f"Item '{item.name}' deleted")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/users/{user_id}/coins/grant", response_model=MessageResponse)
|
||||||
|
async def admin_grant_coins(
|
||||||
|
user_id: int,
|
||||||
|
data: AdminCoinsRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Grant coins to a user (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
await coins_service.admin_grant_coins(db, user, data.amount, data.reason, current_user.id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(message=f"Granted {data.amount} coins to {user.nickname}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/users/{user_id}/coins/deduct", response_model=MessageResponse)
|
||||||
|
async def admin_deduct_coins(
|
||||||
|
user_id: int,
|
||||||
|
data: AdminCoinsRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Deduct coins from a user (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
success = await coins_service.admin_deduct_coins(db, user, data.amount, data.reason, current_user.id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=400, detail="User doesn't have enough coins")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(message=f"Deducted {data.amount} coins from {user.nickname}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/certification/pending", response_model=list[dict])
|
||||||
|
async def admin_get_pending_certifications(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get list of marathons pending certification (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon)
|
||||||
|
.options(selectinload(Marathon.creator))
|
||||||
|
.where(Marathon.certification_status == CertificationStatus.PENDING.value)
|
||||||
|
.order_by(Marathon.certification_requested_at.asc())
|
||||||
|
)
|
||||||
|
marathons = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": m.id,
|
||||||
|
"title": m.title,
|
||||||
|
"creator_nickname": m.creator.nickname,
|
||||||
|
"status": m.status,
|
||||||
|
"participants_count": len(m.participants) if m.participants else 0,
|
||||||
|
"certification_requested_at": m.certification_requested_at,
|
||||||
|
}
|
||||||
|
for m in marathons
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/certification/{marathon_id}/review", response_model=CertificationStatusResponse)
|
||||||
|
async def admin_review_certification(
|
||||||
|
marathon_id: int,
|
||||||
|
data: CertificationReviewRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Approve or reject marathon certification (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
if marathon.certification_status != CertificationStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Marathon is not pending certification")
|
||||||
|
|
||||||
|
if data.approve:
|
||||||
|
marathon.certification_status = CertificationStatus.CERTIFIED.value
|
||||||
|
marathon.certified_at = datetime.utcnow()
|
||||||
|
marathon.certified_by_id = current_user.id
|
||||||
|
marathon.certification_rejection_reason = None
|
||||||
|
else:
|
||||||
|
if not data.rejection_reason:
|
||||||
|
raise HTTPException(status_code=400, detail="Rejection reason is required")
|
||||||
|
marathon.certification_status = CertificationStatus.REJECTED.value
|
||||||
|
marathon.certification_rejection_reason = data.rejection_reason
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(marathon)
|
||||||
|
|
||||||
|
return CertificationStatusResponse(
|
||||||
|
marathon_id=marathon.id,
|
||||||
|
certification_status=marathon.certification_status,
|
||||||
|
is_certified=marathon.is_certified,
|
||||||
|
certification_requested_at=marathon.certification_requested_at,
|
||||||
|
certified_at=marathon.certified_at,
|
||||||
|
certified_by_nickname=current_user.nickname if data.approve else None,
|
||||||
|
rejection_reason=marathon.certification_rejection_reason,
|
||||||
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
|
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -20,7 +21,16 @@ router = APIRouter(prefix="/users", tags=["users"])
|
|||||||
@router.get("/{user_id}", response_model=UserPublic)
|
@router.get("/{user_id}", response_model=UserPublic)
|
||||||
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
|
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||||
"""Get user profile. Requires authentication."""
|
"""Get user profile. Requires authentication."""
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.where(User.id == user_id)
|
||||||
|
.options(
|
||||||
|
selectinload(User.equipped_frame),
|
||||||
|
selectinload(User.equipped_title),
|
||||||
|
selectinload(User.equipped_name_color),
|
||||||
|
selectinload(User.equipped_background),
|
||||||
|
)
|
||||||
|
)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
@@ -239,7 +249,16 @@ async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser)
|
|||||||
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
|
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
|
||||||
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
|
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||||
"""Получить публичный профиль пользователя со статистикой. Requires authentication."""
|
"""Получить публичный профиль пользователя со статистикой. Requires authentication."""
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.where(User.id == user_id)
|
||||||
|
.options(
|
||||||
|
selectinload(User.equipped_frame),
|
||||||
|
selectinload(User.equipped_title),
|
||||||
|
selectinload(User.equipped_name_color),
|
||||||
|
selectinload(User.equipped_background),
|
||||||
|
)
|
||||||
|
)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
@@ -254,8 +273,14 @@ async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUse
|
|||||||
id=user.id,
|
id=user.id,
|
||||||
nickname=user.nickname,
|
nickname=user.nickname,
|
||||||
avatar_url=user.avatar_url,
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_avatar_url=user.telegram_avatar_url,
|
||||||
|
role=user.role,
|
||||||
created_at=user.created_at,
|
created_at=user.created_at,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
|
equipped_frame=user.equipped_frame,
|
||||||
|
equipped_title=user.equipped_title,
|
||||||
|
equipped_name_color=user.equipped_name_color,
|
||||||
|
equipped_background=user.equipped_background,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from app.schemas.game import PlaythroughInfo
|
|||||||
from app.services.points import PointsService
|
from app.services.points import PointsService
|
||||||
from app.services.events import event_service
|
from app.services.events import event_service
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
|
from app.services.coins import coins_service
|
||||||
|
from app.services.consumables import consumables_service
|
||||||
from app.api.v1.games import get_available_games_for_participant
|
from app.api.v1.games import get_available_games_for_participant
|
||||||
|
|
||||||
router = APIRouter(tags=["wheel"])
|
router = APIRouter(tags=["wheel"])
|
||||||
@@ -619,6 +621,11 @@ 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
|
||||||
|
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
|
||||||
|
if boost_multiplier > 1.0:
|
||||||
|
total_points = int(total_points * boost_multiplier)
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.COMPLETED.value
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
assignment.points_earned = total_points
|
assignment.points_earned = total_points
|
||||||
@@ -630,6 +637,15 @@ async def complete_assignment(
|
|||||||
participant.current_streak += 1
|
participant.current_streak += 1
|
||||||
participant.drop_count = 0
|
participant.drop_count = 0
|
||||||
|
|
||||||
|
# Get marathon and award coins if certified
|
||||||
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = marathon_result.scalar_one()
|
||||||
|
coins_earned = 0
|
||||||
|
if marathon.is_certified:
|
||||||
|
coins_earned = await coins_service.award_playthrough_coins(
|
||||||
|
db, current_user, participant, marathon, total_points, assignment.id
|
||||||
|
)
|
||||||
|
|
||||||
# Check if this is a redo of a previously disputed assignment
|
# Check if this is a redo of a previously disputed assignment
|
||||||
is_redo = (
|
is_redo = (
|
||||||
assignment.dispute is not None and
|
assignment.dispute is not None and
|
||||||
@@ -648,6 +664,10 @@ async def complete_assignment(
|
|||||||
}
|
}
|
||||||
if is_redo:
|
if is_redo:
|
||||||
activity_data["is_redo"] = True
|
activity_data["is_redo"] = True
|
||||||
|
if boost_multiplier > 1.0:
|
||||||
|
activity_data["boost_multiplier"] = boost_multiplier
|
||||||
|
if coins_earned > 0:
|
||||||
|
activity_data["coins_earned"] = coins_earned
|
||||||
if playthrough_event:
|
if playthrough_event:
|
||||||
activity_data["event_type"] = playthrough_event.type
|
activity_data["event_type"] = playthrough_event.type
|
||||||
activity_data["event_bonus"] = event_bonus
|
activity_data["event_bonus"] = event_bonus
|
||||||
@@ -673,6 +693,7 @@ async def complete_assignment(
|
|||||||
streak_bonus=streak_bonus,
|
streak_bonus=streak_bonus,
|
||||||
total_points=participant.total_points,
|
total_points=participant.total_points,
|
||||||
new_streak=participant.current_streak,
|
new_streak=participant.current_streak,
|
||||||
|
coins_earned=coins_earned,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regular challenge completion
|
# Regular challenge completion
|
||||||
@@ -707,6 +728,11 @@ 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
|
||||||
|
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
|
||||||
|
if boost_multiplier > 1.0:
|
||||||
|
total_points = int(total_points * boost_multiplier)
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.COMPLETED.value
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
assignment.points_earned = total_points
|
assignment.points_earned = total_points
|
||||||
@@ -718,6 +744,15 @@ async def complete_assignment(
|
|||||||
participant.current_streak += 1
|
participant.current_streak += 1
|
||||||
participant.drop_count = 0
|
participant.drop_count = 0
|
||||||
|
|
||||||
|
# Get marathon and award coins if certified
|
||||||
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = marathon_result.scalar_one()
|
||||||
|
coins_earned = 0
|
||||||
|
if marathon.is_certified:
|
||||||
|
coins_earned = await coins_service.award_challenge_coins(
|
||||||
|
db, current_user, participant, marathon, challenge.difficulty, assignment.id
|
||||||
|
)
|
||||||
|
|
||||||
# Check if this is a redo of a previously disputed assignment
|
# Check if this is a redo of a previously disputed assignment
|
||||||
is_redo = (
|
is_redo = (
|
||||||
assignment.dispute is not None and
|
assignment.dispute is not None and
|
||||||
@@ -735,6 +770,10 @@ async def complete_assignment(
|
|||||||
}
|
}
|
||||||
if is_redo:
|
if is_redo:
|
||||||
activity_data["is_redo"] = True
|
activity_data["is_redo"] = True
|
||||||
|
if boost_multiplier > 1.0:
|
||||||
|
activity_data["boost_multiplier"] = boost_multiplier
|
||||||
|
if coins_earned > 0:
|
||||||
|
activity_data["coins_earned"] = coins_earned
|
||||||
if assignment.event_type == EventType.JACKPOT.value:
|
if assignment.event_type == EventType.JACKPOT.value:
|
||||||
activity_data["event_type"] = assignment.event_type
|
activity_data["event_type"] = assignment.event_type
|
||||||
activity_data["event_bonus"] = event_bonus
|
activity_data["event_bonus"] = event_bonus
|
||||||
@@ -799,6 +838,7 @@ async def complete_assignment(
|
|||||||
streak_bonus=streak_bonus,
|
streak_bonus=streak_bonus,
|
||||||
total_points=participant.total_points,
|
total_points=participant.total_points,
|
||||||
new_streak=participant.current_streak,
|
new_streak=participant.current_streak,
|
||||||
|
coins_earned=coins_earned,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -847,6 +887,12 @@ 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
|
||||||
|
shield_used = False
|
||||||
|
if consumables_service.consume_shield(participant):
|
||||||
|
penalty = 0
|
||||||
|
shield_used = True
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.DROPPED.value
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
assignment.completed_at = datetime.utcnow()
|
assignment.completed_at = datetime.utcnow()
|
||||||
@@ -875,6 +921,8 @@ 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
|
||||||
@@ -893,6 +941,7 @@ 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
|
||||||
@@ -904,6 +953,12 @@ 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
|
||||||
|
shield_used = False
|
||||||
|
if consumables_service.consume_shield(participant):
|
||||||
|
penalty = 0
|
||||||
|
shield_used = True
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.DROPPED.value
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
assignment.completed_at = datetime.utcnow()
|
assignment.completed_at = datetime.utcnow()
|
||||||
@@ -920,6 +975,8 @@ 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:
|
||||||
@@ -939,6 +996,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
|
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode, CertificationStatus
|
||||||
from app.models.participant import Participant, ParticipantRole
|
from app.models.participant import Participant, ParticipantRole
|
||||||
from app.models.game import Game, GameStatus, GameType
|
from app.models.game import Game, GameStatus, GameType
|
||||||
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
|
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
|
||||||
@@ -13,6 +13,10 @@ from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVo
|
|||||||
from app.models.admin_log import AdminLog, AdminActionType
|
from app.models.admin_log import AdminLog, AdminActionType
|
||||||
from app.models.admin_2fa import Admin2FASession
|
from app.models.admin_2fa import Admin2FASession
|
||||||
from app.models.static_content import StaticContent
|
from app.models.static_content import StaticContent
|
||||||
|
from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType
|
||||||
|
from app.models.inventory import UserInventory
|
||||||
|
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
|
||||||
|
from app.models.consumable_usage import ConsumableUsage
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -20,6 +24,7 @@ __all__ = [
|
|||||||
"Marathon",
|
"Marathon",
|
||||||
"MarathonStatus",
|
"MarathonStatus",
|
||||||
"GameProposalMode",
|
"GameProposalMode",
|
||||||
|
"CertificationStatus",
|
||||||
"Participant",
|
"Participant",
|
||||||
"ParticipantRole",
|
"ParticipantRole",
|
||||||
"Game",
|
"Game",
|
||||||
@@ -49,4 +54,12 @@ __all__ = [
|
|||||||
"AdminActionType",
|
"AdminActionType",
|
||||||
"Admin2FASession",
|
"Admin2FASession",
|
||||||
"StaticContent",
|
"StaticContent",
|
||||||
|
"ShopItem",
|
||||||
|
"ShopItemType",
|
||||||
|
"ItemRarity",
|
||||||
|
"ConsumableType",
|
||||||
|
"UserInventory",
|
||||||
|
"CoinTransaction",
|
||||||
|
"CoinTransactionType",
|
||||||
|
"ConsumableUsage",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class AdminActionType(str, Enum):
|
|||||||
# Marathon actions
|
# Marathon actions
|
||||||
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
||||||
MARATHON_DELETE = "marathon_delete"
|
MARATHON_DELETE = "marathon_delete"
|
||||||
|
MARATHON_CERTIFY = "marathon_certify"
|
||||||
|
MARATHON_REVOKE_CERTIFICATION = "marathon_revoke_certification"
|
||||||
|
|
||||||
# Content actions
|
# Content actions
|
||||||
CONTENT_UPDATE = "content_update"
|
CONTENT_UPDATE = "content_update"
|
||||||
|
|||||||
41
backend/app/models/coin_transaction.py
Normal file
41
backend/app/models/coin_transaction.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class CoinTransactionType(str, Enum):
|
||||||
|
CHALLENGE_COMPLETE = "challenge_complete"
|
||||||
|
PLAYTHROUGH_COMPLETE = "playthrough_complete"
|
||||||
|
MARATHON_WIN = "marathon_win"
|
||||||
|
MARATHON_PLACE = "marathon_place"
|
||||||
|
COMMON_ENEMY_BONUS = "common_enemy_bonus"
|
||||||
|
PURCHASE = "purchase"
|
||||||
|
REFUND = "refund"
|
||||||
|
ADMIN_GRANT = "admin_grant"
|
||||||
|
ADMIN_DEDUCT = "admin_deduct"
|
||||||
|
|
||||||
|
|
||||||
|
class CoinTransaction(Base):
|
||||||
|
__tablename__ = "coin_transactions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
amount: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
transaction_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
reference_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship(
|
||||||
|
"User",
|
||||||
|
back_populates="coin_transactions"
|
||||||
|
)
|
||||||
30
backend/app/models/consumable_usage.py
Normal file
30
backend/app/models/consumable_usage.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.shop import ShopItem
|
||||||
|
from app.models.marathon import Marathon
|
||||||
|
from app.models.assignment import Assignment
|
||||||
|
|
||||||
|
|
||||||
|
class ConsumableUsage(Base):
|
||||||
|
__tablename__ = "consumable_usages"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
marathon_id: Mapped[int | None] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
used_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
effect_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship("User")
|
||||||
|
item: Mapped["ShopItem"] = relationship("ShopItem")
|
||||||
|
marathon: Mapped["Marathon | None"] = relationship("Marathon")
|
||||||
|
assignment: Mapped["Assignment | None"] = relationship("Assignment")
|
||||||
39
backend/app/models/inventory.py
Normal file
39
backend/app/models/inventory.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, Boolean
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.shop import ShopItem
|
||||||
|
|
||||||
|
|
||||||
|
class UserInventory(Base):
|
||||||
|
__tablename__ = "user_inventory"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
quantity: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
equipped: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
purchased_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship(
|
||||||
|
"User",
|
||||||
|
back_populates="inventory"
|
||||||
|
)
|
||||||
|
item: Mapped["ShopItem"] = relationship(
|
||||||
|
"ShopItem",
|
||||||
|
back_populates="inventory_items"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""Check if item has expired"""
|
||||||
|
if self.expires_at is None:
|
||||||
|
return False
|
||||||
|
return datetime.utcnow() > self.expires_at
|
||||||
@@ -17,6 +17,13 @@ class GameProposalMode(str, Enum):
|
|||||||
ORGANIZER_ONLY = "organizer_only"
|
ORGANIZER_ONLY = "organizer_only"
|
||||||
|
|
||||||
|
|
||||||
|
class CertificationStatus(str, Enum):
|
||||||
|
NONE = "none"
|
||||||
|
PENDING = "pending"
|
||||||
|
CERTIFIED = "certified"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
class Marathon(Base):
|
class Marathon(Base):
|
||||||
__tablename__ = "marathons"
|
__tablename__ = "marathons"
|
||||||
|
|
||||||
@@ -35,12 +42,28 @@ class Marathon(Base):
|
|||||||
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Certification fields
|
||||||
|
certification_status: Mapped[str] = mapped_column(String(20), default=CertificationStatus.NONE.value)
|
||||||
|
certification_requested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
certified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
certified_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
certification_rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Shop/Consumables settings
|
||||||
|
allow_skips: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
max_skips_per_participant: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
allow_consumables: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
creator: Mapped["User"] = relationship(
|
creator: Mapped["User"] = relationship(
|
||||||
"User",
|
"User",
|
||||||
back_populates="created_marathons",
|
back_populates="created_marathons",
|
||||||
foreign_keys=[creator_id]
|
foreign_keys=[creator_id]
|
||||||
)
|
)
|
||||||
|
certified_by: Mapped["User | None"] = relationship(
|
||||||
|
"User",
|
||||||
|
foreign_keys=[certified_by_id]
|
||||||
|
)
|
||||||
participants: Mapped[list["Participant"]] = relationship(
|
participants: Mapped[list["Participant"]] = relationship(
|
||||||
"Participant",
|
"Participant",
|
||||||
back_populates="marathon",
|
back_populates="marathon",
|
||||||
@@ -61,3 +84,7 @@ class Marathon(Base):
|
|||||||
back_populates="marathon",
|
back_populates="marathon",
|
||||||
cascade="all, delete-orphan"
|
cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_certified(self) -> bool:
|
||||||
|
return self.certification_status == CertificationStatus.CERTIFIED.value
|
||||||
|
|||||||
@@ -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
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -26,6 +26,14 @@ class Participant(Base):
|
|||||||
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
|
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
|
||||||
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Shop: coins earned in this marathon
|
||||||
|
coins_earned: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
# Shop: consumables state
|
||||||
|
skips_used: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user: Mapped["User"] = relationship("User", back_populates="participations")
|
user: Mapped["User"] = relationship("User", back_populates="participations")
|
||||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")
|
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")
|
||||||
|
|||||||
81
backend/app/models/shop.py
Normal file
81
backend/app/models/shop.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from sqlalchemy import String, Text, DateTime, Integer, Boolean, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.inventory import UserInventory
|
||||||
|
|
||||||
|
|
||||||
|
class ShopItemType(str, Enum):
|
||||||
|
FRAME = "frame"
|
||||||
|
TITLE = "title"
|
||||||
|
NAME_COLOR = "name_color"
|
||||||
|
BACKGROUND = "background"
|
||||||
|
CONSUMABLE = "consumable"
|
||||||
|
|
||||||
|
|
||||||
|
class ItemRarity(str, Enum):
|
||||||
|
COMMON = "common"
|
||||||
|
UNCOMMON = "uncommon"
|
||||||
|
RARE = "rare"
|
||||||
|
EPIC = "epic"
|
||||||
|
LEGENDARY = "legendary"
|
||||||
|
|
||||||
|
|
||||||
|
class ConsumableType(str, Enum):
|
||||||
|
SKIP = "skip"
|
||||||
|
SHIELD = "shield"
|
||||||
|
BOOST = "boost"
|
||||||
|
REROLL = "reroll"
|
||||||
|
|
||||||
|
|
||||||
|
class ShopItem(Base):
|
||||||
|
__tablename__ = "shop_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
item_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
price: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
rarity: Mapped[str] = mapped_column(String(20), default=ItemRarity.COMMON.value)
|
||||||
|
asset_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
available_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
available_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
stock_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
stock_remaining: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
inventory_items: Mapped[list["UserInventory"]] = relationship(
|
||||||
|
"UserInventory",
|
||||||
|
back_populates="item"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if item is currently available for purchase"""
|
||||||
|
if not self.is_active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
if self.available_from and self.available_from > now:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.available_until and self.available_until < now:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.stock_remaining is not None and self.stock_remaining <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_consumable(self) -> bool:
|
||||||
|
return self.item_type == ShopItemType.CONSUMABLE.value
|
||||||
@@ -2,9 +2,15 @@ from datetime import datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
|
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.shop import ShopItem
|
||||||
|
from app.models.inventory import UserInventory
|
||||||
|
from app.models.coin_transaction import CoinTransaction
|
||||||
|
|
||||||
|
|
||||||
class UserRole(str, Enum):
|
class UserRole(str, Enum):
|
||||||
USER = "user"
|
USER = "user"
|
||||||
@@ -39,6 +45,15 @@ class User(Base):
|
|||||||
notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True)
|
notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True)
|
notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Shop: coins balance
|
||||||
|
coins_balance: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
# Shop: equipped cosmetics
|
||||||
|
equipped_frame_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
equipped_title_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
equipped_name_color_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
equipped_background_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
created_marathons: Mapped[list["Marathon"]] = relationship(
|
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||||
"Marathon",
|
"Marathon",
|
||||||
@@ -65,6 +80,32 @@ class User(Base):
|
|||||||
foreign_keys=[banned_by_id]
|
foreign_keys=[banned_by_id]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Shop relationships
|
||||||
|
inventory: Mapped[list["UserInventory"]] = relationship(
|
||||||
|
"UserInventory",
|
||||||
|
back_populates="user"
|
||||||
|
)
|
||||||
|
coin_transactions: Mapped[list["CoinTransaction"]] = relationship(
|
||||||
|
"CoinTransaction",
|
||||||
|
back_populates="user"
|
||||||
|
)
|
||||||
|
equipped_frame: Mapped["ShopItem | None"] = relationship(
|
||||||
|
"ShopItem",
|
||||||
|
foreign_keys=[equipped_frame_id]
|
||||||
|
)
|
||||||
|
equipped_title: Mapped["ShopItem | None"] = relationship(
|
||||||
|
"ShopItem",
|
||||||
|
foreign_keys=[equipped_title_id]
|
||||||
|
)
|
||||||
|
equipped_name_color: Mapped["ShopItem | None"] = relationship(
|
||||||
|
"ShopItem",
|
||||||
|
foreign_keys=[equipped_name_color_id]
|
||||||
|
)
|
||||||
|
equipped_background: Mapped["ShopItem | None"] = relationship(
|
||||||
|
"ShopItem",
|
||||||
|
foreign_keys=[equipped_background_id]
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_admin(self) -> bool:
|
def is_admin(self) -> bool:
|
||||||
return self.role == UserRole.ADMIN.value
|
return self.role == UserRole.ADMIN.value
|
||||||
|
|||||||
@@ -104,6 +104,27 @@ from app.schemas.admin import (
|
|||||||
LoginResponse,
|
LoginResponse,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
)
|
)
|
||||||
|
from app.schemas.shop import (
|
||||||
|
ShopItemCreate,
|
||||||
|
ShopItemUpdate,
|
||||||
|
ShopItemResponse,
|
||||||
|
InventoryItemResponse,
|
||||||
|
PurchaseRequest,
|
||||||
|
PurchaseResponse,
|
||||||
|
UseConsumableRequest,
|
||||||
|
UseConsumableResponse,
|
||||||
|
EquipItemRequest,
|
||||||
|
EquipItemResponse,
|
||||||
|
CoinTransactionResponse,
|
||||||
|
CoinsBalanceResponse,
|
||||||
|
AdminCoinsRequest,
|
||||||
|
UserCosmeticsResponse,
|
||||||
|
CertificationRequestSchema,
|
||||||
|
CertificationReviewRequest,
|
||||||
|
CertificationStatusResponse,
|
||||||
|
ConsumablesStatusResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.user import ShopItemPublic
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# User
|
# User
|
||||||
@@ -202,4 +223,24 @@ __all__ = [
|
|||||||
"TwoFactorVerifyRequest",
|
"TwoFactorVerifyRequest",
|
||||||
"LoginResponse",
|
"LoginResponse",
|
||||||
"DashboardStats",
|
"DashboardStats",
|
||||||
|
# Shop
|
||||||
|
"ShopItemCreate",
|
||||||
|
"ShopItemUpdate",
|
||||||
|
"ShopItemResponse",
|
||||||
|
"ShopItemPublic",
|
||||||
|
"InventoryItemResponse",
|
||||||
|
"PurchaseRequest",
|
||||||
|
"PurchaseResponse",
|
||||||
|
"UseConsumableRequest",
|
||||||
|
"UseConsumableResponse",
|
||||||
|
"EquipItemRequest",
|
||||||
|
"EquipItemResponse",
|
||||||
|
"CoinTransactionResponse",
|
||||||
|
"CoinsBalanceResponse",
|
||||||
|
"AdminCoinsRequest",
|
||||||
|
"UserCosmeticsResponse",
|
||||||
|
"CertificationRequestSchema",
|
||||||
|
"CertificationReviewRequest",
|
||||||
|
"CertificationStatusResponse",
|
||||||
|
"ConsumablesStatusResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -79,12 +79,14 @@ class CompleteResult(BaseModel):
|
|||||||
streak_bonus: int
|
streak_bonus: int
|
||||||
total_points: int
|
total_points: int
|
||||||
new_streak: int
|
new_streak: int
|
||||||
|
coins_earned: int = 0 # Coins earned (only in certified marathons)
|
||||||
|
|
||||||
|
|
||||||
class DropResult(BaseModel):
|
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):
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class MarathonCreate(MarathonBase):
|
|||||||
duration_days: int = Field(default=30, ge=1, le=365)
|
duration_days: int = Field(default=30, ge=1, le=365)
|
||||||
is_public: bool = False
|
is_public: bool = False
|
||||||
game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$")
|
game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$")
|
||||||
|
# Shop/Consumables settings
|
||||||
|
allow_skips: bool = True
|
||||||
|
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
|
||||||
|
allow_consumables: bool = True
|
||||||
|
|
||||||
|
|
||||||
class MarathonUpdate(BaseModel):
|
class MarathonUpdate(BaseModel):
|
||||||
@@ -23,6 +27,10 @@ class MarathonUpdate(BaseModel):
|
|||||||
is_public: bool | None = None
|
is_public: bool | None = None
|
||||||
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
|
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
|
||||||
auto_events_enabled: bool | None = None
|
auto_events_enabled: bool | None = None
|
||||||
|
# Shop/Consumables settings
|
||||||
|
allow_skips: bool | None = None
|
||||||
|
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
|
||||||
|
allow_consumables: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ParticipantInfo(BaseModel):
|
class ParticipantInfo(BaseModel):
|
||||||
@@ -32,6 +40,13 @@ class ParticipantInfo(BaseModel):
|
|||||||
current_streak: int
|
current_streak: int
|
||||||
drop_count: int
|
drop_count: int
|
||||||
joined_at: datetime
|
joined_at: datetime
|
||||||
|
# Shop: coins and consumables status
|
||||||
|
coins_earned: int = 0
|
||||||
|
skips_used: int = 0
|
||||||
|
has_shield: bool = False
|
||||||
|
has_active_boost: bool = False
|
||||||
|
boost_multiplier: float | None = None
|
||||||
|
boost_expires_at: datetime | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -56,6 +71,13 @@ class MarathonResponse(MarathonBase):
|
|||||||
games_count: int
|
games_count: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
my_participation: ParticipantInfo | None = None
|
my_participation: ParticipantInfo | None = None
|
||||||
|
# Certification
|
||||||
|
certification_status: str = "none"
|
||||||
|
is_certified: bool = False
|
||||||
|
# Shop/Consumables settings
|
||||||
|
allow_skips: bool = True
|
||||||
|
max_skips_per_participant: int | None = None
|
||||||
|
allow_consumables: bool = True
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -74,6 +96,8 @@ class MarathonListItem(BaseModel):
|
|||||||
participants_count: int
|
participants_count: int
|
||||||
start_date: datetime | None
|
start_date: datetime | None
|
||||||
end_date: datetime | None
|
end_date: datetime | None
|
||||||
|
# Certification badge
|
||||||
|
is_certified: bool = False
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
200
backend/app/schemas/shop.py
Normal file
200
backend/app/schemas/shop.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for Shop system
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# === Shop Items ===
|
||||||
|
|
||||||
|
class ShopItemBase(BaseModel):
|
||||||
|
"""Base schema for shop items"""
|
||||||
|
item_type: str
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
price: int
|
||||||
|
rarity: str = "common"
|
||||||
|
asset_data: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShopItemCreate(ShopItemBase):
|
||||||
|
"""Schema for creating a shop item (admin)"""
|
||||||
|
is_active: bool = True
|
||||||
|
available_from: datetime | None = None
|
||||||
|
available_until: datetime | None = None
|
||||||
|
stock_limit: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShopItemUpdate(BaseModel):
|
||||||
|
"""Schema for updating a shop item (admin)"""
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
price: int | None = Field(None, ge=1)
|
||||||
|
rarity: str | None = None
|
||||||
|
asset_data: dict | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
available_from: datetime | None = None
|
||||||
|
available_until: datetime | None = None
|
||||||
|
stock_limit: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShopItemResponse(ShopItemBase):
|
||||||
|
"""Schema for shop item response"""
|
||||||
|
id: int
|
||||||
|
is_active: bool
|
||||||
|
available_from: datetime | None
|
||||||
|
available_until: datetime | None
|
||||||
|
stock_limit: int | None
|
||||||
|
stock_remaining: int | None
|
||||||
|
created_at: datetime
|
||||||
|
is_available: bool # Computed property
|
||||||
|
is_owned: bool = False # Set by API based on user
|
||||||
|
is_equipped: bool = False # Set by API based on user
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# === Inventory ===
|
||||||
|
|
||||||
|
class InventoryItemResponse(BaseModel):
|
||||||
|
"""Schema for user inventory item"""
|
||||||
|
id: int
|
||||||
|
item: ShopItemResponse
|
||||||
|
quantity: int
|
||||||
|
equipped: bool
|
||||||
|
purchased_at: datetime
|
||||||
|
expires_at: datetime | None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# === Purchases ===
|
||||||
|
|
||||||
|
class PurchaseRequest(BaseModel):
|
||||||
|
"""Schema for purchase request"""
|
||||||
|
item_id: int
|
||||||
|
quantity: int = Field(default=1, ge=1, le=10)
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseResponse(BaseModel):
|
||||||
|
"""Schema for purchase response"""
|
||||||
|
success: bool
|
||||||
|
item: ShopItemResponse
|
||||||
|
quantity: int
|
||||||
|
total_cost: int
|
||||||
|
new_balance: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# === Consumables ===
|
||||||
|
|
||||||
|
class UseConsumableRequest(BaseModel):
|
||||||
|
"""Schema for using a consumable"""
|
||||||
|
item_code: str # 'skip', 'shield', 'boost', 'reroll'
|
||||||
|
marathon_id: int
|
||||||
|
assignment_id: int | None = None # Required for skip and reroll
|
||||||
|
|
||||||
|
|
||||||
|
class UseConsumableResponse(BaseModel):
|
||||||
|
"""Schema for consumable use response"""
|
||||||
|
success: bool
|
||||||
|
item_code: str
|
||||||
|
remaining_quantity: int
|
||||||
|
effect_description: str
|
||||||
|
effect_data: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# === Equipment ===
|
||||||
|
|
||||||
|
class EquipItemRequest(BaseModel):
|
||||||
|
"""Schema for equipping an item"""
|
||||||
|
inventory_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class EquipItemResponse(BaseModel):
|
||||||
|
"""Schema for equip response"""
|
||||||
|
success: bool
|
||||||
|
item_type: str
|
||||||
|
equipped_item: ShopItemResponse | None
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# === Coins ===
|
||||||
|
|
||||||
|
class CoinTransactionResponse(BaseModel):
|
||||||
|
"""Schema for coin transaction"""
|
||||||
|
id: int
|
||||||
|
amount: int
|
||||||
|
transaction_type: str
|
||||||
|
description: str | None
|
||||||
|
reference_type: str | None
|
||||||
|
reference_id: int | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class CoinsBalanceResponse(BaseModel):
|
||||||
|
"""Schema for coins balance with recent transactions"""
|
||||||
|
balance: int
|
||||||
|
recent_transactions: list[CoinTransactionResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminCoinsRequest(BaseModel):
|
||||||
|
"""Schema for admin coin operations"""
|
||||||
|
amount: int = Field(..., ge=1)
|
||||||
|
reason: str = Field(..., min_length=1, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
# === User Cosmetics ===
|
||||||
|
|
||||||
|
class UserCosmeticsResponse(BaseModel):
|
||||||
|
"""Schema for user's equipped cosmetics"""
|
||||||
|
frame: ShopItemResponse | None = None
|
||||||
|
title: ShopItemResponse | None = None
|
||||||
|
name_color: ShopItemResponse | None = None
|
||||||
|
background: ShopItemResponse | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# === Certification ===
|
||||||
|
|
||||||
|
class CertificationRequestSchema(BaseModel):
|
||||||
|
"""Schema for requesting marathon certification"""
|
||||||
|
pass # No fields needed for now
|
||||||
|
|
||||||
|
|
||||||
|
class CertificationReviewRequest(BaseModel):
|
||||||
|
"""Schema for admin reviewing certification"""
|
||||||
|
approve: bool
|
||||||
|
rejection_reason: str | None = Field(None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class CertificationStatusResponse(BaseModel):
|
||||||
|
"""Schema for certification status"""
|
||||||
|
marathon_id: int
|
||||||
|
certification_status: str
|
||||||
|
is_certified: bool
|
||||||
|
certification_requested_at: datetime | None
|
||||||
|
certified_at: datetime | None
|
||||||
|
certified_by_nickname: str | None = None
|
||||||
|
rejection_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# === Consumables Status ===
|
||||||
|
|
||||||
|
class ConsumablesStatusResponse(BaseModel):
|
||||||
|
"""Schema for participant's consumables status in a marathon"""
|
||||||
|
skips_available: int # From inventory
|
||||||
|
skips_used: int # In this marathon
|
||||||
|
skips_remaining: int | None # Based on marathon limit
|
||||||
|
shields_available: int # From inventory
|
||||||
|
has_shield: bool # Currently activated
|
||||||
|
boosts_available: int # From inventory
|
||||||
|
has_active_boost: bool # Currently activated (one-time for next complete)
|
||||||
|
boost_multiplier: float | None # 1.5 if boost active
|
||||||
|
rerolls_available: int # From inventory
|
||||||
@@ -28,6 +28,19 @@ class UserUpdate(BaseModel):
|
|||||||
nickname: str | None = Field(None, min_length=2, max_length=50)
|
nickname: str | None = Field(None, min_length=2, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class ShopItemPublic(BaseModel):
|
||||||
|
"""Minimal shop item info for public display"""
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
item_type: str
|
||||||
|
rarity: str
|
||||||
|
asset_data: dict | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class UserPublic(UserBase):
|
class UserPublic(UserBase):
|
||||||
"""Public user info visible to other users - minimal data"""
|
"""Public user info visible to other users - minimal data"""
|
||||||
id: int
|
id: int
|
||||||
@@ -35,6 +48,11 @@ class UserPublic(UserBase):
|
|||||||
role: str = "user"
|
role: str = "user"
|
||||||
telegram_avatar_url: str | None = None # Only TG avatar is public
|
telegram_avatar_url: str | None = None # Only TG avatar is public
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
# Shop: equipped cosmetics (visible to others)
|
||||||
|
equipped_frame: ShopItemPublic | None = None
|
||||||
|
equipped_title: ShopItemPublic | None = None
|
||||||
|
equipped_name_color: ShopItemPublic | None = None
|
||||||
|
equipped_background: ShopItemPublic | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -51,6 +69,8 @@ class UserPrivate(UserPublic):
|
|||||||
notify_events: bool = True
|
notify_events: bool = True
|
||||||
notify_disputes: bool = True
|
notify_disputes: bool = True
|
||||||
notify_moderation: bool = True
|
notify_moderation: bool = True
|
||||||
|
# Shop: coins balance (only visible to self)
|
||||||
|
coins_balance: int = 0
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
@@ -82,8 +102,15 @@ class UserProfilePublic(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
nickname: str
|
nickname: str
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
|
telegram_avatar_url: str | None = None
|
||||||
|
role: str = "user"
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
stats: UserStats
|
stats: UserStats
|
||||||
|
# Equipped cosmetics
|
||||||
|
equipped_frame: ShopItemPublic | None = None
|
||||||
|
equipped_title: ShopItemPublic | None = None
|
||||||
|
equipped_name_color: ShopItemPublic | None = None
|
||||||
|
equipped_background: ShopItemPublic | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
288
backend/app/services/coins.py
Normal file
288
backend/app/services/coins.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
Coins Service - handles all coin-related operations
|
||||||
|
|
||||||
|
Coins are earned only in certified marathons and can be spent in the shop.
|
||||||
|
"""
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import User, Participant, Marathon, CoinTransaction, CoinTransactionType
|
||||||
|
from app.models.challenge import Difficulty
|
||||||
|
|
||||||
|
|
||||||
|
class CoinsService:
|
||||||
|
"""Service for managing coin transactions and balances"""
|
||||||
|
|
||||||
|
# Coins awarded per challenge difficulty (only in certified marathons)
|
||||||
|
CHALLENGE_COINS = {
|
||||||
|
Difficulty.EASY.value: 5,
|
||||||
|
Difficulty.MEDIUM.value: 12,
|
||||||
|
Difficulty.HARD.value: 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Coins for playthrough = points * this ratio
|
||||||
|
PLAYTHROUGH_COIN_RATIO = 0.05 # 5% of points
|
||||||
|
|
||||||
|
# Coins awarded for marathon placements
|
||||||
|
MARATHON_PLACE_COINS = {
|
||||||
|
1: 100, # 1st place
|
||||||
|
2: 50, # 2nd place
|
||||||
|
3: 30, # 3rd place
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bonus coins for Common Enemy event winners
|
||||||
|
COMMON_ENEMY_BONUS_COINS = {
|
||||||
|
1: 15, # First to complete
|
||||||
|
2: 10, # Second
|
||||||
|
3: 5, # Third
|
||||||
|
}
|
||||||
|
|
||||||
|
async def award_challenge_coins(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
participant: Participant,
|
||||||
|
marathon: Marathon,
|
||||||
|
difficulty: str,
|
||||||
|
assignment_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Award coins for completing a challenge.
|
||||||
|
Only awards coins if marathon is certified.
|
||||||
|
|
||||||
|
Returns: number of coins awarded (0 if marathon not certified)
|
||||||
|
"""
|
||||||
|
if not marathon.is_certified:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
coins = self.CHALLENGE_COINS.get(difficulty, 0)
|
||||||
|
if coins <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Create transaction
|
||||||
|
transaction = CoinTransaction(
|
||||||
|
user_id=user.id,
|
||||||
|
amount=coins,
|
||||||
|
transaction_type=CoinTransactionType.CHALLENGE_COMPLETE.value,
|
||||||
|
reference_type="assignment",
|
||||||
|
reference_id=assignment_id,
|
||||||
|
description=f"Challenge completion ({difficulty})",
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
# Update balances
|
||||||
|
user.coins_balance += coins
|
||||||
|
participant.coins_earned += coins
|
||||||
|
|
||||||
|
return coins
|
||||||
|
|
||||||
|
async def award_playthrough_coins(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
participant: Participant,
|
||||||
|
marathon: Marathon,
|
||||||
|
points: int,
|
||||||
|
assignment_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Award coins for completing a playthrough.
|
||||||
|
Coins = points * PLAYTHROUGH_COIN_RATIO
|
||||||
|
|
||||||
|
Returns: number of coins awarded (0 if marathon not certified)
|
||||||
|
"""
|
||||||
|
if not marathon.is_certified:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
coins = int(points * self.PLAYTHROUGH_COIN_RATIO)
|
||||||
|
if coins <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
transaction = CoinTransaction(
|
||||||
|
user_id=user.id,
|
||||||
|
amount=coins,
|
||||||
|
transaction_type=CoinTransactionType.PLAYTHROUGH_COMPLETE.value,
|
||||||
|
reference_type="assignment",
|
||||||
|
reference_id=assignment_id,
|
||||||
|
description=f"Playthrough completion ({points} points)",
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
user.coins_balance += coins
|
||||||
|
participant.coins_earned += coins
|
||||||
|
|
||||||
|
return coins
|
||||||
|
|
||||||
|
async def award_marathon_place(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
marathon: Marathon,
|
||||||
|
place: int,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Award coins for placing in a marathon (1st, 2nd, 3rd).
|
||||||
|
|
||||||
|
Returns: number of coins awarded (0 if not top 3 or not certified)
|
||||||
|
"""
|
||||||
|
if not marathon.is_certified:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
coins = self.MARATHON_PLACE_COINS.get(place, 0)
|
||||||
|
if coins <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
transaction = CoinTransaction(
|
||||||
|
user_id=user.id,
|
||||||
|
amount=coins,
|
||||||
|
transaction_type=CoinTransactionType.MARATHON_PLACE.value,
|
||||||
|
reference_type="marathon",
|
||||||
|
reference_id=marathon.id,
|
||||||
|
description=f"Marathon #{place} place: {marathon.title}",
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
user.coins_balance += coins
|
||||||
|
|
||||||
|
return coins
|
||||||
|
|
||||||
|
async def award_common_enemy_bonus(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
participant: Participant,
|
||||||
|
marathon: Marathon,
|
||||||
|
rank: int,
|
||||||
|
event_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Award bonus coins for Common Enemy event completion.
|
||||||
|
|
||||||
|
Returns: number of bonus coins awarded
|
||||||
|
"""
|
||||||
|
if not marathon.is_certified:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
coins = self.COMMON_ENEMY_BONUS_COINS.get(rank, 0)
|
||||||
|
if coins <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
transaction = CoinTransaction(
|
||||||
|
user_id=user.id,
|
||||||
|
amount=coins,
|
||||||
|
transaction_type=CoinTransactionType.COMMON_ENEMY_BONUS.value,
|
||||||
|
reference_type="event",
|
||||||
|
reference_id=event_id,
|
||||||
|
description=f"Common Enemy #{rank} place",
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
user.coins_balance += coins
|
||||||
|
participant.coins_earned += coins
|
||||||
|
|
||||||
|
return coins
|
||||||
|
|
||||||
|
async def spend_coins(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
amount: int,
|
||||||
|
description: str,
|
||||||
|
reference_type: str | None = None,
|
||||||
|
reference_id: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Spend coins (for purchases).
|
||||||
|
|
||||||
|
Returns: True if successful, False if insufficient balance
|
||||||
|
"""
|
||||||
|
if user.coins_balance < amount:
|
||||||
|
return False
|
||||||
|
|
||||||
|
transaction = CoinTransaction(
|
||||||
|
user_id=user.id,
|
||||||
|
amount=-amount, # Negative for spending
|
||||||
|
transaction_type=CoinTransactionType.PURCHASE.value,
|
||||||
|
reference_type=reference_type,
|
||||||
|
reference_id=reference_id,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
user.coins_balance -= amount
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def refund_coins(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
amount: int,
|
||||||
|
description: str,
|
||||||
|
reference_type: str | None = None,
|
||||||
|
reference_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Refund coins to user (for failed purchases, etc.)"""
|
||||||
|
transaction = CoinTransaction(
|
||||||
|
user_id=user.id,
|
||||||
|
amount=amount,
|
||||||
|
transaction_type=CoinTransactionType.REFUND.value,
|
||||||
|
reference_type=reference_type,
|
||||||
|
reference_id=reference_id,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
user.coins_balance += amount
|
||||||
|
|
||||||
|
async def admin_grant_coins(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
amount: int,
|
||||||
|
reason: str,
|
||||||
|
admin_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Admin grants coins to user"""
|
||||||
|
transaction = CoinTransaction(
|
||||||
|
user_id=user.id,
|
||||||
|
amount=amount,
|
||||||
|
transaction_type=CoinTransactionType.ADMIN_GRANT.value,
|
||||||
|
reference_type="admin",
|
||||||
|
reference_id=admin_id,
|
||||||
|
description=f"Admin grant: {reason}",
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
user.coins_balance += amount
|
||||||
|
|
||||||
|
async def admin_deduct_coins(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
amount: int,
|
||||||
|
reason: str,
|
||||||
|
admin_id: int,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Admin deducts coins from user.
|
||||||
|
|
||||||
|
Returns: True if successful, False if insufficient balance
|
||||||
|
"""
|
||||||
|
if user.coins_balance < amount:
|
||||||
|
return False
|
||||||
|
|
||||||
|
transaction = CoinTransaction(
|
||||||
|
user_id=user.id,
|
||||||
|
amount=-amount,
|
||||||
|
transaction_type=CoinTransactionType.ADMIN_DEDUCT.value,
|
||||||
|
reference_type="admin",
|
||||||
|
reference_id=admin_id,
|
||||||
|
description=f"Admin deduction: {reason}",
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
user.coins_balance -= amount
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
coins_service = CoinsService()
|
||||||
320
backend/app/services/consumables.py
Normal file
320
backend/app/services/consumables.py
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
"""
|
||||||
|
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
User, Participant, Marathon, Assignment, AssignmentStatus,
|
||||||
|
ShopItem, UserInventory, ConsumableUsage, ConsumableType
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConsumablesService:
|
||||||
|
"""Service for consumable items"""
|
||||||
|
|
||||||
|
# Boost settings
|
||||||
|
BOOST_MULTIPLIER = 1.5
|
||||||
|
|
||||||
|
async def use_skip(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
participant: Participant,
|
||||||
|
marathon: Marathon,
|
||||||
|
assignment: Assignment,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Use a Skip to bypass current assignment without penalty.
|
||||||
|
|
||||||
|
- No streak loss
|
||||||
|
- No drop penalty
|
||||||
|
- Assignment marked as dropped but without negative effects
|
||||||
|
|
||||||
|
Returns: dict with result info
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If skips not allowed or limit reached
|
||||||
|
"""
|
||||||
|
# Check marathon settings
|
||||||
|
if not marathon.allow_skips:
|
||||||
|
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
|
||||||
|
|
||||||
|
if marathon.max_skips_per_participant is not None:
|
||||||
|
if participant.skips_used >= marathon.max_skips_per_participant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check assignment is active
|
||||||
|
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only skip active assignments")
|
||||||
|
|
||||||
|
# Consume skip from inventory
|
||||||
|
item = await self._consume_item(db, user, ConsumableType.SKIP.value)
|
||||||
|
|
||||||
|
# Mark assignment as dropped (but without penalty)
|
||||||
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
|
assignment.completed_at = datetime.utcnow()
|
||||||
|
# Note: We do NOT increase drop_count or reset streak
|
||||||
|
|
||||||
|
# Track skip usage
|
||||||
|
participant.skips_used += 1
|
||||||
|
|
||||||
|
# Log usage
|
||||||
|
usage = ConsumableUsage(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=item.id,
|
||||||
|
marathon_id=marathon.id,
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
effect_data={
|
||||||
|
"type": "skip",
|
||||||
|
"skipped_without_penalty": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(usage)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"skipped": True,
|
||||||
|
"penalty": 0,
|
||||||
|
"streak_preserved": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def use_shield(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
participant: Participant,
|
||||||
|
marathon: Marathon,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Activate a Shield - protects from next drop penalty.
|
||||||
|
|
||||||
|
- Next drop will not cause point penalty
|
||||||
|
- Streak is preserved on next drop
|
||||||
|
|
||||||
|
Returns: dict with result info
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If consumables not allowed or shield already active
|
||||||
|
"""
|
||||||
|
if not marathon.allow_consumables:
|
||||||
|
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||||
|
|
||||||
|
if participant.has_shield:
|
||||||
|
raise HTTPException(status_code=400, detail="Shield is already active")
|
||||||
|
|
||||||
|
# Consume shield from inventory
|
||||||
|
item = await self._consume_item(db, user, ConsumableType.SHIELD.value)
|
||||||
|
|
||||||
|
# Activate shield
|
||||||
|
participant.has_shield = True
|
||||||
|
|
||||||
|
# Log usage
|
||||||
|
usage = ConsumableUsage(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=item.id,
|
||||||
|
marathon_id=marathon.id,
|
||||||
|
effect_data={
|
||||||
|
"type": "shield",
|
||||||
|
"activated": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(usage)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"shield_activated": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def use_boost(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
participant: Participant,
|
||||||
|
marathon: Marathon,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Activate a Boost - multiplies points for NEXT complete only.
|
||||||
|
|
||||||
|
- Points for next completed challenge are multiplied by BOOST_MULTIPLIER
|
||||||
|
- One-time use (consumed on next complete)
|
||||||
|
|
||||||
|
Returns: dict with result info
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If consumables not allowed or boost already active
|
||||||
|
"""
|
||||||
|
if not marathon.allow_consumables:
|
||||||
|
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||||
|
|
||||||
|
if participant.has_active_boost:
|
||||||
|
raise HTTPException(status_code=400, detail="Boost is already activated")
|
||||||
|
|
||||||
|
# Consume boost from inventory
|
||||||
|
item = await self._consume_item(db, user, ConsumableType.BOOST.value)
|
||||||
|
|
||||||
|
# Activate boost (one-time use)
|
||||||
|
participant.has_active_boost = True
|
||||||
|
|
||||||
|
# Log usage
|
||||||
|
usage = ConsumableUsage(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=item.id,
|
||||||
|
marathon_id=marathon.id,
|
||||||
|
effect_data={
|
||||||
|
"type": "boost",
|
||||||
|
"multiplier": self.BOOST_MULTIPLIER,
|
||||||
|
"one_time": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(usage)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"boost_activated": True,
|
||||||
|
"multiplier": self.BOOST_MULTIPLIER,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def use_reroll(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
participant: Participant,
|
||||||
|
marathon: Marathon,
|
||||||
|
assignment: Assignment,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Use a Reroll - discard current assignment and spin again.
|
||||||
|
|
||||||
|
- Current assignment is cancelled (not dropped)
|
||||||
|
- User can spin the wheel again
|
||||||
|
- No penalty
|
||||||
|
|
||||||
|
Returns: dict with result info
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If consumables not allowed or assignment not active
|
||||||
|
"""
|
||||||
|
if not marathon.allow_consumables:
|
||||||
|
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||||
|
|
||||||
|
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only reroll active assignments")
|
||||||
|
|
||||||
|
# Consume reroll from inventory
|
||||||
|
item = await self._consume_item(db, user, ConsumableType.REROLL.value)
|
||||||
|
|
||||||
|
# Cancel current assignment
|
||||||
|
old_challenge_id = assignment.challenge_id
|
||||||
|
old_game_id = assignment.game_id
|
||||||
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
|
assignment.completed_at = datetime.utcnow()
|
||||||
|
# Note: We do NOT increase drop_count (this is a reroll, not a real drop)
|
||||||
|
|
||||||
|
# Log usage
|
||||||
|
usage = ConsumableUsage(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=item.id,
|
||||||
|
marathon_id=marathon.id,
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
effect_data={
|
||||||
|
"type": "reroll",
|
||||||
|
"rerolled_from_challenge_id": old_challenge_id,
|
||||||
|
"rerolled_from_game_id": old_game_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(usage)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"rerolled": True,
|
||||||
|
"can_spin_again": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _consume_item(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
item_code: str,
|
||||||
|
) -> ShopItem:
|
||||||
|
"""
|
||||||
|
Consume 1 unit of a consumable from user's inventory.
|
||||||
|
|
||||||
|
Returns: The consumed ShopItem
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If user doesn't have the item
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserInventory)
|
||||||
|
.options(selectinload(UserInventory.item))
|
||||||
|
.join(ShopItem)
|
||||||
|
.where(
|
||||||
|
UserInventory.user_id == user.id,
|
||||||
|
ShopItem.code == item_code,
|
||||||
|
UserInventory.quantity > 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inv_item = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not inv_item:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"You don't have any {item_code} in your inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decrease quantity
|
||||||
|
inv_item.quantity -= 1
|
||||||
|
|
||||||
|
return inv_item.item
|
||||||
|
|
||||||
|
async def get_consumable_count(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
item_code: str,
|
||||||
|
) -> int:
|
||||||
|
"""Get how many of a consumable user has"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserInventory.quantity)
|
||||||
|
.join(ShopItem)
|
||||||
|
.where(
|
||||||
|
UserInventory.user_id == user_id,
|
||||||
|
ShopItem.code == item_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
quantity = result.scalar_one_or_none()
|
||||||
|
return quantity or 0
|
||||||
|
|
||||||
|
def consume_shield(self, participant: Participant) -> bool:
|
||||||
|
"""
|
||||||
|
Consume shield when dropping (called from wheel.py).
|
||||||
|
|
||||||
|
Returns: True if shield was consumed, False otherwise
|
||||||
|
"""
|
||||||
|
if participant.has_shield:
|
||||||
|
participant.has_shield = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def consume_boost_on_complete(self, participant: Participant) -> float:
|
||||||
|
"""
|
||||||
|
Consume boost when completing assignment (called from wheel.py).
|
||||||
|
One-time use - boost is consumed after single complete.
|
||||||
|
|
||||||
|
Returns: Multiplier value (BOOST_MULTIPLIER if boost was active, 1.0 otherwise)
|
||||||
|
"""
|
||||||
|
if participant.has_active_boost:
|
||||||
|
participant.has_active_boost = False
|
||||||
|
return self.BOOST_MULTIPLIER
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
consumables_service = ConsumablesService()
|
||||||
297
backend/app/services/shop.py
Normal file
297
backend/app/services/shop.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""
|
||||||
|
Shop Service - handles shop items, purchases, and inventory management
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models import User, ShopItem, UserInventory, ShopItemType
|
||||||
|
from app.services.coins import coins_service
|
||||||
|
|
||||||
|
|
||||||
|
class ShopService:
|
||||||
|
"""Service for shop operations"""
|
||||||
|
|
||||||
|
async def get_available_items(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
item_type: str | None = None,
|
||||||
|
include_unavailable: bool = False,
|
||||||
|
) -> list[ShopItem]:
|
||||||
|
"""
|
||||||
|
Get list of shop items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_type: Filter by item type (frame, title, etc.)
|
||||||
|
include_unavailable: Include inactive/out of stock items
|
||||||
|
"""
|
||||||
|
query = select(ShopItem)
|
||||||
|
|
||||||
|
if item_type:
|
||||||
|
query = query.where(ShopItem.item_type == item_type)
|
||||||
|
|
||||||
|
if not include_unavailable:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
query = query.where(
|
||||||
|
ShopItem.is_active == True,
|
||||||
|
(ShopItem.available_from.is_(None)) | (ShopItem.available_from <= now),
|
||||||
|
(ShopItem.available_until.is_(None)) | (ShopItem.available_until >= now),
|
||||||
|
(ShopItem.stock_remaining.is_(None)) | (ShopItem.stock_remaining > 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.order_by(ShopItem.price.asc())
|
||||||
|
result = await db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_item_by_id(self, db: AsyncSession, item_id: int) -> ShopItem | None:
|
||||||
|
"""Get shop item by ID"""
|
||||||
|
result = await db.execute(select(ShopItem).where(ShopItem.id == item_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_item_by_code(self, db: AsyncSession, code: str) -> ShopItem | None:
|
||||||
|
"""Get shop item by code"""
|
||||||
|
result = await db.execute(select(ShopItem).where(ShopItem.code == code))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def purchase_item(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
item_id: int,
|
||||||
|
quantity: int = 1,
|
||||||
|
) -> tuple[UserInventory, int]:
|
||||||
|
"""
|
||||||
|
Purchase an item from the shop.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The purchasing user
|
||||||
|
item_id: ID of item to purchase
|
||||||
|
quantity: Number to purchase (only for consumables)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (inventory item, total cost)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If item not found, not available, or insufficient funds
|
||||||
|
"""
|
||||||
|
# Get item
|
||||||
|
item = await self.get_item_by_id(db, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
# Check availability
|
||||||
|
if not item.is_available:
|
||||||
|
raise HTTPException(status_code=400, detail="Item is not available")
|
||||||
|
|
||||||
|
# For non-consumables, quantity is always 1
|
||||||
|
if item.item_type != ShopItemType.CONSUMABLE.value:
|
||||||
|
quantity = 1
|
||||||
|
|
||||||
|
# Check if already owned
|
||||||
|
existing = await db.execute(
|
||||||
|
select(UserInventory).where(
|
||||||
|
UserInventory.user_id == user.id,
|
||||||
|
UserInventory.item_id == item.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="You already own this item")
|
||||||
|
|
||||||
|
# Check stock
|
||||||
|
if item.stock_remaining is not None and item.stock_remaining < quantity:
|
||||||
|
raise HTTPException(status_code=400, detail="Not enough stock available")
|
||||||
|
|
||||||
|
# Calculate total cost
|
||||||
|
total_cost = item.price * quantity
|
||||||
|
|
||||||
|
# Check balance
|
||||||
|
if user.coins_balance < total_cost:
|
||||||
|
raise HTTPException(status_code=400, detail="Not enough coins")
|
||||||
|
|
||||||
|
# Deduct coins
|
||||||
|
success = await coins_service.spend_coins(
|
||||||
|
db, user, total_cost,
|
||||||
|
f"Purchase: {item.name} x{quantity}",
|
||||||
|
"shop_item", item.id,
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=400, detail="Payment failed")
|
||||||
|
|
||||||
|
# Add to inventory
|
||||||
|
if item.item_type == ShopItemType.CONSUMABLE.value:
|
||||||
|
# For consumables, increase quantity if already exists
|
||||||
|
existing_result = await db.execute(
|
||||||
|
select(UserInventory).where(
|
||||||
|
UserInventory.user_id == user.id,
|
||||||
|
UserInventory.item_id == item.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inv_item = existing_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if inv_item:
|
||||||
|
inv_item.quantity += quantity
|
||||||
|
else:
|
||||||
|
inv_item = UserInventory(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=item.id,
|
||||||
|
quantity=quantity,
|
||||||
|
)
|
||||||
|
db.add(inv_item)
|
||||||
|
else:
|
||||||
|
# For cosmetics, create new inventory entry
|
||||||
|
inv_item = UserInventory(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=item.id,
|
||||||
|
quantity=1,
|
||||||
|
)
|
||||||
|
db.add(inv_item)
|
||||||
|
|
||||||
|
# Decrease stock if limited
|
||||||
|
if item.stock_remaining is not None:
|
||||||
|
item.stock_remaining -= quantity
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
return inv_item, total_cost
|
||||||
|
|
||||||
|
async def get_user_inventory(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
item_type: str | None = None,
|
||||||
|
) -> list[UserInventory]:
|
||||||
|
"""Get user's inventory"""
|
||||||
|
query = (
|
||||||
|
select(UserInventory)
|
||||||
|
.options(selectinload(UserInventory.item))
|
||||||
|
.where(UserInventory.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if item_type:
|
||||||
|
query = query.join(ShopItem).where(ShopItem.item_type == item_type)
|
||||||
|
|
||||||
|
# Exclude empty consumables
|
||||||
|
query = query.where(UserInventory.quantity > 0)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_inventory_item(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
inventory_id: int,
|
||||||
|
) -> UserInventory | None:
|
||||||
|
"""Get specific inventory item"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserInventory)
|
||||||
|
.options(selectinload(UserInventory.item))
|
||||||
|
.where(
|
||||||
|
UserInventory.id == inventory_id,
|
||||||
|
UserInventory.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def equip_item(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
inventory_id: int,
|
||||||
|
) -> ShopItem:
|
||||||
|
"""
|
||||||
|
Equip a cosmetic item from inventory.
|
||||||
|
|
||||||
|
Returns: The equipped item
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If item not found or is a consumable
|
||||||
|
"""
|
||||||
|
# Get inventory item
|
||||||
|
inv_item = await self.get_inventory_item(db, user.id, inventory_id)
|
||||||
|
if not inv_item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||||
|
|
||||||
|
item = inv_item.item
|
||||||
|
|
||||||
|
if item.item_type == ShopItemType.CONSUMABLE.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot equip consumables")
|
||||||
|
|
||||||
|
# Unequip current item of same type
|
||||||
|
await db.execute(
|
||||||
|
update(UserInventory)
|
||||||
|
.where(
|
||||||
|
UserInventory.user_id == user.id,
|
||||||
|
UserInventory.equipped == True,
|
||||||
|
UserInventory.item_id.in_(
|
||||||
|
select(ShopItem.id).where(ShopItem.item_type == item.item_type)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.values(equipped=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Equip new item
|
||||||
|
inv_item.equipped = True
|
||||||
|
|
||||||
|
# Update user's equipped_*_id
|
||||||
|
if item.item_type == ShopItemType.FRAME.value:
|
||||||
|
user.equipped_frame_id = item.id
|
||||||
|
elif item.item_type == ShopItemType.TITLE.value:
|
||||||
|
user.equipped_title_id = item.id
|
||||||
|
elif item.item_type == ShopItemType.NAME_COLOR.value:
|
||||||
|
user.equipped_name_color_id = item.id
|
||||||
|
elif item.item_type == ShopItemType.BACKGROUND.value:
|
||||||
|
user.equipped_background_id = item.id
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
async def unequip_item(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
item_type: str,
|
||||||
|
) -> None:
|
||||||
|
"""Unequip item of specified type"""
|
||||||
|
# Unequip from inventory
|
||||||
|
await db.execute(
|
||||||
|
update(UserInventory)
|
||||||
|
.where(
|
||||||
|
UserInventory.user_id == user.id,
|
||||||
|
UserInventory.equipped == True,
|
||||||
|
UserInventory.item_id.in_(
|
||||||
|
select(ShopItem.id).where(ShopItem.item_type == item_type)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.values(equipped=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear user's equipped_*_id
|
||||||
|
if item_type == ShopItemType.FRAME.value:
|
||||||
|
user.equipped_frame_id = None
|
||||||
|
elif item_type == ShopItemType.TITLE.value:
|
||||||
|
user.equipped_title_id = None
|
||||||
|
elif item_type == ShopItemType.NAME_COLOR.value:
|
||||||
|
user.equipped_name_color_id = None
|
||||||
|
elif item_type == ShopItemType.BACKGROUND.value:
|
||||||
|
user.equipped_background_id = None
|
||||||
|
|
||||||
|
async def check_user_owns_item(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
item_id: int,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if user owns an item"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserInventory).where(
|
||||||
|
UserInventory.user_id == user_id,
|
||||||
|
UserInventory.item_id == item_id,
|
||||||
|
UserInventory.quantity > 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
shop_service = ShopService()
|
||||||
@@ -25,6 +25,8 @@ import { StaticContentPage } from '@/pages/StaticContentPage'
|
|||||||
import { NotFoundPage } from '@/pages/NotFoundPage'
|
import { NotFoundPage } from '@/pages/NotFoundPage'
|
||||||
import { TeapotPage } from '@/pages/TeapotPage'
|
import { TeapotPage } from '@/pages/TeapotPage'
|
||||||
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
||||||
|
import { ShopPage } from '@/pages/ShopPage'
|
||||||
|
import { InventoryPage } from '@/pages/InventoryPage'
|
||||||
|
|
||||||
// Admin Pages
|
// Admin Pages
|
||||||
import {
|
import {
|
||||||
@@ -187,6 +189,25 @@ function App() {
|
|||||||
|
|
||||||
<Route path="users/:id" element={<UserProfilePage />} />
|
<Route path="users/:id" element={<UserProfilePage />} />
|
||||||
|
|
||||||
|
{/* Shop routes */}
|
||||||
|
<Route
|
||||||
|
path="shop"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ShopPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="inventory"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<InventoryPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Easter egg - 418 I'm a teapot */}
|
{/* Easter egg - 418 I'm a teapot */}
|
||||||
<Route path="418" element={<TeapotPage />} />
|
<Route path="418" element={<TeapotPage />} />
|
||||||
<Route path="teapot" element={<TeapotPage />} />
|
<Route path="teapot" element={<TeapotPage />} />
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ export const adminApi = {
|
|||||||
await client.post(`/admin/marathons/${id}/force-finish`)
|
await client.post(`/admin/marathons/${id}/force-finish`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
certifyMarathon: async (id: number): Promise<void> => {
|
||||||
|
await client.post(`/admin/marathons/${id}/certify`)
|
||||||
|
},
|
||||||
|
|
||||||
|
revokeCertification: async (id: number): Promise<void> => {
|
||||||
|
await client.post(`/admin/marathons/${id}/revoke-certification`)
|
||||||
|
},
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
getStats: async (): Promise<PlatformStats> => {
|
getStats: async (): Promise<PlatformStats> => {
|
||||||
const response = await client.get<PlatformStats>('/admin/stats')
|
const response = await client.get<PlatformStats>('/admin/stats')
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export { challengesApi } from './challenges'
|
|||||||
export { assignmentsApi } from './assignments'
|
export { assignmentsApi } from './assignments'
|
||||||
export { usersApi } from './users'
|
export { usersApi } from './users'
|
||||||
export { telegramApi } from './telegram'
|
export { telegramApi } from './telegram'
|
||||||
|
export { shopApi } from './shop'
|
||||||
|
|||||||
102
frontend/src/api/shop.ts
Normal file
102
frontend/src/api/shop.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import client from './client'
|
||||||
|
import type {
|
||||||
|
ShopItem,
|
||||||
|
ShopItemType,
|
||||||
|
InventoryItem,
|
||||||
|
PurchaseResponse,
|
||||||
|
UseConsumableRequest,
|
||||||
|
UseConsumableResponse,
|
||||||
|
CoinsBalance,
|
||||||
|
CoinTransaction,
|
||||||
|
ConsumablesStatus,
|
||||||
|
UserCosmetics,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const shopApi = {
|
||||||
|
// === Каталог товаров ===
|
||||||
|
|
||||||
|
// Получить список товаров
|
||||||
|
getItems: async (itemType?: ShopItemType): Promise<ShopItem[]> => {
|
||||||
|
const params = itemType ? { item_type: itemType } : {}
|
||||||
|
const response = await client.get<ShopItem[]>('/shop/items', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получить товар по ID
|
||||||
|
getItem: async (itemId: number): Promise<ShopItem> => {
|
||||||
|
const response = await client.get<ShopItem>(`/shop/items/${itemId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Покупки ===
|
||||||
|
|
||||||
|
// Купить товар
|
||||||
|
purchase: async (itemId: number, quantity: number = 1): Promise<PurchaseResponse> => {
|
||||||
|
const response = await client.post<PurchaseResponse>('/shop/purchase', {
|
||||||
|
item_id: itemId,
|
||||||
|
quantity,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Инвентарь ===
|
||||||
|
|
||||||
|
// Получить инвентарь пользователя
|
||||||
|
getInventory: async (itemType?: ShopItemType): Promise<InventoryItem[]> => {
|
||||||
|
const params = itemType ? { item_type: itemType } : {}
|
||||||
|
const response = await client.get<InventoryItem[]>('/shop/inventory', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Экипировка ===
|
||||||
|
|
||||||
|
// Экипировать предмет
|
||||||
|
equip: async (inventoryId: number): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await client.post<{ success: boolean; message: string }>('/shop/equip', {
|
||||||
|
inventory_id: inventoryId,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Снять предмет
|
||||||
|
unequip: async (itemType: ShopItemType): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await client.post<{ success: boolean; message: string }>(`/shop/unequip/${itemType}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получить экипированную косметику
|
||||||
|
getCosmetics: async (): Promise<UserCosmetics> => {
|
||||||
|
const response = await client.get<UserCosmetics>('/shop/cosmetics')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Расходуемые ===
|
||||||
|
|
||||||
|
// Использовать расходуемый предмет
|
||||||
|
useConsumable: async (data: UseConsumableRequest): Promise<UseConsumableResponse> => {
|
||||||
|
const response = await client.post<UseConsumableResponse>('/shop/use', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получить статус расходуемых в марафоне
|
||||||
|
getConsumablesStatus: async (marathonId: number): Promise<ConsumablesStatus> => {
|
||||||
|
const response = await client.get<ConsumablesStatus>(`/shop/consumables/${marathonId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Монеты ===
|
||||||
|
|
||||||
|
// Получить баланс и последние транзакции
|
||||||
|
getBalance: async (): Promise<CoinsBalance> => {
|
||||||
|
const response = await client.get<CoinsBalance>('/shop/balance')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получить историю транзакций
|
||||||
|
getTransactions: async (limit: number = 50, offset: number = 0): Promise<CoinTransaction[]> => {
|
||||||
|
const response = await client.get<CoinTransaction[]>('/shop/transactions', {
|
||||||
|
params: { limit, offset },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
|
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { feedApi } from '@/api'
|
import { feedApi } from '@/api'
|
||||||
import type { Activity, ActivityType } from '@/types'
|
import type { Activity, ActivityType, ShopItemPublic, User } from '@/types'
|
||||||
import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
|
import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
|
||||||
import { UserAvatar } from '@/components/ui'
|
import { UserAvatar } from '@/components/ui'
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,77 @@ import {
|
|||||||
formatActivityMessage,
|
formatActivityMessage,
|
||||||
} from '@/utils/activity'
|
} from '@/utils/activity'
|
||||||
|
|
||||||
|
// Helper to get name color styles and animation class
|
||||||
|
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
|
||||||
|
if (!nameColor?.asset_data) return { styles: {}, className: '' }
|
||||||
|
|
||||||
|
const data = nameColor.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string }
|
||||||
|
|
||||||
|
if (data.style === 'gradient' && data.gradient) {
|
||||||
|
return {
|
||||||
|
styles: {
|
||||||
|
background: `linear-gradient(90deg, ${data.gradient.join(', ')})`,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
},
|
||||||
|
className: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.style === 'animated') {
|
||||||
|
return {
|
||||||
|
styles: {
|
||||||
|
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
|
||||||
|
backgroundSize: '400% 100%',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
},
|
||||||
|
className: 'animate-rainbow-rotate',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.style === 'solid' && data.color) {
|
||||||
|
return { styles: { color: data.color }, className: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { styles: {}, className: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get title data
|
||||||
|
function getTitleData(title: ShopItemPublic | null | undefined): { text: string; color: string } | null {
|
||||||
|
if (!title?.asset_data) return null
|
||||||
|
|
||||||
|
const data = title.asset_data as { text?: string; color?: string }
|
||||||
|
if (!data.text) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: data.text,
|
||||||
|
color: data.color || '#ffffff',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styled nickname component for activity feed
|
||||||
|
function StyledNickname({ user }: { user: User }) {
|
||||||
|
const nameColorData = getNameColorData(user.equipped_name_color)
|
||||||
|
const titleData = getTitleData(user.equipped_title)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={nameColorData.className} style={nameColorData.styles}>{user.nickname}</span>
|
||||||
|
{titleData && (
|
||||||
|
<span
|
||||||
|
className="ml-1.5 px-1 py-0.5 text-[10px] font-medium rounded bg-dark-700/50"
|
||||||
|
style={{ color: titleData.color }}
|
||||||
|
>
|
||||||
|
{titleData.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface ActivityFeedProps {
|
interface ActivityFeedProps {
|
||||||
marathonId: number
|
marathonId: number
|
||||||
className?: string
|
className?: string
|
||||||
@@ -273,6 +344,8 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
|||||||
hasAvatar={!!activity.user.avatar_url}
|
hasAvatar={!!activity.user.avatar_url}
|
||||||
nickname={activity.user.nickname}
|
nickname={activity.user.nickname}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
frame={activity.user.equipped_frame}
|
||||||
|
telegramAvatarUrl={activity.user.telegram_avatar_url}
|
||||||
/>
|
/>
|
||||||
{/* Activity type badge */}
|
{/* Activity type badge */}
|
||||||
<div className={`
|
<div className={`
|
||||||
@@ -292,10 +365,10 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
|||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Link
|
<Link
|
||||||
to={`/users/${activity.user.id}`}
|
to={`/users/${activity.user.id}`}
|
||||||
className="text-sm font-semibold text-white hover:text-neon-400 transition-colors"
|
className="text-sm font-semibold hover:text-neon-400 transition-colors"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{activity.user.nickname}
|
<StyledNickname user={activity.user} />
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-xs text-gray-600">
|
<span className="text-xs text-gray-600">
|
||||||
{formatRelativeTime(activity.created_at)}
|
{formatRelativeTime(activity.created_at)}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield } from 'lucide-react'
|
import { useShopStore } from '@/store/shop'
|
||||||
import { TelegramLink } from '@/components/TelegramLink'
|
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield, ShoppingBag, Coins, Backpack } from 'lucide-react'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { user, isAuthenticated, logout } = useAuthStore()
|
const { user, isAuthenticated, logout } = useAuthStore()
|
||||||
|
const { balance, loadBalance } = useShopStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [isScrolled, setIsScrolled] = useState(false)
|
const [isScrolled, setIsScrolled] = useState(false)
|
||||||
@@ -20,6 +21,13 @@ export function Layout() {
|
|||||||
return () => window.removeEventListener('scroll', handleScroll)
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load balance when authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
loadBalance()
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, loadBalance])
|
||||||
|
|
||||||
// Close mobile menu on route change
|
// Close mobile menu on route change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMobileMenuOpen(false)
|
setIsMobileMenuOpen(false)
|
||||||
@@ -74,6 +82,19 @@ export function Layout() {
|
|||||||
<span>Марафоны</span>
|
<span>Марафоны</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/shop"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
|
||||||
|
isActiveLink('/shop')
|
||||||
|
? 'text-yellow-400 bg-yellow-500/10'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-5 h-5" />
|
||||||
|
<span>Магазин</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{user?.role === 'admin' && (
|
{user?.role === 'admin' && (
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
@@ -89,7 +110,7 @@ export function Layout() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
|
<div className="flex items-center gap-2 ml-2 pl-4 border-l border-dark-600">
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -101,9 +122,24 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
<User className="w-5 h-5" />
|
<User className="w-5 h-5" />
|
||||||
<span>{user?.nickname}</span>
|
<span>{user?.nickname}</span>
|
||||||
|
<span className="flex items-center gap-1 text-yellow-400 ml-1">
|
||||||
|
<Coins className="w-4 h-4" />
|
||||||
|
<span className="font-medium">{balance}</span>
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<TelegramLink />
|
<Link
|
||||||
|
to="/inventory"
|
||||||
|
className={clsx(
|
||||||
|
'p-2 rounded-lg transition-all duration-200',
|
||||||
|
isActiveLink('/inventory')
|
||||||
|
? 'text-yellow-400 bg-yellow-500/10'
|
||||||
|
: 'text-gray-400 hover:text-yellow-400 hover:bg-yellow-500/10'
|
||||||
|
)}
|
||||||
|
title="Инвентарь"
|
||||||
|
>
|
||||||
|
<Backpack className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
@@ -159,6 +195,18 @@ export function Layout() {
|
|||||||
<Trophy className="w-5 h-5" />
|
<Trophy className="w-5 h-5" />
|
||||||
<span>Марафоны</span>
|
<span>Марафоны</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/shop"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||||
|
isActiveLink('/shop')
|
||||||
|
? 'text-yellow-400 bg-yellow-500/10'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-5 h-5" />
|
||||||
|
<span>Магазин</span>
|
||||||
|
</Link>
|
||||||
{user?.role === 'admin' && (
|
{user?.role === 'admin' && (
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
@@ -184,6 +232,22 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
<User className="w-5 h-5" />
|
<User className="w-5 h-5" />
|
||||||
<span>{user?.nickname}</span>
|
<span>{user?.nickname}</span>
|
||||||
|
<span className="flex items-center gap-1 text-yellow-400 ml-auto">
|
||||||
|
<Coins className="w-4 h-4" />
|
||||||
|
<span className="font-medium">{balance}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/inventory"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||||
|
isActiveLink('/inventory')
|
||||||
|
? 'text-yellow-400 bg-yellow-500/10'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Backpack className="w-5 h-5" />
|
||||||
|
<span>Инвентарь</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="pt-2 border-t border-dark-600">
|
<div className="pt-2 border-t border-dark-600">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { usersApi } from '@/api'
|
import { usersApi } from '@/api'
|
||||||
|
import { User } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { ShopItemPublic } from '@/types'
|
||||||
|
|
||||||
// Глобальный кэш для blob URL аватарок
|
// Глобальный кэш для blob URL аватарок
|
||||||
const avatarCache = new Map<number, string>()
|
const avatarCache = new Map<number, string>()
|
||||||
@@ -10,18 +13,77 @@ interface UserAvatarProps {
|
|||||||
userId: number
|
userId: number
|
||||||
hasAvatar: boolean // Есть ли у пользователя avatar_url
|
hasAvatar: boolean // Есть ли у пользователя avatar_url
|
||||||
nickname: string
|
nickname: string
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||||
className?: string
|
className?: string
|
||||||
version?: number // Для принудительного обновления при смене аватара
|
version?: number // Для принудительного обновления при смене аватара
|
||||||
|
frame?: ShopItemPublic | null // Equipped frame cosmetic
|
||||||
|
telegramAvatarUrl?: string | null // Fallback to telegram avatar
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
|
xs: 'w-6 h-6 text-[8px]',
|
||||||
sm: 'w-8 h-8 text-xs',
|
sm: 'w-8 h-8 text-xs',
|
||||||
md: 'w-12 h-12 text-sm',
|
md: 'w-12 h-12 text-sm',
|
||||||
lg: 'w-24 h-24 text-xl',
|
lg: 'w-16 h-16 text-base',
|
||||||
|
xl: 'w-24 h-24 text-xl',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '', version = 0 }: UserAvatarProps) {
|
const framePadding = {
|
||||||
|
xs: 2,
|
||||||
|
sm: 2,
|
||||||
|
md: 3,
|
||||||
|
lg: 4,
|
||||||
|
xl: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get frame styles from asset_data
|
||||||
|
function getFrameStyles(frame: ShopItemPublic | null | undefined): React.CSSProperties {
|
||||||
|
if (!frame?.asset_data) return {}
|
||||||
|
|
||||||
|
const data = frame.asset_data as {
|
||||||
|
border_color?: string
|
||||||
|
gradient?: string[]
|
||||||
|
glow_color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: React.CSSProperties = {}
|
||||||
|
|
||||||
|
if (data.gradient && data.gradient.length > 0) {
|
||||||
|
styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
|
||||||
|
styles.backgroundSize = '400% 400%'
|
||||||
|
} else if (data.border_color) {
|
||||||
|
styles.background = data.border_color
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.glow_color) {
|
||||||
|
styles.boxShadow = `0 0 12px ${data.glow_color}, 0 0 24px ${data.glow_color}40`
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get frame animation class
|
||||||
|
function getFrameAnimation(frame: ShopItemPublic | null | undefined): string {
|
||||||
|
if (!frame?.asset_data) return ''
|
||||||
|
|
||||||
|
const data = frame.asset_data as { animation?: string }
|
||||||
|
|
||||||
|
if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
|
||||||
|
if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAvatar({
|
||||||
|
userId,
|
||||||
|
hasAvatar,
|
||||||
|
nickname,
|
||||||
|
size = 'md',
|
||||||
|
className = '',
|
||||||
|
version = 0,
|
||||||
|
frame,
|
||||||
|
telegramAvatarUrl
|
||||||
|
}: UserAvatarProps) {
|
||||||
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
@@ -74,25 +136,54 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
|
|||||||
}, [userId, hasAvatar, version])
|
}, [userId, hasAvatar, version])
|
||||||
|
|
||||||
const sizeClass = sizeClasses[size]
|
const sizeClass = sizeClasses[size]
|
||||||
|
const displayUrl = (blobUrl && !failed) ? blobUrl : telegramAvatarUrl
|
||||||
|
|
||||||
if (blobUrl && !failed) {
|
// Avatar content
|
||||||
return (
|
const avatarContent = displayUrl ? (
|
||||||
<img
|
<img
|
||||||
src={blobUrl}
|
src={displayUrl}
|
||||||
alt={nickname}
|
alt={nickname}
|
||||||
className={`rounded-full object-cover ${sizeClass} ${className}`}
|
className="w-full h-full rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
)
|
) : (
|
||||||
}
|
<div className="w-full h-full rounded-full bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center">
|
||||||
|
|
||||||
// Fallback - первая буква никнейма
|
|
||||||
return (
|
|
||||||
<div className={`rounded-full bg-gray-700 flex items-center justify-center ${sizeClass} ${className}`}>
|
|
||||||
<span className="text-gray-400 font-medium">
|
<span className="text-gray-400 font-medium">
|
||||||
{nickname.charAt(0).toUpperCase()}
|
{nickname.charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// If no frame, return simple avatar
|
||||||
|
if (!frame) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('rounded-full overflow-hidden bg-dark-700', sizeClass, className)}>
|
||||||
|
{avatarContent}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With frame - wrap avatar in frame container
|
||||||
|
const padding = framePadding[size]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'rounded-full flex items-center justify-center',
|
||||||
|
getFrameAnimation(frame),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
...getFrameStyles(frame),
|
||||||
|
padding: `${padding}px`,
|
||||||
|
width: 'fit-content',
|
||||||
|
height: 'fit-content',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={clsx('rounded-full overflow-hidden bg-dark-700', sizeClass)}>
|
||||||
|
{avatarContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Функция для очистки кэша конкретного пользователя (после загрузки нового аватара)
|
// Функция для очистки кэша конкретного пользователя (после загрузки нового аватара)
|
||||||
@@ -105,3 +196,55 @@ export function clearAvatarCache(userId: number) {
|
|||||||
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
|
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
|
||||||
needsCacheBust.add(userId)
|
needsCacheBust.add(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FramePreview component for shop - shows frame without avatar
|
||||||
|
interface FramePreviewProps {
|
||||||
|
frame: ShopItemPublic | null
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewSizes = {
|
||||||
|
xs: 'w-8 h-8',
|
||||||
|
sm: 'w-10 h-10',
|
||||||
|
md: 'w-14 h-14',
|
||||||
|
lg: 'w-20 h-20',
|
||||||
|
xl: 'w-28 h-28',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FramePreview({ frame, size = 'md', className }: FramePreviewProps) {
|
||||||
|
if (!frame?.asset_data) {
|
||||||
|
return (
|
||||||
|
<div className={clsx(
|
||||||
|
previewSizes[size],
|
||||||
|
'rounded-lg border-4 border-gray-600 flex items-center justify-center bg-dark-800',
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<User className="w-1/2 h-1/2 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const padding = framePadding[size]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'rounded-lg flex items-center justify-center',
|
||||||
|
getFrameAnimation(frame),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
...getFrameStyles(frame),
|
||||||
|
padding: `${padding}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={clsx(
|
||||||
|
previewSizes[size],
|
||||||
|
'rounded-md bg-dark-800/90 flex items-center justify-center'
|
||||||
|
)}>
|
||||||
|
<User className="w-1/2 h-1/2 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export { Input } from './Input'
|
|||||||
export { Card, CardHeader, CardTitle, CardContent } from './Card'
|
export { Card, CardHeader, CardTitle, CardContent } from './Card'
|
||||||
export { ToastContainer } from './Toast'
|
export { ToastContainer } from './Toast'
|
||||||
export { ConfirmModal } from './ConfirmModal'
|
export { ConfirmModal } from './ConfirmModal'
|
||||||
export { UserAvatar, clearAvatarCache } from './UserAvatar'
|
export { UserAvatar, clearAvatarCache, FramePreview } from './UserAvatar'
|
||||||
|
|
||||||
// New design system components
|
// New design system components
|
||||||
export { GlitchText, GlitchHeading } from './GlitchText'
|
export { GlitchText, GlitchHeading } from './GlitchText'
|
||||||
|
|||||||
@@ -571,6 +571,125 @@ input:-webkit-autofill:active {
|
|||||||
@apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900;
|
@apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Frame Animations (Shop cosmetics)
|
||||||
|
======================================== */
|
||||||
|
/* Fire pulse animation */
|
||||||
|
@keyframes fire-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
background-size: 200% 200%;
|
||||||
|
background-position: 0% 50%;
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-size: 220% 220%;
|
||||||
|
background-position: 100% 50%;
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fire-pulse {
|
||||||
|
animation: fire-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rainbow rotate animation */
|
||||||
|
@keyframes rainbow-rotate {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-rainbow-rotate {
|
||||||
|
animation: rainbow-rotate 3s linear infinite;
|
||||||
|
background-size: 400% 400%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rainbow text color shift */
|
||||||
|
@keyframes rainbow-shift {
|
||||||
|
0% { color: #FF0000; }
|
||||||
|
16% { color: #FF7F00; }
|
||||||
|
33% { color: #FFFF00; }
|
||||||
|
50% { color: #00FF00; }
|
||||||
|
66% { color: #0000FF; }
|
||||||
|
83% { color: #9400D3; }
|
||||||
|
100% { color: #FF0000; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-rainbow-shift {
|
||||||
|
animation: rainbow-shift 4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fire particles background animation */
|
||||||
|
@keyframes fire-particles {
|
||||||
|
0%, 100% {
|
||||||
|
background-position: 0% 100%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fire-particles {
|
||||||
|
animation: fire-particles 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Star twinkle animation */
|
||||||
|
@keyframes twinkle {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-twinkle {
|
||||||
|
animation: twinkle 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stars background with multiple twinkling layers */
|
||||||
|
.bg-stars-animated {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, #0d1b2a 0%, #1b263b 50%, #0d1b2a 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-stars-animated::before,
|
||||||
|
.bg-stars-animated::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-stars-animated::before {
|
||||||
|
background:
|
||||||
|
radial-gradient(2px 2px at 20px 30px, #fff, transparent),
|
||||||
|
radial-gradient(2px 2px at 80px 60px, rgba(255,255,255,0.9), transparent),
|
||||||
|
radial-gradient(1px 1px at 130px 40px, #fff, transparent),
|
||||||
|
radial-gradient(2px 2px at 180px 90px, rgba(255,255,255,0.8), transparent),
|
||||||
|
radial-gradient(1px 1px at 50px 100px, #fff, transparent),
|
||||||
|
radial-gradient(1.5px 1.5px at 220px 20px, rgba(255,255,255,0.9), transparent);
|
||||||
|
background-size: 250px 150px;
|
||||||
|
animation: twinkle 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-stars-animated::after {
|
||||||
|
background:
|
||||||
|
radial-gradient(1px 1px at 40px 20px, rgba(255,255,255,0.7), transparent),
|
||||||
|
radial-gradient(2px 2px at 100px 80px, #fff, transparent),
|
||||||
|
radial-gradient(1.5px 1.5px at 160px 30px, rgba(255,255,255,0.8), transparent),
|
||||||
|
radial-gradient(1px 1px at 200px 70px, #fff, transparent),
|
||||||
|
radial-gradient(2px 2px at 70px 110px, rgba(255,255,255,0.9), transparent);
|
||||||
|
background-size: 220px 140px;
|
||||||
|
animation: twinkle 4s ease-in-out infinite 1s;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reduced motion support */
|
/* Reduced motion support */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
|
|||||||
387
frontend/src/pages/InventoryPage.tsx
Normal file
387
frontend/src/pages/InventoryPage.tsx
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useShopStore } from '@/store/shop'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||||
|
import {
|
||||||
|
Loader2, Package, ShoppingBag, Coins, Check,
|
||||||
|
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { InventoryItem, ShopItemType } from '@/types'
|
||||||
|
import { RARITY_COLORS, RARITY_NAMES, ITEM_TYPE_NAMES } from '@/types'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
skip: <SkipForward className="w-8 h-8" />,
|
||||||
|
shield: <Shield className="w-8 h-8" />,
|
||||||
|
boost: <Zap className="w-8 h-8" />,
|
||||||
|
reroll: <RefreshCw className="w-8 h-8" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InventoryItemCardProps {
|
||||||
|
inventoryItem: InventoryItem
|
||||||
|
onEquip: (inventoryId: number) => void
|
||||||
|
onUnequip: (itemType: ShopItemType) => void
|
||||||
|
isProcessing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryItemCard({ inventoryItem, onEquip, onUnequip, isProcessing }: InventoryItemCardProps) {
|
||||||
|
const { item, quantity, equipped } = inventoryItem
|
||||||
|
const rarityColors = RARITY_COLORS[item.rarity]
|
||||||
|
|
||||||
|
const getItemPreview = () => {
|
||||||
|
if (item.item_type === 'consumable') {
|
||||||
|
return CONSUMABLE_ICONS[item.code] || <Package className="w-8 h-8" />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name color preview - handles solid, gradient, animated
|
||||||
|
if (item.item_type === 'name_color') {
|
||||||
|
const data = item.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string } | null
|
||||||
|
|
||||||
|
if (data?.style === 'gradient' && data.gradient) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||||
|
style={{ background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (data?.style === 'animated') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-dark-600 animate-rainbow-rotate"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
|
||||||
|
backgroundSize: '400% 400%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const solidColor = data?.color || '#ffffff'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||||
|
style={{ backgroundColor: solidColor }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background preview
|
||||||
|
if (item.item_type === 'background') {
|
||||||
|
const data = item.asset_data as { type?: string; color?: string; gradient?: string[]; pattern?: string; animation?: string } | null
|
||||||
|
let bgStyle: React.CSSProperties = {}
|
||||||
|
let animClass = ''
|
||||||
|
|
||||||
|
if (data?.type === 'solid' && data.color) {
|
||||||
|
bgStyle = { backgroundColor: data.color }
|
||||||
|
} else if (data?.type === 'gradient' && data.gradient) {
|
||||||
|
bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
|
||||||
|
} else if (data?.type === 'pattern') {
|
||||||
|
if (data.pattern === 'stars') {
|
||||||
|
bgStyle = {
|
||||||
|
background: `
|
||||||
|
radial-gradient(1px 1px at 10px 10px, #fff, transparent),
|
||||||
|
radial-gradient(1px 1px at 30px 25px, rgba(255,255,255,0.8), transparent),
|
||||||
|
linear-gradient(135deg, #0d1b2a 0%, #1b263b 100%)
|
||||||
|
`,
|
||||||
|
backgroundSize: '50px 35px, 50px 35px, 100% 100%'
|
||||||
|
}
|
||||||
|
animClass = 'animate-twinkle'
|
||||||
|
} else if (data.pattern === 'gaming-icons') {
|
||||||
|
bgStyle = {
|
||||||
|
background: `
|
||||||
|
linear-gradient(45deg, rgba(34,211,238,0.2) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, rgba(168,85,247,0.2) 25%, transparent 25%),
|
||||||
|
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
|
||||||
|
`,
|
||||||
|
backgroundSize: '15px 15px, 15px 15px, 100% 100%'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data?.type === 'animated' && data.animation === 'fire-particles') {
|
||||||
|
bgStyle = {
|
||||||
|
background: `
|
||||||
|
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.5) 0%, transparent 50%),
|
||||||
|
linear-gradient(to top, #1a0a00 0%, #0d0d0d 100%)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
animClass = 'animate-fire-pulse'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-14 h-10 rounded-lg border-2 border-dark-600 ${animClass}`}
|
||||||
|
style={bgStyle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.item_type === 'frame') {
|
||||||
|
return <FramePreview frame={item} size="lg" />
|
||||||
|
}
|
||||||
|
if (item.item_type === 'title' && item.asset_data?.text) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-lg font-bold"
|
||||||
|
style={{ color: (item.asset_data.color as string) || '#ffffff' }}
|
||||||
|
>
|
||||||
|
{item.asset_data.text as string}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <Package className="w-8 h-8 text-gray-400" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCosmetic = item.item_type !== 'consumable'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard
|
||||||
|
className={clsx(
|
||||||
|
'p-4 border transition-all duration-300',
|
||||||
|
equipped ? 'border-neon-500 bg-neon-500/10' : rarityColors.border
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Equipped badge */}
|
||||||
|
{equipped && (
|
||||||
|
<div className="flex items-center gap-1 text-neon-400 text-xs font-medium mb-2">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Надето
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rarity badge */}
|
||||||
|
{!equipped && (
|
||||||
|
<div className={clsx('text-xs font-medium mb-2', rarityColors.text)}>
|
||||||
|
{RARITY_NAMES[item.rarity]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Item preview */}
|
||||||
|
<div className="flex justify-center items-center h-16 mb-3">
|
||||||
|
{getItemPreview()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item info */}
|
||||||
|
<h3 className="text-white font-semibold text-center mb-1">{item.name}</h3>
|
||||||
|
<p className="text-gray-500 text-xs text-center mb-1">
|
||||||
|
{ITEM_TYPE_NAMES[item.item_type]}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Quantity for consumables */}
|
||||||
|
{item.item_type === 'consumable' && (
|
||||||
|
<p className="text-yellow-400 text-sm text-center mb-3 font-medium">
|
||||||
|
x{quantity}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action button */}
|
||||||
|
{isCosmetic && (
|
||||||
|
<div className="mt-3">
|
||||||
|
{equipped ? (
|
||||||
|
<NeonButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => onUnequip(item.item_type)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Снять'}
|
||||||
|
</NeonButton>
|
||||||
|
) : (
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => onEquip(inventoryItem.id)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Надеть'}
|
||||||
|
</NeonButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Consumable info */}
|
||||||
|
{item.item_type === 'consumable' && (
|
||||||
|
<p className="text-gray-500 text-xs text-center mt-2">
|
||||||
|
Используйте в марафоне
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryPage() {
|
||||||
|
const { inventory, balance, isLoading, loadInventory, loadBalance, equip, unequip, clearError, error } = useShopStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<ShopItemType | 'all'>('all')
|
||||||
|
const [processingId, setProcessingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBalance()
|
||||||
|
loadInventory()
|
||||||
|
}, [loadBalance, loadInventory])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toast.error(error)
|
||||||
|
clearError()
|
||||||
|
}
|
||||||
|
}, [error, toast, clearError])
|
||||||
|
|
||||||
|
const handleEquip = async (inventoryId: number) => {
|
||||||
|
setProcessingId(inventoryId)
|
||||||
|
const success = await equip(inventoryId)
|
||||||
|
setProcessingId(null)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Предмет экипирован!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnequip = async (itemType: ShopItemType) => {
|
||||||
|
setProcessingId(-1) // Generic processing state
|
||||||
|
const success = await unequip(itemType)
|
||||||
|
setProcessingId(null)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Предмет снят')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredInventory = activeTab === 'all'
|
||||||
|
? inventory
|
||||||
|
: inventory.filter(inv => inv.item.item_type === activeTab)
|
||||||
|
|
||||||
|
// Group by type for display
|
||||||
|
const cosmeticItems = filteredInventory.filter(inv => inv.item.item_type !== 'consumable')
|
||||||
|
const consumableItems = filteredInventory.filter(inv => inv.item.item_type === 'consumable')
|
||||||
|
|
||||||
|
const tabs: { id: ShopItemType | 'all'; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ id: 'all', label: 'Все', icon: <Package className="w-4 h-4" /> },
|
||||||
|
{ id: 'frame', label: 'Рамки', icon: <Frame className="w-4 h-4" /> },
|
||||||
|
{ id: 'title', label: 'Титулы', icon: <Type className="w-4 h-4" /> },
|
||||||
|
{ id: 'name_color', label: 'Цвета', icon: <Palette className="w-4 h-4" /> },
|
||||||
|
{ id: 'background', label: 'Фоны', icon: <Image className="w-4 h-4" /> },
|
||||||
|
{ id: 'consumable', label: 'Расходники', icon: <Zap className="w-4 h-4" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isLoading && inventory.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin text-neon-500" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
|
||||||
|
<Package className="w-8 h-8 text-neon-500" />
|
||||||
|
Инвентарь
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Твои предметы и косметика
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Balance */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-dark-800/50 rounded-lg border border-yellow-500/30">
|
||||||
|
<Coins className="w-5 h-5 text-yellow-400" />
|
||||||
|
<span className="text-yellow-400 font-bold text-lg">{balance}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link to shop */}
|
||||||
|
<Link to="/shop">
|
||||||
|
<NeonButton>
|
||||||
|
<ShoppingBag className="w-4 h-4 mr-2" />
|
||||||
|
Магазин
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-neon-500 text-dark-900'
|
||||||
|
: 'bg-dark-700 text-gray-300 hover:bg-dark-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{filteredInventory.length === 0 ? (
|
||||||
|
<GlassCard className="p-8 text-center">
|
||||||
|
<Package className="w-16 h-16 mx-auto text-gray-500 mb-4" />
|
||||||
|
<p className="text-gray-400 mb-4">
|
||||||
|
{activeTab === 'all'
|
||||||
|
? 'Твой инвентарь пуст'
|
||||||
|
: 'Нет предметов в этой категории'}
|
||||||
|
</p>
|
||||||
|
<Link to="/shop">
|
||||||
|
<NeonButton>
|
||||||
|
<ShoppingBag className="w-4 h-4 mr-2" />
|
||||||
|
Перейти в магазин
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
</GlassCard>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Cosmetic items */}
|
||||||
|
{cosmeticItems.length > 0 && (activeTab === 'all' || activeTab !== 'consumable') && (
|
||||||
|
<div className="mb-8">
|
||||||
|
{activeTab === 'all' && (
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-4">Косметика</h2>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{cosmeticItems.map(inv => (
|
||||||
|
<InventoryItemCard
|
||||||
|
key={inv.id}
|
||||||
|
inventoryItem={inv}
|
||||||
|
onEquip={handleEquip}
|
||||||
|
onUnequip={handleUnequip}
|
||||||
|
isProcessing={processingId === inv.id || processingId === -1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Consumable items */}
|
||||||
|
{consumableItems.length > 0 && (activeTab === 'all' || activeTab === 'consumable') && (
|
||||||
|
<div>
|
||||||
|
{activeTab === 'all' && (
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-4">Расходуемые</h2>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{consumableItems.map(inv => (
|
||||||
|
<InventoryItemCard
|
||||||
|
key={inv.id}
|
||||||
|
inventoryItem={inv}
|
||||||
|
onEquip={handleEquip}
|
||||||
|
onUnequip={handleUnequip}
|
||||||
|
isProcessing={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,82 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import type { LeaderboardEntry } from '@/types'
|
import type { LeaderboardEntry, ShopItemPublic, User } from '@/types'
|
||||||
import { GlassCard } from '@/components/ui'
|
import { GlassCard, UserAvatar } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
|
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
|
||||||
|
|
||||||
|
// Helper to get name color styles and animation class
|
||||||
|
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
|
||||||
|
if (!nameColor?.asset_data) return { styles: {}, className: '' }
|
||||||
|
|
||||||
|
const data = nameColor.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string }
|
||||||
|
|
||||||
|
if (data.style === 'gradient' && data.gradient) {
|
||||||
|
return {
|
||||||
|
styles: {
|
||||||
|
background: `linear-gradient(90deg, ${data.gradient.join(', ')})`,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
},
|
||||||
|
className: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.style === 'animated') {
|
||||||
|
return {
|
||||||
|
styles: {
|
||||||
|
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
|
||||||
|
backgroundSize: '400% 100%',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
},
|
||||||
|
className: 'animate-rainbow-rotate',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.style === 'solid' && data.color) {
|
||||||
|
return { styles: { color: data.color }, className: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { styles: {}, className: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get title data
|
||||||
|
function getTitleData(title: ShopItemPublic | null | undefined): { text: string; color: string } | null {
|
||||||
|
if (!title?.asset_data) return null
|
||||||
|
|
||||||
|
const data = title.asset_data as { text?: string; color?: string }
|
||||||
|
if (!data.text) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: data.text,
|
||||||
|
color: data.color || '#ffffff',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styled nickname component
|
||||||
|
function StyledNickname({ user, className = '' }: { user: User; className?: string }) {
|
||||||
|
const nameColorData = getNameColorData(user.equipped_name_color)
|
||||||
|
const titleData = getTitleData(user.equipped_title)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-2 ${className}`}>
|
||||||
|
<span className={nameColorData.className} style={nameColorData.styles}>{user.nickname}</span>
|
||||||
|
{titleData && (
|
||||||
|
<span
|
||||||
|
className="px-1.5 py-0.5 text-xs font-medium rounded bg-dark-700/50"
|
||||||
|
style={{ color: titleData.color }}
|
||||||
|
>
|
||||||
|
{titleData.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function LeaderboardPage() {
|
export function LeaderboardPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
@@ -117,48 +188,66 @@ export function LeaderboardPage() {
|
|||||||
<div className="flex items-end justify-center gap-4 mb-4">
|
<div className="flex items-end justify-center gap-4 mb-4">
|
||||||
{/* 2nd place */}
|
{/* 2nd place */}
|
||||||
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '100ms' }}>
|
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '100ms' }}>
|
||||||
<div className={`
|
<div className="mb-3">
|
||||||
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
|
<UserAvatar
|
||||||
bg-gray-400/10 border border-gray-400/30
|
userId={topThree[1].user.id}
|
||||||
shadow-[0_0_20px_rgba(156,163,175,0.2)]
|
hasAvatar={!!topThree[1].user.avatar_url}
|
||||||
`}>
|
nickname={topThree[1].user.nickname}
|
||||||
<span className="text-3xl font-bold text-gray-300">2</span>
|
size="lg"
|
||||||
|
frame={topThree[1].user.equipped_frame}
|
||||||
|
telegramAvatarUrl={topThree[1].user.telegram_avatar_url}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Link to={`/users/${topThree[1].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
|
<Link to={`/users/${topThree[1].user.id}`} className="glass rounded-xl p-4 text-center w-32 hover:border-neon-500/30 transition-colors border border-transparent">
|
||||||
<Medal className="w-6 h-6 text-gray-300 mx-auto mb-2" />
|
<Medal className="w-6 h-6 text-gray-300 mx-auto mb-2" />
|
||||||
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[1].user.nickname}</p>
|
<p className="text-sm font-medium truncate hover:text-neon-400 transition-colors">
|
||||||
|
<StyledNickname user={topThree[1].user} />
|
||||||
|
</p>
|
||||||
<p className="text-xs text-gray-400">{topThree[1].total_points} очков</p>
|
<p className="text-xs text-gray-400">{topThree[1].total_points} очков</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 1st place */}
|
{/* 1st place */}
|
||||||
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '0ms' }}>
|
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '0ms' }}>
|
||||||
<div className={`
|
<div className="mb-3 relative">
|
||||||
w-24 h-24 rounded-2xl mb-3 flex items-center justify-center
|
<UserAvatar
|
||||||
bg-yellow-500/20 border border-yellow-500/30
|
userId={topThree[0].user.id}
|
||||||
shadow-[0_0_30px_rgba(234,179,8,0.4)]
|
hasAvatar={!!topThree[0].user.avatar_url}
|
||||||
`}>
|
nickname={topThree[0].user.nickname}
|
||||||
<Crown className="w-10 h-10 text-yellow-400" />
|
size="xl"
|
||||||
|
frame={topThree[0].user.equipped_frame}
|
||||||
|
telegramAvatarUrl={topThree[0].user.telegram_avatar_url}
|
||||||
|
/>
|
||||||
|
<div className="absolute -top-2 -right-2 w-8 h-8 rounded-full bg-yellow-500 flex items-center justify-center shadow-lg shadow-yellow-500/50">
|
||||||
|
<Crown className="w-5 h-5 text-dark-900" />
|
||||||
</div>
|
</div>
|
||||||
<Link to={`/users/${topThree[0].user.id}`} className="glass-neon rounded-xl p-4 text-center w-32 hover:shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-shadow">
|
</div>
|
||||||
|
<Link to={`/users/${topThree[0].user.id}`} className="glass-neon rounded-xl p-4 text-center w-36 hover:shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-shadow">
|
||||||
<Star className="w-6 h-6 text-yellow-400 mx-auto mb-2" />
|
<Star className="w-6 h-6 text-yellow-400 mx-auto mb-2" />
|
||||||
<p className="font-semibold text-white truncate hover:text-neon-400 transition-colors">{topThree[0].user.nickname}</p>
|
<p className="font-semibold truncate hover:text-neon-400 transition-colors">
|
||||||
|
<StyledNickname user={topThree[0].user} />
|
||||||
|
</p>
|
||||||
<p className="text-sm text-neon-400 font-bold">{topThree[0].total_points} очков</p>
|
<p className="text-sm text-neon-400 font-bold">{topThree[0].total_points} очков</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3rd place */}
|
{/* 3rd place */}
|
||||||
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '200ms' }}>
|
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '200ms' }}>
|
||||||
<div className={`
|
<div className="mb-3">
|
||||||
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
|
<UserAvatar
|
||||||
bg-amber-600/10 border border-amber-600/30
|
userId={topThree[2].user.id}
|
||||||
shadow-[0_0_20px_rgba(217,119,6,0.2)]
|
hasAvatar={!!topThree[2].user.avatar_url}
|
||||||
`}>
|
nickname={topThree[2].user.nickname}
|
||||||
<span className="text-3xl font-bold text-amber-600">3</span>
|
size="lg"
|
||||||
|
frame={topThree[2].user.equipped_frame}
|
||||||
|
telegramAvatarUrl={topThree[2].user.telegram_avatar_url}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Link to={`/users/${topThree[2].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
|
<Link to={`/users/${topThree[2].user.id}`} className="glass rounded-xl p-4 text-center w-32 hover:border-neon-500/30 transition-colors border border-transparent">
|
||||||
<Award className="w-6 h-6 text-amber-600 mx-auto mb-2" />
|
<Award className="w-6 h-6 text-amber-600 mx-auto mb-2" />
|
||||||
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[2].user.nickname}</p>
|
<p className="text-sm font-medium truncate hover:text-neon-400 transition-colors">
|
||||||
|
<StyledNickname user={topThree[2].user} />
|
||||||
|
</p>
|
||||||
<p className="text-xs text-gray-400">{topThree[2].total_points} очков</p>
|
<p className="text-xs text-gray-400">{topThree[2].total_points} очков</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,20 +311,32 @@ export function LeaderboardPage() {
|
|||||||
|
|
||||||
{/* Rank */}
|
{/* Rank */}
|
||||||
<div className={`
|
<div className={`
|
||||||
relative w-10 h-10 rounded-xl flex items-center justify-center
|
relative w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0
|
||||||
${rankConfig.bg} ${rankConfig.color} ${rankConfig.glow}
|
${rankConfig.bg} ${rankConfig.color} ${rankConfig.glow}
|
||||||
`}>
|
`}>
|
||||||
{rankConfig.icon}
|
{rankConfig.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<UserAvatar
|
||||||
|
userId={entry.user.id}
|
||||||
|
hasAvatar={!!entry.user.avatar_url}
|
||||||
|
nickname={entry.user.nickname}
|
||||||
|
size="sm"
|
||||||
|
frame={entry.user.equipped_frame}
|
||||||
|
telegramAvatarUrl={entry.user.telegram_avatar_url}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className="relative flex-1 min-w-0">
|
<div className="relative flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Link
|
<Link
|
||||||
to={`/users/${entry.user.id}`}
|
to={`/users/${entry.user.id}`}
|
||||||
className={`font-semibold truncate hover:text-neon-400 transition-colors ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}
|
className={`font-semibold truncate hover:text-neon-400 transition-colors ${isCurrentUser ? 'text-neon-400' : ''}`}
|
||||||
>
|
>
|
||||||
{entry.user.nickname}
|
<StyledNickname user={entry.user} />
|
||||||
</Link>
|
</Link>
|
||||||
{isCurrentUser && (
|
{isCurrentUser && (
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-neon-500/20 text-neon-400 rounded-full border border-neon-500/30">
|
<span className="px-2 py-0.5 text-xs font-medium bg-neon-500/20 text-neon-400 rounded-full border border-neon-500/30">
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi } from '@/api'
|
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi, shopApi } from '@/api'
|
||||||
import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment } from '@/types'
|
import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment, ConsumablesStatus, ConsumableType } from '@/types'
|
||||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||||
import { SpinWheel } from '@/components/SpinWheel'
|
import { SpinWheel } from '@/components/SpinWheel'
|
||||||
import { EventBanner } from '@/components/EventBanner'
|
import { EventBanner } from '@/components/EventBanner'
|
||||||
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download } from 'lucide-react'
|
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, Shield, RefreshCw, SkipForward, Package } from 'lucide-react'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
import { useShopStore } from '@/store/shop'
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 15 * 1024 * 1024
|
const MAX_IMAGE_SIZE = 15 * 1024 * 1024
|
||||||
const MAX_VIDEO_SIZE = 30 * 1024 * 1024
|
const MAX_VIDEO_SIZE = 30 * 1024 * 1024
|
||||||
@@ -55,6 +56,10 @@ export function PlayPage() {
|
|||||||
|
|
||||||
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
|
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
|
||||||
|
|
||||||
|
// Consumables
|
||||||
|
const [consumablesStatus, setConsumablesStatus] = useState<ConsumablesStatus | null>(null)
|
||||||
|
const [isUsingConsumable, setIsUsingConsumable] = useState<ConsumableType | null>(null)
|
||||||
|
|
||||||
// Bonus challenge completion
|
// Bonus challenge completion
|
||||||
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
|
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
|
||||||
const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
|
const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
|
||||||
@@ -177,13 +182,14 @@ export function PlayPage() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
|
const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData, consumablesData] = await Promise.all([
|
||||||
marathonsApi.get(parseInt(id)),
|
marathonsApi.get(parseInt(id)),
|
||||||
wheelApi.getCurrentAssignment(parseInt(id)),
|
wheelApi.getCurrentAssignment(parseInt(id)),
|
||||||
gamesApi.getAvailableGames(parseInt(id)),
|
gamesApi.getAvailableGames(parseInt(id)),
|
||||||
eventsApi.getActive(parseInt(id)),
|
eventsApi.getActive(parseInt(id)),
|
||||||
eventsApi.getEventAssignment(parseInt(id)),
|
eventsApi.getEventAssignment(parseInt(id)),
|
||||||
assignmentsApi.getReturnedAssignments(parseInt(id)),
|
assignmentsApi.getReturnedAssignments(parseInt(id)),
|
||||||
|
shopApi.getConsumablesStatus(parseInt(id)).catch(() => null),
|
||||||
])
|
])
|
||||||
setMarathon(marathonData)
|
setMarathon(marathonData)
|
||||||
setCurrentAssignment(assignment)
|
setCurrentAssignment(assignment)
|
||||||
@@ -191,6 +197,7 @@ export function PlayPage() {
|
|||||||
setActiveEvent(eventData)
|
setActiveEvent(eventData)
|
||||||
setEventAssignment(eventAssignmentData)
|
setEventAssignment(eventAssignmentData)
|
||||||
setReturnedAssignments(returnedData)
|
setReturnedAssignments(returnedData)
|
||||||
|
setConsumablesStatus(consumablesData)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load data:', error)
|
console.error('Failed to load data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -255,6 +262,8 @@ export function PlayPage() {
|
|||||||
setProofUrl('')
|
setProofUrl('')
|
||||||
setComment('')
|
setComment('')
|
||||||
await loadData()
|
await loadData()
|
||||||
|
// Refresh coins balance
|
||||||
|
useShopStore.getState().loadBalance()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
|
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
|
||||||
@@ -464,6 +473,85 @@ export function PlayPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consumable handlers
|
||||||
|
const handleUseSkip = async () => {
|
||||||
|
if (!currentAssignment || !id) return
|
||||||
|
setIsUsingConsumable('skip')
|
||||||
|
try {
|
||||||
|
await shopApi.useConsumable({
|
||||||
|
item_code: 'skip',
|
||||||
|
marathon_id: parseInt(id),
|
||||||
|
assignment_id: currentAssignment.id,
|
||||||
|
})
|
||||||
|
toast.success('Задание пропущено без штрафа!')
|
||||||
|
await loadData()
|
||||||
|
useShopStore.getState().loadBalance()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось использовать Skip')
|
||||||
|
} finally {
|
||||||
|
setIsUsingConsumable(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUseReroll = async () => {
|
||||||
|
if (!currentAssignment || !id) return
|
||||||
|
setIsUsingConsumable('reroll')
|
||||||
|
try {
|
||||||
|
await shopApi.useConsumable({
|
||||||
|
item_code: 'reroll',
|
||||||
|
marathon_id: parseInt(id),
|
||||||
|
assignment_id: currentAssignment.id,
|
||||||
|
})
|
||||||
|
toast.success('Задание отменено! Можно крутить заново.')
|
||||||
|
await loadData()
|
||||||
|
useShopStore.getState().loadBalance()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось использовать Reroll')
|
||||||
|
} finally {
|
||||||
|
setIsUsingConsumable(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUseShield = async () => {
|
||||||
|
if (!id) return
|
||||||
|
setIsUsingConsumable('shield')
|
||||||
|
try {
|
||||||
|
await shopApi.useConsumable({
|
||||||
|
item_code: 'shield',
|
||||||
|
marathon_id: parseInt(id),
|
||||||
|
})
|
||||||
|
toast.success('Shield активирован! Следующий пропуск будет бесплатным.')
|
||||||
|
await loadData()
|
||||||
|
useShopStore.getState().loadBalance()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось активировать Shield')
|
||||||
|
} finally {
|
||||||
|
setIsUsingConsumable(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUseBoost = async () => {
|
||||||
|
if (!id) return
|
||||||
|
setIsUsingConsumable('boost')
|
||||||
|
try {
|
||||||
|
await shopApi.useConsumable({
|
||||||
|
item_code: 'boost',
|
||||||
|
marathon_id: parseInt(id),
|
||||||
|
})
|
||||||
|
toast.success('Boost активирован! x1.5 очков за следующее выполнение.')
|
||||||
|
await loadData()
|
||||||
|
useShopStore.getState().loadBalance()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось активировать Boost')
|
||||||
|
} finally {
|
||||||
|
setIsUsingConsumable(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-24">
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
@@ -608,6 +696,135 @@ export function PlayPage() {
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Consumables Panel */}
|
||||||
|
{consumablesStatus && marathon?.allow_consumables && (
|
||||||
|
<GlassCard className="mb-6 border-purple-500/30">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||||
|
<Package className="w-5 h-5 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-purple-400">Расходники</h3>
|
||||||
|
<p className="text-sm text-gray-400">Используйте для облегчения задания</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active effects */}
|
||||||
|
{(consumablesStatus.has_shield || consumablesStatus.has_active_boost) && (
|
||||||
|
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/30 rounded-xl">
|
||||||
|
<p className="text-green-400 text-sm font-medium mb-2">Активные эффекты:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{consumablesStatus.has_shield && (
|
||||||
|
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg border border-blue-500/30 flex items-center gap-1">
|
||||||
|
<Shield className="w-3 h-3" /> Shield (следующий drop бесплатный)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{consumablesStatus.has_active_boost && (
|
||||||
|
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg border border-yellow-500/30 flex items-center gap-1">
|
||||||
|
<Zap className="w-3 h-3" /> Boost x1.5 (следующий complete)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Consumables grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* Skip */}
|
||||||
|
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SkipForward className="w-4 h-4 text-orange-400" />
|
||||||
|
<span className="text-white font-medium">Skip</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400 text-sm">{consumablesStatus.skips_available} шт.</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-xs mb-2">Пропустить без штрафа</p>
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleUseSkip}
|
||||||
|
disabled={consumablesStatus.skips_available === 0 || !currentAssignment || isUsingConsumable !== null}
|
||||||
|
isLoading={isUsingConsumable === 'skip'}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Использовать
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reroll */}
|
||||||
|
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-white font-medium">Reroll</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400 text-sm">{consumablesStatus.rerolls_available} шт.</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-xs mb-2">Переспинить задание</p>
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleUseReroll}
|
||||||
|
disabled={consumablesStatus.rerolls_available === 0 || !currentAssignment || isUsingConsumable !== null}
|
||||||
|
isLoading={isUsingConsumable === 'reroll'}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Использовать
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shield */}
|
||||||
|
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-white font-medium">Shield</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400 text-sm">
|
||||||
|
{consumablesStatus.has_shield ? 'Активен' : `${consumablesStatus.shields_available} шт.`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-xs mb-2">Защита от штрафа</p>
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleUseShield}
|
||||||
|
disabled={consumablesStatus.has_shield || consumablesStatus.shields_available === 0 || isUsingConsumable !== null}
|
||||||
|
isLoading={isUsingConsumable === 'shield'}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{consumablesStatus.has_shield ? 'Активен' : 'Активировать'}
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Boost */}
|
||||||
|
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4 text-yellow-400" />
|
||||||
|
<span className="text-white font-medium">Boost</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400 text-sm">
|
||||||
|
{consumablesStatus.has_active_boost ? 'Активен' : `${consumablesStatus.boosts_available} шт.`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-xs mb-2">x1.5 очков</p>
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleUseBoost}
|
||||||
|
disabled={consumablesStatus.has_active_boost || consumablesStatus.boosts_available === 0 || isUsingConsumable !== null}
|
||||||
|
isLoading={isUsingConsumable === 'boost'}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tabs for Common Enemy event */}
|
{/* Tabs for Common Enemy event */}
|
||||||
{activeEvent?.event?.type === 'common_enemy' && (
|
{activeEvent?.event?.type === 'common_enemy' && (
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6">
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
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 } from '@/api'
|
||||||
import type { UserStats } from '@/types'
|
import type { UserStats, ShopItemPublic } from '@/types'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import {
|
import {
|
||||||
NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
|
NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
|
||||||
@@ -13,8 +14,9 @@ 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
|
AlertTriangle, FileCheck, Backpack, Edit3
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
// Schemas
|
// Schemas
|
||||||
const nicknameSchema = z.object({
|
const nicknameSchema = z.object({
|
||||||
@@ -33,6 +35,235 @@ const passwordSchema = z.object({
|
|||||||
type NicknameForm = z.infer<typeof nicknameSchema>
|
type NicknameForm = z.infer<typeof nicknameSchema>
|
||||||
type PasswordForm = z.infer<typeof passwordSchema>
|
type PasswordForm = z.infer<typeof passwordSchema>
|
||||||
|
|
||||||
|
// ============ COSMETICS HELPERS ============
|
||||||
|
|
||||||
|
// Background asset_data structure:
|
||||||
|
// - type: 'solid' | 'gradient' | 'pattern' | 'animated'
|
||||||
|
// - color: '#1a1a2e' (for solid)
|
||||||
|
// - gradient: ['#1a1a2e', '#4a0080'] (for gradient)
|
||||||
|
// - pattern: 'stars' | 'gaming-icons' (for pattern)
|
||||||
|
// - animation: 'fire-particles' (for animated)
|
||||||
|
// - animated: boolean (for animated patterns)
|
||||||
|
|
||||||
|
interface BackgroundResult {
|
||||||
|
styles: React.CSSProperties
|
||||||
|
className: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackgroundData(background: ShopItemPublic | null): BackgroundResult {
|
||||||
|
if (!background?.asset_data) {
|
||||||
|
return { styles: {}, className: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = background.asset_data as {
|
||||||
|
type?: string
|
||||||
|
gradient?: string[]
|
||||||
|
pattern?: string
|
||||||
|
color?: string
|
||||||
|
animation?: string
|
||||||
|
animated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: React.CSSProperties = {}
|
||||||
|
let className = ''
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'solid':
|
||||||
|
if (data.color) {
|
||||||
|
styles.backgroundColor = data.color
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'gradient':
|
||||||
|
if (data.gradient && data.gradient.length > 0) {
|
||||||
|
styles.background = `linear-gradient(135deg, ${data.gradient.join(', ')})`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'pattern':
|
||||||
|
// Pattern backgrounds - use CSS classes for animated stars
|
||||||
|
if (data.pattern === 'stars') {
|
||||||
|
// Use CSS class for twinkling stars effect
|
||||||
|
className = 'bg-stars-animated'
|
||||||
|
} else if (data.pattern === 'gaming-icons') {
|
||||||
|
styles.background = `
|
||||||
|
linear-gradient(45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
|
||||||
|
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
|
||||||
|
`
|
||||||
|
styles.backgroundSize = '40px 40px, 40px 40px, 40px 40px, 40px 40px, 100% 100%'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'animated':
|
||||||
|
// Animated backgrounds
|
||||||
|
if (data.animation === 'fire-particles') {
|
||||||
|
styles.background = `
|
||||||
|
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.4) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 30% 80%, rgba(255,50,0,0.3) 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 70% 90%, rgba(255,150,0,0.3) 0%, transparent 45%),
|
||||||
|
linear-gradient(to top, #1a0a00 0%, #0d0d0d 60%, #1a1a2e 100%)
|
||||||
|
`
|
||||||
|
className = 'animate-fire-pulse'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return { styles, className }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name color asset_data structure:
|
||||||
|
// - style: 'solid' | 'gradient' | 'animated'
|
||||||
|
// - color: '#FF4444' (for solid)
|
||||||
|
// - gradient: ['#FF6B6B', '#FFE66D'] (for gradient)
|
||||||
|
// - animation: 'rainbow-shift' (for animated)
|
||||||
|
|
||||||
|
interface NameColorResult {
|
||||||
|
type: 'solid' | 'gradient' | 'animated'
|
||||||
|
color?: string
|
||||||
|
gradient?: string[]
|
||||||
|
animation?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameColorData(nameColor: ShopItemPublic | null): NameColorResult {
|
||||||
|
if (!nameColor?.asset_data) {
|
||||||
|
return { type: 'solid', color: '#ffffff' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = nameColor.asset_data as {
|
||||||
|
style?: string
|
||||||
|
color?: string
|
||||||
|
gradient?: string[]
|
||||||
|
animation?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.style === 'gradient' && data.gradient) {
|
||||||
|
return { type: 'gradient', gradient: data.gradient }
|
||||||
|
}
|
||||||
|
if (data.style === 'animated') {
|
||||||
|
return { type: 'animated', animation: data.animation }
|
||||||
|
}
|
||||||
|
return { type: 'solid', color: data.color || '#ffffff' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get title from equipped_title
|
||||||
|
function getTitleData(title: ShopItemPublic | null): { text: string; color: string } | null {
|
||||||
|
if (!title?.asset_data) return null
|
||||||
|
const data = title.asset_data as { text?: string; color?: string }
|
||||||
|
if (!data.text) return null
|
||||||
|
return { text: data.text, color: data.color || '#ffffff' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get frame styles from asset_data
|
||||||
|
function getFrameStyles(frame: ShopItemPublic | null): React.CSSProperties {
|
||||||
|
if (!frame?.asset_data) return {}
|
||||||
|
|
||||||
|
const data = frame.asset_data as {
|
||||||
|
border_color?: string
|
||||||
|
gradient?: string[]
|
||||||
|
glow_color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: React.CSSProperties = {}
|
||||||
|
|
||||||
|
if (data.gradient && data.gradient.length > 0) {
|
||||||
|
styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
|
||||||
|
styles.backgroundSize = '400% 400%'
|
||||||
|
} else if (data.border_color) {
|
||||||
|
styles.background = data.border_color
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.glow_color) {
|
||||||
|
styles.boxShadow = `0 0 20px ${data.glow_color}, 0 0 40px ${data.glow_color}40`
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get frame animation class
|
||||||
|
function getFrameAnimation(frame: ShopItemPublic | null): string {
|
||||||
|
if (!frame?.asset_data) return ''
|
||||||
|
const data = frame.asset_data as { animation?: string }
|
||||||
|
if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
|
||||||
|
if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ HERO AVATAR COMPONENT ============
|
||||||
|
|
||||||
|
function HeroAvatar({
|
||||||
|
avatarUrl,
|
||||||
|
nickname,
|
||||||
|
frame,
|
||||||
|
onClick,
|
||||||
|
isUploading,
|
||||||
|
isLoading
|
||||||
|
}: {
|
||||||
|
avatarUrl: string | null | undefined
|
||||||
|
nickname: string | undefined
|
||||||
|
frame: ShopItemPublic | null
|
||||||
|
onClick: () => void
|
||||||
|
isUploading: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
}) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="w-32 h-32 md:w-40 md:h-40 rounded-2xl bg-dark-700/50 skeleton" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarContent = (
|
||||||
|
<div className="w-32 h-32 md:w-40 md:h-40 rounded-2xl overflow-hidden bg-dark-700/80 backdrop-blur-sm">
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={nickname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
|
||||||
|
<User className="w-16 h-16 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const hoverOverlay = (
|
||||||
|
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded-2xl">
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="w-10 h-10 text-neon-500 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Camera className="w-10 h-10 text-neon-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!frame) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="relative rounded-2xl border-2 border-neon-500/50 hover:border-neon-500 transition-all shadow-[0_0_30px_rgba(34,211,238,0.15)] hover:shadow-[0_0_40px_rgba(34,211,238,0.3)] group"
|
||||||
|
>
|
||||||
|
{avatarContent}
|
||||||
|
{hoverOverlay}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={isUploading}
|
||||||
|
className={clsx(
|
||||||
|
'relative rounded-2xl p-1.5 transition-all group',
|
||||||
|
getFrameAnimation(frame)
|
||||||
|
)}
|
||||||
|
style={getFrameStyles(frame)}
|
||||||
|
>
|
||||||
|
{avatarContent}
|
||||||
|
{hoverOverlay}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
|
const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -298,47 +529,90 @@ export function ProfilePage() {
|
|||||||
const isLinked = !!user?.telegram_id
|
const isLinked = !!user?.telegram_id
|
||||||
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
||||||
|
|
||||||
return (
|
// Get cosmetics data
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
const equippedFrame = user?.equipped_frame as ShopItemPublic | null
|
||||||
{/* Header */}
|
const equippedTitle = user?.equipped_title as ShopItemPublic | null
|
||||||
<div className="mb-8">
|
const equippedNameColor = user?.equipped_name_color as ShopItemPublic | null
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Мой профиль</h1>
|
const equippedBackground = user?.equipped_background as ShopItemPublic | null
|
||||||
<p className="text-gray-400">Настройки вашего аккаунта</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Profile Card */}
|
const titleData = getTitleData(equippedTitle)
|
||||||
<GlassCard variant="neon">
|
const nameColorData = getNameColorData(equippedNameColor)
|
||||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
|
||||||
{/* Avatar */}
|
// Get nickname styles based on color type
|
||||||
<div className="relative group flex-shrink-0">
|
const getNicknameStyles = (): React.CSSProperties => {
|
||||||
{isLoadingAvatar ? (
|
if (nameColorData.type === 'solid') {
|
||||||
<div className="w-28 h-28 rounded-2xl bg-dark-700 skeleton" />
|
return { color: nameColorData.color }
|
||||||
) : (
|
}
|
||||||
<button
|
if (nameColorData.type === 'gradient' && nameColorData.gradient) {
|
||||||
onClick={handleAvatarClick}
|
return {
|
||||||
disabled={isUploadingAvatar}
|
background: `linear-gradient(90deg, ${nameColorData.gradient.join(', ')})`,
|
||||||
className="relative w-28 h-28 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 hover:border-neon-500 transition-all group-hover:shadow-[0_0_20px_rgba(34,211,238,0.25)]"
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nameColorData.type === 'animated') {
|
||||||
|
// Rainbow animated - uses CSS animation with background-position
|
||||||
|
return {
|
||||||
|
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3, #ff0000)',
|
||||||
|
backgroundSize: '400% 100%',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { color: '#ffffff' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get nickname animation class
|
||||||
|
const getNicknameAnimation = (): string => {
|
||||||
|
if (nameColorData.type === 'animated' && nameColorData.animation === 'rainbow-shift') {
|
||||||
|
return 'animate-rainbow-rotate'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get background data
|
||||||
|
const backgroundData = getBackgroundData(equippedBackground)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
{/* ============ HERO SECTION ============ */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'relative rounded-3xl overflow-hidden',
|
||||||
|
backgroundData.className
|
||||||
|
)}
|
||||||
|
style={backgroundData.styles}
|
||||||
>
|
>
|
||||||
{displayAvatar ? (
|
{/* Default gradient background if no custom background */}
|
||||||
<img
|
{!equippedBackground && (
|
||||||
src={displayAvatar}
|
<div className="absolute inset-0 bg-gradient-to-br from-dark-800 via-dark-900 to-neon-900/20" />
|
||||||
alt={user?.nickname}
|
)}
|
||||||
className="w-full h-full object-cover"
|
|
||||||
|
{/* Overlay for readability */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-dark-900/90 via-dark-900/40 to-transparent" />
|
||||||
|
|
||||||
|
{/* Scan lines effect */}
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(transparent_50%,rgba(0,0,0,0.03)_50%)] bg-[length:100%_4px] pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Glow effects */}
|
||||||
|
<div className="absolute top-0 left-1/4 w-96 h-96 bg-neon-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 right-1/4 w-64 h-64 bg-accent-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10 px-6 py-10 md:px-10 md:py-14">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-10">
|
||||||
|
{/* Avatar with Frame */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<HeroAvatar
|
||||||
|
avatarUrl={displayAvatar}
|
||||||
|
nickname={user?.nickname}
|
||||||
|
frame={equippedFrame}
|
||||||
|
onClick={handleAvatarClick}
|
||||||
|
isUploading={isUploadingAvatar}
|
||||||
|
isLoading={isLoadingAvatar}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
|
|
||||||
<User className="w-12 h-12 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
{isUploadingAvatar ? (
|
|
||||||
<Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Camera className="w-8 h-8 text-neon-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -348,17 +622,98 @@ export function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nickname Form */}
|
{/* User Info */}
|
||||||
<div className="flex-1 w-full sm:w-auto">
|
<div className="flex-1 text-center md:text-left">
|
||||||
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
|
{/* Nickname with color + Title badge */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center md:justify-start gap-3 mb-3">
|
||||||
|
<h1
|
||||||
|
className={clsx(
|
||||||
|
'text-3xl md:text-4xl font-bold font-display tracking-wide drop-shadow-[0_0_10px_rgba(255,255,255,0.3)]',
|
||||||
|
getNicknameAnimation()
|
||||||
|
)}
|
||||||
|
style={getNicknameStyles()}
|
||||||
|
>
|
||||||
|
{user?.nickname || 'Игрок'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Title badge */}
|
||||||
|
{titleData && (
|
||||||
|
<span
|
||||||
|
className="px-3 py-1 rounded-full text-sm font-semibold border backdrop-blur-sm"
|
||||||
|
style={{
|
||||||
|
color: titleData.color,
|
||||||
|
borderColor: `${titleData.color}50`,
|
||||||
|
backgroundColor: `${titleData.color}15`,
|
||||||
|
boxShadow: `0 0 15px ${titleData.color}30`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{titleData.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role badge */}
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-500/20 border border-purple-500/30 text-purple-400 text-sm font-medium mb-4">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
Администратор
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick stats preview */}
|
||||||
|
{stats && (
|
||||||
|
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm text-gray-300 mt-4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Trophy className="w-4 h-4 text-yellow-500" />
|
||||||
|
<span>{stats.wins_count} побед</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Target className="w-4 h-4 text-neon-400" />
|
||||||
|
<span>{stats.marathons_count} марафонов</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Flame className="w-4 h-4 text-orange-400" />
|
||||||
|
<span>{stats.total_points_earned} очков</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inventory link */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link
|
||||||
|
to="/inventory"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700/50 hover:bg-dark-700 border border-dark-600 hover:border-neon-500/30 text-gray-300 hover:text-white transition-all"
|
||||||
|
>
|
||||||
|
<Backpack className="w-4 h-4" />
|
||||||
|
Инвентарь
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ============ NICKNAME EDIT SECTION ============ */}
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
||||||
|
<Edit3 className="w-5 h-5 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Изменить никнейм</h2>
|
||||||
|
<p className="text-sm text-gray-400">Ваше игровое имя</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
<Input
|
<Input
|
||||||
label="Никнейм"
|
|
||||||
{...nicknameForm.register('nickname')}
|
{...nicknameForm.register('nickname')}
|
||||||
error={nicknameForm.formState.errors.nickname?.message}
|
error={nicknameForm.formState.errors.nickname?.message}
|
||||||
|
placeholder="Введите никнейм"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<NeonButton
|
<NeonButton
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
|
||||||
isLoading={nicknameForm.formState.isSubmitting}
|
isLoading={nicknameForm.formState.isSubmitting}
|
||||||
disabled={!nicknameForm.formState.isDirty}
|
disabled={!nicknameForm.formState.isDirty}
|
||||||
icon={<Save className="w-4 h-4" />}
|
icon={<Save className="w-4 h-4" />}
|
||||||
@@ -366,8 +721,6 @@ export function ProfilePage() {
|
|||||||
Сохранить
|
Сохранить
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
|
|||||||
470
frontend/src/pages/ShopPage.tsx
Normal file
470
frontend/src/pages/ShopPage.tsx
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useShopStore } from '@/store/shop'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||||
|
import {
|
||||||
|
Loader2, Coins, ShoppingBag, Package, Sparkles,
|
||||||
|
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward,
|
||||||
|
Minus, Plus
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
|
||||||
|
import { RARITY_COLORS, RARITY_NAMES } from '@/types'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
const ITEM_TYPE_ICONS: Record<ShopItemType, React.ReactNode> = {
|
||||||
|
frame: <Frame className="w-5 h-5" />,
|
||||||
|
title: <Type className="w-5 h-5" />,
|
||||||
|
name_color: <Palette className="w-5 h-5" />,
|
||||||
|
background: <Image className="w-5 h-5" />,
|
||||||
|
consumable: <Zap className="w-5 h-5" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
skip: <SkipForward className="w-8 h-8" />,
|
||||||
|
shield: <Shield className="w-8 h-8" />,
|
||||||
|
boost: <Zap className="w-8 h-8" />,
|
||||||
|
reroll: <RefreshCw className="w-8 h-8" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShopItemCardProps {
|
||||||
|
item: ShopItem
|
||||||
|
onPurchase: (item: ShopItem, quantity: number) => void
|
||||||
|
isPurchasing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
|
||||||
|
const [quantity, setQuantity] = useState(1)
|
||||||
|
const rarityColors = RARITY_COLORS[item.rarity]
|
||||||
|
const isConsumable = item.item_type === 'consumable'
|
||||||
|
const maxQuantity = item.stock_remaining !== null ? Math.min(10, item.stock_remaining) : 10
|
||||||
|
const totalPrice = item.price * quantity
|
||||||
|
|
||||||
|
const incrementQuantity = () => {
|
||||||
|
if (quantity < maxQuantity) {
|
||||||
|
setQuantity(q => q + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrementQuantity = () => {
|
||||||
|
if (quantity > 1) {
|
||||||
|
setQuantity(q => q - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemPreview = () => {
|
||||||
|
if (item.item_type === 'consumable') {
|
||||||
|
return CONSUMABLE_ICONS[item.code] || <Package className="w-8 h-8" />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name color preview - handles solid, gradient, animated
|
||||||
|
if (item.item_type === 'name_color') {
|
||||||
|
const data = item.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string } | null
|
||||||
|
|
||||||
|
// Gradient style
|
||||||
|
if (data?.style === 'gradient' && data.gradient) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||||
|
style={{ background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated rainbow style
|
||||||
|
if (data?.style === 'animated') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-dark-600 animate-rainbow-rotate"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
|
||||||
|
backgroundSize: '400% 400%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solid color style (default)
|
||||||
|
const solidColor = data?.color || '#ffffff'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||||
|
style={{ backgroundColor: solidColor }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background preview
|
||||||
|
if (item.item_type === 'background') {
|
||||||
|
const data = item.asset_data as { type?: string; color?: string; gradient?: string[]; pattern?: string; animation?: string } | null
|
||||||
|
let bgStyle: React.CSSProperties = {}
|
||||||
|
let animClass = ''
|
||||||
|
|
||||||
|
if (data?.type === 'solid' && data.color) {
|
||||||
|
bgStyle = { backgroundColor: data.color }
|
||||||
|
} else if (data?.type === 'gradient' && data.gradient) {
|
||||||
|
bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
|
||||||
|
} else if (data?.type === 'pattern') {
|
||||||
|
if (data.pattern === 'stars') {
|
||||||
|
bgStyle = {
|
||||||
|
background: `
|
||||||
|
radial-gradient(1px 1px at 10px 10px, #fff, transparent),
|
||||||
|
radial-gradient(1px 1px at 30px 25px, rgba(255,255,255,0.8), transparent),
|
||||||
|
radial-gradient(1px 1px at 50px 15px, #fff, transparent),
|
||||||
|
linear-gradient(135deg, #0d1b2a 0%, #1b263b 100%)
|
||||||
|
`,
|
||||||
|
backgroundSize: '60px 40px, 60px 40px, 60px 40px, 100% 100%'
|
||||||
|
}
|
||||||
|
animClass = 'animate-twinkle'
|
||||||
|
} else if (data.pattern === 'gaming-icons') {
|
||||||
|
bgStyle = {
|
||||||
|
background: `
|
||||||
|
linear-gradient(45deg, rgba(34,211,238,0.2) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, rgba(168,85,247,0.2) 25%, transparent 25%),
|
||||||
|
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
|
||||||
|
`,
|
||||||
|
backgroundSize: '20px 20px, 20px 20px, 100% 100%'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data?.type === 'animated' && data.animation === 'fire-particles') {
|
||||||
|
bgStyle = {
|
||||||
|
background: `
|
||||||
|
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.5) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 30% 80%, rgba(255,50,0,0.4) 0%, transparent 40%),
|
||||||
|
linear-gradient(to top, #1a0a00 0%, #0d0d0d 100%)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
animClass = 'animate-fire-pulse'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx('w-16 h-12 rounded-lg border-2 border-dark-600', animClass)}
|
||||||
|
style={bgStyle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.item_type === 'frame') {
|
||||||
|
// Use FramePreview for animated and gradient frames
|
||||||
|
const frameItem: ShopItemPublic = {
|
||||||
|
id: item.id,
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
item_type: item.item_type,
|
||||||
|
rarity: item.rarity,
|
||||||
|
asset_data: item.asset_data,
|
||||||
|
}
|
||||||
|
return <FramePreview frame={frameItem} size="lg" />
|
||||||
|
}
|
||||||
|
if (item.item_type === 'title' && item.asset_data?.text) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-lg font-bold"
|
||||||
|
style={{ color: (item.asset_data.color as string) || '#ffffff' }}
|
||||||
|
>
|
||||||
|
{item.asset_data.text as string}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ITEM_TYPE_ICONS[item.item_type]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard
|
||||||
|
className={clsx(
|
||||||
|
'p-4 border transition-all duration-300',
|
||||||
|
rarityColors.border,
|
||||||
|
item.is_owned && 'opacity-60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Rarity badge */}
|
||||||
|
<div className={clsx('text-xs font-medium mb-2', rarityColors.text)}>
|
||||||
|
{RARITY_NAMES[item.rarity]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item preview */}
|
||||||
|
<div className="flex justify-center items-center h-20 mb-3">
|
||||||
|
{getItemPreview()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item info */}
|
||||||
|
<h3 className="text-white font-semibold text-center mb-1">{item.name}</h3>
|
||||||
|
<p className="text-gray-400 text-xs text-center mb-3 line-clamp-2">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Quantity selector for consumables */}
|
||||||
|
{isConsumable && !item.is_owned && item.is_available && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={decrementQuantity}
|
||||||
|
disabled={quantity <= 1 || isPurchasing}
|
||||||
|
className="w-7 h-7 rounded-lg bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="w-8 text-center text-white font-bold">{quantity}</span>
|
||||||
|
<button
|
||||||
|
onClick={incrementQuantity}
|
||||||
|
disabled={quantity >= maxQuantity || isPurchasing}
|
||||||
|
className="w-7 h-7 rounded-lg bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Price and action */}
|
||||||
|
<div className="flex items-center justify-between mt-auto">
|
||||||
|
<div className="flex items-center gap-1 text-yellow-400">
|
||||||
|
<Coins className="w-4 h-4" />
|
||||||
|
<span className="font-bold">{isConsumable ? totalPrice : item.price}</span>
|
||||||
|
{isConsumable && quantity > 1 && (
|
||||||
|
<span className="text-xs text-gray-500">({item.price}×{quantity})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.is_owned && !isConsumable ? (
|
||||||
|
<span className="text-green-400 text-sm flex items-center gap-1">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Куплено
|
||||||
|
</span>
|
||||||
|
) : item.is_equipped ? (
|
||||||
|
<span className="text-neon-400 text-sm">Надето</span>
|
||||||
|
) : (
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPurchase(item, quantity)}
|
||||||
|
disabled={isPurchasing || !item.is_available}
|
||||||
|
>
|
||||||
|
{isPurchasing ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Купить'
|
||||||
|
)}
|
||||||
|
</NeonButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stock info */}
|
||||||
|
{item.stock_remaining !== null && (
|
||||||
|
<div className="text-xs text-gray-500 text-center mt-2">
|
||||||
|
Осталось: {item.stock_remaining}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShopPage() {
|
||||||
|
const { items, balance, isLoading, loadItems, loadBalance, purchase, clearError, error } = useShopStore()
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<ShopItemType | 'all'>('all')
|
||||||
|
const [purchasingId, setPurchasingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBalance()
|
||||||
|
loadItems()
|
||||||
|
}, [loadBalance, loadItems])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toast.error(error)
|
||||||
|
clearError()
|
||||||
|
}
|
||||||
|
}, [error, toast, clearError])
|
||||||
|
|
||||||
|
const handlePurchase = async (item: ShopItem, quantity: number = 1) => {
|
||||||
|
const totalCost = item.price * quantity
|
||||||
|
const isConsumable = item.item_type === 'consumable'
|
||||||
|
const quantityText = quantity > 1 ? ` (×${quantity})` : ''
|
||||||
|
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Подтвердите покупку',
|
||||||
|
message: isConsumable && quantity > 1
|
||||||
|
? `Купить "${item.name}" × ${quantity} шт. за ${totalCost} монет?`
|
||||||
|
: `Купить "${item.name}" за ${item.price} монет?`,
|
||||||
|
confirmText: 'Купить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setPurchasingId(item.id)
|
||||||
|
const success = await purchase(item.id, quantity)
|
||||||
|
setPurchasingId(null)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success(`Вы приобрели "${item.name}"${quantityText}!`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredItems = activeTab === 'all'
|
||||||
|
? items
|
||||||
|
: items.filter(item => item.item_type === activeTab)
|
||||||
|
|
||||||
|
// Group items by type for "All" tab
|
||||||
|
const itemsByType: Record<ShopItemType, ShopItem[]> = {
|
||||||
|
frame: [],
|
||||||
|
title: [],
|
||||||
|
name_color: [],
|
||||||
|
background: [],
|
||||||
|
consumable: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'all') {
|
||||||
|
items.forEach(item => {
|
||||||
|
if (itemsByType[item.item_type]) {
|
||||||
|
itemsByType[item.item_type].push(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryOrder: ShopItemType[] = ['frame', 'title', 'name_color', 'background', 'consumable']
|
||||||
|
const categoryLabels: Record<ShopItemType, { label: string; icon: React.ReactNode }> = {
|
||||||
|
frame: { label: 'Рамки профиля', icon: <Frame className="w-5 h-5" /> },
|
||||||
|
title: { label: 'Титулы', icon: <Type className="w-5 h-5" /> },
|
||||||
|
name_color: { label: 'Цвета ника', icon: <Palette className="w-5 h-5" /> },
|
||||||
|
background: { label: 'Фоны профиля', icon: <Image className="w-5 h-5" /> },
|
||||||
|
consumable: { label: 'Расходуемые предметы', icon: <Zap className="w-5 h-5" /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { id: ShopItemType | 'all'; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ id: 'all', label: 'Все', icon: <ShoppingBag className="w-4 h-4" /> },
|
||||||
|
{ id: 'frame', label: 'Рамки', icon: <Frame className="w-4 h-4" /> },
|
||||||
|
{ id: 'title', label: 'Титулы', icon: <Type className="w-4 h-4" /> },
|
||||||
|
{ id: 'name_color', label: 'Цвета', icon: <Palette className="w-4 h-4" /> },
|
||||||
|
{ id: 'background', label: 'Фоны', icon: <Image className="w-4 h-4" /> },
|
||||||
|
{ id: 'consumable', label: 'Расходники', icon: <Zap className="w-4 h-4" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isLoading && items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin text-neon-500" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
|
||||||
|
<ShoppingBag className="w-8 h-8 text-neon-500" />
|
||||||
|
Магазин
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Покупай косметику и расходуемые предметы
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Balance */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-dark-800/50 rounded-lg border border-yellow-500/30">
|
||||||
|
<Coins className="w-5 h-5 text-yellow-400" />
|
||||||
|
<span className="text-yellow-400 font-bold text-lg">{balance}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link to inventory */}
|
||||||
|
<Link to="/inventory">
|
||||||
|
<NeonButton variant="secondary">
|
||||||
|
<Package className="w-4 h-4 mr-2" />
|
||||||
|
Инвентарь
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-neon-500 text-dark-900'
|
||||||
|
: 'bg-dark-700 text-gray-300 hover:bg-dark-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items grid */}
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<GlassCard className="p-8 text-center">
|
||||||
|
<Package className="w-16 h-16 mx-auto text-gray-500 mb-4" />
|
||||||
|
<p className="text-gray-400">Нет доступных товаров в этой категории</p>
|
||||||
|
</GlassCard>
|
||||||
|
) : activeTab === 'all' ? (
|
||||||
|
// Grouped view for "All" tab
|
||||||
|
<div className="space-y-8">
|
||||||
|
{categoryOrder.map(category => {
|
||||||
|
const categoryItems = itemsByType[category]
|
||||||
|
if (categoryItems.length === 0) return null
|
||||||
|
|
||||||
|
const { label, icon } = categoryLabels[category]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category}>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center text-neon-400">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">{label}</h2>
|
||||||
|
<span className="text-sm text-gray-500">({categoryItems.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{categoryItems.map(item => (
|
||||||
|
<ShopItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onPurchase={handlePurchase}
|
||||||
|
isPurchasing={purchasingId === item.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Regular grid for specific category
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{filteredItems.map(item => (
|
||||||
|
<ShopItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onPurchase={handlePurchase}
|
||||||
|
isPurchasing={purchasingId === item.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info about coins */}
|
||||||
|
<GlassCard className="mt-8 p-4">
|
||||||
|
<h3 className="text-white font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<Coins className="w-5 h-5 text-yellow-400" />
|
||||||
|
Как заработать монеты?
|
||||||
|
</h3>
|
||||||
|
<ul className="text-gray-400 text-sm space-y-1">
|
||||||
|
<li>• Выполняй задания в <span className="text-neon-400">сертифицированных</span> марафонах</li>
|
||||||
|
<li>• Easy задание — 5 монет, Medium — 12 монет, Hard — 25 монет</li>
|
||||||
|
<li>• Playthrough — ~5% от заработанных очков</li>
|
||||||
|
<li>• Топ-3 места в марафоне: 1-е — 100, 2-е — 50, 3-е — 30 монет</li>
|
||||||
|
</ul>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,12 +2,200 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { usersApi } from '@/api'
|
import { usersApi } from '@/api'
|
||||||
import type { UserProfilePublic } from '@/types'
|
import type { UserProfilePublic, ShopItemPublic } from '@/types'
|
||||||
import { GlassCard, StatsCard } from '@/components/ui'
|
import { GlassCard, StatsCard } from '@/components/ui'
|
||||||
import {
|
import {
|
||||||
User, Trophy, Target, CheckCircle, Flame,
|
User, Trophy, Target, CheckCircle, Flame,
|
||||||
Loader2, ArrowLeft, Calendar, Zap
|
Loader2, ArrowLeft, Calendar, Shield
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
// ============ COSMETICS HELPERS ============
|
||||||
|
|
||||||
|
interface BackgroundResult {
|
||||||
|
styles: React.CSSProperties
|
||||||
|
className: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackgroundData(background: ShopItemPublic | null): BackgroundResult {
|
||||||
|
if (!background?.asset_data) {
|
||||||
|
return { styles: {}, className: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = background.asset_data as {
|
||||||
|
type?: string
|
||||||
|
gradient?: string[]
|
||||||
|
pattern?: string
|
||||||
|
color?: string
|
||||||
|
animation?: string
|
||||||
|
animated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: React.CSSProperties = {}
|
||||||
|
let className = ''
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'solid':
|
||||||
|
if (data.color) {
|
||||||
|
styles.backgroundColor = data.color
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'gradient':
|
||||||
|
if (data.gradient && data.gradient.length > 0) {
|
||||||
|
styles.background = `linear-gradient(135deg, ${data.gradient.join(', ')})`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'pattern':
|
||||||
|
if (data.pattern === 'stars') {
|
||||||
|
className = 'bg-stars-animated'
|
||||||
|
} else if (data.pattern === 'gaming-icons') {
|
||||||
|
styles.background = `
|
||||||
|
linear-gradient(45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
|
||||||
|
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
|
||||||
|
`
|
||||||
|
styles.backgroundSize = '40px 40px, 40px 40px, 40px 40px, 40px 40px, 100% 100%'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'animated':
|
||||||
|
if (data.animation === 'fire-particles') {
|
||||||
|
styles.background = `
|
||||||
|
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.4) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 30% 80%, rgba(255,50,0,0.3) 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 70% 90%, rgba(255,150,0,0.3) 0%, transparent 45%),
|
||||||
|
linear-gradient(to top, #1a0a00 0%, #0d0d0d 60%, #1a1a2e 100%)
|
||||||
|
`
|
||||||
|
className = 'animate-fire-pulse'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return { styles, className }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NameColorResult {
|
||||||
|
type: 'solid' | 'gradient' | 'animated'
|
||||||
|
color?: string
|
||||||
|
gradient?: string[]
|
||||||
|
animation?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameColorData(nameColor: ShopItemPublic | null): NameColorResult {
|
||||||
|
if (!nameColor?.asset_data) {
|
||||||
|
return { type: 'solid', color: '#ffffff' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = nameColor.asset_data as {
|
||||||
|
style?: string
|
||||||
|
color?: string
|
||||||
|
gradient?: string[]
|
||||||
|
animation?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.style === 'gradient' && data.gradient) {
|
||||||
|
return { type: 'gradient', gradient: data.gradient }
|
||||||
|
}
|
||||||
|
if (data.style === 'animated') {
|
||||||
|
return { type: 'animated', animation: data.animation }
|
||||||
|
}
|
||||||
|
return { type: 'solid', color: data.color || '#ffffff' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTitleData(title: ShopItemPublic | null): { text: string; color: string } | null {
|
||||||
|
if (!title?.asset_data) return null
|
||||||
|
const data = title.asset_data as { text?: string; color?: string }
|
||||||
|
if (!data.text) return null
|
||||||
|
return { text: data.text, color: data.color || '#ffffff' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFrameStyles(frame: ShopItemPublic | null): React.CSSProperties {
|
||||||
|
if (!frame?.asset_data) return {}
|
||||||
|
|
||||||
|
const data = frame.asset_data as {
|
||||||
|
border_color?: string
|
||||||
|
gradient?: string[]
|
||||||
|
glow_color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: React.CSSProperties = {}
|
||||||
|
|
||||||
|
if (data.gradient && data.gradient.length > 0) {
|
||||||
|
styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
|
||||||
|
styles.backgroundSize = '400% 400%'
|
||||||
|
} else if (data.border_color) {
|
||||||
|
styles.background = data.border_color
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.glow_color) {
|
||||||
|
styles.boxShadow = `0 0 20px ${data.glow_color}, 0 0 40px ${data.glow_color}40`
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFrameAnimation(frame: ShopItemPublic | null): string {
|
||||||
|
if (!frame?.asset_data) return ''
|
||||||
|
const data = frame.asset_data as { animation?: string }
|
||||||
|
if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
|
||||||
|
if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ HERO AVATAR COMPONENT ============
|
||||||
|
|
||||||
|
function HeroAvatar({
|
||||||
|
avatarUrl,
|
||||||
|
telegramAvatarUrl,
|
||||||
|
nickname,
|
||||||
|
frame,
|
||||||
|
}: {
|
||||||
|
avatarUrl: string | null
|
||||||
|
telegramAvatarUrl: string | null
|
||||||
|
nickname: string
|
||||||
|
frame: ShopItemPublic | null
|
||||||
|
}) {
|
||||||
|
const displayAvatar = avatarUrl || telegramAvatarUrl
|
||||||
|
|
||||||
|
const avatarContent = (
|
||||||
|
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl overflow-hidden bg-dark-700/80 backdrop-blur-sm">
|
||||||
|
{displayAvatar ? (
|
||||||
|
<img
|
||||||
|
src={displayAvatar}
|
||||||
|
alt={nickname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
|
||||||
|
<User className="w-14 h-14 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!frame) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border-2 border-neon-500/50 shadow-[0_0_30px_rgba(34,211,238,0.15)]">
|
||||||
|
{avatarContent}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'rounded-2xl p-1.5',
|
||||||
|
getFrameAnimation(frame)
|
||||||
|
)}
|
||||||
|
style={getFrameStyles(frame)}
|
||||||
|
>
|
||||||
|
{avatarContent}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ MAIN COMPONENT ============
|
||||||
|
|
||||||
export function UserProfilePage() {
|
export function UserProfilePage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -107,8 +295,46 @@ export function UserProfilePage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get cosmetics data
|
||||||
|
const backgroundData = getBackgroundData(profile.equipped_background)
|
||||||
|
const nameColorData = getNameColorData(profile.equipped_name_color)
|
||||||
|
const titleData = getTitleData(profile.equipped_title)
|
||||||
|
const displayAvatar = avatarBlobUrl || profile.telegram_avatar_url
|
||||||
|
|
||||||
|
// Get nickname styles based on color type
|
||||||
|
const getNicknameStyles = (): React.CSSProperties => {
|
||||||
|
if (nameColorData.type === 'solid') {
|
||||||
|
return { color: nameColorData.color }
|
||||||
|
}
|
||||||
|
if (nameColorData.type === 'gradient' && nameColorData.gradient) {
|
||||||
|
return {
|
||||||
|
background: `linear-gradient(90deg, ${nameColorData.gradient.join(', ')})`,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nameColorData.type === 'animated') {
|
||||||
|
return {
|
||||||
|
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3, #ff0000)',
|
||||||
|
backgroundSize: '400% 100%',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { color: '#ffffff' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNicknameAnimation = (): string => {
|
||||||
|
if (nameColorData.type === 'animated' && nameColorData.animation === 'rainbow-shift') {
|
||||||
|
return 'animate-rainbow-rotate'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
{/* Кнопка назад */}
|
{/* Кнопка назад */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
@@ -118,42 +344,107 @@ export function UserProfilePage() {
|
|||||||
Назад
|
Назад
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Профиль */}
|
{/* ============ HERO SECTION ============ */}
|
||||||
<GlassCard variant="neon">
|
<div
|
||||||
<div className="flex items-center gap-6">
|
className={clsx(
|
||||||
{/* Аватар */}
|
'relative rounded-3xl overflow-hidden',
|
||||||
<div className="relative">
|
backgroundData.className
|
||||||
<div className="w-24 h-24 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 shadow-[0_0_14px_rgba(34,211,238,0.15)]">
|
|
||||||
{avatarBlobUrl ? (
|
|
||||||
<img
|
|
||||||
src={avatarBlobUrl}
|
|
||||||
alt={profile.nickname}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-dark-700 to-dark-800">
|
|
||||||
<User className="w-12 h-12 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
style={backgroundData.styles}
|
||||||
{/* Online indicator effect */}
|
>
|
||||||
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-lg bg-neon-500/20 border border-neon-500/30 flex items-center justify-center">
|
{/* Default gradient background if no custom background */}
|
||||||
<Zap className="w-3 h-3 text-neon-400" />
|
{!profile.equipped_background && (
|
||||||
</div>
|
<div className="absolute inset-0 bg-gradient-to-br from-dark-800 via-dark-900 to-neon-900/20" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay for readability */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-dark-900/90 via-dark-900/40 to-transparent" />
|
||||||
|
|
||||||
|
{/* Scan lines effect */}
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(transparent_50%,rgba(0,0,0,0.03)_50%)] bg-[length:100%_4px] pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Glow effects */}
|
||||||
|
<div className="absolute top-0 left-1/4 w-96 h-96 bg-neon-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 right-1/4 w-64 h-64 bg-accent-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10 px-6 py-10 md:px-10 md:py-14">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-10">
|
||||||
|
{/* Avatar with Frame */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<HeroAvatar
|
||||||
|
avatarUrl={displayAvatar}
|
||||||
|
telegramAvatarUrl={profile.telegram_avatar_url}
|
||||||
|
nickname={profile.nickname}
|
||||||
|
frame={profile.equipped_frame}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Инфо */}
|
{/* User Info */}
|
||||||
<div>
|
<div className="flex-1 text-center md:text-left">
|
||||||
<h1 className="text-2xl font-bold text-white mb-2">
|
{/* Nickname with color + Title badge */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center md:justify-start gap-3 mb-3">
|
||||||
|
<h1
|
||||||
|
className={clsx(
|
||||||
|
'text-3xl md:text-4xl font-bold font-display tracking-wide drop-shadow-[0_0_10px_rgba(255,255,255,0.3)]',
|
||||||
|
getNicknameAnimation()
|
||||||
|
)}
|
||||||
|
style={getNicknameStyles()}
|
||||||
|
>
|
||||||
{profile.nickname}
|
{profile.nickname}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
|
||||||
|
{/* Title badge */}
|
||||||
|
{titleData && (
|
||||||
|
<span
|
||||||
|
className="px-3 py-1 rounded-full text-sm font-semibold border backdrop-blur-sm"
|
||||||
|
style={{
|
||||||
|
color: titleData.color,
|
||||||
|
borderColor: `${titleData.color}50`,
|
||||||
|
backgroundColor: `${titleData.color}15`,
|
||||||
|
boxShadow: `0 0 15px ${titleData.color}30`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{titleData.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin badge */}
|
||||||
|
{profile.role === 'admin' && (
|
||||||
|
<div className="flex justify-center md:justify-start mb-3">
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-500/20 border border-purple-500/30 text-purple-400 text-sm font-medium">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
Администратор
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Registration date */}
|
||||||
|
<div className="flex items-center justify-center md:justify-start gap-2 text-gray-400 text-sm mb-4">
|
||||||
<Calendar className="w-4 h-4 text-accent-400" />
|
<Calendar className="w-4 h-4 text-accent-400" />
|
||||||
<span>Зарегистрирован {formatDate(profile.created_at)}</span>
|
<span>Зарегистрирован {formatDate(profile.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Quick stats preview */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm text-gray-300">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Trophy className="w-4 h-4 text-yellow-500" />
|
||||||
|
<span>{profile.stats.wins_count} побед</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Target className="w-4 h-4 text-neon-400" />
|
||||||
|
<span>{profile.stats.marathons_count} марафонов</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Flame className="w-4 h-4 text-orange-400" />
|
||||||
|
<span>{profile.stats.total_points_earned} очков</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
|
||||||
|
|
||||||
{/* Статистика */}
|
{/* Статистика */}
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AdminMarathon } from '@/types'
|
|||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
import { NeonButton } from '@/components/ui'
|
import { NeonButton } from '@/components/ui'
|
||||||
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2 } from 'lucide-react'
|
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2, BadgeCheck, BadgeX } from 'lucide-react'
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
|
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
|
||||||
preparing: {
|
preparing: {
|
||||||
@@ -108,6 +108,47 @@ export function AdminMarathonsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCertify = async (marathon: AdminMarathon) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Верифицировать марафон',
|
||||||
|
message: `Верифицировать марафон "${marathon.title}"? Участники смогут зарабатывать монетки.`,
|
||||||
|
confirmText: 'Верифицировать',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.certifyMarathon(marathon.id)
|
||||||
|
setMarathons(marathons.map(m =>
|
||||||
|
m.id === marathon.id ? { ...m, is_certified: true, certification_status: 'certified' } : m
|
||||||
|
))
|
||||||
|
toast.success('Марафон верифицирован')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to certify marathon:', err)
|
||||||
|
toast.error('Ошибка верификации')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevokeCertification = async (marathon: AdminMarathon) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Отозвать верификацию',
|
||||||
|
message: `Отозвать верификацию марафона "${marathon.title}"? Участники больше не смогут зарабатывать монетки.`,
|
||||||
|
confirmText: 'Отозвать',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.revokeCertification(marathon.id)
|
||||||
|
setMarathons(marathons.map(m =>
|
||||||
|
m.id === marathon.id ? { ...m, is_certified: false, certification_status: 'none' } : m
|
||||||
|
))
|
||||||
|
toast.success('Верификация отозвана')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to revoke certification:', err)
|
||||||
|
toast.error('Ошибка отзыва верификации')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -145,6 +186,7 @@ export function AdminMarathonsPage() {
|
|||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Верификация</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th>
|
||||||
@@ -154,13 +196,13 @@ export function AdminMarathonsPage() {
|
|||||||
<tbody className="divide-y divide-dark-600">
|
<tbody className="divide-y divide-dark-600">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center">
|
<td colSpan={9} className="px-4 py-8 text-center">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : marathons.length === 0 ? (
|
) : marathons.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
<td colSpan={9} className="px-4 py-8 text-center text-gray-400">
|
||||||
Марафоны не найдены
|
Марафоны не найдены
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -179,6 +221,19 @@ export function AdminMarathonsPage() {
|
|||||||
{statusConfig.label}
|
{statusConfig.label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{marathon.is_certified ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
|
||||||
|
<BadgeCheck className="w-3 h-3" />
|
||||||
|
Верифицирован
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-dark-600/50 text-gray-400 border border-dark-500">
|
||||||
|
<BadgeX className="w-3 h-3" />
|
||||||
|
Нет
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td>
|
<td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td>
|
<td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-400">
|
<td className="px-4 py-3 text-sm text-gray-400">
|
||||||
@@ -188,6 +243,23 @@ export function AdminMarathonsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{marathon.is_certified ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevokeCertification(marathon)}
|
||||||
|
className="p-2 text-yellow-400 hover:bg-yellow-500/20 rounded-lg transition-colors"
|
||||||
|
title="Отозвать верификацию"
|
||||||
|
>
|
||||||
|
<BadgeX className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCertify(marathon)}
|
||||||
|
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
|
||||||
|
title="Верифицировать"
|
||||||
|
>
|
||||||
|
<BadgeCheck className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{marathon.status !== 'finished' && (
|
{marathon.status !== 'finished' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleForceFinish(marathon)}
|
onClick={() => handleForceFinish(marathon)}
|
||||||
|
|||||||
123
frontend/src/store/shop.ts
Normal file
123
frontend/src/store/shop.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { shopApi } from '@/api/shop'
|
||||||
|
import type { ShopItem, InventoryItem, ShopItemType } from '@/types'
|
||||||
|
import { useAuthStore } from './auth'
|
||||||
|
|
||||||
|
interface ShopState {
|
||||||
|
// State
|
||||||
|
balance: number
|
||||||
|
items: ShopItem[]
|
||||||
|
inventory: InventoryItem[]
|
||||||
|
isLoading: boolean
|
||||||
|
isBalanceLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadBalance: () => Promise<void>
|
||||||
|
loadItems: (itemType?: ShopItemType) => Promise<void>
|
||||||
|
loadInventory: (itemType?: ShopItemType) => Promise<void>
|
||||||
|
purchase: (itemId: number, quantity?: number) => Promise<boolean>
|
||||||
|
equip: (inventoryId: number) => Promise<boolean>
|
||||||
|
unequip: (itemType: ShopItemType) => Promise<boolean>
|
||||||
|
updateBalance: (newBalance: number) => void
|
||||||
|
clearError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useShopStore = create<ShopState>()((set, get) => ({
|
||||||
|
balance: 0,
|
||||||
|
items: [],
|
||||||
|
inventory: [],
|
||||||
|
isLoading: false,
|
||||||
|
isBalanceLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
loadBalance: async () => {
|
||||||
|
set({ isBalanceLoading: true })
|
||||||
|
try {
|
||||||
|
const data = await shopApi.getBalance()
|
||||||
|
set({ balance: data.balance, isBalanceLoading: false })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load balance:', err)
|
||||||
|
set({ isBalanceLoading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadItems: async (itemType?: ShopItemType) => {
|
||||||
|
set({ isLoading: true, error: null })
|
||||||
|
try {
|
||||||
|
const items = await shopApi.getItems(itemType)
|
||||||
|
set({ items, isLoading: false })
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
set({
|
||||||
|
error: error.response?.data?.detail || 'Не удалось загрузить товары',
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadInventory: async (itemType?: ShopItemType) => {
|
||||||
|
set({ isLoading: true, error: null })
|
||||||
|
try {
|
||||||
|
const inventory = await shopApi.getInventory(itemType)
|
||||||
|
set({ inventory, isLoading: false })
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
set({
|
||||||
|
error: error.response?.data?.detail || 'Не удалось загрузить инвентарь',
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
purchase: async (itemId: number, quantity: number = 1) => {
|
||||||
|
try {
|
||||||
|
const result = await shopApi.purchase(itemId, quantity)
|
||||||
|
set({ balance: result.new_balance })
|
||||||
|
// Reload items and inventory to update ownership status
|
||||||
|
await Promise.all([get().loadItems(), get().loadInventory()])
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
set({ error: error.response?.data?.detail || 'Не удалось совершить покупку' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
equip: async (inventoryId: number) => {
|
||||||
|
try {
|
||||||
|
await shopApi.equip(inventoryId)
|
||||||
|
await get().loadInventory()
|
||||||
|
// Sync user data to update equipped cosmetics in UI
|
||||||
|
await useAuthStore.getState().syncUser()
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
set({ error: error.response?.data?.detail || 'Не удалось экипировать предмет' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
unequip: async (itemType: ShopItemType) => {
|
||||||
|
try {
|
||||||
|
await shopApi.unequip(itemType)
|
||||||
|
await get().loadInventory()
|
||||||
|
// Sync user data to update equipped cosmetics in UI
|
||||||
|
await useAuthStore.getState().syncUser()
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
set({ error: error.response?.data?.detail || 'Не удалось снять предмет' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBalance: (newBalance: number) => {
|
||||||
|
set({ balance: newBalance })
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Convenience hook for just getting the balance
|
||||||
|
export const useCoinsBalance = () => useShopStore((state) => state.balance)
|
||||||
@@ -9,6 +9,11 @@ export interface UserPublic {
|
|||||||
role: UserRole
|
role: UserRole
|
||||||
telegram_avatar_url: string | null
|
telegram_avatar_url: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
// Equipped cosmetics
|
||||||
|
equipped_frame: ShopItemPublic | null
|
||||||
|
equipped_title: ShopItemPublic | null
|
||||||
|
equipped_name_color: ShopItemPublic | null
|
||||||
|
equipped_background: ShopItemPublic | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full user info (only for own profile from /auth/me)
|
// Full user info (only for own profile from /auth/me)
|
||||||
@@ -80,6 +85,7 @@ export interface Marathon {
|
|||||||
is_public: boolean
|
is_public: boolean
|
||||||
game_proposal_mode: GameProposalMode
|
game_proposal_mode: GameProposalMode
|
||||||
auto_events_enabled: boolean
|
auto_events_enabled: boolean
|
||||||
|
allow_consumables: boolean
|
||||||
cover_url: string | null
|
cover_url: string | null
|
||||||
start_date: string | null
|
start_date: string | null
|
||||||
end_date: string | null
|
end_date: string | null
|
||||||
@@ -507,6 +513,8 @@ export interface AdminMarathon {
|
|||||||
start_date: string | null
|
start_date: string | null
|
||||||
end_date: string | null
|
end_date: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
certification_status: string
|
||||||
|
is_certified: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformStats {
|
export interface PlatformStats {
|
||||||
@@ -690,11 +698,162 @@ export interface UserProfilePublic {
|
|||||||
id: number
|
id: number
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
|
telegram_avatar_url: string | null
|
||||||
|
role: UserRole
|
||||||
created_at: string
|
created_at: string
|
||||||
stats: UserStats
|
stats: UserStats
|
||||||
|
// Equipped cosmetics
|
||||||
|
equipped_frame: ShopItemPublic | null
|
||||||
|
equipped_title: ShopItemPublic | null
|
||||||
|
equipped_name_color: ShopItemPublic | null
|
||||||
|
equipped_background: ShopItemPublic | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordChangeData {
|
export interface PasswordChangeData {
|
||||||
current_password: string
|
current_password: string
|
||||||
new_password: string
|
new_password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Shop types ===
|
||||||
|
|
||||||
|
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
|
||||||
|
export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||||
|
export type ConsumableType = 'skip' | 'shield' | 'boost' | 'reroll'
|
||||||
|
|
||||||
|
export interface ShopItemPublic {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
item_type: ShopItemType
|
||||||
|
rarity: ItemRarity
|
||||||
|
asset_data: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShopItem {
|
||||||
|
id: number
|
||||||
|
item_type: ShopItemType
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
price: number
|
||||||
|
rarity: ItemRarity
|
||||||
|
asset_data: Record<string, unknown> | null
|
||||||
|
is_active: boolean
|
||||||
|
available_from: string | null
|
||||||
|
available_until: string | null
|
||||||
|
stock_limit: number | null
|
||||||
|
stock_remaining: number | null
|
||||||
|
created_at: string
|
||||||
|
is_available: boolean
|
||||||
|
is_owned: boolean
|
||||||
|
is_equipped: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: number
|
||||||
|
item: ShopItem
|
||||||
|
quantity: number
|
||||||
|
equipped: boolean
|
||||||
|
purchased_at: string
|
||||||
|
expires_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseRequest {
|
||||||
|
item_id: number
|
||||||
|
quantity?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseResponse {
|
||||||
|
success: boolean
|
||||||
|
item: ShopItem
|
||||||
|
quantity: number
|
||||||
|
total_cost: number
|
||||||
|
new_balance: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseConsumableRequest {
|
||||||
|
item_code: ConsumableType
|
||||||
|
marathon_id: number
|
||||||
|
assignment_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseConsumableResponse {
|
||||||
|
success: boolean
|
||||||
|
item_code: string
|
||||||
|
remaining_quantity: number
|
||||||
|
effect_description: string
|
||||||
|
effect_data: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoinTransaction {
|
||||||
|
id: number
|
||||||
|
amount: number
|
||||||
|
transaction_type: string
|
||||||
|
description: string | null
|
||||||
|
reference_type: string | null
|
||||||
|
reference_id: number | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoinsBalance {
|
||||||
|
balance: number
|
||||||
|
recent_transactions: CoinTransaction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsumablesStatus {
|
||||||
|
skips_available: number
|
||||||
|
skips_used: number
|
||||||
|
skips_remaining: number | null
|
||||||
|
shields_available: number
|
||||||
|
has_shield: boolean
|
||||||
|
boosts_available: number
|
||||||
|
has_active_boost: boolean
|
||||||
|
boost_multiplier: number | null
|
||||||
|
rerolls_available: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCosmetics {
|
||||||
|
frame: ShopItem | null
|
||||||
|
title: ShopItem | null
|
||||||
|
name_color: ShopItem | null
|
||||||
|
background: ShopItem | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certification types
|
||||||
|
export type CertificationStatus = 'none' | 'pending' | 'certified' | 'rejected'
|
||||||
|
|
||||||
|
export interface CertificationStatusResponse {
|
||||||
|
marathon_id: number
|
||||||
|
certification_status: CertificationStatus
|
||||||
|
is_certified: boolean
|
||||||
|
certification_requested_at: string | null
|
||||||
|
certified_at: string | null
|
||||||
|
certified_by_nickname: string | null
|
||||||
|
rejection_reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rarity colors for UI
|
||||||
|
export const RARITY_COLORS: Record<ItemRarity, { bg: string; border: string; text: string }> = {
|
||||||
|
common: { bg: 'bg-gray-500/20', border: 'border-gray-500', text: 'text-gray-400' },
|
||||||
|
uncommon: { bg: 'bg-green-500/20', border: 'border-green-500', text: 'text-green-400' },
|
||||||
|
rare: { bg: 'bg-blue-500/20', border: 'border-blue-500', text: 'text-blue-400' },
|
||||||
|
epic: { bg: 'bg-purple-500/20', border: 'border-purple-500', text: 'text-purple-400' },
|
||||||
|
legendary: { bg: 'bg-yellow-500/20', border: 'border-yellow-500', text: 'text-yellow-400' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RARITY_NAMES: Record<ItemRarity, string> = {
|
||||||
|
common: 'Обычный',
|
||||||
|
uncommon: 'Необычный',
|
||||||
|
rare: 'Редкий',
|
||||||
|
epic: 'Эпический',
|
||||||
|
legendary: 'Легендарный',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ITEM_TYPE_NAMES: Record<ShopItemType, string> = {
|
||||||
|
frame: 'Рамка',
|
||||||
|
title: 'Титул',
|
||||||
|
name_color: 'Цвет ника',
|
||||||
|
background: 'Фон профиля',
|
||||||
|
consumable: 'Расходуемое',
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user