Compare commits

..

3 Commits

Author SHA1 Message Date
2874b64481 Bug fixes 2026-01-08 06:51:15 +07:00
4488a13808 Merge branch 'master' into marathon-v2 2026-01-08 05:37:27 +07:00
6a7717a474 Add shop 2026-01-05 08:42:49 +07:00
48 changed files with 6057 additions and 184 deletions

View File

@@ -0,0 +1,230 @@
"""Add shop system with coins, items, inventory, certification
Revision ID: 023_add_shop_system
Revises: 022_add_notification_settings
Create Date: 2025-01-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '023_add_shop_system'
down_revision: Union[str, None] = '022_add_notification_settings'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
# === 1. Создаём таблицу shop_items ===
if not table_exists('shop_items'):
op.create_table(
'shop_items',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('item_type', sa.String(30), nullable=False, index=True),
sa.Column('code', sa.String(50), nullable=False, unique=True, index=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('price', sa.Integer(), nullable=False),
sa.Column('rarity', sa.String(20), nullable=False, server_default='common'),
sa.Column('asset_data', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('available_from', sa.DateTime(), nullable=True),
sa.Column('available_until', sa.DateTime(), nullable=True),
sa.Column('stock_limit', sa.Integer(), nullable=True),
sa.Column('stock_remaining', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# === 2. Создаём таблицу user_inventory ===
if not table_exists('user_inventory'):
op.create_table(
'user_inventory',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('quantity', sa.Integer(), nullable=False, server_default='1'),
sa.Column('equipped', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('purchased_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('expires_at', sa.DateTime(), nullable=True),
)
# === 3. Создаём таблицу coin_transactions ===
if not table_exists('coin_transactions'):
op.create_table(
'coin_transactions',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('amount', sa.Integer(), nullable=False),
sa.Column('transaction_type', sa.String(30), nullable=False),
sa.Column('reference_type', sa.String(30), nullable=True),
sa.Column('reference_id', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# === 4. Создаём таблицу consumable_usages ===
if not table_exists('consumable_usages'):
op.create_table(
'consumable_usages',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False),
sa.Column('marathon_id', sa.Integer(), sa.ForeignKey('marathons.id', ondelete='CASCADE'), nullable=True),
sa.Column('assignment_id', sa.Integer(), sa.ForeignKey('assignments.id', ondelete='CASCADE'), nullable=True),
sa.Column('used_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('effect_data', sa.JSON(), nullable=True),
)
# === 5. Добавляем поля в users ===
# coins_balance - баланс монет
if not column_exists('users', 'coins_balance'):
op.add_column('users', sa.Column('coins_balance', sa.Integer(), nullable=False, server_default='0'))
# equipped_frame_id - экипированная рамка
if not column_exists('users', 'equipped_frame_id'):
op.add_column('users', sa.Column('equipped_frame_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_title_id - экипированный титул
if not column_exists('users', 'equipped_title_id'):
op.add_column('users', sa.Column('equipped_title_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_name_color_id - экипированный цвет ника
if not column_exists('users', 'equipped_name_color_id'):
op.add_column('users', sa.Column('equipped_name_color_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_background_id - экипированный фон
if not column_exists('users', 'equipped_background_id'):
op.add_column('users', sa.Column('equipped_background_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# === 6. Добавляем поля сертификации в marathons ===
# certification_status - статус сертификации
if not column_exists('marathons', 'certification_status'):
op.add_column('marathons', sa.Column('certification_status', sa.String(20), nullable=False, server_default='none'))
# certification_requested_at - когда подана заявка
if not column_exists('marathons', 'certification_requested_at'):
op.add_column('marathons', sa.Column('certification_requested_at', sa.DateTime(), nullable=True))
# certified_at - когда сертифицирован
if not column_exists('marathons', 'certified_at'):
op.add_column('marathons', sa.Column('certified_at', sa.DateTime(), nullable=True))
# certified_by_id - кем сертифицирован
if not column_exists('marathons', 'certified_by_id'):
op.add_column('marathons', sa.Column('certified_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True))
# certification_rejection_reason - причина отказа
if not column_exists('marathons', 'certification_rejection_reason'):
op.add_column('marathons', sa.Column('certification_rejection_reason', sa.Text(), nullable=True))
# === 7. Добавляем настройки consumables в marathons ===
# allow_skips - разрешены ли скипы
if not column_exists('marathons', 'allow_skips'):
op.add_column('marathons', sa.Column('allow_skips', sa.Boolean(), nullable=False, server_default='true'))
# max_skips_per_participant - лимит скипов на участника
if not column_exists('marathons', 'max_skips_per_participant'):
op.add_column('marathons', sa.Column('max_skips_per_participant', sa.Integer(), nullable=True))
# allow_consumables - разрешены ли расходуемые
if not column_exists('marathons', 'allow_consumables'):
op.add_column('marathons', sa.Column('allow_consumables', sa.Boolean(), nullable=False, server_default='true'))
# === 8. Добавляем поля в participants ===
# coins_earned - заработано монет в марафоне
if not column_exists('participants', 'coins_earned'):
op.add_column('participants', sa.Column('coins_earned', sa.Integer(), nullable=False, server_default='0'))
# skips_used - использовано скипов
if not column_exists('participants', 'skips_used'):
op.add_column('participants', sa.Column('skips_used', sa.Integer(), nullable=False, server_default='0'))
# active_boost_multiplier - активный множитель буста
if not column_exists('participants', 'active_boost_multiplier'):
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
# active_boost_expires_at - когда истекает буст
if not column_exists('participants', 'active_boost_expires_at'):
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
# has_shield - есть ли активный щит
if not column_exists('participants', 'has_shield'):
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
def downgrade() -> None:
# === Удаляем поля из participants ===
if column_exists('participants', 'has_shield'):
op.drop_column('participants', 'has_shield')
if column_exists('participants', 'active_boost_expires_at'):
op.drop_column('participants', 'active_boost_expires_at')
if column_exists('participants', 'active_boost_multiplier'):
op.drop_column('participants', 'active_boost_multiplier')
if column_exists('participants', 'skips_used'):
op.drop_column('participants', 'skips_used')
if column_exists('participants', 'coins_earned'):
op.drop_column('participants', 'coins_earned')
# === Удаляем поля consumables из marathons ===
if column_exists('marathons', 'allow_consumables'):
op.drop_column('marathons', 'allow_consumables')
if column_exists('marathons', 'max_skips_per_participant'):
op.drop_column('marathons', 'max_skips_per_participant')
if column_exists('marathons', 'allow_skips'):
op.drop_column('marathons', 'allow_skips')
# === Удаляем поля сертификации из marathons ===
if column_exists('marathons', 'certification_rejection_reason'):
op.drop_column('marathons', 'certification_rejection_reason')
if column_exists('marathons', 'certified_by_id'):
op.drop_column('marathons', 'certified_by_id')
if column_exists('marathons', 'certified_at'):
op.drop_column('marathons', 'certified_at')
if column_exists('marathons', 'certification_requested_at'):
op.drop_column('marathons', 'certification_requested_at')
if column_exists('marathons', 'certification_status'):
op.drop_column('marathons', 'certification_status')
# === Удаляем поля из users ===
if column_exists('users', 'equipped_background_id'):
op.drop_column('users', 'equipped_background_id')
if column_exists('users', 'equipped_name_color_id'):
op.drop_column('users', 'equipped_name_color_id')
if column_exists('users', 'equipped_title_id'):
op.drop_column('users', 'equipped_title_id')
if column_exists('users', 'equipped_frame_id'):
op.drop_column('users', 'equipped_frame_id')
if column_exists('users', 'coins_balance'):
op.drop_column('users', 'coins_balance')
# === Удаляем таблицы ===
if table_exists('consumable_usages'):
op.drop_table('consumable_usages')
if table_exists('coin_transactions'):
op.drop_table('coin_transactions')
if table_exists('user_inventory'):
op.drop_table('user_inventory')
if table_exists('shop_items'):
op.drop_table('shop_items')

View File

@@ -0,0 +1,495 @@
"""Seed shop items (frames, titles, consumables)
Revision ID: 024_seed_shop_items
Revises: 023_add_shop_system
Create Date: 2025-01-05
"""
from typing import Sequence, Union
from datetime import datetime
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '024_seed_shop_items'
down_revision: Union[str, None] = '023_add_shop_system'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not table_exists('shop_items'):
return
now = datetime.utcnow()
# Таблица shop_items
shop_items = sa.table(
'shop_items',
sa.column('id', sa.Integer),
sa.column('item_type', sa.String),
sa.column('code', sa.String),
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('price', sa.Integer),
sa.column('rarity', sa.String),
sa.column('asset_data', sa.JSON),
sa.column('is_active', sa.Boolean),
sa.column('created_at', sa.DateTime),
)
# === Рамки аватара ===
frames = [
{
'item_type': 'frame',
'code': 'frame_bronze',
'name': 'Бронзовая рамка',
'description': 'Простая бронзовая рамка для начинающих',
'price': 50,
'rarity': 'common',
'asset_data': {
'border_color': '#CD7F32',
'border_width': 3,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_silver',
'name': 'Серебряная рамка',
'description': 'Элегантная серебряная рамка',
'price': 100,
'rarity': 'uncommon',
'asset_data': {
'border_color': '#C0C0C0',
'border_width': 3,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_gold',
'name': 'Золотая рамка',
'description': 'Престижная золотая рамка',
'price': 200,
'rarity': 'rare',
'asset_data': {
'border_color': '#FFD700',
'border_width': 4,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_diamond',
'name': 'Бриллиантовая рамка',
'description': 'Сверкающая бриллиантовая рамка для истинных ценителей',
'price': 500,
'rarity': 'epic',
'asset_data': {
'border_color': '#B9F2FF',
'border_width': 4,
'border_style': 'double',
'glow': True,
'glow_color': '#B9F2FF'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_fire',
'name': 'Огненная рамка',
'description': 'Анимированная рамка с эффектом пламени',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'border_style': 'gradient',
'gradient': ['#FF4500', '#FF8C00', '#FFD700'],
'animated': True,
'animation': 'fire-pulse'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_neon',
'name': 'Неоновая рамка',
'description': 'Яркая неоновая рамка с свечением',
'price': 800,
'rarity': 'epic',
'asset_data': {
'border_color': '#00FF00',
'border_width': 3,
'glow': True,
'glow_color': '#00FF00',
'glow_intensity': 10
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_rainbow',
'name': 'Радужная рамка',
'description': 'Переливающаяся радужная рамка',
'price': 1500,
'rarity': 'legendary',
'asset_data': {
'border_style': 'gradient',
'gradient': ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#9400D3'],
'animated': True,
'animation': 'rainbow-rotate'
},
'is_active': True,
},
]
# === Титулы ===
titles = [
{
'item_type': 'title',
'code': 'title_newcomer',
'name': 'Новичок',
'description': 'Первый шаг в мир марафонов',
'price': 30,
'rarity': 'common',
'asset_data': {
'text': 'Новичок',
'color': '#808080'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_runner',
'name': 'Марафонец',
'description': 'Опытный участник марафонов',
'price': 100,
'rarity': 'uncommon',
'asset_data': {
'text': 'Марафонец',
'color': '#4169E1'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_hunter',
'name': 'Охотник за челленджами',
'description': 'Мастер выполнения сложных заданий',
'price': 200,
'rarity': 'rare',
'asset_data': {
'text': 'Охотник за челленджами',
'color': '#228B22'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_veteran',
'name': 'Ветеран',
'description': 'Закаленный в боях участник',
'price': 300,
'rarity': 'rare',
'asset_data': {
'text': 'Ветеран',
'color': '#8B4513'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_champion',
'name': 'Чемпион',
'description': 'Победитель марафонов',
'price': 500,
'rarity': 'epic',
'asset_data': {
'text': 'Чемпион',
'color': '#FFD700',
'icon': 'trophy'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_legend',
'name': 'Легенда',
'description': 'Легендарный участник марафонов',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'text': 'Легенда',
'color': '#FF4500',
'glow': True,
'icon': 'star'
},
'is_active': True,
},
]
# === Цвета никнейма ===
name_colors = [
{
'item_type': 'name_color',
'code': 'color_red',
'name': 'Красный ник',
'description': 'Яркий красный цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#FF4444'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_blue',
'name': 'Синий ник',
'description': 'Глубокий синий цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#4444FF'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_green',
'name': 'Зеленый ник',
'description': 'Сочный зеленый цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#44FF44'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_purple',
'name': 'Фиолетовый ник',
'description': 'Королевский фиолетовый цвет',
'price': 200,
'rarity': 'rare',
'asset_data': {
'style': 'solid',
'color': '#9932CC'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gold',
'name': 'Золотой ник',
'description': 'Престижный золотой цвет',
'price': 300,
'rarity': 'rare',
'asset_data': {
'style': 'solid',
'color': '#FFD700'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gradient_sunset',
'name': 'Закат',
'description': 'Красивый градиент заката',
'price': 500,
'rarity': 'epic',
'asset_data': {
'style': 'gradient',
'gradient': ['#FF6B6B', '#FFE66D']
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gradient_ocean',
'name': 'Океан',
'description': 'Градиент морских глубин',
'price': 500,
'rarity': 'epic',
'asset_data': {
'style': 'gradient',
'gradient': ['#4ECDC4', '#44A3FF']
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_rainbow',
'name': 'Радужный ник',
'description': 'Анимированный радужный цвет',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'style': 'animated',
'animation': 'rainbow-shift'
},
'is_active': True,
},
]
# === Фоны профиля ===
backgrounds = [
{
'item_type': 'background',
'code': 'bg_dark',
'name': 'Тёмный фон',
'description': 'Элегантный тёмный фон',
'price': 100,
'rarity': 'common',
'asset_data': {
'type': 'solid',
'color': '#1a1a2e'
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_gradient_purple',
'name': 'Фиолетовый градиент',
'description': 'Красивый фиолетовый градиент',
'price': 200,
'rarity': 'uncommon',
'asset_data': {
'type': 'gradient',
'gradient': ['#1a1a2e', '#4a0080']
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_stars',
'name': 'Звёздное небо',
'description': 'Фон с мерцающими звёздами',
'price': 400,
'rarity': 'rare',
'asset_data': {
'type': 'pattern',
'pattern': 'stars',
'animated': True
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_gaming',
'name': 'Игровой фон',
'description': 'Фон с игровыми элементами',
'price': 500,
'rarity': 'epic',
'asset_data': {
'type': 'pattern',
'pattern': 'gaming-icons'
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_fire',
'name': 'Огненный фон',
'description': 'Анимированный огненный фон',
'price': 800,
'rarity': 'legendary',
'asset_data': {
'type': 'animated',
'animation': 'fire-particles'
},
'is_active': True,
},
]
# === Расходуемые предметы ===
consumables = [
{
'item_type': 'consumable',
'code': 'skip',
'name': 'Пропуск',
'description': 'Пропустить текущее задание без штрафа и потери streak',
'price': 100,
'rarity': 'common',
'asset_data': {
'effect': 'skip',
'icon': 'skip-forward'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'shield',
'name': 'Щит',
'description': 'Защита от штрафа при следующем дропе. Streak сохраняется.',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'effect': 'shield',
'icon': 'shield'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'boost',
'name': 'Буст x1.5',
'description': 'Множитель очков x1.5 на следующие 2 часа',
'price': 200,
'rarity': 'rare',
'asset_data': {
'effect': 'boost',
'multiplier': 1.5,
'duration_hours': 2,
'icon': 'zap'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'reroll',
'name': 'Перекрут',
'description': 'Перекрутить колесо и получить новое задание',
'price': 80,
'rarity': 'common',
'asset_data': {
'effect': 'reroll',
'icon': 'refresh-cw'
},
'is_active': True,
},
]
# Вставляем все товары
all_items = frames + titles + name_colors + backgrounds + consumables
# Добавляем created_at ко всем товарам
for item in all_items:
item['created_at'] = now
op.bulk_insert(shop_items, all_items)
def downgrade() -> None:
# Удаляем все seed-товары по коду
op.execute("DELETE FROM shop_items WHERE code LIKE 'frame_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'title_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'color_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'bg_%'")
op.execute("DELETE FROM shop_items WHERE code IN ('skip', 'shield', 'boost', 'reroll')")

View File

@@ -0,0 +1,52 @@
"""Simplify boost consumable - make it one-time instead of timed
Revision ID: 025_simplify_boost
Revises: 024_seed_shop_items
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '025_simplify_boost'
down_revision: Union[str, None] = '024_seed_shop_items'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c['name'] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# Add new boolean column for one-time boost
if not column_exists('participants', 'has_active_boost'):
op.add_column('participants', sa.Column('has_active_boost', sa.Boolean(), nullable=False, server_default='false'))
# Remove old timed boost columns
if column_exists('participants', 'active_boost_multiplier'):
op.drop_column('participants', 'active_boost_multiplier')
if column_exists('participants', 'active_boost_expires_at'):
op.drop_column('participants', 'active_boost_expires_at')
def downgrade() -> None:
# Restore old columns
if not column_exists('participants', 'active_boost_multiplier'):
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
if not column_exists('participants', 'active_boost_expires_at'):
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
# Remove new column
if column_exists('participants', 'has_active_boost'):
op.drop_column('participants', 'has_active_boost')

View File

@@ -4,6 +4,7 @@ from datetime import datetime
from fastapi import Depends, HTTPException, status, Header from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
@@ -35,7 +36,16 @@ async def get_current_user(
detail="Invalid token payload", detail="Invalid token payload",
) )
result = await db.execute(select(User).where(User.id == int(user_id))) result = await db.execute(
select(User)
.where(User.id == int(user_id))
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:

View File

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

View File

@@ -7,7 +7,7 @@ from typing import Optional
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
from app.models import ( from app.models import (
User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent, User, UserRole, Marathon, MarathonStatus, CertificationStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
) )
from app.schemas import ( from app.schemas import (
@@ -37,6 +37,8 @@ class AdminMarathonResponse(BaseModel):
start_date: str | None start_date: str | None
end_date: str | None end_date: str | None
created_at: str created_at: str
certification_status: str = "none"
is_certified: bool = False
class Config: class Config:
from_attributes = True from_attributes = True
@@ -219,7 +221,12 @@ async def list_marathons(
query = ( query = (
select(Marathon) select(Marathon)
.options(selectinload(Marathon.creator)) .options(
selectinload(Marathon.creator).selectinload(User.equipped_frame),
selectinload(Marathon.creator).selectinload(User.equipped_title),
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
selectinload(Marathon.creator).selectinload(User.equipped_background),
)
.order_by(Marathon.created_at.desc()) .order_by(Marathon.created_at.desc())
) )
@@ -248,6 +255,8 @@ async def list_marathons(
start_date=marathon.start_date.isoformat() if marathon.start_date else None, start_date=marathon.start_date.isoformat() if marathon.start_date else None,
end_date=marathon.end_date.isoformat() if marathon.end_date else None, end_date=marathon.end_date.isoformat() if marathon.end_date else None,
created_at=marathon.created_at.isoformat(), created_at=marathon.created_at.isoformat(),
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
)) ))
return response return response
@@ -443,6 +452,8 @@ async def force_finish_marathon(
db: DbSession, db: DbSession,
): ):
"""Force finish a marathon. Admin only.""" """Force finish a marathon. Admin only."""
from app.services.coins import coins_service
require_admin_with_2fa(current_user) require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
@@ -456,6 +467,24 @@ async def force_finish_marathon(
old_status = marathon.status old_status = marathon.status
marathon.status = MarathonStatus.FINISHED.value marathon.status = MarathonStatus.FINISHED.value
marathon.end_date = datetime.utcnow() marathon.end_date = datetime.utcnow()
# Award coins for top 3 places (only in certified marathons)
if marathon.is_certified:
top_result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
.limit(3)
)
top_participants = top_result.scalars().all()
for place, participant in enumerate(top_participants, start=1):
if participant.total_points > 0:
await coins_service.award_marathon_place(
db, participant.user, marathon, place
)
await db.commit() await db.commit()
# Log action # Log action
@@ -1102,3 +1131,75 @@ async def resolve_dispute(
return MessageResponse( return MessageResponse(
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}" message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
) )
# ============ Marathon Certification ============
@router.post("/marathons/{marathon_id}/certify", response_model=MessageResponse)
async def certify_marathon(
request: Request,
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Certify (verify) a marathon. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.certification_status == CertificationStatus.CERTIFIED.value:
raise HTTPException(status_code=400, detail="Marathon is already certified")
marathon.certification_status = CertificationStatus.CERTIFIED.value
marathon.certified_at = datetime.utcnow()
marathon.certified_by_id = current_user.id
marathon.certification_rejection_reason = None
await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.MARATHON_CERTIFY.value,
"marathon", marathon_id,
{"title": marathon.title},
request.client.host if request.client else None
)
return MessageResponse(message="Marathon certified successfully")
@router.post("/marathons/{marathon_id}/revoke-certification", response_model=MessageResponse)
async def revoke_marathon_certification(
request: Request,
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Revoke certification from a marathon. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.certification_status != CertificationStatus.CERTIFIED.value:
raise HTTPException(status_code=400, detail="Marathon is not certified")
marathon.certification_status = CertificationStatus.NONE.value
marathon.certified_at = None
marathon.certified_by_id = None
await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.MARATHON_REVOKE_CERTIFICATION.value,
"marathon", marathon_id,
{"title": marathon.title},
request.client.host if request.client else None
)
return MessageResponse(message="Marathon certification revoked")

View File

