diff --git a/next.config.js b/next.config.js
index 8eb7609..2fabef7 100644
--- a/next.config.js
+++ b/next.config.js
@@ -3,6 +3,9 @@ const nextConfig = {
eslint: {
dirs: ["src", "playwright-tests"],
},
+ images: {
+ domains: ["www.google.com"],
+ },
};
module.exports = nextConfig;
diff --git a/package-lock.json b/package-lock.json
index f225f00..79c72cc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"@mui/material": "6.1.6",
"@mui/x-date-pickers": "7.22.2",
"@react-oauth/google": "^0.12.0",
+ "@reduxjs/toolkit": "^2.5.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0",
"@types/node": "20.14.10",
@@ -41,6 +42,7 @@
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.45.4",
"react-i18next": "^15.0.0",
+ "react-redux": "^9.2.0",
"react-virtuoso": "4.12.0",
"typescript": "5.5.4",
"yup": "^1.2.0"
@@ -4844,6 +4846,30 @@
"react-dom": ">=16.8.0"
}
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.5.0.tgz",
+ "integrity": "sha512-awNe2oTodsZ6LmRqmkFhtb/KH03hUhxOamEQy411m3Njj3BbFvoBovxo4Q1cBWnV1ErprVj9MlF0UPXkng0eyg==",
+ "license": "MIT",
+ "dependencies": {
+ "immer": "^10.0.3",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@release-it/conventional-changelog": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-8.0.2.tgz",
@@ -6463,6 +6489,12 @@
"integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==",
"dev": true
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
@@ -8303,9 +8335,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001637",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001637.tgz",
- "integrity": "sha512-1x0qRI1mD1o9e+7mBI7XtzFAP4XszbHaVWsMiGbSPLYekKTJF7K+FNk6AsXH4sUpc+qrsI3pVgf1Jdl/uGkuSQ==",
+ "version": "1.0.30001695",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz",
+ "integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==",
"funding": [
{
"type": "opencollective",
@@ -8319,7 +8351,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
- ]
+ ],
+ "license": "CC-BY-4.0"
},
"node_modules/case-sensitive-paths-webpack-plugin": {
"version": "2.4.0",
@@ -12543,6 +12576,16 @@
"node": ">=0.8.0"
}
},
+ "node_modules/immer": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
+ "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -15759,11 +15802,12 @@
"node": ">=12"
}
},
- "node_modules/proxy-agent/node_modules/proxy-from-env": {
+ "node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/public-encrypt": {
"version": "4.0.3",
@@ -16114,6 +16158,29 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -16443,6 +16510,21 @@
"node": ">=8"
}
},
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.3.tgz",
@@ -17038,6 +17120,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/reselect": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+ "license": "MIT"
+ },
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@@ -18971,6 +19059,15 @@
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"dev": true
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
+ "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/utf7": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz",
diff --git a/package.json b/package.json
index 250b01d..8fe6dfc 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"@mui/material": "6.1.6",
"@mui/x-date-pickers": "7.22.2",
"@react-oauth/google": "^0.12.0",
+ "@reduxjs/toolkit": "^2.5.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0",
"@types/node": "20.14.10",
@@ -49,6 +50,7 @@
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.45.4",
"react-i18next": "^15.0.0",
+ "react-redux": "^9.2.0",
"react-virtuoso": "4.12.0",
"typescript": "5.5.4",
"yup": "^1.2.0"
diff --git a/playwright-tests/login/login.spec.ts b/playwright-tests/login/login.spec.ts
new file mode 100644
index 0000000..132e9c7
--- /dev/null
+++ b/playwright-tests/login/login.spec.ts
@@ -0,0 +1,7 @@
+import { test, expect } from "@playwright/test";
+
+test("should have login title", async ({ page }) => {
+ await page.goto("/en/login");
+ const title = await page.title();
+ expect(title).toBe("Login");
+});
diff --git a/public/images/arbisoft-logo.png b/public/images/arbisoft-logo.png
new file mode 100644
index 0000000..cc42d89
Binary files /dev/null and b/public/images/arbisoft-logo.png differ
diff --git a/src/app/[language]/layout.tsx b/src/app/[language]/layout.tsx
index daf2935..3b1fdfb 100644
--- a/src/app/[language]/layout.tsx
+++ b/src/app/[language]/layout.tsx
@@ -3,7 +3,6 @@ import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
-import CssBaseline from "@mui/material/CssBaseline";
import { dir } from "i18next";
import "@/services/i18n/config";
import { languages } from "@/services/i18n/config";
@@ -16,6 +15,8 @@ import QueryClientProvider from "@/services/react-query/query-client-provider";
import queryClient from "@/services/react-query/query-client";
import ReactQueryDevtools from "@/services/react-query/react-query-devtools";
import InitColorSchemeScript from "@/components/theme/init-color-scheme-script";
+import { GoogleOAuthProvider } from "@react-oauth/google";
+import { Providers } from "@/redux/store/provider";
type Props = {
params: { language: string };
@@ -40,19 +41,24 @@ export default function RootLayout({
children: React.ReactNode;
params: { language: string };
}) {
+ const clientId = process.env.NEXT_PUBLIC_CLIENT_ID;
+
return (
-
-
-
-
-
- {children}
-
-
-
+
+
+
+
+
+
+ {children}
+
+
+
+
+
);
diff --git a/src/app/[language]/login/page.tsx b/src/app/[language]/login/page.tsx
new file mode 100644
index 0000000..7acd1c0
--- /dev/null
+++ b/src/app/[language]/login/page.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from "next";
+import { getServerTranslation } from "@/services/i18n";
+import LoginPage from "@/features/LoginPage";
+
+type Props = {
+ params: { language: string };
+};
+
+export async function generateMetadata({ params }: Props): Promise {
+ const { t } = await getServerTranslation(params.language, "login");
+
+ return {
+ title: t("title"),
+ };
+}
+
+export default LoginPage;
diff --git a/src/app/[language]/page.tsx b/src/app/[language]/page.tsx
index 7b77728..8c9e47c 100644
--- a/src/app/[language]/page.tsx
+++ b/src/app/[language]/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { getServerTranslation } from "@/services/i18n";
-import Typography from "@mui/material/Typography";
+import HomePage from "@/features/HomePage";
+
type Props = {
params: { language: string };
};
@@ -16,5 +17,5 @@ export async function generateMetadata({ params }: Props): Promise {
export default async function Home({ params }: Props) {
const { t } = await getServerTranslation(params.language, "home");
- return {t("description")};
+ return ;
}
diff --git a/src/app/globals.css b/src/app/globals.css
index f017e8b..475034e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1 +1,10 @@
/* Your global CSS here */
+body {
+ background: linear-gradient(
+ 145deg,
+ rgba(16, 24, 56, 1) 0%,
+ rgba(23, 54, 85, 1) 100%
+ );
+ height: 100vh;
+ margin: 0;
+}
diff --git a/src/components/AlertModal/alertModal.tsx b/src/components/AlertModal/alertModal.tsx
new file mode 100644
index 0000000..9c6a64d
--- /dev/null
+++ b/src/components/AlertModal/alertModal.tsx
@@ -0,0 +1,27 @@
+import { FC } from "react";
+import Snackbar from "@mui/material/Snackbar";
+import { AlertModalProps } from "./types";
+import { AlertContainer } from "./styled";
+
+const AlertModal: FC = ({
+ handleCloseAlertModal,
+ errorMessage = "Something wrong happened",
+ vertical = "top",
+ horizontal = "right",
+ severity = "error",
+}) => {
+ return (
+
+
+ {errorMessage}
+
+
+ );
+};
+
+export default AlertModal;
diff --git a/src/components/AlertModal/index.ts b/src/components/AlertModal/index.ts
new file mode 100644
index 0000000..72ea9e3
--- /dev/null
+++ b/src/components/AlertModal/index.ts
@@ -0,0 +1,3 @@
+import AlertModal from "./alertModal";
+
+export default AlertModal;
diff --git a/src/components/AlertModal/styled.tsx b/src/components/AlertModal/styled.tsx
new file mode 100644
index 0000000..30a376a
--- /dev/null
+++ b/src/components/AlertModal/styled.tsx
@@ -0,0 +1,10 @@
+import Alert from "@mui/material/Alert";
+import { styled, css } from "@mui/material/styles";
+
+export const AlertContainer = styled(Alert, {
+ name: "AlertContainer",
+})(
+ () => css`
+ width: 100%;
+ `
+);
diff --git a/src/components/AlertModal/types.ts b/src/components/AlertModal/types.ts
new file mode 100644
index 0000000..daeb448
--- /dev/null
+++ b/src/components/AlertModal/types.ts
@@ -0,0 +1,7 @@
+export type AlertModalProps = {
+ handleCloseAlertModal: () => void;
+ errorMessage?: string;
+ vertical?: "bottom" | "top";
+ horizontal?: "center" | "left" | "right";
+ severity?: "success" | "info" | "warning" | "error";
+};
diff --git a/src/endpoints/index.ts b/src/endpoints/index.ts
new file mode 100644
index 0000000..a256edf
--- /dev/null
+++ b/src/endpoints/index.ts
@@ -0,0 +1,3 @@
+import * as login from "./login";
+
+export { login };
diff --git a/src/endpoints/login.ts b/src/endpoints/login.ts
new file mode 100644
index 0000000..07a6000
--- /dev/null
+++ b/src/endpoints/login.ts
@@ -0,0 +1 @@
+export const login = "/users/login";
diff --git a/src/features/HomePage/homePage.tsx b/src/features/HomePage/homePage.tsx
new file mode 100644
index 0000000..2080b28
--- /dev/null
+++ b/src/features/HomePage/homePage.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import Typography from "@mui/material/Typography";
+import { useRouter } from "next/navigation";
+import { FC, useEffect } from "react";
+import useLanguage from "@/services/i18n/use-language";
+
+const HomePage: FC<{ description: string }> = ({ description }) => {
+ const router = useRouter();
+ const language = useLanguage();
+
+ useEffect(() => {
+ const token = localStorage.getItem("access_token");
+ if (!token) {
+ router.push(`/${language}/login/`);
+ }
+ }, [language, router]);
+
+ return {description};
+};
+
+export default HomePage;
diff --git a/src/features/HomePage/index.ts b/src/features/HomePage/index.ts
new file mode 100644
index 0000000..ef76777
--- /dev/null
+++ b/src/features/HomePage/index.ts
@@ -0,0 +1,3 @@
+import HomePage from "./homePage";
+
+export default HomePage;
diff --git a/src/features/LoginPage/index.ts b/src/features/LoginPage/index.ts
new file mode 100644
index 0000000..55779d3
--- /dev/null
+++ b/src/features/LoginPage/index.ts
@@ -0,0 +1,3 @@
+import LoginPage from "./loginPage";
+
+export default LoginPage;
diff --git a/src/features/LoginPage/loginPage.tsx b/src/features/LoginPage/loginPage.tsx
new file mode 100644
index 0000000..bcebb49
--- /dev/null
+++ b/src/features/LoginPage/loginPage.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useGoogleLogin, CredentialResponse } from "@react-oauth/google";
+import Box from "@mui/material/Box";
+import Image from "next/image";
+import Button from "@mui/material/Button";
+import Typography from "@mui/material/Typography";
+import { useRouter } from "next/navigation";
+import useLanguage from "@/services/i18n/use-language";
+import { useEffect, useState } from "react";
+import { useProposalsMutation } from "@/redux/Login/loginSlice";
+import AlertModal from "@/components/AlertModal";
+import {
+ LoginButtonContainer,
+ LoginContainer,
+ LoginSubContainer,
+} from "./styled";
+import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
+
+export default function LoginPage() {
+ const [error, setError] = useState(null);
+
+ const router = useRouter();
+ const language = useLanguage();
+
+ const [login] = useProposalsMutation();
+
+ const onSuccess = async (credentialResponse: CredentialResponse) => {
+ if ("access_token" in credentialResponse) {
+ const response = await login({
+ auth_token: credentialResponse.access_token as string,
+ });
+ const errorState = response?.error as FetchBaseQueryError;
+
+ if (response.data) {
+ localStorage.setItem("access_token", response.data.access ?? "");
+ localStorage.setItem("refresh_token", response.data.refresh ?? "");
+ router.replace(`/${language}/`);
+ } else if (errorState) {
+ const errorMessage = errorState.data as string[];
+ setError(errorMessage[0]);
+ }
+ } else {
+ setError("Google login failed: No credential received.");
+ return;
+ }
+ };
+
+ const onError = () => {
+ setError("Authentication Error: Google login failed. Please try again.");
+ };
+
+ const googleLoginHandler = useGoogleLogin({
+ onSuccess: (response) => onSuccess(response as CredentialResponse),
+ onError: onError,
+ });
+
+ useEffect(() => {
+ const token = localStorage.getItem("access_token");
+ if (token) {
+ router.push(`/${language}/`);
+ }
+ }, [router]);
+
+ return (
+
+
+
+
+
+
+
+ {error && (
+ setError(null)}
+ errorMessage={error}
+ />
+ )}
+
+ );
+}
diff --git a/src/features/LoginPage/styled.tsx b/src/features/LoginPage/styled.tsx
new file mode 100644
index 0000000..5dc53d0
--- /dev/null
+++ b/src/features/LoginPage/styled.tsx
@@ -0,0 +1,53 @@
+import Box from "@mui/material/Box";
+import { styled, css } from "@mui/material/styles";
+
+export const LoginContainer = styled(Box, {
+ name: "LoginContainer",
+})(
+ () => css`
+ align-self: center;
+ height: 100%;
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ `
+);
+
+export const LoginSubContainer = styled(Box, {
+ name: "LoginSubContainer",
+})(
+ () => css`
+ width: 450px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 50px;
+ gap: 30px;
+ border: 1px solid black;
+ border-radius: 12px;
+ background-color: white;
+ `
+);
+
+export const LoginButtonContainer = styled(Box, {
+ name: "LoginButtonContainer",
+})(
+ () => css`
+ width: 100%;
+ border: 1px solid #908e8e;
+ border-radius: 6px;
+
+ .login-button {
+ padding: 0;
+ width: 100%;
+ }
+
+ .button-content {
+ display: flex;
+ gap: 8px;
+ padding: 10px 30px;
+ align-items: center;
+ justify-content: center;
+ width: 100%
+ `
+);
diff --git a/src/models/Auth/auth.tsx b/src/models/Auth/auth.tsx
new file mode 100644
index 0000000..aed1e7d
--- /dev/null
+++ b/src/models/Auth/auth.tsx
@@ -0,0 +1,12 @@
+export type LoginResponse = {
+ access: string;
+ refresh: string;
+ user_info: {
+ avatar: string;
+ first_name: string;
+ full_name: string;
+ last_name: string;
+ };
+};
+
+export type LoginParams = { auth_token: string };
diff --git a/src/models/Auth/index.ts b/src/models/Auth/index.ts
new file mode 100644
index 0000000..97ccf76
--- /dev/null
+++ b/src/models/Auth/index.ts
@@ -0,0 +1 @@
+export * from "./auth";
diff --git a/src/redux/Login/loginSlice.ts b/src/redux/Login/loginSlice.ts
new file mode 100644
index 0000000..3932db7
--- /dev/null
+++ b/src/redux/Login/loginSlice.ts
@@ -0,0 +1,18 @@
+import { login } from "@/endpoints";
+
+import { baseApi } from "../baseApi";
+import { LoginResponse, LoginParams } from "@/models/Auth";
+
+export const proposalsApi = baseApi.injectEndpoints({
+ endpoints: (builder) => ({
+ proposals: builder.mutation({
+ query: ({ auth_token }) => ({
+ url: login.login,
+ method: "POST",
+ body: { auth_token },
+ }),
+ }),
+ }),
+});
+
+export const { useProposalsMutation } = proposalsApi;
diff --git a/src/redux/baseApi.tsx b/src/redux/baseApi.tsx
new file mode 100644
index 0000000..4e0bfac
--- /dev/null
+++ b/src/redux/baseApi.tsx
@@ -0,0 +1,13 @@
+import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
+
+export const REDUCER_PATH = "api";
+export const SPOTIFY_REDUCER_PATH = "spotify_api";
+
+export const tagTypes = [] as const; // TODO: will add tags here
+
+export const baseApi = createApi({
+ reducerPath: REDUCER_PATH,
+ baseQuery: fetchBaseQuery({ baseUrl: process.env.NEXT_PUBLIC_BASE_URL }),
+ tagTypes,
+ endpoints: () => ({}),
+});
diff --git a/src/redux/store/configureStore.tsx b/src/redux/store/configureStore.tsx
new file mode 100644
index 0000000..f1509b9
--- /dev/null
+++ b/src/redux/store/configureStore.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { combineReducers, configureStore } from "@reduxjs/toolkit";
+
+import { baseApi } from "../baseApi";
+
+const rootReducer = combineReducers({
+ [baseApi.reducerPath]: baseApi.reducer,
+});
+
+const store = configureStore({
+ devTools: process.env.NODE_ENV !== "production",
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware().concat([baseApi.middleware]),
+ reducer: rootReducer,
+});
+
+export { store };
diff --git a/src/redux/store/provider.tsx b/src/redux/store/provider.tsx
new file mode 100644
index 0000000..e1cd370
--- /dev/null
+++ b/src/redux/store/provider.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import React from "react";
+import { Provider } from "react-redux";
+
+import { store } from "./configureStore";
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/src/services/i18n/locales/en/login.json b/src/services/i18n/locales/en/login.json
new file mode 100644
index 0000000..d3ab0a3
--- /dev/null
+++ b/src/services/i18n/locales/en/login.json
@@ -0,0 +1,3 @@
+{
+ "title": "Login"
+}