-
+
{user?.nickname}
-
+
diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx
new file mode 100644
index 0000000..d650fe8
--- /dev/null
+++ b/frontend/src/pages/NotFoundPage.tsx
@@ -0,0 +1,33 @@
+import { Link } from 'react-router-dom'
+import { Button } from '@/components/ui'
+import { Gamepad2, Home, Ghost } from 'lucide-react'
+
+export function NotFoundPage() {
+ return (
+
+ {/* Иконка с анимацией */}
+
+
+
+
+
+ {/* Заголовок */}
+
404
+
+ Страница не найдена
+
+
+ Похоже, эта страница ушла на марафон и не вернулась.
+ Попробуй начать с главной.
+
+
+ {/* Кнопка */}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx
new file mode 100644
index 0000000..3e40039
--- /dev/null
+++ b/frontend/src/pages/ProfilePage.tsx
@@ -0,0 +1,461 @@
+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 {
+ Button, Input, Card, CardHeader, CardTitle, CardContent
+} 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
+type PasswordForm = z.infer
+
+export function ProfilePage() {
+ const { user, updateUser } = useAuthStore()
+ const toast = useToast()
+
+ // Состояние
+ const [stats, setStats] = useState(null)
+ const [isLoadingStats, setIsLoadingStats] = useState(true)
+ const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
+ const [showPasswordForm, setShowPasswordForm] = useState(false)
+ const [showCurrentPassword, setShowCurrentPassword] = useState(false)
+ const [showNewPassword, setShowNewPassword] = useState(false)
+
+ // Telegram state
+ const [telegramLoading, setTelegramLoading] = useState(false)
+ const [isPolling, setIsPolling] = useState(false)
+ const pollingRef = useRef | null>(null)
+
+ const fileInputRef = useRef(null)
+
+ // Формы
+ const nicknameForm = useForm({
+ resolver: zodResolver(nicknameSchema),
+ defaultValues: { nickname: user?.nickname || '' },
+ })
+
+ const passwordForm = useForm({
+ resolver: zodResolver(passwordSchema),
+ defaultValues: { current_password: '', new_password: '', confirm_password: '' },
+ })
+
+ // Загрузка статистики
+ useEffect(() => {
+ loadStats()
+ return () => {
+ if (pollingRef.current) clearInterval(pollingRef.current)
+ }
+ }, [])
+
+ // Обновляем форму никнейма при изменении 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) => {
+ 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 })
+ 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
+ const displayAvatar = user?.telegram_avatar_url || user?.avatar_url
+
+ return (
+
+
Мой профиль
+
+ {/* Карточка профиля */}
+
+
+
+ {/* Аватар */}
+
+
+
+
+
+ {/* Форма никнейма */}
+
+
+
+
+
+
+
+ {/* Статистика */}
+
+
+
+
+ Статистика
+
+
+
+ {isLoadingStats ? (
+
+
+
+ ) : stats ? (
+
+
+
+
{stats.marathons_count}
+
Марафонов
+
+
+
+
{stats.wins_count}
+
Побед
+
+
+
+
{stats.completed_assignments}
+
Заданий
+
+
+
+
{stats.total_points_earned}
+
Очков
+
+
+ ) : (
+ Не удалось загрузить статистику
+ )}
+
+
+
+ {/* Telegram */}
+
+
+
+
+ Telegram
+
+
+
+ {isLinked ? (
+
+
+
+ {user?.telegram_avatar_url ? (
+

+ ) : (
+
+ )}
+
+
+
+ {user?.telegram_first_name} {user?.telegram_last_name}
+
+ {user?.telegram_username && (
+
@{user.telegram_username}
+ )}
+
+
+
+
+ ) : (
+
+
+ Привяжи Telegram для получения уведомлений о событиях и марафонах.
+
+ {isPolling ? (
+
+
+
+
Ожидание привязки...
+
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {/* Смена пароля */}
+
+
+
+
+ Безопасность
+
+
+
+ {!showPasswordForm ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/UserProfilePage.tsx b/frontend/src/pages/UserProfilePage.tsx
new file mode 100644
index 0000000..fee5219
--- /dev/null
+++ b/frontend/src/pages/UserProfilePage.tsx
@@ -0,0 +1,174 @@
+import { useState, useEffect } from 'react'
+import { useParams, useNavigate, Link } from 'react-router-dom'
+import { useAuthStore } from '@/store/auth'
+import { usersApi } from '@/api'
+import type { UserProfilePublic } from '@/types'
+import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
+import {
+ User, Trophy, Target, CheckCircle, Flame,
+ Loader2, ArrowLeft, Calendar
+} from 'lucide-react'
+
+export function UserProfilePage() {
+ const { id } = useParams<{ id: string }>()
+ const navigate = useNavigate()
+ const currentUser = useAuthStore((state) => state.user)
+
+ const [profile, setProfile] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!id) return
+
+ const userId = parseInt(id)
+
+ // Редирект на свой профиль
+ if (currentUser && userId === currentUser.id) {
+ navigate('/profile', { replace: true })
+ return
+ }
+
+ loadProfile(userId)
+ }, [id, currentUser, navigate])
+
+ const loadProfile = async (userId: number) => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ const data = await usersApi.getProfile(userId)
+ setProfile(data)
+ } catch (err: unknown) {
+ const error = err as { response?: { status?: number } }
+ if (error.response?.status === 404) {
+ setError('Пользователь не найден')
+ } else {
+ setError('Не удалось загрузить профиль')
+ }
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString('ru-RU', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ })
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (error || !profile) {
+ return (
+
+
+
+
+
+ {error || 'Пользователь не найден'}
+
+
+ Вернуться на главную
+
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Кнопка назад */}
+
+
+ {/* Профиль */}
+
+
+
+ {/* Аватар */}
+
+ {profile.avatar_url ? (
+

+ ) : (
+
+
+
+ )}
+
+
+ {/* Инфо */}
+
+
+ {profile.nickname}
+
+
+
+ Зарегистрирован {formatDate(profile.created_at)}
+
+
+
+
+
+
+ {/* Статистика */}
+
+
+
+
+ Статистика
+
+
+
+
+
+
+
+ {profile.stats.marathons_count}
+
+
Марафонов
+
+
+
+
+ {profile.stats.wins_count}
+
+
Побед
+
+
+
+
+ {profile.stats.completed_assignments}
+
+
Заданий
+
+
+
+
+ {profile.stats.total_points_earned}
+
+
Очков
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts
index e6e86cf..bcbc7a0 100644
--- a/frontend/src/pages/index.ts
+++ b/frontend/src/pages/index.ts
@@ -7,3 +7,6 @@ export { MarathonPage } from './MarathonPage'
export { LobbyPage } from './LobbyPage'
export { PlayPage } from './PlayPage'
export { LeaderboardPage } from './LeaderboardPage'
+export { ProfilePage } from './ProfilePage'
+export { UserProfilePage } from './UserProfilePage'
+export { NotFoundPage } from './NotFoundPage'
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index b51f4fa..3d72680 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -464,3 +464,24 @@ export interface ReturnedAssignment {
original_completed_at: string
dispute_reason: string
}
+
+// Profile types
+export interface UserStats {
+ marathons_count: number
+ wins_count: number
+ completed_assignments: number
+ total_points_earned: number
+}
+
+export interface UserProfilePublic {
+ id: number
+ nickname: string
+ avatar_url: string | null
+ created_at: string
+ stats: UserStats
+}
+
+export interface PasswordChangeData {
+ current_password: string
+ new_password: string
+}