Files
game-marathon/frontend/src/pages/ProfilePage.tsx
2026-01-08 10:06:59 +07:00

1093 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useShopStore } from '@/store/shop'
import { usersApi, telegramApi, authApi, promoApi } 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, Gift
} 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<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()
// State
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)
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
const [isLoadingAvatar, setIsLoadingAvatar] = useState(true)
// Telegram state
const [telegramLoading, setTelegramLoading] = useState(false)
const [isPolling, setIsPolling] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | 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<string | null>(null)
// Promo code state
const [promoCode, setPromoCode] = useState('')
const [isRedeemingPromo, setIsRedeemingPromo] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Forms
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: '' },
})
// Load stats
useEffect(() => {
loadStats()
return () => {
if (pollingRef.current) clearInterval(pollingRef.current)
}
}, [])
// Ref для отслеживания текущего blob URL
const avatarBlobRef = useRef<string | null>(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<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 })
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)
}
}
// Redeem promo code
const handleRedeemPromo = async (e: React.FormEvent) => {
e.preventDefault()
if (!promoCode.trim()) return
setIsRedeemingPromo(true)
try {
const response = await promoApi.redeem(promoCode.trim())
toast.success(response.data.message)
setPromoCode('')
// Update coin balance in store
useShopStore.getState().updateBalance(response.data.new_balance)
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
const message = err.response?.data?.detail || 'Не удалось активировать промокод'
toast.error(message)
} finally {
setIsRedeemingPromo(false)
}
}
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-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}
onClick={handleAvatarClick}
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()
)}
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 */}
<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" />
</div>
))}
</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>
{/* Promo Code */}
<GlassCard>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Gift className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Промокод</h2>
<p className="text-sm text-gray-400">Введите код для получения монет</p>
</div>
</div>
<form onSubmit={handleRedeemPromo} className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Введите промокод"
value={promoCode}
onChange={(e) => setPromoCode(e.target.value.toUpperCase())}
maxLength={50}
/>
</div>
<NeonButton
type="submit"
isLoading={isRedeemingPromo}
disabled={!promoCode.trim()}
icon={<Gift className="w-4 h-4" />}
>
Активировать
</NeonButton>
</form>
</GlassCard>
{/* Telegram */}
<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>
)}
</div>
<NeonButton
variant="danger"
size="sm"
onClick={handleUnlinkTelegram}
isLoading={telegramLoading}
icon={<Link2Off className="w-4 h-4" />}
>
Отвязать
</NeonButton>
</div>
</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>
</div>
</div>
) : (
<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>
<div className="relative">
<Input
label="Новый пароль"
type={showNewPassword ? 'text' : 'password'}
{...passwordForm.register('new_password')}
error={passwordForm.formState.errors.new_password?.message}
/>
<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>
<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>
{/* 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>
)}
</div>
)
}