Add shop
This commit is contained in:
@@ -25,6 +25,8 @@ import { StaticContentPage } from '@/pages/StaticContentPage'
|
||||
import { NotFoundPage } from '@/pages/NotFoundPage'
|
||||
import { TeapotPage } from '@/pages/TeapotPage'
|
||||
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
||||
import { ShopPage } from '@/pages/ShopPage'
|
||||
import { InventoryPage } from '@/pages/InventoryPage'
|
||||
|
||||
// Admin Pages
|
||||
import {
|
||||
@@ -187,6 +189,25 @@ function App() {
|
||||
|
||||
<Route path="users/:id" element={<UserProfilePage />} />
|
||||
|
||||
{/* Shop routes */}
|
||||
<Route
|
||||
path="shop"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ShopPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="inventory"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<InventoryPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Easter egg - 418 I'm a teapot */}
|
||||
<Route path="418" element={<TeapotPage />} />
|
||||
<Route path="teapot" element={<TeapotPage />} />
|
||||
|
||||
@@ -9,3 +9,4 @@ export { challengesApi } from './challenges'
|
||||
export { assignmentsApi } from './assignments'
|
||||
export { usersApi } from './users'
|
||||
export { telegramApi } from './telegram'
|
||||
export { shopApi } from './shop'
|
||||
|
||||
102
frontend/src/api/shop.ts
Normal file
102
frontend/src/api/shop.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import client from './client'
|
||||
import type {
|
||||
ShopItem,
|
||||
ShopItemType,
|
||||
InventoryItem,
|
||||
PurchaseResponse,
|
||||
UseConsumableRequest,
|
||||
UseConsumableResponse,
|
||||
CoinsBalance,
|
||||
CoinTransaction,
|
||||
ConsumablesStatus,
|
||||
UserCosmetics,
|
||||
} from '@/types'
|
||||
|
||||
export const shopApi = {
|
||||
// === Каталог товаров ===
|
||||
|
||||
// Получить список товаров
|
||||
getItems: async (itemType?: ShopItemType): Promise<ShopItem[]> => {
|
||||
const params = itemType ? { item_type: itemType } : {}
|
||||
const response = await client.get<ShopItem[]>('/shop/items', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Получить товар по ID
|
||||
getItem: async (itemId: number): Promise<ShopItem> => {
|
||||
const response = await client.get<ShopItem>(`/shop/items/${itemId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// === Покупки ===
|
||||
|
||||
// Купить товар
|
||||
purchase: async (itemId: number, quantity: number = 1): Promise<PurchaseResponse> => {
|
||||
const response = await client.post<PurchaseResponse>('/shop/purchase', {
|
||||
item_id: itemId,
|
||||
quantity,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// === Инвентарь ===
|
||||
|
||||
// Получить инвентарь пользователя
|
||||
getInventory: async (itemType?: ShopItemType): Promise<InventoryItem[]> => {
|
||||
const params = itemType ? { item_type: itemType } : {}
|
||||
const response = await client.get<InventoryItem[]>('/shop/inventory', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// === Экипировка ===
|
||||
|
||||
// Экипировать предмет
|
||||
equip: async (inventoryId: number): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await client.post<{ success: boolean; message: string }>('/shop/equip', {
|
||||
inventory_id: inventoryId,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Снять предмет
|
||||
unequip: async (itemType: ShopItemType): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await client.post<{ success: boolean; message: string }>(`/shop/unequip/${itemType}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Получить экипированную косметику
|
||||
getCosmetics: async (): Promise<UserCosmetics> => {
|
||||
const response = await client.get<UserCosmetics>('/shop/cosmetics')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// === Расходуемые ===
|
||||
|
||||
// Использовать расходуемый предмет
|
||||
useConsumable: async (data: UseConsumableRequest): Promise<UseConsumableResponse> => {
|
||||
const response = await client.post<UseConsumableResponse>('/shop/use', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Получить статус расходуемых в марафоне
|
||||
getConsumablesStatus: async (marathonId: number): Promise<ConsumablesStatus> => {
|
||||
const response = await client.get<ConsumablesStatus>(`/shop/consumables/${marathonId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// === Монеты ===
|
||||
|
||||
// Получить баланс и последние транзакции
|
||||
getBalance: async (): Promise<CoinsBalance> => {
|
||||
const response = await client.get<CoinsBalance>('/shop/balance')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Получить историю транзакций
|
||||
getTransactions: async (limit: number = 50, offset: number = 0): Promise<CoinTransaction[]> => {
|
||||
const response = await client.get<CoinTransaction[]>('/shop/transactions', {
|
||||
params: { limit, offset },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { feedApi } from '@/api'
|
||||
import type { Activity, ActivityType } from '@/types'
|
||||
import type { Activity, ActivityType, ShopItemPublic, User } from '@/types'
|
||||
import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
|
||||
import { UserAvatar } from '@/components/ui'
|
||||
import {
|
||||
@@ -12,6 +12,77 @@ import {
|
||||
formatActivityMessage,
|
||||
} from '@/utils/activity'
|
||||
|
||||
// Helper to get name color styles and animation class
|
||||
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
|
||||
if (!nameColor?.asset_data) return { styles: {}, className: '' }
|
||||
|
||||
const data = nameColor.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string }
|
||||
|
||||
if (data.style === 'gradient' && data.gradient) {
|
||||
return {
|
||||
styles: {
|
||||
background: `linear-gradient(90deg, ${data.gradient.join(', ')})`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
},
|
||||
className: '',
|
||||
}
|
||||
}
|
||||
|
||||
if (data.style === 'animated') {
|
||||
return {
|
||||
styles: {
|
||||
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
|
||||
backgroundSize: '400% 100%',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
},
|
||||
className: 'animate-rainbow-rotate',
|
||||
}
|
||||
}
|
||||
|
||||
if (data.style === 'solid' && data.color) {
|
||||
return { styles: { color: data.color }, className: '' }
|
||||
}
|
||||
|
||||
return { styles: {}, className: '' }
|
||||
}
|
||||
|
||||
// Helper to get title data
|
||||
function getTitleData(title: ShopItemPublic | null | undefined): { text: string; color: string } | null {
|
||||
if (!title?.asset_data) return null
|
||||
|
||||
const data = title.asset_data as { text?: string; color?: string }
|
||||
if (!data.text) return null
|
||||
|
||||
return {
|
||||
text: data.text,
|
||||
color: data.color || '#ffffff',
|
||||
}
|
||||
}
|
||||
|
||||
// Styled nickname component for activity feed
|
||||
function StyledNickname({ user }: { user: User }) {
|
||||
const nameColorData = getNameColorData(user.equipped_name_color)
|
||||
const titleData = getTitleData(user.equipped_title)
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={nameColorData.className} style={nameColorData.styles}>{user.nickname}</span>
|
||||
{titleData && (
|
||||
<span
|
||||
className="ml-1.5 px-1 py-0.5 text-[10px] font-medium rounded bg-dark-700/50"
|
||||
style={{ color: titleData.color }}
|
||||
>
|
||||
{titleData.text}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActivityFeedProps {
|
||||
marathonId: number
|
||||
className?: string
|
||||
@@ -273,6 +344,8 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
||||
hasAvatar={!!activity.user.avatar_url}
|
||||
nickname={activity.user.nickname}
|
||||
size="sm"
|
||||
frame={activity.user.equipped_frame}
|
||||
telegramAvatarUrl={activity.user.telegram_avatar_url}
|
||||
/>
|
||||
{/* Activity type badge */}
|
||||
<div className={`
|
||||
@@ -292,10 +365,10 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
to={`/users/${activity.user.id}`}
|
||||
className="text-sm font-semibold text-white hover:text-neon-400 transition-colors"
|
||||
className="text-sm font-semibold hover:text-neon-400 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{activity.user.nickname}
|
||||
<StyledNickname user={activity.user} />
|
||||
</Link>
|
||||
<span className="text-xs text-gray-600">
|
||||
{formatRelativeTime(activity.created_at)}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield } from 'lucide-react'
|
||||
import { TelegramLink } from '@/components/TelegramLink'
|
||||
import { useShopStore } from '@/store/shop'
|
||||
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield, ShoppingBag, Coins, Backpack } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
export function Layout() {
|
||||
const { user, isAuthenticated, logout } = useAuthStore()
|
||||
const { balance, loadBalance } = useShopStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
@@ -20,6 +21,13 @@ export function Layout() {
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
// Load balance when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadBalance()
|
||||
}
|
||||
}, [isAuthenticated, loadBalance])
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false)
|
||||
@@ -74,6 +82,19 @@ export function Layout() {
|
||||
<span>Марафоны</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/shop"
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
|
||||
isActiveLink('/shop')
|
||||
? 'text-yellow-400 bg-yellow-500/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
<span>Магазин</span>
|
||||
</Link>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
@@ -89,7 +110,7 @@ export function Layout() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
|
||||
<div className="flex items-center gap-2 ml-2 pl-4 border-l border-dark-600">
|
||||
<Link
|
||||
to="/profile"
|
||||
className={clsx(
|
||||
@@ -101,9 +122,24 @@ export function Layout() {
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
<span>{user?.nickname}</span>
|
||||
<span className="flex items-center gap-1 text-yellow-400 ml-1">
|
||||
<Coins className="w-4 h-4" />
|
||||
<span className="font-medium">{balance}</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<TelegramLink />
|
||||
<Link
|
||||
to="/inventory"
|
||||
className={clsx(
|
||||
'p-2 rounded-lg transition-all duration-200',
|
||||
isActiveLink('/inventory')
|
||||
? 'text-yellow-400 bg-yellow-500/10'
|
||||
: 'text-gray-400 hover:text-yellow-400 hover:bg-yellow-500/10'
|
||||
)}
|
||||
title="Инвентарь"
|
||||
>
|
||||
<Backpack className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
@@ -159,6 +195,18 @@ export function Layout() {
|
||||
<Trophy className="w-5 h-5" />
|
||||
<span>Марафоны</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop"
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||
isActiveLink('/shop')
|
||||
? 'text-yellow-400 bg-yellow-500/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
<span>Магазин</span>
|
||||
</Link>
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
@@ -184,6 +232,22 @@ export function Layout() {
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
<span>{user?.nickname}</span>
|
||||
<span className="flex items-center gap-1 text-yellow-400 ml-auto">
|
||||
<Coins className="w-4 h-4" />
|
||||
<span className="font-medium">{balance}</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/inventory"
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||
isActiveLink('/inventory')
|
||||
? 'text-yellow-400 bg-yellow-500/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<Backpack className="w-5 h-5" />
|
||||
<span>Инвентарь</span>
|
||||
</Link>
|
||||
<div className="pt-2 border-t border-dark-600">
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { usersApi } from '@/api'
|
||||
import { User } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import type { ShopItemPublic } from '@/types'
|
||||
|
||||
// Глобальный кэш для blob URL аватарок
|
||||
const avatarCache = new Map<number, string>()
|
||||
@@ -10,18 +13,77 @@ interface UserAvatarProps {
|
||||
userId: number
|
||||
hasAvatar: boolean // Есть ли у пользователя avatar_url
|
||||
nickname: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
version?: number // Для принудительного обновления при смене аватара
|
||||
frame?: ShopItemPublic | null // Equipped frame cosmetic
|
||||
telegramAvatarUrl?: string | null // Fallback to telegram avatar
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6 text-[8px]',
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-12 h-12 text-sm',
|
||||
lg: 'w-24 h-24 text-xl',
|
||||
lg: 'w-16 h-16 text-base',
|
||||
xl: 'w-24 h-24 text-xl',
|
||||
}
|
||||
|
||||
export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '', version = 0 }: UserAvatarProps) {
|
||||
const framePadding = {
|
||||
xs: 2,
|
||||
sm: 2,
|
||||
md: 3,
|
||||
lg: 4,
|
||||
xl: 5,
|
||||
}
|
||||
|
||||
// Get frame styles from asset_data
|
||||
function getFrameStyles(frame: ShopItemPublic | null | undefined): React.CSSProperties {
|
||||
if (!frame?.asset_data) return {}
|
||||
|
||||
const data = frame.asset_data as {
|
||||
border_color?: string
|
||||
gradient?: string[]
|
||||
glow_color?: string
|
||||
}
|
||||
|
||||
const styles: React.CSSProperties = {}
|
||||
|
||||
if (data.gradient && data.gradient.length > 0) {
|
||||
styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
|
||||
styles.backgroundSize = '400% 400%'
|
||||
} else if (data.border_color) {
|
||||
styles.background = data.border_color
|
||||
}
|
||||
|
||||
if (data.glow_color) {
|
||||
styles.boxShadow = `0 0 12px ${data.glow_color}, 0 0 24px ${data.glow_color}40`
|
||||
}
|
||||
|
||||
return styles
|
||||
}
|
||||
|
||||
// Get frame animation class
|
||||
function getFrameAnimation(frame: ShopItemPublic | null | undefined): string {
|
||||
if (!frame?.asset_data) return ''
|
||||
|
||||
const data = frame.asset_data as { animation?: string }
|
||||
|
||||
if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
|
||||
if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function UserAvatar({
|
||||
userId,
|
||||
hasAvatar,
|
||||
nickname,
|
||||
size = 'md',
|
||||
className = '',
|
||||
version = 0,
|
||||
frame,
|
||||
telegramAvatarUrl
|
||||
}: UserAvatarProps) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
@@ -74,25 +136,54 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
|
||||
}, [userId, hasAvatar, version])
|
||||
|
||||
const sizeClass = sizeClasses[size]
|
||||
const displayUrl = (blobUrl && !failed) ? blobUrl : telegramAvatarUrl
|
||||
|
||||
if (blobUrl && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={nickname}
|
||||
className={`rounded-full object-cover ${sizeClass} ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback - первая буква никнейма
|
||||
return (
|
||||
<div className={`rounded-full bg-gray-700 flex items-center justify-center ${sizeClass} ${className}`}>
|
||||
// Avatar content
|
||||
const avatarContent = displayUrl ? (
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt={nickname}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full rounded-full bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center">
|
||||
<span className="text-gray-400 font-medium">
|
||||
{nickname.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
// If no frame, return simple avatar
|
||||
if (!frame) {
|
||||
return (
|
||||
<div className={clsx('rounded-full overflow-hidden bg-dark-700', sizeClass, className)}>
|
||||
{avatarContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// With frame - wrap avatar in frame container
|
||||
const padding = framePadding[size]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-full flex items-center justify-center',
|
||||
getFrameAnimation(frame),
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
...getFrameStyles(frame),
|
||||
padding: `${padding}px`,
|
||||
width: 'fit-content',
|
||||
height: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<div className={clsx('rounded-full overflow-hidden bg-dark-700', sizeClass)}>
|
||||
{avatarContent}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Функция для очистки кэша конкретного пользователя (после загрузки нового аватара)
|
||||
@@ -105,3 +196,55 @@ export function clearAvatarCache(userId: number) {
|
||||
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
|
||||
needsCacheBust.add(userId)
|
||||
}
|
||||
|
||||
// FramePreview component for shop - shows frame without avatar
|
||||
interface FramePreviewProps {
|
||||
frame: ShopItemPublic | null
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const previewSizes = {
|
||||
xs: 'w-8 h-8',
|
||||
sm: 'w-10 h-10',
|
||||
md: 'w-14 h-14',
|
||||
lg: 'w-20 h-20',
|
||||
xl: 'w-28 h-28',
|
||||
}
|
||||
|
||||
export function FramePreview({ frame, size = 'md', className }: FramePreviewProps) {
|
||||
if (!frame?.asset_data) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
previewSizes[size],
|
||||
'rounded-lg border-4 border-gray-600 flex items-center justify-center bg-dark-800',
|
||||
className
|
||||
)}>
|
||||
<User className="w-1/2 h-1/2 text-gray-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const padding = framePadding[size]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-lg flex items-center justify-center',
|
||||
getFrameAnimation(frame),
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
...getFrameStyles(frame),
|
||||
padding: `${padding}px`,
|
||||
}}
|
||||
>
|
||||
<div className={clsx(
|
||||
previewSizes[size],
|
||||
'rounded-md bg-dark-800/90 flex items-center justify-center'
|
||||
)}>
|
||||
<User className="w-1/2 h-1/2 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export { Input } from './Input'
|
||||
export { Card, CardHeader, CardTitle, CardContent } from './Card'
|
||||
export { ToastContainer } from './Toast'
|
||||
export { ConfirmModal } from './ConfirmModal'
|
||||
export { UserAvatar, clearAvatarCache } from './UserAvatar'
|
||||
export { UserAvatar, clearAvatarCache, FramePreview } from './UserAvatar'
|
||||
|
||||
// New design system components
|
||||
export { GlitchText, GlitchHeading } from './GlitchText'
|
||||
|
||||
@@ -571,6 +571,125 @@ input:-webkit-autofill:active {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Frame Animations (Shop cosmetics)
|
||||
======================================== */
|
||||
/* Fire pulse animation */
|
||||
@keyframes fire-pulse {
|
||||
0%, 100% {
|
||||
background-size: 200% 200%;
|
||||
background-position: 0% 50%;
|
||||
filter: brightness(1);
|
||||
}
|
||||
50% {
|
||||
background-size: 220% 220%;
|
||||
background-position: 100% 50%;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fire-pulse {
|
||||
animation: fire-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Rainbow rotate animation */
|
||||
@keyframes rainbow-rotate {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-rainbow-rotate {
|
||||
animation: rainbow-rotate 3s linear infinite;
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
/* Rainbow text color shift */
|
||||
@keyframes rainbow-shift {
|
||||
0% { color: #FF0000; }
|
||||
16% { color: #FF7F00; }
|
||||
33% { color: #FFFF00; }
|
||||
50% { color: #00FF00; }
|
||||
66% { color: #0000FF; }
|
||||
83% { color: #9400D3; }
|
||||
100% { color: #FF0000; }
|
||||
}
|
||||
|
||||
.animate-rainbow-shift {
|
||||
animation: rainbow-shift 4s linear infinite;
|
||||
}
|
||||
|
||||
/* Fire particles background animation */
|
||||
@keyframes fire-particles {
|
||||
0%, 100% {
|
||||
background-position: 0% 100%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fire-particles {
|
||||
animation: fire-particles 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Star twinkle animation */
|
||||
@keyframes twinkle {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-twinkle {
|
||||
animation: twinkle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Stars background with multiple twinkling layers */
|
||||
.bg-stars-animated {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #0d1b2a 0%, #1b263b 50%, #0d1b2a 100%);
|
||||
}
|
||||
|
||||
.bg-stars-animated::before,
|
||||
.bg-stars-animated::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bg-stars-animated::before {
|
||||
background:
|
||||
radial-gradient(2px 2px at 20px 30px, #fff, transparent),
|
||||
radial-gradient(2px 2px at 80px 60px, rgba(255,255,255,0.9), transparent),
|
||||
radial-gradient(1px 1px at 130px 40px, #fff, transparent),
|
||||
radial-gradient(2px 2px at 180px 90px, rgba(255,255,255,0.8), transparent),
|
||||
radial-gradient(1px 1px at 50px 100px, #fff, transparent),
|
||||
radial-gradient(1.5px 1.5px at 220px 20px, rgba(255,255,255,0.9), transparent);
|
||||
background-size: 250px 150px;
|
||||
animation: twinkle 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bg-stars-animated::after {
|
||||
background:
|
||||
radial-gradient(1px 1px at 40px 20px, rgba(255,255,255,0.7), transparent),
|
||||
radial-gradient(2px 2px at 100px 80px, #fff, transparent),
|
||||
radial-gradient(1.5px 1.5px at 160px 30px, rgba(255,255,255,0.8), transparent),
|
||||
radial-gradient(1px 1px at 200px 70px, #fff, transparent),
|
||||
radial-gradient(2px 2px at 70px 110px, rgba(255,255,255,0.9), transparent);
|
||||
background-size: 220px 140px;
|
||||
animation: twinkle 4s ease-in-out infinite 1s;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
||||
387
frontend/src/pages/InventoryPage.tsx
Normal file
387
frontend/src/pages/InventoryPage.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useShopStore } from '@/store/shop'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||
import {
|
||||
Loader2, Package, ShoppingBag, Coins, Check,
|
||||
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward
|
||||
} from 'lucide-react'
|
||||
import type { InventoryItem, ShopItemType } from '@/types'
|
||||
import { RARITY_COLORS, RARITY_NAMES, ITEM_TYPE_NAMES } from '@/types'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||
skip: <SkipForward className="w-8 h-8" />,
|
||||
shield: <Shield className="w-8 h-8" />,
|
||||
boost: <Zap className="w-8 h-8" />,
|
||||
reroll: <RefreshCw className="w-8 h-8" />,
|
||||
}
|
||||
|
||||
interface InventoryItemCardProps {
|
||||
inventoryItem: InventoryItem
|
||||
onEquip: (inventoryId: number) => void
|
||||
onUnequip: (itemType: ShopItemType) => void
|
||||
isProcessing: boolean
|
||||
}
|
||||
|
||||
function InventoryItemCard({ inventoryItem, onEquip, onUnequip, isProcessing }: InventoryItemCardProps) {
|
||||
const { item, quantity, equipped } = inventoryItem
|
||||
const rarityColors = RARITY_COLORS[item.rarity]
|
||||
|
||||
const getItemPreview = () => {
|
||||
if (item.item_type === 'consumable') {
|
||||
return CONSUMABLE_ICONS[item.code] || <Package className="w-8 h-8" />
|
||||
}
|
||||
|
||||
// Name color preview - handles solid, gradient, animated
|
||||
if (item.item_type === 'name_color') {
|
||||
const data = item.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string } | null
|
||||
|
||||
if (data?.style === 'gradient' && data.gradient) {
|
||||
return (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||
style={{ background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (data?.style === 'animated') {
|
||||
return (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full border-2 border-dark-600 animate-rainbow-rotate"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
|
||||
backgroundSize: '400% 400%'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const solidColor = data?.color || '#ffffff'
|
||||
return (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||
style={{ backgroundColor: solidColor }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Background preview
|
||||
if (item.item_type === 'background') {
|
||||
const data = item.asset_data as { type?: string; color?: string; gradient?: string[]; pattern?: string; animation?: string } | null
|
||||
let bgStyle: React.CSSProperties = {}
|
||||
let animClass = ''
|
||||
|
||||
if (data?.type === 'solid' && data.color) {
|
||||
bgStyle = { backgroundColor: data.color }
|
||||
} else if (data?.type === 'gradient' && data.gradient) {
|
||||
bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
|
||||
} else if (data?.type === 'pattern') {
|
||||
if (data.pattern === 'stars') {
|
||||
bgStyle = {
|
||||
background: `
|
||||
radial-gradient(1px 1px at 10px 10px, #fff, transparent),
|
||||
radial-gradient(1px 1px at 30px 25px, rgba(255,255,255,0.8), transparent),
|
||||
linear-gradient(135deg, #0d1b2a 0%, #1b263b 100%)
|
||||
`,
|
||||
backgroundSize: '50px 35px, 50px 35px, 100% 100%'
|
||||
}
|
||||
animClass = 'animate-twinkle'
|
||||
} else if (data.pattern === 'gaming-icons') {
|
||||
bgStyle = {
|
||||
background: `
|
||||
linear-gradient(45deg, rgba(34,211,238,0.2) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(168,85,247,0.2) 25%, transparent 25%),
|
||||
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
|
||||
`,
|
||||
backgroundSize: '15px 15px, 15px 15px, 100% 100%'
|
||||
}
|
||||
}
|
||||
} else if (data?.type === 'animated' && data.animation === 'fire-particles') {
|
||||
bgStyle = {
|
||||
background: `
|
||||
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.5) 0%, transparent 50%),
|
||||
linear-gradient(to top, #1a0a00 0%, #0d0d0d 100%)
|
||||
`
|
||||
}
|
||||
animClass = 'animate-fire-pulse'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-14 h-10 rounded-lg border-2 border-dark-600 ${animClass}`}
|
||||
style={bgStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.item_type === 'frame') {
|
||||
return <FramePreview frame={item} size="lg" />
|
||||
}
|
||||
if (item.item_type === 'title' && item.asset_data?.text) {
|
||||
return (
|
||||
<span
|
||||
className="text-lg font-bold"
|
||||
style={{ color: (item.asset_data.color as string) || '#ffffff' }}
|
||||
>
|
||||
{item.asset_data.text as string}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <Package className="w-8 h-8 text-gray-400" />
|
||||
}
|
||||
|
||||
const isCosmetic = item.item_type !== 'consumable'
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
className={clsx(
|
||||
'p-4 border transition-all duration-300',
|
||||
equipped ? 'border-neon-500 bg-neon-500/10' : rarityColors.border
|
||||
)}
|
||||
>
|
||||
{/* Equipped badge */}
|
||||
{equipped && (
|
||||
<div className="flex items-center gap-1 text-neon-400 text-xs font-medium mb-2">
|
||||
<Check className="w-3 h-3" />
|
||||
Надето
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rarity badge */}
|
||||
{!equipped && (
|
||||
<div className={clsx('text-xs font-medium mb-2', rarityColors.text)}>
|
||||
{RARITY_NAMES[item.rarity]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Item preview */}
|
||||
<div className="flex justify-center items-center h-16 mb-3">
|
||||
{getItemPreview()}
|
||||
</div>
|
||||
|
||||
{/* Item info */}
|
||||
<h3 className="text-white font-semibold text-center mb-1">{item.name}</h3>
|
||||
<p className="text-gray-500 text-xs text-center mb-1">
|
||||
{ITEM_TYPE_NAMES[item.item_type]}
|
||||
</p>
|
||||
|
||||
{/* Quantity for consumables */}
|
||||
{item.item_type === 'consumable' && (
|
||||
<p className="text-yellow-400 text-sm text-center mb-3 font-medium">
|
||||
x{quantity}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
{isCosmetic && (
|
||||
<div className="mt-3">
|
||||
{equipped ? (
|
||||
<NeonButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => onUnequip(item.item_type)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Снять'}
|
||||
</NeonButton>
|
||||
) : (
|
||||
<NeonButton
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => onEquip(inventoryItem.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Надеть'}
|
||||
</NeonButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Consumable info */}
|
||||
{item.item_type === 'consumable' && (
|
||||
<p className="text-gray-500 text-xs text-center mt-2">
|
||||
Используйте в марафоне
|
||||
</p>
|
||||
)}
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function InventoryPage() {
|
||||
const { inventory, balance, isLoading, loadInventory, loadBalance, equip, unequip, clearError, error } = useShopStore()
|
||||
const toast = useToast()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ShopItemType | 'all'>('all')
|
||||
const [processingId, setProcessingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadBalance()
|
||||
loadInventory()
|
||||
}, [loadBalance, loadInventory])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
clearError()
|
||||
}
|
||||
}, [error, toast, clearError])
|
||||
|
||||
const handleEquip = async (inventoryId: number) => {
|
||||
setProcessingId(inventoryId)
|
||||
const success = await equip(inventoryId)
|
||||
setProcessingId(null)
|
||||
|
||||
if (success) {
|
||||
toast.success('Предмет экипирован!')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnequip = async (itemType: ShopItemType) => {
|
||||
setProcessingId(-1) // Generic processing state
|
||||
const success = await unequip(itemType)
|
||||
setProcessingId(null)
|
||||
|
||||
if (success) {
|
||||
toast.success('Предмет снят')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredInventory = activeTab === 'all'
|
||||
? inventory
|
||||
: inventory.filter(inv => inv.item.item_type === activeTab)
|
||||
|
||||
// Group by type for display
|
||||
const cosmeticItems = filteredInventory.filter(inv => inv.item.item_type !== 'consumable')
|
||||
const consumableItems = filteredInventory.filter(inv => inv.item.item_type === 'consumable')
|
||||
|
||||
const tabs: { id: ShopItemType | 'all'; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'all', label: 'Все', icon: <Package className="w-4 h-4" /> },
|
||||
{ id: 'frame', label: 'Рамки', icon: <Frame className="w-4 h-4" /> },
|
||||
{ id: 'title', label: 'Титулы', icon: <Type className="w-4 h-4" /> },
|
||||
{ id: 'name_color', label: 'Цвета', icon: <Palette className="w-4 h-4" /> },
|
||||
{ id: 'background', label: 'Фоны', icon: <Image className="w-4 h-4" /> },
|
||||
{ id: 'consumable', label: 'Расходники', icon: <Zap className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
if (isLoading && inventory.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-neon-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
|
||||
<Package className="w-8 h-8 text-neon-500" />
|
||||
Инвентарь
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Твои предметы и косметика
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Balance */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-dark-800/50 rounded-lg border border-yellow-500/30">
|
||||
<Coins className="w-5 h-5 text-yellow-400" />
|
||||
<span className="text-yellow-400 font-bold text-lg">{balance}</span>
|
||||
</div>
|
||||
|
||||
{/* Link to shop */}
|
||||
<Link to="/shop">
|
||||
<NeonButton>
|
||||
<ShoppingBag className="w-4 h-4 mr-2" />
|
||||
Магазин
|
||||
</NeonButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap',
|
||||
activeTab === tab.id
|
||||
? 'bg-neon-500 text-dark-900'
|
||||
: 'bg-dark-700 text-gray-300 hover:bg-dark-600'
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{filteredInventory.length === 0 ? (
|
||||
<GlassCard className="p-8 text-center">
|
||||
<Package className="w-16 h-16 mx-auto text-gray-500 mb-4" />
|
||||
<p className="text-gray-400 mb-4">
|
||||
{activeTab === 'all'
|
||||
? 'Твой инвентарь пуст'
|
||||
: 'Нет предметов в этой категории'}
|
||||
</p>
|
||||
<Link to="/shop">
|
||||
<NeonButton>
|
||||
<ShoppingBag className="w-4 h-4 mr-2" />
|
||||
Перейти в магазин
|
||||
</NeonButton>
|
||||
</Link>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<>
|
||||
{/* Cosmetic items */}
|
||||
{cosmeticItems.length > 0 && (activeTab === 'all' || activeTab !== 'consumable') && (
|
||||
<div className="mb-8">
|
||||
{activeTab === 'all' && (
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Косметика</h2>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{cosmeticItems.map(inv => (
|
||||
<InventoryItemCard
|
||||
key={inv.id}
|
||||
inventoryItem={inv}
|
||||
onEquip={handleEquip}
|
||||
onUnequip={handleUnequip}
|
||||
isProcessing={processingId === inv.id || processingId === -1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Consumable items */}
|
||||
{consumableItems.length > 0 && (activeTab === 'all' || activeTab === 'consumable') && (
|
||||
<div>
|
||||
{activeTab === 'all' && (
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Расходуемые</h2>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{consumableItems.map(inv => (
|
||||
<InventoryItemCard
|
||||
key={inv.id}
|
||||
inventoryItem={inv}
|
||||
onEquip={handleEquip}
|
||||
onUnequip={handleUnequip}
|
||||
isProcessing={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,82 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { marathonsApi } from '@/api'
|
||||
import type { LeaderboardEntry } from '@/types'
|
||||
import { GlassCard } from '@/components/ui'
|
||||
import type { LeaderboardEntry, ShopItemPublic, User } from '@/types'
|
||||
import { GlassCard, UserAvatar } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
|
||||
|
||||
// Helper to get name color styles and animation class
|
||||
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
|
||||
if (!nameColor?.asset_data) return { styles: {}, className: '' }
|
||||
|
||||
const data = nameColor.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string }
|
||||
|
||||
if (data.style === 'gradient' && data.gradient) {
|
||||
return {
|
||||
styles: {
|
||||
background: `linear-gradient(90deg, ${data.gradient.join(', ')})`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
},
|
||||
className: '',
|
||||
}
|
||||
}
|
||||
|
||||
if (data.style === 'animated') {
|
||||
return {
|
||||
styles: {
|
||||
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
|
||||
backgroundSize: '400% 100%',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
},
|
||||
className: 'animate-rainbow-rotate',
|
||||
}
|
||||
}
|
||||
|
||||
if (data.style === 'solid' && data.color) {
|
||||
return { styles: { color: data.color }, className: '' }
|
||||
}
|
||||
|
||||
return { styles: {}, className: '' }
|
||||
}
|
||||
|
||||
// Helper to get title data
|
||||
function getTitleData(title: ShopItemPublic | null | undefined): { text: string; color: string } | null {
|
||||
if (!title?.asset_data) return null
|
||||
|
||||
const data = title.asset_data as { text?: string; color?: string }
|
||||
if (!data.text) return null
|
||||
|
||||
return {
|
||||
text: data.text,
|
||||
color: data.color || '#ffffff',
|
||||
}
|
||||
}
|
||||
|
||||
// Styled nickname component
|
||||
function StyledNickname({ user, className = '' }: { user: User; className?: string }) {
|
||||
const nameColorData = getNameColorData(user.equipped_name_color)
|
||||
const titleData = getTitleData(user.equipped_title)
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 ${className}`}>
|
||||
<span className={nameColorData.className} style={nameColorData.styles}>{user.nickname}</span>
|
||||
{titleData && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-xs font-medium rounded bg-dark-700/50"
|
||||
style={{ color: titleData.color }}
|
||||
>
|
||||
{titleData.text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function LeaderboardPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
@@ -117,48 +188,66 @@ export function LeaderboardPage() {
|
||||
<div className="flex items-end justify-center gap-4 mb-4">
|
||||
{/* 2nd place */}
|
||||
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '100ms' }}>
|
||||
<div className={`
|
||||
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
|
||||
bg-gray-400/10 border border-gray-400/30
|
||||
shadow-[0_0_20px_rgba(156,163,175,0.2)]
|
||||
`}>
|
||||
<span className="text-3xl font-bold text-gray-300">2</span>
|
||||
<div className="mb-3">
|
||||
<UserAvatar
|
||||
userId={topThree[1].user.id}
|
||||
hasAvatar={!!topThree[1].user.avatar_url}
|
||||
nickname={topThree[1].user.nickname}
|
||||
size="lg"
|
||||
frame={topThree[1].user.equipped_frame}
|
||||
telegramAvatarUrl={topThree[1].user.telegram_avatar_url}
|
||||
/>
|
||||
</div>
|
||||
<Link to={`/users/${topThree[1].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
|
||||
<Link to={`/users/${topThree[1].user.id}`} className="glass rounded-xl p-4 text-center w-32 hover:border-neon-500/30 transition-colors border border-transparent">
|
||||
<Medal className="w-6 h-6 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[1].user.nickname}</p>
|
||||
<p className="text-sm font-medium truncate hover:text-neon-400 transition-colors">
|
||||
<StyledNickname user={topThree[1].user} />
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{topThree[1].total_points} очков</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 1st place */}
|
||||
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '0ms' }}>
|
||||
<div className={`
|
||||
w-24 h-24 rounded-2xl mb-3 flex items-center justify-center
|
||||
bg-yellow-500/20 border border-yellow-500/30
|
||||
shadow-[0_0_30px_rgba(234,179,8,0.4)]
|
||||
`}>
|
||||
<Crown className="w-10 h-10 text-yellow-400" />
|
||||
<div className="mb-3 relative">
|
||||
<UserAvatar
|
||||
userId={topThree[0].user.id}
|
||||
hasAvatar={!!topThree[0].user.avatar_url}
|
||||
nickname={topThree[0].user.nickname}
|
||||
size="xl"
|
||||
frame={topThree[0].user.equipped_frame}
|
||||
telegramAvatarUrl={topThree[0].user.telegram_avatar_url}
|
||||
/>
|
||||
<div className="absolute -top-2 -right-2 w-8 h-8 rounded-full bg-yellow-500 flex items-center justify-center shadow-lg shadow-yellow-500/50">
|
||||
<Crown className="w-5 h-5 text-dark-900" />
|
||||
</div>
|
||||
</div>
|
||||
<Link to={`/users/${topThree[0].user.id}`} className="glass-neon rounded-xl p-4 text-center w-32 hover:shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-shadow">
|
||||
<Link to={`/users/${topThree[0].user.id}`} className="glass-neon rounded-xl p-4 text-center w-36 hover:shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-shadow">
|
||||
<Star className="w-6 h-6 text-yellow-400 mx-auto mb-2" />
|
||||
<p className="font-semibold text-white truncate hover:text-neon-400 transition-colors">{topThree[0].user.nickname}</p>
|
||||
<p className="font-semibold truncate hover:text-neon-400 transition-colors">
|
||||
<StyledNickname user={topThree[0].user} />
|
||||
</p>
|
||||
<p className="text-sm text-neon-400 font-bold">{topThree[0].total_points} очков</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 3rd place */}
|
||||
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '200ms' }}>
|
||||
<div className={`
|
||||
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
|
||||
bg-amber-600/10 border border-amber-600/30
|
||||
shadow-[0_0_20px_rgba(217,119,6,0.2)]
|
||||
`}>
|
||||
<span className="text-3xl font-bold text-amber-600">3</span>
|
||||
<div className="mb-3">
|
||||
<UserAvatar
|
||||
userId={topThree[2].user.id}
|
||||
hasAvatar={!!topThree[2].user.avatar_url}
|
||||
nickname={topThree[2].user.nickname}
|
||||
size="lg"
|
||||
frame={topThree[2].user.equipped_frame}
|
||||
telegramAvatarUrl={topThree[2].user.telegram_avatar_url}
|
||||
/>
|
||||
</div>
|
||||
<Link to={`/users/${topThree[2].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
|
||||
<Link to={`/users/${topThree[2].user.id}`} className="glass rounded-xl p-4 text-center w-32 hover:border-neon-500/30 transition-colors border border-transparent">
|
||||
<Award className="w-6 h-6 text-amber-600 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[2].user.nickname}</p>
|
||||
<p className="text-sm font-medium truncate hover:text-neon-400 transition-colors">
|
||||
<StyledNickname user={topThree[2].user} />
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{topThree[2].total_points} очков</p>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -222,20 +311,32 @@ export function LeaderboardPage() {
|
||||
|
||||
{/* Rank */}
|
||||
<div className={`
|
||||
relative w-10 h-10 rounded-xl flex items-center justify-center
|
||||
relative w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0
|
||||
${rankConfig.bg} ${rankConfig.color} ${rankConfig.glow}
|
||||
`}>
|
||||
{rankConfig.icon}
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
<UserAvatar
|
||||
userId={entry.user.id}
|
||||
hasAvatar={!!entry.user.avatar_url}
|
||||
nickname={entry.user.nickname}
|
||||
size="sm"
|
||||
frame={entry.user.equipped_frame}
|
||||
telegramAvatarUrl={entry.user.telegram_avatar_url}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User info */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
to={`/users/${entry.user.id}`}
|
||||
className={`font-semibold truncate hover:text-neon-400 transition-colors ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}
|
||||
className={`font-semibold truncate hover:text-neon-400 transition-colors ${isCurrentUser ? 'text-neon-400' : ''}`}
|
||||
>
|
||||
{entry.user.nickname}
|
||||
<StyledNickname user={entry.user} />
|
||||
</Link>
|
||||
{isCurrentUser && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-neon-500/20 text-neon-400 rounded-full border border-neon-500/30">
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { usersApi, telegramApi, authApi } from '@/api'
|
||||
import type { UserStats } from '@/types'
|
||||
import type { UserStats, ShopItemPublic } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import {
|
||||
NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
|
||||
@@ -13,8 +14,9 @@ import {
|
||||
User, Camera, Trophy, Target, CheckCircle, Flame,
|
||||
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
||||
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
|
||||
AlertTriangle, FileCheck
|
||||
AlertTriangle, FileCheck, Backpack, Edit3
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Schemas
|
||||
const nicknameSchema = z.object({
|
||||
@@ -33,6 +35,235 @@ const passwordSchema = z.object({
|
||||
type NicknameForm = z.infer<typeof nicknameSchema>
|
||||
type PasswordForm = z.infer<typeof passwordSchema>
|
||||
|
||||
// ============ COSMETICS HELPERS ============
|
||||
|
||||
// Background asset_data structure:
|
||||
// - type: 'solid' | 'gradient' | 'pattern' | 'animated'
|
||||
// - color: '#1a1a2e' (for solid)
|
||||
// - gradient: ['#1a1a2e', '#4a0080'] (for gradient)
|
||||
// - pattern: 'stars' | 'gaming-icons' (for pattern)
|
||||
// - animation: 'fire-particles' (for animated)
|
||||
// - animated: boolean (for animated patterns)
|
||||
|
||||
interface BackgroundResult {
|
||||
styles: React.CSSProperties
|
||||
className: string
|
||||
}
|
||||
|
||||
function getBackgroundData(background: ShopItemPublic | null): BackgroundResult {
|
||||
if (!background?.asset_data) {
|
||||
return { styles: {}, className: '' }
|
||||
}
|
||||
|
||||
const data = background.asset_data as {
|
||||
type?: string
|
||||
gradient?: string[]
|
||||
pattern?: string
|
||||
color?: string
|
||||
animation?: string
|
||||
animated?: boolean
|
||||
}
|
||||
|
||||
const styles: React.CSSProperties = {}
|
||||
let className = ''
|
||||
|
||||
switch (data.type) {
|
||||
case 'solid':
|
||||
if (data.color) {
|
||||
styles.backgroundColor = data.color
|
||||
}
|
||||
break
|
||||
case 'gradient':
|
||||
if (data.gradient && data.gradient.length > 0) {
|
||||
styles.background = `linear-gradient(135deg, ${data.gradient.join(', ')})`
|
||||
}
|
||||
break
|
||||
case 'pattern':
|
||||
// Pattern backgrounds - use CSS classes for animated stars
|
||||
if (data.pattern === 'stars') {
|
||||
// Use CSS class for twinkling stars effect
|
||||
className = 'bg-stars-animated'
|
||||
} else if (data.pattern === 'gaming-icons') {
|
||||
styles.background = `
|
||||
linear-gradient(45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
|
||||
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
|
||||
`
|
||||
styles.backgroundSize = '40px 40px, 40px 40px, 40px 40px, 40px 40px, 100% 100%'
|
||||
}
|
||||
break
|
||||
case 'animated':
|
||||
// Animated backgrounds
|
||||
if (data.animation === 'fire-particles') {
|
||||
styles.background = `
|
||||
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.4) 0%, transparent 50%),
|
||||
radial-gradient(circle at 30% 80%, rgba(255,50,0,0.3) 0%, transparent 40%),
|
||||
radial-gradient(circle at 70% 90%, rgba(255,150,0,0.3) 0%, transparent 45%),
|
||||
linear-gradient(to top, #1a0a00 0%, #0d0d0d 60%, #1a1a2e 100%)
|
||||
`
|
||||
className = 'animate-fire-pulse'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return { styles, className }
|
||||
}
|
||||
|
||||
// Name color asset_data structure:
|
||||
// - style: 'solid' | 'gradient' | 'animated'
|
||||
// - color: '#FF4444' (for solid)
|
||||
// - gradient: ['#FF6B6B', '#FFE66D'] (for gradient)
|
||||
// - animation: 'rainbow-shift' (for animated)
|
||||
|
||||
interface NameColorResult {
|
||||
type: 'solid' | 'gradient' | 'animated'
|
||||
color?: string
|
||||
gradient?: string[]
|
||||
animation?: string
|
||||
}
|
||||
|
||||
function getNameColorData(nameColor: ShopItemPublic | null): NameColorResult {
|
||||
if (!nameColor?.asset_data) {
|
||||
return { type: 'solid', color: '#ffffff' }
|
||||
}
|
||||
|
||||
const data = nameColor.asset_data as {
|
||||
style?: string
|
||||
color?: string
|
||||
gradient?: string[]
|
||||
animation?: string
|
||||
}
|
||||
|
||||
if (data.style === 'gradient' && data.gradient) {
|
||||
return { type: 'gradient', gradient: data.gradient }
|
||||
}
|
||||
if (data.style === 'animated') {
|
||||
return { type: 'animated', animation: data.animation }
|
||||
}
|
||||
return { type: 'solid', color: data.color || '#ffffff' }
|
||||
}
|
||||
|
||||
// Get title from equipped_title
|
||||
function getTitleData(title: ShopItemPublic | null): { text: string; color: string } | null {
|
||||
if (!title?.asset_data) return null
|
||||
const data = title.asset_data as { text?: string; color?: string }
|
||||
if (!data.text) return null
|
||||
return { text: data.text, color: data.color || '#ffffff' }
|
||||
}
|
||||
|
||||
// Get frame styles from asset_data
|
||||
function getFrameStyles(frame: ShopItemPublic | null): React.CSSProperties {
|
||||
if (!frame?.asset_data) return {}
|
||||
|
||||
const data = frame.asset_data as {
|
||||
border_color?: string
|
||||
gradient?: string[]
|
||||
glow_color?: string
|
||||
}
|
||||
|
||||
const styles: React.CSSProperties = {}
|
||||
|
||||
if (data.gradient && data.gradient.length > 0) {
|
||||
styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
|
||||
styles.backgroundSize = '400% 400%'
|
||||
} else if (data.border_color) {
|
||||
styles.background = data.border_color
|
||||
}
|
||||
|
||||
if (data.glow_color) {
|
||||
styles.boxShadow = `0 0 20px ${data.glow_color}, 0 0 40px ${data.glow_color}40`
|
||||
}
|
||||
|
||||
return styles
|
||||
}
|
||||
|
||||
// Get frame animation class
|
||||
function getFrameAnimation(frame: ShopItemPublic | null): string {
|
||||
if (!frame?.asset_data) return ''
|
||||
const data = frame.asset_data as { animation?: string }
|
||||
if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
|
||||
if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
|
||||
return ''
|
||||
}
|
||||
|
||||
// ============ HERO AVATAR COMPONENT ============
|
||||
|
||||
function HeroAvatar({
|
||||
avatarUrl,
|
||||
nickname,
|
||||
frame,
|
||||
onClick,
|
||||
isUploading,
|
||||
isLoading
|
||||
}: {
|
||||
avatarUrl: string | null | undefined
|
||||
nickname: string | undefined
|
||||
frame: ShopItemPublic | null
|
||||
onClick: () => void
|
||||
isUploading: boolean
|
||||
isLoading: boolean
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return <div className="w-32 h-32 md:w-40 md:h-40 rounded-2xl bg-dark-700/50 skeleton" />
|
||||
}
|
||||
|
||||
const avatarContent = (
|
||||
<div className="w-32 h-32 md:w-40 md:h-40 rounded-2xl overflow-hidden bg-dark-700/80 backdrop-blur-sm">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={nickname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
|
||||
<User className="w-16 h-16 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const hoverOverlay = (
|
||||
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded-2xl">
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-10 h-10 text-neon-500 animate-spin" />
|
||||
) : (
|
||||
<Camera className="w-10 h-10 text-neon-500" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!frame) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isUploading}
|
||||
className="relative rounded-2xl border-2 border-neon-500/50 hover:border-neon-500 transition-all shadow-[0_0_30px_rgba(34,211,238,0.15)] hover:shadow-[0_0_40px_rgba(34,211,238,0.3)] group"
|
||||
>
|
||||
{avatarContent}
|
||||
{hoverOverlay}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isUploading}
|
||||
className={clsx(
|
||||
'relative rounded-2xl p-1.5 transition-all group',
|
||||
getFrameAnimation(frame)
|
||||
)}
|
||||
style={getFrameStyles(frame)}
|
||||
>
|
||||
{avatarContent}
|
||||
{hoverOverlay}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfilePage() {
|
||||
const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
|
||||
const toast = useToast()
|
||||
@@ -298,76 +529,198 @@ export function ProfilePage() {
|
||||
const isLinked = !!user?.telegram_id
|
||||
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
||||
|
||||
// Get cosmetics data
|
||||
const equippedFrame = user?.equipped_frame as ShopItemPublic | null
|
||||
const equippedTitle = user?.equipped_title as ShopItemPublic | null
|
||||
const equippedNameColor = user?.equipped_name_color as ShopItemPublic | null
|
||||
const equippedBackground = user?.equipped_background as ShopItemPublic | null
|
||||
|
||||
const titleData = getTitleData(equippedTitle)
|
||||
const nameColorData = getNameColorData(equippedNameColor)
|
||||
|
||||
// Get nickname styles based on color type
|
||||
const getNicknameStyles = (): React.CSSProperties => {
|
||||
if (nameColorData.type === 'solid') {
|
||||
return { color: nameColorData.color }
|
||||
}
|
||||
if (nameColorData.type === 'gradient' && nameColorData.gradient) {
|
||||
return {
|
||||
background: `linear-gradient(90deg, ${nameColorData.gradient.join(', ')})`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}
|
||||
}
|
||||
if (nameColorData.type === 'animated') {
|
||||
// Rainbow animated - uses CSS animation with background-position
|
||||
return {
|
||||
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3, #ff0000)',
|
||||
backgroundSize: '400% 100%',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}
|
||||
}
|
||||
return { color: '#ffffff' }
|
||||
}
|
||||
|
||||
// Get nickname animation class
|
||||
const getNicknameAnimation = (): string => {
|
||||
if (nameColorData.type === 'animated' && nameColorData.animation === 'rainbow-shift') {
|
||||
return 'animate-rainbow-rotate'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Get background data
|
||||
const backgroundData = getBackgroundData(equippedBackground)
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Мой профиль</h1>
|
||||
<p className="text-gray-400">Настройки вашего аккаунта</p>
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* ============ HERO SECTION ============ */}
|
||||
<div
|
||||
className={clsx(
|
||||
'relative rounded-3xl overflow-hidden',
|
||||
backgroundData.className
|
||||
)}
|
||||
style={backgroundData.styles}
|
||||
>
|
||||
{/* Default gradient background if no custom background */}
|
||||
{!equippedBackground && (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-dark-800 via-dark-900 to-neon-900/20" />
|
||||
)}
|
||||
|
||||
{/* Profile Card */}
|
||||
<GlassCard variant="neon">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="relative group flex-shrink-0">
|
||||
{isLoadingAvatar ? (
|
||||
<div className="w-28 h-28 rounded-2xl bg-dark-700 skeleton" />
|
||||
) : (
|
||||
<button
|
||||
{/* Overlay for readability */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-dark-900/90 via-dark-900/40 to-transparent" />
|
||||
|
||||
{/* Scan lines effect */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(transparent_50%,rgba(0,0,0,0.03)_50%)] bg-[length:100%_4px] pointer-events-none" />
|
||||
|
||||
{/* Glow effects */}
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-neon-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-1/4 w-64 h-64 bg-accent-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 px-6 py-10 md:px-10 md:py-14">
|
||||
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-10">
|
||||
{/* Avatar with Frame */}
|
||||
<div className="flex-shrink-0">
|
||||
<HeroAvatar
|
||||
avatarUrl={displayAvatar}
|
||||
nickname={user?.nickname}
|
||||
frame={equippedFrame}
|
||||
onClick={handleAvatarClick}
|
||||
disabled={isUploadingAvatar}
|
||||
className="relative w-28 h-28 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 hover:border-neon-500 transition-all group-hover:shadow-[0_0_20px_rgba(34,211,238,0.25)]"
|
||||
>
|
||||
{displayAvatar ? (
|
||||
<img
|
||||
src={displayAvatar}
|
||||
alt={user?.nickname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
|
||||
<User className="w-12 h-12 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{isUploadingAvatar ? (
|
||||
<Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
|
||||
) : (
|
||||
<Camera className="w-8 h-8 text-neon-500" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nickname Form */}
|
||||
<div className="flex-1 w-full sm:w-auto">
|
||||
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
|
||||
<Input
|
||||
label="Никнейм"
|
||||
{...nicknameForm.register('nickname')}
|
||||
error={nicknameForm.formState.errors.nickname?.message}
|
||||
isUploading={isUploadingAvatar}
|
||||
isLoading={isLoadingAvatar}
|
||||
/>
|
||||
<NeonButton
|
||||
type="submit"
|
||||
size="sm"
|
||||
isLoading={nicknameForm.formState.isSubmitting}
|
||||
disabled={!nicknameForm.formState.isDirty}
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Сохранить
|
||||
</NeonButton>
|
||||
</form>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
{/* Nickname with color + Title badge */}
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-3 mb-3">
|
||||
<h1
|
||||
className={clsx(
|
||||
'text-3xl md:text-4xl font-bold font-display tracking-wide drop-shadow-[0_0_10px_rgba(255,255,255,0.3)]',
|
||||
getNicknameAnimation()
|
||||
)}
|
||||
style={getNicknameStyles()}
|
||||
>
|
||||
{user?.nickname || 'Игрок'}
|
||||
</h1>
|
||||
|
||||
{/* Title badge */}
|
||||
{titleData && (
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-sm font-semibold border backdrop-blur-sm"
|
||||
style={{
|
||||
color: titleData.color,
|
||||
borderColor: `${titleData.color}50`,
|
||||
backgroundColor: `${titleData.color}15`,
|
||||
boxShadow: `0 0 15px ${titleData.color}30`
|
||||
}}
|
||||
>
|
||||
{titleData.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role badge */}
|
||||
{user?.role === 'admin' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-500/20 border border-purple-500/30 text-purple-400 text-sm font-medium mb-4">
|
||||
<Shield className="w-4 h-4" />
|
||||
Администратор
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick stats preview */}
|
||||
{stats && (
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm text-gray-300 mt-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Trophy className="w-4 h-4 text-yellow-500" />
|
||||
<span>{stats.wins_count} побед</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Target className="w-4 h-4 text-neon-400" />
|
||||
<span>{stats.marathons_count} марафонов</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Flame className="w-4 h-4 text-orange-400" />
|
||||
<span>{stats.total_points_earned} очков</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inventory link */}
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/inventory"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700/50 hover:bg-dark-700 border border-dark-600 hover:border-neon-500/30 text-gray-300 hover:text-white transition-all"
|
||||
>
|
||||
<Backpack className="w-4 h-4" />
|
||||
Инвентарь
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ NICKNAME EDIT SECTION ============ */}
|
||||
<GlassCard>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
||||
<Edit3 className="w-5 h-5 text-neon-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Изменить никнейм</h2>
|
||||
<p className="text-sm text-gray-400">Ваше игровое имя</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
{...nicknameForm.register('nickname')}
|
||||
error={nicknameForm.formState.errors.nickname?.message}
|
||||
placeholder="Введите никнейм"
|
||||
/>
|
||||
</div>
|
||||
<NeonButton
|
||||
type="submit"
|
||||
isLoading={nicknameForm.formState.isSubmitting}
|
||||
disabled={!nicknameForm.formState.isDirty}
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Сохранить
|
||||
</NeonButton>
|
||||
</form>
|
||||
</GlassCard>
|
||||
|
||||
{/* Stats */}
|
||||
|
||||
470
frontend/src/pages/ShopPage.tsx
Normal file
470
frontend/src/pages/ShopPage.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useShopStore } from '@/store/shop'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||
import {
|
||||
Loader2, Coins, ShoppingBag, Package, Sparkles,
|
||||
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward,
|
||||
Minus, Plus
|
||||
} from 'lucide-react'
|
||||
import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
|
||||
import { RARITY_COLORS, RARITY_NAMES } from '@/types'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const ITEM_TYPE_ICONS: Record<ShopItemType, React.ReactNode> = {
|
||||
frame: <Frame className="w-5 h-5" />,
|
||||
title: <Type className="w-5 h-5" />,
|
||||
name_color: <Palette className="w-5 h-5" />,
|
||||
background: <Image className="w-5 h-5" />,
|
||||
consumable: <Zap className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||
skip: <SkipForward className="w-8 h-8" />,
|
||||
shield: <Shield className="w-8 h-8" />,
|
||||
boost: <Zap className="w-8 h-8" />,
|
||||
reroll: <RefreshCw className="w-8 h-8" />,
|
||||
}
|
||||
|
||||
interface ShopItemCardProps {
|
||||
item: ShopItem
|
||||
onPurchase: (item: ShopItem, quantity: number) => void
|
||||
isPurchasing: boolean
|
||||
}
|
||||
|
||||
function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const rarityColors = RARITY_COLORS[item.rarity]
|
||||
const isConsumable = item.item_type === 'consumable'
|
||||
const maxQuantity = item.stock_remaining !== null ? Math.min(10, item.stock_remaining) : 10
|
||||
const totalPrice = item.price * quantity
|
||||
|
||||
const incrementQuantity = () => {
|
||||
if (quantity < maxQuantity) {
|
||||
setQuantity(q => q + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const decrementQuantity = () => {
|
||||
if (quantity > 1) {
|
||||
setQuantity(q => q - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const getItemPreview = () => {
|
||||
if (item.item_type === 'consumable') {
|
||||
return CONSUMABLE_ICONS[item.code] || <Package className="w-8 h-8" />
|
||||
}
|
||||
|
||||
// Name color preview - handles solid, gradient, animated
|
||||
if (item.item_type === 'name_color') {
|
||||
const data = item.asset_data as { style?: string; color?: string; gradient?: string[]; animation?: string } | null
|
||||
|
||||
// Gradient style
|
||||
if (data?.style === 'gradient' && data.gradient) {
|
||||
return (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||
style={{ background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Animated rainbow style
|
||||
if (data?.style === 'animated') {
|
||||
return (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full border-2 border-dark-600 animate-rainbow-rotate"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
|
||||
backgroundSize: '400% 400%'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Solid color style (default)
|
||||
const solidColor = data?.color || '#ffffff'
|
||||
return (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||
style={{ backgroundColor: solidColor }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Background preview
|
||||
if (item.item_type === 'background') {
|
||||
const data = item.asset_data as { type?: string; color?: string; gradient?: string[]; pattern?: string; animation?: string } | null
|
||||
let bgStyle: React.CSSProperties = {}
|
||||
let animClass = ''
|
||||
|
||||
if (data?.type === 'solid' && data.color) {
|
||||
bgStyle = { backgroundColor: data.color }
|
||||
} else if (data?.type === 'gradient' && data.gradient) {
|
||||
bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
|
||||
} else if (data?.type === 'pattern') {
|
||||
if (data.pattern === 'stars') {
|
||||
bgStyle = {
|
||||
background: `
|
||||
radial-gradient(1px 1px at 10px 10px, #fff, transparent),
|
||||
radial-gradient(1px 1px at 30px 25px, rgba(255,255,255,0.8), transparent),
|
||||
radial-gradient(1px 1px at 50px 15px, #fff, transparent),
|
||||
linear-gradient(135deg, #0d1b2a 0%, #1b263b 100%)
|
||||
`,
|
||||
backgroundSize: '60px 40px, 60px 40px, 60px 40px, 100% 100%'
|
||||
}
|
||||
animClass = 'animate-twinkle'
|
||||
} else if (data.pattern === 'gaming-icons') {
|
||||
bgStyle = {
|
||||
background: `
|
||||
linear-gradient(45deg, rgba(34,211,238,0.2) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(168,85,247,0.2) 25%, transparent 25%),
|
||||
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
|
||||
`,
|
||||
backgroundSize: '20px 20px, 20px 20px, 100% 100%'
|
||||
}
|
||||
}
|
||||
} else if (data?.type === 'animated' && data.animation === 'fire-particles') {
|
||||
bgStyle = {
|
||||
background: `
|
||||
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.5) 0%, transparent 50%),
|
||||
radial-gradient(circle at 30% 80%, rgba(255,50,0,0.4) 0%, transparent 40%),
|
||||
linear-gradient(to top, #1a0a00 0%, #0d0d0d 100%)
|
||||
`
|
||||
}
|
||||
animClass = 'animate-fire-pulse'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('w-16 h-12 rounded-lg border-2 border-dark-600', animClass)}
|
||||
style={bgStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.item_type === 'frame') {
|
||||
// Use FramePreview for animated and gradient frames
|
||||
const frameItem: ShopItemPublic = {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
item_type: item.item_type,
|
||||
rarity: item.rarity,
|
||||
asset_data: item.asset_data,
|
||||
}
|
||||
return <FramePreview frame={frameItem} size="lg" />
|
||||
}
|
||||
if (item.item_type === 'title' && item.asset_data?.text) {
|
||||
return (
|
||||
<span
|
||||
className="text-lg font-bold"
|
||||
style={{ color: (item.asset_data.color as string) || '#ffffff' }}
|
||||
>
|
||||
{item.asset_data.text as string}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return ITEM_TYPE_ICONS[item.item_type]
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
className={clsx(
|
||||
'p-4 border transition-all duration-300',
|
||||
rarityColors.border,
|
||||
item.is_owned && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{/* Rarity badge */}
|
||||
<div className={clsx('text-xs font-medium mb-2', rarityColors.text)}>
|
||||
{RARITY_NAMES[item.rarity]}
|
||||
</div>
|
||||
|
||||
{/* Item preview */}
|
||||
<div className="flex justify-center items-center h-20 mb-3">
|
||||
{getItemPreview()}
|
||||
</div>
|
||||
|
||||
{/* Item info */}
|
||||
<h3 className="text-white font-semibold text-center mb-1">{item.name}</h3>
|
||||
<p className="text-gray-400 text-xs text-center mb-3 line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
{/* Quantity selector for consumables */}
|
||||
{isConsumable && !item.is_owned && item.is_available && (
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<button
|
||||
onClick={decrementQuantity}
|
||||
disabled={quantity <= 1 || isPurchasing}
|
||||
className="w-7 h-7 rounded-lg bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-8 text-center text-white font-bold">{quantity}</span>
|
||||
<button
|
||||
onClick={incrementQuantity}
|
||||
disabled={quantity >= maxQuantity || isPurchasing}
|
||||
className="w-7 h-7 rounded-lg bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price and action */}
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<div className="flex items-center gap-1 text-yellow-400">
|
||||
<Coins className="w-4 h-4" />
|
||||
<span className="font-bold">{isConsumable ? totalPrice : item.price}</span>
|
||||
{isConsumable && quantity > 1 && (
|
||||
<span className="text-xs text-gray-500">({item.price}×{quantity})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.is_owned && !isConsumable ? (
|
||||
<span className="text-green-400 text-sm flex items-center gap-1">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Куплено
|
||||
</span>
|
||||
) : item.is_equipped ? (
|
||||
<span className="text-neon-400 text-sm">Надето</span>
|
||||
) : (
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => onPurchase(item, quantity)}
|
||||
disabled={isPurchasing || !item.is_available}
|
||||
>
|
||||
{isPurchasing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Купить'
|
||||
)}
|
||||
</NeonButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stock info */}
|
||||
{item.stock_remaining !== null && (
|
||||
<div className="text-xs text-gray-500 text-center mt-2">
|
||||
Осталось: {item.stock_remaining}
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShopPage() {
|
||||
const { items, balance, isLoading, loadItems, loadBalance, purchase, clearError, error } = useShopStore()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ShopItemType | 'all'>('all')
|
||||
const [purchasingId, setPurchasingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadBalance()
|
||||
loadItems()
|
||||
}, [loadBalance, loadItems])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
clearError()
|
||||
}
|
||||
}, [error, toast, clearError])
|
||||
|
||||
const handlePurchase = async (item: ShopItem, quantity: number = 1) => {
|
||||
const totalCost = item.price * quantity
|
||||
const isConsumable = item.item_type === 'consumable'
|
||||
const quantityText = quantity > 1 ? ` (×${quantity})` : ''
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: 'Подтвердите покупку',
|
||||
message: isConsumable && quantity > 1
|
||||
? `Купить "${item.name}" × ${quantity} шт. за ${totalCost} монет?`
|
||||
: `Купить "${item.name}" за ${item.price} монет?`,
|
||||
confirmText: 'Купить',
|
||||
cancelText: 'Отмена',
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
setPurchasingId(item.id)
|
||||
const success = await purchase(item.id, quantity)
|
||||
setPurchasingId(null)
|
||||
|
||||
if (success) {
|
||||
toast.success(`Вы приобрели "${item.name}"${quantityText}!`)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = activeTab === 'all'
|
||||
? items
|
||||
: items.filter(item => item.item_type === activeTab)
|
||||
|
||||
// Group items by type for "All" tab
|
||||
const itemsByType: Record<ShopItemType, ShopItem[]> = {
|
||||
frame: [],
|
||||
title: [],
|
||||
name_color: [],
|
||||
background: [],
|
||||
consumable: [],
|
||||
}
|
||||
|
||||
if (activeTab === 'all') {
|
||||
items.forEach(item => {
|
||||
if (itemsByType[item.item_type]) {
|
||||
itemsByType[item.item_type].push(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const categoryOrder: ShopItemType[] = ['frame', 'title', 'name_color', 'background', 'consumable']
|
||||
const categoryLabels: Record<ShopItemType, { label: string; icon: React.ReactNode }> = {
|
||||
frame: { label: 'Рамки профиля', icon: <Frame className="w-5 h-5" /> },
|
||||
title: { label: 'Титулы', icon: <Type className="w-5 h-5" /> },
|
||||
name_color: { label: 'Цвета ника', icon: <Palette className="w-5 h-5" /> },
|
||||
background: { label: 'Фоны профиля', icon: <Image className="w-5 h-5" /> },
|
||||
consumable: { label: 'Расходуемые предметы', icon: <Zap className="w-5 h-5" /> },
|
||||
}
|
||||
|
||||
const tabs: { id: ShopItemType | 'all'; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'all', label: 'Все', icon: <ShoppingBag className="w-4 h-4" /> },
|
||||
{ id: 'frame', label: 'Рамки', icon: <Frame className="w-4 h-4" /> },
|
||||
{ id: 'title', label: 'Титулы', icon: <Type className="w-4 h-4" /> },
|
||||
{ id: 'name_color', label: 'Цвета', icon: <Palette className="w-4 h-4" /> },
|
||||
{ id: 'background', label: 'Фоны', icon: <Image className="w-4 h-4" /> },
|
||||
{ id: 'consumable', label: 'Расходники', icon: <Zap className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
if (isLoading && items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-neon-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
|
||||
<ShoppingBag className="w-8 h-8 text-neon-500" />
|
||||
Магазин
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Покупай косметику и расходуемые предметы
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Balance */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-dark-800/50 rounded-lg border border-yellow-500/30">
|
||||
<Coins className="w-5 h-5 text-yellow-400" />
|
||||
<span className="text-yellow-400 font-bold text-lg">{balance}</span>
|
||||
</div>
|
||||
|
||||
{/* Link to inventory */}
|
||||
<Link to="/inventory">
|
||||
<NeonButton variant="secondary">
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Инвентарь
|
||||
</NeonButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap',
|
||||
activeTab === tab.id
|
||||
? 'bg-neon-500 text-dark-900'
|
||||
: 'bg-dark-700 text-gray-300 hover:bg-dark-600'
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Items grid */}
|
||||
{filteredItems.length === 0 ? (
|
||||
<GlassCard className="p-8 text-center">
|
||||
<Package className="w-16 h-16 mx-auto text-gray-500 mb-4" />
|
||||
<p className="text-gray-400">Нет доступных товаров в этой категории</p>
|
||||
</GlassCard>
|
||||
) : activeTab === 'all' ? (
|
||||
// Grouped view for "All" tab
|
||||
<div className="space-y-8">
|
||||
{categoryOrder.map(category => {
|
||||
const categoryItems = itemsByType[category]
|
||||
if (categoryItems.length === 0) return null
|
||||
|
||||
const { label, icon } = categoryLabels[category]
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center text-neon-400">
|
||||
{icon}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white">{label}</h2>
|
||||
<span className="text-sm text-gray-500">({categoryItems.length})</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{categoryItems.map(item => (
|
||||
<ShopItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onPurchase={handlePurchase}
|
||||
isPurchasing={purchasingId === item.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// Regular grid for specific category
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filteredItems.map(item => (
|
||||
<ShopItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onPurchase={handlePurchase}
|
||||
isPurchasing={purchasingId === item.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info about coins */}
|
||||
<GlassCard className="mt-8 p-4">
|
||||
<h3 className="text-white font-semibold mb-2 flex items-center gap-2">
|
||||
<Coins className="w-5 h-5 text-yellow-400" />
|
||||
Как заработать монеты?
|
||||
</h3>
|
||||
<ul className="text-gray-400 text-sm space-y-1">
|
||||
<li>• Выполняй задания в <span className="text-neon-400">сертифицированных</span> марафонах</li>
|
||||
<li>• Easy задание — 5 монет, Medium — 12 монет, Hard — 25 монет</li>
|
||||
<li>• Playthrough — ~5% от заработанных очков</li>
|
||||
<li>• Топ-3 места в марафоне: 1-е — 100, 2-е — 50, 3-е — 30 монет</li>
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,200 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { usersApi } from '@/api'
|
||||
import type { UserProfilePublic } from '@/types'
|
||||
import type { UserProfilePublic, ShopItemPublic } from '@/types'
|
||||
import { GlassCard, StatsCard } from '@/components/ui'
|
||||
import {
|
||||
User, Trophy, Target, CheckCircle, Flame,
|
||||
Loader2, ArrowLeft, Calendar, Zap
|
||||
Loader2, ArrowLeft, Calendar, Shield
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// ============ COSMETICS HELPERS ============
|
||||
|
||||
interface BackgroundResult {
|
||||
styles: React.CSSProperties
|
||||
className: string
|
||||
}
|
||||
|
||||
function getBackgroundData(background: ShopItemPublic | null): BackgroundResult {
|
||||
if (!background?.asset_data) {
|
||||
return { styles: {}, className: '' }
|
||||
}
|
||||
|
||||
const data = background.asset_data as {
|
||||
type?: string
|
||||
gradient?: string[]
|
||||
pattern?: string
|
||||
color?: string
|
||||
animation?: string
|
||||
animated?: boolean
|
||||
}
|
||||
|
||||
const styles: React.CSSProperties = {}
|
||||
let className = ''
|
||||
|
||||
switch (data.type) {
|
||||
case 'solid':
|
||||
if (data.color) {
|
||||
styles.backgroundColor = data.color
|
||||
}
|
||||
break
|
||||
case 'gradient':
|
||||
if (data.gradient && data.gradient.length > 0) {
|
||||
styles.background = `linear-gradient(135deg, ${data.gradient.join(', ')})`
|
||||
}
|
||||
break
|
||||
case 'pattern':
|
||||
if (data.pattern === 'stars') {
|
||||
className = 'bg-stars-animated'
|
||||
} else if (data.pattern === 'gaming-icons') {
|
||||
styles.background = `
|
||||
linear-gradient(45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(34,211,238,0.1) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(168,85,247,0.1) 75%),
|
||||
linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)
|
||||
`
|
||||
styles.backgroundSize = '40px 40px, 40px 40px, 40px 40px, 40px 40px, 100% 100%'
|
||||
}
|
||||
break
|
||||
case 'animated':
|
||||
if (data.animation === 'fire-particles') {
|
||||
styles.background = `
|
||||
radial-gradient(circle at 50% 100%, rgba(255,100,0,0.4) 0%, transparent 50%),
|
||||
radial-gradient(circle at 30% 80%, rgba(255,50,0,0.3) 0%, transparent 40%),
|
||||
radial-gradient(circle at 70% 90%, rgba(255,150,0,0.3) 0%, transparent 45%),
|
||||
linear-gradient(to top, #1a0a00 0%, #0d0d0d 60%, #1a1a2e 100%)
|
||||
`
|
||||
className = 'animate-fire-pulse'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return { styles, className }
|
||||
}
|
||||
|
||||
interface NameColorResult {
|
||||
type: 'solid' | 'gradient' | 'animated'
|
||||
color?: string
|
||||
gradient?: string[]
|
||||
animation?: string
|
||||
}
|
||||
|
||||
function getNameColorData(nameColor: ShopItemPublic | null): NameColorResult {
|
||||
if (!nameColor?.asset_data) {
|
||||
return { type: 'solid', color: '#ffffff' }
|
||||
}
|
||||
|
||||
const data = nameColor.asset_data as {
|
||||
style?: string
|
||||
color?: string
|
||||
gradient?: string[]
|
||||
animation?: string
|
||||
}
|
||||
|
||||
if (data.style === 'gradient' && data.gradient) {
|
||||
return { type: 'gradient', gradient: data.gradient }
|
||||
}
|
||||
if (data.style === 'animated') {
|
||||
return { type: 'animated', animation: data.animation }
|
||||
}
|
||||
return { type: 'solid', color: data.color || '#ffffff' }
|
||||
}
|
||||
|
||||
function getTitleData(title: ShopItemPublic | null): { text: string; color: string } | null {
|
||||
if (!title?.asset_data) return null
|
||||
const data = title.asset_data as { text?: string; color?: string }
|
||||
if (!data.text) return null
|
||||
return { text: data.text, color: data.color || '#ffffff' }
|
||||
}
|
||||
|
||||
function getFrameStyles(frame: ShopItemPublic | null): React.CSSProperties {
|
||||
if (!frame?.asset_data) return {}
|
||||
|
||||
const data = frame.asset_data as {
|
||||
border_color?: string
|
||||
gradient?: string[]
|
||||
glow_color?: string
|
||||
}
|
||||
|
||||
const styles: React.CSSProperties = {}
|
||||
|
||||
if (data.gradient && data.gradient.length > 0) {
|
||||
styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})`
|
||||
styles.backgroundSize = '400% 400%'
|
||||
} else if (data.border_color) {
|
||||
styles.background = data.border_color
|
||||
}
|
||||
|
||||
if (data.glow_color) {
|
||||
styles.boxShadow = `0 0 20px ${data.glow_color}, 0 0 40px ${data.glow_color}40`
|
||||
}
|
||||
|
||||
return styles
|
||||
}
|
||||
|
||||
function getFrameAnimation(frame: ShopItemPublic | null): string {
|
||||
if (!frame?.asset_data) return ''
|
||||
const data = frame.asset_data as { animation?: string }
|
||||
if (data.animation === 'fire-pulse') return 'animate-fire-pulse'
|
||||
if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate'
|
||||
return ''
|
||||
}
|
||||
|
||||
// ============ HERO AVATAR COMPONENT ============
|
||||
|
||||
function HeroAvatar({
|
||||
avatarUrl,
|
||||
telegramAvatarUrl,
|
||||
nickname,
|
||||
frame,
|
||||
}: {
|
||||
avatarUrl: string | null
|
||||
telegramAvatarUrl: string | null
|
||||
nickname: string
|
||||
frame: ShopItemPublic | null
|
||||
}) {
|
||||
const displayAvatar = avatarUrl || telegramAvatarUrl
|
||||
|
||||
const avatarContent = (
|
||||
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl overflow-hidden bg-dark-700/80 backdrop-blur-sm">
|
||||
{displayAvatar ? (
|
||||
<img
|
||||
src={displayAvatar}
|
||||
alt={nickname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
|
||||
<User className="w-14 h-14 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!frame) {
|
||||
return (
|
||||
<div className="rounded-2xl border-2 border-neon-500/50 shadow-[0_0_30px_rgba(34,211,238,0.15)]">
|
||||
{avatarContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-2xl p-1.5',
|
||||
getFrameAnimation(frame)
|
||||
)}
|
||||
style={getFrameStyles(frame)}
|
||||
>
|
||||
{avatarContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ MAIN COMPONENT ============
|
||||
|
||||
export function UserProfilePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -107,8 +295,46 @@ export function UserProfilePage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Get cosmetics data
|
||||
const backgroundData = getBackgroundData(profile.equipped_background)
|
||||
const nameColorData = getNameColorData(profile.equipped_name_color)
|
||||
const titleData = getTitleData(profile.equipped_title)
|
||||
const displayAvatar = avatarBlobUrl || profile.telegram_avatar_url
|
||||
|
||||
// Get nickname styles based on color type
|
||||
const getNicknameStyles = (): React.CSSProperties => {
|
||||
if (nameColorData.type === 'solid') {
|
||||
return { color: nameColorData.color }
|
||||
}
|
||||
if (nameColorData.type === 'gradient' && nameColorData.gradient) {
|
||||
return {
|
||||
background: `linear-gradient(90deg, ${nameColorData.gradient.join(', ')})`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}
|
||||
}
|
||||
if (nameColorData.type === 'animated') {
|
||||
return {
|
||||
background: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3, #ff0000)',
|
||||
backgroundSize: '400% 100%',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}
|
||||
}
|
||||
return { color: '#ffffff' }
|
||||
}
|
||||
|
||||
const getNicknameAnimation = (): string => {
|
||||
if (nameColorData.type === 'animated' && nameColorData.animation === 'rainbow-shift') {
|
||||
return 'animate-rainbow-rotate'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Кнопка назад */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
@@ -118,42 +344,107 @@ export function UserProfilePage() {
|
||||
Назад
|
||||
</button>
|
||||
|
||||
{/* Профиль */}
|
||||
<GlassCard variant="neon">
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Аватар */}
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 shadow-[0_0_14px_rgba(34,211,238,0.15)]">
|
||||
{avatarBlobUrl ? (
|
||||
<img
|
||||
src={avatarBlobUrl}
|
||||
alt={profile.nickname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-dark-700 to-dark-800">
|
||||
<User className="w-12 h-12 text-gray-500" />
|
||||
{/* ============ HERO SECTION ============ */}
|
||||
<div
|
||||
className={clsx(
|
||||
'relative rounded-3xl overflow-hidden',
|
||||
backgroundData.className
|
||||
)}
|
||||
style={backgroundData.styles}
|
||||
>
|
||||
{/* Default gradient background if no custom background */}
|
||||
{!profile.equipped_background && (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-dark-800 via-dark-900 to-neon-900/20" />
|
||||
)}
|
||||
|
||||
{/* Overlay for readability */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-dark-900/90 via-dark-900/40 to-transparent" />
|
||||
|
||||
{/* Scan lines effect */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(transparent_50%,rgba(0,0,0,0.03)_50%)] bg-[length:100%_4px] pointer-events-none" />
|
||||
|
||||
{/* Glow effects */}
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-neon-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-1/4 w-64 h-64 bg-accent-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 px-6 py-10 md:px-10 md:py-14">
|
||||
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-10">
|
||||
{/* Avatar with Frame */}
|
||||
<div className="flex-shrink-0">
|
||||
<HeroAvatar
|
||||
avatarUrl={displayAvatar}
|
||||
telegramAvatarUrl={profile.telegram_avatar_url}
|
||||
nickname={profile.nickname}
|
||||
frame={profile.equipped_frame}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
{/* Nickname with color + Title badge */}
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-3 mb-3">
|
||||
<h1
|
||||
className={clsx(
|
||||
'text-3xl md:text-4xl font-bold font-display tracking-wide drop-shadow-[0_0_10px_rgba(255,255,255,0.3)]',
|
||||
getNicknameAnimation()
|
||||
)}
|
||||
style={getNicknameStyles()}
|
||||
>
|
||||
{profile.nickname}
|
||||
</h1>
|
||||
|
||||
{/* Title badge */}
|
||||
{titleData && (
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-sm font-semibold border backdrop-blur-sm"
|
||||
style={{
|
||||
color: titleData.color,
|
||||
borderColor: `${titleData.color}50`,
|
||||
backgroundColor: `${titleData.color}15`,
|
||||
boxShadow: `0 0 15px ${titleData.color}30`
|
||||
}}
|
||||
>
|
||||
{titleData.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin badge */}
|
||||
{profile.role === 'admin' && (
|
||||
<div className="flex justify-center md:justify-start mb-3">
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-500/20 border border-purple-500/30 text-purple-400 text-sm font-medium">
|
||||
<Shield className="w-4 h-4" />
|
||||
Администратор
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Online indicator effect */}
|
||||
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-lg bg-neon-500/20 border border-neon-500/30 flex items-center justify-center">
|
||||
<Zap className="w-3 h-3 text-neon-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Инфо */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">
|
||||
{profile.nickname}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Calendar className="w-4 h-4 text-accent-400" />
|
||||
<span>Зарегистрирован {formatDate(profile.created_at)}</span>
|
||||
{/* Registration date */}
|
||||
<div className="flex items-center justify-center md:justify-start gap-2 text-gray-400 text-sm mb-4">
|
||||
<Calendar className="w-4 h-4 text-accent-400" />
|
||||
<span>Зарегистрирован {formatDate(profile.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{/* Quick stats preview */}
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm text-gray-300">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Trophy className="w-4 h-4 text-yellow-500" />
|
||||
<span>{profile.stats.wins_count} побед</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Target className="w-4 h-4 text-neon-400" />
|
||||
<span>{profile.stats.marathons_count} марафонов</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Flame className="w-4 h-4 text-orange-400" />
|
||||
<span>{profile.stats.total_points_earned} очков</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<GlassCard>
|
||||
|
||||
123
frontend/src/store/shop.ts
Normal file
123
frontend/src/store/shop.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { create } from 'zustand'
|
||||
import { shopApi } from '@/api/shop'
|
||||
import type { ShopItem, InventoryItem, ShopItemType } from '@/types'
|
||||
import { useAuthStore } from './auth'
|
||||
|
||||
interface ShopState {
|
||||
// State
|
||||
balance: number
|
||||
items: ShopItem[]
|
||||
inventory: InventoryItem[]
|
||||
isLoading: boolean
|
||||
isBalanceLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
loadBalance: () => Promise<void>
|
||||
loadItems: (itemType?: ShopItemType) => Promise<void>
|
||||
loadInventory: (itemType?: ShopItemType) => Promise<void>
|
||||
purchase: (itemId: number, quantity?: number) => Promise<boolean>
|
||||
equip: (inventoryId: number) => Promise<boolean>
|
||||
unequip: (itemType: ShopItemType) => Promise<boolean>
|
||||
updateBalance: (newBalance: number) => void
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export const useShopStore = create<ShopState>()((set, get) => ({
|
||||
balance: 0,
|
||||
items: [],
|
||||
inventory: [],
|
||||
isLoading: false,
|
||||
isBalanceLoading: false,
|
||||
error: null,
|
||||
|
||||
loadBalance: async () => {
|
||||
set({ isBalanceLoading: true })
|
||||
try {
|
||||
const data = await shopApi.getBalance()
|
||||
set({ balance: data.balance, isBalanceLoading: false })
|
||||
} catch (err) {
|
||||
console.error('Failed to load balance:', err)
|
||||
set({ isBalanceLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
loadItems: async (itemType?: ShopItemType) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const items = await shopApi.getItems(itemType)
|
||||
set({ items, isLoading: false })
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
set({
|
||||
error: error.response?.data?.detail || 'Не удалось загрузить товары',
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
loadInventory: async (itemType?: ShopItemType) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const inventory = await shopApi.getInventory(itemType)
|
||||
set({ inventory, isLoading: false })
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
set({
|
||||
error: error.response?.data?.detail || 'Не удалось загрузить инвентарь',
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
purchase: async (itemId: number, quantity: number = 1) => {
|
||||
try {
|
||||
const result = await shopApi.purchase(itemId, quantity)
|
||||
set({ balance: result.new_balance })
|
||||
// Reload items and inventory to update ownership status
|
||||
await Promise.all([get().loadItems(), get().loadInventory()])
|
||||
return true
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
set({ error: error.response?.data?.detail || 'Не удалось совершить покупку' })
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
equip: async (inventoryId: number) => {
|
||||
try {
|
||||
await shopApi.equip(inventoryId)
|
||||
await get().loadInventory()
|
||||
// Sync user data to update equipped cosmetics in UI
|
||||
await useAuthStore.getState().syncUser()
|
||||
return true
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
set({ error: error.response?.data?.detail || 'Не удалось экипировать предмет' })
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
unequip: async (itemType: ShopItemType) => {
|
||||
try {
|
||||
await shopApi.unequip(itemType)
|
||||
await get().loadInventory()
|
||||
// Sync user data to update equipped cosmetics in UI
|
||||
await useAuthStore.getState().syncUser()
|
||||
return true
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
set({ error: error.response?.data?.detail || 'Не удалось снять предмет' })
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
updateBalance: (newBalance: number) => {
|
||||
set({ balance: newBalance })
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}))
|
||||
|
||||
// Convenience hook for just getting the balance
|
||||
export const useCoinsBalance = () => useShopStore((state) => state.balance)
|
||||
@@ -9,6 +9,11 @@ export interface UserPublic {
|
||||
role: UserRole
|
||||
telegram_avatar_url: string | null
|
||||
created_at: string
|
||||
// Equipped cosmetics
|
||||
equipped_frame: ShopItemPublic | null
|
||||
equipped_title: ShopItemPublic | null
|
||||
equipped_name_color: ShopItemPublic | null
|
||||
equipped_background: ShopItemPublic | null
|
||||
}
|
||||
|
||||
// Full user info (only for own profile from /auth/me)
|
||||
@@ -688,11 +693,161 @@ export interface UserProfilePublic {
|
||||
id: number
|
||||
nickname: string
|
||||
avatar_url: string | null
|
||||
telegram_avatar_url: string | null
|
||||
role: UserRole
|
||||
created_at: string
|
||||
stats: UserStats
|
||||
// Equipped cosmetics
|
||||
equipped_frame: ShopItemPublic | null
|
||||
equipped_title: ShopItemPublic | null
|
||||
equipped_name_color: ShopItemPublic | null
|
||||
equipped_background: ShopItemPublic | null
|
||||
}
|
||||
|
||||
export interface PasswordChangeData {
|
||||
current_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
// === Shop types ===
|
||||
|
||||
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
|
||||
export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||
export type ConsumableType = 'skip' | 'shield' | 'boost' | 'reroll'
|
||||
|
||||
export interface ShopItemPublic {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
item_type: ShopItemType
|
||||
rarity: ItemRarity
|
||||
asset_data: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface ShopItem {
|
||||
id: number
|
||||
item_type: ShopItemType
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
price: number
|
||||
rarity: ItemRarity
|
||||
asset_data: Record<string, unknown> | null
|
||||
is_active: boolean
|
||||
available_from: string | null
|
||||
available_until: string | null
|
||||
stock_limit: number | null
|
||||
stock_remaining: number | null
|
||||
created_at: string
|
||||
is_available: boolean
|
||||
is_owned: boolean
|
||||
is_equipped: boolean
|
||||
}
|
||||
|
||||
export interface InventoryItem {
|
||||
id: number
|
||||
item: ShopItem
|
||||
quantity: number
|
||||
equipped: boolean
|
||||
purchased_at: string
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
export interface PurchaseRequest {
|
||||
item_id: number
|
||||
quantity?: number
|
||||
}
|
||||
|
||||
export interface PurchaseResponse {
|
||||
success: boolean
|
||||
item: ShopItem
|
||||
quantity: number
|
||||
total_cost: number
|
||||
new_balance: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface UseConsumableRequest {
|
||||
item_code: ConsumableType
|
||||
marathon_id: number
|
||||
assignment_id?: number
|
||||
}
|
||||
|
||||
export interface UseConsumableResponse {
|
||||
success: boolean
|
||||
item_code: string
|
||||
remaining_quantity: number
|
||||
effect_description: string
|
||||
effect_data: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface CoinTransaction {
|
||||
id: number
|
||||
amount: number
|
||||
transaction_type: string
|
||||
description: string | null
|
||||
reference_type: string | null
|
||||
reference_id: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CoinsBalance {
|
||||
balance: number
|
||||
recent_transactions: CoinTransaction[]
|
||||
}
|
||||
|
||||
export interface ConsumablesStatus {
|
||||
skips_available: number
|
||||
skips_used: number
|
||||
skips_remaining: number | null
|
||||
has_shield: boolean
|
||||
has_active_boost: boolean
|
||||
boost_multiplier: number | null
|
||||
boost_expires_at: string | null
|
||||
rerolls_available: number
|
||||
}
|
||||
|
||||
export interface UserCosmetics {
|
||||
frame: ShopItem | null
|
||||
title: ShopItem | null
|
||||
name_color: ShopItem | null
|
||||
background: ShopItem | null
|
||||
}
|
||||
|
||||
// Certification types
|
||||
export type CertificationStatus = 'none' | 'pending' | 'certified' | 'rejected'
|
||||
|
||||
export interface CertificationStatusResponse {
|
||||
marathon_id: number
|
||||
certification_status: CertificationStatus
|
||||
is_certified: boolean
|
||||
certification_requested_at: string | null
|
||||
certified_at: string | null
|
||||
certified_by_nickname: string | null
|
||||
rejection_reason: string | null
|
||||
}
|
||||
|
||||
// Rarity colors for UI
|
||||
export const RARITY_COLORS: Record<ItemRarity, { bg: string; border: string; text: string }> = {
|
||||
common: { bg: 'bg-gray-500/20', border: 'border-gray-500', text: 'text-gray-400' },
|
||||
uncommon: { bg: 'bg-green-500/20', border: 'border-green-500', text: 'text-green-400' },
|
||||
rare: { bg: 'bg-blue-500/20', border: 'border-blue-500', text: 'text-blue-400' },
|
||||
epic: { bg: 'bg-purple-500/20', border: 'border-purple-500', text: 'text-purple-400' },
|
||||
legendary: { bg: 'bg-yellow-500/20', border: 'border-yellow-500', text: 'text-yellow-400' },
|
||||
}
|
||||
|
||||
export const RARITY_NAMES: Record<ItemRarity, string> = {
|
||||
common: 'Обычный',
|
||||
uncommon: 'Необычный',
|
||||
rare: 'Редкий',
|
||||
epic: 'Эпический',
|
||||
legendary: 'Легендарный',
|
||||
}
|
||||
|
||||
export const ITEM_TYPE_NAMES: Record<ShopItemType, string> = {
|
||||
frame: 'Рамка',
|
||||
title: 'Титул',
|
||||
name_color: 'Цвет ника',
|
||||
background: 'Фон профиля',
|
||||
consumable: 'Расходуемое',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user