diff --git a/package-lock.json b/package-lock.json
index e17047e..11af6d9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,8 +9,10 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
+ "@tanstack/react-query": "^5.84.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-router": "^7.7.1",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
@@ -2623,6 +2625,32 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.83.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.1.tgz",
+ "integrity": "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.84.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.84.1.tgz",
+ "integrity": "sha512-zo7EUygcWJMQfFNWDSG7CBhy8irje/XY0RDVKKV4IQJAysb+ZJkkJPcnQi+KboyGUgT+SQebRFoTqLuTtfoDLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.83.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -4381,6 +4409,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
+ "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/cosmiconfig": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
@@ -8254,6 +8291,28 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.7.1.tgz",
+ "integrity": "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -8569,6 +8628,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
+ "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
+ "license": "MIT"
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
diff --git a/package.json b/package.json
index 14ae8da..bbfa817 100644
--- a/package.json
+++ b/package.json
@@ -17,8 +17,10 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
+ "@tanstack/react-query": "^5.84.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-router": "^7.7.1",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 1c3bb12..b0b6cf9 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -1,26 +1,21 @@
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
+import { RouterProvider, createMemoryRouter } from 'react-router';
import { describe, it, expect } from 'vitest';
-import App from './App';
-import { ErrorBoundary } from '@/components/ErrorBoundary';
+import { routes } from '@/app/routes';
import { UI_STRINGS } from '@/shared/constants/ui-strings';
describe('App component', () => {
- it('renders components', () => {
- render();
- expect(screen.getByText(UI_STRINGS.title)).toBeInTheDocument();
- });
-
- it('throw the error with the button', () => {
- render(
- App down}>
-
- ,
- );
+ it('renders ErrorLayout with Header and NotFoundPage', async () => {
+ const testRouter = createMemoryRouter(routes, {
+ initialEntries: ['/error'],
+ });
- const errorButton = screen.getByText(UI_STRINGS.errorButton);
+ render();
- fireEvent.click(errorButton);
+ expect(await screen.findByAltText(UI_STRINGS.altLogo)).toBeInTheDocument();
- expect(screen.getByText(/App down/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole('heading', { name: UI_STRINGS.title }),
+ ).toBeInTheDocument();
});
});
diff --git a/src/App.tsx b/src/App.tsx
index f785afa..29cb17a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,34 +1,23 @@
-import { Component, type ReactNode } from 'react';
-import Footer from '@/components/Footer';
-import Header from '@/components/Header';
-import { HomePage } from '@/pages/HomePage';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { createBrowserRouter, RouterProvider } from 'react-router';
+import { routes } from '@/app/routes';
-type AppState = {
- wouldThrow: boolean;
-};
+const router = createBrowserRouter(routes);
-class App extends Component {
- state: AppState = {
- wouldThrow: false,
- };
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 2 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
- render(): ReactNode {
- const { wouldThrow } = this.state;
-
- if (wouldThrow) {
- throw new Error('test error from button');
- }
-
- return (
-
-
-
-
-
-
- );
- }
+export default function App() {
+ return (
+
+
+ );
+
+ );
}
-
-export default App;
diff --git a/src/app/paths.ts b/src/app/paths.ts
new file mode 100644
index 0000000..c66606d
--- /dev/null
+++ b/src/app/paths.ts
@@ -0,0 +1,6 @@
+export const PATHS = {
+ HOME: '/',
+ ABOUT: '/about',
+ CHARACTER: '/character',
+ NOT_FOUND: '*',
+} as const;
diff --git a/src/app/routes.tsx b/src/app/routes.tsx
new file mode 100644
index 0000000..1fa6d6d
--- /dev/null
+++ b/src/app/routes.tsx
@@ -0,0 +1,42 @@
+import { Navigate } from 'react-router';
+import { PATHS } from '@/app/paths';
+import { FallBack } from '@/components/FallBack';
+import { ErrorLayout } from '@/layouts/ErrorLayout';
+import { MainLayout } from '@/layouts/MainLayout';
+import { AboutPage } from '@/pages/AboutPage';
+import { HomePage } from '@/pages/HomePage';
+import { NotFoundPage } from '@/pages/NotFoundPage';
+
+export const routes = [
+ {
+ path: '/',
+ element: ,
+ },
+ {
+ path: '/character',
+ element: ,
+ errorElement: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: ':characterId',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: PATHS.ABOUT,
+ element: ,
+ errorElement: ,
+ children: [{ index: true, element: }],
+ },
+ {
+ path: PATHS.NOT_FOUND,
+ element: ,
+ errorElement: ,
+ children: [{ path: PATHS.NOT_FOUND, element: }],
+ },
+];
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 985724f..98d04e6 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -1,37 +1,35 @@
-import { Component, type ButtonHTMLAttributes } from 'react';
+import { type ButtonHTMLAttributes, type ReactNode } from 'react';
-interface ButtonProps extends ButtonHTMLAttributes {
+type ButtonProps = {
variant?: 'default' | 'secondary';
size?: 'sm' | 'default';
className?: string;
-}
+ children?: ReactNode;
+} & ButtonHTMLAttributes;
-export class Button extends Component {
- render() {
- const {
- variant = 'default',
- size = 'default',
- className = '',
- ...rest
- } = this.props;
+export const Button = ({
+ variant = 'default',
+ size = 'default',
+ className = '',
+ children,
+ ...rest
+}: ButtonProps) => {
+ let baseClasses =
+ 'inline-flex items-center justify-center text-sm transition-colors focus:outline-none focus:ring focus:ring-gray-300 disabled:opacity-50 disabled:pointer-events-none';
- let baseClasses =
- 'inline-flex items-center justify-center text-sm transition-colors focus:outline-none focus:ring focus:ring-gray-300 disabled:opacity-50 disabled:pointer-events-none';
+ let variantClasses =
+ variant === 'secondary'
+ ? 'bg-gray-300 text-gray-800 hover:bg-gray-400'
+ : 'bg-gray-200 hover:bg-gray-400';
- let variantClasses =
- variant === 'secondary'
- ? 'bg-gray-300 text-gray-800 hover:bg-gray-400'
- : 'bg-gray-200 hover:bg-gray-400';
+ let sizeClasses = size === 'sm' ? 'h-8 w-8 px-4' : 'h-9 px-4 min-sm:px-6';
- let sizeClasses = size === 'sm' ? 'h-8 w-8 px-4' : 'h-9 px-4 min-sm:px-6';
-
- return (
-
- );
- }
-}
+ return (
+
+ );
+};
diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx
index b594590..2171861 100644
--- a/src/components/Card/Card.tsx
+++ b/src/components/Card/Card.tsx
@@ -1,14 +1,17 @@
-import { Component } from 'react';
import { CardText } from './CardText';
import { CARD_TEXT } from '@/shared/constants/cards';
import type { Character } from '@/types/character';
type CardProps = {
character: Character;
+ onClick?: (id: number) => void;
+ variant?: 'list' | 'details';
};
-export class Card extends Component {
- getValue(key: string, value: string): string {
+export const Card = ({ character, onClick, variant = 'list' }: CardProps) => {
+ const { id, name, status, species, gender, image, origin, location } =
+ character;
+ const getValue = (key: string, value: string): string => {
if (value === 'unknown') {
switch (key) {
case 'status':
@@ -20,44 +23,66 @@ export class Card extends Component {
}
}
return value;
- }
+ };
- render() {
- const { name, status, species, gender, image, origin, location } =
- this.props.character;
+ const baseClasses =
+ 'flex gap-4 p-4 bg-gray-200 dark:bg-gray-400 transition-all animate-fadeIn';
- return (
-
-

-
-
{name}
-
- {CARD_TEXT.label.species}: {species}
-
+ const listLayout =
+ 'flex-col cursor-pointer md:flex-row items-center max-w-xl';
+ const detailsLayout = 'flex-col items-center text-center w-full';
+
+ const isClickable = !!onClick;
+
+ return (
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ onClick?.(id);
+ }
+ }
+ : undefined
+ }
+ onClick={isClickable ? () => onClick?.(id) : undefined}
+ role={isClickable ? 'button' : undefined}
+ tabIndex={isClickable ? 0 : undefined}
+ className={`${baseClasses} ${variant === 'list' ? listLayout : detailsLayout}`}
+ >
+

+
+
{name}
+
+ {CARD_TEXT.label.species}: {species}
+
+
+ {CARD_TEXT.label.status}: {getValue('status', status)}
+
+
+ {CARD_TEXT.label.gender}: {getValue('gender', gender)}
+
+ {origin.name !== 'unknown' ? (
- {CARD_TEXT.label.status}: {this.getValue('status', status)}
+ {CARD_TEXT.label.origin}: {origin.name}
+ ) : (
- {CARD_TEXT.label.gender}: {this.getValue('gender', gender)}
+ {CARD_TEXT.fallback.originFallback}:{' '}
+ {location?.name && location.name !== 'unknown'
+ ? location.name
+ : CARD_TEXT.fallback.locationUnknown}
- {origin.name !== 'unknown' ? (
-
- {CARD_TEXT.label.origin}: {origin.name}
-
- ) : (
-
- {CARD_TEXT.fallback.originFallback}:{' '}
- {location?.name && location.name !== 'unknown'
- ? location.name
- : CARD_TEXT.fallback.locationUnknown}
-
- )}
-
+ )}
- );
- }
-}
+
+ );
+};
diff --git a/src/components/CardList/CardList.tsx b/src/components/CardList/CardList.tsx
index c1dde9a..0545c5f 100644
--- a/src/components/CardList/CardList.tsx
+++ b/src/components/CardList/CardList.tsx
@@ -1,25 +1,24 @@
-import { Component } from 'react';
import { Card } from '@/components/Card';
import type { Character } from '@/types/character';
type CardListProps = {
items: Character[];
+ className?: string;
+ onClick?: (id: number) => void;
};
-export class CardList extends Component
{
- render() {
- const { items } = this.props;
-
- if (!items.length) {
- return No results
;
- }
-
- return (
-
- {items.map((char) => (
-
- ))}
-
- );
+export const CardList = ({ items, className = '', onClick }: CardListProps) => {
+ if (!items.length) {
+ return No results
;
}
-}
+
+ return (
+
+ {items.map((char) => (
+
+ ))}
+
+ );
+};
diff --git a/src/components/CharacterDetails.tsx b/src/components/CharacterDetails.tsx
new file mode 100644
index 0000000..bccc85c
--- /dev/null
+++ b/src/components/CharacterDetails.tsx
@@ -0,0 +1,48 @@
+import spinner from '@/assets/spinner-gap-thin.svg';
+import { Button } from '@/components/Button';
+import { Card } from '@/components/Card';
+import { LoadingOverlay } from '@/components/LoadingOverlay';
+import { useCharacterQuery } from '@/hooks/useCharacterQuery';
+import { ERROR_UI_STRINGS } from '@/shared/constants/errors';
+import { UI_STRINGS } from '@/shared/constants/ui-strings';
+
+type CharacterDetailsProps = {
+ characterId: string;
+ onClose: () => void;
+};
+
+export const CharacterDetails = ({
+ characterId,
+ onClose,
+}: CharacterDetailsProps) => {
+ const {
+ data: character,
+ isLoading,
+ isError,
+ } = useCharacterQuery(characterId);
+
+ if (isError || !character)
+ return isLoading
+ ? null
+ : isError || (
+
+ {ERROR_UI_STRINGS.unknownError}
+
+ );
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Fallback/FallBack.test.tsx b/src/components/Fallback/FallBack.test.tsx
index 07b29db..92b7017 100644
--- a/src/components/Fallback/FallBack.test.tsx
+++ b/src/components/Fallback/FallBack.test.tsx
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import { expect, it } from 'vitest';
-import { FallBack } from '@/components/Fallback';
+import { FallBack } from './FallBack';
import { ERROR_UI_STRINGS } from '@/shared/constants/errors';
it('renders fallback content', () => {
diff --git a/src/components/Fallback/FallBack.tsx b/src/components/Fallback/FallBack.tsx
index f4572b4..2f7a49b 100644
--- a/src/components/Fallback/FallBack.tsx
+++ b/src/components/Fallback/FallBack.tsx
@@ -1,30 +1,27 @@
-import { Component, type ReactNode } from 'react';
import summerImage from '@/assets/Rick-And-Morty-PNG-Pic-Background.png';
import { Button } from '@/components/Button';
import { ERROR_UI_STRINGS } from '@/shared/constants/errors';
-export class FallBack extends Component {
- render(): ReactNode {
- return (
-
-

-
- {ERROR_UI_STRINGS.heading}
-
-
- {ERROR_UI_STRINGS.description}
-
-
-
- );
- }
-}
+export const FallBack = () => {
+ return (
+
+

+
+ {ERROR_UI_STRINGS.heading}
+
+
+ {ERROR_UI_STRINGS.description}
+
+
+
+ );
+};
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 0d1de13..14ce35c 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -1,24 +1,22 @@
-import { Component, type ReactNode } from 'react';
+import { useState } from 'react';
import { Button } from '@/components/Button';
import { UI_STRINGS } from '@/shared/constants/ui-strings';
-type FooterProps = {
- onThrowError: () => void;
-};
+export const Footer = () => {
+ const [wouldThrow, setWouldThrow] = useState(false);
-class Footer extends Component {
- render(): ReactNode {
- return (
-
- );
+ if (wouldThrow) {
+ throw new Error('test error from button');
}
-}
-export default Footer;
+ return (
+
+ );
+};
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 5400a87..9c4ea05 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -1,11 +1,12 @@
-import { Component, type ReactNode } from 'react';
+import { Link } from 'react-router';
+import { PATHS } from '@/app/paths';
import logo from '@/assets/rick-and-morty-sticker-b-w.webp';
import { UI_STRINGS } from '@/shared/constants/ui-strings';
-class Header extends Component {
- render(): ReactNode {
- return (
-
+export const Header = () => {
+ return (
+
+
{UI_STRINGS.title}
-
- );
- }
-}
-
-export default Header;
+
+
+
+ );
+};
diff --git a/src/components/Input.tsx b/src/components/Input.tsx
index 4fa6684..ebcb603 100644
--- a/src/components/Input.tsx
+++ b/src/components/Input.tsx
@@ -1,19 +1,19 @@
-import { Component, type InputHTMLAttributes } from 'react';
+import { type InputHTMLAttributes } from 'react';
-interface InputProps extends InputHTMLAttributes {
+type InputProps = {
className?: string;
-}
+} & InputHTMLAttributes;
-export class Input extends Component {
- render() {
- const { className = '', type = 'text', ...rest } = this.props;
-
- return (
-
- );
- }
-}
+export const Input = ({
+ className = '',
+ type = 'text',
+ ...rest
+}: InputProps) => {
+ return (
+
+ );
+};
diff --git a/src/components/LoadingOverlay.tsx b/src/components/LoadingOverlay.tsx
index c286f11..388e610 100644
--- a/src/components/LoadingOverlay.tsx
+++ b/src/components/LoadingOverlay.tsx
@@ -1,22 +1,18 @@
-import { Component, type ReactNode } from 'react';
+import { type ReactNode } from 'react';
type LoadingOverlayProps = {
show: boolean;
children?: ReactNode;
};
-export class LoadingOverlay extends Component {
- render() {
- const { show, children } = this.props;
+export const LoadingOverlay = ({ show, children }: LoadingOverlayProps) => {
+ if (!show) return null;
- if (!show) return null;
-
- return (
-
- {children ?? (
-
- )}
-
- );
- }
-}
+ return (
+
+ {children ?? (
+
+ )}
+
+ );
+};
diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx
index e53d827..d98253f 100644
--- a/src/components/Pagination.tsx
+++ b/src/components/Pagination.tsx
@@ -1,4 +1,3 @@
-import { Component } from 'react';
import LeftIcon from '@/assets/chevron-left.svg?react';
import RightIcon from '@/assets/chevron-right.svg?react';
import { Button } from '@/components/Button';
@@ -12,66 +11,66 @@ type PaginationProps = {
const MAX_PAGES = 7;
-export class Pagination extends Component {
- handleClick = (page: number) => {
- const { onChange } = this.props;
- if (page !== this.props.value) {
+export const Pagination = ({
+ total = 0,
+ value,
+ onChange,
+ className = '',
+}: PaginationProps) => {
+ const handleClick = (page: number) => {
+ if (page !== value) {
onChange(page);
}
};
- render() {
- const { total = 0, value, className = '' } = this.props;
+ if (total < 1) return null;
- if (total < 1) return null;
+ const pageButtons = [];
- const pageButtons = [];
+ const isShowAllPages = total <= MAX_PAGES;
- const isShowAllPages = total <= MAX_PAGES;
-
- if (isShowAllPages) {
- for (let i = 1; i <= total; i++) {
- pageButtons.push(
- ,
- );
- }
- }
-
- return (
-
+ if (isShowAllPages) {
+ for (let i = 1; i <= total; i++) {
+ pageButtons.push(
+ {i}
+ ,
+ );
+ }
+ }
- {isShowAllPages ? (
- pageButtons
- ) : (
-
- Dimension {value} of {total}
-
- )}
+ return (
+
+
-
-
- );
- }
-}
+ {isShowAllPages ? (
+ pageButtons
+ ) : (
+
+ Dimension {value} of {total}
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/SearchBar/SearchBar.tsx b/src/components/SearchBar/SearchBar.tsx
index c10900a..03c4772 100644
--- a/src/components/SearchBar/SearchBar.tsx
+++ b/src/components/SearchBar/SearchBar.tsx
@@ -1,4 +1,4 @@
-import { Component, type ReactNode } from 'react';
+import { useEffect, useState } from 'react';
import { Button } from '@/components/Button';
import { Input } from '@/components/Input';
import { UI_STRINGS } from '@/shared/constants/ui-strings';
@@ -9,64 +9,52 @@ type Props = {
searchQuery: string;
};
-type State = {
- inputValue: string;
-};
-
-export class SearchBar extends Component {
- state: State = {
- inputValue: this.props.searchQuery,
- };
+export const SearchBar = ({ onSearch, isLoading, searchQuery }: Props) => {
+ const [inputValue, setInputValue] = useState(searchQuery);
- componentDidUpdate(prevProps: Props) {
- if (prevProps.searchQuery !== this.props.searchQuery) {
- this.setState({ inputValue: this.props.searchQuery });
- }
- }
+ useEffect(() => {
+ setInputValue(searchQuery);
+ }, [searchQuery]);
- handleInputChange = (e: React.ChangeEvent) => {
- this.setState({ inputValue: e.target.value });
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setInputValue(e.target.value);
};
- handleSearch = () => {
- const text = this.state.inputValue.trim();
- this.props.onSearch(text);
- this.setState({ inputValue: text });
+ const handleSearch = () => {
+ const text = inputValue.trim();
+ onSearch(text);
+ setInputValue(text);
};
- handleSubmit = (e: React.FormEvent) => {
+ const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
- this.handleSearch();
+ handleSearch();
};
- render(): ReactNode {
- const { isLoading } = this.props;
-
- return (
-
-
-
- );
- }
-}
+ {UI_STRINGS.searchButton}
+
+
+
+ );
+};
diff --git a/src/hooks/useCharacterQuery.ts b/src/hooks/useCharacterQuery.ts
new file mode 100644
index 0000000..c76d554
--- /dev/null
+++ b/src/hooks/useCharacterQuery.ts
@@ -0,0 +1,10 @@
+import { useQuery } from '@tanstack/react-query';
+import { fetchCharacterById } from '@/shared/utils/fetch-character';
+
+export const useCharacterQuery = (id: string) => {
+ return useQuery({
+ queryKey: ['character', id],
+ queryFn: () => fetchCharacterById(id),
+ enabled: !!id,
+ });
+};
diff --git a/src/hooks/useCharactersQuery.ts b/src/hooks/useCharactersQuery.ts
new file mode 100644
index 0000000..9d3e338
--- /dev/null
+++ b/src/hooks/useCharactersQuery.ts
@@ -0,0 +1,8 @@
+import { useQuery } from '@tanstack/react-query';
+import { fetchCharacters } from '@/shared/utils/fetch-сharacters';
+
+export const useCharactersQuery = (query: string, page: number) =>
+ useQuery({
+ queryKey: ['characters', { query, page }],
+ queryFn: () => fetchCharacters(query, page),
+ });
diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts
new file mode 100644
index 0000000..2acb2aa
--- /dev/null
+++ b/src/hooks/useLocalStorage.ts
@@ -0,0 +1,35 @@
+import { useCallback, useState } from 'react';
+
+export function useLocalStorage(key: string, defaultValue: T) {
+ const get = (): T => {
+ try {
+ const item = localStorage.getItem(key);
+ if (item === null) return defaultValue;
+
+ if (typeof defaultValue === 'string') {
+ return item as T;
+ }
+ return JSON.parse(item) as T;
+ } catch {
+ return defaultValue;
+ }
+ };
+
+ const [value, setValue] = useState(get);
+
+ const set = useCallback(
+ (value: T) => {
+ try {
+ if (typeof value === 'string') {
+ localStorage.setItem(key, value);
+ } else {
+ localStorage.setItem(key, JSON.stringify(value));
+ }
+ setValue(value);
+ } catch {}
+ },
+ [key],
+ );
+
+ return [value, set] as const;
+}
diff --git a/src/layouts/ErrorLayout.tsx b/src/layouts/ErrorLayout.tsx
new file mode 100644
index 0000000..bd6cb07
--- /dev/null
+++ b/src/layouts/ErrorLayout.tsx
@@ -0,0 +1,13 @@
+import { Outlet } from 'react-router';
+import { Header } from '@/components/Header';
+
+export const ErrorLayout = () => {
+ return (
+
+ );
+};
diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx
new file mode 100644
index 0000000..251c6a0
--- /dev/null
+++ b/src/layouts/MainLayout.tsx
@@ -0,0 +1,15 @@
+import { Outlet } from 'react-router';
+import { Footer } from '@/components/Footer';
+import { Header } from '@/components/Header';
+
+export const MainLayout = () => {
+ return (
+
+ );
+};
diff --git a/src/main.tsx b/src/main.tsx
index ca36fb3..d8832f2 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { ErrorBoundary } from '@/components/ErrorBoundary';
-import { FallBack } from '@/components/Fallback';
+import { FallBack } from '@/components/FallBack/index.ts';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
createRoot(document.getElementById('root')!).render(
diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx
new file mode 100644
index 0000000..c50bc96
--- /dev/null
+++ b/src/pages/AboutPage.tsx
@@ -0,0 +1,9 @@
+import { UI_STRINGS } from '@/shared/constants/ui-strings';
+
+export const AboutPage = () => {
+ return (
+
+ {UI_STRINGS.contentAboutPage}
+
+ );
+};
diff --git a/src/pages/HomePage/HomePage.test.tsx b/src/pages/HomePage/HomePage.test.tsx
index 1ab1fbc..ff7484b 100644
--- a/src/pages/HomePage/HomePage.test.tsx
+++ b/src/pages/HomePage/HomePage.test.tsx
@@ -1,87 +1,51 @@
-import { render, screen, waitFor, fireEvent } from '@testing-library/react';
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { createMemoryRouter, RouterProvider } from 'react-router';
+import { describe, expect, it, vi } from 'vitest';
import { HomePage } from './HomePage';
-import { fetchCharacters } from '@/shared/utils/fetch-сharacters';
-import { searchStorage } from '@/shared/utils/local-storage';
+import { FallBack } from '@/components/FallBack';
+import { MainLayout } from '@/layouts/MainLayout';
import { mockCharacters } from '@/tests/mockCharacters';
-import type { CharacterApiResponse } from '@/types/character';
-vi.mock('@/shared/utils/fetch-сharacters');
-vi.mock('@/shared/utils/local-storage', () => ({
- searchStorage: {
- get: vi.fn(() => ''),
- set: vi.fn(),
- },
+vi.mock('@/hooks/useCharactersQuery', () => ({
+ useCharactersQuery: () => ({
+ data: {
+ results: mockCharacters.results,
+ info: mockCharacters.info,
+ },
+ isLoading: false,
+ isError: false,
+ error: null,
+ refetch: vi.fn(),
+ }),
}));
-const mockResponse = mockCharacters as CharacterApiResponse;
-
-const renderHomePage = () => render();
-const mockSuccessResponse = () =>
- vi.mocked(fetchCharacters).mockResolvedValue(mockResponse);
-
-describe('HomePage tests', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.mocked(searchStorage.get).mockReturnValue('test');
- });
-
- it('renders characters from API on mount', async () => {
- mockSuccessResponse();
- renderHomePage();
-
- expect(screen.getByAltText(/loading/i)).toBeInTheDocument();
+describe('HomePage via routing', () => {
+ it('renders characters from query', async () => {
+ const router = createMemoryRouter(
+ [
+ {
+ path: '/',
+ element: ,
+ errorElement: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ ],
+ },
+ ],
+ {
+ initialEntries: ['/?name=rick'],
+ },
+ );
+
+ render();
await waitFor(() => {
expect(
- screen.getByText(mockResponse.results[0].name),
+ screen.getByText(mockCharacters.results[0].name),
).toBeInTheDocument();
});
});
-
- it('handles API error', async () => {
- vi.mocked(fetchCharacters).mockRejectedValue(new Error('network error'));
-
- renderHomePage();
-
- await waitFor(() => {
- expect(screen.getByText(/network error/i)).toBeInTheDocument();
- });
- });
-
- it('shows spinner', async () => {
- mockSuccessResponse();
- renderHomePage();
-
- expect(screen.getByAltText(/loading/i)).toBeInTheDocument();
-
- await waitFor(() => {
- expect(screen.queryByAltText(/loading/i)).not.toBeInTheDocument();
- });
- });
-
- it('loads initial search query from localStorage', async () => {
- mockSuccessResponse();
- renderHomePage();
-
- await waitFor(() => {
- expect(fetchCharacters).toHaveBeenCalledWith('test', 1);
- });
- });
-
- it('page navigation calls fetchCharacters', async () => {
- const fetchMock = vi
- .mocked(fetchCharacters)
- .mockResolvedValue(mockResponse);
-
- vi.mocked(searchStorage.get).mockReturnValue('test');
- renderHomePage();
- await waitFor(() => expect(fetchMock).toHaveBeenCalled());
-
- await waitFor(() => {
- fireEvent.click(screen.getByText('2'));
- });
-
- expect(fetchMock).toHaveBeenCalledWith('test', 2);
- });
});
diff --git a/src/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx
index 9f45853..e09599e 100644
--- a/src/pages/HomePage/HomePage.tsx
+++ b/src/pages/HomePage/HomePage.tsx
@@ -1,111 +1,121 @@
-import { type ReactNode, Component } from 'react';
+import { useEffect } from 'react';
+import { useNavigate, useParams, useSearchParams } from 'react-router';
import spinner from '@/assets/spinner-gap-thin.svg';
import { CardList } from '@/components/CardList';
+import { CharacterDetails } from '@/components/CharacterDetails';
import { LoadingOverlay } from '@/components/LoadingOverlay';
import { Pagination } from '@/components/Pagination';
import { SearchBar } from '@/components/SearchBar';
+import { useCharactersQuery } from '@/hooks/useCharactersQuery';
+import { useLocalStorage } from '@/hooks/useLocalStorage';
import { ERROR_UI_STRINGS } from '@/shared/constants/errors';
import { UI_STRINGS } from '@/shared/constants/ui-strings';
-import { fetchCharacters } from '@/shared/utils/fetch-сharacters';
import { searchStorage } from '@/shared/utils/local-storage';
-import type { ApiInfo, Character } from '@/types/character';
-type State = {
- info: ApiInfo | null;
- characters: Character[];
- isLoading: boolean;
- hasError: boolean;
- errorMessage: string | null;
- page: number;
- searchQuery: string;
-};
+export const HomePage = () => {
+ const navigate = useNavigate();
+ const { characterId } = useParams();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [storedSearchQuery, setStoredSearchQuery] = useLocalStorage(
+ searchStorage.key,
+ '',
+ );
-export class HomePage extends Component {
- state: State = {
- info: null,
- characters: [],
- isLoading: false,
- hasError: false,
- errorMessage: null,
- page: 1,
- searchQuery: searchStorage.get(),
- };
+ const searchQueryFromURL = searchParams.get('name') || '';
+ const pageFromURL = Number(searchParams.get('page') || 1);
- componentDidMount(): void {
- this.fetchCharacters(this.state.searchQuery);
- }
+ const searchQuery = searchQueryFromURL || '';
+ const { data, isLoading, isError, error } = useCharactersQuery(
+ searchQuery,
+ pageFromURL,
+ );
- fetchCharacters = async (search: string, page = 1) => {
- this.setState({ isLoading: true, hasError: false, errorMessage: null });
+ useEffect(() => {
+ if (!searchQueryFromURL && storedSearchQuery) {
+ setSearchParams({ name: storedSearchQuery, page: '1' });
+ }
+ }, [searchQueryFromURL, storedSearchQuery, setSearchParams]);
- try {
- const data = await fetchCharacters(search, page);
+ useEffect(() => {
+ if (!characterId || !data?.results?.length) return;
+ const isEqual = data.results.some(
+ (char) => String(char.id) === characterId,
+ );
- this.setState({
- info: data.info,
- characters: data.results,
- page,
- });
- } catch (error) {
- this.setState({
- hasError: true,
- errorMessage: (error as Error).message,
- characters: [],
- });
- } finally {
- this.setState({ isLoading: false });
+ if (!isEqual) {
+ navigate(`/?name=${searchQuery}&page=${pageFromURL}`, { replace: true });
}
- };
+ }, [characterId, data?.results, navigate, searchQuery, pageFromURL]);
- handleSearch = (text: string) => {
- searchStorage.set(text);
- this.fetchCharacters(text);
+ const handleSearch = (text: string) => {
+ setStoredSearchQuery(text);
+ setSearchParams({ name: text, page: '1' });
};
- handlePageChange = (page: number) =>
- this.fetchCharacters(searchStorage.get(), page);
-
- render(): ReactNode {
- const {
- characters,
- isLoading,
- hasError,
- errorMessage,
- page,
- info,
- searchQuery,
- } = this.state;
+ const handlePageChange = (nextPage: number) => {
+ setSearchParams({ name: searchQuery, page: String(nextPage) });
+ };
- return (
-
-
-
-
-
+
+
- {isLoading ? null : hasError ? (
-
- {errorMessage ?? ERROR_UI_STRINGS.unknownError}
-
- ) : (
-
- )}
- {!isLoading && !hasError && (
-
+
+ {isLoading ? null : isError ? (
+
+ {(error as Error)?.message || ERROR_UI_STRINGS.unknownError}
+
+ ) : data?.results?.length === 0 ? (
+
+ {ERROR_UI_STRINGS.notFound}
+
+ ) : (
+
+
+
+ navigate(`/character/${id}?${searchParams.toString()}`)
+ }
+ />
+ {!isLoading && !isError && (
+
+ )}
+
+ {characterId && (
+
+
+ navigate(`/character?${searchParams.toString()}`)
+ }
+ />
+
+ )}
+
+ )}
+ {characterId && (
+
+ navigate(`/character?${searchParams.toString()}`)}
/>
- )}
-
- );
- }
-}
+
+ )}
+
+ );
+};
diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx
new file mode 100644
index 0000000..7416a8c
--- /dev/null
+++ b/src/pages/NotFoundPage.tsx
@@ -0,0 +1,9 @@
+import { ERROR_UI_STRINGS } from '@/shared/constants/errors';
+
+export const NotFoundPage = () => {
+ return (
+
+ {ERROR_UI_STRINGS.unknownError}
+
+ );
+};
diff --git a/src/shared/constants/ui-strings.ts b/src/shared/constants/ui-strings.ts
index 3f52dc6..85c3478 100644
--- a/src/shared/constants/ui-strings.ts
+++ b/src/shared/constants/ui-strings.ts
@@ -5,4 +5,8 @@ export const UI_STRINGS = {
errorButton: 'Break the Universe',
altLoading: 'Loading...',
altLogo: 'Logo: Rick and Morty',
+ home: 'Home Dimension',
+ about: 'Dev Dimension',
+ contentAboutPage:
+ 'Meeseeks here! The About page? Yeah... Evil Morty hid it somewhere between dimensions. Still working on it, okay?!',
} as const;
diff --git a/src/shared/utils/fetch-character.ts b/src/shared/utils/fetch-character.ts
new file mode 100644
index 0000000..32cc73b
--- /dev/null
+++ b/src/shared/utils/fetch-character.ts
@@ -0,0 +1,10 @@
+import { BASE_API_URL } from '@/shared/constants/api';
+import { ERROR_UI_STRINGS } from '@/shared/constants/errors';
+import type { Character } from '@/types/character';
+
+export const fetchCharacterById = async (id: string): Promise => {
+ const url = `${BASE_API_URL}/character/${id}`;
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(ERROR_UI_STRINGS.unknownError);
+ return (await response.json()) as Character;
+};
diff --git a/src/shared/utils/fetch-characters.test.ts b/src/shared/utils/fetch-characters.test.ts
index d9f534c..db8ef91 100644
--- a/src/shared/utils/fetch-characters.test.ts
+++ b/src/shared/utils/fetch-characters.test.ts
@@ -1,6 +1,5 @@
import { describe, beforeEach, vi, it, expect } from 'vitest';
import { fetchCharacters } from './fetch-сharacters';
-import { ERROR_UI_STRINGS } from '@/shared/constants/errors';
import { mockCharacters } from '@/tests/mockCharacters';
import type { CharacterApiResponse } from '@/types/character';
@@ -22,15 +21,23 @@ describe('fetchCharacters', () => {
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('name=Rick'));
});
- it('throws error', async () => {
+ it('empty results', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({}),
} as Response);
- await expect(fetchCharacters('unknown')).rejects.toThrow(
- ERROR_UI_STRINGS.notFound,
- );
+ const result = await fetchCharacters('unknown');
+
+ expect(result).toEqual({
+ results: [],
+ info: {
+ count: 0,
+ pages: 1,
+ next: null,
+ prev: null,
+ },
+ });
});
});
diff --git "a/src/shared/utils/fetch-\321\201haracters.ts" "b/src/shared/utils/fetch-\321\201haracters.ts"
index ab668a5..5788c7d 100644
--- "a/src/shared/utils/fetch-\321\201haracters.ts"
+++ "b/src/shared/utils/fetch-\321\201haracters.ts"
@@ -10,8 +10,20 @@ export async function fetchCharacters(
const response = await fetch(url);
+ if (response.status === 404) {
+ return {
+ results: [],
+ info: {
+ count: 0,
+ pages: 1,
+ next: null,
+ prev: null,
+ },
+ };
+ }
+
if (!response.ok) {
- throw new Error(ERROR_UI_STRINGS.notFound);
+ throw new Error(ERROR_UI_STRINGS.unknownError);
}
return (await response.json()) as CharacterApiResponse;
diff --git a/src/shared/utils/local-storage.ts b/src/shared/utils/local-storage.ts
index e8e5857..f9c1309 100644
--- a/src/shared/utils/local-storage.ts
+++ b/src/shared/utils/local-storage.ts
@@ -1,6 +1,8 @@
const SEARCH_KEY = 'rick-and-morty-search';
export const searchStorage = {
+ key: SEARCH_KEY,
+
get(): string {
return localStorage.getItem(SEARCH_KEY) || '';
},
diff --git a/src/tests/renderWithRouter.ts b/src/tests/renderWithRouter.ts
new file mode 100644
index 0000000..e69de29