This commit is contained in:
2026-01-05 07:15:50 +07:00
parent 65b2512d8c
commit 6a7717a474
44 changed files with 5678 additions and 183 deletions

View File

@@ -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 />} />

View File

@@ -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
View File

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

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { 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)}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -571,6 +571,125 @@ input:-webkit-autofill:active {
@apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900;
}
/* ========================================
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) {
*,

View File

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

View File

@@ -1,11 +1,82 @@
import { useState, useEffect } from 'react'
import { 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">

View File

@@ -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 */}

View File

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

View File

@@ -2,12 +2,200 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { 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
View File

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

View File

@@ -9,6 +9,11 @@ export interface UserPublic {
role: UserRole
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: 'Расходуемое',
}