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, ShopItemPublic } from '@/types' import { useToast } from '@/store/toast' import { NeonButton, Input, GlassCard, StatsCard, clearAvatarCache } from '@/components/ui' import { User, Camera, Trophy, Target, CheckCircle, Flame, Loader2, MessageCircle, Link2, Link2Off, ExternalLink, Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles, AlertTriangle, FileCheck, Backpack, Edit3 } from 'lucide-react' import clsx from 'clsx' // Schemas const nicknameSchema = z.object({ nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'), }) const passwordSchema = z.object({ current_password: z.string().min(6, 'Минимум 6 символов'), new_password: z.string().min(6, 'Минимум 6 символов').max(100, 'Максимум 100 символов'), confirm_password: z.string(), }).refine((data) => data.new_password === data.confirm_password, { message: 'Пароли не совпадают', path: ['confirm_password'], }) type NicknameForm = z.infer type PasswordForm = z.infer // ============ COSMETICS HELPERS ============ // Background asset_data structure: // - type: 'solid' | 'gradient' | 'pattern' | 'animated' // - color: '#1a1a2e' (for solid) // - gradient: ['#1a1a2e', '#4a0080'] (for gradient) // - pattern: 'stars' | 'gaming-icons' (for pattern) // - animation: 'fire-particles' (for animated) // - animated: boolean (for animated patterns) interface BackgroundResult { styles: React.CSSProperties className: string } function getBackgroundData(background: ShopItemPublic | null): BackgroundResult { if (!background?.asset_data) { return { styles: {}, className: '' } } const data = background.asset_data as { type?: string gradient?: string[] pattern?: string color?: string animation?: string animated?: boolean } const styles: React.CSSProperties = {} let className = '' switch (data.type) { case 'solid': if (data.color) { styles.backgroundColor = data.color } break case 'gradient': if (data.gradient && data.gradient.length > 0) { styles.background = `linear-gradient(135deg, ${data.gradient.join(', ')})` } break case 'pattern': // Pattern backgrounds - use CSS classes for animated stars if (data.pattern === 'stars') { // Use CSS class for twinkling stars effect className = 'bg-stars-animated' } else if (data.pattern === 'gaming-icons') { styles.background = ` linear-gradient(45deg, rgba(34,211,238,0.1) 25%, transparent 25%), linear-gradient(-45deg, rgba(34,211,238,0.1) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(168,85,247,0.1) 75%), linear-gradient(-45deg, transparent 75%, rgba(168,85,247,0.1) 75%), linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%) ` styles.backgroundSize = '40px 40px, 40px 40px, 40px 40px, 40px 40px, 100% 100%' } break case 'animated': // Animated backgrounds if (data.animation === 'fire-particles') { styles.background = ` radial-gradient(circle at 50% 100%, rgba(255,100,0,0.4) 0%, transparent 50%), radial-gradient(circle at 30% 80%, rgba(255,50,0,0.3) 0%, transparent 40%), radial-gradient(circle at 70% 90%, rgba(255,150,0,0.3) 0%, transparent 45%), linear-gradient(to top, #1a0a00 0%, #0d0d0d 60%, #1a1a2e 100%) ` className = 'animate-fire-pulse' } break } return { styles, className } } // Name color asset_data structure: // - style: 'solid' | 'gradient' | 'animated' // - color: '#FF4444' (for solid) // - gradient: ['#FF6B6B', '#FFE66D'] (for gradient) // - animation: 'rainbow-shift' (for animated) interface NameColorResult { type: 'solid' | 'gradient' | 'animated' color?: string gradient?: string[] animation?: string } function getNameColorData(nameColor: ShopItemPublic | null): NameColorResult { if (!nameColor?.asset_data) { return { type: 'solid', color: '#ffffff' } } const data = nameColor.asset_data as { style?: string color?: string gradient?: string[] animation?: string } if (data.style === 'gradient' && data.gradient) { return { type: 'gradient', gradient: data.gradient } } if (data.style === 'animated') { return { type: 'animated', animation: data.animation } } return { type: 'solid', color: data.color || '#ffffff' } } // Get title from equipped_title function getTitleData(title: ShopItemPublic | null): { text: string; color: string } | null { if (!title?.asset_data) return null const data = title.asset_data as { text?: string; color?: string } if (!data.text) return null return { text: data.text, color: data.color || '#ffffff' } } // Get frame styles from asset_data function getFrameStyles(frame: ShopItemPublic | null): React.CSSProperties { if (!frame?.asset_data) return {} const data = frame.asset_data as { border_color?: string gradient?: string[] glow_color?: string } const styles: React.CSSProperties = {} if (data.gradient && data.gradient.length > 0) { styles.background = `linear-gradient(45deg, ${data.gradient.join(', ')})` styles.backgroundSize = '400% 400%' } else if (data.border_color) { styles.background = data.border_color } if (data.glow_color) { styles.boxShadow = `0 0 20px ${data.glow_color}, 0 0 40px ${data.glow_color}40` } return styles } // Get frame animation class function getFrameAnimation(frame: ShopItemPublic | null): string { if (!frame?.asset_data) return '' const data = frame.asset_data as { animation?: string } if (data.animation === 'fire-pulse') return 'animate-fire-pulse' if (data.animation === 'rainbow-rotate') return 'animate-rainbow-rotate' return '' } // ============ HERO AVATAR COMPONENT ============ function HeroAvatar({ avatarUrl, nickname, frame, onClick, isUploading, isLoading }: { avatarUrl: string | null | undefined nickname: string | undefined frame: ShopItemPublic | null onClick: () => void isUploading: boolean isLoading: boolean }) { if (isLoading) { return
} const avatarContent = (
{avatarUrl ? ( {nickname} ) : (
)}
) const hoverOverlay = (
{isUploading ? ( ) : ( )}
) if (!frame) { return ( ) } return ( ) } export function ProfilePage() { const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore() const toast = useToast() // State const [stats, setStats] = useState(null) const [isLoadingStats, setIsLoadingStats] = useState(true) const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) const [showPasswordForm, setShowPasswordForm] = useState(false) const [showCurrentPassword, setShowCurrentPassword] = useState(false) const [showNewPassword, setShowNewPassword] = useState(false) const [avatarBlobUrl, setAvatarBlobUrl] = useState(null) const [isLoadingAvatar, setIsLoadingAvatar] = useState(true) // Telegram state const [telegramLoading, setTelegramLoading] = useState(false) const [isPolling, setIsPolling] = useState(false) const pollingRef = useRef | null>(null) // Notification settings state const [notifyEvents, setNotifyEvents] = useState(user?.notify_events ?? true) const [notifyDisputes, setNotifyDisputes] = useState(user?.notify_disputes ?? true) const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true) const [notificationUpdating, setNotificationUpdating] = useState(null) const fileInputRef = useRef(null) // Forms const nicknameForm = useForm({ resolver: zodResolver(nicknameSchema), defaultValues: { nickname: user?.nickname || '' }, }) const passwordForm = useForm({ resolver: zodResolver(passwordSchema), defaultValues: { current_password: '', new_password: '', confirm_password: '' }, }) // Load stats useEffect(() => { loadStats() return () => { if (pollingRef.current) clearInterval(pollingRef.current) } }, []) // Ref для отслеживания текущего blob URL const avatarBlobRef = useRef(null) // Load avatar via API useEffect(() => { if (!user?.id || !user?.avatar_url) { setIsLoadingAvatar(false) return } let cancelled = false const bustCache = avatarVersion > 0 setIsLoadingAvatar(true) usersApi.getAvatarUrl(user.id, bustCache) .then(url => { if (cancelled) { URL.revokeObjectURL(url) return } // Очищаем старый blob URL if (avatarBlobRef.current) { URL.revokeObjectURL(avatarBlobRef.current) } avatarBlobRef.current = url setAvatarBlobUrl(url) }) .catch(() => { if (!cancelled) { setAvatarBlobUrl(null) } }) .finally(() => { if (!cancelled) { setIsLoadingAvatar(false) } }) return () => { cancelled = true } }, [user?.id, user?.avatar_url, avatarVersion]) // Cleanup blob URL on unmount useEffect(() => { return () => { if (avatarBlobRef.current) { URL.revokeObjectURL(avatarBlobRef.current) } } }, []) // Update nickname form when user changes useEffect(() => { if (user?.nickname) { nicknameForm.reset({ nickname: user.nickname }) } }, [user?.nickname]) const loadStats = async () => { try { const data = await usersApi.getMyStats() setStats(data) } catch (error) { console.error('Failed to load stats:', error) } finally { setIsLoadingStats(false) } } // Update nickname const onNicknameSubmit = async (data: NicknameForm) => { try { const updatedUser = await usersApi.updateNickname(data) updateUser({ nickname: updatedUser.nickname }) toast.success('Никнейм обновлен') } catch { toast.error('Не удалось обновить никнейм') } } // Upload avatar const handleAvatarClick = () => { fileInputRef.current?.click() } const handleAvatarChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return if (!file.type.startsWith('image/')) { toast.error('Файл должен быть изображением') return } if (file.size > 5 * 1024 * 1024) { toast.error('Максимальный размер файла 5 МБ') return } setIsUploadingAvatar(true) try { const updatedUser = await usersApi.uploadAvatar(file) updateUser({ avatar_url: updatedUser.avatar_url }) if (user?.id) { clearAvatarCache(user.id) } // Bump version - это вызовет перезагрузку через useEffect bumpAvatarVersion() toast.success('Аватар обновлен') } catch { toast.error('Не удалось загрузить аватар') } finally { setIsUploadingAvatar(false) } } // Change password const onPasswordSubmit = async (data: PasswordForm) => { try { await usersApi.changePassword({ current_password: data.current_password, new_password: data.new_password, }) toast.success('Пароль успешно изменен') passwordForm.reset() setShowPasswordForm(false) } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } const message = err.response?.data?.detail || 'Не удалось сменить пароль' toast.error(message) } } // Telegram functions const startPolling = () => { setIsPolling(true) let attempts = 0 pollingRef.current = setInterval(async () => { attempts++ try { const userData = await authApi.me() if (userData.telegram_id) { updateUser({ telegram_id: userData.telegram_id, telegram_username: userData.telegram_username, telegram_first_name: userData.telegram_first_name, telegram_last_name: userData.telegram_last_name, telegram_avatar_url: userData.telegram_avatar_url, }) toast.success('Telegram привязан!') setIsPolling(false) if (pollingRef.current) clearInterval(pollingRef.current) } } catch { /* ignore */ } if (attempts >= 60) { setIsPolling(false) if (pollingRef.current) clearInterval(pollingRef.current) } }, 5000) } const handleLinkTelegram = async () => { setTelegramLoading(true) try { const { bot_url } = await telegramApi.generateLinkToken() window.open(bot_url, '_blank') startPolling() } catch { toast.error('Не удалось сгенерировать ссылку') } finally { setTelegramLoading(false) } } const handleUnlinkTelegram = async () => { setTelegramLoading(true) try { await telegramApi.unlinkTelegram() updateUser({ telegram_id: null, telegram_username: null, telegram_first_name: null, telegram_last_name: null, telegram_avatar_url: null, }) toast.success('Telegram отвязан') } catch { toast.error('Не удалось отвязать Telegram') } finally { setTelegramLoading(false) } } // Update notification setting const handleNotificationToggle = async ( setting: 'notify_events' | 'notify_disputes' | 'notify_moderation', currentValue: boolean, setValue: (value: boolean) => void ) => { setNotificationUpdating(setting) const newValue = !currentValue setValue(newValue) try { await usersApi.updateNotificationSettings({ [setting]: newValue }) updateUser({ [setting]: newValue }) toast.success('Настройки сохранены') } catch { // Revert on error setValue(currentValue) toast.error('Не удалось сохранить настройки') } finally { setNotificationUpdating(null) } } 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 (
{/* ============ HERO SECTION ============ */}
{/* Default gradient background if no custom background */} {!equippedBackground && (
)} {/* Overlay for readability */}
{/* Scan lines effect */}
{/* Glow effects */}
{/* Content */}
{/* Avatar with Frame */}
{/* User Info */}
{/* Nickname with color + Title badge */}

{user?.nickname || 'Игрок'}

{/* Title badge */} {titleData && ( {titleData.text} )}
{/* Role badge */} {user?.role === 'admin' && (
Администратор
)} {/* Quick stats preview */} {stats && (
{stats.wins_count} побед
{stats.marathons_count} марафонов
{stats.total_points_earned} очков
)} {/* Inventory link */}
Инвентарь
{/* ============ NICKNAME EDIT SECTION ============ */}

Изменить никнейм

Ваше игровое имя

} > Сохранить
{/* Stats */}

