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'
|
|
|
|
|
|
import { useAuthStore } from '@/store/auth'
|
|
|
|
|
|
import { usersApi, telegramApi, authApi } from '@/api'
|
|
|
|
|
|
import type { UserStats } from '@/types'
|
|
|
|
|
|
import { useToast } from '@/store/toast'
|
|
|
|
|
|
import {
|
2025-12-17 00:04:14 +07:00
|
|
|
|
Button, Input, Card, CardHeader, CardTitle, CardContent, clearAvatarCache
|
2025-12-16 22:12:12 +07:00
|
|
|
|
} from '@/components/ui'
|
|
|
|
|
|
import {
|
|
|
|
|
|
User, Camera, Trophy, Target, CheckCircle, Flame,
|
|
|
|
|
|
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
|
|
|
|
|
Eye, EyeOff, Save, KeyRound
|
|
|
|
|
|
} from 'lucide-react'
|
|
|
|
|
|
|
|
|
|
|
|
// Схемы валидации
|
|
|
|
|
|
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 } = useAuthStore()
|
|
|
|
|
|
const toast = useToast()
|
|
|
|
|
|
|
|
|
|
|
|
// Состояние
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
|
|
|
|
|
|
|
// Формы
|
|
|
|
|
|
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: '' },
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка статистики
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadStats()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (pollingRef.current) clearInterval(pollingRef.current)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2025-12-17 00:04:14 +07:00
|
|
|
|
// Загрузка аватарки через API
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (user?.id && user?.avatar_url) {
|
|
|
|
|
|
loadAvatar(user.id)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setIsLoadingAvatar(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (avatarBlobUrl) {
|
|
|
|
|
|
URL.revokeObjectURL(avatarBlobUrl)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [user?.id, user?.avatar_url])
|
|
|
|
|
|
|
|
|
|
|
|
const loadAvatar = async (userId: number) => {
|
|
|
|
|
|
setIsLoadingAvatar(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = await usersApi.getAvatarUrl(userId)
|
|
|
|
|
|
setAvatarBlobUrl(url)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setAvatarBlobUrl(null)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsLoadingAvatar(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 22:12:12 +07:00
|
|
|
|
// Обновляем форму никнейма при изменении user
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Обновление никнейма
|
|
|
|
|
|
const onNicknameSubmit = async (data: NicknameForm) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updatedUser = await usersApi.updateNickname(data)
|
|
|
|
|
|
updateUser({ nickname: updatedUser.nickname })
|
|
|
|
|
|
toast.success('Никнейм обновлен')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
toast.error('Не удалось обновить никнейм')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка аватара
|
|
|
|
|
|
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
|
|
|
|
// Перезагружаем аватарку через API
|
|
|
|
|
|
if (user?.id) {
|
|
|
|
|
|
// Очищаем старый blob URL и глобальный кэш
|
|
|
|
|
|
if (avatarBlobUrl) {
|
|
|
|
|
|
URL.revokeObjectURL(avatarBlobUrl)
|
|
|
|
|
|
}
|
|
|
|
|
|
clearAvatarCache(user.id)
|
|
|
|
|
|
await loadAvatar(user.id)
|
|
|
|
|
|
}
|
2025-12-16 22:12:12 +07:00
|
|
|
|
toast.success('Аватар обновлен')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
toast.error('Не удалось загрузить аватар')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsUploadingAvatar(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Смена пароля
|
|
|
|
|
|
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 функции
|
|
|
|
|
|
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
|
2025-12-17 00:04:14 +07:00
|
|
|
|
// Приоритет: загруженная аватарка (blob) > телеграм аватарка
|
|
|
|
|
|
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
2025-12-16 22:12:12 +07:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="max-w-2xl mx-auto space-y-6">
|
|
|
|
|
|
<h1 className="text-2xl font-bold text-white">Мой профиль</h1>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Карточка профиля */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardContent className="pt-6">
|
|
|
|
|
|
<div className="flex items-start gap-6">
|
|
|
|
|
|
{/* Аватар */}
|
|
|
|
|
|
<div className="relative group flex-shrink-0">
|
2025-12-17 00:04:14 +07:00
|
|
|
|
{isLoadingAvatar ? (
|
|
|
|
|
|
<div className="w-24 h-24 rounded-full bg-gray-700 animate-pulse" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleAvatarClick}
|
|
|
|
|
|
disabled={isUploadingAvatar}
|
|
|
|
|
|
className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-700 hover:opacity-80 transition-opacity"
|
|
|
|
|
|
>
|
|
|
|
|
|
{displayAvatar ? (
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={displayAvatar}
|
|
|
|
|
|
alt={user?.nickname}
|
|
|
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
|
/>
|
2025-12-16 22:12:12 +07:00
|
|
|
|
) : (
|
2025-12-17 00:04:14 +07:00
|
|
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
|
|
|
|
<User className="w-12 h-12 text-gray-500" />
|
|
|
|
|
|
</div>
|
2025-12-16 22:12:12 +07:00
|
|
|
|
)}
|
2025-12-17 00:04:14 +07:00
|
|
|
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
|
|
|
|
{isUploadingAvatar ? (
|
|
|
|
|
|
<Loader2 className="w-6 h-6 text-white animate-spin" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Camera className="w-6 h-6 text-white" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2025-12-16 22:12:12 +07:00
|
|
|
|
<input
|
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept="image/*"
|
|
|
|
|
|
onChange={handleAvatarChange}
|
|
|
|
|
|
className="hidden"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Форма никнейма */}
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
label="Никнейм"
|
|
|
|
|
|
{...nicknameForm.register('nickname')}
|
|
|
|
|
|
error={nicknameForm.formState.errors.nickname?.message}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
isLoading={nicknameForm.formState.isSubmitting}
|
|
|
|
|
|
disabled={!nicknameForm.formState.isDirty}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Save className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Сохранить
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Статистика */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
|
<Trophy className="w-5 h-5 text-yellow-500" />
|
|
|
|
|
|
Статистика
|
|
|
|
|
|
</CardTitle>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{isLoadingStats ? (
|
2025-12-17 00:04:14 +07:00
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
|
|
|
|
{[...Array(4)].map((_, i) => (
|
|
|
|
|
|
<div key={i} className="bg-gray-900 rounded-lg p-4 text-center">
|
|
|
|
|
|
<div className="w-6 h-6 bg-gray-700 rounded mx-auto mb-2 animate-pulse" />
|
|
|
|
|
|
<div className="h-8 w-12 bg-gray-700 rounded mx-auto mb-2 animate-pulse" />
|
|
|
|
|
|
<div className="h-4 w-16 bg-gray-700 rounded mx-auto animate-pulse" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2025-12-16 22:12:12 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
) : stats ? (
|
|
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
|
|
|
|
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
|
|
|
|
|
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" />
|
|
|
|
|
|
<div className="text-2xl font-bold text-white">{stats.marathons_count}</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400">Марафонов</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
|
|
|
|
|
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" />
|
|
|
|
|
|
<div className="text-2xl font-bold text-white">{stats.wins_count}</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400">Побед</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
|
|
|
|
|
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
|
|
|
|
|
<div className="text-2xl font-bold text-white">{stats.completed_assignments}</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400">Заданий</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
|
|
|
|
|
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" />
|
|
|
|
|
|
<div className="text-2xl font-bold text-white">{stats.total_points_earned}</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400">Очков</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="text-gray-400 text-center">Не удалось загрузить статистику</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Telegram */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
|
<MessageCircle className="w-5 h-5 text-blue-400" />
|
|
|
|
|
|
Telegram
|
|
|
|
|
|
</CardTitle>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{isLinked ? (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="flex items-center gap-4 p-4 bg-gray-900 rounded-lg">
|
|
|
|
|
|
<div className="w-12 h-12 rounded-full bg-blue-500/20 flex items-center justify-center overflow-hidden">
|
|
|
|
|
|
{user?.telegram_avatar_url ? (
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={user.telegram_avatar_url}
|
|
|
|
|
|
alt="Telegram avatar"
|
|
|
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Link2 className="w-6 h-6 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>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="danger"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleUnlinkTelegram}
|
|
|
|
|
|
isLoading={telegramLoading}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Link2Off className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Отвязать
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<p className="text-gray-400">
|
|
|
|
|
|
Привяжи Telegram для получения уведомлений о событиях и марафонах.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{isPolling ? (
|
|
|
|
|
|
<div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button onClick={handleLinkTelegram} isLoading={telegramLoading}>
|
|
|
|
|
|
<ExternalLink className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Привязать Telegram
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Смена пароля */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
|
<KeyRound className="w-5 h-5 text-gray-400" />
|
|
|
|
|
|
Безопасность
|
|
|
|
|
|
</CardTitle>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{!showPasswordForm ? (
|
|
|
|
|
|
<Button variant="secondary" onClick={() => setShowPasswordForm(true)}>
|
|
|
|
|
|
Сменить пароль
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<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-8 text-gray-400 hover:text-white"
|
|
|
|
|
|
>
|
|
|
|
|
|
{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-8 text-gray-400 hover:text-white"
|
|
|
|
|
|
>
|
|
|
|
|
|
{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-2">
|
|
|
|
|
|
<Button type="submit" isLoading={passwordForm.formState.isSubmitting}>
|
|
|
|
|
|
Сменить пароль
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setShowPasswordForm(false)
|
|
|
|
|
|
passwordForm.reset()
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Отмена
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|