+ // Avatar content
+ const avatarContent = displayUrl ? (
+

+ ) : (
+
{nickname.charAt(0).toUpperCase()}
)
+
+ // If no frame, return simple avatar
+ if (!frame) {
+ return (
+
+ {avatarContent}
+
+ )
+ }
+
+ // With frame - wrap avatar in frame container
+ const padding = framePadding[size]
+
+ return (
+
+ )
}
// Функция для очистки кэша конкретного пользователя (после загрузки нового аватара)
@@ -105,3 +196,55 @@ export function clearAvatarCache(userId: number) {
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
needsCacheBust.add(userId)
}
+
+// FramePreview component for shop - shows frame without avatar
+interface FramePreviewProps {
+ frame: ShopItemPublic | null
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
+ className?: string
+}
+
+const previewSizes = {
+ xs: 'w-8 h-8',
+ sm: 'w-10 h-10',
+ md: 'w-14 h-14',
+ lg: 'w-20 h-20',
+ xl: 'w-28 h-28',
+}
+
+export function FramePreview({ frame, size = 'md', className }: FramePreviewProps) {
+ if (!frame?.asset_data) {
+ return (
+
+
+
+ )
+ }
+
+ const padding = framePadding[size]
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
index 52ead2e..fc90c86 100644
--- a/frontend/src/components/ui/index.ts
+++ b/frontend/src/components/ui/index.ts
@@ -3,7 +3,7 @@ export { Input } from './Input'
export { Card, CardHeader, CardTitle, CardContent } from './Card'
export { ToastContainer } from './Toast'
export { ConfirmModal } from './ConfirmModal'
-export { UserAvatar, clearAvatarCache } from './UserAvatar'
+export { UserAvatar, clearAvatarCache, FramePreview } from './UserAvatar'
// New design system components
export { GlitchText, GlitchHeading } from './GlitchText'
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 98b3a1f..11456f8 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -571,6 +571,125 @@ input:-webkit-autofill:active {
@apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900;
}
+/* ========================================
+ Frame Animations (Shop cosmetics)
+ ======================================== */
+/* Fire pulse animation */
+@keyframes fire-pulse {
+ 0%, 100% {
+ background-size: 200% 200%;
+ background-position: 0% 50%;
+ filter: brightness(1);
+ }
+ 50% {
+ background-size: 220% 220%;
+ background-position: 100% 50%;
+ filter: brightness(1.2);
+ }
+}
+
+.animate-fire-pulse {
+ animation: fire-pulse 2s ease-in-out infinite;
+}
+
+/* Rainbow rotate animation */
+@keyframes rainbow-rotate {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 50% {
+ background-position: 100% 50%;
+ }
+ 100% {
+ background-position: 0% 50%;
+ }
+}
+
+.animate-rainbow-rotate {
+ animation: rainbow-rotate 3s linear infinite;
+ background-size: 400% 400%;
+}
+
+/* Rainbow text color shift */
+@keyframes rainbow-shift {
+ 0% { color: #FF0000; }
+ 16% { color: #FF7F00; }
+ 33% { color: #FFFF00; }
+ 50% { color: #00FF00; }
+ 66% { color: #0000FF; }
+ 83% { color: #9400D3; }
+ 100% { color: #FF0000; }
+}
+
+.animate-rainbow-shift {
+ animation: rainbow-shift 4s linear infinite;
+}
+
+/* Fire particles background animation */
+@keyframes fire-particles {
+ 0%, 100% {
+ background-position: 0% 100%;
+ }
+ 50% {
+ background-position: 100% 0%;
+ }
+}
+
+.animate-fire-particles {
+ animation: fire-particles 3s ease-in-out infinite;
+}
+
+/* Star twinkle animation */
+@keyframes twinkle {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.3;
+ }
+}
+
+.animate-twinkle {
+ animation: twinkle 2s ease-in-out infinite;
+}
+
+/* Stars background with multiple twinkling layers */
+.bg-stars-animated {
+ position: relative;
+ background: linear-gradient(135deg, #0d1b2a 0%, #1b263b 50%, #0d1b2a 100%);
+}
+
+.bg-stars-animated::before,
+.bg-stars-animated::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+.bg-stars-animated::before {
+ background:
+ radial-gradient(2px 2px at 20px 30px, #fff, transparent),
+ radial-gradient(2px 2px at 80px 60px, rgba(255,255,255,0.9), transparent),
+ radial-gradient(1px 1px at 130px 40px, #fff, transparent),
+ radial-gradient(2px 2px at 180px 90px, rgba(255,255,255,0.8), transparent),
+ radial-gradient(1px 1px at 50px 100px, #fff, transparent),
+ radial-gradient(1.5px 1.5px at 220px 20px, rgba(255,255,255,0.9), transparent);
+ background-size: 250px 150px;
+ animation: twinkle 3s ease-in-out infinite;
+}
+
+.bg-stars-animated::after {
+ background:
+ radial-gradient(1px 1px at 40px 20px, rgba(255,255,255,0.7), transparent),
+ radial-gradient(2px 2px at 100px 80px, #fff, transparent),
+ radial-gradient(1.5px 1.5px at 160px 30px, rgba(255,255,255,0.8), transparent),
+ radial-gradient(1px 1px at 200px 70px, #fff, transparent),
+ radial-gradient(2px 2px at 70px 110px, rgba(255,255,255,0.9), transparent);
+ background-size: 220px 140px;
+ animation: twinkle 4s ease-in-out infinite 1s;
+}
+
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
*,
diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx
new file mode 100644
index 0000000..06877ba
--- /dev/null
+++ b/frontend/src/pages/InventoryPage.tsx
@@ -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
= {
+ skip: ,
+ shield: ,
+ boost: ,
+ reroll: ,
+}
+
+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] ||
+ }
+
+ // 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 (
+
+ )
+ }
+ if (data?.style === 'animated') {
+ return (
+
+ )
+ }
+ const solidColor = data?.color || '#ffffff'
+ return (
+
+ )
+ }
+
+ // Background preview
+ if (item.item_type === 'background') {
+ const data = item.asset_data as { type?: string; color?: string; gradient?: string[]; pattern?: string; animation?: string } | null
+ let bgStyle: React.CSSProperties = {}
+ let animClass = ''
+
+ if (data?.type === 'solid' && data.color) {
+ bgStyle = { backgroundColor: data.color }
+ } else if (data?.type === 'gradient' && data.gradient) {
+ bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
+ } else if (data?.type === 'pattern') {
+ if (data.pattern === 'stars') {
+ bgStyle = {
+ background: `
+ radial-gradient(1px 1px at 10px 10px, #fff, transparent),
+ radial-gradient(1px 1px at 30px 25px, rgba(255,255,255,0.8), transparent),
+ 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 (
+
+ )
+ }
+
+ if (item.item_type === 'frame') {
+ return
+ }
+ if (item.item_type === 'title' && item.asset_data?.text) {
+ return (
+
+ {item.asset_data.text as string}
+
+ )
+ }
+ return
+ }
+
+ const isCosmetic = item.item_type !== 'consumable'
+
+ return (
+
+ {/* Equipped badge */}
+ {equipped && (
+
+
+ Надето
+
+ )}
+
+ {/* Rarity badge */}
+ {!equipped && (
+
+ {RARITY_NAMES[item.rarity]}
+
+ )}
+
+ {/* Item preview */}
+
+ {getItemPreview()}
+
+
+ {/* Item info */}
+ {item.name}
+
+ {ITEM_TYPE_NAMES[item.item_type]}
+
+
+ {/* Quantity for consumables */}
+ {item.item_type === 'consumable' && (
+
+ x{quantity}
+
+ )}
+
+ {/* Action button */}
+ {isCosmetic && (
+
+ {equipped ? (
+ onUnequip(item.item_type)}
+ disabled={isProcessing}
+ >
+ {isProcessing ? : 'Снять'}
+
+ ) : (
+ onEquip(inventoryItem.id)}
+ disabled={isProcessing}
+ >
+ {isProcessing ? : 'Надеть'}
+
+ )}
+
+ )}
+
+ {/* Consumable info */}
+ {item.item_type === 'consumable' && (
+
+ Используйте в марафоне
+
+ )}
+
+ )
+}
+
+export function InventoryPage() {
+ const { inventory, balance, isLoading, loadInventory, loadBalance, equip, unequip, clearError, error } = useShopStore()
+ const toast = useToast()
+
+ const [activeTab, setActiveTab] = useState('all')
+ const [processingId, setProcessingId] = useState(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: },
+ { id: 'frame', label: 'Рамки', icon: },
+ { id: 'title', label: 'Титулы', icon: },
+ { id: 'name_color', label: 'Цвета', icon: },
+ { id: 'background', label: 'Фоны', icon: },
+ { id: 'consumable', label: 'Расходники', icon: },
+ ]
+
+ if (isLoading && inventory.length === 0) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ Инвентарь
+
+
+ Твои предметы и косметика
+
+
+
+
+ {/* Balance */}
+
+
+ {balance}
+
+
+ {/* Link to shop */}
+
+
+
+ Магазин
+
+
+
+
+
+ {/* Tabs */}
+
+ {tabs.map(tab => (
+
+ ))}
+
+
+ {/* Empty state */}
+ {filteredInventory.length === 0 ? (
+
+
+
+ {activeTab === 'all'
+ ? 'Твой инвентарь пуст'
+ : 'Нет предметов в этой категории'}
+
+
+
+
+ Перейти в магазин
+
+
+
+ ) : (
+ <>
+ {/* Cosmetic items */}
+ {cosmeticItems.length > 0 && (activeTab === 'all' || activeTab !== 'consumable') && (
+
+ {activeTab === 'all' && (
+
Косметика
+ )}
+
+ {cosmeticItems.map(inv => (
+
+ ))}
+
+
+ )}
+
+ {/* Consumable items */}
+ {consumableItems.length > 0 && (activeTab === 'all' || activeTab === 'consumable') && (
+
+ {activeTab === 'all' && (
+
Расходуемые
+ )}
+
+ {consumableItems.map(inv => (
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/LeaderboardPage.tsx b/frontend/src/pages/LeaderboardPage.tsx
index 42673f8..9b98293 100644
--- a/frontend/src/pages/LeaderboardPage.tsx
+++ b/frontend/src/pages/LeaderboardPage.tsx
@@ -1,11 +1,82 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
-import type { LeaderboardEntry } from '@/types'
-import { GlassCard } from '@/components/ui'
+import type { LeaderboardEntry, ShopItemPublic, User } from '@/types'
+import { GlassCard, UserAvatar } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
+// Helper to get name color styles and animation class
+function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
+ if (!nameColor?.asset_data) return { styles: {}, className: '' }
+
+ const data = nameColor.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string }
+
+ if (data.style === 'gradient' && data.gradient) {
+ return {
+ styles: {
+ background: `linear-gradient(90deg, ${data.gradient.join(', ')})`,
+ WebkitBackgroundClip: 'text',
+ WebkitTextFillColor: 'transparent',
+ backgroundClip: 'text',
+ },
+ className: '',
+ }
+ }
+
+ if (data.style === 'animated') {
+ return {
+ styles: {
+ background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
+ backgroundSize: '400% 100%',
+ WebkitBackgroundClip: 'text',
+ WebkitTextFillColor: 'transparent',
+ backgroundClip: 'text',
+ },
+ className: 'animate-rainbow-rotate',
+ }
+ }
+
+ if (data.style === 'solid' && data.color) {
+ return { styles: { color: data.color }, className: '' }
+ }
+
+ return { styles: {}, className: '' }
+}
+
+// Helper to get title data
+function getTitleData(title: ShopItemPublic | null | undefined): { text: string; color: string } | null {
+ if (!title?.asset_data) return null
+
+ const data = title.asset_data as { text?: string; color?: string }
+ if (!data.text) return null
+
+ return {
+ text: data.text,
+ color: data.color || '#ffffff',
+ }
+}
+
+// Styled nickname component
+function StyledNickname({ user, className = '' }: { user: User; className?: string }) {
+ const nameColorData = getNameColorData(user.equipped_name_color)
+ const titleData = getTitleData(user.equipped_title)
+
+ return (
+
+ {user.nickname}
+ {titleData && (
+
+ {titleData.text}
+
+ )}
+
+ )
+}
+
export function LeaderboardPage() {
const { id } = useParams<{ id: string }>()
const user = useAuthStore((state) => state.user)
@@ -117,48 +188,66 @@ export function LeaderboardPage() {
{/* 2nd place */}
-
-
2
+
+
-
+
-
{topThree[1].user.nickname}
+
+
+
{topThree[1].total_points} очков
{/* 1st place */}
-
-
+
-
+
-
{topThree[0].user.nickname}
+
+
+
{topThree[0].total_points} очков
{/* 3rd place */}
-
-
3
+
+
-
+
-
{topThree[2].user.nickname}
+
+
+
{topThree[2].total_points} очков
@@ -222,20 +311,32 @@ export function LeaderboardPage() {
{/* Rank */}
{rankConfig.icon}
+ {/* Avatar */}
+
+
+
+
{/* User info */}
-
+
- {entry.user.nickname}
+
{isCurrentUser && (
diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx
index 6053e96..6796270 100644
--- a/frontend/src/pages/ProfilePage.tsx
+++ b/frontend/src/pages/ProfilePage.tsx
@@ -2,9 +2,10 @@ import { useState, useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
+import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { usersApi, telegramApi, authApi } from '@/api'
-import type { UserStats } from '@/types'
+import type { UserStats, ShopItemPublic } from '@/types'
import { useToast } from '@/store/toast'
import {
NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
@@ -13,8 +14,9 @@ import {
User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
- AlertTriangle, FileCheck
+ AlertTriangle, FileCheck, Backpack, Edit3
} from 'lucide-react'
+import clsx from 'clsx'
// Schemas
const nicknameSchema = z.object({
@@ -33,6 +35,235 @@ const passwordSchema = z.object({
type NicknameForm = z.infer
type PasswordForm = z.infer
+// ============ COSMETICS HELPERS ============
+
+// Background asset_data structure:
+// - type: 'solid' | 'gradient' | 'pattern' | 'animated'
+// - color: '#1a1a2e' (for solid)
+// - gradient: ['#1a1a2e', '#4a0080'] (for gradient)
+// - pattern: 'stars' | 'gaming-icons' (for pattern)
+// - animation: 'fire-particles' (for animated)
+// - animated: boolean (for animated patterns)
+
+interface BackgroundResult {
+ styles: React.CSSProperties
+ className: string
+}
+
+function getBackgroundData(background: ShopItemPublic | null): BackgroundResult {
+ if (!background?.asset_data) {
+ return { styles: {}, className: '' }
+ }
+
+ const data = background.asset_data as {
+ type?: string
+ gradient?: string[]
+ pattern?: string
+ color?: string
+ animation?: string
+ animated?: boolean
+ }
+
+ const styles: React.CSSProperties = {}
+ let className = ''
+
+ switch (data.type) {
+ case 'solid':
+ if (data.color) {
+ styles.backgroundColor = data.color
+ }
+ break
+ case 'gradient':
+ if (data.gradient && data.gradient.length > 0) {
+ styles.background = `linear-gradient(135deg, ${data.gradient.join(', ')})`
+ }
+ break
+ case 'pattern':
+ // Pattern backgrounds - use CSS classes for animated stars
+ if (data.pattern === 'stars') {
+ // Use CSS class for twinkling stars effect
+ className = 'bg-stars-animated'
+ } else if (data.pattern === 'gaming-icons') {
+ styles.background = `
+ linear-gradient(45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
+ linear-gradient(-45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
+ linear-gradient(-45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
+ linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
+ `
+ styles.backgroundSize = '40px 40px, 40px 40px, 40px 40px, 40px 40px, 100% 100%'
+ }
+ break
+ case 'animated':
+ // Animated backgrounds
+ if (data.animation === 'fire-particles') {
+ styles.background = `
+ radial-gradient(circle at 50% 100%, rgba(255,100,0,0.4) 0%, transparent 50%),
+ radial-gradient(circle at 30% 80%, rgba(255,50,0,0.3) 0%, transparent 40%),
+ radial-gradient(circle at 70% 90%, rgba(255,150,0,0.3) 0%, transparent 45%),
+ linear-gradient(to top, #1a0a00 0%, #0d0d0d 60%, #1a1a2e 100%)
+ `
+ className = 'animate-fire-pulse'
+ }
+ break
+ }
+
+ return { styles, className }
+}
+
+// Name color asset_data structure:
+// - style: 'solid' | 'gradient' | 'animated'
+// - color: '#FF4444' (for solid)
+// - gradient: ['#FF6B6B', '#FFE66D'] (for gradient)
+// - animation: 'rainbow-shift' (for animated)
+
+interface NameColorResult {
+ type: 'solid' | 'gradient' | 'animated'
+ color?: string
+ gradient?: string[]
+ animation?: string
+}
+
+function getNameColorData(nameColor: ShopItemPublic | null): NameColorResult {
+ if (!nameColor?.asset_data) {
+ return { type: 'solid', color: '#ffffff' }
+ }
+
+ const data = nameColor.asset_data as {
+ style?: string
+ color?: string
+ gradient?: string[]
+ animation?: string
+ }
+
+ if (data.style === 'gradient' && data.gradient) {
+ return { type: 'gradient', gradient: data.gradient }
+ }
+ if (data.style === 'animated') {
+ return { type: 'animated', animation: data.animation }
+ }
+ return { type: 'solid', color: data.color || '#ffffff' }
+}
+
+// Get title from equipped_title
+function getTitleData(title: ShopItemPublic | null): { text: string; color: string } | null {
+ if (!title?.asset_data) return null
+ const data = title.asset_data as { text?: string; color?: string }
+ if (!data.text) return null
+ return { text: data.text, color: data.color || '#ffffff' }
+}
+
+// Get frame styles from asset_data
+function getFrameStyles(frame: ShopItemPublic | null): React.CSSProperties {
+ if (!frame?.asset_data) return {}
+
+ const data = frame.asset_data as {
+ border_color?: string
+ gradient?: string[]
+ glow_color?: string
+ }
+
+ const styles: React.CSSProperties = {}
+
+ if (data.gradient && data.gradient.length > 0) {
+ styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
+ styles.backgroundSize = '400% 400%'
+ } else if (data.border_color) {
+ styles.background = data.border_color
+ }
+
+ if (data.glow_color) {
+ styles.boxShadow = `0 0 20px ${data.glow_color}, 0 0 40px ${data.glow_color}40`
+ }
+
+ return styles
+}
+
+// Get frame animation class
+function getFrameAnimation(frame: ShopItemPublic | null): string {
+ if (!frame?.asset_data) return ''
+ const data = frame.asset_data as { animation?: string }
+ if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
+ if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
+ return ''
+}
+
+// ============ HERO AVATAR COMPONENT ============
+
+function HeroAvatar({
+ avatarUrl,
+ nickname,
+ frame,
+ onClick,
+ isUploading,
+ isLoading
+}: {
+ avatarUrl: string | null | undefined
+ nickname: string | undefined
+ frame: ShopItemPublic | null
+ onClick: () => void
+ isUploading: boolean
+ isLoading: boolean
+}) {
+ if (isLoading) {
+ return
+ }
+
+ const avatarContent = (
+
+ {avatarUrl ? (
+

+ ) : (
+
+
+
+ )}
+
+ )
+
+ const hoverOverlay = (
+
+ {isUploading ? (
+
+ ) : (
+
+ )}
+
+ )
+
+ if (!frame) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
export function ProfilePage() {
const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
const toast = useToast()
@@ -298,76 +529,198 @@ export function ProfilePage() {
const isLinked = !!user?.telegram_id
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
+ // Get cosmetics data
+ const equippedFrame = user?.equipped_frame as ShopItemPublic | null
+ const equippedTitle = user?.equipped_title as ShopItemPublic | null
+ const equippedNameColor = user?.equipped_name_color as ShopItemPublic | null
+ const equippedBackground = user?.equipped_background as ShopItemPublic | null
+
+ const titleData = getTitleData(equippedTitle)
+ const nameColorData = getNameColorData(equippedNameColor)
+
+ // Get nickname styles based on color type
+ const getNicknameStyles = (): React.CSSProperties => {
+ if (nameColorData.type === 'solid') {
+ return { color: nameColorData.color }
+ }
+ if (nameColorData.type === 'gradient' && nameColorData.gradient) {
+ return {
+ background: `linear-gradient(90deg, ${nameColorData.gradient.join(', ')})`,
+ WebkitBackgroundClip: 'text',
+ WebkitTextFillColor: 'transparent',
+ backgroundClip: 'text',
+ }
+ }
+ if (nameColorData.type === 'animated') {
+ // Rainbow animated - uses CSS animation with background-position
+ return {
+ background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3, #ff0000)',
+ backgroundSize: '400% 100%',
+ WebkitBackgroundClip: 'text',
+ WebkitTextFillColor: 'transparent',
+ backgroundClip: 'text',
+ }
+ }
+ return { color: '#ffffff' }
+ }
+
+ // Get nickname animation class
+ const getNicknameAnimation = (): string => {
+ if (nameColorData.type === 'animated' && nameColorData.animation === 'rainbow-shift') {
+ return 'animate-rainbow-rotate'
+ }
+ return ''
+ }
+
+ // Get background data
+ const backgroundData = getBackgroundData(equippedBackground)
+
return (
-
- {/* Header */}
-
-
Мой профиль
-
Настройки вашего аккаунта
-
+
+ {/* ============ HERO SECTION ============ */}
+
+ {/* Default gradient background if no custom background */}
+ {!equippedBackground && (
+
+ )}
- {/* Profile Card */}
-
-
- {/* Avatar */}
-
- {isLoadingAvatar ? (
-
- ) : (
-
+
+ {/* Scan lines effect */}
+
+
+ {/* Glow effects */}
+
+
+
+ {/* Content */}
+
+
+ {/* Avatar with Frame */}
+
+
- {displayAvatar ? (
-
- ) : (
-
-
-
- )}
-
- {isUploadingAvatar ? (
-
- ) : (
-
- )}
-
-
- )}
-
-
-
- {/* Nickname Form */}
-
-
+
+
+
+ {/* User Info */}
+
+ {/* Nickname with color + Title badge */}
+
+
+ {user?.nickname || 'Игрок'}
+
+
+ {/* Title badge */}
+ {titleData && (
+
+ {titleData.text}
+
+ )}
+
+
+ {/* Role badge */}
+ {user?.role === 'admin' && (
+
+
+ Администратор
+
+ )}
+
+ {/* Quick stats preview */}
+ {stats && (
+
+
+
+ {stats.wins_count} побед
+
+
+
+ {stats.marathons_count} марафонов
+
+
+
+ {stats.total_points_earned} очков
+
+
+ )}
+
+ {/* Inventory link */}
+
+
+
+ Инвентарь
+
+
+
+
+
+ {/* ============ NICKNAME EDIT SECTION ============ */}
+
+
+
+
+
+
+
Изменить никнейм
+
Ваше игровое имя
+
+
+
{/* Stats */}
diff --git a/frontend/src/pages/ShopPage.tsx b/frontend/src/pages/ShopPage.tsx
new file mode 100644
index 0000000..dcafcba
--- /dev/null
+++ b/frontend/src/pages/ShopPage.tsx
@@ -0,0 +1,470 @@
+import { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
+import { useShopStore } from '@/store/shop'
+import { useToast } from '@/store/toast'
+import { useConfirm } from '@/store/confirm'
+import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
+import {
+ Loader2, Coins, ShoppingBag, Package, Sparkles,
+ Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward,
+ Minus, Plus
+} from 'lucide-react'
+import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
+import { RARITY_COLORS, RARITY_NAMES } from '@/types'
+import clsx from 'clsx'
+
+const ITEM_TYPE_ICONS: Record
= {
+ frame: ,
+ title: ,
+ name_color: ,
+ background: ,
+ consumable: ,
+}
+
+const CONSUMABLE_ICONS: Record = {
+ skip: ,
+ shield: ,
+ boost: ,
+ reroll: ,
+}
+
+interface ShopItemCardProps {
+ item: ShopItem
+ onPurchase: (item: ShopItem, quantity: number) => void
+ isPurchasing: boolean
+}
+
+function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
+ const [quantity, setQuantity] = useState(1)
+ const rarityColors = RARITY_COLORS[item.rarity]
+ const isConsumable = item.item_type === 'consumable'
+ const maxQuantity = item.stock_remaining !== null ? Math.min(10, item.stock_remaining) : 10
+ const totalPrice = item.price * quantity
+
+ const incrementQuantity = () => {
+ if (quantity < maxQuantity) {
+ setQuantity(q => q + 1)
+ }
+ }
+
+ const decrementQuantity = () => {
+ if (quantity > 1) {
+ setQuantity(q => q - 1)
+ }
+ }
+
+ const getItemPreview = () => {
+ if (item.item_type === 'consumable') {
+ return CONSUMABLE_ICONS[item.code] ||
+ }
+
+ // Name color preview - handles solid, gradient, animated
+ if (item.item_type === 'name_color') {
+ const data = item.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string } | null
+
+ // Gradient style
+ if (data?.style === 'gradient' && data.gradient) {
+ return (
+
+ )
+ }
+
+ // Animated rainbow style
+ if (data?.style === 'animated') {
+ return (
+
+ )
+ }
+
+ // Solid color style (default)
+ const solidColor = data?.color || '#ffffff'
+ return (
+
+ )
+ }
+
+ // Background preview
+ if (item.item_type === 'background') {
+ const data = item.asset_data as { type?: string; color?: string; gradient?: string[]; pattern?: string; animation?: string } | null
+ let bgStyle: React.CSSProperties = {}
+ let animClass = ''
+
+ if (data?.type === 'solid' && data.color) {
+ bgStyle = { backgroundColor: data.color }
+ } else if (data?.type === 'gradient' && data.gradient) {
+ bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
+ } else if (data?.type === 'pattern') {
+ if (data.pattern === 'stars') {
+ bgStyle = {
+ background: `
+ radial-gradient(1px 1px at 10px 10px, #fff, transparent),
+ radial-gradient(1px 1px at 30px 25px, rgba(255,255,255,0.8), transparent),
+ radial-gradient(1px 1px at 50px 15px, #fff, transparent),
+ linear-gradient(135deg, #0d1b2a 0%, #1b263b 100%)
+ `,
+ backgroundSize: '60px 40px, 60px 40px, 60px 40px, 100% 100%'
+ }
+ animClass = 'animate-twinkle'
+ } else if (data.pattern === 'gaming-icons') {
+ bgStyle = {
+ background: `
+ linear-gradient(45deg, rgba(34,211,238,0.2) 25%, transparent 25%),
+ linear-gradient(-45deg, rgba(168,85,247,0.2) 25%, transparent 25%),
+ linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
+ `,
+ backgroundSize: '20px 20px, 20px 20px, 100% 100%'
+ }
+ }
+ } else if (data?.type === 'animated' && data.animation === 'fire-particles') {
+ bgStyle = {
+ background: `
+ radial-gradient(circle at 50% 100%, rgba(255,100,0,0.5) 0%, transparent 50%),
+ radial-gradient(circle at 30% 80%, rgba(255,50,0,0.4) 0%, transparent 40%),
+ linear-gradient(to top, #1a0a00 0%, #0d0d0d 100%)
+ `
+ }
+ animClass = 'animate-fire-pulse'
+ }
+
+ return (
+
+ )
+ }
+
+ if (item.item_type === 'frame') {
+ // Use FramePreview for animated and gradient frames
+ const frameItem: ShopItemPublic = {
+ id: item.id,
+ code: item.code,
+ name: item.name,
+ item_type: item.item_type,
+ rarity: item.rarity,
+ asset_data: item.asset_data,
+ }
+ return
+ }
+ if (item.item_type === 'title' && item.asset_data?.text) {
+ return (
+
+ {item.asset_data.text as string}
+
+ )
+ }
+ return ITEM_TYPE_ICONS[item.item_type]
+ }
+
+ return (
+
+ {/* Rarity badge */}
+
+ {RARITY_NAMES[item.rarity]}
+
+
+ {/* Item preview */}
+
+ {getItemPreview()}
+
+
+ {/* Item info */}
+ {item.name}
+
+ {item.description}
+
+
+ {/* Quantity selector for consumables */}
+ {isConsumable && !item.is_owned && item.is_available && (
+
+
+
{quantity}
+
+
+ )}
+
+ {/* Price and action */}
+
+
+
+ {isConsumable ? totalPrice : item.price}
+ {isConsumable && quantity > 1 && (
+ ({item.price}×{quantity})
+ )}
+
+
+ {item.is_owned && !isConsumable ? (
+
+
+ Куплено
+
+ ) : item.is_equipped ? (
+
Надето
+ ) : (
+
onPurchase(item, quantity)}
+ disabled={isPurchasing || !item.is_available}
+ >
+ {isPurchasing ? (
+
+ ) : (
+ 'Купить'
+ )}
+
+ )}
+
+
+ {/* Stock info */}
+ {item.stock_remaining !== null && (
+
+ Осталось: {item.stock_remaining}
+
+ )}
+
+ )
+}
+
+export function ShopPage() {
+ const { items, balance, isLoading, loadItems, loadBalance, purchase, clearError, error } = useShopStore()
+ const toast = useToast()
+ const confirm = useConfirm()
+
+ const [activeTab, setActiveTab] = useState('all')
+ const [purchasingId, setPurchasingId] = useState(null)
+
+ useEffect(() => {
+ loadBalance()
+ loadItems()
+ }, [loadBalance, loadItems])
+
+ useEffect(() => {
+ if (error) {
+ toast.error(error)
+ clearError()
+ }
+ }, [error, toast, clearError])
+
+ const handlePurchase = async (item: ShopItem, quantity: number = 1) => {
+ const totalCost = item.price * quantity
+ const isConsumable = item.item_type === 'consumable'
+ const quantityText = quantity > 1 ? ` (×${quantity})` : ''
+
+ const confirmed = await confirm({
+ title: 'Подтвердите покупку',
+ message: isConsumable && quantity > 1
+ ? `Купить "${item.name}" × ${quantity} шт. за ${totalCost} монет?`
+ : `Купить "${item.name}" за ${item.price} монет?`,
+ confirmText: 'Купить',
+ cancelText: 'Отмена',
+ })
+
+ if (!confirmed) return
+
+ setPurchasingId(item.id)
+ const success = await purchase(item.id, quantity)
+ setPurchasingId(null)
+
+ if (success) {
+ toast.success(`Вы приобрели "${item.name}"${quantityText}!`)
+ }
+ }
+
+ const filteredItems = activeTab === 'all'
+ ? items
+ : items.filter(item => item.item_type === activeTab)
+
+ // Group items by type for "All" tab
+ const itemsByType: Record = {
+ frame: [],
+ title: [],
+ name_color: [],
+ background: [],
+ consumable: [],
+ }
+
+ if (activeTab === 'all') {
+ items.forEach(item => {
+ if (itemsByType[item.item_type]) {
+ itemsByType[item.item_type].push(item)
+ }
+ })
+ }
+
+ const categoryOrder: ShopItemType[] = ['frame', 'title', 'name_color', 'background', 'consumable']
+ const categoryLabels: Record = {
+ frame: { label: 'Рамки профиля', icon: },
+ title: { label: 'Титулы', icon: },
+ name_color: { label: 'Цвета ника', icon: },
+ background: { label: 'Фоны профиля', icon: },
+ consumable: { label: 'Расходуемые предметы', icon: },
+ }
+
+ const tabs: { id: ShopItemType | 'all'; label: string; icon: React.ReactNode }[] = [
+ { id: 'all', label: 'Все', icon: },
+ { id: 'frame', label: 'Рамки', icon: },
+ { id: 'title', label: 'Титулы', icon: },
+ { id: 'name_color', label: 'Цвета', icon: },
+ { id: 'background', label: 'Фоны', icon: },
+ { id: 'consumable', label: 'Расходники', icon: },
+ ]
+
+ if (isLoading && items.length === 0) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ Магазин
+
+
+ Покупай косметику и расходуемые предметы
+
+
+
+
+ {/* Balance */}
+
+
+ {balance}
+
+
+ {/* Link to inventory */}
+
+
+
+ Инвентарь
+
+
+
+
+
+ {/* Tabs */}
+
+ {tabs.map(tab => (
+
+ ))}
+
+
+ {/* Items grid */}
+ {filteredItems.length === 0 ? (
+
+
+ Нет доступных товаров в этой категории
+
+ ) : activeTab === 'all' ? (
+ // Grouped view for "All" tab
+
+ {categoryOrder.map(category => {
+ const categoryItems = itemsByType[category]
+ if (categoryItems.length === 0) return null
+
+ const { label, icon } = categoryLabels[category]
+
+ return (
+
+
+
+ {icon}
+
+
{label}
+
({categoryItems.length})
+
+
+ {categoryItems.map(item => (
+
+ ))}
+
+
+ )
+ })}
+
+ ) : (
+ // Regular grid for specific category
+
+ {filteredItems.map(item => (
+
+ ))}
+
+ )}
+
+ {/* Info about coins */}
+
+
+
+ Как заработать монеты?
+
+
+ - • Выполняй задания в сертифицированных марафонах
+ - • Easy задание — 5 монет, Medium — 12 монет, Hard — 25 монет
+ - • Playthrough — ~5% от заработанных очков
+ - • Топ-3 места в марафоне: 1-е — 100, 2-е — 50, 3-е — 30 монет
+
+
+
+ )
+}
diff --git a/frontend/src/pages/UserProfilePage.tsx b/frontend/src/pages/UserProfilePage.tsx
index 05939d1..805bbaf 100644
--- a/frontend/src/pages/UserProfilePage.tsx
+++ b/frontend/src/pages/UserProfilePage.tsx
@@ -2,12 +2,200 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { usersApi } from '@/api'
-import type { UserProfilePublic } from '@/types'
+import type { UserProfilePublic, ShopItemPublic } from '@/types'
import { GlassCard, StatsCard } from '@/components/ui'
import {
User, Trophy, Target, CheckCircle, Flame,
- Loader2, ArrowLeft, Calendar, Zap
+ Loader2, ArrowLeft, Calendar, Shield
} from 'lucide-react'
+import clsx from 'clsx'
+
+// ============ COSMETICS HELPERS ============
+
+interface BackgroundResult {
+ styles: React.CSSProperties
+ className: string
+}
+
+function getBackgroundData(background: ShopItemPublic | null): BackgroundResult {
+ if (!background?.asset_data) {
+ return { styles: {}, className: '' }
+ }
+
+ const data = background.asset_data as {
+ type?: string
+ gradient?: string[]
+ pattern?: string
+ color?: string
+ animation?: string
+ animated?: boolean
+ }
+
+ const styles: React.CSSProperties = {}
+ let className = ''
+
+ switch (data.type) {
+ case 'solid':
+ if (data.color) {
+ styles.backgroundColor = data.color
+ }
+ break
+ case 'gradient':
+ if (data.gradient && data.gradient.length > 0) {
+ styles.background = `linear-gradient(135deg, ${data.gradient.join(', ')})`
+ }
+ break
+ case 'pattern':
+ if (data.pattern === 'stars') {
+ className = 'bg-stars-animated'
+ } else if (data.pattern === 'gaming-icons') {
+ styles.background = `
+ linear-gradient(45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
+ linear-gradient(-45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
+ linear-gradient(-45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
+ linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
+ `
+ styles.backgroundSize = '40px 40px, 40px 40px, 40px 40px, 40px 40px, 100% 100%'
+ }
+ break
+ case 'animated':
+ if (data.animation === 'fire-particles') {
+ styles.background = `
+ radial-gradient(circle at 50% 100%, rgba(255,100,0,0.4) 0%, transparent 50%),
+ radial-gradient(circle at 30% 80%, rgba(255,50,0,0.3) 0%, transparent 40%),
+ radial-gradient(circle at 70% 90%, rgba(255,150,0,0.3) 0%, transparent 45%),
+ linear-gradient(to top, #1a0a00 0%, #0d0d0d 60%, #1a1a2e 100%)
+ `
+ className = 'animate-fire-pulse'
+ }
+ break
+ }
+
+ return { styles, className }
+}
+
+interface NameColorResult {
+ type: 'solid' | 'gradient' | 'animated'
+ color?: string
+ gradient?: string[]
+ animation?: string
+}
+
+function getNameColorData(nameColor: ShopItemPublic | null): NameColorResult {
+ if (!nameColor?.asset_data) {
+ return { type: 'solid', color: '#ffffff' }
+ }
+
+ const data = nameColor.asset_data as {
+ style?: string
+ color?: string
+ gradient?: string[]
+ animation?: string
+ }
+
+ if (data.style === 'gradient' && data.gradient) {
+ return { type: 'gradient', gradient: data.gradient }
+ }
+ if (data.style === 'animated') {
+ return { type: 'animated', animation: data.animation }
+ }
+ return { type: 'solid', color: data.color || '#ffffff' }
+}
+
+function getTitleData(title: ShopItemPublic | null): { text: string; color: string } | null {
+ if (!title?.asset_data) return null
+ const data = title.asset_data as { text?: string; color?: string }
+ if (!data.text) return null
+ return { text: data.text, color: data.color || '#ffffff' }
+}
+
+function getFrameStyles(frame: ShopItemPublic | null): React.CSSProperties {
+ if (!frame?.asset_data) return {}
+
+ const data = frame.asset_data as {
+ border_color?: string
+ gradient?: string[]
+ glow_color?: string
+ }
+
+ const styles: React.CSSProperties = {}
+
+ if (data.gradient && data.gradient.length > 0) {
+ styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
+ styles.backgroundSize = '400% 400%'
+ } else if (data.border_color) {
+ styles.background = data.border_color
+ }
+
+ if (data.glow_color) {
+ styles.boxShadow = `0 0 20px ${data.glow_color}, 0 0 40px ${data.glow_color}40`
+ }
+
+ return styles
+}
+
+function getFrameAnimation(frame: ShopItemPublic | null): string {
+ if (!frame?.asset_data) return ''
+ const data = frame.asset_data as { animation?: string }
+ if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
+ if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
+ return ''
+}
+
+// ============ HERO AVATAR COMPONENT ============
+
+function HeroAvatar({
+ avatarUrl,
+ telegramAvatarUrl,
+ nickname,
+ frame,
+}: {
+ avatarUrl: string | null
+ telegramAvatarUrl: string | null
+ nickname: string
+ frame: ShopItemPublic | null
+}) {
+ const displayAvatar = avatarUrl || telegramAvatarUrl
+
+ const avatarContent = (
+
+ {displayAvatar ? (
+

+ ) : (
+
+
+
+ )}
+
+ )
+
+ if (!frame) {
+ return (
+
+ {avatarContent}
+
+ )
+ }
+
+ return (
+
+ {avatarContent}
+
+ )
+}
+
+// ============ MAIN COMPONENT ============
export function UserProfilePage() {
const { id } = useParams<{ id: string }>()
@@ -107,8 +295,46 @@ export function UserProfilePage() {
)
}
+ // Get cosmetics data
+ const backgroundData = getBackgroundData(profile.equipped_background)
+ const nameColorData = getNameColorData(profile.equipped_name_color)
+ const titleData = getTitleData(profile.equipped_title)
+ const displayAvatar = avatarBlobUrl || profile.telegram_avatar_url
+
+ // Get nickname styles based on color type
+ const getNicknameStyles = (): React.CSSProperties => {
+ if (nameColorData.type === 'solid') {
+ return { color: nameColorData.color }
+ }
+ if (nameColorData.type === 'gradient' && nameColorData.gradient) {
+ return {
+ background: `linear-gradient(90deg, ${nameColorData.gradient.join(', ')})`,
+ WebkitBackgroundClip: 'text',
+ WebkitTextFillColor: 'transparent',
+ backgroundClip: 'text',
+ }
+ }
+ if (nameColorData.type === 'animated') {
+ return {
+ background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3, #ff0000)',
+ backgroundSize: '400% 100%',
+ WebkitBackgroundClip: 'text',
+ WebkitTextFillColor: 'transparent',
+ backgroundClip: 'text',
+ }
+ }
+ return { color: '#ffffff' }
+ }
+
+ const getNicknameAnimation = (): string => {
+ if (nameColorData.type === 'animated' && nameColorData.animation === 'rainbow-shift') {
+ return 'animate-rainbow-rotate'
+ }
+ return ''
+ }
+
return (
-
+
{/* Кнопка назад */}
- {/* Профиль */}
-
-
- {/* Аватар */}
-
-
- {avatarBlobUrl ? (
-

- ) : (
-
-
+ {/* ============ HERO SECTION ============ */}
+
+ {/* Default gradient background if no custom background */}
+ {!profile.equipped_background && (
+
+ )}
+
+ {/* Overlay for readability */}
+
+
+ {/* Scan lines effect */}
+
+
+ {/* Glow effects */}
+
+
+
+ {/* Content */}
+
+
+ {/* Avatar with Frame */}
+
+
+
+
+ {/* User Info */}
+
+ {/* Nickname with color + Title badge */}
+
+
+ {profile.nickname}
+
+
+ {/* Title badge */}
+ {titleData && (
+
+ {titleData.text}
+
+ )}
+
+
+ {/* Admin badge */}
+ {profile.role === 'admin' && (
+
)}
-
- {/* Online indicator effect */}
-
-
-
-
- {/* Инфо */}
-
-
- {profile.nickname}
-
-
-
-
Зарегистрирован {formatDate(profile.created_at)}
+ {/* Registration date */}
+
+
+ Зарегистрирован {formatDate(profile.created_at)}
+
+
+ {/* Quick stats preview */}
+
+
+
+ {profile.stats.wins_count} побед
+
+
+
+ {profile.stats.marathons_count} марафонов
+
+
+
+ {profile.stats.total_points_earned} очков
+
+
-
+
{/* Статистика */}
diff --git a/frontend/src/store/shop.ts b/frontend/src/store/shop.ts
new file mode 100644
index 0000000..60b1890
--- /dev/null
+++ b/frontend/src/store/shop.ts
@@ -0,0 +1,123 @@
+import { create } from 'zustand'
+import { shopApi } from '@/api/shop'
+import type { ShopItem, InventoryItem, ShopItemType } from '@/types'
+import { useAuthStore } from './auth'
+
+interface ShopState {
+ // State
+ balance: number
+ items: ShopItem[]
+ inventory: InventoryItem[]
+ isLoading: boolean
+ isBalanceLoading: boolean
+ error: string | null
+
+ // Actions
+ loadBalance: () => Promise
+ loadItems: (itemType?: ShopItemType) => Promise
+ loadInventory: (itemType?: ShopItemType) => Promise
+ purchase: (itemId: number, quantity?: number) => Promise
+ equip: (inventoryId: number) => Promise
+ unequip: (itemType: ShopItemType) => Promise
+ updateBalance: (newBalance: number) => void
+ clearError: () => void
+}
+
+export const useShopStore = create()((set, get) => ({
+ balance: 0,
+ items: [],
+ inventory: [],
+ isLoading: false,
+ isBalanceLoading: false,
+ error: null,
+
+ loadBalance: async () => {
+ set({ isBalanceLoading: true })
+ try {
+ const data = await shopApi.getBalance()
+ set({ balance: data.balance, isBalanceLoading: false })
+ } catch (err) {
+ console.error('Failed to load balance:', err)
+ set({ isBalanceLoading: false })
+ }
+ },
+
+ loadItems: async (itemType?: ShopItemType) => {
+ set({ isLoading: true, error: null })
+ try {
+ const items = await shopApi.getItems(itemType)
+ set({ items, isLoading: false })
+ } catch (err) {
+ const error = err as { response?: { data?: { detail?: string } } }
+ set({
+ error: error.response?.data?.detail || 'Не удалось загрузить товары',
+ isLoading: false,
+ })
+ }
+ },
+
+ loadInventory: async (itemType?: ShopItemType) => {
+ set({ isLoading: true, error: null })
+ try {
+ const inventory = await shopApi.getInventory(itemType)
+ set({ inventory, isLoading: false })
+ } catch (err) {
+ const error = err as { response?: { data?: { detail?: string } } }
+ set({
+ error: error.response?.data?.detail || 'Не удалось загрузить инвентарь',
+ isLoading: false,
+ })
+ }
+ },
+
+ purchase: async (itemId: number, quantity: number = 1) => {
+ try {
+ const result = await shopApi.purchase(itemId, quantity)
+ set({ balance: result.new_balance })
+ // Reload items and inventory to update ownership status
+ await Promise.all([get().loadItems(), get().loadInventory()])
+ return true
+ } catch (err) {
+ const error = err as { response?: { data?: { detail?: string } } }
+ set({ error: error.response?.data?.detail || 'Не удалось совершить покупку' })
+ return false
+ }
+ },
+
+ equip: async (inventoryId: number) => {
+ try {
+ await shopApi.equip(inventoryId)
+ await get().loadInventory()
+ // Sync user data to update equipped cosmetics in UI
+ await useAuthStore.getState().syncUser()
+ return true
+ } catch (err) {
+ const error = err as { response?: { data?: { detail?: string } } }
+ set({ error: error.response?.data?.detail || 'Не удалось экипировать предмет' })
+ return false
+ }
+ },
+
+ unequip: async (itemType: ShopItemType) => {
+ try {
+ await shopApi.unequip(itemType)
+ await get().loadInventory()
+ // Sync user data to update equipped cosmetics in UI
+ await useAuthStore.getState().syncUser()
+ return true
+ } catch (err) {
+ const error = err as { response?: { data?: { detail?: string } } }
+ set({ error: error.response?.data?.detail || 'Не удалось снять предмет' })
+ return false
+ }
+ },
+
+ updateBalance: (newBalance: number) => {
+ set({ balance: newBalance })
+ },
+
+ clearError: () => set({ error: null }),
+}))
+
+// Convenience hook for just getting the balance
+export const useCoinsBalance = () => useShopStore((state) => state.balance)
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 396df72..ce598f0 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -9,6 +9,11 @@ export interface UserPublic {
role: UserRole
telegram_avatar_url: string | null
created_at: string
+ // Equipped cosmetics
+ equipped_frame: ShopItemPublic | null
+ equipped_title: ShopItemPublic | null
+ equipped_name_color: ShopItemPublic | null
+ equipped_background: ShopItemPublic | null
}
// Full user info (only for own profile from /auth/me)
@@ -688,11 +693,161 @@ export interface UserProfilePublic {
id: number
nickname: string
avatar_url: string | null
+ telegram_avatar_url: string | null
+ role: UserRole
created_at: string
stats: UserStats
+ // Equipped cosmetics
+ equipped_frame: ShopItemPublic | null
+ equipped_title: ShopItemPublic | null
+ equipped_name_color: ShopItemPublic | null
+ equipped_background: ShopItemPublic | null
}
export interface PasswordChangeData {
current_password: string
new_password: string
}
+
+// === Shop types ===
+
+export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
+export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
+export type ConsumableType = 'skip' | 'shield' | 'boost' | 'reroll'
+
+export interface ShopItemPublic {
+ id: number
+ code: string
+ name: string
+ item_type: ShopItemType
+ rarity: ItemRarity
+ asset_data: Record | null
+}
+
+export interface ShopItem {
+ id: number
+ item_type: ShopItemType
+ code: string
+ name: string
+ description: string | null
+ price: number
+ rarity: ItemRarity
+ asset_data: Record | null
+ is_active: boolean
+ available_from: string | null
+ available_until: string | null
+ stock_limit: number | null
+ stock_remaining: number | null
+ created_at: string
+ is_available: boolean
+ is_owned: boolean
+ is_equipped: boolean
+}
+
+export interface InventoryItem {
+ id: number
+ item: ShopItem
+ quantity: number
+ equipped: boolean
+ purchased_at: string
+ expires_at: string | null
+}
+
+export interface PurchaseRequest {
+ item_id: number
+ quantity?: number
+}
+
+export interface PurchaseResponse {
+ success: boolean
+ item: ShopItem
+ quantity: number
+ total_cost: number
+ new_balance: number
+ message: string
+}
+
+export interface UseConsumableRequest {
+ item_code: ConsumableType
+ marathon_id: number
+ assignment_id?: number
+}
+
+export interface UseConsumableResponse {
+ success: boolean
+ item_code: string
+ remaining_quantity: number
+ effect_description: string
+ effect_data: Record | null
+}
+
+export interface CoinTransaction {
+ id: number
+ amount: number
+ transaction_type: string
+ description: string | null
+ reference_type: string | null
+ reference_id: number | null
+ created_at: string
+}
+
+export interface CoinsBalance {
+ balance: number
+ recent_transactions: CoinTransaction[]
+}
+
+export interface ConsumablesStatus {
+ skips_available: number
+ skips_used: number
+ skips_remaining: number | null
+ has_shield: boolean
+ has_active_boost: boolean
+ boost_multiplier: number | null
+ boost_expires_at: string | null
+ rerolls_available: number
+}
+
+export interface UserCosmetics {
+ frame: ShopItem | null
+ title: ShopItem | null
+ name_color: ShopItem | null
+ background: ShopItem | null
+}
+
+// Certification types
+export type CertificationStatus = 'none' | 'pending' | 'certified' | 'rejected'
+
+export interface CertificationStatusResponse {
+ marathon_id: number
+ certification_status: CertificationStatus
+ is_certified: boolean
+ certification_requested_at: string | null
+ certified_at: string | null
+ certified_by_nickname: string | null
+ rejection_reason: string | null
+}
+
+// Rarity colors for UI
+export const RARITY_COLORS: Record = {
+ common: { bg: 'bg-gray-500/20', border: 'border-gray-500', text: 'text-gray-400' },
+ uncommon: { bg: 'bg-green-500/20', border: 'border-green-500', text: 'text-green-400' },
+ rare: { bg: 'bg-blue-500/20', border: 'border-blue-500', text: 'text-blue-400' },
+ epic: { bg: 'bg-purple-500/20', border: 'border-purple-500', text: 'text-purple-400' },
+ legendary: { bg: 'bg-yellow-500/20', border: 'border-yellow-500', text: 'text-yellow-400' },
+}
+
+export const RARITY_NAMES: Record = {
+ common: 'Обычный',
+ uncommon: 'Необычный',
+ rare: 'Редкий',
+ epic: 'Эпический',
+ legendary: 'Легендарный',
+}
+
+export const ITEM_TYPE_NAMES: Record = {
+ frame: 'Рамка',
+ title: 'Титул',
+ name_color: 'Цвет ника',
+ background: 'Фон профиля',
+ consumable: 'Расходуемое',
+}