Загрузка заданий...
+{error}
+- Загрузка задания... -
+Задание не найдено
- {competition.name} + {competition.title}
{children}
; }; -export default CompetitionsPage; + +export default CompetitionsPage; \ No newline at end of file diff --git a/services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx b/services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx similarity index 80% rename from services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx rename to services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx index 11d6289..60ac1fb 100644 --- a/services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx +++ b/services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx @@ -1,5 +1,5 @@ -import { Competition } from "@/shared/types"; -import { CompetitionCard } from "../../components/CompetitionCard"; +import { Competition } from "@/shared/types/competition"; +import { CompetitionCard } from "../components/CompetitionCard"; import { Link } from "react-router"; interface CompetitionGridProps { diff --git a/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx b/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx new file mode 100644 index 0000000..8b71193 --- /dev/null +++ b/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx @@ -0,0 +1,15 @@ +import { Ban } from "lucide-react"; + +export const NoCompetitions = () => { + return ( +Событий нет
++ Увы, очередная победа.рф +
+
Добро пожаловать!
diff --git a/services/frontend/src/pages/Review/index.tsx b/services/frontend/src/pages/Review/index.tsx
index e69de29..233b3c0 100644
--- a/services/frontend/src/pages/Review/index.tsx
+++ b/services/frontend/src/pages/Review/index.tsx
@@ -0,0 +1,51 @@
+import { Loading } from "@/components/ui/loading";
+import { getReviewer, getReviewerSubmissions } from "@/shared/api/review";
+import { useQuery } from "@tanstack/react-query";
+import { useNavigate, useParams } from "react-router";
+import { ReviewHeader } from "./modules/review-header";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+const ReviewPage = () => {
+ const { token } = useParams<{ token: string }>();
+ const navigate = useNavigate();
+
+ const reviewerQuery = useQuery({
+ queryKey: ["reviewer", token],
+ queryFn: async () => getReviewer(token || ""),
+ retry: 0,
+ });
+ const submissionsQuery = useQuery({
+ queryKey: ["submissions", token],
+ queryFn: async () => getReviewerSubmissions(token || ""),
+ retry: 0,
+ });
+
+ if (reviewerQuery.isLoading || submissionsQuery.isLoading) {
+ return ;
+ }
+
+ if (!token || !reviewerQuery.data || !submissionsQuery.data) {
+ navigate("/");
+ return;
+ }
+
+ return (
+
+
+
+
+
+
+ Посылки
+
+ Доступные
+ Проверенные
+
+
+
+
+
+ );
+};
+
+export default ReviewPage;
diff --git a/services/frontend/src/pages/Review/modules/review-header.tsx b/services/frontend/src/pages/Review/modules/review-header.tsx
new file mode 100644
index 0000000..d27e9e8
--- /dev/null
+++ b/services/frontend/src/pages/Review/modules/review-header.tsx
@@ -0,0 +1,27 @@
+import { buttonVariants } from "@/components/ui/button";
+import { DataRushReview } from "@/components/ui/icons/datarush-review";
+import { Reviewer } from "@/shared/types/review";
+import { Link } from "react-router";
+
+interface ReviewHeaderProps {
+ reviewer: Reviewer;
+}
+
+export const ReviewHeader = ({ reviewer }: ReviewHeaderProps) => {
+ return (
+
+
+
+
+ {reviewer.name} {reviewer.surname}
+
+
+ Выйти
+
+
+
+ );
+};
diff --git a/services/frontend/src/pages/UserProfile/index.tsx b/services/frontend/src/pages/UserProfile/index.tsx
new file mode 100644
index 0000000..ec9d1d1
--- /dev/null
+++ b/services/frontend/src/pages/UserProfile/index.tsx
@@ -0,0 +1,398 @@
+import React from "react";
+import { User } from "lucide-react";
+import { useUserStore } from "@/shared/stores/user";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+const UserProfile = () => {
+ const user = useUserStore((state) => state.user);
+
+ return (
+
+
+
+ {user?.avatar ? (
+
+ ) : (
+
+ )}
+
+
+ {user?.username}
+
+ {user?.role || "Участник"} • На платформе с{" "}
+ {new Date(user?.createdAt || Date.now()).toLocaleDateString("ru-RU", {
+ year: "numeric",
+ month: "long",
+ })}
+
+
+
+
+
+
+
+ Информация
+
+
+ Статистика
+
+
+ Достижения
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const UserInfo = () => {
+ const user = useUserStore((state) => state.user);
+
+ return (
+
+
+ Личная информация
+
+
+
+
+
+ Полное имя
+
+
+ {user?.fullName || "Не указано"}
+
+
+
+
+
+ Email
+
+ {user?.email || "Не указано"}
+
+
+
+
+ Учебное заведение
+
+
+ {user?.university || "Не указано"}
+
+
+
+
+
+ Специализация
+
+
+ {user?.specialization || "Не указано"}
+
+
+
+
+
+
+ О себе
+
+
+ {user?.bio || "Пользователь пока не добавил информацию о себе."}
+
+
+
+
+ );
+};
+
+const UserStatistics = () => {
+ // Mock statistics data
+ const statistics = {
+ totalCompetitions: 12,
+ completedCompetitions: 8,
+ totalScore: 756,
+ averageScore: 94.5,
+ bestResult: {
+ competition: "Олимпиада DANO 2024",
+ place: 3,
+ score: 97,
+ },
+ totalTasks: 86,
+ solvedTasks: 72,
+ tasksByStatus: {
+ correct: 58,
+ partial: 14,
+ wrong: 9,
+ unattempted: 5,
+ },
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Лучший результат
+
+
+
+
+ {statistics.bestResult.competition}
+
+
+ Место
+
+ {statistics.bestResult.place}
+
+
+
+ Баллы
+
+ {statistics.bestResult.score}
+
+
+
+
+
+
+
+
+ Решение задач
+
+
+
+
+ Всего задач
+
+ {statistics.totalTasks}
+
+
+
+ Решено задач
+
+ {statistics.solvedTasks}
+
+
+
+
+
+
+ Статусы решений
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Верно ({statistics.tasksByStatus.correct})
+
+
+
+
+
+ Частично ({statistics.tasksByStatus.partial})
+
+
+
+
+
+ Неверно ({statistics.tasksByStatus.wrong})
+
+
+
+
+
+
+
+
+ );
+};
+
+
+const StatCard = ({ title, value }: { title: string; value: number | string }) => (
+
+
+ {title}
+ {value}
+
+
+);
+
+const UserAchievements = () => {
+ const achievements = [
+ {
+ id: 1,
+ name: "Первые шаги",
+ description: "Участие в первом соревновании",
+ imageUrl: "/achievements/first-steps.png",
+ unlocked: true,
+ },
+ {
+ id: 2,
+ name: "Восходящая звезда",
+ description: "Победа в соревновании",
+ imageUrl: "/achievements/rising-star.png",
+ unlocked: true,
+ },
+ {
+ id: 3,
+ name: "Мастер кода",
+ description: "Решите 50 задач на программирование",
+ imageUrl: "/achievements/code-master.png",
+ unlocked: true,
+ },
+ {
+ id: 4,
+ name: "Бронзовый призер",
+ description: "Займите 3 место в соревновании",
+ imageUrl: "/achievements/bronze.png",
+ unlocked: true,
+ },
+ {
+ id: 5,
+ name: "Серебряный призер",
+ description: "Займите 2 место в соревновании",
+ imageUrl: "/achievements/silver.png",
+ unlocked: false,
+ },
+ {
+ id: 6,
+ name: "Золотой призер",
+ description: "Займите 1 место в соревновании",
+ imageUrl: "/achievements/gold.png",
+ unlocked: false,
+ },
+ {
+ id: 7,
+ name: "Марафонец",
+ description: "Участвуйте в 10 соревнованиях",
+ imageUrl: "/achievements/marathon.png",
+ unlocked: false,
+ },
+ {
+ id: 8,
+ name: "Идеальное решение",
+ description: "Получите максимальные баллы за все задачи в соревновании",
+ imageUrl: "/achievements/perfect.png",
+ unlocked: false,
+ },
+ ];
+
+ return (
+
+
+ Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length}
+
+
+
+ {achievements.map((achievement) => (
+
+
+ {achievement.imageUrl ? (
+
+
+
+ ) : (
+
+
+ {achievement.name.substring(0, 1)}
+
+
+ )}
+
+
+ {achievement.name}
+
+
+ {achievement.description}
+
+
+ ))}
+
+
+ );
+};
+
+export default UserProfile;
\ No newline at end of file
diff --git a/services/frontend/src/pages/UserProfile/modules/UserAchievements/index.tsx b/services/frontend/src/pages/UserProfile/modules/UserAchievements/index.tsx
new file mode 100644
index 0000000..a713aa0
--- /dev/null
+++ b/services/frontend/src/pages/UserProfile/modules/UserAchievements/index.tsx
@@ -0,0 +1,45 @@
+const UserAchievements = () => {
+ return (
+
+
+ Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length}
+
+
+
+ {achievements.map((achievement) => (
+
+
+ {achievement.imageUrl ? (
+
+
+
+ ) : (
+
+
+ {achievement.name.substring(0, 1)}
+
+
+ )}
+
+
+ {achievement.name}
+
+
+ {achievement.description}
+
+
+ ))}
+
+
+ );
+};
+
+export default UserAchievements
\ No newline at end of file
diff --git a/services/frontend/src/pages/UserProfile/modules/UserStatistics/index.tsx b/services/frontend/src/pages/UserProfile/modules/UserStatistics/index.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/services/frontend/src/shared/api/auth.ts b/services/frontend/src/shared/api/auth.ts
index 901a4e1..58e5c77 100644
--- a/services/frontend/src/shared/api/auth.ts
+++ b/services/frontend/src/shared/api/auth.ts
@@ -1,4 +1,4 @@
-import { authFetch } from ".";
+import { apiFetch } from ".";
interface AuthResponse {
token: string;
@@ -9,14 +9,14 @@ export const signup = async (body: {
username: string;
password: string;
}) => {
- return await authFetch("/sign-up", {
+ return await apiFetch("/sign-up", {
method: "POST",
body,
});
};
export const login = async (body: { email: string; password: string }) => {
- return await authFetch("/sign-in", {
+ return await apiFetch("/sign-in", {
method: "POST",
body,
});
diff --git a/services/frontend/src/shared/api/competitions.ts b/services/frontend/src/shared/api/competitions.ts
new file mode 100644
index 0000000..8c47564
--- /dev/null
+++ b/services/frontend/src/shared/api/competitions.ts
@@ -0,0 +1,20 @@
+import { userFetch } from ".";
+import { Competition } from "../types/competition";
+
+export const getCompetitions = async (participating?: boolean) => {
+ return await userFetch("/competitions", {
+ params: {
+ is_participating: participating,
+ },
+ });
+};
+
+export const getCompetition = async (id: string) => {
+ return await userFetch(`/competition/${id}`);
+};
+
+export const startCompetition = async (competitionId: string) => {
+ return await userFetch(`/competitions/${competitionId}/start`, {
+ method: 'POST'
+ });
+};
\ No newline at end of file
diff --git a/services/frontend/src/shared/api/index.ts b/services/frontend/src/shared/api/index.ts
index 8772105..0616a21 100644
--- a/services/frontend/src/shared/api/index.ts
+++ b/services/frontend/src/shared/api/index.ts
@@ -14,17 +14,16 @@ export class ApiError extends Error {
}
}
-export const authFetch = ofetch.create({
+export const apiFetch = ofetch.create({
baseURL: BASE_URL,
async onResponseError({ response }) {
throw new ApiError(response);
},
});
-export const apiFetch = ofetch.create({
+export const userFetch = ofetch.create({
baseURL: BASE_URL,
async onRequest({ options }) {
- console.log(import.meta.env.VITE_API_ENDPOINT);
options.headers.set("Authorization", "Bearer " + getToken());
},
async onResponseError({ response }) {
diff --git a/services/frontend/src/shared/api/review.ts b/services/frontend/src/shared/api/review.ts
new file mode 100644
index 0000000..887999b
--- /dev/null
+++ b/services/frontend/src/shared/api/review.ts
@@ -0,0 +1,10 @@
+import { apiFetch } from ".";
+import { Reviewer } from "../types/review";
+
+export const getReviewer = async (token: string) => {
+ return await apiFetch(`/review/${token}`);
+};
+
+export const getReviewerSubmissions = async (token: string) => {
+ return await apiFetch(`/review/${token}/submissions`);
+};
diff --git a/services/frontend/src/shared/api/session.ts b/services/frontend/src/shared/api/session.ts
new file mode 100644
index 0000000..05b0c01
--- /dev/null
+++ b/services/frontend/src/shared/api/session.ts
@@ -0,0 +1,38 @@
+import { userFetch } from ".";
+import { Task, Solution, TaskAttachment } from "../types/task";
+
+export const getCompetitionTasks = async (competitionId: string) => {
+ return await userFetch(`/competitions/${competitionId}/tasks`);
+};
+
+export const getTaskSolutionHistory = async (competitionId: string, taskId: string) => {
+ return await userFetch(`/competitions/${competitionId}/tasks/${taskId}/history`);
+};
+
+export const getTaskAttachments = async (competitionId: string, taskId: string) => {
+ return await userFetch(`/competitions/${competitionId}/tasks/${taskId}/attachments`);
+};
+
+
+export const submitTaskSolution = async (
+ competitionId: string,
+ taskId: string,
+ solution: string | File
+) => {
+ const endpoint = `/competitions/${competitionId}/tasks/${taskId}/submit`;
+ console.log("SUBMIT ", taskId, competitionId, solution)
+ if (typeof solution === 'string') {
+ return await userFetch(endpoint, {
+ method: 'POST',
+ body: { answer: solution }
+ });
+ } else {
+ const formData = new FormData();
+ formData.append('file', solution);
+
+ return await userFetch(endpoint, {
+ method: 'POST',
+ body: formData
+ });
+ }
+};
\ No newline at end of file
diff --git a/services/frontend/src/shared/api/user.ts b/services/frontend/src/shared/api/user.ts
index b71c15f..84b000d 100644
--- a/services/frontend/src/shared/api/user.ts
+++ b/services/frontend/src/shared/api/user.ts
@@ -1,6 +1,6 @@
-import { apiFetch } from ".";
+import { userFetch } from ".";
import { User } from "../types/user";
export const getCurrentUser = async () => {
- return await apiFetch("/me");
+ return await userFetch("/me");
};
diff --git a/services/frontend/src/shared/mocks/mocks.ts b/services/frontend/src/shared/mocks/mocks.ts
index b7e2525..d1f24aa 100644
--- a/services/frontend/src/shared/mocks/mocks.ts
+++ b/services/frontend/src/shared/mocks/mocks.ts
@@ -57,49 +57,70 @@ const mockTasks: Task[] = [
id: "1",
number: "1.1",
status: TaskStatus.Uncleared,
- solutionType: "input"
+ solutionType: "input",
+ description: "123",
+ maxScore: 10,
},
{
id: "2",
number: "1.2",
status: TaskStatus.Checking,
- solutionType: "file"
+ solutionType: "file",
+ description: "123",
+ maxScore: 20,
},
{
id: "3",
number: "1.3",
status: TaskStatus.Correct,
- solutionType: "code"
+ solutionType: "code",
+ description: "123",
+ maxScore: 20,
},
{
id: "4",
number: "2.1",
status: TaskStatus.Partial,
- solutionType: "input"
+ solutionType: "input",
+ description: "123",
+ maxScore: 20,
+
},
{
id: "5",
number: "2.2",
status: TaskStatus.Wrong,
- solutionType: "file"
+ solutionType: "file",
+ description: "123",
+ maxScore: 20,
+
},
{
id: "6",
number: "2.3",
status: TaskStatus.Uncleared,
- solutionType: "code"
+ solutionType: "code",
+ description: "123",
+ maxScore: 20,
+
},
{
id: "7",
number: "3.1",
status: TaskStatus.Checking,
- solutionType: "file"
+ solutionType: "file",
+ description: "123",
+ maxScore: 20,
+
},
{
id: "8",
number: "3.2",
status: TaskStatus.Correct,
- solutionType: "input"
+ solutionType: "input",
+ description: "123",
+ maxScore: 20,
+
},
];
@@ -132,5 +153,84 @@ const mockSolutions: Solution[] = [
];
+const mockAchievements = [
+ {
+ id: 1,
+ name: "Первые шаги",
+ description: "Участие в первом соревновании",
+ imageUrl: "/achievements/first-steps.png",
+ unlocked: true,
+ },
+ {
+ id: 2,
+ name: "Восходящая звезда",
+ description: "Победа в соревновании",
+ imageUrl: "/achievements/rising-star.png",
+ unlocked: true,
+ },
+ {
+ id: 3,
+ name: "Мастер кода",
+ description: "Решите 50 задач на программирование",
+ imageUrl: "/achievements/code-master.png",
+ unlocked: true,
+ },
+ {
+ id: 4,
+ name: "Бронзовый призер",
+ description: "Займите 3 место в соревновании",
+ imageUrl: "/achievements/bronze.png",
+ unlocked: true,
+ },
+ {
+ id: 5,
+ name: "Серебряный призер",
+ description: "Займите 2 место в соревновании",
+ imageUrl: "/achievements/silver.png",
+ unlocked: false,
+ },
+ {
+ id: 6,
+ name: "Золотой призер",
+ description: "Займите 1 место в соревновании",
+ imageUrl: "/achievements/gold.png",
+ unlocked: false,
+ },
+ {
+ id: 7,
+ name: "Марафонец",
+ description: "Участвуйте в 10 соревнованиях",
+ imageUrl: "/achievements/marathon.png",
+ unlocked: false,
+ },
+ {
+ id: 8,
+ name: "Идеальное решение",
+ description: "Получите максимальные баллы за все задачи в соревновании",
+ imageUrl: "/achievements/perfect.png",
+ unlocked: false,
+ },
+];
-export { mockCompetitions, mockTasks, mockSolutions };
+
+const mockStatistics = {
+ totalCompetitions: 12,
+ completedCompetitions: 8,
+ totalScore: 756,
+ averageScore: 94.5,
+ bestResult: {
+ competition: "Олимпиада DANO 2024",
+ place: 3,
+ score: 97,
+ },
+ totalTasks: 86,
+ solvedTasks: 72,
+ tasksByStatus: {
+ correct: 58,
+ partial: 14,
+ wrong: 9,
+ unattempted: 5,
+ },
+};
+
+export { mockCompetitions, mockTasks, mockSolutions, mockAchievements, mockStatistics };
diff --git a/services/frontend/src/shared/types.ts b/services/frontend/src/shared/types.ts
index 6645a59..083614d 100644
--- a/services/frontend/src/shared/types.ts
+++ b/services/frontend/src/shared/types.ts
@@ -12,6 +12,11 @@ enum TaskStatus {
Wrong = "wrong"
}
+enum ParticipationType {
+ Solo = "solo",
+ Team = "team"
+}
+
interface Competition {
id: string;
name: string;
@@ -19,6 +24,9 @@ interface Competition {
isOlympics: boolean;
status: CompetitionStatus;
description?: string;
+ startDate: Date;
+ endDate: Date;
+ participationType: ParticipationType
}
type SolutionType = "input" | "file" | "code";
@@ -30,12 +38,17 @@ interface Solution {
score?: number,
maxScore?: number,
}
+
interface Task {
id: string;
number: string;
+ description: string;
+ maxScore: number;
status: TaskStatus;
solutionType: SolutionType;
+ requirements?: string;
+ attachments?: string[];
}
-export { CompetitionStatus, TaskStatus };
+export { CompetitionStatus, TaskStatus, ParticipationType };
export type { Solution, Competition, Task };
diff --git a/services/frontend/src/shared/types/competition.ts b/services/frontend/src/shared/types/competition.ts
new file mode 100644
index 0000000..beea20e
--- /dev/null
+++ b/services/frontend/src/shared/types/competition.ts
@@ -0,0 +1,26 @@
+export interface Competition {
+ id: string;
+ title: string;
+ description: string;
+ state: CompetitionState;
+ image_url?: string;
+ start_date?: Date;
+ end_date?: Date;
+ type: CompetitionType;
+ participation_type: CompetitionParticipationType;
+}
+
+export enum CompetitionState {
+ NOT_STARTED = "not_started",
+ STARTED = "started",
+ FINISHED = "finished",
+}
+
+export enum CompetitionType {
+ EDU = "edu",
+ COMPETITIVE = "competitive",
+}
+
+export enum CompetitionParticipationType {
+ SOLO = "solo",
+}
diff --git a/services/frontend/src/shared/types/review.ts b/services/frontend/src/shared/types/review.ts
new file mode 100644
index 0000000..f3a9094
--- /dev/null
+++ b/services/frontend/src/shared/types/review.ts
@@ -0,0 +1,5 @@
+export interface Reviewer {
+ id: string;
+ name: string;
+ surname: string;
+}
diff --git a/services/frontend/src/shared/types/task.ts b/services/frontend/src/shared/types/task.ts
new file mode 100644
index 0000000..07c6fe1
--- /dev/null
+++ b/services/frontend/src/shared/types/task.ts
@@ -0,0 +1,36 @@
+interface Task {
+ id: string;
+ title: string;
+ description: string;
+ type: TaskType;
+ in_competition_position: number;
+ points: number;
+}
+
+export interface TaskAttachment {
+ id: string;
+ file: string;
+ public: boolean;
+}
+
+enum TaskType {
+ INPUT = "input",
+ FILE = "checker",
+ CODE = "review",
+}
+
+enum SolutionStatus {
+ SENT = "sent",
+ CHECKING = "checking",
+ CHECKED = "checked",
+}
+
+interface Solution {
+ id: string,
+ status: SolutionStatus,
+ timestamp: string,
+ earned_points: number
+}
+
+export type {Task, Solution}
+export {TaskType, SolutionStatus}
\ No newline at end of file
diff --git a/services/frontend/src/styles/globals.css b/services/frontend/src/styles/globals.css
index 644cc81..860667b 100644
--- a/services/frontend/src/styles/globals.css
+++ b/services/frontend/src/styles/globals.css
@@ -88,7 +88,6 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
-
}
@theme inline {
@@ -120,6 +119,7 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
+ --radius-6: calc(var(--radius) + 6px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
Посылки
++ {reviewer.name} {reviewer.surname} +
+ + Выйти + +{user?.username}
++ {user?.role || "Участник"} • На платформе с{" "} + {new Date(user?.createdAt || Date.now()).toLocaleDateString("ru-RU", { + year: "numeric", + month: "long", + })} +
++ Полное имя +
++ {user?.fullName || "Не указано"} +
++ Email +
+{user?.email || "Не указано"}
++ Учебное заведение +
++ {user?.university || "Не указано"} +
++ Специализация +
++ {user?.specialization || "Не указано"} +
++ О себе +
++ {user?.bio || "Пользователь пока не добавил информацию о себе."} +
++ {statistics.bestResult.competition} +
++ Статусы решений +
+{title}
+{value}
++ Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length} +
+ ++ {achievement.name} +
++ {achievement.description} +
++ Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length} +
+ ++ {achievement.name} +
++ {achievement.description} +
+