diff --git a/backend/alembic/versions/023_add_shop_system.py b/backend/alembic/versions/023_add_shop_system.py new file mode 100644 index 0000000..a458142 --- /dev/null +++ b/backend/alembic/versions/023_add_shop_system.py @@ -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') diff --git a/backend/alembic/versions/024_seed_shop_items.py b/backend/alembic/versions/024_seed_shop_items.py new file mode 100644 index 0000000..2fd8046 --- /dev/null +++ b/backend/alembic/versions/024_seed_shop_items.py @@ -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')") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 468df86..6129716 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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: diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 4cfaeca..359f307 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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) diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index 167b817..ccc3cd0 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -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") diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 54612b1..85c2108 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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: diff --git a/backend/app/api/v1/feed.py b/backend/app/api/v1/feed.py index 37faf67..75b0881 100644 --- a/backend/app/api/v1/feed.py +++ b/backend/app/api/v1/feed.py @@ -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) diff --git a/backend/app/api/v1/games.py b/backend/app/api/v1/games.py index addfdaa..7894238 100644 --- a/backend/app/api/v1/games.py +++ b/backend/app/api/v1/games.py @@ -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, diff --git a/backend/app/api/v1/marathons.py b/backend/app/api/v1/marathons.py index c13b5ec..b0797a7 100644 --- a/backend/app/api/v1/marathons.py +++ b/backend/app/api/v1/marathons.py @@ -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()) ) diff --git a/backend/app/api/v1/shop.py b/backend/app/api/v1/shop.py new file mode 100644 index 0000000..c3707fe --- /dev/null +++ b/backend/app/api/v1/shop.py @@ -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, + ) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 60fe31d..edde01d 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -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, ) diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index b3a636c..878e2fa 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -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, ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b195960..634f620 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/admin_log.py b/backend/app/models/admin_log.py index b1a1b0a..bd2fec5 100644 --- a/backend/app/models/admin_log.py +++ b/backend/app/models/admin_log.py @@ -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" diff --git a/backend/app/models/coin_transaction.py b/backend/app/models/coin_transaction.py new file mode 100644 index 0000000..beaec6f --- /dev/null +++ b/backend/app/models/coin_transaction.py @@ -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" + ) diff --git a/backend/app/models/consumable_usage.py b/backend/app/models/consumable_usage.py new file mode 100644 index 0000000..f35cbd3 --- /dev/null +++ b/backend/app/models/consumable_usage.py @@ -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") diff --git a/backend/app/models/inventory.py b/backend/app/models/inventory.py new file mode 100644 index 0000000..40e543e --- /dev/null +++ b/backend/app/models/inventory.py @@ -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 diff --git a/backend/app/models/marathon.py b/backend/app/models/marathon.py index 61841a5..ccd6b87 100644 --- a/backend/app/models/marathon.py +++ b/backend/app/models/marathon.py @@ -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 diff --git a/backend/app/models/participant.py b/backend/app/models/participant.py index 49b1737..2e9c0f3 100644 --- a/backend/app/models/participant.py +++ b/backend/app/models/participant.py @@ -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 diff --git a/backend/app/models/shop.py b/backend/app/models/shop.py new file mode 100644 index 0000000..31bde3a --- /dev/null +++ b/backend/app/models/shop.py @@ -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 diff --git a/backend/app/models/user.py b/backend/app/models/user.py index ed8ed38..a1f1ca4 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index d7623c8..a26844a 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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", ] diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py index 67d892f..dd2fae6 100644 --- a/backend/app/schemas/assignment.py +++ b/backend/app/schemas/assignment.py @@ -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): diff --git a/backend/app/schemas/marathon.py b/backend/app/schemas/marathon.py index 8dce928..fcd8c15 100644 --- a/backend/app/schemas/marathon.py +++ b/backend/app/schemas/marathon.py @@ -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 diff --git a/backend/app/schemas/shop.py b/backend/app/schemas/shop.py new file mode 100644 index 0000000..367abda --- /dev/null +++ b/backend/app/schemas/shop.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index fb165cc..40cd234 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/coins.py b/backend/app/services/coins.py new file mode 100644 index 0000000..aacae0b --- /dev/null +++ b/backend/app/services/coins.py @@ -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() diff --git a/backend/app/services/consumables.py b/backend/app/services/consumables.py new file mode 100644 index 0000000..046a4ca --- /dev/null +++ b/backend/app/services/consumables.py @@ -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() diff --git a/backend/app/services/shop.py b/backend/app/services/shop.py new file mode 100644 index 0000000..15aa997 --- /dev/null +++ b/backend/app/services/shop.py @@ -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() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c990ed..79e710b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + {/* Shop routes */} + + + + } + /> + + + + + } + /> + {/* Easter egg - 418 I'm a teapot */} } /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 5819376..6663f7b 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -9,3 +9,4 @@ export { challengesApi } from './challenges' export { assignmentsApi } from './assignments' export { usersApi } from './users' export { telegramApi } from './telegram' +export { shopApi } from './shop' diff --git a/frontend/src/api/shop.ts b/frontend/src/api/shop.ts new file mode 100644 index 0000000..096aa95 --- /dev/null +++ b/frontend/src/api/shop.ts @@ -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 => { + const params = itemType ? { item_type: itemType } : {} + const response = await client.get('/shop/items', { params }) + return response.data + }, + + // Получить товар по ID + getItem: async (itemId: number): Promise => { + const response = await client.get(`/shop/items/${itemId}`) + return response.data + }, + + // === Покупки === + + // Купить товар + purchase: async (itemId: number, quantity: number = 1): Promise => { + const response = await client.post('/shop/purchase', { + item_id: itemId, + quantity, + }) + return response.data + }, + + // === Инвентарь === + + // Получить инвентарь пользователя + getInventory: async (itemType?: ShopItemType): Promise => { + const params = itemType ? { item_type: itemType } : {} + const response = await client.get('/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 => { + const response = await client.get('/shop/cosmetics') + return response.data + }, + + // === Расходуемые === + + // Использовать расходуемый предмет + useConsumable: async (data: UseConsumableRequest): Promise => { + const response = await client.post('/shop/use', data) + return response.data + }, + + // Получить статус расходуемых в марафоне + getConsumablesStatus: async (marathonId: number): Promise => { + const response = await client.get(`/shop/consumables/${marathonId}`) + return response.data + }, + + // === Монеты === + + // Получить баланс и последние транзакции + getBalance: async (): Promise => { + const response = await client.get('/shop/balance') + return response.data + }, + + // Получить историю транзакций + getTransactions: async (limit: number = 50, offset: number = 0): Promise => { + const response = await client.get('/shop/transactions', { + params: { limit, offset }, + }) + return response.data + }, +} diff --git a/frontend/src/components/ActivityFeed.tsx b/frontend/src/components/ActivityFeed.tsx index 7317f89..0983ae8 100644 --- a/frontend/src/components/ActivityFeed.tsx +++ b/frontend/src/components/ActivityFeed.tsx @@ -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 ( + <> + {user.nickname} + {titleData && ( + + {titleData.text} + + )} + + ) +} + 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 */}
e.stopPropagation()} > - {activity.user.nickname} + {formatRelativeTime(activity.created_at)} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index b2f3959..9c82645 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -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() { Марафоны + + + Магазин + + {user?.role === 'admin' && ( )} -
+
{user?.nickname} + + + {balance} + - + + + + ))} +
+ + {/* Empty state */} + {filteredInventory.length === 0 ? ( + + +

+ {activeTab === 'all' + ? 'Твой инвентарь пуст' + : 'Нет предметов в этой категории'} +

+ + + + Перейти в магазин + + +
+ ) : ( + <> + {/* Cosmetic items */} + {cosmeticItems.length > 0 && (activeTab === 'all' || activeTab !== 'consumable') && ( +
+ {activeTab === 'all' && ( +

Косметика

+ )} +
+ {cosmeticItems.map(inv => ( + + ))} +
+
+ )} + + {/* Consumable items */} + {consumableItems.length > 0 && (activeTab === 'all' || activeTab === 'consumable') && ( +
+ {activeTab === 'all' && ( +

Расходуемые

+ )} +
+ {consumableItems.map(inv => ( + + ))} +
+
+ )} + + )} +
+ ) +} diff --git a/frontend/src/pages/LeaderboardPage.tsx b/frontend/src/pages/LeaderboardPage.tsx index 42673f8..9b98293 100644 --- a/frontend/src/pages/LeaderboardPage.tsx +++ b/frontend/src/pages/LeaderboardPage.tsx @@ -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 ( + + {user.nickname} + {titleData && ( + + {titleData.text} + + )} + + ) +} + export function LeaderboardPage() { const { id } = useParams<{ id: string }>() const user = useAuthStore((state) => state.user) @@ -117,48 +188,66 @@ export function LeaderboardPage() {
{/* 2nd place */}
-
- 2 +
+
- + -

{topThree[1].user.nickname}

+

+ +

{topThree[1].total_points} очков

{/* 1st place */}
-
- +
+ +
+ +
- + -

{topThree[0].user.nickname}

+

+ +

{topThree[0].total_points} очков

{/* 3rd place */}
-
- 3 +
+
- + -

{topThree[2].user.nickname}

+

+ +

{topThree[2].total_points} очков

@@ -222,20 +311,32 @@ export function LeaderboardPage() { {/* Rank */}
{rankConfig.icon}
+ {/* Avatar */} +
+ +
+ {/* User info */}
-
+
- {entry.user.nickname} + {isCurrentUser && ( diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 6053e96..6796270 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -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 type PasswordForm = z.infer +// ============ 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
+ } + + const avatarContent = ( +
+ {avatarUrl ? ( + {nickname} + ) : ( +
+ +
+ )} +
+ ) + + const hoverOverlay = ( +
+ {isUploading ? ( + + ) : ( + + )} +
+ ) + + if (!frame) { + return ( + + ) + } + + return ( + + ) +} + 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 ( -
- {/* Header */} -
-

Мой профиль

-

Настройки вашего аккаунта

-
+
+ {/* ============ HERO SECTION ============ */} +
+ {/* Default gradient background if no custom background */} + {!equippedBackground && ( +
+ )} - {/* Profile Card */} - -
- {/* Avatar */} -
- {isLoadingAvatar ? ( -
- ) : ( - - )} - -
- - {/* Nickname Form */} -
-
- - } - > - Сохранить - -
+ +
+ + {/* User Info */} +
+ {/* Nickname with color + Title badge */} +
+

+ {user?.nickname || 'Игрок'} +

+ + {/* Title badge */} + {titleData && ( + + {titleData.text} + + )} +
+ + {/* Role badge */} + {user?.role === 'admin' && ( +
+ + Администратор +
+ )} + + {/* Quick stats preview */} + {stats && ( +
+
+ + {stats.wins_count} побед +
+
+ + {stats.marathons_count} марафонов +
+
+ + {stats.total_points_earned} очков +
+
+ )} + + {/* Inventory link */} +
+ + + Инвентарь + +
+
+
+ + {/* ============ NICKNAME EDIT SECTION ============ */} + +
+
+ +
+
+

Изменить никнейм

+

Ваше игровое имя

+
+
+
+
+ +
+ } + > + Сохранить + +
{/* Stats */} diff --git a/frontend/src/pages/ShopPage.tsx b/frontend/src/pages/ShopPage.tsx new file mode 100644 index 0000000..dcafcba --- /dev/null +++ b/frontend/src/pages/ShopPage.tsx @@ -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 = { + frame: , + title: , + name_color: , + background: , + consumable: , +} + +const CONSUMABLE_ICONS: Record = { + skip: , + shield: , + boost: , + reroll: , +} + +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] || + } + + // 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 ( +
+ ) + } + + // Animated rainbow style + if (data?.style === 'animated') { + return ( +
+ ) + } + + // Solid color style (default) + const solidColor = data?.color || '#ffffff' + return ( +
+ ) + } + + // 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 ( +
+ ) + } + + 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 + } + if (item.item_type === 'title' && item.asset_data?.text) { + return ( + + {item.asset_data.text as string} + + ) + } + return ITEM_TYPE_ICONS[item.item_type] + } + + return ( + + {/* Rarity badge */} +
+ {RARITY_NAMES[item.rarity]} +
+ + {/* Item preview */} +
+ {getItemPreview()} +
+ + {/* Item info */} +

{item.name}

+

+ {item.description} +

+ + {/* Quantity selector for consumables */} + {isConsumable && !item.is_owned && item.is_available && ( +
+ + {quantity} + +
+ )} + + {/* Price and action */} +
+
+ + {isConsumable ? totalPrice : item.price} + {isConsumable && quantity > 1 && ( + ({item.price}×{quantity}) + )} +
+ + {item.is_owned && !isConsumable ? ( + + + Куплено + + ) : item.is_equipped ? ( + Надето + ) : ( + onPurchase(item, quantity)} + disabled={isPurchasing || !item.is_available} + > + {isPurchasing ? ( + + ) : ( + 'Купить' + )} + + )} +
+ + {/* Stock info */} + {item.stock_remaining !== null && ( +
+ Осталось: {item.stock_remaining} +
+ )} +
+ ) +} + +export function ShopPage() { + const { items, balance, isLoading, loadItems, loadBalance, purchase, clearError, error } = useShopStore() + const toast = useToast() + const confirm = useConfirm() + + const [activeTab, setActiveTab] = useState('all') + const [purchasingId, setPurchasingId] = useState(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 = { + 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 = { + frame: { label: 'Рамки профиля', icon: }, + title: { label: 'Титулы', icon: }, + name_color: { label: 'Цвета ника', icon: }, + background: { label: 'Фоны профиля', icon: }, + consumable: { label: 'Расходуемые предметы', icon: }, + } + + const tabs: { id: ShopItemType | 'all'; label: string; icon: React.ReactNode }[] = [ + { id: 'all', label: 'Все', icon: }, + { id: 'frame', label: 'Рамки', icon: }, + { id: 'title', label: 'Титулы', icon: }, + { id: 'name_color', label: 'Цвета', icon: }, + { id: 'background', label: 'Фоны', icon: }, + { id: 'consumable', label: 'Расходники', icon: }, + ] + + if (isLoading && items.length === 0) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+

+ + Магазин +

+

+ Покупай косметику и расходуемые предметы +

+
+ +
+ {/* Balance */} +
+ + {balance} +
+ + {/* Link to inventory */} + + + + Инвентарь + + +
+
+ + {/* Tabs */} +
+ {tabs.map(tab => ( + + ))} +
+ + {/* Items grid */} + {filteredItems.length === 0 ? ( + + +

Нет доступных товаров в этой категории

+
+ ) : activeTab === 'all' ? ( + // Grouped view for "All" tab +
+ {categoryOrder.map(category => { + const categoryItems = itemsByType[category] + if (categoryItems.length === 0) return null + + const { label, icon } = categoryLabels[category] + + return ( +
+
+
+ {icon} +
+

{label}

+ ({categoryItems.length}) +
+
+ {categoryItems.map(item => ( + + ))} +
+
+ ) + })} +
+ ) : ( + // Regular grid for specific category +
+ {filteredItems.map(item => ( + + ))} +
+ )} + + {/* Info about coins */} + +

+ + Как заработать монеты? +

+
    +
  • • Выполняй задания в сертифицированных марафонах
  • +
  • • Easy задание — 5 монет, Medium — 12 монет, Hard — 25 монет
  • +
  • • Playthrough — ~5% от заработанных очков
  • +
  • • Топ-3 места в марафоне: 1-е — 100, 2-е — 50, 3-е — 30 монет
  • +
+
+
+ ) +} diff --git a/frontend/src/pages/UserProfilePage.tsx b/frontend/src/pages/UserProfilePage.tsx index 05939d1..805bbaf 100644 --- a/frontend/src/pages/UserProfilePage.tsx +++ b/frontend/src/pages/UserProfilePage.tsx @@ -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 = ( +
+ {displayAvatar ? ( + {nickname} + ) : ( +
+ +
+ )} +
+ ) + + if (!frame) { + return ( +
+ {avatarContent} +
+ ) + } + + return ( +
+ {avatarContent} +
+ ) +} + +// ============ 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 ( -
+
{/* Кнопка назад */} - {/* Профиль */} - -
- {/* Аватар */} -
-
- {avatarBlobUrl ? ( - {profile.nickname} - ) : ( -
- + {/* ============ HERO SECTION ============ */} +
+ {/* Default gradient background if no custom background */} + {!profile.equipped_background && ( +
+ )} + + {/* Overlay for readability */} +
+ + {/* Scan lines effect */} +
+ + {/* Glow effects */} +
+
+ + {/* Content */} +
+
+ {/* Avatar with Frame */} +
+ +
+ + {/* User Info */} +
+ {/* Nickname with color + Title badge */} +
+

+ {profile.nickname} +

+ + {/* Title badge */} + {titleData && ( + + {titleData.text} + + )} +
+ + {/* Admin badge */} + {profile.role === 'admin' && ( +
+
+ + Администратор +
)} -
- {/* Online indicator effect */} -
- -
-
- {/* Инфо */} -
-

- {profile.nickname} -

-
- - Зарегистрирован {formatDate(profile.created_at)} + {/* Registration date */} +
+ + Зарегистрирован {formatDate(profile.created_at)} +
+ + {/* Quick stats preview */} +
+
+ + {profile.stats.wins_count} побед +
+
+ + {profile.stats.marathons_count} марафонов +
+
+ + {profile.stats.total_points_earned} очков +
+
- +
{/* Статистика */} diff --git a/frontend/src/store/shop.ts b/frontend/src/store/shop.ts new file mode 100644 index 0000000..60b1890 --- /dev/null +++ b/frontend/src/store/shop.ts @@ -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 + loadItems: (itemType?: ShopItemType) => Promise + loadInventory: (itemType?: ShopItemType) => Promise + purchase: (itemId: number, quantity?: number) => Promise + equip: (inventoryId: number) => Promise + unequip: (itemType: ShopItemType) => Promise + updateBalance: (newBalance: number) => void + clearError: () => void +} + +export const useShopStore = create()((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) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 396df72..ce598f0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 | null +} + +export interface ShopItem { + id: number + item_type: ShopItemType + code: string + name: string + description: string | null + price: number + rarity: ItemRarity + asset_data: Record | 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 | 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 = { + 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 = { + common: 'Обычный', + uncommon: 'Необычный', + rare: 'Редкий', + epic: 'Эпический', + legendary: 'Легендарный', +} + +export const ITEM_TYPE_NAMES: Record = { + frame: 'Рамка', + title: 'Титул', + name_color: 'Цвет ника', + background: 'Фон профиля', + consumable: 'Расходуемое', +}