550 lines
19 KiB
TypeScript
550 lines
19 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
||
import { useForm } from 'react-hook-form'
|
||
import { zodResolver } from '@hookform/resolvers/zod'
|
||
import { z } from 'zod'
|
||
import { useAuthStore } from '@/store/auth'
|
||
import { usersApi, telegramApi, authApi } from '@/api'
|
||
import type { UserStats } 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
|
||
} from 'lucide-react'
|
||
|
||
// 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>
|
||
|
||
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)
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
const isLinked = !!user?.telegram_id
|
||
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
||
|
||
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>
|
||
|
||
{/* 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
|
||
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}
|
||
/>
|
||
<NeonButton
|
||
type="submit"
|
||
size="sm"
|
||
isLoading={nicknameForm.formState.isSubmitting}
|
||
disabled={!nicknameForm.formState.isDirty}
|
||
icon={<Save className="w-4 h-4" />}
|
||
>
|
||
Сохранить
|
||
</NeonButton>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
|
||
{/* 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>
|
||
</div>
|
||
)
|
||
}
|