+
}
+ label="Ваш профиль"
onClick={() => {
setIsProfileOpen(false);
- }}
+ }}
/>
-
-
}
- label="Настройки"
+
+
}
+ label="Настройки"
onClick={() => {
setIsProfileOpen(false);
- }}
+ }}
/>
-
-
}
- label="Статистика"
+
+
}
+ label="Статистика"
onClick={() => {
setIsProfileOpen(false);
- }}
+ }}
/>
-
-
-
}
- label="Выйти"
+
+
+
}
+ label="Выйти"
onClick={() => {
setIsProfileOpen(false);
- }}
+ }}
/>
@@ -82,11 +88,16 @@ interface ProfileOptionProps {
className?: string;
}
-const ProfileOption: React.FC
= ({ icon, label, onClick, className }) => {
+const ProfileOption: React.FC = ({
+ icon,
+ label,
+ onClick,
+ className,
+}) => {
return (
-
+
{competition.name}
-
-
- {competition.description || ''}
-
+
+ {competition.description || ""}
-
-
+
+
@@ -63,4 +63,4 @@ const CompetitionPage = () => {
);
};
-export default CompetitionPage;
\ No newline at end of file
+export default CompetitionPage;
diff --git a/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx b/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx
index 5049675..ad5edb0 100644
--- a/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx
+++ b/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx
@@ -11,13 +11,9 @@ export function CompetitionCard({
competition,
className,
}: CompetitionCardProps) {
-
return (
![]()
)}
- {competition.name}
+
+ {competition.name}
+
diff --git a/services/frontend/src/pages/Competitions/index.tsx b/services/frontend/src/pages/Competitions/index.tsx
index 0af28c2..cd09103 100644
--- a/services/frontend/src/pages/Competitions/index.tsx
+++ b/services/frontend/src/pages/Competitions/index.tsx
@@ -25,7 +25,7 @@ const CompetitionsPage = () => {
);
return (
-
+
Мои события
@@ -50,11 +50,15 @@ const CompetitionsPage = () => {
};
const Section = ({ children }: { children: React.ReactNode }) => {
- return {children}
;
+ return {children}
;
};
const SectionHeader = ({ children }: { children: React.ReactNode }) => {
- return {children}
;
+ return (
+
+ {children}
+
+ );
};
const SectionTitle = ({ children }: { children: React.ReactNode }) => {
diff --git a/services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx b/services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx
index b596230..11d6289 100644
--- a/services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx
+++ b/services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx
@@ -8,7 +8,7 @@ interface CompetitionGridProps {
export function CompetitionGrid({ competitions }: CompetitionGridProps) {
return (
-
+
{competitions.map((competition) => (
diff --git a/services/frontend/src/pages/Login/components/input.tsx b/services/frontend/src/pages/Login/components/input.tsx
new file mode 100644
index 0000000..f822806
--- /dev/null
+++ b/services/frontend/src/pages/Login/components/input.tsx
@@ -0,0 +1,22 @@
+interface InputProps extends React.InputHTMLAttributes
{
+ label?: string;
+ error?: string;
+}
+
+export const Input = ({ label, error, id, ...props }: InputProps) => {
+ return (
+
+ {label && (
+
+ )}
+
+ {error && {error}}
+
+ );
+};
diff --git a/services/frontend/src/pages/Login/index.tsx b/services/frontend/src/pages/Login/index.tsx
new file mode 100644
index 0000000..e4702a4
--- /dev/null
+++ b/services/frontend/src/pages/Login/index.tsx
@@ -0,0 +1,48 @@
+import { DataRush } from "@/components/ui/icons/datarush";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { LoginTab } from "./modules/LoginTab";
+import { SignupTab } from "./modules/SignupTab";
+import React from "react";
+import { getToken } from "@/shared/token";
+import { useNavigate } from "react-router";
+
+const LoginPage = () => {
+ const navigate = useNavigate();
+
+ React.useEffect(() => {
+ const token = getToken();
+ if (token) {
+ navigate("/");
+ }
+ }, []);
+
+ return (
+
+
+
+
+ Добро пожаловать!
+
+
+
+ Вход
+ Регистрация
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default LoginPage;
diff --git a/services/frontend/src/pages/Login/modules/LoginTab.tsx b/services/frontend/src/pages/Login/modules/LoginTab.tsx
new file mode 100644
index 0000000..b4bcf64
--- /dev/null
+++ b/services/frontend/src/pages/Login/modules/LoginTab.tsx
@@ -0,0 +1,74 @@
+import { Button } from "@/components/ui/button";
+import { Input } from "../components/input";
+import { login } from "@/shared/api/auth";
+import { saveToken } from "@/shared/token";
+import { useNavigate } from "react-router";
+import { useState } from "react";
+import { Spinner } from "@/components/ui/spinner";
+import { ApiError } from "@/shared/api";
+
+export const LoginTab = () => {
+ const navigate = useNavigate();
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const loginAction = async (
+ formData: FormData,
+ e: React.FormEvent,
+ ) => {
+ e.preventDefault();
+
+ setLoading(true);
+
+ const email = formData.get("email");
+ const password = formData.get("password");
+
+ if (!email || !password) {
+ setError("Неверное имя пользователя или пароль");
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const token = await login({
+ email: email.toString(),
+ password: password.toString(),
+ });
+ saveToken(token.token);
+ navigate("/");
+ } catch (e) {
+ if (e instanceof ApiError && (e.status === 400 || e.status === 401)) {
+ setError("Неверное имя пользователя или пароль");
+ } else {
+ setError("Произошла непредвиденная ошибка");
+ }
+ }
+
+ setLoading(false);
+ };
+
+ return (
+
+ );
+};
diff --git a/services/frontend/src/pages/Login/modules/SignupTab.tsx b/services/frontend/src/pages/Login/modules/SignupTab.tsx
new file mode 100644
index 0000000..386da05
--- /dev/null
+++ b/services/frontend/src/pages/Login/modules/SignupTab.tsx
@@ -0,0 +1,134 @@
+import { Button } from "@/components/ui/button";
+import { Input } from "../components/input";
+import { z } from "zod";
+import React from "react";
+import { signup } from "@/shared/api/auth";
+import { Spinner } from "@/components/ui/spinner";
+import { saveToken } from "@/shared/token";
+import { useNavigate } from "react-router";
+import { ApiError } from "@/shared/api";
+
+const signupSchema = z.object({
+ email: z.string().email({ message: "Некорректная почта" }).trim(),
+ username: z
+ .string()
+ .min(1, { message: "Имя пользователя должно быть не меньше 1 знака" })
+ .max(50, { message: "Имя пользователя должно быть не больше 50 знаков" })
+ .trim(),
+ password: z
+ .string()
+ .min(8, { message: "Пароль должен быть не меньше 8 знаков" })
+ .regex(/[a-zA-Z]/, {
+ message: "Пароль должен содержать хотя бы одну букву",
+ })
+ .regex(/[0-9]/, { message: "Пароль должен содержать хотя бы одну цифру" })
+ .regex(/[^a-zA-Z0-9]/, {
+ message: "Пароль должен содержать хотя бы один специальный символ",
+ })
+ .trim(),
+});
+
+interface SignupFormErrors {
+ username?: string[];
+ email?: string[];
+ password?: string[];
+
+ message?: string;
+}
+
+export const SignupTab = () => {
+ const navigate = useNavigate();
+ const [errors, setErrors] = React.useState(null);
+ const [loading, setLoading] = React.useState(false);
+
+ const signupAction = async (
+ formData: FormData,
+ e: React.FormEvent,
+ ) => {
+ e.preventDefault();
+
+ setLoading(true);
+
+ const validatedFields = signupSchema.safeParse({
+ email: formData.get("email"),
+ username: formData.get("username"),
+ password: formData.get("password"),
+ });
+
+ if (!validatedFields.success) {
+ setErrors(validatedFields.error.flatten().fieldErrors);
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const token = await signup({
+ ...validatedFields.data,
+ });
+ saveToken(token.token);
+ navigate("/");
+ } catch (e) {
+ if (e instanceof ApiError) {
+ if (e.status === 400) {
+ setErrors({ message: "Неверные данные" });
+ } else if (e.status === 409) {
+ setErrors({
+ message:
+ "Пользователь с такой почтой или именем пользователя уже существует",
+ });
+ } else {
+ setErrors({ message: "Произошла непредвиденная ошибка" });
+ }
+ } else {
+ setErrors({ message: "Произошла непредвиденная ошибка" });
+ }
+ }
+
+ setLoading(false);
+ };
+
+ return (
+
+ );
+};
diff --git a/services/frontend/src/shared/api/auth.ts b/services/frontend/src/shared/api/auth.ts
new file mode 100644
index 0000000..901a4e1
--- /dev/null
+++ b/services/frontend/src/shared/api/auth.ts
@@ -0,0 +1,23 @@
+import { authFetch } from ".";
+
+interface AuthResponse {
+ token: string;
+}
+
+export const signup = async (body: {
+ email: string;
+ username: string;
+ password: string;
+}) => {
+ return await authFetch("/sign-up", {
+ method: "POST",
+ body,
+ });
+};
+
+export const login = async (body: { email: string; password: string }) => {
+ return await authFetch("/sign-in", {
+ method: "POST",
+ body,
+ });
+};
diff --git a/services/frontend/src/shared/api/index.ts b/services/frontend/src/shared/api/index.ts
new file mode 100644
index 0000000..1edcff5
--- /dev/null
+++ b/services/frontend/src/shared/api/index.ts
@@ -0,0 +1,38 @@
+import { ofetch } from "ofetch";
+import { getToken, removeToken } from "../token";
+
+const BASE_URL = import.meta.env.VITE_API_ENDPOINT;
+
+export class ApiError extends Error {
+ response: Response;
+ status: number;
+
+ constructor(response: Response) {
+ super(response.statusText);
+ this.response = response;
+ this.status = response.status;
+ }
+}
+
+export const authFetch = ofetch.create({
+ baseURL: BASE_URL,
+ async onResponseError({ response }) {
+ throw new ApiError(response);
+ },
+});
+
+export const apiFetch = ofetch.create({
+ baseURL: BASE_URL,
+ headers: {
+ Authorization: "Bearer " + getToken(),
+ },
+ async onResponseError({ response }) {
+ if (response.status === 401) {
+ removeToken();
+ window.location.href = "/login";
+ return;
+ }
+
+ throw new ApiError(response);
+ },
+});
diff --git a/services/frontend/src/shared/api/user.ts b/services/frontend/src/shared/api/user.ts
new file mode 100644
index 0000000..b71c15f
--- /dev/null
+++ b/services/frontend/src/shared/api/user.ts
@@ -0,0 +1,6 @@
+import { apiFetch } from ".";
+import { User } from "../types/user";
+
+export const getCurrentUser = async () => {
+ return await apiFetch("/me");
+};
diff --git a/services/frontend/src/shared/stores/user.ts b/services/frontend/src/shared/stores/user.ts
new file mode 100644
index 0000000..6e7509d
--- /dev/null
+++ b/services/frontend/src/shared/stores/user.ts
@@ -0,0 +1,23 @@
+import { create } from "zustand";
+import { User } from "../types/user";
+import { getCurrentUser } from "../api/user";
+
+interface UserState {
+ user: User | null;
+ loading: boolean;
+
+ fetchUser: () => Promise;
+}
+
+const useUserStore = create((set) => ({
+ user: null,
+ loading: true,
+
+ fetchUser: async () => {
+ set({ loading: true });
+ const user = await getCurrentUser();
+ set({ user, loading: false });
+ },
+}));
+
+export { useUserStore };
diff --git a/services/frontend/src/shared/token.ts b/services/frontend/src/shared/token.ts
new file mode 100644
index 0000000..03ef7b2
--- /dev/null
+++ b/services/frontend/src/shared/token.ts
@@ -0,0 +1,17 @@
+import Cookie from "js-cookie";
+
+export const getToken = () => {
+ return Cookie.get("token");
+};
+
+export const saveToken = (token: string) => {
+ Cookie.set("token", token, {
+ secure: true,
+ sameSite: "Strict",
+ expires: 30,
+ });
+};
+
+export const removeToken = () => {
+ Cookie.remove("token");
+};
diff --git a/services/frontend/src/shared/types/user.ts b/services/frontend/src/shared/types/user.ts
new file mode 100644
index 0000000..20c51e2
--- /dev/null
+++ b/services/frontend/src/shared/types/user.ts
@@ -0,0 +1,5 @@
+export interface User {
+ id: string;
+ email: string;
+ username: string;
+}
diff --git a/services/frontend/src/widgets/auth-layout.tsx b/services/frontend/src/widgets/auth-layout.tsx
new file mode 100644
index 0000000..726b4c1
--- /dev/null
+++ b/services/frontend/src/widgets/auth-layout.tsx
@@ -0,0 +1,17 @@
+import { useUserStore } from "@/shared/stores/user";
+import React from "react";
+import { Outlet } from "react-router";
+
+export const AuthLayout = () => {
+ const fetchUser = useUserStore((state) => state.fetchUser);
+
+ React.useEffect(() => {
+ async function fetchData() {
+ await fetchUser();
+ }
+
+ fetchData();
+ }, []);
+
+ return ;
+};
diff --git a/services/frontend/src/widgets/navbar-layout.tsx b/services/frontend/src/widgets/navbar-layout.tsx
index 3b40070..a8cd738 100644
--- a/services/frontend/src/widgets/navbar-layout.tsx
+++ b/services/frontend/src/widgets/navbar-layout.tsx
@@ -5,8 +5,10 @@ const NavbarLayout = () => {
return (
<>
-