Add shop
This commit is contained in:
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')")
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime
|
||||
from fastapi import Depends, HTTPException, status, Header
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -35,7 +36,16 @@ async def get_current_user(
|
||||
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()
|
||||
|
||||
if user is None:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -16,3 +16,4 @@ router.include_router(events.router)
|
||||
router.include_router(assignments.router)
|
||||
router.include_router(telegram.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.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
|
||||
)
|
||||
from app.schemas import (
|
||||
@@ -37,6 +37,8 @@ class AdminMarathonResponse(BaseModel):
|
||||
start_date: str | None
|
||||
end_date: str | None
|
||||
created_at: str
|
||||
certification_status: str = "none"
|
||||
is_certified: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -219,7 +221,12 @@ async def list_marathons(
|
||||
|
||||
query = (
|
||||
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())
|
||||
)
|
||||
|
||||
@@ -248,6 +255,8 @@ async def list_marathons(
|
||||
start_date=marathon.start_date.isoformat() if marathon.start_date else None,
|
||||
end_date=marathon.end_date.isoformat() if marathon.end_date else None,
|
||||
created_at=marathon.created_at.isoformat(),
|
||||
certification_status=marathon.certification_status,
|
||||
is_certified=marathon.is_certified,
|
||||
))
|
||||
|
||||
return response
|
||||
@@ -1102,3 +1111,75 @@ async def resolve_dispute(
|
||||
return MessageResponse(
|
||||
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 sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
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")
|
||||
async def login(request: Request, data: UserLogin, db: DbSession):
|
||||
# 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()
|
||||
|
||||
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()
|
||||
|
||||
# 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()
|
||||
|
||||
if not user:
|
||||
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
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.schemas import FeedResponse, ActivityResponse, UserPublic
|
||||
|
||||
@@ -37,7 +37,12 @@ async def get_feed(
|
||||
# Get activities
|
||||
result = await db.execute(
|
||||
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)
|
||||
.order_by(Activity.created_at.desc())
|
||||
.limit(limit)
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.api.deps import (
|
||||
from app.core.config import settings
|
||||
from app.models import (
|
||||
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.assignment import AvailableGamesCount
|
||||
@@ -23,8 +23,14 @@ async def get_game_or_404(db, game_id: int) -> Game:
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(
|
||||
selectinload(Game.proposed_by),
|
||||
selectinload(Game.approved_by),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||
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)
|
||||
)
|
||||
@@ -73,8 +79,14 @@ async def list_games(
|
||||
select(Game, func.count(Challenge.id).label("challenges_count"))
|
||||
.outerjoin(Challenge)
|
||||
.options(
|
||||
selectinload(Game.proposed_by),
|
||||
selectinload(Game.approved_by),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||
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)
|
||||
.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"))
|
||||
.outerjoin(Challenge)
|
||||
.options(
|
||||
selectinload(Game.proposed_by),
|
||||
selectinload(Game.approved_by),
|
||||
selectinload(Game.proposed_by).selectinload(User.equipped_frame),
|
||||
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,
|
||||
|
||||
@@ -20,7 +20,7 @@ optional_auth = HTTPBearer(auto_error=False)
|
||||
from app.models import (
|
||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus,
|
||||
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
|
||||
)
|
||||
from app.schemas import (
|
||||
MarathonCreate,
|
||||
@@ -80,7 +80,12 @@ def generate_invite_code() -> str:
|
||||
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
||||
result = await db.execute(
|
||||
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)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
@@ -465,7 +470,12 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
|
||||
|
||||
result = await db.execute(
|
||||
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)
|
||||
.order_by(Participant.joined_at)
|
||||
)
|
||||
@@ -504,7 +514,12 @@ async def set_participant_role(
|
||||
# Get participant
|
||||
result = await db.execute(
|
||||
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,
|
||||
Participant.user_id == user_id,
|
||||
@@ -569,7 +584,12 @@ async def get_leaderboard(
|
||||
|
||||
result = await db.execute(
|
||||
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)
|
||||
.order_by(Participant.total_points.desc())
|
||||
)
|
||||
|
||||
631
backend/app/api/v1/shop.py
Normal file
631
backend/app/api/v1/shop.py
Normal file
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
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 until {effect['expires_at']}"
|
||||
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
|
||||
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
|
||||
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,
|
||||
has_shield=participant.has_shield,
|
||||
has_active_boost=participant.has_active_boost,
|
||||
boost_multiplier=participant.active_boost_multiplier if participant.has_active_boost else None,
|
||||
boost_expires_at=participant.active_boost_expires_at if participant.has_active_boost else None,
|
||||
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 sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.core.config import settings
|
||||
@@ -20,7 +21,16 @@ router = APIRouter(prefix="/users", tags=["users"])
|
||||
@router.get("/{user_id}", response_model=UserPublic)
|
||||
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||
"""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()
|
||||
|
||||
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)
|
||||
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||
"""Получить публичный профиль пользователя со статистикой. 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()
|
||||
|
||||
if not user:
|
||||
@@ -254,8 +273,14 @@ async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUse
|
||||
id=user.id,
|
||||
nickname=user.nickname,
|
||||
avatar_url=user.avatar_url,
|
||||
telegram_avatar_url=user.telegram_avatar_url,
|
||||
role=user.role,
|
||||
created_at=user.created_at,
|
||||
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.events import event_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
|
||||
|
||||
router = APIRouter(tags=["wheel"])
|
||||
@@ -584,6 +586,11 @@ async def complete_assignment(
|
||||
)
|
||||
total_points += bonus_points
|
||||
|
||||
# Apply boost multiplier from consumable
|
||||
boost_multiplier = consumables_service.get_active_boost_multiplier(participant)
|
||||
if boost_multiplier > 1.0:
|
||||
total_points = int(total_points * boost_multiplier)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.COMPLETED.value
|
||||
assignment.points_earned = total_points
|
||||
@@ -595,6 +602,15 @@ async def complete_assignment(
|
||||
participant.current_streak += 1
|
||||
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
|
||||
is_redo = (
|
||||
assignment.dispute is not None and
|
||||
@@ -613,6 +629,10 @@ async def complete_assignment(
|
||||
}
|
||||
if is_redo:
|
||||
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
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
@@ -635,6 +655,7 @@ async def complete_assignment(
|
||||
streak_bonus=streak_bonus,
|
||||
total_points=participant.total_points,
|
||||
new_streak=participant.current_streak,
|
||||
coins_earned=coins_earned,
|
||||
)
|
||||
|
||||
# Regular challenge completion
|
||||
@@ -669,6 +690,11 @@ async def complete_assignment(
|
||||
total_points += common_enemy_bonus
|
||||
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
|
||||
|
||||
# Apply boost multiplier from consumable
|
||||
boost_multiplier = consumables_service.get_active_boost_multiplier(participant)
|
||||
if boost_multiplier > 1.0:
|
||||
total_points = int(total_points * boost_multiplier)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.COMPLETED.value
|
||||
assignment.points_earned = total_points
|
||||
@@ -680,6 +706,15 @@ async def complete_assignment(
|
||||
participant.current_streak += 1
|
||||
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
|
||||
is_redo = (
|
||||
assignment.dispute is not None and
|
||||
@@ -697,6 +732,10 @@ async def complete_assignment(
|
||||
}
|
||||
if is_redo:
|
||||
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:
|
||||
activity_data["event_type"] = assignment.event_type
|
||||
activity_data["event_bonus"] = event_bonus
|
||||
@@ -761,6 +800,7 @@ async def complete_assignment(
|
||||
streak_bonus=streak_bonus,
|
||||
total_points=participant.total_points,
|
||||
new_streak=participant.current_streak,
|
||||
coins_earned=coins_earned,
|
||||
)
|
||||
|
||||
|
||||
@@ -801,6 +841,12 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
participant.drop_count, game.playthrough_points, None
|
||||
)
|
||||
|
||||
# Check for shield - if active, no penalty
|
||||
shield_used = False
|
||||
if consumables_service.consume_shield(participant):
|
||||
penalty = 0
|
||||
shield_used = True
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
@@ -823,16 +869,20 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
participant.drop_count += 1
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"game": game.title,
|
||||
"is_playthrough": True,
|
||||
"penalty": penalty,
|
||||
"lost_bonuses": completed_bonuses_count,
|
||||
}
|
||||
if shield_used:
|
||||
activity_data["shield_used"] = True
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.DROP.value,
|
||||
data={
|
||||
"game": game.title,
|
||||
"is_playthrough": True,
|
||||
"penalty": penalty,
|
||||
"lost_bonuses": completed_bonuses_count,
|
||||
},
|
||||
data=activity_data,
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
@@ -842,6 +892,7 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
penalty=penalty,
|
||||
total_points=participant.total_points,
|
||||
new_drop_count=participant.drop_count,
|
||||
shield_used=shield_used,
|
||||
)
|
||||
|
||||
# Regular challenge drop
|
||||
@@ -853,6 +904,12 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
# Calculate penalty (0 if double_risk event is active)
|
||||
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
|
||||
|
||||
# Check for shield - if active, no penalty
|
||||
shield_used = False
|
||||
if consumables_service.consume_shield(participant):
|
||||
penalty = 0
|
||||
shield_used = True
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
@@ -869,6 +926,8 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
"difficulty": assignment.challenge.difficulty,
|
||||
"penalty": penalty,
|
||||
}
|
||||
if shield_used:
|
||||
activity_data["shield_used"] = True
|
||||
if active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
if active_event.type == EventType.DOUBLE_RISK.value:
|
||||
@@ -888,6 +947,7 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
penalty=penalty,
|
||||
total_points=participant.total_points,
|
||||
new_drop_count=participant.drop_count,
|
||||
shield_used=shield_used,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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.game import Game, GameStatus, GameType
|
||||
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_2fa import Admin2FASession
|
||||
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__ = [
|
||||
"User",
|
||||
@@ -20,6 +24,7 @@ __all__ = [
|
||||
"Marathon",
|
||||
"MarathonStatus",
|
||||
"GameProposalMode",
|
||||
"CertificationStatus",
|
||||
"Participant",
|
||||
"ParticipantRole",
|
||||
"Game",
|
||||
@@ -49,4 +54,12 @@ __all__ = [
|
||||
"AdminActionType",
|
||||
"Admin2FASession",
|
||||
"StaticContent",
|
||||
"ShopItem",
|
||||
"ShopItemType",
|
||||
"ItemRarity",
|
||||
"ConsumableType",
|
||||
"UserInventory",
|
||||
"CoinTransaction",
|
||||
"CoinTransactionType",
|
||||
"ConsumableUsage",
|
||||
]
|
||||
|
||||
@@ -17,6 +17,8 @@ class AdminActionType(str, Enum):
|
||||
# Marathon actions
|
||||
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
||||
MARATHON_DELETE = "marathon_delete"
|
||||
MARATHON_CERTIFY = "marathon_certify"
|
||||
MARATHON_REVOKE_CERTIFICATION = "marathon_revoke_certification"
|
||||
|
||||
# Content actions
|
||||
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"
|
||||
|
||||
|
||||
class CertificationStatus(str, Enum):
|
||||
NONE = "none"
|
||||
PENDING = "pending"
|
||||
CERTIFIED = "certified"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class Marathon(Base):
|
||||
__tablename__ = "marathons"
|
||||
|
||||
@@ -35,12 +42,28 @@ class Marathon(Base):
|
||||
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
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
|
||||
creator: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="created_marathons",
|
||||
foreign_keys=[creator_id]
|
||||
)
|
||||
certified_by: Mapped["User | None"] = relationship(
|
||||
"User",
|
||||
foreign_keys=[certified_by_id]
|
||||
)
|
||||
participants: Mapped[list["Participant"]] = relationship(
|
||||
"Participant",
|
||||
back_populates="marathon",
|
||||
@@ -61,3 +84,7 @@ class Marathon(Base):
|
||||
back_populates="marathon",
|
||||
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 enum import Enum
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean, Float
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -26,6 +26,15 @@ class Participant(Base):
|
||||
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
|
||||
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)
|
||||
active_boost_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
active_boost_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", back_populates="participations")
|
||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")
|
||||
@@ -38,3 +47,16 @@ class Participant(Base):
|
||||
@property
|
||||
def is_organizer(self) -> bool:
|
||||
return self.role == ParticipantRole.ORGANIZER.value
|
||||
|
||||
@property
|
||||
def has_active_boost(self) -> bool:
|
||||
"""Check if participant has an active boost"""
|
||||
if self.active_boost_multiplier is None or self.active_boost_expires_at is None:
|
||||
return False
|
||||
return datetime.utcnow() < self.active_boost_expires_at
|
||||
|
||||
def get_boost_multiplier(self) -> float:
|
||||
"""Get current boost multiplier (1.0 if no active boost)"""
|
||||
if self.has_active_boost:
|
||||
return self.active_boost_multiplier or 1.0
|
||||
return 1.0
|
||||
|
||||
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 sqlalchemy import String, BigInteger, DateTime, Boolean, 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.shop import ShopItem
|
||||
from app.models.inventory import UserInventory
|
||||
from app.models.coin_transaction import CoinTransaction
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
USER = "user"
|
||||
@@ -39,6 +45,15 @@ class User(Base):
|
||||
notify_disputes: 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
|
||||
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||
"Marathon",
|
||||
@@ -65,6 +80,32 @@ class User(Base):
|
||||
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
|
||||
def is_admin(self) -> bool:
|
||||
return self.role == UserRole.ADMIN.value
|
||||
|
||||
@@ -104,6 +104,27 @@ from app.schemas.admin import (
|
||||
LoginResponse,
|
||||
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__ = [
|
||||
# User
|
||||
@@ -202,4 +223,24 @@ __all__ = [
|
||||
"TwoFactorVerifyRequest",
|
||||
"LoginResponse",
|
||||
"DashboardStats",
|
||||
# Shop
|
||||
"ShopItemCreate",
|
||||
"ShopItemUpdate",
|
||||
"ShopItemResponse",
|
||||
"ShopItemPublic",
|
||||
"InventoryItemResponse",
|
||||
"PurchaseRequest",
|
||||
"PurchaseResponse",
|
||||
"UseConsumableRequest",
|
||||
"UseConsumableResponse",
|
||||
"EquipItemRequest",
|
||||
"EquipItemResponse",
|
||||
"CoinTransactionResponse",
|
||||
"CoinsBalanceResponse",
|
||||
"AdminCoinsRequest",
|
||||
"UserCosmeticsResponse",
|
||||
"CertificationRequestSchema",
|
||||
"CertificationReviewRequest",
|
||||
"CertificationStatusResponse",
|
||||
"ConsumablesStatusResponse",
|
||||
]
|
||||
|
||||
@@ -77,12 +77,14 @@ class CompleteResult(BaseModel):
|
||||
streak_bonus: int
|
||||
total_points: int
|
||||
new_streak: int
|
||||
coins_earned: int = 0 # Coins earned (only in certified marathons)
|
||||
|
||||
|
||||
class DropResult(BaseModel):
|
||||
penalty: int
|
||||
total_points: int
|
||||
new_drop_count: int
|
||||
shield_used: bool = False # Whether shield consumable was used to prevent penalty
|
||||
|
||||
|
||||
class EventAssignmentResponse(BaseModel):
|
||||
|
||||
@@ -14,6 +14,10 @@ class MarathonCreate(MarathonBase):
|
||||
duration_days: int = Field(default=30, ge=1, le=365)
|
||||
is_public: bool = False
|
||||
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):
|
||||
@@ -23,6 +27,10 @@ class MarathonUpdate(BaseModel):
|
||||
is_public: bool | None = None
|
||||
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
|
||||
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):
|
||||
@@ -32,6 +40,13 @@ class ParticipantInfo(BaseModel):
|
||||
current_streak: int
|
||||
drop_count: int
|
||||
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:
|
||||
from_attributes = True
|
||||
@@ -56,6 +71,13 @@ class MarathonResponse(MarathonBase):
|
||||
games_count: int
|
||||
created_at: datetime
|
||||
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:
|
||||
from_attributes = True
|
||||
@@ -74,6 +96,8 @@ class MarathonListItem(BaseModel):
|
||||
participants_count: int
|
||||
start_date: datetime | None
|
||||
end_date: datetime | None
|
||||
# Certification badge
|
||||
is_certified: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
199
backend/app/schemas/shop.py
Normal file
199
backend/app/schemas/shop.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
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
|
||||
has_shield: bool
|
||||
has_active_boost: bool
|
||||
boost_multiplier: float | None
|
||||
boost_expires_at: datetime | None
|
||||
rerolls_available: int # From inventory
|
||||
@@ -28,6 +28,19 @@ class UserUpdate(BaseModel):
|
||||
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):
|
||||
"""Public user info visible to other users - minimal data"""
|
||||
id: int
|
||||
@@ -35,6 +48,11 @@ class UserPublic(UserBase):
|
||||
role: str = "user"
|
||||
telegram_avatar_url: str | None = None # Only TG avatar is public
|
||||
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:
|
||||
from_attributes = True
|
||||
@@ -51,6 +69,8 @@ class UserPrivate(UserPublic):
|
||||
notify_events: bool = True
|
||||
notify_disputes: bool = True
|
||||
notify_moderation: bool = True
|
||||
# Shop: coins balance (only visible to self)
|
||||
coins_balance: int = 0
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
@@ -82,8 +102,15 @@ class UserProfilePublic(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
avatar_url: str | None = None
|
||||
telegram_avatar_url: str | None = None
|
||||
role: str = "user"
|
||||
created_at: datetime
|
||||
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:
|
||||
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()
|
||||
323
backend/app/services/consumables.py
Normal file
323
backend/app/services/consumables.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
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_DURATION_HOURS = 2
|
||||
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 2 hours.
|
||||
|
||||
- Points for completed challenges are multiplied by BOOST_MULTIPLIER
|
||||
- Duration: BOOST_DURATION_HOURS
|
||||
|
||||
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=f"Boost already active until {participant.active_boost_expires_at}"
|
||||
)
|
||||
|
||||
# Consume boost from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.BOOST.value)
|
||||
|
||||
# Activate boost
|
||||
participant.active_boost_multiplier = self.BOOST_MULTIPLIER
|
||||
participant.active_boost_expires_at = datetime.utcnow() + timedelta(hours=self.BOOST_DURATION_HOURS)
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "boost",
|
||||
"multiplier": self.BOOST_MULTIPLIER,
|
||||
"duration_hours": self.BOOST_DURATION_HOURS,
|
||||
"expires_at": participant.active_boost_expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"boost_activated": True,
|
||||
"multiplier": self.BOOST_MULTIPLIER,
|
||||
"expires_at": participant.active_boost_expires_at,
|
||||
}
|
||||
|
||||
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_on_drop(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 get_active_boost_multiplier(self, participant: Participant) -> float:
|
||||
"""
|
||||
Get current boost multiplier for participant.
|
||||
|
||||
Returns: Multiplier value (1.0 if no active boost)
|
||||
"""
|
||||
return participant.get_boost_multiplier()
|
||||
|
||||
|
||||
# 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 { TeapotPage } from '@/pages/TeapotPage'
|
||||
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
||||
import { ShopPage } from '@/pages/ShopPage'
|
||||
import { InventoryPage } from '@/pages/InventoryPage'
|
||||
|
||||
// Admin Pages
|
||||
import {
|
||||
@@ -187,6 +189,25 @@ function App() {
|
||||
|
||||
<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 */}
|
||||
<Route path="418" element={<TeapotPage />} />
|
||||
<Route path="teapot" element={<TeapotPage />} />
|
||||
|
||||
@@ -9,3 +9,4 @@ export { challengesApi } from './challenges'
|
||||
export { assignmentsApi } from './assignments'
|
||||
export { usersApi } from './users'
|
||||
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 { useNavigate, Link } from 'react-router-dom'
|
||||
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 { UserAvatar } from '@/components/ui'
|
||||
import {
|
||||
@@ -12,6 +12,77 @@ import {
|
||||
formatActivityMessage,
|
||||
} 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 {
|
||||
marathonId: number
|
||||
className?: string
|
||||
@@ -273,6 +344,8 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
||||
hasAvatar={!!activity.user.avatar_url}
|
||||
nickname={activity.user.nickname}
|
||||
size="sm"
|
||||
frame={activity.user.equipped_frame}
|
||||
telegramAvatarUrl={activity.user.telegram_avatar_url}
|
||||
/>
|
||||
{/* Activity type badge */}
|
||||
<div className={`
|
||||
@@ -292,10 +365,10 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
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()}
|
||||
>
|
||||
{activity.user.nickname}
|
||||
<StyledNickname user={activity.user} />
|
||||
</Link>
|
||||
<span className="text-xs text-gray-600">
|
||||
{formatRelativeTime(activity.created_at)}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield } from 'lucide-react'
|
||||
import { TelegramLink } from '@/components/TelegramLink'
|
||||
import { useShopStore } from '@/store/shop'
|
||||
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield, ShoppingBag, Coins, Backpack } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
export function Layout() {
|
||||
const { user, isAuthenticated, logout } = useAuthStore()
|
||||
const { balance, loadBalance } = useShopStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
@@ -20,6 +21,13 @@ export function Layout() {
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
// Load balance when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadBalance()
|
||||
}
|
||||
}, [isAuthenticated, loadBalance])
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false)
|
||||
@@ -74,6 +82,19 @@ export function Layout() {
|
||||
<span>Марафоны</span>
|
||||
</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' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
@@ -89,7 +110,7 @@ export function Layout() {
|
||||
</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
|
||||
to="/profile"
|
||||
className={clsx(
|
||||
@@ -101,9 +122,24 @@ export function Layout() {
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
<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>
|
||||
|
||||
<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
|
||||
onClick={handleLogout}
|
||||
@@ -159,6 +195,18 @@ export function Layout() {
|
||||
<Trophy className="w-5 h-5" />
|
||||
<span>Марафоны</span>
|
||||
</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' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
@@ -184,6 +232,22 @@ export function Layout() {
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
<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>
|
||||
<div className="pt-2 border-t border-dark-600">
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { usersApi } from '@/api'
|
||||
import { User } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import type { ShopItemPublic } from '@/types'
|
||||
|
||||
// Глобальный кэш для blob URL аватарок
|
||||
const avatarCache = new Map<number, string>()
|
||||
@@ -10,18 +13,77 @@ interface UserAvatarProps {
|
||||
userId: number
|
||||
hasAvatar: boolean // Есть ли у пользователя avatar_url
|
||||
nickname: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
version?: number // Для принудительного обновления при смене аватара
|
||||
frame?: ShopItemPublic | null // Equipped frame cosmetic
|
||||
telegramAvatarUrl?: string | null // Fallback to telegram avatar
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6 text-[8px]',
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
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 [failed, setFailed] = useState(false)
|
||||
|
||||
@@ -74,25 +136,54 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
|
||||
}, [userId, hasAvatar, version])
|
||||
|
||||
const sizeClass = sizeClasses[size]
|
||||
const displayUrl = (blobUrl && !failed) ? blobUrl : telegramAvatarUrl
|
||||
|
||||
if (blobUrl && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={nickname}
|
||||
className={`rounded-full object-cover ${sizeClass} ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback - первая буква никнейма
|
||||
return (
|
||||
<div className={`rounded-full bg-gray-700 flex items-center justify-center ${sizeClass} ${className}`}>
|
||||
// Avatar content
|
||||
const avatarContent = displayUrl ? (
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt={nickname}
|
||||
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">
|
||||
<span className="text-gray-400 font-medium">
|
||||
{nickname.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</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-кэш браузера
|
||||
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 { ToastContainer } from './Toast'
|
||||
export { ConfirmModal } from './ConfirmModal'
|
||||
export { UserAvatar, clearAvatarCache } from './UserAvatar'
|
||||
export { UserAvatar, clearAvatarCache, FramePreview } from './UserAvatar'
|
||||
|
||||
// New design system components
|
||||
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;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
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 */
|
||||
@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 { useParams, Link } from 'react-router-dom'
|
||||
import { marathonsApi } from '@/api'
|
||||
import type { LeaderboardEntry } from '@/types'
|
||||
import { GlassCard } from '@/components/ui'
|
||||
import type { LeaderboardEntry, ShopItemPublic, User } from '@/types'
|
||||
import { GlassCard, UserAvatar } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
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() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
@@ -117,48 +188,66 @@ export function LeaderboardPage() {
|
||||
<div className="flex items-end justify-center gap-4 mb-4">
|
||||
{/* 2nd place */}
|
||||
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '100ms' }}>
|
||||
<div className={`
|
||||
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
|
||||
bg-gray-400/10 border border-gray-400/30
|
||||
shadow-[0_0_20px_rgba(156,163,175,0.2)]
|
||||
`}>
|
||||
<span className="text-3xl font-bold text-gray-300">2</span>
|
||||
<div className="mb-3">
|
||||
<UserAvatar
|
||||
userId={topThree[1].user.id}
|
||||
hasAvatar={!!topThree[1].user.avatar_url}
|
||||
nickname={topThree[1].user.nickname}
|
||||
size="lg"
|
||||
frame={topThree[1].user.equipped_frame}
|
||||
telegramAvatarUrl={topThree[1].user.telegram_avatar_url}
|
||||
/>
|
||||
</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" />
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 1st place */}
|
||||
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '0ms' }}>
|
||||
<div className={`
|
||||
w-24 h-24 rounded-2xl mb-3 flex items-center justify-center
|
||||
bg-yellow-500/20 border border-yellow-500/30
|
||||
shadow-[0_0_30px_rgba(234,179,8,0.4)]
|
||||
`}>
|
||||
<Crown className="w-10 h-10 text-yellow-400" />
|
||||
<div className="mb-3 relative">
|
||||
<UserAvatar
|
||||
userId={topThree[0].user.id}
|
||||
hasAvatar={!!topThree[0].user.avatar_url}
|
||||
nickname={topThree[0].user.nickname}
|
||||
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">
|
||||
<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" />
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 3rd place */}
|
||||
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '200ms' }}>
|
||||
<div className={`
|
||||
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
|
||||
bg-amber-600/10 border border-amber-600/30
|
||||
shadow-[0_0_20px_rgba(217,119,6,0.2)]
|
||||
`}>
|
||||
<span className="text-3xl font-bold text-amber-600">3</span>
|
||||
<div className="mb-3">
|
||||
<UserAvatar
|
||||
userId={topThree[2].user.id}
|
||||
hasAvatar={!!topThree[2].user.avatar_url}
|
||||
nickname={topThree[2].user.nickname}
|
||||
size="lg"
|
||||
frame={topThree[2].user.equipped_frame}
|
||||
telegramAvatarUrl={topThree[2].user.telegram_avatar_url}
|
||||
/>
|
||||
</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" />
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -222,20 +311,32 @@ export function LeaderboardPage() {
|
||||
|
||||
{/* Rank */}
|
||||
<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.icon}
|
||||
</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 */}
|
||||
<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
|
||||
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>
|
||||
{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">
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { usersApi, telegramApi, authApi } from '@/api'
|
||||
import type { UserStats } from '@/types'
|
||||
import type { UserStats, ShopItemPublic } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import {
|
||||
NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
|
||||
@@ -13,8 +14,9 @@ import {
|
||||
User, Camera, Trophy, Target, CheckCircle, Flame,
|
||||
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
||||
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
|
||||
AlertTriangle, FileCheck
|
||||
AlertTriangle, FileCheck, Backpack, Edit3
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Schemas
|
||||
const nicknameSchema = z.object({
|
||||
@@ -33,6 +35,235 @@ const passwordSchema = z.object({
|
||||
type NicknameForm = z.infer<typeof nicknameSchema>
|
||||
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() {
|
||||
const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
|
||||
const toast = useToast()
|
||||
@@ -298,76 +529,198 @@ export function ProfilePage() {
|
||||
const isLinked = !!user?.telegram_id
|
||||
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
||||
|
||||
// Get cosmetics data
|
||||
const equippedFrame = user?.equipped_frame as ShopItemPublic | null
|
||||
const equippedTitle = user?.equipped_title as ShopItemPublic | null
|
||||
const equippedNameColor = user?.equipped_name_color as ShopItemPublic | null
|
||||
const equippedBackground = user?.equipped_background as ShopItemPublic | null
|
||||
|
||||
const titleData = getTitleData(equippedTitle)
|
||||
const nameColorData = getNameColorData(equippedNameColor)
|
||||
|
||||
// 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') {
|
||||
// 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-3xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Мой профиль</h1>
|
||||
<p className="text-gray-400">Настройки вашего аккаунта</p>
|
||||
</div>
|
||||
<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}
|
||||
>
|
||||
{/* Default gradient background if no custom background */}
|
||||
{!equippedBackground && (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-dark-800 via-dark-900 to-neon-900/20" />
|
||||
)}
|
||||
|
||||
{/* Profile Card */}
|
||||
<GlassCard variant="neon">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="relative group flex-shrink-0">
|
||||
{isLoadingAvatar ? (
|
||||
<div className="w-28 h-28 rounded-2xl bg-dark-700 skeleton" />
|
||||
) : (
|
||||
<button
|
||||
{/* 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}
|
||||
disabled={isUploadingAvatar}
|
||||
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)]"
|
||||
>
|
||||
{displayAvatar ? (
|
||||
<img
|
||||
src={displayAvatar}
|
||||
alt={user?.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-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
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nickname Form */}
|
||||
<div className="flex-1 w-full sm:w-auto">
|
||||
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
|
||||
<Input
|
||||
label="Никнейм"
|
||||
{...nicknameForm.register('nickname')}
|
||||
error={nicknameForm.formState.errors.nickname?.message}
|
||||
isUploading={isUploadingAvatar}
|
||||
isLoading={isLoadingAvatar}
|
||||
/>
|
||||
<NeonButton
|
||||
type="submit"
|
||||
size="sm"
|
||||
isLoading={nicknameForm.formState.isSubmitting}
|
||||
disabled={!nicknameForm.formState.isDirty}
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Сохранить
|
||||
</NeonButton>
|
||||
</form>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
{/* 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
|
||||
{...nicknameForm.register('nickname')}
|
||||
error={nicknameForm.formState.errors.nickname?.message}
|
||||
placeholder="Введите никнейм"
|
||||
/>
|
||||
</div>
|
||||
<NeonButton
|
||||
type="submit"
|
||||
isLoading={nicknameForm.formState.isSubmitting}
|
||||
disabled={!nicknameForm.formState.isDirty}
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Сохранить
|
||||
</NeonButton>
|
||||
</form>
|
||||
</GlassCard>
|
||||
|
||||
{/* 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 { useAuthStore } from '@/store/auth'
|
||||
import { usersApi } from '@/api'
|
||||
import type { UserProfilePublic } from '@/types'
|
||||
import type { UserProfilePublic, ShopItemPublic } from '@/types'
|
||||
import { GlassCard, StatsCard } from '@/components/ui'
|
||||
import {
|
||||
User, Trophy, Target, CheckCircle, Flame,
|
||||
Loader2, ArrowLeft, Calendar, Zap
|
||||
Loader2, ArrowLeft, Calendar, Shield
|
||||
} 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() {
|
||||
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 (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Кнопка назад */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
@@ -118,42 +344,107 @@ export function UserProfilePage() {
|
||||
Назад
|
||||
</button>
|
||||
|
||||
{/* Профиль */}
|
||||
<GlassCard variant="neon">
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Аватар */}
|
||||
<div className="relative">
|
||||
<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" />
|
||||
{/* ============ HERO SECTION ============ */}
|
||||
<div
|
||||
className={clsx(
|
||||
'relative rounded-3xl overflow-hidden',
|
||||
backgroundData.className
|
||||
)}
|
||||
style={backgroundData.styles}
|
||||
>
|
||||
{/* Default gradient background if no custom background */}
|
||||
{!profile.equipped_background && (
|
||||
<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>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
{/* 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}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
{/* 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">
|
||||
<Zap className="w-3 h-3 text-neon-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Инфо */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">
|
||||
{profile.nickname}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Calendar className="w-4 h-4 text-accent-400" />
|
||||
<span>Зарегистрирован {formatDate(profile.created_at)}</span>
|
||||
{/* 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" />
|
||||
<span>Зарегистрирован {formatDate(profile.created_at)}</span>
|
||||
</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>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<GlassCard>
|
||||
|
||||
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
|
||||
telegram_avatar_url: string | null
|
||||
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)
|
||||
@@ -688,11 +693,161 @@ export interface UserProfilePublic {
|
||||
id: number
|
||||
nickname: string
|
||||
avatar_url: string | null
|
||||
telegram_avatar_url: string | null
|
||||
role: UserRole
|
||||
created_at: string
|
||||
stats: UserStats
|
||||
// Equipped cosmetics
|
||||
equipped_frame: ShopItemPublic | null
|
||||
equipped_title: ShopItemPublic | null
|
||||
equipped_name_color: ShopItemPublic | null
|
||||
equipped_background: ShopItemPublic | null
|
||||
}
|
||||
|
||||
export interface PasswordChangeData {
|
||||
current_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
|
||||
has_shield: boolean
|
||||
has_active_boost: boolean
|
||||
boost_multiplier: number | null
|
||||
boost_expires_at: string | 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