@@ -3,6 +3,7 @@ import secrets
from fastapi import APIRouter, HTTPException, status, Request from fastapi import APIRouter, HTTPException, status, Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.security import verify_password, get_password_hash, create_access_token from app.core.security import verify_password, get_password_hash, create_access_token
@@ -48,7 +49,16 @@ async def register(request: Request, data: UserRegister, db: DbSession):
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def login(request: Request, data: UserLogin, db: DbSession): async def login(request: Request, data: UserLogin, db: DbSession):
# Find user # Find user
result = await db.execute(select(User).where(User.login == data.login.lower())) result = await db.execute(
select(User)
.where(User.login == data.login.lower())
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.password_hash): if not user or not verify_password(data.password, user.password_hash):
@@ -147,7 +157,16 @@ async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession
await db.commit() await db.commit()
# Get user # Get user
result = await db.execute(select(User).where(User.id == session.user_id)) result = await db.execute(
select(User)
.where(User.id == session.user_id)
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:

View File

@@ -3,7 +3,7 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.models import Activity, Participant, Dispute, ActivityType from app.models import Activity, Participant, Dispute, ActivityType, User
from app.models.dispute import DisputeStatus from app.models.dispute import DisputeStatus
from app.schemas import FeedResponse, ActivityResponse, UserPublic from app.schemas import FeedResponse, ActivityResponse, UserPublic
@@ -37,7 +37,12 @@ async def get_feed(
# Get activities # Get activities
result = await db.execute( result = await db.execute(
select(Activity) select(Activity)
.options(selectinload(Activity.user)) .options(
selectinload(Activity.user).selectinload(User.equipped_frame),
selectinload(Activity.user).selectinload(User.equipped_title),
selectinload(Activity.user).selectinload(User.equipped_name_color),
selectinload(Activity.user).selectinload(User.equipped_background),
)
.where(Activity.marathon_id == marathon_id) .where(Activity.marathon_id == marathon_id)
.order_by(Activity.created_at.desc()) .order_by(Activity.created_at.desc())
.limit(limit) .limit(limit)

View File

@@ -9,7 +9,7 @@ from app.api.deps import (
from app.core.config import settings from app.core.config import settings
from app.models import ( from app.models import (
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType, Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User
) )
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
from app.schemas.assignment import AvailableGamesCount from app.schemas.assignment import AvailableGamesCount
@@ -23,8 +23,14 @@ async def get_game_or_404(db, game_id: int) -> Game:
result = await db.execute( result = await db.execute(
select(Game) select(Game)
.options( .options(
selectinload(Game.proposed_by), selectinload(Game.proposed_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by), selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
) )
.where(Game.id == game_id) .where(Game.id == game_id)
) )
@@ -73,8 +79,14 @@ async def list_games(
select(Game, func.count(Challenge.id).label("challenges_count")) select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge) .outerjoin(Challenge)
.options( .options(
selectinload(Game.proposed_by), selectinload(Game.proposed_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by), selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
) )
.where(Game.marathon_id == marathon_id) .where(Game.marathon_id == marathon_id)
.group_by(Game.id) .group_by(Game.id)
@@ -106,8 +118,14 @@ async def list_pending_games(marathon_id: int, current_user: CurrentUser, db: Db
select(Game, func.count(Challenge.id).label("challenges_count")) select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge) .outerjoin(Challenge)
.options( .options(
selectinload(Game.proposed_by), selectinload(Game.proposed_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by), selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
) )
.where( .where(
Game.marathon_id == marathon_id, Game.marathon_id == marathon_id,

View File

@@ -20,7 +20,7 @@ optional_auth = HTTPBearer(auto_error=False)
from app.models import ( from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge, Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
) )
from app.schemas import ( from app.schemas import (
MarathonCreate, MarathonCreate,
@@ -80,7 +80,12 @@ def generate_invite_code() -> str:
async def get_marathon_or_404(db, marathon_id: int) -> Marathon: async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
result = await db.execute( result = await db.execute(
select(Marathon) select(Marathon)
.options(selectinload(Marathon.creator)) .options(
selectinload(Marathon.creator).selectinload(User.equipped_frame),
selectinload(Marathon.creator).selectinload(User.equipped_title),
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
selectinload(Marathon.creator).selectinload(User.equipped_background),
)
.where(Marathon.id == marathon_id) .where(Marathon.id == marathon_id)
) )
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()
@@ -348,6 +353,8 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
@router.post("/{marathon_id}/finish", response_model=MarathonResponse) @router.post("/{marathon_id}/finish", response_model=MarathonResponse)
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
from app.services.coins import coins_service
# Require organizer role # Require organizer role
await require_organizer(db, current_user, marathon_id) await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id) marathon = await get_marathon_or_404(db, marathon_id)
@@ -357,6 +364,24 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
marathon.status = MarathonStatus.FINISHED.value marathon.status = MarathonStatus.FINISHED.value
# Award coins for top 3 places (only in certified marathons)
if marathon.is_certified:
# Get top 3 participants by total_points
top_result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
.limit(3)
)
top_participants = top_result.scalars().all()
for place, participant in enumerate(top_participants, start=1):
if participant.total_points > 0: # Only award if they have points
await coins_service.award_marathon_place(
db, participant.user, marathon, place
)
# Log activity # Log activity
activity = Activity( activity = Activity(
marathon_id=marathon_id, marathon_id=marathon_id,
@@ -465,7 +490,12 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
result = await db.execute( result = await db.execute(
select(Participant) select(Participant)
.options(selectinload(Participant.user)) .options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where(Participant.marathon_id == marathon_id) .where(Participant.marathon_id == marathon_id)
.order_by(Participant.joined_at) .order_by(Participant.joined_at)
) )
@@ -504,7 +534,12 @@ async def set_participant_role(
# Get participant # Get participant
result = await db.execute( result = await db.execute(
select(Participant) select(Participant)
.options(selectinload(Participant.user)) .options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where( .where(
Participant.marathon_id == marathon_id, Participant.marathon_id == marathon_id,
Participant.user_id == user_id, Participant.user_id == user_id,
@@ -569,7 +604,12 @@ async def get_leaderboard(
result = await db.execute( result = await db.execute(
select(Participant) select(Participant)
.options(selectinload(Participant.user)) .options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where(Participant.marathon_id == marathon_id) .where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc()) .order_by(Participant.total_points.desc())
) )

634
backend/app/api/v1/shop.py Normal file
View File

@@ -0,0 +1,634 @@
"""
Shop API endpoints - catalog, purchases, inventory, cosmetics, consumables
"""
from datetime import datetime
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import CurrentUser, DbSession, require_participant, require_admin_with_2fa
from app.models import (
User, Marathon, Participant, Assignment, AssignmentStatus,
ShopItem, UserInventory, CoinTransaction, ShopItemType,
CertificationStatus,
)
from app.schemas import (
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
InventoryItemResponse, PurchaseRequest, PurchaseResponse,
UseConsumableRequest, UseConsumableResponse,
EquipItemRequest, EquipItemResponse,
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
ConsumablesStatusResponse, MessageResponse,
)
from app.services.shop import shop_service
from app.services.coins import coins_service
from app.services.consumables import consumables_service
router = APIRouter(prefix="/shop", tags=["shop"])
# === Catalog ===
@router.get("/items", response_model=list[ShopItemResponse])
async def get_shop_items(
current_user: CurrentUser,
db: DbSession,
item_type: str | None = None,
include_unavailable: bool = False,
):
"""Get list of shop items"""
items = await shop_service.get_available_items(db, item_type, include_unavailable)
# Get user's inventory to mark owned/equipped items
user_inventory = await shop_service.get_user_inventory(db, current_user.id)
owned_ids = {inv.item_id for inv in user_inventory}
equipped_ids = {inv.item_id for inv in user_inventory if inv.equipped}
result = []
for item in items:
item_dict = ShopItemResponse.model_validate(item).model_dump()
item_dict["is_owned"] = item.id in owned_ids
item_dict["is_equipped"] = item.id in equipped_ids
result.append(ShopItemResponse(**item_dict))
return result
@router.get("/items/{item_id}", response_model=ShopItemResponse)
async def get_shop_item(
item_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get single shop item by ID"""
item = await shop_service.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
is_owned = await shop_service.check_user_owns_item(db, current_user.id, item_id)
# Check if equipped
is_equipped = False
if is_owned:
inventory = await shop_service.get_user_inventory(db, current_user.id, item.item_type)
is_equipped = any(inv.equipped and inv.item_id == item_id for inv in inventory)
response = ShopItemResponse.model_validate(item)
response.is_owned = is_owned
response.is_equipped = is_equipped
return response
# === Purchases ===
@router.post("/purchase", response_model=PurchaseResponse)
async def purchase_item(
data: PurchaseRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Purchase an item from the shop"""
inv_item, total_cost = await shop_service.purchase_item(
db, current_user, data.item_id, data.quantity
)
await db.commit()
await db.refresh(current_user)
item = await shop_service.get_item_by_id(db, data.item_id)
return PurchaseResponse(
success=True,
item=ShopItemResponse.model_validate(item),
quantity=data.quantity,
total_cost=total_cost,
new_balance=current_user.coins_balance,
message=f"Successfully purchased {item.name} x{data.quantity}",
)
# === Inventory ===
@router.get("/inventory", response_model=list[InventoryItemResponse])
async def get_my_inventory(
current_user: CurrentUser,
db: DbSession,
item_type: str | None = None,
):
"""Get current user's inventory"""
inventory = await shop_service.get_user_inventory(db, current_user.id, item_type)
return [InventoryItemResponse.model_validate(inv) for inv in inventory]
# === Equip/Unequip ===
@router.post("/equip", response_model=EquipItemResponse)
async def equip_item(
data: EquipItemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Equip a cosmetic item from inventory"""
item = await shop_service.equip_item(db, current_user, data.inventory_id)
await db.commit()
return EquipItemResponse(
success=True,
item_type=item.item_type,
equipped_item=ShopItemResponse.model_validate(item),
message=f"Equipped {item.name}",
)
@router.post("/unequip/{item_type}", response_model=EquipItemResponse)
async def unequip_item(
item_type: str,
current_user: CurrentUser,
db: DbSession,
):
"""Unequip item of specified type"""
valid_types = [ShopItemType.FRAME.value, ShopItemType.TITLE.value,
ShopItemType.NAME_COLOR.value, ShopItemType.BACKGROUND.value]
if item_type not in valid_types:
raise HTTPException(status_code=400, detail=f"Invalid item type: {item_type}")
await shop_service.unequip_item(db, current_user, item_type)
await db.commit()
return EquipItemResponse(
success=True,
item_type=item_type,
equipped_item=None,
message=f"Unequipped {item_type}",
)
# === Consumables ===
@router.post("/use", response_model=UseConsumableResponse)
async def use_consumable(
data: UseConsumableRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Use a consumable item"""
# Get marathon
result = await db.execute(select(Marathon).where(Marathon.id == data.marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
# Get participant
participant = await require_participant(db, current_user.id, data.marathon_id)
# For skip and reroll, we need the assignment
assignment = None
if data.item_code in ["skip", "reroll"]:
if not data.assignment_id:
raise HTTPException(status_code=400, detail="assignment_id is required for skip/reroll")
result = await db.execute(
select(Assignment).where(
Assignment.id == data.assignment_id,
Assignment.participant_id == participant.id,
)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Use the consumable
if data.item_code == "skip":
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
effect_description = "Assignment skipped without penalty"
elif data.item_code == "shield":
effect = await consumables_service.use_shield(db, current_user, participant, marathon)
effect_description = "Shield activated - next drop will be free"
elif data.item_code == "boost":
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
effect_description = f"Boost x{effect['multiplier']} activated for next complete"
elif data.item_code == "reroll":
effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment)
effect_description = "Assignment rerolled - you can spin again"
else:
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
await db.commit()
# Get remaining quantity
remaining = await consumables_service.get_consumable_count(db, current_user.id, data.item_code)
return UseConsumableResponse(
success=True,
item_code=data.item_code,
remaining_quantity=remaining,
effect_description=effect_description,
effect_data=effect,
)
@router.get("/consumables/{marathon_id}", response_model=ConsumablesStatusResponse)
async def get_consumables_status(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get consumables status for participant in marathon"""
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
participant = await require_participant(db, current_user.id, marathon_id)
# Get inventory counts for all consumables
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
shields_available = await consumables_service.get_consumable_count(db, current_user.id, "shield")
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
rerolls_available = await consumables_service.get_consumable_count(db, current_user.id, "reroll")
# Calculate remaining skips for this marathon
skips_remaining = None
if marathon.max_skips_per_participant is not None:
skips_remaining = max(0, marathon.max_skips_per_participant - participant.skips_used)
return ConsumablesStatusResponse(
skips_available=skips_available,
skips_used=participant.skips_used,
skips_remaining=skips_remaining,
shields_available=shields_available,
has_shield=participant.has_shield,
boosts_available=boosts_available,
has_active_boost=participant.has_active_boost,
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
rerolls_available=rerolls_available,
)
# === Coins ===
@router.get("/balance", response_model=CoinsBalanceResponse)
async def get_coins_balance(
current_user: CurrentUser,
db: DbSession,
):
"""Get current user's coins balance with recent transactions"""
result = await db.execute(
select(CoinTransaction)
.where(CoinTransaction.user_id == current_user.id)
.order_by(CoinTransaction.created_at.desc())
.limit(10)
)
transactions = result.scalars().all()
return CoinsBalanceResponse(
balance=current_user.coins_balance,
recent_transactions=[CoinTransactionResponse.model_validate(t) for t in transactions],
)
@router.get("/transactions", response_model=list[CoinTransactionResponse])
async def get_coin_transactions(
current_user: CurrentUser,
db: DbSession,
limit: int = 50,
offset: int = 0,
):
"""Get user's coin transaction history"""
result = await db.execute(
select(CoinTransaction)
.where(CoinTransaction.user_id == current_user.id)
.order_by(CoinTransaction.created_at.desc())
.offset(offset)
.limit(min(limit, 100))
)
transactions = result.scalars().all()
return [CoinTransactionResponse.model_validate(t) for t in transactions]
# === Certification (organizer endpoints) ===
@router.post("/certification/{marathon_id}/request", response_model=CertificationStatusResponse)
async def request_certification(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Request certification for a marathon (organizer only)"""
# Check user is organizer
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only the creator can request certification")
if marathon.certification_status != CertificationStatus.NONE.value:
raise HTTPException(
status_code=400,
detail=f"Marathon already has certification status: {marathon.certification_status}"
)
marathon.certification_status = CertificationStatus.PENDING.value
marathon.certification_requested_at = datetime.utcnow()
await db.commit()
await db.refresh(marathon)
return CertificationStatusResponse(
marathon_id=marathon.id,
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
certification_requested_at=marathon.certification_requested_at,
certified_at=marathon.certified_at,
certified_by_nickname=None,
rejection_reason=None,
)
@router.delete("/certification/{marathon_id}/request", response_model=MessageResponse)
async def cancel_certification_request(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Cancel certification request (organizer only)"""
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only the creator can cancel certification request")
if marathon.certification_status != CertificationStatus.PENDING.value:
raise HTTPException(status_code=400, detail="No pending certification request to cancel")
marathon.certification_status = CertificationStatus.NONE.value
marathon.certification_requested_at = None
await db.commit()
return MessageResponse(message="Certification request cancelled")
@router.get("/certification/{marathon_id}", response_model=CertificationStatusResponse)
async def get_certification_status(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get certification status of a marathon"""
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.certified_by))
.where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
return CertificationStatusResponse(
marathon_id=marathon.id,
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
certification_requested_at=marathon.certification_requested_at,
certified_at=marathon.certified_at,
certified_by_nickname=marathon.certified_by.nickname if marathon.certified_by else None,
rejection_reason=marathon.certification_rejection_reason,
)
# === Admin endpoints ===
@router.get("/admin/items", response_model=list[ShopItemResponse])
async def admin_get_all_items(
current_user: CurrentUser,
db: DbSession,
):
"""Get all shop items including inactive (admin only)"""
require_admin_with_2fa(current_user)
items = await shop_service.get_available_items(db, include_unavailable=True)
return [ShopItemResponse.model_validate(item) for item in items]
@router.post("/admin/items", response_model=ShopItemResponse)
async def admin_create_item(
data: ShopItemCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a new shop item (admin only)"""
require_admin_with_2fa(current_user)
# Check code uniqueness
existing = await shop_service.get_item_by_code(db, data.code)
if existing:
raise HTTPException(status_code=400, detail=f"Item with code '{data.code}' already exists")
item = ShopItem(
item_type=data.item_type,
code=data.code,
name=data.name,
description=data.description,
price=data.price,
rarity=data.rarity,
asset_data=data.asset_data,
is_active=data.is_active,
available_from=data.available_from,
available_until=data.available_until,
stock_limit=data.stock_limit,
stock_remaining=data.stock_limit, # Initialize remaining = limit
)
db.add(item)
await db.commit()
await db.refresh(item)
return ShopItemResponse.model_validate(item)
@router.put("/admin/items/{item_id}", response_model=ShopItemResponse)
async def admin_update_item(
item_id: int,
data: ShopItemUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update a shop item (admin only)"""
require_admin_with_2fa(current_user)
item = await shop_service.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
# Update fields
if data.name is not None:
item.name = data.name
if data.description is not None:
item.description = data.description
if data.price is not None:
item.price = data.price
if data.rarity is not None:
item.rarity = data.rarity
if data.asset_data is not None:
item.asset_data = data.asset_data
if data.is_active is not None:
item.is_active = data.is_active
if data.available_from is not None:
item.available_from = data.available_from
if data.available_until is not None:
item.available_until = data.available_until
if data.stock_limit is not None:
# If increasing limit, also increase remaining
if item.stock_limit is not None and data.stock_limit > item.stock_limit:
diff = data.stock_limit - item.stock_limit
item.stock_remaining = (item.stock_remaining or 0) + diff
item.stock_limit = data.stock_limit
await db.commit()
await db.refresh(item)
return ShopItemResponse.model_validate(item)
@router.delete("/admin/items/{item_id}", response_model=MessageResponse)
async def admin_delete_item(
item_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete a shop item (admin only)"""
require_admin_with_2fa(current_user)
item = await shop_service.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
await db.delete(item)
await db.commit()
return MessageResponse(message=f"Item '{item.name}' deleted")
@router.post("/admin/users/{user_id}/coins/grant", response_model=MessageResponse)
async def admin_grant_coins(
user_id: int,
data: AdminCoinsRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Grant coins to a user (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
await coins_service.admin_grant_coins(db, user, data.amount, data.reason, current_user.id)
await db.commit()
return MessageResponse(message=f"Granted {data.amount} coins to {user.nickname}")
@router.post("/admin/users/{user_id}/coins/deduct", response_model=MessageResponse)
async def admin_deduct_coins(
user_id: int,
data: AdminCoinsRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Deduct coins from a user (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
success = await coins_service.admin_deduct_coins(db, user, data.amount, data.reason, current_user.id)
if not success:
raise HTTPException(status_code=400, detail="User doesn't have enough coins")
await db.commit()
return MessageResponse(message=f"Deducted {data.amount} coins from {user.nickname}")
@router.get("/admin/certification/pending", response_model=list[dict])
async def admin_get_pending_certifications(
current_user: CurrentUser,
db: DbSession,
):
"""Get list of marathons pending certification (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.creator))
.where(Marathon.certification_status == CertificationStatus.PENDING.value)
.order_by(Marathon.certification_requested_at.asc())
)
marathons = result.scalars().all()
return [
{
"id": m.id,
"title": m.title,
"creator_nickname": m.creator.nickname,
"status": m.status,
"participants_count": len(m.participants) if m.participants else 0,
"certification_requested_at": m.certification_requested_at,
}
for m in marathons
]
@router.post("/admin/certification/{marathon_id}/review", response_model=CertificationStatusResponse)
async def admin_review_certification(
marathon_id: int,
data: CertificationReviewRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Approve or reject marathon certification (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.certification_status != CertificationStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Marathon is not pending certification")
if data.approve:
marathon.certification_status = CertificationStatus.CERTIFIED.value
marathon.certified_at = datetime.utcnow()
marathon.certified_by_id = current_user.id
marathon.certification_rejection_reason = None
else:
if not data.rejection_reason:
raise HTTPException(status_code=400, detail="Rejection reason is required")
marathon.certification_status = CertificationStatus.REJECTED.value
marathon.certification_rejection_reason = data.rejection_reason
await db.commit()
await db.refresh(marathon)
return CertificationStatusResponse(
marathon_id=marathon.id,
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
certification_requested_at=marathon.certification_requested_at,
certified_at=marathon.certified_at,
certified_by_nickname=current_user.nickname if data.approve else None,
rejection_reason=marathon.certification_rejection_reason,
)

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.config import settings from app.core.config import settings
@@ -20,7 +21,16 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=UserPublic) @router.get("/{user_id}", response_model=UserPublic)
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser): async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
"""Get user profile. Requires authentication.""" """Get user profile. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(
select(User)
.where(User.id == user_id)
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
@@ -239,7 +249,16 @@ async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser)
@router.get("/{user_id}/profile", response_model=UserProfilePublic) @router.get("/{user_id}/profile", response_model=UserProfilePublic)
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser): async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
"""Получить публичный профиль пользователя со статистикой. Requires authentication.""" """Получить публичный профиль пользователя со статистикой. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(
select(User)
.where(User.id == user_id)
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
@@ -254,8 +273,14 @@ async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUse
id=user.id, id=user.id,
nickname=user.nickname, nickname=user.nickname,
avatar_url=user.avatar_url, avatar_url=user.avatar_url,
telegram_avatar_url=user.telegram_avatar_url,
role=user.role,
created_at=user.created_at, created_at=user.created_at,
stats=stats, stats=stats,
equipped_frame=user.equipped_frame,
equipped_title=user.equipped_title,
equipped_name_color=user.equipped_name_color,
equipped_background=user.equipped_background,
) )

View File

@@ -20,6 +20,8 @@ from app.schemas.game import PlaythroughInfo
from app.services.points import PointsService from app.services.points import PointsService
from app.services.events import event_service from app.services.events import event_service
from app.services.storage import storage_service from app.services.storage import storage_service
from app.services.coins import coins_service
from app.services.consumables import consumables_service
from app.api.v1.games import get_available_games_for_participant from app.api.v1.games import get_available_games_for_participant
router = APIRouter(tags=["wheel"]) router = APIRouter(tags=["wheel"])
@@ -619,6 +621,11 @@ async def complete_assignment(
if ba.status == BonusAssignmentStatus.COMPLETED.value: if ba.status == BonusAssignmentStatus.COMPLETED.value:
ba.points_earned = int(ba.challenge.points * multiplier) ba.points_earned = int(ba.challenge.points * multiplier)
# Apply boost multiplier from consumable
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier)
# Update assignment # Update assignment
assignment.status = AssignmentStatus.COMPLETED.value assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points assignment.points_earned = total_points
@@ -630,6 +637,15 @@ async def complete_assignment(
participant.current_streak += 1 participant.current_streak += 1
participant.drop_count = 0 participant.drop_count = 0
# Get marathon and award coins if certified
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = marathon_result.scalar_one()
coins_earned = 0
if marathon.is_certified:
coins_earned = await coins_service.award_playthrough_coins(
db, current_user, participant, marathon, total_points, assignment.id
)
# Check if this is a redo of a previously disputed assignment # Check if this is a redo of a previously disputed assignment
is_redo = ( is_redo = (
assignment.dispute is not None and assignment.dispute is not None and
@@ -648,6 +664,10 @@ async def complete_assignment(
} }
if is_redo: if is_redo:
activity_data["is_redo"] = True activity_data["is_redo"] = True
if boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if playthrough_event: if playthrough_event:
activity_data["event_type"] = playthrough_event.type activity_data["event_type"] = playthrough_event.type
activity_data["event_bonus"] = event_bonus activity_data["event_bonus"] = event_bonus
@@ -673,6 +693,7 @@ async def complete_assignment(
streak_bonus=streak_bonus, streak_bonus=streak_bonus,
total_points=participant.total_points, total_points=participant.total_points,
new_streak=participant.current_streak, new_streak=participant.current_streak,
coins_earned=coins_earned,
) )
# Regular challenge completion # Regular challenge completion
@@ -707,6 +728,11 @@ async def complete_assignment(
total_points += common_enemy_bonus total_points += common_enemy_bonus
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}") print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Apply boost multiplier from consumable
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier)
# Update assignment # Update assignment
assignment.status = AssignmentStatus.COMPLETED.value assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points assignment.points_earned = total_points
@@ -718,6 +744,15 @@ async def complete_assignment(
participant.current_streak += 1 participant.current_streak += 1
participant.drop_count = 0 participant.drop_count = 0
# Get marathon and award coins if certified
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = marathon_result.scalar_one()
coins_earned = 0
if marathon.is_certified:
coins_earned = await coins_service.award_challenge_coins(
db, current_user, participant, marathon, challenge.difficulty, assignment.id
)
# Check if this is a redo of a previously disputed assignment # Check if this is a redo of a previously disputed assignment
is_redo = ( is_redo = (
assignment.dispute is not None and assignment.dispute is not None and
@@ -735,6 +770,10 @@ async def complete_assignment(
} }
if is_redo: if is_redo:
activity_data["is_redo"] = True activity_data["is_redo"] = True
if boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if assignment.event_type == EventType.JACKPOT.value: if assignment.event_type == EventType.JACKPOT.value:
activity_data["event_type"] = assignment.event_type activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus activity_data["event_bonus"] = event_bonus
@@ -799,6 +838,7 @@ async def complete_assignment(
streak_bonus=streak_bonus, streak_bonus=streak_bonus,
total_points=participant.total_points, total_points=participant.total_points,
new_streak=participant.current_streak, new_streak=participant.current_streak,
coins_earned=coins_earned,
) )
@@ -847,6 +887,12 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
participant.drop_count, game.playthrough_points, playthrough_event participant.drop_count, game.playthrough_points, playthrough_event
) )
# Check for shield - if active, no penalty
shield_used = False
if consumables_service.consume_shield(participant):
penalty = 0
shield_used = True
# Update assignment # Update assignment
assignment.status = AssignmentStatus.DROPPED.value assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow() assignment.completed_at = datetime.utcnow()
@@ -875,6 +921,8 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
"penalty": penalty, "penalty": penalty,
"lost_bonuses": completed_bonuses_count, "lost_bonuses": completed_bonuses_count,
} }
if shield_used:
activity_data["shield_used"] = True
if playthrough_event: if playthrough_event:
activity_data["event_type"] = playthrough_event.type activity_data["event_type"] = playthrough_event.type
activity_data["free_drop"] = True activity_data["free_drop"] = True
@@ -893,6 +941,7 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
penalty=penalty, penalty=penalty,
total_points=participant.total_points, total_points=participant.total_points,
new_drop_count=participant.drop_count, new_drop_count=participant.drop_count,
shield_used=shield_used,
) )
# Regular challenge drop # Regular challenge drop
@@ -904,6 +953,12 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
# Calculate penalty (0 if double_risk event is active) # Calculate penalty (0 if double_risk event is active)
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event) penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
# Check for shield - if active, no penalty
shield_used = False
if consumables_service.consume_shield(participant):
penalty = 0
shield_used = True
# Update assignment # Update assignment
assignment.status = AssignmentStatus.DROPPED.value assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow() assignment.completed_at = datetime.utcnow()
@@ -920,6 +975,8 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
"difficulty": assignment.challenge.difficulty, "difficulty": assignment.challenge.difficulty,
"penalty": penalty, "penalty": penalty,
} }
if shield_used:
activity_data["shield_used"] = True
if active_event: if active_event:
activity_data["event_type"] = active_event.type activity_data["event_type"] = active_event.type
if active_event.type == EventType.DOUBLE_RISK.value: if active_event.type == EventType.DOUBLE_RISK.value:
@@ -939,6 +996,7 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
penalty=penalty, penalty=penalty,
total_points=participant.total_points, total_points=participant.total_points,
new_drop_count=participant.drop_count, new_drop_count=participant.drop_count,
shield_used=shield_used,
) )

View File

@@ -1,5 +1,5 @@
from app.models.user import User, UserRole from app.models.user import User, UserRole
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode from app.models.marathon import Marathon, MarathonStatus, GameProposalMode, CertificationStatus
from app.models.participant import Participant, ParticipantRole from app.models.participant import Participant, ParticipantRole
from app.models.game import Game, GameStatus, GameType from app.models.game import Game, GameStatus, GameType
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
@@ -13,6 +13,10 @@ from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVo
from app.models.admin_log import AdminLog, AdminActionType from app.models.admin_log import AdminLog, AdminActionType
from app.models.admin_2fa import Admin2FASession from app.models.admin_2fa import Admin2FASession
from app.models.static_content import StaticContent from app.models.static_content import StaticContent
from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType
from app.models.inventory import UserInventory
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
from app.models.consumable_usage import ConsumableUsage
__all__ = [ __all__ = [
"User", "User",
@@ -20,6 +24,7 @@ __all__ = [
"Marathon", "Marathon",
"MarathonStatus", "MarathonStatus",
"GameProposalMode", "GameProposalMode",
"CertificationStatus",
"Participant", "Participant",
"ParticipantRole", "ParticipantRole",
"Game", "Game",
@@ -49,4 +54,12 @@ __all__ = [
"AdminActionType", "AdminActionType",
"Admin2FASession", "Admin2FASession",
"StaticContent", "StaticContent",
"ShopItem",
"ShopItemType",
"ItemRarity",
"ConsumableType",
"UserInventory",
"CoinTransaction",
"CoinTransactionType",
"ConsumableUsage",
] ]

View File

@@ -17,6 +17,8 @@ class AdminActionType(str, Enum):
# Marathon actions # Marathon actions
MARATHON_FORCE_FINISH = "marathon_force_finish" MARATHON_FORCE_FINISH = "marathon_force_finish"
MARATHON_DELETE = "marathon_delete" MARATHON_DELETE = "marathon_delete"
MARATHON_CERTIFY = "marathon_certify"
MARATHON_REVOKE_CERTIFICATION = "marathon_revoke_certification"
# Content actions # Content actions
CONTENT_UPDATE = "content_update" CONTENT_UPDATE = "content_update"

View File

@@ -0,0 +1,41 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class CoinTransactionType(str, Enum):
CHALLENGE_COMPLETE = "challenge_complete"
PLAYTHROUGH_COMPLETE = "playthrough_complete"
MARATHON_WIN = "marathon_win"
MARATHON_PLACE = "marathon_place"
COMMON_ENEMY_BONUS = "common_enemy_bonus"
PURCHASE = "purchase"
REFUND = "refund"
ADMIN_GRANT = "admin_grant"
ADMIN_DEDUCT = "admin_deduct"
class CoinTransaction(Base):
__tablename__ = "coin_transactions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
amount: Mapped[int] = mapped_column(Integer, nullable=False)
transaction_type: Mapped[str] = mapped_column(String(30), nullable=False)
reference_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="coin_transactions"
)

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.shop import ShopItem
from app.models.marathon import Marathon
from app.models.assignment import Assignment
class ConsumableUsage(Base):
__tablename__ = "consumable_usages"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False)
marathon_id: Mapped[int | None] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), nullable=True)
assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True)
used_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
effect_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Relationships
user: Mapped["User"] = relationship("User")
item: Mapped["ShopItem"] = relationship("ShopItem")
marathon: Mapped["Marathon | None"] = relationship("Marathon")
assignment: Mapped["Assignment | None"] = relationship("Assignment")

View File

@@ -0,0 +1,39 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.shop import ShopItem
class UserInventory(Base):
__tablename__ = "user_inventory"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False, index=True)
quantity: Mapped[int] = mapped_column(Integer, default=1)
equipped: Mapped[bool] = mapped_column(Boolean, default=False)
purchased_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="inventory"
)
item: Mapped["ShopItem"] = relationship(
"ShopItem",
back_populates="inventory_items"
)
@property
def is_expired(self) -> bool:
"""Check if item has expired"""
if self.expires_at is None:
return False
return datetime.utcnow() > self.expires_at

View File

@@ -17,6 +17,13 @@ class GameProposalMode(str, Enum):
ORGANIZER_ONLY = "organizer_only" ORGANIZER_ONLY = "organizer_only"
class CertificationStatus(str, Enum):
NONE = "none"
PENDING = "pending"
CERTIFIED = "certified"
REJECTED = "rejected"
class Marathon(Base): class Marathon(Base):
__tablename__ = "marathons" __tablename__ = "marathons"
@@ -35,12 +42,28 @@ class Marathon(Base):
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True) cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Certification fields
certification_status: Mapped[str] = mapped_column(String(20), default=CertificationStatus.NONE.value)
certification_requested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
certified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
certified_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
certification_rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
# Shop/Consumables settings
allow_skips: Mapped[bool] = mapped_column(Boolean, default=True)
max_skips_per_participant: Mapped[int | None] = mapped_column(Integer, nullable=True)
allow_consumables: Mapped[bool] = mapped_column(Boolean, default=True)
# Relationships # Relationships
creator: Mapped["User"] = relationship( creator: Mapped["User"] = relationship(
"User", "User",
back_populates="created_marathons", back_populates="created_marathons",
foreign_keys=[creator_id] foreign_keys=[creator_id]
) )
certified_by: Mapped["User | None"] = relationship(
"User",
foreign_keys=[certified_by_id]
)
participants: Mapped[list["Participant"]] = relationship( participants: Mapped[list["Participant"]] = relationship(
"Participant", "Participant",
back_populates="marathon", back_populates="marathon",
@@ -61,3 +84,7 @@ class Marathon(Base):
back_populates="marathon", back_populates="marathon",
cascade="all, delete-orphan" cascade="all, delete-orphan"
) )
@property
def is_certified(self) -> bool:
return self.certification_status == CertificationStatus.CERTIFIED.value

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -26,6 +26,14 @@ class Participant(Base):
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Shop: coins earned in this marathon
coins_earned: Mapped[int] = mapped_column(Integer, default=0)
# Shop: consumables state
skips_used: Mapped[int] = mapped_column(Integer, default=0)
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships # Relationships
user: Mapped["User"] = relationship("User", back_populates="participations") user: Mapped["User"] = relationship("User", back_populates="participations")
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants") marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")

View File

@@ -0,0 +1,81 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, Integer, Boolean, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.inventory import UserInventory
class ShopItemType(str, Enum):
FRAME = "frame"
TITLE = "title"
NAME_COLOR = "name_color"
BACKGROUND = "background"
CONSUMABLE = "consumable"
class ItemRarity(str, Enum):
COMMON = "common"
UNCOMMON = "uncommon"
RARE = "rare"
EPIC = "epic"
LEGENDARY = "legendary"
class ConsumableType(str, Enum):
SKIP = "skip"
SHIELD = "shield"
BOOST = "boost"
REROLL = "reroll"
class ShopItem(Base):
__tablename__ = "shop_items"
id: Mapped[int] = mapped_column(primary_key=True)
item_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
price: Mapped[int] = mapped_column(Integer, nullable=False)
rarity: Mapped[str] = mapped_column(String(20), default=ItemRarity.COMMON.value)
asset_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
available_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
available_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
stock_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
stock_remaining: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
inventory_items: Mapped[list["UserInventory"]] = relationship(
"UserInventory",
back_populates="item"
)
@property
def is_available(self) -> bool:
"""Check if item is currently available for purchase"""
if not self.is_active:
return False
now = datetime.utcnow()
if self.available_from and self.available_from > now:
return False
if self.available_until and self.available_until < now:
return False
if self.stock_remaining is not None and self.stock_remaining <= 0:
return False
return True
@property
def is_consumable(self) -> bool:
return self.item_type == ShopItemType.CONSUMABLE.value

View File

@@ -2,9 +2,15 @@ from datetime import datetime
from enum import Enum from enum import Enum
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base from app.core.database import Base
if TYPE_CHECKING:
from app.models.shop import ShopItem
from app.models.inventory import UserInventory
from app.models.coin_transaction import CoinTransaction
class UserRole(str, Enum): class UserRole(str, Enum):
USER = "user" USER = "user"
@@ -39,6 +45,15 @@ class User(Base):
notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True) notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True)
notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True) notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True)
# Shop: coins balance
coins_balance: Mapped[int] = mapped_column(Integer, default=0)
# Shop: equipped cosmetics
equipped_frame_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
equipped_title_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
equipped_name_color_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
equipped_background_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
# Relationships # Relationships
created_marathons: Mapped[list["Marathon"]] = relationship( created_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon", "Marathon",
@@ -65,6 +80,32 @@ class User(Base):
foreign_keys=[banned_by_id] foreign_keys=[banned_by_id]
) )
# Shop relationships
inventory: Mapped[list["UserInventory"]] = relationship(
"UserInventory",
back_populates="user"
)
coin_transactions: Mapped[list["CoinTransaction"]] = relationship(
"CoinTransaction",
back_populates="user"
)
equipped_frame: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_frame_id]
)
equipped_title: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_title_id]
)
equipped_name_color: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_name_color_id]
)
equipped_background: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_background_id]
)
@property @property
def is_admin(self) -> bool: def is_admin(self) -> bool:
return self.role == UserRole.ADMIN.value return self.role == UserRole.ADMIN.value

View File

@@ -104,6 +104,27 @@ from app.schemas.admin import (
LoginResponse, LoginResponse,
DashboardStats, DashboardStats,
) )
from app.schemas.shop import (
ShopItemCreate,
ShopItemUpdate,
ShopItemResponse,
InventoryItemResponse,
PurchaseRequest,
PurchaseResponse,
UseConsumableRequest,
UseConsumableResponse,
EquipItemRequest,
EquipItemResponse,
CoinTransactionResponse,
CoinsBalanceResponse,
AdminCoinsRequest,
UserCosmeticsResponse,
CertificationRequestSchema,
CertificationReviewRequest,
CertificationStatusResponse,
ConsumablesStatusResponse,
)
from app.schemas.user import ShopItemPublic
__all__ = [ __all__ = [
# User # User
@@ -202,4 +223,24 @@ __all__ = [
"TwoFactorVerifyRequest", "TwoFactorVerifyRequest",
"LoginResponse", "LoginResponse",
"DashboardStats", "DashboardStats",
# Shop
"ShopItemCreate",
"ShopItemUpdate",
"ShopItemResponse",
"ShopItemPublic",
"InventoryItemResponse",
"PurchaseRequest",
"PurchaseResponse",
"UseConsumableRequest",
"UseConsumableResponse",
"EquipItemRequest",
"EquipItemResponse",
"CoinTransactionResponse",
"CoinsBalanceResponse",
"AdminCoinsRequest",
"UserCosmeticsResponse",
"CertificationRequestSchema",
"CertificationReviewRequest",
"CertificationStatusResponse",
"ConsumablesStatusResponse",
] ]

View File

@@ -79,12 +79,14 @@ class CompleteResult(BaseModel):
streak_bonus: int streak_bonus: int
total_points: int total_points: int
new_streak: int new_streak: int
coins_earned: int = 0 # Coins earned (only in certified marathons)
class DropResult(BaseModel): class DropResult(BaseModel):
penalty: int penalty: int
total_points: int total_points: int
new_drop_count: int new_drop_count: int
shield_used: bool = False # Whether shield consumable was used to prevent penalty
class EventAssignmentResponse(BaseModel): class EventAssignmentResponse(BaseModel):

View File

@@ -14,6 +14,10 @@ class MarathonCreate(MarathonBase):
duration_days: int = Field(default=30, ge=1, le=365) duration_days: int = Field(default=30, ge=1, le=365)
is_public: bool = False is_public: bool = False
game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$") game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$")
# Shop/Consumables settings
allow_skips: bool = True
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
allow_consumables: bool = True
class MarathonUpdate(BaseModel): class MarathonUpdate(BaseModel):
@@ -23,6 +27,10 @@ class MarathonUpdate(BaseModel):
is_public: bool | None = None is_public: bool | None = None
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$") game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
auto_events_enabled: bool | None = None auto_events_enabled: bool | None = None
# Shop/Consumables settings
allow_skips: bool | None = None
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
allow_consumables: bool | None = None
class ParticipantInfo(BaseModel): class ParticipantInfo(BaseModel):
@@ -32,6 +40,13 @@ class ParticipantInfo(BaseModel):
current_streak: int current_streak: int
drop_count: int drop_count: int
joined_at: datetime joined_at: datetime
# Shop: coins and consumables status
coins_earned: int = 0
skips_used: int = 0
has_shield: bool = False
has_active_boost: bool = False
boost_multiplier: float | None = None
boost_expires_at: datetime | None = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -56,6 +71,13 @@ class MarathonResponse(MarathonBase):
games_count: int games_count: int
created_at: datetime created_at: datetime
my_participation: ParticipantInfo | None = None my_participation: ParticipantInfo | None = None
# Certification
certification_status: str = "none"
is_certified: bool = False
# Shop/Consumables settings
allow_skips: bool = True
max_skips_per_participant: int | None = None
allow_consumables: bool = True
class Config: class Config:
from_attributes = True from_attributes = True
@@ -74,6 +96,8 @@ class MarathonListItem(BaseModel):
participants_count: int participants_count: int
start_date: datetime | None start_date: datetime | None
end_date: datetime | None end_date: datetime | None
# Certification badge
is_certified: bool = False
class Config: class Config:
from_attributes = True from_attributes = True

200
backend/app/schemas/shop.py Normal file
View File

@@ -0,0 +1,200 @@
"""
Pydantic schemas for Shop system
"""
from datetime import datetime
from pydantic import BaseModel, Field
from typing import Any
# === Shop Items ===
class ShopItemBase(BaseModel):
"""Base schema for shop items"""
item_type: str
code: str
name: str
description: str | None = None
price: int
rarity: str = "common"
asset_data: dict | None = None
class ShopItemCreate(ShopItemBase):
"""Schema for creating a shop item (admin)"""
is_active: bool = True
available_from: datetime | None = None
available_until: datetime | None = None
stock_limit: int | None = None
class ShopItemUpdate(BaseModel):
"""Schema for updating a shop item (admin)"""
name: str | None = None
description: str | None = None
price: int | None = Field(None, ge=1)
rarity: str | None = None
asset_data: dict | None = None
is_active: bool | None = None
available_from: datetime | None = None
available_until: datetime | None = None
stock_limit: int | None = None
class ShopItemResponse(ShopItemBase):
"""Schema for shop item response"""
id: int
is_active: bool
available_from: datetime | None
available_until: datetime | None
stock_limit: int | None
stock_remaining: int | None
created_at: datetime
is_available: bool # Computed property
is_owned: bool = False # Set by API based on user
is_equipped: bool = False # Set by API based on user
class Config:
from_attributes = True
# === Inventory ===
class InventoryItemResponse(BaseModel):
"""Schema for user inventory item"""
id: int
item: ShopItemResponse
quantity: int
equipped: bool
purchased_at: datetime
expires_at: datetime | None
class Config:
from_attributes = True
# === Purchases ===
class PurchaseRequest(BaseModel):
"""Schema for purchase request"""
item_id: int
quantity: int = Field(default=1, ge=1, le=10)
class PurchaseResponse(BaseModel):
"""Schema for purchase response"""
success: bool
item: ShopItemResponse
quantity: int
total_cost: int
new_balance: int
message: str
# === Consumables ===
class UseConsumableRequest(BaseModel):
"""Schema for using a consumable"""
item_code: str # 'skip', 'shield', 'boost', 'reroll'
marathon_id: int
assignment_id: int | None = None # Required for skip and reroll
class UseConsumableResponse(BaseModel):
"""Schema for consumable use response"""
success: bool
item_code: str
remaining_quantity: int
effect_description: str
effect_data: dict | None = None
# === Equipment ===
class EquipItemRequest(BaseModel):
"""Schema for equipping an item"""
inventory_id: int
class EquipItemResponse(BaseModel):
"""Schema for equip response"""
success: bool
item_type: str
equipped_item: ShopItemResponse | None
message: str
# === Coins ===
class CoinTransactionResponse(BaseModel):
"""Schema for coin transaction"""
id: int
amount: int
transaction_type: str
description: str | None
reference_type: str | None
reference_id: int | None
created_at: datetime
class Config:
from_attributes = True
class CoinsBalanceResponse(BaseModel):
"""Schema for coins balance with recent transactions"""
balance: int
recent_transactions: list[CoinTransactionResponse]
class AdminCoinsRequest(BaseModel):
"""Schema for admin coin operations"""
amount: int = Field(..., ge=1)
reason: str = Field(..., min_length=1, max_length=500)
# === User Cosmetics ===
class UserCosmeticsResponse(BaseModel):
"""Schema for user's equipped cosmetics"""
frame: ShopItemResponse | None = None
title: ShopItemResponse | None = None
name_color: ShopItemResponse | None = None
background: ShopItemResponse | None = None
# === Certification ===
class CertificationRequestSchema(BaseModel):
"""Schema for requesting marathon certification"""
pass # No fields needed for now
class CertificationReviewRequest(BaseModel):
"""Schema for admin reviewing certification"""
approve: bool
rejection_reason: str | None = Field(None, max_length=1000)
class CertificationStatusResponse(BaseModel):
"""Schema for certification status"""
marathon_id: int
certification_status: str
is_certified: bool
certification_requested_at: datetime | None
certified_at: datetime | None
certified_by_nickname: str | None = None
rejection_reason: str | None = None
# === Consumables Status ===
class ConsumablesStatusResponse(BaseModel):
"""Schema for participant's consumables status in a marathon"""
skips_available: int # From inventory
skips_used: int # In this marathon
skips_remaining: int | None # Based on marathon limit
shields_available: int # From inventory
has_shield: bool # Currently activated
boosts_available: int # From inventory
has_active_boost: bool # Currently activated (one-time for next complete)
boost_multiplier: float | None # 1.5 if boost active
rerolls_available: int # From inventory

View File

@@ -28,6 +28,19 @@ class UserUpdate(BaseModel):
nickname: str | None = Field(None, min_length=2, max_length=50) nickname: str | None = Field(None, min_length=2, max_length=50)
class ShopItemPublic(BaseModel):
"""Minimal shop item info for public display"""
id: int
code: str
name: str
item_type: str
rarity: str
asset_data: dict | None = None
class Config:
from_attributes = True
class UserPublic(UserBase): class UserPublic(UserBase):
"""Public user info visible to other users - minimal data""" """Public user info visible to other users - minimal data"""
id: int id: int
@@ -35,6 +48,11 @@ class UserPublic(UserBase):
role: str = "user" role: str = "user"
telegram_avatar_url: str | None = None # Only TG avatar is public telegram_avatar_url: str | None = None # Only TG avatar is public
created_at: datetime created_at: datetime
# Shop: equipped cosmetics (visible to others)
equipped_frame: ShopItemPublic | None = None
equipped_title: ShopItemPublic | None = None
equipped_name_color: ShopItemPublic | None = None
equipped_background: ShopItemPublic | None = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -51,6 +69,8 @@ class UserPrivate(UserPublic):
notify_events: bool = True notify_events: bool = True
notify_disputes: bool = True notify_disputes: bool = True
notify_moderation: bool = True notify_moderation: bool = True
# Shop: coins balance (only visible to self)
coins_balance: int = 0
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
@@ -82,8 +102,15 @@ class UserProfilePublic(BaseModel):
id: int id: int
nickname: str nickname: str
avatar_url: str | None = None avatar_url: str | None = None
telegram_avatar_url: str | None = None
role: str = "user"
created_at: datetime created_at: datetime
stats: UserStats stats: UserStats
# Equipped cosmetics
equipped_frame: ShopItemPublic | None = None
equipped_title: ShopItemPublic | None = None
equipped_name_color: ShopItemPublic | None = None
equipped_background: ShopItemPublic | None = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -0,0 +1,288 @@
"""
Coins Service - handles all coin-related operations
Coins are earned only in certified marathons and can be spent in the shop.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User, Participant, Marathon, CoinTransaction, CoinTransactionType
from app.models.challenge import Difficulty
class CoinsService:
"""Service for managing coin transactions and balances"""
# Coins awarded per challenge difficulty (only in certified marathons)
CHALLENGE_COINS = {
Difficulty.EASY.value: 5,
Difficulty.MEDIUM.value: 12,
Difficulty.HARD.value: 25,
}
# Coins for playthrough = points * this ratio
PLAYTHROUGH_COIN_RATIO = 0.05 # 5% of points
# Coins awarded for marathon placements
MARATHON_PLACE_COINS = {
1: 100, # 1st place
2: 50, # 2nd place
3: 30, # 3rd place
}
# Bonus coins for Common Enemy event winners
COMMON_ENEMY_BONUS_COINS = {
1: 15, # First to complete
2: 10, # Second
3: 5, # Third
}
async def award_challenge_coins(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
difficulty: str,
assignment_id: int,
) -> int:
"""
Award coins for completing a challenge.
Only awards coins if marathon is certified.
Returns: number of coins awarded (0 if marathon not certified)
"""
if not marathon.is_certified:
return 0
coins = self.CHALLENGE_COINS.get(difficulty, 0)
if coins <= 0:
return 0
# Create transaction
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.CHALLENGE_COMPLETE.value,
reference_type="assignment",
reference_id=assignment_id,
description=f"Challenge completion ({difficulty})",
)
db.add(transaction)
# Update balances
user.coins_balance += coins
participant.coins_earned += coins
return coins
async def award_playthrough_coins(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
points: int,
assignment_id: int,
) -> int:
"""
Award coins for completing a playthrough.
Coins = points * PLAYTHROUGH_COIN_RATIO
Returns: number of coins awarded (0 if marathon not certified)
"""
if not marathon.is_certified:
return 0
coins = int(points * self.PLAYTHROUGH_COIN_RATIO)
if coins <= 0:
return 0
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.PLAYTHROUGH_COMPLETE.value,
reference_type="assignment",
reference_id=assignment_id,
description=f"Playthrough completion ({points} points)",
)
db.add(transaction)
user.coins_balance += coins
participant.coins_earned += coins
return coins
async def award_marathon_place(
self,
db: AsyncSession,
user: User,
marathon: Marathon,
place: int,
) -> int:
"""
Award coins for placing in a marathon (1st, 2nd, 3rd).
Returns: number of coins awarded (0 if not top 3 or not certified)
"""
if not marathon.is_certified:
return 0
coins = self.MARATHON_PLACE_COINS.get(place, 0)
if coins <= 0:
return 0
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.MARATHON_PLACE.value,
reference_type="marathon",
reference_id=marathon.id,
description=f"Marathon #{place} place: {marathon.title}",
)
db.add(transaction)
user.coins_balance += coins
return coins
async def award_common_enemy_bonus(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
rank: int,
event_id: int,
) -> int:
"""
Award bonus coins for Common Enemy event completion.
Returns: number of bonus coins awarded
"""
if not marathon.is_certified:
return 0
coins = self.COMMON_ENEMY_BONUS_COINS.get(rank, 0)
if coins <= 0:
return 0
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.COMMON_ENEMY_BONUS.value,
reference_type="event",
reference_id=event_id,
description=f"Common Enemy #{rank} place",
)
db.add(transaction)
user.coins_balance += coins
participant.coins_earned += coins
return coins
async def spend_coins(
self,
db: AsyncSession,
user: User,
amount: int,
description: str,
reference_type: str | None = None,
reference_id: int | None = None,
) -> bool:
"""
Spend coins (for purchases).
Returns: True if successful, False if insufficient balance
"""
if user.coins_balance < amount:
return False
transaction = CoinTransaction(
user_id=user.id,
amount=-amount, # Negative for spending
transaction_type=CoinTransactionType.PURCHASE.value,
reference_type=reference_type,
reference_id=reference_id,
description=description,
)
db.add(transaction)
user.coins_balance -= amount
return True
async def refund_coins(
self,
db: AsyncSession,
user: User,
amount: int,
description: str,
reference_type: str | None = None,
reference_id: int | None = None,
) -> None:
"""Refund coins to user (for failed purchases, etc.)"""
transaction = CoinTransaction(
user_id=user.id,
amount=amount,
transaction_type=CoinTransactionType.REFUND.value,
reference_type=reference_type,
reference_id=reference_id,
description=description,
)
db.add(transaction)
user.coins_balance += amount
async def admin_grant_coins(
self,
db: AsyncSession,
user: User,
amount: int,
reason: str,
admin_id: int,
) -> None:
"""Admin grants coins to user"""
transaction = CoinTransaction(
user_id=user.id,
amount=amount,
transaction_type=CoinTransactionType.ADMIN_GRANT.value,
reference_type="admin",
reference_id=admin_id,
description=f"Admin grant: {reason}",
)
db.add(transaction)
user.coins_balance += amount
async def admin_deduct_coins(
self,
db: AsyncSession,
user: User,
amount: int,
reason: str,
admin_id: int,
) -> bool:
"""
Admin deducts coins from user.
Returns: True if successful, False if insufficient balance
"""
if user.coins_balance < amount:
return False
transaction = CoinTransaction(
user_id=user.id,
amount=-amount,
transaction_type=CoinTransactionType.ADMIN_DEDUCT.value,
reference_type="admin",
reference_id=admin_id,
description=f"Admin deduction: {reason}",
)
db.add(transaction)
user.coins_balance -= amount
return True
# Singleton instance
coins_service = CoinsService()

View File

@@ -0,0 +1,320 @@
"""
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
"""
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
User, Participant, Marathon, Assignment, AssignmentStatus,
ShopItem, UserInventory, ConsumableUsage, ConsumableType
)
class ConsumablesService:
"""Service for consumable items"""
# Boost settings
BOOST_MULTIPLIER = 1.5
async def use_skip(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
) -> dict:
"""
Use a Skip to bypass current assignment without penalty.
- No streak loss
- No drop penalty
- Assignment marked as dropped but without negative effects
Returns: dict with result info
Raises:
HTTPException: If skips not allowed or limit reached
"""
# Check marathon settings
if not marathon.allow_skips:
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
if marathon.max_skips_per_participant is not None:
if participant.skips_used >= marathon.max_skips_per_participant:
raise HTTPException(
status_code=400,
detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)"
)
# Check assignment is active
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only skip active assignments")
# Consume skip from inventory
item = await self._consume_item(db, user, ConsumableType.SKIP.value)
# Mark assignment as dropped (but without penalty)
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Note: We do NOT increase drop_count or reset streak
# Track skip usage
participant.skips_used += 1
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "skip",
"skipped_without_penalty": True,
},
)
db.add(usage)
return {
"success": True,
"skipped": True,
"penalty": 0,
"streak_preserved": True,
}
async def use_shield(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Activate a Shield - protects from next drop penalty.
- Next drop will not cause point penalty
- Streak is preserved on next drop
Returns: dict with result info
Raises:
HTTPException: If consumables not allowed or shield already active
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_shield:
raise HTTPException(status_code=400, detail="Shield is already active")
# Consume shield from inventory
item = await self._consume_item(db, user, ConsumableType.SHIELD.value)
# Activate shield
participant.has_shield = True
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "shield",
"activated": True,
},
)
db.add(usage)
return {
"success": True,
"shield_activated": True,
}
async def use_boost(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Activate a Boost - multiplies points for NEXT complete only.
- Points for next completed challenge are multiplied by BOOST_MULTIPLIER
- One-time use (consumed on next complete)
Returns: dict with result info
Raises:
HTTPException: If consumables not allowed or boost already active
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_active_boost:
raise HTTPException(status_code=400, detail="Boost is already activated")
# Consume boost from inventory
item = await self._consume_item(db, user, ConsumableType.BOOST.value)
# Activate boost (one-time use)
participant.has_active_boost = True
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "boost",
"multiplier": self.BOOST_MULTIPLIER,
"one_time": True,
},
)
db.add(usage)
return {
"success": True,
"boost_activated": True,
"multiplier": self.BOOST_MULTIPLIER,
}
async def use_reroll(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
) -> dict:
"""
Use a Reroll - discard current assignment and spin again.
- Current assignment is cancelled (not dropped)
- User can spin the wheel again
- No penalty
Returns: dict with result info
Raises:
HTTPException: If consumables not allowed or assignment not active
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only reroll active assignments")
# Consume reroll from inventory
item = await self._consume_item(db, user, ConsumableType.REROLL.value)
# Cancel current assignment
old_challenge_id = assignment.challenge_id
old_game_id = assignment.game_id
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Note: We do NOT increase drop_count (this is a reroll, not a real drop)
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "reroll",
"rerolled_from_challenge_id": old_challenge_id,
"rerolled_from_game_id": old_game_id,
},
)
db.add(usage)
return {
"success": True,
"rerolled": True,
"can_spin_again": True,
}
async def _consume_item(
self,
db: AsyncSession,
user: User,
item_code: str,
) -> ShopItem:
"""
Consume 1 unit of a consumable from user's inventory.
Returns: The consumed ShopItem
Raises:
HTTPException: If user doesn't have the item
"""
result = await db.execute(
select(UserInventory)
.options(selectinload(UserInventory.item))
.join(ShopItem)
.where(
UserInventory.user_id == user.id,
ShopItem.code == item_code,
UserInventory.quantity > 0,
)
)
inv_item = result.scalar_one_or_none()
if not inv_item:
raise HTTPException(
status_code=400,
detail=f"You don't have any {item_code} in your inventory"
)
# Decrease quantity
inv_item.quantity -= 1
return inv_item.item
async def get_consumable_count(
self,
db: AsyncSession,
user_id: int,
item_code: str,
) -> int:
"""Get how many of a consumable user has"""
result = await db.execute(
select(UserInventory.quantity)
.join(ShopItem)
.where(
UserInventory.user_id == user_id,
ShopItem.code == item_code,
)
)
quantity = result.scalar_one_or_none()
return quantity or 0
def consume_shield(self, participant: Participant) -> bool:
"""
Consume shield when dropping (called from wheel.py).
Returns: True if shield was consumed, False otherwise
"""
if participant.has_shield:
participant.has_shield = False
return True
return False
def consume_boost_on_complete(self, participant: Participant) -> float:
"""
Consume boost when completing assignment (called from wheel.py).
One-time use - boost is consumed after single complete.
Returns: Multiplier value (BOOST_MULTIPLIER if boost was active, 1.0 otherwise)
"""
if participant.has_active_boost:
participant.has_active_boost = False
return self.BOOST_MULTIPLIER
return 1.0
# Singleton instance
consumables_service = ConsumablesService()

View File

@@ -0,0 +1,297 @@
"""
Shop Service - handles shop items, purchases, and inventory management
"""
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import User, ShopItem, UserInventory, ShopItemType
from app.services.coins import coins_service
class ShopService:
"""Service for shop operations"""
async def get_available_items(
self,
db: AsyncSession,
item_type: str | None = None,
include_unavailable: bool = False,
) -> list[ShopItem]:
"""
Get list of shop items.
Args:
item_type: Filter by item type (frame, title, etc.)
include_unavailable: Include inactive/out of stock items
"""
query = select(ShopItem)
if item_type:
query = query.where(ShopItem.item_type == item_type)
if not include_unavailable:
now = datetime.utcnow()
query = query.where(
ShopItem.is_active == True,
(ShopItem.available_from.is_(None)) | (ShopItem.available_from <= now),
(ShopItem.available_until.is_(None)) | (ShopItem.available_until >= now),
(ShopItem.stock_remaining.is_(None)) | (ShopItem.stock_remaining > 0),
)
query = query.order_by(ShopItem.price.asc())
result = await db.execute(query)
return list(result.scalars().all())
async def get_item_by_id(self, db: AsyncSession, item_id: int) -> ShopItem | None:
"""Get shop item by ID"""
result = await db.execute(select(ShopItem).where(ShopItem.id == item_id))
return result.scalar_one_or_none()
async def get_item_by_code(self, db: AsyncSession, code: str) -> ShopItem | None:
"""Get shop item by code"""
result = await db.execute(select(ShopItem).where(ShopItem.code == code))
return result.scalar_one_or_none()
async def purchase_item(
self,
db: AsyncSession,
user: User,
item_id: int,
quantity: int = 1,
) -> tuple[UserInventory, int]:
"""
Purchase an item from the shop.
Args:
user: The purchasing user
item_id: ID of item to purchase
quantity: Number to purchase (only for consumables)
Returns:
Tuple of (inventory item, total cost)
Raises:
HTTPException: If item not found, not available, or insufficient funds
"""
# Get item
item = await self.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
# Check availability
if not item.is_available:
raise HTTPException(status_code=400, detail="Item is not available")
# For non-consumables, quantity is always 1
if item.item_type != ShopItemType.CONSUMABLE.value:
quantity = 1
# Check if already owned
existing = await db.execute(
select(UserInventory).where(
UserInventory.user_id == user.id,
UserInventory.item_id == item.id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="You already own this item")
# Check stock
if item.stock_remaining is not None and item.stock_remaining < quantity:
raise HTTPException(status_code=400, detail="Not enough stock available")
# Calculate total cost
total_cost = item.price * quantity
# Check balance
if user.coins_balance < total_cost:
raise HTTPException(status_code=400, detail="Not enough coins")
# Deduct coins
success = await coins_service.spend_coins(
db, user, total_cost,
f"Purchase: {item.name} x{quantity}",
"shop_item", item.id,
)
if not success:
raise HTTPException(status_code=400, detail="Payment failed")
# Add to inventory
if item.item_type == ShopItemType.CONSUMABLE.value:
# For consumables, increase quantity if already exists
existing_result = await db.execute(
select(UserInventory).where(
UserInventory.user_id == user.id,
UserInventory.item_id == item.id,
)
)
inv_item = existing_result.scalar_one_or_none()
if inv_item:
inv_item.quantity += quantity
else:
inv_item = UserInventory(
user_id=user.id,
item_id=item.id,
quantity=quantity,
)
db.add(inv_item)
else:
# For cosmetics, create new inventory entry
inv_item = UserInventory(
user_id=user.id,
item_id=item.id,
quantity=1,
)
db.add(inv_item)
# Decrease stock if limited
if item.stock_remaining is not None:
item.stock_remaining -= quantity
await db.flush()
return inv_item, total_cost
async def get_user_inventory(
self,
db: AsyncSession,
user_id: int,
item_type: str | None = None,
) -> list[UserInventory]:
"""Get user's inventory"""
query = (
select(UserInventory)
.options(selectinload(UserInventory.item))
.where(UserInventory.user_id == user_id)
)
if item_type:
query = query.join(ShopItem).where(ShopItem.item_type == item_type)
# Exclude empty consumables
query = query.where(UserInventory.quantity > 0)
result = await db.execute(query)
return list(result.scalars().all())
async def get_inventory_item(
self,
db: AsyncSession,
user_id: int,
inventory_id: int,
) -> UserInventory | None:
"""Get specific inventory item"""
result = await db.execute(
select(UserInventory)
.options(selectinload(UserInventory.item))
.where(
UserInventory.id == inventory_id,
UserInventory.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def equip_item(
self,
db: AsyncSession,
user: User,
inventory_id: int,
) -> ShopItem:
"""
Equip a cosmetic item from inventory.
Returns: The equipped item
Raises:
HTTPException: If item not found or is a consumable
"""
# Get inventory item
inv_item = await self.get_inventory_item(db, user.id, inventory_id)
if not inv_item:
raise HTTPException(status_code=404, detail="Item not found in inventory")
item = inv_item.item
if item.item_type == ShopItemType.CONSUMABLE.value:
raise HTTPException(status_code=400, detail="Cannot equip consumables")
# Unequip current item of same type
await db.execute(
update(UserInventory)
.where(
UserInventory.user_id == user.id,
UserInventory.equipped == True,
UserInventory.item_id.in_(
select(ShopItem.id).where(ShopItem.item_type == item.item_type)
),
)
.values(equipped=False)
)
# Equip new item
inv_item.equipped = True
# Update user's equipped_*_id
if item.item_type == ShopItemType.FRAME.value:
user.equipped_frame_id = item.id
elif item.item_type == ShopItemType.TITLE.value:
user.equipped_title_id = item.id
elif item.item_type == ShopItemType.NAME_COLOR.value:
user.equipped_name_color_id = item.id
elif item.item_type == ShopItemType.BACKGROUND.value:
user.equipped_background_id = item.id
return item
async def unequip_item(
self,
db: AsyncSession,
user: User,
item_type: str,
) -> None:
"""Unequip item of specified type"""
# Unequip from inventory
await db.execute(
update(UserInventory)
.where(
UserInventory.user_id == user.id,
UserInventory.equipped == True,
UserInventory.item_id.in_(
select(ShopItem.id).where(ShopItem.item_type == item_type)
),
)
.values(equipped=False)
)
# Clear user's equipped_*_id
if item_type == ShopItemType.FRAME.value:
user.equipped_frame_id = None
elif item_type == ShopItemType.TITLE.value:
user.equipped_title_id = None
elif item_type == ShopItemType.NAME_COLOR.value:
user.equipped_name_color_id = None
elif item_type == ShopItemType.BACKGROUND.value:
user.equipped_background_id = None
async def check_user_owns_item(
self,
db: AsyncSession,
user_id: int,
item_id: int,
) -> bool:
"""Check if user owns an item"""
result = await db.execute(
select(UserInventory).where(
UserInventory.user_id == user_id,
UserInventory.item_id == item_id,
UserInventory.quantity > 0,
)
)
return result.scalar_one_or_none() is not None
# Singleton instance
shop_service = ShopService()

View File

@@ -25,6 +25,8 @@ import { StaticContentPage } from '@/pages/StaticContentPage'
import { NotFoundPage } from '@/pages/NotFoundPage' import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage' import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage' import { ServerErrorPage } from '@/pages/ServerErrorPage'
import { ShopPage } from '@/pages/ShopPage'
import { InventoryPage } from '@/pages/InventoryPage'
// Admin Pages // Admin Pages
import { import {
@@ -187,6 +189,25 @@ function App() {
<Route path="users/:id" element={<UserProfilePage />} /> <Route path="users/:id" element={<UserProfilePage />} />
{/* Shop routes */}
<Route
path="shop"
element={
<ProtectedRoute>
<ShopPage />
</ProtectedRoute>
}
/>
<Route
path="inventory"
element={
<ProtectedRoute>
<InventoryPage />
</ProtectedRoute>
}
/>
{/* Easter egg - 418 I'm a teapot */} {/* Easter egg - 418 I'm a teapot */}
<Route path="418" element={<TeapotPage />} /> <Route path="418" element={<TeapotPage />} />
<Route path="teapot" element={<TeapotPage />} /> <Route path="teapot" element={<TeapotPage />} />

View File

@@ -76,6 +76,14 @@ export const adminApi = {
await client.post(`/admin/marathons/${id}/force-finish`) await client.post(`/admin/marathons/${id}/force-finish`)
}, },
certifyMarathon: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/certify`)
},
revokeCertification: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/revoke-certification`)
},
// Stats // Stats
getStats: async (): Promise<PlatformStats> => { getStats: async (): Promise<PlatformStats> => {
const response = await client.get<PlatformStats>('/admin/stats') const response = await client.get<PlatformStats>('/admin/stats')

View File

@@ -9,3 +9,4 @@ export { challengesApi } from './challenges'
export { assignmentsApi } from './assignments' export { assignmentsApi } from './assignments'
export { usersApi } from './users' export { usersApi } from './users'
export { telegramApi } from './telegram' export { telegramApi } from './telegram'
export { shopApi } from './shop'

102
frontend/src/api/shop.ts Normal file
View File

@@ -0,0 +1,102 @@
import client from './client'
import type {
ShopItem,
ShopItemType,
InventoryItem,
PurchaseResponse,
UseConsumableRequest,
UseConsumableResponse,
CoinsBalance,
CoinTransaction,
ConsumablesStatus,
UserCosmetics,
} from '@/types'
export const shopApi = {
// === Каталог товаров ===
// Получить список товаров
getItems: async (itemType?: ShopItemType): Promise<ShopItem[]> => {
const params = itemType ? { item_type: itemType } : {}
const response = await client.get<ShopItem[]>('/shop/items', { params })
return response.data
},
// Получить товар по ID
getItem: async (itemId: number): Promise<ShopItem> => {
const response = await client.get<ShopItem>(`/shop/items/${itemId}`)
return response.data
},
// === Покупки ===
// Купить товар
purchase: async (itemId: number, quantity: number = 1): Promise<PurchaseResponse> => {
const response = await client.post<PurchaseResponse>('/shop/purchase', {
item_id: itemId,
quantity,
})
return response.data
},
// === Инвентарь ===
// Получить инвентарь пользователя
getInventory: async (itemType?: ShopItemType): Promise<InventoryItem[]> => {
const params = itemType ? { item_type: itemType } : {}
const response = await client.get<InventoryItem[]>('/shop/inventory', { params })
return response.data
},
// === Экипировка ===
// Экипировать предмет
equip: async (inventoryId: number): Promise<{ success: boolean; message: string }> => {
const response = await client.post<{ success: boolean; message: string }>('/shop/equip', {
inventory_id: inventoryId,
})
return response.data
},
// Снять предмет
unequip: async (itemType: ShopItemType): Promise<{ success: boolean; message: string }> => {
const response = await client.post<{ success: boolean; message: string }>(`/shop/unequip/${itemType}`)
return response.data
},
// Получить экипированную косметику
getCosmetics: async (): Promise<UserCosmetics> => {
const response = await client.get<UserCosmetics>('/shop/cosmetics')
return response.data
},
// === Расходуемые ===
// Использовать расходуемый предмет
useConsumable: async (data: UseConsumableRequest): Promise<UseConsumableResponse> => {
const response = await client.post<UseConsumableResponse>('/shop/use', data)
return response.data
},
// Получить статус расходуемых в марафоне
getConsumablesStatus: async (marathonId: number): Promise<ConsumablesStatus> => {
const response = await client.get<ConsumablesStatus>(`/shop/consumables/${marathonId}`)
return response.data
},
// === Монеты ===
// Получить баланс и последние транзакции
getBalance: async (): Promise<CoinsBalance> => {
const response = await client.get<CoinsBalance>('/shop/balance')
return response.data
},
// Получить историю транзакций
getTransactions: async (limit: number = 50, offset: number = 0): Promise<CoinTransaction[]> => {
const response = await client.get<CoinTransaction[]>('/shop/transactions', {
params: { limit, offset },
})
return response.data
},
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react' import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { feedApi } from '@/api' import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types' import type { Activity, ActivityType, ShopItemPublic, User } from '@/types'
import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react' import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
import { UserAvatar } from '@/components/ui' import { UserAvatar } from '@/components/ui'
import { import {
@@ -12,6 +12,77 @@ import {
formatActivityMessage, formatActivityMessage,
} from '@/utils/activity' } from '@/utils/activity'
// Helper to get name color styles and animation class
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
if (!nameColor?.asset_data) return { styles: {}, className: '' }
const data = nameColor.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string }
if (data.style === 'gradient' && data.gradient) {
return {
styles: {
background: `linear-gradient(90deg, ${data.gradient.join(', ')})`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
},
className: '',
}
}
if (data.style === 'animated') {
return {
styles: {
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
backgroundSize: '400% 100%',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
},
className: 'animate-rainbow-rotate',
}
}
if (data.style === 'solid' && data.color) {
return { styles: { color: data.color }, className: '' }
}
return { styles: {}, className: '' }
}
// Helper to get title data
function getTitleData(title: ShopItemPublic | null | undefined): { text: string; color: string } | null {
if (!title?.asset_data) return null
const data = title.asset_data as { text?: string; color?: string }
if (!data.text) return null
return {
text: data.text,
color: data.color || '#ffffff',
}
}
// Styled nickname component for activity feed
function StyledNickname({ user }: { user: User }) {
const nameColorData = getNameColorData(user.equipped_name_color)
const titleData = getTitleData(user.equipped_title)
return (
<>
<span className={nameColorData.className} style={nameColorData.styles}>{user.nickname}</span>
{titleData && (
<span
className="ml-1.5 px-1 py-0.5 text-[10px] font-medium rounded bg-dark-700/50"
style={{ color: titleData.color }}
>
{titleData.text}
</span>
)}
</>
)
}
interface ActivityFeedProps { interface ActivityFeedProps {
marathonId: number marathonId: number
className?: string className?: string
@@ -273,6 +344,8 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
hasAvatar={!!activity.user.avatar_url} hasAvatar={!!activity.user.avatar_url}
nickname={activity.user.nickname} nickname={activity.user.nickname}
size="sm" size="sm"
frame={activity.user.equipped_frame}
telegramAvatarUrl={activity.user.telegram_avatar_url}
/> />
{/* Activity type badge */} {/* Activity type badge */}
<div className={` <div className={`
@@ -292,10 +365,10 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Link <Link
to={`/users/${activity.user.id}`} to={`/users/${activity.user.id}`}
className="text-sm font-semibold text-white hover:text-neon-400 transition-colors" className="text-sm font-semibold hover:text-neon-400 transition-colors"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{activity.user.nickname} <StyledNickname user={activity.user} />
</Link> </Link>
<span className="text-xs text-gray-600"> <span className="text-xs text-gray-600">
{formatRelativeTime(activity.created_at)} {formatRelativeTime(activity.created_at)}

View File

@@ -1,12 +1,13 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom' import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield } from 'lucide-react' import { useShopStore } from '@/store/shop'
import { TelegramLink } from '@/components/TelegramLink' import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield, ShoppingBag, Coins, Backpack } from 'lucide-react'
import { clsx } from 'clsx' import { clsx } from 'clsx'
export function Layout() { export function Layout() {
const { user, isAuthenticated, logout } = useAuthStore() const { user, isAuthenticated, logout } = useAuthStore()
const { balance, loadBalance } = useShopStore()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [isScrolled, setIsScrolled] = useState(false) const [isScrolled, setIsScrolled] = useState(false)
@@ -20,6 +21,13 @@ export function Layout() {
return () => window.removeEventListener('scroll', handleScroll) return () => window.removeEventListener('scroll', handleScroll)
}, []) }, [])
// Load balance when authenticated
useEffect(() => {
if (isAuthenticated) {
loadBalance()
}
}, [isAuthenticated, loadBalance])
// Close mobile menu on route change // Close mobile menu on route change
useEffect(() => { useEffect(() => {
setIsMobileMenuOpen(false) setIsMobileMenuOpen(false)
@@ -74,6 +82,19 @@ export function Layout() {
<span>Марафоны</span> <span>Марафоны</span>
</Link> </Link>
<Link
to="/shop"
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
isActiveLink('/shop')
? 'text-yellow-400 bg-yellow-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<ShoppingBag className="w-5 h-5" />
<span>Магазин</span>
</Link>
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<Link <Link
to="/admin" to="/admin"
@@ -89,7 +110,7 @@ export function Layout() {
</Link> </Link>
)} )}
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600"> <div className="flex items-center gap-2 ml-2 pl-4 border-l border-dark-600">
<Link <Link
to="/profile" to="/profile"
className={clsx( className={clsx(
@@ -101,9 +122,24 @@ export function Layout() {
> >
<User className="w-5 h-5" /> <User className="w-5 h-5" />
<span>{user?.nickname}</span> <span>{user?.nickname}</span>
<span className="flex items-center gap-1 text-yellow-400 ml-1">
<Coins className="w-4 h-4" />
<span className="font-medium">{balance}</span>
</span>
</Link> </Link>
<TelegramLink /> <Link
to="/inventory"
className={clsx(
'p-2 rounded-lg transition-all duration-200',
isActiveLink('/inventory')
? 'text-yellow-400 bg-yellow-500/10'
: 'text-gray-400 hover:text-yellow-400 hover:bg-yellow-500/10'
)}
title="Инвентарь"
>
<Backpack className="w-5 h-5" />
</Link>
<button <button
onClick={handleLogout} onClick={handleLogout}
@@ -159,6 +195,18 @@ export function Layout() {
<Trophy className="w-5 h-5" /> <Trophy className="w-5 h-5" />
<span>Марафоны</span> <span>Марафоны</span>
</Link> </Link>
<Link
to="/shop"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
isActiveLink('/shop')
? 'text-yellow-400 bg-yellow-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<ShoppingBag className="w-5 h-5" />
<span>Магазин</span>
</Link>
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<Link <Link
to="/admin" to="/admin"
@@ -184,6 +232,22 @@ export function Layout() {
> >
<User className="w-5 h-5" /> <User className="w-5 h-5" />
<span>{user?.nickname}</span> <span>{user?.nickname}</span>
<span className="flex items-center gap-1 text-yellow-400 ml-auto">
<Coins className="w-4 h-4" />
<span className="font-medium">{balance}</span>
</span>
</Link>
<Link
to="/inventory"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
isActiveLink('/inventory')
? 'text-yellow-400 bg-yellow-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<Backpack className="w-5 h-5" />
<span>Инвентарь</span>
</Link> </Link>
<div className="pt-2 border-t border-dark-600"> <div className="pt-2 border-t border-dark-600">
<button <button

View File

@@ -1,5 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { usersApi } from '@/api' import { usersApi } from '@/api'
import { User } from 'lucide-react'
import clsx from 'clsx'
import type { ShopItemPublic } from '@/types'
// Глобальный кэш для blob URL аватарок // Глобальный кэш для blob URL аватарок
const avatarCache = new Map<number, string>() const avatarCache = new Map<number, string>()
@@ -10,18 +13,77 @@ interface UserAvatarProps {
userId: number userId: number
hasAvatar: boolean // Есть ли у пользователя avatar_url hasAvatar: boolean // Есть ли у пользователя avatar_url
nickname: string nickname: string
size?: 'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
className?: string className?: string
version?: number // Для принудительного обновления при смене аватара version?: number // Для принудительного обновления при смене аватара
frame?: ShopItemPublic | null // Equipped frame cosmetic
telegramAvatarUrl?: string | null // Fallback to telegram avatar
} }
const sizeClasses = { const sizeClasses = {
xs: 'w-6 h-6 text-[8px]',
sm: 'w-8 h-8 text-xs', sm: 'w-8 h-8 text-xs',
md: 'w-12 h-12 text-sm', md: 'w-12 h-12 text-sm',
lg: 'w-24 h-24 text-xl', lg: 'w-16 h-16 text-base',
xl: 'w-24 h-24 text-xl',
} }
export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '', version = 0 }: UserAvatarProps) { const framePadding = {
xs: 2,
sm: 2,
md: 3,
lg: 4,
xl: 5,
}
// Get frame styles from asset_data
function getFrameStyles(frame: ShopItemPublic | null | undefined): React.CSSProperties {
if (!frame?.asset_data) return {}
const data = frame.asset_data as {
border_color?: string
gradient?: string[]
glow_color?: string
}
const styles: React.CSSProperties = {}
if (data.gradient && data.gradient.length > 0) {
styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
styles.backgroundSize = '400% 400%'
} else if (data.border_color) {
styles.background = data.border_color
}
if (data.glow_color) {
styles.boxShadow = `0 0 12px ${data.glow_color}, 0 0 24px ${data.glow_color}40`
}
return styles
}
// Get frame animation class
function getFrameAnimation(frame: ShopItemPublic | null | undefined): string {
if (!frame?.asset_data) return ''
const data = frame.asset_data as { animation?: string }
if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
return ''
}
export function UserAvatar({
userId,
hasAvatar,
nickname,
size = 'md',
className = '',
version = 0,
frame,
telegramAvatarUrl
}: UserAvatarProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null) const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
@@ -74,25 +136,54 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
}, [userId, hasAvatar, version]) }, [userId, hasAvatar, version])
const sizeClass = sizeClasses[size] const sizeClass = sizeClasses[size]
const displayUrl = (blobUrl && !failed) ? blobUrl : telegramAvatarUrl
if (blobUrl && !failed) { // Avatar content
return ( const avatarContent = displayUrl ? (
<img <img
src={blobUrl} src={displayUrl}
alt={nickname} alt={nickname}
className={`rounded-full object-cover ${sizeClass} ${className}`} className="w-full h-full rounded-full object-cover"
/> />
) ) : (
} <div className="w-full h-full rounded-full bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center">
// Fallback - первая буква никнейма
return (
<div className={`rounded-full bg-gray-700 flex items-center justify-center ${sizeClass} ${className}`}>
<span className="text-gray-400 font-medium"> <span className="text-gray-400 font-medium">
{nickname.charAt(0).toUpperCase()} {nickname.charAt(0).toUpperCase()}
</span> </span>
</div> </div>
) )
// If no frame, return simple avatar
if (!frame) {
return (
<div className={clsx('rounded-full overflow-hidden bg-dark-700', sizeClass, className)}>
{avatarContent}
</div>
)
}
// With frame - wrap avatar in frame container
const padding = framePadding[size]
return (
<div
className={clsx(
'rounded-full flex items-center justify-center',
getFrameAnimation(frame),
className
)}
style={{
...getFrameStyles(frame),
padding: `${padding}px`,
width: 'fit-content',
height: 'fit-content',
}}
>
<div className={clsx('rounded-full overflow-hidden bg-dark-700', sizeClass)}>
{avatarContent}
</div>
</div>
)
} }
// Функция для очистки кэша конкретного пользователя (после загрузки нового аватара) // Функция для очистки кэша конкретного пользователя (после загрузки нового аватара)
@@ -105,3 +196,55 @@ export function clearAvatarCache(userId: number) {
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера // Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
needsCacheBust.add(userId) needsCacheBust.add(userId)
} }
// FramePreview component for shop - shows frame without avatar
interface FramePreviewProps {
frame: ShopItemPublic | null
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
className?: string
}
const previewSizes = {
xs: 'w-8 h-8',
sm: 'w-10 h-10',
md: 'w-14 h-14',
lg: 'w-20 h-20',
xl: 'w-28 h-28',
}
export function FramePreview({ frame, size = 'md', className }: FramePreviewProps) {
if (!frame?.asset_data) {
return (
<div className={clsx(
previewSizes[size],
'rounded-lg border-4 border-gray-600 flex items-center justify-center bg-dark-800',
className
)}>
<User className="w-1/2 h-1/2 text-gray-500" />
</div>
)
}
const padding = framePadding[size]
return (
<div
className={clsx(
'rounded-lg flex items-center justify-center',
getFrameAnimation(frame),
className
)}
style={{
...getFrameStyles(frame),
padding: `${padding}px`,
}}
>
<div className={clsx(
previewSizes[size],
'rounded-md bg-dark-800/90 flex items-center justify-center'
)}>
<User className="w-1/2 h-1/2 text-gray-400" />
</div>
</div>
)
}

View File

@@ -3,7 +3,7 @@ export { Input } from './Input'
export { Card, CardHeader, CardTitle, CardContent } from './Card' export { Card, CardHeader, CardTitle, CardContent } from './Card'
export { ToastContainer } from './Toast' export { ToastContainer } from './Toast'
export { ConfirmModal } from './ConfirmModal' export { ConfirmModal } from './ConfirmModal'
export { UserAvatar, clearAvatarCache } from './UserAvatar' export { UserAvatar, clearAvatarCache, FramePreview } from './UserAvatar'
// New design system components // New design system components
export { GlitchText, GlitchHeading } from './GlitchText' export { GlitchText, GlitchHeading } from './GlitchText'

View File

@@ -571,6 +571,125 @@ input:-webkit-autofill:active {
@apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900; @apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900;
} }
/* ========================================
Frame Animations (Shop cosmetics)
======================================== */
/* Fire pulse animation */
@keyframes fire-pulse {
0%, 100% {
background-size: 200% 200%;
background-position: 0% 50%;
filter: brightness(1);
}
50% {
background-size: 220% 220%;
background-position: 100% 50%;
filter: brightness(1.2);
}
}
.animate-fire-pulse {
animation: fire-pulse 2s ease-in-out infinite;
}
/* Rainbow rotate animation */
@keyframes rainbow-rotate {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animate-rainbow-rotate {
animation: rainbow-rotate 3s linear infinite;
background-size: 400% 400%;
}
/* Rainbow text color shift */
@keyframes rainbow-shift {
0% { color: #FF0000; }
16% { color: #FF7F00; }
33% { color: #FFFF00; }
50% { color: #00FF00; }
66% { color: #0000FF; }
83% { color: #9400D3; }
100% { color: #FF0000; }
}
.animate-rainbow-shift {
animation: rainbow-shift 4s linear infinite;
}
/* Fire particles background animation */
@keyframes fire-particles {
0%, 100% {
background-position: 0% 100%;
}
50% {
background-position: 100% 0%;
}
}
.animate-fire-particles {
animation: fire-particles 3s ease-in-out infinite;
}
/* Star twinkle animation */
@keyframes twinkle {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.animate-twinkle {
animation: twinkle 2s ease-in-out infinite;
}
/* Stars background with multiple twinkling layers */
.bg-stars-animated {
position: relative;
background: linear-gradient(135deg, #0d1b2a 0%, #1b263b 50%, #0d1b2a 100%);
}
.bg-stars-animated::before,
.bg-stars-animated::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
}
.bg-stars-animated::before {
background:
radial-gradient(2px 2px at 20px 30px, #fff, transparent),
radial-gradient(2px 2px at 80px 60px, rgba(255,255,255,0.9), transparent),
radial-gradient(1px 1px at 130px 40px, #fff, transparent),
radial-gradient(2px 2px at 180px 90px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 50px 100px, #fff, transparent),
radial-gradient(1.5px 1.5px at 220px 20px, rgba(255,255,255,0.9), transparent);
background-size: 250px 150px;
animation: twinkle 3s ease-in-out infinite;
}
.bg-stars-animated::after {
background:
radial-gradient(1px 1px at 40px 20px, rgba(255,255,255,0.7), transparent),
radial-gradient(2px 2px at 100px 80px, #fff, transparent),
radial-gradient(1.5px 1.5px at 160px 30px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 200px 70px, #fff, transparent),
radial-gradient(2px 2px at 70px 110px, rgba(255,255,255,0.9), transparent);
background-size: 220px 140px;
animation: twinkle 4s ease-in-out infinite 1s;
}
/* Reduced motion support */ /* Reduced motion support */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,

View File

@@ -0,0 +1,387 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useShopStore } from '@/store/shop'
import { useToast } from '@/store/toast'
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
import {
Loader2, Package, ShoppingBag, Coins, Check,
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward
} from 'lucide-react'
import type { InventoryItem, ShopItemType } from '@/types'
import { RARITY_COLORS, RARITY_NAMES, ITEM_TYPE_NAMES } from '@/types'
import clsx from 'clsx'
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
skip: <SkipForward className="w-8 h-8" />,
shield: <Shield className="w-8 h-8" />,
boost: <Zap className="w-8 h-8" />,
reroll: <RefreshCw className="w-8 h-8" />,
}
interface InventoryItemCardProps {
inventoryItem: InventoryItem
onEquip: (inventoryId: number) => void
onUnequip: (itemType: ShopItemType) => void
isProcessing: boolean
}
function InventoryItemCard({ inventoryItem, onEquip, onUnequip, isProcessing }: InventoryItemCardProps) {
const { item, quantity, equipped } = inventoryItem
const rarityColors = RARITY_COLORS[item.rarity]
const getItemPreview = () => {
if (item.item_type === 'consumable') {
return CONSUMABLE_ICONS[item.code] || <Package className="w-8 h-8" />
}
// Name color preview - handles solid, gradient, animated
if (item.item_type === 'name_color') {
const data = item.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string } | null
if (data?.style === 'gradient' && data.gradient) {
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600"
style={{ background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }}
/>
)
}
if (data?.style === 'animated') {
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600 animate-rainbow-rotate"
style={{
background: 'linear-gradient(135deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
backgroundSize: '400% 400%'
}}
/>
)
}
const solidColor = data?.color || '#ffffff'
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600"
style={{ backgroundColor: solidColor }}
/>
)
}
// Background preview
if (item.item_type === 'background') {
const data = item.asset_data as { type?: string; color?: string; gradient?: string[]; pattern?: string; animation?: string } | null
let bgStyle: React.CSSProperties = {}
let animClass = ''
if (data?.type === 'solid' && data.color) {
bgStyle = { backgroundColor: data.color }
} else if (data?.type === 'gradient' && data.gradient) {
bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
} else if (data?.type === 'pattern') {
if (data.pattern === 'stars') {
bgStyle = {
background: `
radial-gradient(1px 1px at 10px 10px, #fff, transparent),
radial-gradient(1px 1px at 30px 25px, rgba(255,255,255,0.8), transparent),
linear-gradient(135deg, #0d1b2a 0%, #1b263b 100%)
`,
backgroundSize: '50px 35px, 50px 35px, 100% 100%'
}
animClass = 'animate-twinkle'
} else if (data.pattern === 'gaming-icons') {
bgStyle = {
background: `
linear-gradient(45deg, rgba(34,211,238,0.2) 25%, transparent 25%),
linear-gradient(-45deg, rgba(168,85,247,0.2) 25%, transparent 25%),
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
`,
backgroundSize: '15px 15px, 15px 15px, 100% 100%'
}
}
} else if (data?.type === 'animated' && data.animation === 'fire-particles') {
bgStyle = {
background: `
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.5) 0%, transparent 50%),
linear-gradient(to top, #1a0a00 0%, #0d0d0d 100%)
`
}
animClass = 'animate-fire-pulse'
}
return (
<div
className={`w-14 h-10 rounded-lg border-2 border-dark-600 ${animClass}`}
style={bgStyle}
/>
)
}
if (item.item_type === 'frame') {
return <FramePreview frame={item} size="lg" />
}
if (item.item_type === 'title' && item.asset_data?.text) {
return (
<span
className="text-lg font-bold"
style={{ color: (item.asset_data.color as string) || '#ffffff' }}
>
{item.asset_data.text as string}
</span>
)
}
return <Package className="w-8 h-8 text-gray-400" />
}
const isCosmetic = item.item_type !== 'consumable'
return (
<GlassCard
className={clsx(
'p-4 border transition-all duration-300',
equipped ? 'border-neon-500 bg-neon-500/10' : rarityColors.border
)}
>
{/* Equipped badge */}
{equipped && (
<div className="flex items-center gap-1 text-neon-400 text-xs font-medium mb-2">
<Check className="w-3 h-3" />
Надето
</div>
)}
{/* Rarity badge */}
{!equipped && (
<div className={clsx('text-xs font-medium mb-2', rarityColors.text)}>
{RARITY_NAMES[item.rarity]}
</div>
)}
{/* Item preview */}
<div className="flex justify-center items-center h-16 mb-3">
{getItemPreview()}
</div>
{/* Item info */}
<h3 className="text-white font-semibold text-center mb-1">{item.name}</h3>
<p className="text-gray-500 text-xs text-center mb-1">
{ITEM_TYPE_NAMES[item.item_type]}
</p>
{/* Quantity for consumables */}
{item.item_type === 'consumable' && (
<p className="text-yellow-400 text-sm text-center mb-3 font-medium">
x{quantity}
</p>
)}
{/* Action button */}
{isCosmetic && (
<div className="mt-3">
{equipped ? (
<NeonButton
variant="secondary"
size="sm"
className="w-full"
onClick={() => onUnequip(item.item_type)}
disabled={isProcessing}
>
{isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Снять'}
</NeonButton>
) : (
<NeonButton
size="sm"
className="w-full"
onClick={() => onEquip(inventoryItem.id)}
disabled={isProcessing}
>
{isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Надеть'}
</NeonButton>
)}
</div>
)}
{/* Consumable info */}
{item.item_type === 'consumable' && (
<p className="text-gray-500 text-xs text-center mt-2">
Используйте в марафоне
</p>
)}
</GlassCard>
)
}
export function InventoryPage() {
const { inventory, balance, isLoading, loadInventory, loadBalance, equip, unequip, clearError, error } = useShopStore()
const toast = useToast()
const [activeTab, setActiveTab] = useState<ShopItemType | 'all'>('all')
const [processingId, setProcessingId] = useState<number | null>(null)
useEffect(() => {
loadBalance()
loadInventory()
}, [loadBalance, loadInventory])
useEffect(() => {
if (error) {
toast.error(error)
clearError()
}
}, [error, toast, clearError])
const handleEquip = async (inventoryId: number) => {
setProcessingId(inventoryId)
const success = await equip(inventoryId)
setProcessingId(null)
if (success) {
toast.success('Предмет экипирован!')
}
}
const handleUnequip = async (itemType: ShopItemType) => {
setProcessingId(-1) // Generic processing state
const success = await unequip(itemType)
setProcessingId(null)
if (success) {
toast.success('Предмет снят')
}
}
const filteredInventory = activeTab === 'all'
? inventory
: inventory.filter(inv => inv.item.item_type === activeTab)
// Group by type for display
const cosmeticItems = filteredInventory.filter(inv => inv.item.item_type !== 'consumable')
const consumableItems = filteredInventory.filter(inv => inv.item.item_type === 'consumable')
const tabs: { id: ShopItemType | 'all'; label: string; icon: React.ReactNode }[] = [
{ id: 'all', label: 'Все', icon: <Package className="w-4 h-4" /> },
{ id: 'frame', label: 'Рамки', icon: <Frame className="w-4 h-4" /> },
{ id: 'title', label: 'Титулы', icon: <Type className="w-4 h-4" /> },
{ id: 'name_color', label: 'Цвета', icon: <Palette className="w-4 h-4" /> },
{ id: 'background', label: 'Фоны', icon: <Image className="w-4 h-4" /> },
{ id: 'consumable', label: 'Расходники', icon: <Zap className="w-4 h-4" /> },
]
if (isLoading && inventory.length === 0) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-12 h-12 animate-spin text-neon-500" />
</div>
)
}
return (
<div className="max-w-6xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
<Package className="w-8 h-8 text-neon-500" />
Инвентарь
</h1>
<p className="text-gray-400 mt-1">
Твои предметы и косметика
</p>
</div>
<div className="flex items-center gap-4">
{/* Balance */}
<div className="flex items-center gap-2 px-4 py-2 bg-dark-800/50 rounded-lg border border-yellow-500/30">
<Coins className="w-5 h-5 text-yellow-400" />
<span className="text-yellow-400 font-bold text-lg">{balance}</span>
</div>
{/* Link to shop */}
<Link to="/shop">
<NeonButton>
<ShoppingBag className="w-4 h-4 mr-2" />
Магазин
</NeonButton>
</Link>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap',
activeTab === tab.id
? 'bg-neon-500 text-dark-900'
: 'bg-dark-700 text-gray-300 hover:bg-dark-600'
)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Empty state */}
{filteredInventory.length === 0 ? (
<GlassCard className="p-8 text-center">
<Package className="w-16 h-16 mx-auto text-gray-500 mb-4" />
<p className="text-gray-400 mb-4">
{activeTab === 'all'
? 'Твой инвентарь пуст'
: 'Нет предметов в этой категории'}
</p>
<Link to="/shop">
<NeonButton>
<ShoppingBag className="w-4 h-4 mr-2" />
Перейти в магазин
</NeonButton>
</Link>
</GlassCard>
) : (
<>
{/* Cosmetic items */}
{cosmeticItems.length > 0 && (activeTab === 'all' || activeTab !== 'consumable') && (
<div className="mb-8">
{activeTab === 'all' && (
<h2 className="text-xl font-semibold text-white mb-4">Косметика</h2>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{cosmeticItems.map(inv => (
<InventoryItemCard
key={inv.id}
inventoryItem={inv}
onEquip={handleEquip}
onUnequip={handleUnequip}
isProcessing={processingId === inv.id || processingId === -1}
/>
))}
</div>
</div>
)}
{/* Consumable items */}
{consumableItems.length > 0 && (activeTab === 'all' || activeTab === 'consumable') && (
<div>
{activeTab === 'all' && (
<h2 className="text-xl font-semibold text-white mb-4">Расходуемые</h2>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{consumableItems.map(inv => (
<InventoryItemCard
key={inv.id}
inventoryItem={inv}
onEquip={handleEquip}
onUnequip={handleUnequip}
isProcessing={false}
/>
))}
</div>
</div>
)}
</>
)}
</div>
)
}

View File

@@ -1,11 +1,82 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import type { LeaderboardEntry } from '@/types' import type { LeaderboardEntry, ShopItemPublic, User } from '@/types'
import { GlassCard } from '@/components/ui' import { GlassCard, UserAvatar } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react' import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
// Helper to get name color styles and animation class
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
if (!nameColor?.asset_data) return { styles: {}, className: '' }
const data = nameColor.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string }
if (data.style === 'gradient' && data.gradient) {
return {
styles: {
background: `linear-gradient(90deg, ${data.gradient.join(', ')})`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
},
className: '',
}
}
if (data.style === 'animated') {
return {
styles: {
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
backgroundSize: '400% 100%',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
},
className: 'animate-rainbow-rotate',
}
}
if (data.style === 'solid' && data.color) {
return { styles: { color: data.color }, className: '' }
}
return { styles: {}, className: '' }
}
// Helper to get title data
function getTitleData(title: ShopItemPublic | null | undefined): { text: string; color: string } | null {
if (!title?.asset_data) return null
const data = title.asset_data as { text?: string; color?: string }
if (!data.text) return null
return {
text: data.text,
color: data.color || '#ffffff',
}
}
// Styled nickname component
function StyledNickname({ user, className = '' }: { user: User; className?: string }) {
const nameColorData = getNameColorData(user.equipped_name_color)
const titleData = getTitleData(user.equipped_title)
return (
<span className={`inline-flex items-center gap-2 ${className}`}>
<span className={nameColorData.className} style={nameColorData.styles}>{user.nickname}</span>
{titleData && (
<span
className="px-1.5 py-0.5 text-xs font-medium rounded bg-dark-700/50"
style={{ color: titleData.color }}
>
{titleData.text}
</span>
)}
</span>
)
}
export function LeaderboardPage() { export function LeaderboardPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const user = useAuthStore((state) => state.user) const user = useAuthStore((state) => state.user)
@@ -117,48 +188,66 @@ export function LeaderboardPage() {
<div className="flex items-end justify-center gap-4 mb-4"> <div className="flex items-end justify-center gap-4 mb-4">
{/* 2nd place */} {/* 2nd place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '100ms' }}> <div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '100ms' }}>
<div className={` <div className="mb-3">
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center <UserAvatar
bg-gray-400/10 border border-gray-400/30 userId={topThree[1].user.id}
shadow-[0_0_20px_rgba(156,163,175,0.2)] hasAvatar={!!topThree[1].user.avatar_url}
`}> nickname={topThree[1].user.nickname}
<span className="text-3xl font-bold text-gray-300">2</span> size="lg"
frame={topThree[1].user.equipped_frame}
telegramAvatarUrl={topThree[1].user.telegram_avatar_url}
/>
</div> </div>
<Link to={`/users/${topThree[1].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent"> <Link to={`/users/${topThree[1].user.id}`} className="glass rounded-xl p-4 text-center w-32 hover:border-neon-500/30 transition-colors border border-transparent">
<Medal className="w-6 h-6 text-gray-300 mx-auto mb-2" /> <Medal className="w-6 h-6 text-gray-300 mx-auto mb-2" />
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[1].user.nickname}</p> <p className="text-sm font-medium truncate hover:text-neon-400 transition-colors">
<StyledNickname user={topThree[1].user} />
</p>
<p className="text-xs text-gray-400">{topThree[1].total_points} очков</p> <p className="text-xs text-gray-400">{topThree[1].total_points} очков</p>
</Link> </Link>
</div> </div>
{/* 1st place */} {/* 1st place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '0ms' }}> <div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '0ms' }}>
<div className={` <div className="mb-3 relative">
w-24 h-24 rounded-2xl mb-3 flex items-center justify-center <UserAvatar
bg-yellow-500/20 border border-yellow-500/30 userId={topThree[0].user.id}
shadow-[0_0_30px_rgba(234,179,8,0.4)] hasAvatar={!!topThree[0].user.avatar_url}
`}> nickname={topThree[0].user.nickname}
<Crown className="w-10 h-10 text-yellow-400" /> size="xl"
frame={topThree[0].user.equipped_frame}
telegramAvatarUrl={topThree[0].user.telegram_avatar_url}
/>
<div className="absolute -top-2 -right-2 w-8 h-8 rounded-full bg-yellow-500 flex items-center justify-center shadow-lg shadow-yellow-500/50">
<Crown className="w-5 h-5 text-dark-900" />
</div>
</div> </div>
<Link to={`/users/${topThree[0].user.id}`} className="glass-neon rounded-xl p-4 text-center w-32 hover:shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-shadow"> <Link to={`/users/${topThree[0].user.id}`} className="glass-neon rounded-xl p-4 text-center w-36 hover:shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-shadow">
<Star className="w-6 h-6 text-yellow-400 mx-auto mb-2" /> <Star className="w-6 h-6 text-yellow-400 mx-auto mb-2" />
<p className="font-semibold text-white truncate hover:text-neon-400 transition-colors">{topThree[0].user.nickname}</p> <p className="font-semibold truncate hover:text-neon-400 transition-colors">
<StyledNickname user={topThree[0].user} />
</p>
<p className="text-sm text-neon-400 font-bold">{topThree[0].total_points} очков</p> <p className="text-sm text-neon-400 font-bold">{topThree[0].total_points} очков</p>
</Link> </Link>
</div> </div>
{/* 3rd place */} {/* 3rd place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '200ms' }}> <div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '200ms' }}>
<div className={` <div className="mb-3">
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center <UserAvatar
bg-amber-600/10 border border-amber-600/30 userId={topThree[2].user.id}
shadow-[0_0_20px_rgba(217,119,6,0.2)] hasAvatar={!!topThree[2].user.avatar_url}
`}> nickname={topThree[2].user.nickname}
<span className="text-3xl font-bold text-amber-600">3</span> size="lg"
frame={topThree[2].user.equipped_frame}
telegramAvatarUrl={topThree[2].user.telegram_avatar_url}
/>
</div> </div>
<Link to={`/users/${topThree[2].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent"> <Link to={`/users/${topThree[2].user.id}`} className="glass rounded-xl p-4 text-center w-32 hover:border-neon-500/30 transition-colors border border-transparent">
<Award className="w-6 h-6 text-amber-600 mx-auto mb-2" /> <Award className="w-6 h-6 text-amber-600 mx-auto mb-2" />
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[2].user.nickname}</p> <p className="text-sm font-medium truncate hover:text-neon-400 transition-colors">
<StyledNickname user={topThree[2].user} />
</p>
<p className="text-xs text-gray-400">{topThree[2].total_points} очков</p> <p className="text-xs text-gray-400">{topThree[2].total_points} очков</p>
</Link> </Link>
</div> </div>
@@ -222,20 +311,32 @@ export function LeaderboardPage() {
{/* Rank */} {/* Rank */}
<div className={` <div className={`
relative w-10 h-10 rounded-xl flex items-center justify-center relative w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0
${rankConfig.bg} ${rankConfig.color} ${rankConfig.glow} ${rankConfig.bg} ${rankConfig.color} ${rankConfig.glow}
`}> `}>
{rankConfig.icon} {rankConfig.icon}
</div> </div>
{/* Avatar */}
<div className="flex-shrink-0">
<UserAvatar
userId={entry.user.id}
hasAvatar={!!entry.user.avatar_url}
nickname={entry.user.nickname}
size="sm"
frame={entry.user.equipped_frame}
telegramAvatarUrl={entry.user.telegram_avatar_url}
/>
</div>
{/* User info */} {/* User info */}
<div className="relative flex-1 min-w-0"> <div className="relative flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<Link <Link
to={`/users/${entry.user.id}`} to={`/users/${entry.user.id}`}
className={`font-semibold truncate hover:text-neon-400 transition-colors ${isCurrentUser ? 'text-neon-400' : 'text-white'}`} className={`font-semibold truncate hover:text-neon-400 transition-colors ${isCurrentUser ? 'text-neon-400' : ''}`}
> >
{entry.user.nickname} <StyledNickname user={entry.user} />
</Link> </Link>
{isCurrentUser && ( {isCurrentUser && (
<span className="px-2 py-0.5 text-xs font-medium bg-neon-500/20 text-neon-400 rounded-full border border-neon-500/30"> <span className="px-2 py-0.5 text-xs font-medium bg-neon-500/20 text-neon-400 rounded-full border border-neon-500/30">

View File

@@ -1,13 +1,14 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi } from '@/api' import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi, shopApi } from '@/api'
import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment } from '@/types' import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment, ConsumablesStatus, ConsumableType } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel' import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download } from 'lucide-react' import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, Shield, RefreshCw, SkipForward, Package } from 'lucide-react'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { useShopStore } from '@/store/shop'
const MAX_IMAGE_SIZE = 15 * 1024 * 1024 const MAX_IMAGE_SIZE = 15 * 1024 * 1024
const MAX_VIDEO_SIZE = 30 * 1024 * 1024 const MAX_VIDEO_SIZE = 30 * 1024 * 1024
@@ -55,6 +56,10 @@ export function PlayPage() {
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([]) const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
// Consumables
const [consumablesStatus, setConsumablesStatus] = useState<ConsumablesStatus | null>(null)
const [isUsingConsumable, setIsUsingConsumable] = useState<ConsumableType | null>(null)
// Bonus challenge completion // Bonus challenge completion
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null) const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([]) const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
@@ -177,13 +182,14 @@ export function PlayPage() {
const loadData = async () => { const loadData = async () => {
if (!id) return if (!id) return
try { try {
const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([ const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData, consumablesData] = await Promise.all([
marathonsApi.get(parseInt(id)), marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)), wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.getAvailableGames(parseInt(id)), gamesApi.getAvailableGames(parseInt(id)),
eventsApi.getActive(parseInt(id)), eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)), eventsApi.getEventAssignment(parseInt(id)),
assignmentsApi.getReturnedAssignments(parseInt(id)), assignmentsApi.getReturnedAssignments(parseInt(id)),
shopApi.getConsumablesStatus(parseInt(id)).catch(() => null),
]) ])
setMarathon(marathonData) setMarathon(marathonData)
setCurrentAssignment(assignment) setCurrentAssignment(assignment)
@@ -191,6 +197,7 @@ export function PlayPage() {
setActiveEvent(eventData) setActiveEvent(eventData)
setEventAssignment(eventAssignmentData) setEventAssignment(eventAssignmentData)
setReturnedAssignments(returnedData) setReturnedAssignments(returnedData)
setConsumablesStatus(consumablesData)
} catch (error) { } catch (error) {
console.error('Failed to load data:', error) console.error('Failed to load data:', error)
} finally { } finally {
@@ -255,6 +262,8 @@ export function PlayPage() {
setProofUrl('') setProofUrl('')
setComment('') setComment('')
await loadData() await loadData()
// Refresh coins balance
useShopStore.getState().loadBalance()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось выполнить') toast.error(error.response?.data?.detail || 'Не удалось выполнить')
@@ -464,6 +473,85 @@ export function PlayPage() {
} }
} }
// Consumable handlers
const handleUseSkip = async () => {
if (!currentAssignment || !id) return
setIsUsingConsumable('skip')
try {
await shopApi.useConsumable({
item_code: 'skip',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
})
toast.success('Задание пропущено без штрафа!')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Skip')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseReroll = async () => {
if (!currentAssignment || !id) return
setIsUsingConsumable('reroll')
try {
await shopApi.useConsumable({
item_code: 'reroll',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
})
toast.success('Задание отменено! Можно крутить заново.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Reroll')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseShield = async () => {
if (!id) return
setIsUsingConsumable('shield')
try {
await shopApi.useConsumable({
item_code: 'shield',
marathon_id: parseInt(id),
})
toast.success('Shield активирован! Следующий пропуск будет бесплатным.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось активировать Shield')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseBoost = async () => {
if (!id) return
setIsUsingConsumable('boost')
try {
await shopApi.useConsumable({
item_code: 'boost',
marathon_id: parseInt(id),
})
toast.success('Boost активирован! x1.5 очков за следующее выполнение.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось активировать Boost')
} finally {
setIsUsingConsumable(null)
}
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center py-24"> <div className="flex flex-col items-center justify-center py-24">
@@ -608,6 +696,135 @@ export function PlayPage() {
</GlassCard> </GlassCard>
)} )}
{/* Consumables Panel */}
{consumablesStatus && marathon?.allow_consumables && (
<GlassCard className="mb-6 border-purple-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
<Package className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-purple-400">Расходники</h3>
<p className="text-sm text-gray-400">Используйте для облегчения задания</p>
</div>
</div>
{/* Active effects */}
{(consumablesStatus.has_shield || consumablesStatus.has_active_boost) && (
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/30 rounded-xl">
<p className="text-green-400 text-sm font-medium mb-2">Активные эффекты:</p>
<div className="flex flex-wrap gap-2">
{consumablesStatus.has_shield && (
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg border border-blue-500/30 flex items-center gap-1">
<Shield className="w-3 h-3" /> Shield (следующий drop бесплатный)
</span>
)}
{consumablesStatus.has_active_boost && (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg border border-yellow-500/30 flex items-center gap-1">
<Zap className="w-3 h-3" /> Boost x1.5 (следующий complete)
</span>
)}
</div>
</div>
)}
{/* Consumables grid */}
<div className="grid grid-cols-2 gap-3">
{/* Skip */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<SkipForward className="w-4 h-4 text-orange-400" />
<span className="text-white font-medium">Skip</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.skips_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Пропустить без штрафа</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseSkip}
disabled={consumablesStatus.skips_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'skip'}
className="w-full"
>
Использовать
</NeonButton>
</div>
{/* Reroll */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-cyan-400" />
<span className="text-white font-medium">Reroll</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.rerolls_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Переспинить задание</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseReroll}
disabled={consumablesStatus.rerolls_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'reroll'}
className="w-full"
>
Использовать
</NeonButton>
</div>
{/* Shield */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" />
<span className="text-white font-medium">Shield</span>
</div>
<span className="text-gray-400 text-sm">
{consumablesStatus.has_shield ? 'Активен' : `${consumablesStatus.shields_available} шт.`}
</span>
</div>
<p className="text-gray-500 text-xs mb-2">Защита от штрафа</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseShield}
disabled={consumablesStatus.has_shield || consumablesStatus.shields_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'shield'}
className="w-full"
>
{consumablesStatus.has_shield ? 'Активен' : 'Активировать'}
</NeonButton>
</div>
{/* Boost */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-yellow-400" />
<span className="text-white font-medium">Boost</span>
</div>
<span className="text-gray-400 text-sm">
{consumablesStatus.has_active_boost ? 'Активен' : `${consumablesStatus.boosts_available} шт.`}
</span>
</div>
<p className="text-gray-500 text-xs mb-2">x1.5 очков</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseBoost}
disabled={consumablesStatus.has_active_boost || consumablesStatus.boosts_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'boost'}
className="w-full"
>
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
</NeonButton>
</div>
</div>
</GlassCard>
)}
{/* Tabs for Common Enemy event */} {/* Tabs for Common Enemy event */}
{activeEvent?.event?.type === 'common_enemy' && ( {activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6"> <div className="flex gap-2 mb-6">

View File

@@ -2,9 +2,10 @@ import { useState, useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { usersApi, telegramApi, authApi } from '@/api' import { usersApi, telegramApi, authApi } from '@/api'
import type { UserStats } from '@/types' import type { UserStats, ShopItemPublic } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { import {
NeonButton, Input, GlassCard, StatsCard, clearAvatarCache NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
@@ -13,8 +14,9 @@ import {
User, Camera, Trophy, Target, CheckCircle, Flame, User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink, Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles, Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
AlertTriangle, FileCheck AlertTriangle, FileCheck, Backpack, Edit3
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx'
// Schemas // Schemas
const nicknameSchema = z.object({ const nicknameSchema = z.object({
@@ -33,6 +35,235 @@ const passwordSchema = z.object({
type NicknameForm = z.infer<typeof nicknameSchema> type NicknameForm = z.infer<typeof nicknameSchema>
type PasswordForm = z.infer<typeof passwordSchema> type PasswordForm = z.infer<typeof passwordSchema>
// ============ COSMETICS HELPERS ============
// Background asset_data structure:
// - type: 'solid' | 'gradient' | 'pattern' | 'animated'
// - color: '#1a1a2e' (for solid)
// - gradient: ['#1a1a2e', '#4a0080'] (for gradient)
// - pattern: 'stars' | 'gaming-icons' (for pattern)
// - animation: 'fire-particles' (for animated)
// - animated: boolean (for animated patterns)
interface BackgroundResult {
styles: React.CSSProperties
className: string
}
function getBackgroundData(background: ShopItemPublic | null): BackgroundResult {
if (!background?.asset_data) {
return { styles: {}, className: '' }
}
const data = background.asset_data as {
type?: string
gradient?: string[]
pattern?: string
color?: string
animation?: string
animated?: boolean
}
const styles: React.CSSProperties = {}
let className = ''
switch (data.type) {
case 'solid':
if (data.color) {
styles.backgroundColor = data.color
}
break
case 'gradient':
if (data.gradient && data.gradient.length > 0) {
styles.background = `linear-gradient(135deg, ${data.gradient.join(', ')})`
}
break
case 'pattern':
// Pattern backgrounds - use CSS classes for animated stars
if (data.pattern === 'stars') {
// Use CSS class for twinkling stars effect
className = 'bg-stars-animated'
} else if (data.pattern === 'gaming-icons') {
styles.background = `
linear-gradient(45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
linear-gradient(-45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
linear-gradient(-45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
`
styles.backgroundSize = '40px 40px, 40px 40px, 40px 40px, 40px 40px, 100% 100%'
}
break
case 'animated':
// Animated backgrounds
if (data.animation === 'fire-particles') {
styles.background = `
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.4) 0%, transparent 50%),
radial-gradient(circle at 30% 80%, rgba(255,50,0,0.3) 0%, transparent 40%),
radial-gradient(circle at 70% 90%, rgba(255,150,0,0.3) 0%, transparent 45%),
linear-gradient(to top, #1a0a00 0%, #0d0d0d 60%, #1a1a2e 100%)
`
className = 'animate-fire-pulse'
}
break
}
return { styles, className }
}
// Name color asset_data structure:
// - style: 'solid' | 'gradient' | 'animated'
// - color: '#FF4444' (for solid)
// - gradient: ['#FF6B6B', '#FFE66D'] (for gradient)
// - animation: 'rainbow-shift' (for animated)
interface NameColorResult {
type: 'solid' | 'gradient' | 'animated'
color?: string
gradient?: string[]
animation?: string
}
function getNameColorData(nameColor: ShopItemPublic | null): NameColorResult {
if (!nameColor?.asset_data) {
return { type: 'solid', color: '#ffffff' }
}
const data = nameColor.asset_data as {
style?: string
color?: string
gradient?: string[]
animation?: string
}
if (data.style === 'gradient' && data.gradient) {
return { type: 'gradient', gradient: data.gradient }
}
if (data.style === 'animated') {
return { type: 'animated', animation: data.animation }
}
return { type: 'solid', color: data.color || '#ffffff' }
}
// Get title from equipped_title
function getTitleData(title: ShopItemPublic | null): { text: string; color: string } | null {
if (!title?.asset_data) return null
const data = title.asset_data as { text?: string; color?: string }
if (!data.text) return null
return { text: data.text, color: data.color || '#ffffff' }
}
// Get frame styles from asset_data
function getFrameStyles(frame: ShopItemPublic | null): React.CSSProperties {
if (!frame?.asset_data) return {}
const data = frame.asset_data as {
border_color?: string
gradient?: string[]
glow_color?: string
}
const styles: React.CSSProperties = {}
if (data.gradient && data.gradient.length > 0) {
styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
styles.backgroundSize = '400% 400%'
} else if (data.border_color) {
styles.background = data.border_color
}
if (data.glow_color) {
styles.boxShadow = `0 0 20px ${data.glow_color}, 0 0 40px ${data.glow_color}40`
}
return styles
}
// Get frame animation class
function getFrameAnimation(frame: ShopItemPublic | null): string {
if (!frame?.asset_data) return ''
const data = frame.asset_data as { animation?: string }
if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
return ''
}
// ============ HERO AVATAR COMPONENT ============
function HeroAvatar({
avatarUrl,
nickname,
frame,
onClick,
isUploading,
isLoading
}: {
avatarUrl: string | null | undefined
nickname: string | undefined
frame: ShopItemPublic | null
onClick: () => void
isUploading: boolean
isLoading: boolean
}) {
if (isLoading) {
return <div className="w-32 h-32 md:w-40 md:h-40 rounded-2xl bg-dark-700/50 skeleton" />
}
const avatarContent = (
<div className="w-32 h-32 md:w-40 md:h-40 rounded-2xl overflow-hidden bg-dark-700/80 backdrop-blur-sm">
{avatarUrl ? (
<img
src={avatarUrl}
alt={nickname}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
<User className="w-16 h-16 text-gray-500" />
</div>
)}
</div>
)
const hoverOverlay = (
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded-2xl">
{isUploading ? (
<Loader2 className="w-10 h-10 text-neon-500 animate-spin" />
) : (
<Camera className="w-10 h-10 text-neon-500" />
)}
</div>
)
if (!frame) {
return (
<button
onClick={onClick}
disabled={isUploading}
className="relative rounded-2xl border-2 border-neon-500/50 hover:border-neon-500 transition-all shadow-[0_0_30px_rgba(34,211,238,0.15)] hover:shadow-[0_0_40px_rgba(34,211,238,0.3)] group"
>
{avatarContent}
{hoverOverlay}
</button>
)
}
return (
<button
onClick={onClick}
disabled={isUploading}
className={clsx(
'relative rounded-2xl p-1.5 transition-all group',
getFrameAnimation(frame)
)}
style={getFrameStyles(frame)}
>
{avatarContent}
{hoverOverlay}
</button>
)
}
export function ProfilePage() { export function ProfilePage() {
const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore() const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
const toast = useToast() const toast = useToast()
@@ -298,76 +529,198 @@ export function ProfilePage() {
const isLinked = !!user?.telegram_id const isLinked = !!user?.telegram_id
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
// 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 ( return (
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
{/* Header */} {/* ============ HERO SECTION ============ */}
<div className="mb-8"> <div
<h1 className="text-3xl font-bold text-white mb-2">Мой профиль</h1> className={clsx(
<p className="text-gray-400">Настройки вашего аккаунта</p> 'relative rounded-3xl overflow-hidden',
</div> backgroundData.className
)}
style={backgroundData.styles}
>
{/* Default gradient background if no custom background */}
{!equippedBackground && (
<div className="absolute inset-0 bg-gradient-to-br from-dark-800 via-dark-900 to-neon-900/20" />
)}
{/* Profile Card */} {/* Overlay for readability */}
<GlassCard variant="neon"> <div className="absolute inset-0 bg-gradient-to-t from-dark-900/90 via-dark-900/40 to-transparent" />
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
{/* Avatar */} {/* Scan lines effect */}
<div className="relative group flex-shrink-0"> <div className="absolute inset-0 bg-[linear-gradient(transparent_50%,rgba(0,0,0,0.03)_50%)] bg-[length:100%_4px] pointer-events-none" />
{isLoadingAvatar ? (
<div className="w-28 h-28 rounded-2xl bg-dark-700 skeleton" /> {/* Glow effects */}
) : ( <div className="absolute top-0 left-1/4 w-96 h-96 bg-neon-500/10 rounded-full blur-3xl pointer-events-none" />
<button <div className="absolute bottom-0 right-1/4 w-64 h-64 bg-accent-500/10 rounded-full blur-3xl pointer-events-none" />
{/* Content */}
<div className="relative z-10 px-6 py-10 md:px-10 md:py-14">
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-10">
{/* Avatar with Frame */}
<div className="flex-shrink-0">
<HeroAvatar
avatarUrl={displayAvatar}
nickname={user?.nickname}
frame={equippedFrame}
onClick={handleAvatarClick} onClick={handleAvatarClick}
disabled={isUploadingAvatar} isUploading={isUploadingAvatar}
className="relative w-28 h-28 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 hover:border-neon-500 transition-all group-hover:shadow-[0_0_20px_rgba(34,211,238,0.25)]" isLoading={isLoadingAvatar}
>
{displayAvatar ? (
<img
src={displayAvatar}
alt={user?.nickname}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
<User className="w-12 h-12 text-gray-500" />
</div>
)}
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{isUploadingAvatar ? (
<Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
) : (
<Camera className="w-8 h-8 text-neon-500" />
)}
</div>
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
</div>
{/* Nickname Form */}
<div className="flex-1 w-full sm:w-auto">
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
<Input
label="Никнейм"
{...nicknameForm.register('nickname')}
error={nicknameForm.formState.errors.nickname?.message}
/> />
<NeonButton <input
type="submit" ref={fileInputRef}
size="sm" type="file"
isLoading={nicknameForm.formState.isSubmitting} accept="image/*"
disabled={!nicknameForm.formState.isDirty} onChange={handleAvatarChange}
icon={<Save className="w-4 h-4" />} className="hidden"
> />
Сохранить </div>
</NeonButton>
</form> {/* User Info */}
<div className="flex-1 text-center md:text-left">
{/* Nickname with color + Title badge */}
<div className="flex flex-wrap items-center justify-center md:justify-start gap-3 mb-3">
<h1
className={clsx(
'text-3xl md:text-4xl font-bold font-display tracking-wide drop-shadow-[0_0_10px_rgba(255,255,255,0.3)]',
getNicknameAnimation()
)}
style={getNicknameStyles()}
>
{user?.nickname || 'Игрок'}
</h1>
{/* Title badge */}
{titleData && (
<span
className="px-3 py-1 rounded-full text-sm font-semibold border backdrop-blur-sm"
style={{
color: titleData.color,
borderColor: `${titleData.color}50`,
backgroundColor: `${titleData.color}15`,
boxShadow: `0 0 15px ${titleData.color}30`
}}
>
{titleData.text}
</span>
)}
</div>
{/* Role badge */}
{user?.role === 'admin' && (
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-500/20 border border-purple-500/30 text-purple-400 text-sm font-medium mb-4">
<Shield className="w-4 h-4" />
Администратор
</div>
)}
{/* Quick stats preview */}
{stats && (
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm text-gray-300 mt-4">
<div className="flex items-center gap-1.5">
<Trophy className="w-4 h-4 text-yellow-500" />
<span>{stats.wins_count} побед</span>
</div>
<div className="flex items-center gap-1.5">
<Target className="w-4 h-4 text-neon-400" />
<span>{stats.marathons_count} марафонов</span>
</div>
<div className="flex items-center gap-1.5">
<Flame className="w-4 h-4 text-orange-400" />
<span>{stats.total_points_earned} очков</span>
</div>
</div>
)}
{/* Inventory link */}
<div className="mt-6">
<Link
to="/inventory"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700/50 hover:bg-dark-700 border border-dark-600 hover:border-neon-500/30 text-gray-300 hover:text-white transition-all"
>
<Backpack className="w-4 h-4" />
Инвентарь
</Link>
</div>
</div>
</div> </div>
</div> </div>
</div>
{/* ============ NICKNAME EDIT SECTION ============ */}
<GlassCard>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Edit3 className="w-5 h-5 text-neon-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Изменить никнейм</h2>
<p className="text-sm text-gray-400">Ваше игровое имя</p>
</div>
</div>
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
{...nicknameForm.register('nickname')}
error={nicknameForm.formState.errors.nickname?.message}
placeholder="Введите никнейм"
/>
</div>
<NeonButton
type="submit"
isLoading={nicknameForm.formState.isSubmitting}
disabled={!nicknameForm.formState.isDirty}
icon={<Save className="w-4 h-4" />}
>
Сохранить
</NeonButton>
</form>
</GlassCard> </GlassCard>
{/* Stats */} {/* Stats */}

View File

@@ -0,0 +1,470 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useShopStore } from '@/store/shop'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
import {
Loader2, Coins, ShoppingBag, Package, Sparkles,
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward,
Minus, Plus
} from 'lucide-react'
import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
import { RARITY_COLORS, RARITY_NAMES } from '@/types'
import clsx from 'clsx'
const ITEM_TYPE_ICONS: Record<ShopItemType, React.ReactNode> = {
frame: <Frame className="w-5 h-5" />,
title: <Type className="w-5 h-5" />,
name_color: <Palette className="w-5 h-5" />,
background: <Image className="w-5 h-5" />,
consumable: <Zap className="w-5 h-5" />,
}
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
skip: <SkipForward className="w-8 h-8" />,
shield: <Shield className="w-8 h-8" />,
boost: <Zap className="w-8 h-8" />,
reroll: <RefreshCw className="w-8 h-8" />,
}
interface ShopItemCardProps {
item: ShopItem
onPurchase: (item: ShopItem, quantity: number) => void
isPurchasing: boolean
}
function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
const [quantity, setQuantity] = useState(1)
const rarityColors = RARITY_COLORS[item.rarity]
const isConsumable = item.item_type === 'consumable'
const maxQuantity = item.stock_remaining !== null ? Math.min(10, item.stock_remaining) : 10
const totalPrice = item.price * quantity
const incrementQuantity = () => {
if (quantity < maxQuantity) {
setQuantity(q => q + 1)
}
}
const decrementQuantity = () => {
if (quantity > 1) {
setQuantity(q => q - 1)
}
}
const getItemPreview = () => {
if (item.item_type === 'consumable') {
return CONSUMABLE_ICONS[item.code] || <Package className="w-8 h-8" />
}
// Name color preview - handles solid, gradient, animated
if (item.item_type === 'name_color') {
const data = item.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string } | null
// Gradient style
if (data?.style === 'gradient' && data.gradient) {
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600"
style={{ background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }}
/>
)
}
// Animated rainbow style
if (data?.style === 'animated') {
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600 animate-rainbow-rotate"
style={{
background: 'linear-gradient(135deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
backgroundSize: '400% 400%'
}}
/>
)
}
// Solid color style (default)
const solidColor = data?.color || '#ffffff'
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600"
style={{ backgroundColor: solidColor }}
/>
)
}
// Background preview
if (item.item_type === 'background') {
const data = item.asset_data as { type?: string; color?: string; gradient?: string[]; pattern?: string; animation?: string } | null
let bgStyle: React.CSSProperties = {}
let animClass = ''
if (data?.type === 'solid' && data.color) {
bgStyle = { backgroundColor: data.color }
} else if (data?.type === 'gradient' && data.gradient) {
bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
} else if (data?.type === 'pattern') {
if (data.pattern === 'stars') {
bgStyle = {
background: `
radial-gradient(1px 1px at 10px 10px, #fff, transparent),
radial-gradient(1px 1px at 30px 25px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 50px 15px, #fff, transparent),
linear-gradient(135deg, #0d1b2a 0%, #1b263b 100%)
`,
backgroundSize: '60px 40px, 60px 40px, 60px 40px, 100% 100%'
}
animClass = 'animate-twinkle'
} else if (data.pattern === 'gaming-icons') {
bgStyle = {
background: `
linear-gradient(45deg, rgba(34,211,238,0.2) 25%, transparent 25%),
linear-gradient(-45deg, rgba(168,85,247,0.2) 25%, transparent 25%),
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
`,
backgroundSize: '20px 20px, 20px 20px, 100% 100%'
}
}
} else if (data?.type === 'animated' && data.animation === 'fire-particles') {
bgStyle = {
background: `
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.5) 0%, transparent 50%),
radial-gradient(circle at 30% 80%, rgba(255,50,0,0.4) 0%, transparent 40%),
linear-gradient(to top, #1a0a00 0%, #0d0d0d 100%)
`
}
animClass = 'animate-fire-pulse'
}
return (
<div
className={clsx('w-16 h-12 rounded-lg border-2 border-dark-600', animClass)}
style={bgStyle}
/>
)
}
if (item.item_type === 'frame') {
// Use FramePreview for animated and gradient frames
const frameItem: ShopItemPublic = {
id: item.id,
code: item.code,
name: item.name,
item_type: item.item_type,
rarity: item.rarity,
asset_data: item.asset_data,
}
return <FramePreview frame={frameItem} size="lg" />
}
if (item.item_type === 'title' && item.asset_data?.text) {
return (
<span
className="text-lg font-bold"
style={{ color: (item.asset_data.color as string) || '#ffffff' }}
>
{item.asset_data.text as string}
</span>
)
}
return ITEM_TYPE_ICONS[item.item_type]
}
return (
<GlassCard
className={clsx(
'p-4 border transition-all duration-300',
rarityColors.border,
item.is_owned && 'opacity-60'
)}
>
{/* Rarity badge */}
<div className={clsx('text-xs font-medium mb-2', rarityColors.text)}>
{RARITY_NAMES[item.rarity]}
</div>
{/* Item preview */}
<div className="flex justify-center items-center h-20 mb-3">
{getItemPreview()}
</div>
{/* Item info */}
<h3 className="text-white font-semibold text-center mb-1">{item.name}</h3>
<p className="text-gray-400 text-xs text-center mb-3 line-clamp-2">
{item.description}
</p>
{/* Quantity selector for consumables */}
{isConsumable && !item.is_owned && item.is_available && (
<div className="flex items-center justify-center gap-2 mb-3">
<button
onClick={decrementQuantity}
disabled={quantity <= 1 || isPurchasing}
className="w-7 h-7 rounded-lg bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
>
<Minus className="w-4 h-4" />
</button>
<span className="w-8 text-center text-white font-bold">{quantity}</span>
<button
onClick={incrementQuantity}
disabled={quantity >= maxQuantity || isPurchasing}
className="w-7 h-7 rounded-lg bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
)}
{/* Price and action */}
<div className="flex items-center justify-between mt-auto">
<div className="flex items-center gap-1 text-yellow-400">
<Coins className="w-4 h-4" />
<span className="font-bold">{isConsumable ? totalPrice : item.price}</span>
{isConsumable && quantity > 1 && (
<span className="text-xs text-gray-500">({item.price}×{quantity})</span>
)}
</div>
{item.is_owned && !isConsumable ? (
<span className="text-green-400 text-sm flex items-center gap-1">
<Sparkles className="w-4 h-4" />
Куплено
</span>
) : item.is_equipped ? (
<span className="text-neon-400 text-sm">Надето</span>
) : (
<NeonButton
size="sm"
onClick={() => onPurchase(item, quantity)}
disabled={isPurchasing || !item.is_available}
>
{isPurchasing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Купить'
)}
</NeonButton>
)}
</div>
{/* Stock info */}
{item.stock_remaining !== null && (
<div className="text-xs text-gray-500 text-center mt-2">
Осталось: {item.stock_remaining}
</div>
)}
</GlassCard>
)
}
export function ShopPage() {
const { items, balance, isLoading, loadItems, loadBalance, purchase, clearError, error } = useShopStore()
const toast = useToast()
const confirm = useConfirm()
const [activeTab, setActiveTab] = useState<ShopItemType | 'all'>('all')
const [purchasingId, setPurchasingId] = useState<number | null>(null)
useEffect(() => {
loadBalance()
loadItems()
}, [loadBalance, loadItems])
useEffect(() => {
if (error) {
toast.error(error)
clearError()
}
}, [error, toast, clearError])
const handlePurchase = async (item: ShopItem, quantity: number = 1) => {
const totalCost = item.price * quantity
const isConsumable = item.item_type === 'consumable'
const quantityText = quantity > 1 ? ` (×${quantity})` : ''
const confirmed = await confirm({
title: 'Подтвердите покупку',
message: isConsumable && quantity > 1
? `Купить "${item.name}" × ${quantity} шт. за ${totalCost} монет?`
: `Купить "${item.name}" за ${item.price} монет?`,
confirmText: 'Купить',
cancelText: 'Отмена',
})
if (!confirmed) return
setPurchasingId(item.id)
const success = await purchase(item.id, quantity)
setPurchasingId(null)
if (success) {
toast.success(`Вы приобрели "${item.name}"${quantityText}!`)
}
}
const filteredItems = activeTab === 'all'
? items
: items.filter(item => item.item_type === activeTab)
// Group items by type for "All" tab
const itemsByType: Record<ShopItemType, ShopItem[]> = {
frame: [],
title: [],
name_color: [],
background: [],
consumable: [],
}
if (activeTab === 'all') {
items.forEach(item => {
if (itemsByType[item.item_type]) {
itemsByType[item.item_type].push(item)
}
})
}
const categoryOrder: ShopItemType[] = ['frame', 'title', 'name_color', 'background', 'consumable']
const categoryLabels: Record<ShopItemType, { label: string; icon: React.ReactNode }> = {
frame: { label: 'Рамки профиля', icon: <Frame className="w-5 h-5" /> },
title: { label: 'Титулы', icon: <Type className="w-5 h-5" /> },
name_color: { label: 'Цвета ника', icon: <Palette className="w-5 h-5" /> },
background: { label: 'Фоны профиля', icon: <Image className="w-5 h-5" /> },
consumable: { label: 'Расходуемые предметы', icon: <Zap className="w-5 h-5" /> },
}
const tabs: { id: ShopItemType | 'all'; label: string; icon: React.ReactNode }[] = [
{ id: 'all', label: 'Все', icon: <ShoppingBag className="w-4 h-4" /> },
{ id: 'frame', label: 'Рамки', icon: <Frame className="w-4 h-4" /> },
{ id: 'title', label: 'Титулы', icon: <Type className="w-4 h-4" /> },
{ id: 'name_color', label: 'Цвета', icon: <Palette className="w-4 h-4" /> },
{ id: 'background', label: 'Фоны', icon: <Image className="w-4 h-4" /> },
{ id: 'consumable', label: 'Расходники', icon: <Zap className="w-4 h-4" /> },
]
if (isLoading && items.length === 0) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-12 h-12 animate-spin text-neon-500" />
</div>
)
}
return (
<div className="max-w-6xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
<ShoppingBag className="w-8 h-8 text-neon-500" />
Магазин
</h1>
<p className="text-gray-400 mt-1">
Покупай косметику и расходуемые предметы
</p>
</div>
<div className="flex items-center gap-4">
{/* Balance */}
<div className="flex items-center gap-2 px-4 py-2 bg-dark-800/50 rounded-lg border border-yellow-500/30">
<Coins className="w-5 h-5 text-yellow-400" />
<span className="text-yellow-400 font-bold text-lg">{balance}</span>
</div>
{/* Link to inventory */}
<Link to="/inventory">
<NeonButton variant="secondary">
<Package className="w-4 h-4 mr-2" />
Инвентарь
</NeonButton>
</Link>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap',
activeTab === tab.id
? 'bg-neon-500 text-dark-900'
: 'bg-dark-700 text-gray-300 hover:bg-dark-600'
)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Items grid */}
{filteredItems.length === 0 ? (
<GlassCard className="p-8 text-center">
<Package className="w-16 h-16 mx-auto text-gray-500 mb-4" />
<p className="text-gray-400">Нет доступных товаров в этой категории</p>
</GlassCard>
) : activeTab === 'all' ? (
// Grouped view for "All" tab
<div className="space-y-8">
{categoryOrder.map(category => {
const categoryItems = itemsByType[category]
if (categoryItems.length === 0) return null
const { label, icon } = categoryLabels[category]
return (
<div key={category}>
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center text-neon-400">
{icon}
</div>
<h2 className="text-lg font-semibold text-white">{label}</h2>
<span className="text-sm text-gray-500">({categoryItems.length})</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{categoryItems.map(item => (
<ShopItemCard
key={item.id}
item={item}
onPurchase={handlePurchase}
isPurchasing={purchasingId === item.id}
/>
))}
</div>
</div>
)
})}
</div>
) : (
// Regular grid for specific category
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{filteredItems.map(item => (
<ShopItemCard
key={item.id}
item={item}
onPurchase={handlePurchase}
isPurchasing={purchasingId === item.id}
/>
))}
</div>
)}
{/* Info about coins */}
<GlassCard className="mt-8 p-4">
<h3 className="text-white font-semibold mb-2 flex items-center gap-2">
<Coins className="w-5 h-5 text-yellow-400" />
Как заработать монеты?
</h3>
<ul className="text-gray-400 text-sm space-y-1">
<li> Выполняй задания в <span className="text-neon-400">сертифицированных</span> марафонах</li>
<li> Easy задание 5 монет, Medium 12 монет, Hard 25 монет</li>
<li> Playthrough ~5% от заработанных очков</li>
<li> Топ-3 места в марафоне: 1-е 100, 2-е 50, 3-е 30 монет</li>
</ul>
</GlassCard>
</div>
)
}

View File

@@ -2,12 +2,200 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { usersApi } from '@/api' import { usersApi } from '@/api'
import type { UserProfilePublic } from '@/types' import type { UserProfilePublic, ShopItemPublic } from '@/types'
import { GlassCard, StatsCard } from '@/components/ui' import { GlassCard, StatsCard } from '@/components/ui'
import { import {
User, Trophy, Target, CheckCircle, Flame, User, Trophy, Target, CheckCircle, Flame,
Loader2, ArrowLeft, Calendar, Zap Loader2, ArrowLeft, Calendar, Shield
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx'
// ============ COSMETICS HELPERS ============
interface BackgroundResult {
styles: React.CSSProperties
className: string
}
function getBackgroundData(background: ShopItemPublic | null): BackgroundResult {
if (!background?.asset_data) {
return { styles: {}, className: '' }
}
const data = background.asset_data as {
type?: string
gradient?: string[]
pattern?: string
color?: string
animation?: string
animated?: boolean
}
const styles: React.CSSProperties = {}
let className = ''
switch (data.type) {
case 'solid':
if (data.color) {
styles.backgroundColor = data.color
}
break
case 'gradient':
if (data.gradient && data.gradient.length > 0) {
styles.background = `linear-gradient(135deg, ${data.gradient.join(', ')})`
}
break
case 'pattern':
if (data.pattern === 'stars') {
className = 'bg-stars-animated'
} else if (data.pattern === 'gaming-icons') {
styles.background = `
linear-gradient(45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
linear-gradient(-45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
linear-gradient(-45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
`
styles.backgroundSize = '40px 40px, 40px 40px, 40px 40px, 40px 40px, 100% 100%'
}
break
case 'animated':
if (data.animation === 'fire-particles') {
styles.background = `
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.4) 0%, transparent 50%),
radial-gradient(circle at 30% 80%, rgba(255,50,0,0.3) 0%, transparent 40%),
radial-gradient(circle at 70% 90%, rgba(255,150,0,0.3) 0%, transparent 45%),
linear-gradient(to top, #1a0a00 0%, #0d0d0d 60%, #1a1a2e 100%)
`
className = 'animate-fire-pulse'
}
break
}
return { styles, className }
}
interface NameColorResult {
type: 'solid' | 'gradient' | 'animated'
color?: string
gradient?: string[]
animation?: string
}
function getNameColorData(nameColor: ShopItemPublic | null): NameColorResult {
if (!nameColor?.asset_data) {
return { type: 'solid', color: '#ffffff' }
}
const data = nameColor.asset_data as {
style?: string
color?: string
gradient?: string[]
animation?: string
}
if (data.style === 'gradient' && data.gradient) {
return { type: 'gradient', gradient: data.gradient }
}
if (data.style === 'animated') {
return { type: 'animated', animation: data.animation }
}
return { type: 'solid', color: data.color || '#ffffff' }
}
function getTitleData(title: ShopItemPublic | null): { text: string; color: string } | null {
if (!title?.asset_data) return null
const data = title.asset_data as { text?: string; color?: string }
if (!data.text) return null
return { text: data.text, color: data.color || '#ffffff' }
}
function getFrameStyles(frame: ShopItemPublic | null): React.CSSProperties {
if (!frame?.asset_data) return {}
const data = frame.asset_data as {
border_color?: string
gradient?: string[]
glow_color?: string
}
const styles: React.CSSProperties = {}
if (data.gradient && data.gradient.length > 0) {
styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
styles.backgroundSize = '400% 400%'
} else if (data.border_color) {
styles.background = data.border_color
}
if (data.glow_color) {
styles.boxShadow = `0 0 20px ${data.glow_color}, 0 0 40px ${data.glow_color}40`
}
return styles
}
function getFrameAnimation(frame: ShopItemPublic | null): string {
if (!frame?.asset_data) return ''
const data = frame.asset_data as { animation?: string }
if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
return ''
}
// ============ HERO AVATAR COMPONENT ============
function HeroAvatar({
avatarUrl,
telegramAvatarUrl,
nickname,
frame,
}: {
avatarUrl: string | null
telegramAvatarUrl: string | null
nickname: string
frame: ShopItemPublic | null
}) {
const displayAvatar = avatarUrl || telegramAvatarUrl
const avatarContent = (
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl overflow-hidden bg-dark-700/80 backdrop-blur-sm">
{displayAvatar ? (
<img
src={displayAvatar}
alt={nickname}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
<User className="w-14 h-14 text-gray-500" />
</div>
)}
</div>
)
if (!frame) {
return (
<div className="rounded-2xl border-2 border-neon-500/50 shadow-[0_0_30px_rgba(34,211,238,0.15)]">
{avatarContent}
</div>
)
}
return (
<div
className={clsx(
'rounded-2xl p-1.5',
getFrameAnimation(frame)
)}
style={getFrameStyles(frame)}
>
{avatarContent}
</div>
)
}
// ============ MAIN COMPONENT ============
export function UserProfilePage() { export function UserProfilePage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -107,8 +295,46 @@ export function UserProfilePage() {
) )
} }
// Get cosmetics data
const backgroundData = getBackgroundData(profile.equipped_background)
const nameColorData = getNameColorData(profile.equipped_name_color)
const titleData = getTitleData(profile.equipped_title)
const displayAvatar = avatarBlobUrl || profile.telegram_avatar_url
// Get nickname styles based on color type
const getNicknameStyles = (): React.CSSProperties => {
if (nameColorData.type === 'solid') {
return { color: nameColorData.color }
}
if (nameColorData.type === 'gradient' && nameColorData.gradient) {
return {
background: `linear-gradient(90deg, ${nameColorData.gradient.join(', ')})`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}
}
if (nameColorData.type === 'animated') {
return {
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3, #ff0000)',
backgroundSize: '400% 100%',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}
}
return { color: '#ffffff' }
}
const getNicknameAnimation = (): string => {
if (nameColorData.type === 'animated' && nameColorData.animation === 'rainbow-shift') {
return 'animate-rainbow-rotate'
}
return ''
}
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
{/* Кнопка назад */} {/* Кнопка назад */}
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
@@ -118,42 +344,107 @@ export function UserProfilePage() {
Назад Назад
</button> </button>
{/* Профиль */} {/* ============ HERO SECTION ============ */}
<GlassCard variant="neon"> <div
<div className="flex items-center gap-6"> className={clsx(
{/* Аватар */} 'relative rounded-3xl overflow-hidden',
<div className="relative"> backgroundData.className
<div className="w-24 h-24 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 shadow-[0_0_14px_rgba(34,211,238,0.15)]"> )}
{avatarBlobUrl ? ( style={backgroundData.styles}
<img >
src={avatarBlobUrl} {/* Default gradient background if no custom background */}
alt={profile.nickname} {!profile.equipped_background && (
className="w-full h-full object-cover" <div className="absolute inset-0 bg-gradient-to-br from-dark-800 via-dark-900 to-neon-900/20" />
/> )}
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-dark-700 to-dark-800"> {/* Overlay for readability */}
<User className="w-12 h-12 text-gray-500" /> <div className="absolute inset-0 bg-gradient-to-t from-dark-900/90 via-dark-900/40 to-transparent" />
{/* Scan lines effect */}
<div className="absolute inset-0 bg-[linear-gradient(transparent_50%,rgba(0,0,0,0.03)_50%)] bg-[length:100%_4px] pointer-events-none" />
{/* Glow effects */}
<div className="absolute top-0 left-1/4 w-96 h-96 bg-neon-500/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-64 h-64 bg-accent-500/10 rounded-full blur-3xl pointer-events-none" />
{/* Content */}
<div className="relative z-10 px-6 py-10 md:px-10 md:py-14">
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-10">
{/* Avatar with Frame */}
<div className="flex-shrink-0">
<HeroAvatar
avatarUrl={displayAvatar}
telegramAvatarUrl={profile.telegram_avatar_url}
nickname={profile.nickname}
frame={profile.equipped_frame}
/>
</div>
{/* User Info */}
<div className="flex-1 text-center md:text-left">
{/* Nickname with color + Title badge */}
<div className="flex flex-wrap items-center justify-center md:justify-start gap-3 mb-3">
<h1
className={clsx(
'text-3xl md:text-4xl font-bold font-display tracking-wide drop-shadow-[0_0_10px_rgba(255,255,255,0.3)]',
getNicknameAnimation()
)}
style={getNicknameStyles()}
>
{profile.nickname}
</h1>
{/* Title badge */}
{titleData && (
<span
className="px-3 py-1 rounded-full text-sm font-semibold border backdrop-blur-sm"
style={{
color: titleData.color,
borderColor: `${titleData.color}50`,
backgroundColor: `${titleData.color}15`,
boxShadow: `0 0 15px ${titleData.color}30`
}}
>
{titleData.text}
</span>
)}
</div>
{/* Admin badge */}
{profile.role === 'admin' && (
<div className="flex justify-center md:justify-start mb-3">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-500/20 border border-purple-500/30 text-purple-400 text-sm font-medium">
<Shield className="w-4 h-4" />
Администратор
</div>
</div> </div>
)} )}
</div>
{/* Online indicator effect */}
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-lg bg-neon-500/20 border border-neon-500/30 flex items-center justify-center">
<Zap className="w-3 h-3 text-neon-400" />
</div>
</div>
{/* Инфо */} {/* Registration date */}
<div> <div className="flex items-center justify-center md:justify-start gap-2 text-gray-400 text-sm mb-4">
<h1 className="text-2xl font-bold text-white mb-2"> <Calendar className="w-4 h-4 text-accent-400" />
{profile.nickname} <span>Зарегистрирован {formatDate(profile.created_at)}</span>
</h1> </div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4 text-accent-400" /> {/* Quick stats preview */}
<span>Зарегистрирован {formatDate(profile.created_at)}</span> <div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm text-gray-300">
<div className="flex items-center gap-1.5">
<Trophy className="w-4 h-4 text-yellow-500" />
<span>{profile.stats.wins_count} побед</span>
</div>
<div className="flex items-center gap-1.5">
<Target className="w-4 h-4 text-neon-400" />
<span>{profile.stats.marathons_count} марафонов</span>
</div>
<div className="flex items-center gap-1.5">
<Flame className="w-4 h-4 text-orange-400" />
<span>{profile.stats.total_points_earned} очков</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</GlassCard> </div>
{/* Статистика */} {/* Статистика */}
<GlassCard> <GlassCard>

View File

@@ -4,7 +4,7 @@ import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui' import { NeonButton } from '@/components/ui'
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2 } from 'lucide-react' import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2, BadgeCheck, BadgeX } from 'lucide-react'
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = { const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
preparing: { preparing: {
@@ -108,6 +108,47 @@ export function AdminMarathonsPage() {
} }
} }
const handleCertify = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Верифицировать марафон',
message: `Верифицировать марафон "${marathon.title}"? Участники смогут зарабатывать монетки.`,
confirmText: 'Верифицировать',
})
if (!confirmed) return
try {
await adminApi.certifyMarathon(marathon.id)
setMarathons(marathons.map(m =>
m.id === marathon.id ? { ...m, is_certified: true, certification_status: 'certified' } : m
))
toast.success('Марафон верифицирован')
} catch (err) {
console.error('Failed to certify marathon:', err)
toast.error('Ошибка верификации')
}
}
const handleRevokeCertification = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Отозвать верификацию',
message: `Отозвать верификацию марафона "${marathon.title}"? Участники больше не смогут зарабатывать монетки.`,
confirmText: 'Отозвать',
variant: 'warning',
})
if (!confirmed) return
try {
await adminApi.revokeCertification(marathon.id)
setMarathons(marathons.map(m =>
m.id === marathon.id ? { ...m, is_certified: false, certification_status: 'none' } : m
))
toast.success('Верификация отозвана')
} catch (err) {
console.error('Failed to revoke certification:', err)
toast.error('Ошибка отзыва верификации')
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -145,6 +186,7 @@ export function AdminMarathonsPage() {
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Верификация</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th>
@@ -154,13 +196,13 @@ export function AdminMarathonsPage() {
<tbody className="divide-y divide-dark-600"> <tbody className="divide-y divide-dark-600">
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={8} className="px-4 py-8 text-center"> <td colSpan={9} className="px-4 py-8 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" /> <div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
</td> </td>
</tr> </tr>
) : marathons.length === 0 ? ( ) : marathons.length === 0 ? (
<tr> <tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400"> <td colSpan={9} className="px-4 py-8 text-center text-gray-400">
Марафоны не найдены Марафоны не найдены
</td> </td>
</tr> </tr>
@@ -179,6 +221,19 @@ export function AdminMarathonsPage() {
{statusConfig.label} {statusConfig.label}
</span> </span>
</td> </td>
<td className="px-4 py-3">
{marathon.is_certified ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
<BadgeCheck className="w-3 h-3" />
Верифицирован
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-dark-600/50 text-gray-400 border border-dark-500">
<BadgeX className="w-3 h-3" />
Нет
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td> <td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td> <td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td>
<td className="px-4 py-3 text-sm text-gray-400"> <td className="px-4 py-3 text-sm text-gray-400">
@@ -188,6 +243,23 @@ export function AdminMarathonsPage() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{marathon.is_certified ? (
<button
onClick={() => handleRevokeCertification(marathon)}
className="p-2 text-yellow-400 hover:bg-yellow-500/20 rounded-lg transition-colors"
title="Отозвать верификацию"
>
<BadgeX className="w-4 h-4" />
</button>
) : (
<button
onClick={() => handleCertify(marathon)}
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
title="Верифицировать"
>
<BadgeCheck className="w-4 h-4" />
</button>
)}
{marathon.status !== 'finished' && ( {marathon.status !== 'finished' && (
<button <button
onClick={() => handleForceFinish(marathon)} onClick={() => handleForceFinish(marathon)}

123
frontend/src/store/shop.ts Normal file
View File

@@ -0,0 +1,123 @@
import { create } from 'zustand'
import { shopApi } from '@/api/shop'
import type { ShopItem, InventoryItem, ShopItemType } from '@/types'
import { useAuthStore } from './auth'
interface ShopState {
// State
balance: number
items: ShopItem[]
inventory: InventoryItem[]
isLoading: boolean
isBalanceLoading: boolean
error: string | null
// Actions
loadBalance: () => Promise<void>
loadItems: (itemType?: ShopItemType) => Promise<void>
loadInventory: (itemType?: ShopItemType) => Promise<void>
purchase: (itemId: number, quantity?: number) => Promise<boolean>
equip: (inventoryId: number) => Promise<boolean>
unequip: (itemType: ShopItemType) => Promise<boolean>
updateBalance: (newBalance: number) => void
clearError: () => void
}
export const useShopStore = create<ShopState>()((set, get) => ({
balance: 0,
items: [],
inventory: [],
isLoading: false,
isBalanceLoading: false,
error: null,
loadBalance: async () => {
set({ isBalanceLoading: true })
try {
const data = await shopApi.getBalance()
set({ balance: data.balance, isBalanceLoading: false })
} catch (err) {
console.error('Failed to load balance:', err)
set({ isBalanceLoading: false })
}
},
loadItems: async (itemType?: ShopItemType) => {
set({ isLoading: true, error: null })
try {
const items = await shopApi.getItems(itemType)
set({ items, isLoading: false })
} catch (err) {
const error = err as { response?: { data?: { detail?: string } } }
set({
error: error.response?.data?.detail || 'Не удалось загрузить товары',
isLoading: false,
})
}
},
loadInventory: async (itemType?: ShopItemType) => {
set({ isLoading: true, error: null })
try {
const inventory = await shopApi.getInventory(itemType)
set({ inventory, isLoading: false })
} catch (err) {
const error = err as { response?: { data?: { detail?: string } } }
set({
error: error.response?.data?.detail || 'Не удалось загрузить инвентарь',
isLoading: false,
})
}
},
purchase: async (itemId: number, quantity: number = 1) => {
try {
const result = await shopApi.purchase(itemId, quantity)
set({ balance: result.new_balance })
// Reload items and inventory to update ownership status
await Promise.all([get().loadItems(), get().loadInventory()])
return true
} catch (err) {
const error = err as { response?: { data?: { detail?: string } } }
set({ error: error.response?.data?.detail || 'Не удалось совершить покупку' })
return false
}
},
equip: async (inventoryId: number) => {
try {
await shopApi.equip(inventoryId)
await get().loadInventory()
// Sync user data to update equipped cosmetics in UI
await useAuthStore.getState().syncUser()
return true
} catch (err) {
const error = err as { response?: { data?: { detail?: string } } }
set({ error: error.response?.data?.detail || 'Не удалось экипировать предмет' })
return false
}
},
unequip: async (itemType: ShopItemType) => {
try {
await shopApi.unequip(itemType)
await get().loadInventory()
// Sync user data to update equipped cosmetics in UI
await useAuthStore.getState().syncUser()
return true
} catch (err) {
const error = err as { response?: { data?: { detail?: string } } }
set({ error: error.response?.data?.detail || 'Не удалось снять предмет' })
return false
}
},
updateBalance: (newBalance: number) => {
set({ balance: newBalance })
},
clearError: () => set({ error: null }),
}))
// Convenience hook for just getting the balance
export const useCoinsBalance = () => useShopStore((state) => state.balance)

View File

@@ -9,6 +9,11 @@ export interface UserPublic {
role: UserRole role: UserRole
telegram_avatar_url: string | null telegram_avatar_url: string | null
created_at: string created_at: string
// Equipped cosmetics
equipped_frame: ShopItemPublic | null
equipped_title: ShopItemPublic | null
equipped_name_color: ShopItemPublic | null
equipped_background: ShopItemPublic | null
} }
// Full user info (only for own profile from /auth/me) // Full user info (only for own profile from /auth/me)
@@ -80,6 +85,7 @@ export interface Marathon {
is_public: boolean is_public: boolean
game_proposal_mode: GameProposalMode game_proposal_mode: GameProposalMode
auto_events_enabled: boolean auto_events_enabled: boolean
allow_consumables: boolean
cover_url: string | null cover_url: string | null
start_date: string | null start_date: string | null
end_date: string | null end_date: string | null
@@ -507,6 +513,8 @@ export interface AdminMarathon {
start_date: string | null start_date: string | null
end_date: string | null end_date: string | null
created_at: string created_at: string
certification_status: string
is_certified: boolean
} }
export interface PlatformStats { export interface PlatformStats {
@@ -690,11 +698,162 @@ export interface UserProfilePublic {
id: number id: number
nickname: string nickname: string
avatar_url: string | null avatar_url: string | null
telegram_avatar_url: string | null
role: UserRole
created_at: string created_at: string
stats: UserStats stats: UserStats
// Equipped cosmetics
equipped_frame: ShopItemPublic | null
equipped_title: ShopItemPublic | null
equipped_name_color: ShopItemPublic | null
equipped_background: ShopItemPublic | null
} }
export interface PasswordChangeData { export interface PasswordChangeData {
current_password: string current_password: string
new_password: string new_password: string
} }
// === Shop types ===
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
export type ConsumableType = 'skip' | 'shield' | 'boost' | 'reroll'
export interface ShopItemPublic {
id: number
code: string
name: string
item_type: ShopItemType
rarity: ItemRarity
asset_data: Record<string, unknown> | null
}
export interface ShopItem {
id: number
item_type: ShopItemType
code: string
name: string
description: string | null
price: number
rarity: ItemRarity
asset_data: Record<string, unknown> | null
is_active: boolean
available_from: string | null
available_until: string | null
stock_limit: number | null
stock_remaining: number | null
created_at: string
is_available: boolean
is_owned: boolean
is_equipped: boolean
}
export interface InventoryItem {
id: number
item: ShopItem
quantity: number
equipped: boolean
purchased_at: string
expires_at: string | null
}
export interface PurchaseRequest {
item_id: number
quantity?: number
}
export interface PurchaseResponse {
success: boolean
item: ShopItem
quantity: number
total_cost: number
new_balance: number
message: string
}
export interface UseConsumableRequest {
item_code: ConsumableType
marathon_id: number
assignment_id?: number
}
export interface UseConsumableResponse {
success: boolean
item_code: string
remaining_quantity: number
effect_description: string
effect_data: Record<string, unknown> | null
}
export interface CoinTransaction {
id: number
amount: number
transaction_type: string
description: string | null
reference_type: string | null
reference_id: number | null
created_at: string
}
export interface CoinsBalance {
balance: number
recent_transactions: CoinTransaction[]
}
export interface ConsumablesStatus {
skips_available: number
skips_used: number
skips_remaining: number | null
shields_available: number
has_shield: boolean
boosts_available: number
has_active_boost: boolean
boost_multiplier: number | null
rerolls_available: number
}
export interface UserCosmetics {
frame: ShopItem | null
title: ShopItem | null
name_color: ShopItem | null
background: ShopItem | null
}
// Certification types
export type CertificationStatus = 'none' | 'pending' | 'certified' | 'rejected'
export interface CertificationStatusResponse {
marathon_id: number
certification_status: CertificationStatus
is_certified: boolean
certification_requested_at: string | null
certified_at: string | null
certified_by_nickname: string | null
rejection_reason: string | null
}
// Rarity colors for UI
export const RARITY_COLORS: Record<ItemRarity, { bg: string; border: string; text: string }> = {
common: { bg: 'bg-gray-500/20', border: 'border-gray-500', text: 'text-gray-400' },
uncommon: { bg: 'bg-green-500/20', border: 'border-green-500', text: 'text-green-400' },
rare: { bg: 'bg-blue-500/20', border: 'border-blue-500', text: 'text-blue-400' },
epic: { bg: 'bg-purple-500/20', border: 'border-purple-500', text: 'text-purple-400' },
legendary: { bg: 'bg-yellow-500/20', border: 'border-yellow-500', text: 'text-yellow-400' },
}
export const RARITY_NAMES: Record<ItemRarity, string> = {
common: 'Обычный',
uncommon: 'Необычный',
rare: 'Редкий',
epic: 'Эпический',
legendary: 'Легендарный',
}
export const ITEM_TYPE_NAMES: Record<ShopItemType, string> = {
frame: 'Рамка',
title: 'Титул',
name_color: 'Цвет ника',
background: 'Фон профиля',
consumable: 'Расходуемое',
}