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 ( -
-
-
- -
this.setState({ wouldThrow: true })} /> -
-
- ); - } +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} -
-

{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} +
+

{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 ( +
+ + {UI_STRINGS.altLoading} + + + +
+ ); +}; 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.imageAlt} -

- {ERROR_UI_STRINGS.heading} -

-

- {ERROR_UI_STRINGS.description} -

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

+ {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.altLogo} {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 ( -
- - {UI_STRINGS.altLoading} - - + + {UI_STRINGS.altLoading} - {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