Статистика

{isLoadingStats ? (
{[...Array(4)].map((_, i) => (
))}
) : stats ? (
} color="neon" /> } color="purple" /> } color="neon" /> } color="pink" />
) : (

Не удалось загрузить статистику

)}
{/* Telegram */}

Telegram

{isLinked ? 'Аккаунт привязан' : 'Привяжите для уведомлений'}

{isLinked ? (
{user?.telegram_avatar_url ? ( Telegram avatar ) : ( )}

{user?.telegram_first_name} {user?.telegram_last_name}

{user?.telegram_username && (

@{user.telegram_username}

)}
} > Отвязать
) : (

Привяжите Telegram для получения уведомлений о событиях и марафонах.

{isPolling ? (

Ожидание привязки...

) : ( } > Привязать Telegram )}
)}
{/* Security */}

Безопасность

Управление паролем

{!showPasswordForm ? ( setShowPasswordForm(true)} icon={} > Сменить пароль ) : (
} > Сменить пароль { setShowPasswordForm(false) passwordForm.reset() }} > Отмена
)}
{/* Notifications */} {isLinked && (

Уведомления

Настройте типы уведомлений в Telegram

{/* Events toggle */} {/* Disputes toggle */} {/* Moderation toggle */} {/* Info about mandatory notifications */}

Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.

)}
) }