-
+
{
/>
)}
+
{showDetails && (
-
+
)}
diff --git a/src/pages/home.tsx b/src/pages/home.tsx
new file mode 100644
index 0000000..7d3a055
--- /dev/null
+++ b/src/pages/home.tsx
@@ -0,0 +1,7 @@
+'use client';
+
+import HomePage from './home-page';
+
+export default function Home() {
+ return
;
+}
diff --git a/src/pages/index-page/index.module.scss b/src/pages/index-page/index.module.scss
new file mode 100644
index 0000000..795e54e
--- /dev/null
+++ b/src/pages/index-page/index.module.scss
@@ -0,0 +1,114 @@
+@use '../../styles/colors.scss' as *;
+
+@keyframes gradientAnimation {
+ 0% {
+ background-position: 0% 25%;
+ background-color: #cfff5f;
+ }
+ 25% {
+ background-position: 50% 50%;
+ background-color: #ff5f5f;
+ }
+ 50% {
+ background-position: 100% 50%;
+ background-color: #e7a10a;
+ }
+ 75% {
+ background-position: 50% 50%;
+ background-color: #ff7e5f;
+ }
+ 100% {
+ background-position: 0% 25%;
+ background-color: #a50c0c;
+ }
+}
+
+.container {
+ background-color: #00000013;
+ color: $text-light;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 20px;
+
+ background-image: linear-gradient(to right, #333 1px, transparent 1px),
+ linear-gradient(to bottom, #333 1px, transparent 1px);
+ background-size: 40px 40px;
+
+ .title {
+ font-size: 2rem;
+ margin-bottom: 10px;
+ background-image: linear-gradient(90deg, #ff3300, #fbff1e, #ff9900);
+ background-size: 200% 100%;
+ background-clip: text;
+ -webkit-background-clip: text;
+ color: transparent;
+ animation: gradientAnimation 3s infinite linear;
+ display: inline-block;
+ text-transform: uppercase;
+ }
+
+ .description {
+ font-size: 1.2rem;
+ max-width: 600px;
+ margin-bottom: 20px;
+ }
+
+ .imageGrid {
+ display: flex;
+ gap: 20px;
+ justify-content: center;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+
+ .imageContainer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .imageText {
+ margin-top: 8px;
+ font-size: 1rem;
+ color: $text-dark;
+ }
+ }
+
+ .link {
+ font-size: 1.2rem;
+
+ color: #eeff00;
+ text-decoration: none;
+ margin-top: 20px;
+ transition: color 0.3s ease-in-out;
+
+ &:hover {
+ color: #ff9900;
+ }
+ }
+
+ &_light {
+ background-image: linear-gradient(
+ to right,
+ $light-input 1px,
+ transparent 1px
+ ),
+ linear-gradient(to bottom, $light-input 1px, transparent 1px);
+ background-size: 40px 40px;
+
+ background-color: #00000013;
+ color: $text-black;
+
+ .link {
+ color: #ff9900;
+
+ &:hover {
+ color: #ff5e1e;
+ }
+ }
+ }
+}
diff --git a/src/pages/index-page/index.tsx b/src/pages/index-page/index.tsx
new file mode 100644
index 0000000..9c91a18
--- /dev/null
+++ b/src/pages/index-page/index.tsx
@@ -0,0 +1,76 @@
+//'use client';
+
+import Head from 'next/head';
+import Image from 'next/image';
+import Link from 'next/link';
+import styles from './index.module.scss';
+import { useContext } from 'react';
+import classNames from 'classnames';
+import { ThemeContext } from '../../context/themeContext';
+
+const IndexPage: React.FC = () => {
+ const { theme } = useContext(ThemeContext);
+ const isLight = theme === 'light';
+
+ return (
+ <>
+
+
Test App
+
+
+
+
+
+ RS School React Labs Project
+
+ This project is developed as part task in the RS School React course.
+ It utilizes Next.js for server-side rendering and enhanced
+ performance.
+
+
+
+
+ Move to home page
+
+
+
+ >
+ );
+};
+
+export default IndexPage;
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
new file mode 100644
index 0000000..0a2cd13
--- /dev/null
+++ b/src/pages/index.tsx
@@ -0,0 +1,5 @@
+import IndexPage from './index-page';
+
+export default function Home() {
+ return
;
+}
diff --git a/src/pages/not-found-page/index.module.scss b/src/pages/not-found-page/index.module.scss
index 2a91b29..6788875 100644
--- a/src/pages/not-found-page/index.module.scss
+++ b/src/pages/not-found-page/index.module.scss
@@ -1,28 +1,144 @@
@use '../../styles/colors.scss' as *;
.notFound {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ color: $text-light;
+ text-align: center;
+ padding: 20px;
+ font-family: 'Roboto', sans-serif;
+
+ .header {
+ font-size: 4rem;
+ color: $red-button;
+ margin-bottom: 20px;
+ animation: fadeIn 1s ease-out;
+
+ &:hover {
+ color: $red-button-hover;
+ }
+ }
+
+ .description,
+ .redirect {
+ font-size: 1.2rem;
+ color: $text-light;
+ margin: 10px 0;
+ max-width: 600px;
+ animation: fadeIn 1s ease-out;
+ }
+
.pageNotFoundContainer {
display: flex;
justify-content: center;
align-items: center;
+ margin: 30px 0;
.pageNotFoundImage {
- width: 20%;
- height: 20%;
+ width: 250px;
+ height: auto;
+ animation: zoomIn 1.5s ease-out;
}
}
.linkGoHome {
- color: $text-dark;
+ font-size: 1.5rem;
+ color: $text-light;
+ background-color: $red-button;
+ padding: 10px 30px;
+ border-radius: 30px;
text-decoration: none;
- transition: color 0.3s ease-in-out;
+ display: inline-block;
+ transition: all 0.3s ease;
+ margin-top: 20px;
&:hover {
- color: $red-button;
+ background-color: $red-button-hover;
+ transform: scale(1.05);
}
&:active {
- color: $red-button-hover;
+ background-color: $red-button-hover;
+ transform: scale(1);
+ }
+ }
+}
+
+@keyframes fadeIn {
+ 0% {
+ opacity: 0;
+ transform: translateY(-20px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes zoomIn {
+ 0% {
+ transform: scale(0.8);
+ opacity: 0;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+@media (max-width: 768px) {
+ .notFound {
+ padding: 15px;
+
+ .header {
+ font-size: 2.5rem;
+ margin-bottom: 15px;
+ }
+
+ .description,
+ .redirect {
+ font-size: 1rem;
+ max-width: 90%;
+ }
+
+ .pageNotFoundContainer {
+ .pageNotFoundImage {
+ width: 150px;
+ }
+ }
+
+ .linkGoHome {
+ font-size: 1.2rem;
+ padding: 8px 20px;
+ }
+ }
+}
+
+@media (max-width: 480px) {
+ .notFound {
+ .header {
+ font-size: 2rem;
+ }
+
+ .description,
+ .redirect {
+ font-size: 0.9rem;
+ }
+
+ .pageNotFoundContainer {
+ margin: 20px 0;
+
+ .pageNotFoundImage {
+ width: 100px;
+ }
+ }
+
+ .linkGoHome {
+ font-size: 1.1rem;
+ padding: 8px 15px;
}
}
}
diff --git a/src/pages/not-found-page/index.test.tsx b/src/pages/not-found-page/index.test.tsx
index 8ee3fac..339244e 100644
--- a/src/pages/not-found-page/index.test.tsx
+++ b/src/pages/not-found-page/index.test.tsx
@@ -1,46 +1,34 @@
import { render, screen } from '@testing-library/react';
-import { BrowserRouter as Router } from 'react-router-dom';
-import NotFound from '.';
+import NotFound from '../404';
+import '@testing-library/jest-dom';
-jest.mock('../../assets/icons/404.png', () => 'mocked-404.png');
+describe('NotFound Component', () => {
+ it('renders the 404 message', () => {
+ render(
);
-describe('NotFound component', () => {
- const renderNotFound = () => {
- render(
-
-
-
- );
- };
+ expect(screen.getByText('404 - Page Not Found')).toBeInTheDocument();
- test('renders 404 page with correct text', () => {
- renderNotFound();
-
- expect(screen.getByText(/404 - Page Not Found/i)).toBeInTheDocument();
expect(
- screen.getByText(/The page you are looking for does not exist./i)
+ screen.getByText('The page you are looking for does not exist.')
).toBeInTheDocument();
+
expect(
- screen.getByText(
- /You will be automatically redirected to the home page./i
- )
+ screen.getByText('You will be automatically redirected to the home page.')
).toBeInTheDocument();
});
- test('renders image with 404 class', () => {
- renderNotFound();
+ it('renders the 404 image', () => {
+ render(
);
- const image = screen.getByAltText('404') as HTMLImageElement;
+ const image = screen.getByAltText('404');
expect(image).toBeInTheDocument();
- expect(image).toHaveClass('pageNotFoundImage');
- expect(image.src).toContain('mocked-404.png');
});
- test('renders "Go to Home" link', () => {
- renderNotFound();
+ it('renders the "Go to Home" link', () => {
+ render(
);
- const link = screen.getByText(/Go to Home/i);
+ const link = screen.getByRole('link', { name: /go to home/i });
expect(link).toBeInTheDocument();
- expect(link).toHaveAttribute('href', '/');
+ expect(link).toHaveAttribute('href', '/home');
});
});
diff --git a/src/pages/not-found-page/index.tsx b/src/pages/not-found-page/index.tsx
index 6f08228..117199c 100644
--- a/src/pages/not-found-page/index.tsx
+++ b/src/pages/not-found-page/index.tsx
@@ -1,21 +1,30 @@
-import image404 from '../../assets/icons/404.png';
-import { Link } from 'react-router-dom';
+import Image from 'next/image';
+import Link from 'next/link';
import styles from './index.module.scss';
-const NotFound = () => {
+export default function NotFound() {
return (
-
404 - Page Not Found
-
The page you are looking for does not exist.
-
You will be automatically redirected to the home page.
+
404 - Page Not Found
+
+ The page you are looking for does not exist.
+
+
+ You will be automatically redirected to the home page.
+
-
+
-
+
Go to Home
);
-};
-
-export default NotFound;
+}
diff --git a/src/pages/providers.tsx b/src/pages/providers.tsx
new file mode 100644
index 0000000..99b3752
--- /dev/null
+++ b/src/pages/providers.tsx
@@ -0,0 +1,10 @@
+'use client';
+
+import { Provider } from 'react-redux';
+import { setupStore } from '../store/store';
+
+const store = setupStore();
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ return
{children} ;
+}
diff --git a/src/pages/test-page/index.test.tsx b/src/pages/test-page/index.test.tsx
deleted file mode 100644
index 751edfe..0000000
--- a/src/pages/test-page/index.test.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import TestPage from './index';
-import { useParams } from 'react-router-dom';
-
-jest.mock('react-router-dom', () => ({
- ...jest.requireActual('react-router-dom'),
- useParams: jest.fn(),
-}));
-
-describe('TestPage', () => {
- it('renders correctly with mocked id from URL', () => {
- (useParams as jest.Mock).mockReturnValue({ id: '123' });
-
- render(
-
-
-
- );
-
- expect(screen.getByText('TestPaget123')).toBeInTheDocument();
- });
-
- it('renders correctly with a different mocked id', () => {
- (useParams as jest.Mock).mockReturnValue({ id: '456' });
-
- render(
-
-
-
- );
-
- expect(screen.getByText('TestPaget456')).toBeInTheDocument();
- });
-});
diff --git a/src/pages/test-page/index.tsx b/src/pages/test-page/index.tsx
deleted file mode 100644
index 907613c..0000000
--- a/src/pages/test-page/index.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import React from 'react';
-import { useParams } from 'react-router-dom';
-
-const TestPage: React.FC = () => {
- const { id } = useParams<{ id: string }>();
- return
TestPaget{id}
;
-};
-
-export default TestPage;
diff --git a/src/services/PeopleService.test.tsx b/src/services/PeopleService.test.tsx
new file mode 100644
index 0000000..b601a0d
--- /dev/null
+++ b/src/services/PeopleService.test.tsx
@@ -0,0 +1,182 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { useFetchAllQuery, useFetchByIdQuery } from './PeopleService';
+import fetchMock from 'jest-fetch-mock';
+import { setupStore } from '../store/store';
+import { ReactNode } from 'react';
+import { API_BASE_URL } from '../consts/urls';
+
+const store = setupStore();
+describe('peopleAPI', () => {
+ function wrapper({ children }: { children: ReactNode }) {
+ return
{children} ;
+ }
+
+ beforeEach(() => {
+ fetchMock.enableMocks();
+ });
+
+ afterEach(() => {
+ fetchMock.resetMocks();
+ fetchMock.disableMocks();
+ });
+
+ it('fetches a list of characters', async () => {
+ const endpointName = 'fetchAll';
+ const people = '/people/';
+ const data = {
+ results: [{ id: '1', name: 'Luke Skywalker' }],
+ count: 1,
+ };
+ const searchItem = 'searchItem';
+ const currentPage = 2;
+ const url = `${API_BASE_URL}${people}?search=${searchItem}&page=${currentPage}`;
+ fetchMock.mockOnceIf(url, () =>
+ Promise.resolve({
+ status: 200,
+ body: JSON.stringify(data),
+ })
+ );
+ const { result } = renderHook(
+ () =>
+ useFetchAllQuery({
+ search: searchItem,
+ page: currentPage,
+ }),
+ {
+ wrapper,
+ }
+ );
+
+ expect(result.current).toMatchObject({
+ status: 'pending',
+ endpointName,
+ isLoading: true,
+ isSuccess: false,
+ isError: false,
+ isFetching: true,
+ });
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ const calledRequest = fetchMock.mock.calls[0][0];
+
+ const calledUrl =
+ calledRequest instanceof Request ? calledRequest.url : calledRequest;
+ expect(calledUrl).toBe(url);
+
+ expect(result.current).toMatchObject({
+ status: 'fulfilled',
+ endpointName,
+ data,
+ isLoading: false,
+ isSuccess: true,
+ isError: false,
+ currentData: data,
+ isFetching: false,
+ });
+ });
+
+ it('fetches a list of characters without searchItem and currentPage', async () => {
+ const endpointName = 'fetchAll';
+ const people = '/people/';
+ const data = {
+ results: [{ id: '1', name: 'Luke Skywalker' }],
+ count: 1,
+ };
+ const searchItem = undefined;
+ const currentPage = undefined;
+ const url = `${API_BASE_URL}${people}?search=&page=1`;
+ fetchMock.mockOnceIf(url, () =>
+ Promise.resolve({
+ status: 200,
+ body: JSON.stringify(data),
+ })
+ );
+ const { result } = renderHook(
+ () =>
+ useFetchAllQuery({
+ search: searchItem,
+ page: currentPage,
+ }),
+ {
+ wrapper,
+ }
+ );
+
+ expect(result.current).toMatchObject({
+ status: 'pending',
+ endpointName,
+ isLoading: true,
+ isSuccess: false,
+ isError: false,
+ isFetching: true,
+ });
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ const calledRequest = fetchMock.mock.calls[0][0];
+
+ const calledUrl =
+ calledRequest instanceof Request ? calledRequest.url : calledRequest;
+ expect(calledUrl).toBe(url);
+
+ expect(result.current).toMatchObject({
+ status: 'fulfilled',
+ endpointName,
+ data,
+ isLoading: false,
+ isSuccess: true,
+ isError: false,
+ currentData: data,
+ isFetching: false,
+ });
+ });
+
+ it('fetches a character by ID', async () => {
+ const endpointName = 'fetchById';
+ const characterId = '1';
+ const urlFetchById = `${API_BASE_URL}/people/${characterId}/`;
+ const characterData = {
+ id: '1',
+ name: 'Luke Skywalker',
+ height: '172',
+ mass: '77',
+ };
+ fetchMock.mockOnceIf(urlFetchById, JSON.stringify(characterData));
+
+ fetchMock.mockOnceIf(urlFetchById, () =>
+ Promise.resolve({
+ status: 200,
+ body: JSON.stringify(characterData),
+ })
+ );
+ const { result } = renderHook(() => useFetchByIdQuery(characterId), {
+ wrapper,
+ });
+
+ expect(result.current).toMatchObject({
+ status: 'pending',
+ endpointName,
+ isLoading: true,
+ isSuccess: false,
+ isError: false,
+ });
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ const calledRequest = fetchMock.mock.calls[0][0];
+
+ const calledUrl =
+ calledRequest instanceof Request ? calledRequest.url : calledRequest;
+ expect(calledUrl).toBe(urlFetchById);
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current).toMatchObject({
+ status: 'fulfilled',
+ endpointName,
+ data: characterData,
+ isLoading: false,
+ isSuccess: true,
+ isError: false,
+ currentData: characterData,
+ isFetching: false,
+ });
+ });
+});
diff --git a/src/index.scss b/src/styles/globals.scss
similarity index 94%
rename from src/index.scss
rename to src/styles/globals.scss
index 38223ef..efafaac 100644
--- a/src/index.scss
+++ b/src/styles/globals.scss
@@ -1,4 +1,4 @@
-@use './styles/colors.scss' as *;
+@use './colors.scss' as *;
body {
margin: 0;
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
deleted file mode 100644
index 11f02fe..0000000
--- a/src/vite-env.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-///
diff --git a/tsconfig.json b/tsconfig.json
index 1ffef60..4c44351 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,32 @@
{
- "files": [],
- "references": [
- { "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
- ]
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "allowSyntheticDefaultImports": true,
+ "noEmit": true,
+ "jsx": "preserve",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "incremental": true,
+ "plugins": [{ "name": "next" }]
+ },
+ "include": [
+ "./src",
+ "./dist/types/**/*.ts",
+ "./next-env.d.ts",
+ "./declarations.d.ts"
+ ],
+ "exclude": ["./node_modules"]
}
diff --git a/tsconfig.node.json b/tsconfig.node.json
deleted file mode 100644
index db0becc..0000000
--- a/tsconfig.node.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "compilerOptions": {
- "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
- "target": "ES2022",
- "lib": ["ES2023"],
- "module": "ESNext",
- "skipLibCheck": true,
-
- /* Bundler mode */
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "isolatedModules": true,
- "moduleDetection": "force",
- "noEmit": true,
-
- /* Linting */
- "strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true,
- "noUncheckedSideEffectImports": true
- },
- "include": ["vite.config.ts"]
-}
diff --git a/vite.config.ts b/vite.config.ts
deleted file mode 100644
index 6da11b5..0000000
--- a/vite.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react-swc';
-
-// https://vite.dev/config/
-export default defineConfig({
- plugins: [react()],
-});