Add shop
This commit is contained in:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user