2025-12-16 22:12:12 +07:00
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
|
|
|
|
import { useForm } from 'react-hook-form'
|
|
|
|
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
|
|
|
|
import { z } from 'zod'
|
2026-01-05 07:15:50 +07:00
|
|
|
|
import { Link } from 'react-router-dom'
|
2025-12-16 22:12:12 +07:00
|
|
|
|
import { useAuthStore } from '@/store/auth'
|
|
|
|
|
|
import { usersApi, telegramApi, authApi } from '@/api'
|
2026-01-05 07:15:50 +07:00
|
|
|
|
import type { UserStats, ShopItemPublic } from '@/types'
|
2025-12-16 22:12:12 +07:00
|
|
|
|
import { useToast } from '@/store/toast'
|
|
|
|
|
|
import {
|
2025-12-17 02:03:33 +07:00
|
|
|
|
NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
|
2025-12-16 22:12:12 +07:00
|
|
|
|
} from '@/components/ui'
|
|
|
|
|
|
import {
|
|
|
|
|
|
User, Camera, Trophy, Target, CheckCircle, Flame,
|
|
|
|
|
|
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
2026-01-04 02:47:38 +07:00
|
|
|
|
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
|
2026-01-05 07:15:50 +07:00
|
|
|
|
AlertTriangle, FileCheck, Backpack, Edit3
|
2025-12-16 22:12:12 +07:00
|
|
|
|
} from 'lucide-react'
|
2026-01-05 07:15:50 +07:00
|
|
|
|
import clsx from 'clsx'
|
2025-12-16 22:12:12 +07:00
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
// Schemas
|
2025-12-16 22:12:12 +07:00
|
|
|
|
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<typeof nicknameSchema>
|
|
|
|
|
|
type PasswordForm = z.infer<typeof passwordSchema>
|
|
|
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
|
// ============ 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 22:12:12 +07:00
|
|
|
|
export function ProfilePage() {
|
2025-12-17 19:50:55 +07:00
|
|
|
|
const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
|
2025-12-16 22:12:12 +07:00
|
|
|
|
const toast = useToast()
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
// State
|
2025-12-16 22:12:12 +07:00
|
|
|
|
const [stats, setStats] = useState<UserStats | null>(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)
|
2025-12-17 00:04:14 +07:00
|
|
|
|
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
|
|
|
|
|
|
const [isLoadingAvatar, setIsLoadingAvatar] = useState(true)
|
2025-12-16 22:12:12 +07:00
|
|
|
|
|
|
|
|
|
|
// Telegram state
|
|
|
|
|
|
const [telegramLoading, setTelegramLoading] = useState(false)
|
|
|
|
|
|
const [isPolling, setIsPolling] = useState(false)
|
|
|
|
|
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
|
|
|
|
|
2026-01-04 02:47:38 +07:00
|
|
|
|
// 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<string | null>(null)
|
|
|
|
|
|
|
2025-12-16 22:12:12 +07:00
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
// Forms
|
2025-12-16 22:12:12 +07:00
|
|
|
|
const nicknameForm = useForm<NicknameForm>({
|
|
|
|
|
|
resolver: zodResolver(nicknameSchema),
|
|
|
|
|
|
defaultValues: { nickname: user?.nickname || '' },
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const passwordForm = useForm<PasswordForm>({
|
|
|
|
|
|
resolver: zodResolver(passwordSchema),
|
|
|
|
|
|
defaultValues: { current_password: '', new_password: '', confirm_password: '' },
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
// Load stats
|
2025-12-16 22:12:12 +07:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadStats()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (pollingRef.current) clearInterval(pollingRef.current)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2025-12-17 19:50:55 +07:00
|
|
|
|
// Ref для отслеживания текущего blob URL
|
|
|
|
|
|
const avatarBlobRef = useRef<string | null>(null)
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
// Load avatar via API
|
2025-12-17 00:04:14 +07:00
|
|
|
|
useEffect(() => {
|
2025-12-17 19:50:55 +07:00
|
|
|
|
if (!user?.id || !user?.avatar_url) {
|
2025-12-17 00:04:14 +07:00
|
|
|
|
setIsLoadingAvatar(false)
|
2025-12-17 19:50:55 +07:00
|
|
|
|
return
|
2025-12-17 00:04:14 +07:00
|
|
|
|
}
|
2025-12-17 19:50:55 +07:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-17 00:04:14 +07:00
|
|
|
|
return () => {
|
2025-12-17 19:50:55 +07:00
|
|
|
|
cancelled = true
|
2025-12-17 00:04:14 +07:00
|
|
|
|
}
|
2025-12-17 19:50:55 +07:00
|
|
|
|
}, [user?.id, user?.avatar_url, avatarVersion])
|
2025-12-17 00:04:14 +07:00
|
|
|
|
|
2025-12-17 19:50:55 +07:00
|
|
|
|
// Cleanup blob URL on unmount
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (avatarBlobRef.current) {
|
|
|
|
|
|
URL.revokeObjectURL(avatarBlobRef.current)
|
|
|
|
|
|
}
|
2025-12-17 00:04:14 +07:00
|
|
|
|
}
|
2025-12-17 19:50:55 +07:00
|
|
|
|
}, [])
|
2025-12-17 00:04:14 +07:00
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
// Update nickname form when user changes
|
2025-12-16 22:12:12 +07:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
// Update nickname
|
2025-12-16 22:12:12 +07:00
|
|
|
|
const onNicknameSubmit = async (data: NicknameForm) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updatedUser = await usersApi.updateNickname(data)
|
|
|
|
|
|
updateUser({ nickname: updatedUser.nickname })
|
|
|
|
|
|
toast.success('Никнейм обновлен')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
toast.error('Не удалось обновить никнейм')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
// Upload avatar
|
2025-12-16 22:12:12 +07:00
|
|
|
|
const handleAvatarClick = () => {
|
|
|
|
|
|
fileInputRef.current?.click()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
|
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 })
|
2025-12-17 00:04:14 +07:00
|
|
|
|
if (user?.id) {
|
|
|
|
|
|
clearAvatarCache(user.id)
|
|
|
|
|
|
}
|
2025-12-17 19:50:55 +07:00
|
|
|
|
// Bump version - это вызовет перезагрузку через useEffect
|
|
|
|
|
|
bumpAvatarVersion()
|
2025-12-16 22:12:12 +07:00
|
|
|
|
toast.success('Аватар обновлен')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
toast.error('Не удалось загрузить аватар')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsUploadingAvatar(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
// Change password
|
2025-12-16 22:12:12 +07:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
// Telegram functions
|
2025-12-16 22:12:12 +07:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 02:47:38 +07:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 22:12:12 +07:00
|
|
|
|
const isLinked = !!user?.telegram_id
|
2025-12-17 00:04:14 +07:00
|
|
|
|
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
2025-12-16 22:12:12 +07:00
|
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
|
// 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
|
2025-12-17 02:03:33 +07:00
|
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
|
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-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" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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}
|
2025-12-17 02:03:33 +07:00
|
|
|
|
onClick={handleAvatarClick}
|
2026-01-05 07:15:50 +07:00
|
|
|
|
isUploading={isUploadingAvatar}
|
|
|
|
|
|
isLoading={isLoadingAvatar}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<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()
|
2025-12-16 22:12:12 +07:00
|
|
|
|
)}
|
2026-01-05 07:15:50 +07:00
|
|
|
|
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" />
|
|
|
|
|
|
Администратор
|
2025-12-17 02:03:33 +07:00
|
|
|
|
</div>
|
2026-01-05 07:15:50 +07:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
</div>
|
2026-01-05 07:15:50 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-16 22:12:12 +07:00
|
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
|
{/* ============ 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>
|
2025-12-16 22:12:12 +07:00
|
|
|
|
</div>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
</div>
|
2026-01-05 07:15:50 +07:00
|
|
|
|
<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>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
</GlassCard>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Stats */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
|
|
|
|
|
<Trophy className="w-5 h-5 text-yellow-500" />
|
|
|
|
|
|
Статистика
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
{isLoadingStats ? (
|
|
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
|
|
|
|
{[...Array(4)].map((_, i) => (
|
|
|
|
|
|
<div key={i} className="glass rounded-xl p-4">
|
|
|
|
|
|
<div className="w-12 h-12 bg-dark-700 rounded-lg mb-3 skeleton" />
|
|
|
|
|
|
<div className="h-8 w-16 bg-dark-700 rounded mb-2 skeleton" />
|
|
|
|
|
|
<div className="h-4 w-20 bg-dark-700 rounded skeleton" />
|
2025-12-16 22:12:12 +07:00
|
|
|
|
</div>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : stats ? (
|
|
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
|
|
|
|
<StatsCard
|
|
|
|
|
|
label="Марафонов"
|
|
|
|
|
|
value={stats.marathons_count}
|
|
|
|
|
|
icon={<Target className="w-6 h-6" />}
|
|
|
|
|
|
color="neon"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<StatsCard
|
|
|
|
|
|
label="Побед"
|
|
|
|
|
|
value={stats.wins_count}
|
|
|
|
|
|
icon={<Trophy className="w-6 h-6" />}
|
|
|
|
|
|
color="purple"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<StatsCard
|
|
|
|
|
|
label="Заданий"
|
|
|
|
|
|
value={stats.completed_assignments}
|
|
|
|
|
|
icon={<CheckCircle className="w-6 h-6" />}
|
|
|
|
|
|
color="neon"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<StatsCard
|
|
|
|
|
|
label="Очков"
|
|
|
|
|
|
value={stats.total_points_earned}
|
|
|
|
|
|
icon={<Flame className="w-6 h-6" />}
|
|
|
|
|
|
color="pink"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<GlassCard className="text-center py-8">
|
|
|
|
|
|
<p className="text-gray-400">Не удалось загрузить статистику</p>
|
|
|
|
|
|
</GlassCard>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-12-16 22:12:12 +07:00
|
|
|
|
|
|
|
|
|
|
{/* Telegram */}
|
2025-12-17 02:03:33 +07:00
|
|
|
|
<GlassCard>
|
|
|
|
|
|
<div className="flex items-center gap-3 mb-6">
|
|
|
|
|
|
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
|
|
|
|
|
<MessageCircle className="w-6 h-6 text-blue-400" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-lg font-semibold text-white">Telegram</h2>
|
|
|
|
|
|
<p className="text-sm text-gray-400">
|
|
|
|
|
|
{isLinked ? 'Аккаунт привязан' : 'Привяжите для уведомлений'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{isLinked ? (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="flex items-center gap-4 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
|
|
|
|
|
<div className="w-14 h-14 rounded-xl bg-blue-500/20 flex items-center justify-center overflow-hidden border border-blue-500/30">
|
|
|
|
|
|
{user?.telegram_avatar_url ? (
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={user.telegram_avatar_url}
|
|
|
|
|
|
alt="Telegram avatar"
|
|
|
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Link2 className="w-7 h-7 text-blue-400" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<p className="text-white font-medium">
|
|
|
|
|
|
{user?.telegram_first_name} {user?.telegram_last_name}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{user?.telegram_username && (
|
|
|
|
|
|
<p className="text-blue-400 text-sm">@{user.telegram_username}</p>
|
|
|
|
|
|
)}
|
2025-12-16 22:12:12 +07:00
|
|
|
|
</div>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
<NeonButton
|
|
|
|
|
|
variant="danger"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleUnlinkTelegram}
|
|
|
|
|
|
isLoading={telegramLoading}
|
|
|
|
|
|
icon={<Link2Off className="w-4 h-4" />}
|
|
|
|
|
|
>
|
|
|
|
|
|
Отвязать
|
|
|
|
|
|
</NeonButton>
|
2025-12-16 22:12:12 +07:00
|
|
|
|
</div>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<p className="text-gray-400">
|
|
|
|
|
|
Привяжите Telegram для получения уведомлений о событиях и марафонах.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{isPolling ? (
|
|
|
|
|
|
<div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
|
|
|
|
|
|
<p className="text-blue-400">Ожидание привязки...</p>
|
2025-12-16 22:12:12 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
) : (
|
|
|
|
|
|
<NeonButton
|
|
|
|
|
|
onClick={handleLinkTelegram}
|
|
|
|
|
|
isLoading={telegramLoading}
|
|
|
|
|
|
icon={<ExternalLink className="w-4 h-4" />}
|
|
|
|
|
|
>
|
|
|
|
|
|
Привязать Telegram
|
|
|
|
|
|
</NeonButton>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</GlassCard>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Security */}
|
|
|
|
|
|
<GlassCard>
|
|
|
|
|
|
<div className="flex items-center gap-3 mb-6">
|
|
|
|
|
|
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
|
|
|
|
|
<Shield className="w-6 h-6 text-accent-400" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-lg font-semibold text-white">Безопасность</h2>
|
|
|
|
|
|
<p className="text-sm text-gray-400">Управление паролем</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{!showPasswordForm ? (
|
|
|
|
|
|
<NeonButton
|
|
|
|
|
|
onClick={() => setShowPasswordForm(true)}
|
|
|
|
|
|
icon={<KeyRound className="w-4 h-4" />}
|
|
|
|
|
|
>
|
|
|
|
|
|
Сменить пароль
|
|
|
|
|
|
</NeonButton>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
label="Текущий пароль"
|
|
|
|
|
|
type={showCurrentPassword ? 'text' : 'password'}
|
|
|
|
|
|
{...passwordForm.register('current_password')}
|
|
|
|
|
|
error={passwordForm.formState.errors.current_password?.message}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
|
|
|
|
|
className="absolute right-3 top-9 text-gray-400 hover:text-white transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
{showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-12-16 22:12:12 +07:00
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
<div className="relative">
|
2025-12-16 22:12:12 +07:00
|
|
|
|
<Input
|
2025-12-17 02:03:33 +07:00
|
|
|
|
label="Новый пароль"
|
2025-12-16 22:12:12 +07:00
|
|
|
|
type={showNewPassword ? 'text' : 'password'}
|
2025-12-17 02:03:33 +07:00
|
|
|
|
{...passwordForm.register('new_password')}
|
|
|
|
|
|
error={passwordForm.formState.errors.new_password?.message}
|
2025-12-16 22:12:12 +07:00
|
|
|
|
/>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
|
|
|
|
|
className="absolute right-3 top-9 text-gray-400 hover:text-white transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-12-16 22:12:12 +07:00
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
<Input
|
|
|
|
|
|
label="Подтвердите новый пароль"
|
|
|
|
|
|
type={showNewPassword ? 'text' : 'password'}
|
|
|
|
|
|
{...passwordForm.register('confirm_password')}
|
|
|
|
|
|
error={passwordForm.formState.errors.confirm_password?.message}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
|
<NeonButton
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
isLoading={passwordForm.formState.isSubmitting}
|
|
|
|
|
|
icon={<Save className="w-4 h-4" />}
|
|
|
|
|
|
>
|
|
|
|
|
|
Сменить пароль
|
|
|
|
|
|
</NeonButton>
|
|
|
|
|
|
<NeonButton
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setShowPasswordForm(false)
|
|
|
|
|
|
passwordForm.reset()
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Отмена
|
|
|
|
|
|
</NeonButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</GlassCard>
|
2026-01-04 02:47:38 +07:00
|
|
|
|
|
|
|
|
|
|
{/* Notifications */}
|
|
|
|
|
|
{isLinked && (
|
|
|
|
|
|
<GlassCard>
|
|
|
|
|
|
<div className="flex items-center gap-3 mb-6">
|
|
|
|
|
|
<div className="w-12 h-12 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
|
|
|
|
|
<Bell className="w-6 h-6 text-neon-400" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-lg font-semibold text-white">Уведомления</h2>
|
|
|
|
|
|
<p className="text-sm text-gray-400">Настройте типы уведомлений в Telegram</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{/* Events toggle */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleNotificationToggle('notify_events', notifyEvents, setNotifyEvents)}
|
|
|
|
|
|
disabled={notificationUpdating !== null}
|
|
|
|
|
|
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
|
|
|
|
|
<Sparkles className="w-5 h-5 text-yellow-400" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-left">
|
|
|
|
|
|
<p className="text-white font-medium">События</p>
|
|
|
|
|
|
<p className="text-sm text-gray-400">Golden Hour, Jackpot, Double Risk и др.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className={`
|
|
|
|
|
|
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
|
|
|
|
|
|
${notifyEvents ? 'bg-neon-500' : 'bg-dark-600'}
|
|
|
|
|
|
${notificationUpdating === 'notify_events' ? 'opacity-50' : ''}
|
|
|
|
|
|
`}>
|
|
|
|
|
|
<div className={`
|
|
|
|
|
|
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
|
|
|
|
|
|
${notifyEvents ? 'left-6' : 'left-1'}
|
|
|
|
|
|
`} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Disputes toggle */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleNotificationToggle('notify_disputes', notifyDisputes, setNotifyDisputes)}
|
|
|
|
|
|
disabled={notificationUpdating !== null}
|
|
|
|
|
|
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
|
|
|
|
|
<AlertTriangle className="w-5 h-5 text-orange-400" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-left">
|
|
|
|
|
|
<p className="text-white font-medium">Споры</p>
|
|
|
|
|
|
<p className="text-sm text-gray-400">Оспаривания заданий и их решения</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className={`
|
|
|
|
|
|
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
|
|
|
|
|
|
${notifyDisputes ? 'bg-neon-500' : 'bg-dark-600'}
|
|
|
|
|
|
${notificationUpdating === 'notify_disputes' ? 'opacity-50' : ''}
|
|
|
|
|
|
`}>
|
|
|
|
|
|
<div className={`
|
|
|
|
|
|
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
|
|
|
|
|
|
${notifyDisputes ? 'left-6' : 'left-1'}
|
|
|
|
|
|
`} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Moderation toggle */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleNotificationToggle('notify_moderation', notifyModeration, setNotifyModeration)}
|
|
|
|
|
|
disabled={notificationUpdating !== null}
|
|
|
|
|
|
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
|
|
|
|
|
<FileCheck className="w-5 h-5 text-green-400" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-left">
|
|
|
|
|
|
<p className="text-white font-medium">Модерация</p>
|
|
|
|
|
|
<p className="text-sm text-gray-400">Одобрение/отклонение игр и челленджей</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className={`
|
|
|
|
|
|
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
|
|
|
|
|
|
${notifyModeration ? 'bg-neon-500' : 'bg-dark-600'}
|
|
|
|
|
|
${notificationUpdating === 'notify_moderation' ? 'opacity-50' : ''}
|
|
|
|
|
|
`}>
|
|
|
|
|
|
<div className={`
|
|
|
|
|
|
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
|
|
|
|
|
|
${notifyModeration ? 'left-6' : 'left-1'}
|
|
|
|
|
|
`} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Info about mandatory notifications */}
|
|
|
|
|
|
<p className="text-xs text-gray-500 mt-4">
|
|
|
|
|
|
Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</GlassCard>
|
|
|
|
|
|
)}
|
2025-12-16 22:12:12 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|