From 66a25a5bb2101db67ba9624e0ca3b1710b7f8ec2 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 27 Jul 2025 17:24:22 +0200 Subject: [PATCH 01/30] refactor: migrate Button to function component --- src/components/Button.tsx | 56 +++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 29 deletions(-) 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 ( + + ); +}; From d69996b58ce13c96d75458b2fd94d107330b4b5f Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 27 Jul 2025 17:40:19 +0200 Subject: [PATCH 02/30] refactor: migrate Input to function component --- src/components/Input.tsx | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) 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 ( + + ); +}; From 653d5e1f4dbefaf51b1945ae0082d9e31ea10ee4 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 27 Jul 2025 17:52:02 +0200 Subject: [PATCH 03/30] refactor: migrate Header and Footer to function component --- src/App.tsx | 4 ++-- src/components/Footer.tsx | 26 +++++++++----------------- src/components/Header.tsx | 27 ++++++++++----------------- 3 files changed, 21 insertions(+), 36 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f785afa..4e37b5d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { Component, type ReactNode } from 'react'; -import Footer from '@/components/Footer'; -import Header from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { Header } from '@/components/Header'; import { HomePage } from '@/pages/HomePage'; type AppState = { diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 0d1de13..547d53e 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,4 +1,3 @@ -import { Component, type ReactNode } from 'react'; import { Button } from '@/components/Button'; import { UI_STRINGS } from '@/shared/constants/ui-strings'; @@ -6,19 +5,12 @@ type FooterProps = { onThrowError: () => void; }; -class Footer extends Component { - render(): ReactNode { - return ( -
- -
- ); - } -} - -export default Footer; +export const Footer = ({ onThrowError }: FooterProps) => { + return ( +
+ +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 5400a87..514418d 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,22 +1,15 @@ -import { Component, type ReactNode } from 'react'; 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 ( -
- {UI_STRINGS.altLogo} -

- {UI_STRINGS.title} -

-
- ); - } -} +export const Header = () => { + return ( +
+ {UI_STRINGS.altLogo} +

+ {UI_STRINGS.title} +

+
+ ); +}; export default Header; From e034448fa57b9a851d14fdbbcf2a7f2a21b2e6eb Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 27 Jul 2025 18:01:11 +0200 Subject: [PATCH 04/30] refactor: migrate LoadingOverlay to function component --- src/components/LoadingOverlay.tsx | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) 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 ?? ( +
+ )} +
+ ); +}; From a8f1ca7140f004c815d81727d1802f4624439190 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 27 Jul 2025 20:46:54 +0200 Subject: [PATCH 05/30] refactor: migrate Pagination to function component --- src/components/Pagination.tsx | 105 +++++++++++++++++----------------- 1 file changed, 52 insertions(+), 53 deletions(-) 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} + + )} + + +
+ ); +}; From 2e7edde03e34e6353d8de0b47fcf1bd586eae397 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 27 Jul 2025 21:31:55 +0200 Subject: [PATCH 06/30] refactor: migrate FallBack to function component --- src/components/Fallback/FallBack.test.tsx | 2 +- src/components/Fallback/FallBack.tsx | 49 +++++++++++------------ src/main.tsx | 2 +- 3 files changed, 25 insertions(+), 28 deletions(-) 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/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( From 326ace8e7c51df2beaa3414f846108edc1bb13cc Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 27 Jul 2025 21:50:16 +0200 Subject: [PATCH 07/30] refactor: migrate Card to function component --- src/components/Card/Card.tsx | 71 +++++++++++++++++------------------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index b594590..f464c8a 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -1,4 +1,3 @@ -import { Component } from 'react'; import { CardText } from './CardText'; import { CARD_TEXT } from '@/shared/constants/cards'; import type { Character } from '@/types/character'; @@ -7,8 +6,9 @@ type CardProps = { character: Character; }; -export class Card extends Component { - getValue(key: string, value: string): string { +export const Card = ({ character }: CardProps) => { + const { name, status, species, gender, image, origin, location } = character; + const getValue = (key: string, value: string): string => { if (value === 'unknown') { switch (key) { case 'status': @@ -20,44 +20,39 @@ export class Card extends Component { } } return value; - } + }; - render() { - const { name, status, species, gender, image, origin, location } = - this.props.character; - - return ( -
- {name} -
-

{name}

- - {CARD_TEXT.label.species}: {species} - + return ( +
+ {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} - - )} -
+ )}
- ); - } -} +
+ ); +}; From 2216928006f5a42593df73eacaf5aa3eca8a5bfc Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 27 Jul 2025 21:54:43 +0200 Subject: [PATCH 08/30] refactor: migrate CardList to function component --- src/components/CardList/CardList.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/components/CardList/CardList.tsx b/src/components/CardList/CardList.tsx index c1dde9a..a60c178 100644 --- a/src/components/CardList/CardList.tsx +++ b/src/components/CardList/CardList.tsx @@ -1,4 +1,3 @@ -import { Component } from 'react'; import { Card } from '@/components/Card'; import type { Character } from '@/types/character'; @@ -6,10 +5,8 @@ type CardListProps = { items: Character[]; }; -export class CardList extends Component { - render() { - const { items } = this.props; - +export const CardList = ({ items }: CardListProps) => { + { if (!items.length) { return

No results

; } @@ -22,4 +19,4 @@ export class CardList extends Component {
); } -} +}; From 3e35c9883f2cfb1b9db781fe89bd1b188faa6186 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 27 Jul 2025 22:13:49 +0200 Subject: [PATCH 09/30] refactor: migrate SearchBar to function component --- src/components/SearchBar/SearchBar.tsx | 92 +++++++++++--------------- 1 file changed, 40 insertions(+), 52 deletions(-) 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} + + +
+ ); +}; From 2311b71a3637b87369b10016652b50e6a3b79968 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 27 Jul 2025 22:57:27 +0200 Subject: [PATCH 10/30] refactor: move error throwing logic to Footer --- src/App.tsx | 16 +--------------- src/components/Footer.tsx | 16 +++++++++++----- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4e37b5d..0d5b7cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,28 +3,14 @@ import { Footer } from '@/components/Footer'; import { Header } from '@/components/Header'; import { HomePage } from '@/pages/HomePage'; -type AppState = { - wouldThrow: boolean; -}; - class App extends Component { - state: AppState = { - wouldThrow: false, - }; - render(): ReactNode { - const { wouldThrow } = this.state; - - if (wouldThrow) { - throw new Error('test error from button'); - } - return (
-
this.setState({ wouldThrow: true })} /> +
); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 547d53e..14ce35c 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,14 +1,20 @@ +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); + + if (wouldThrow) { + throw new Error('test error from button'); + } -export const Footer = ({ onThrowError }: FooterProps) => { return (
-
From fbb58f050b7a9155d58a40da6b6f9c2d6844210e Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 27 Jul 2025 23:43:04 +0200 Subject: [PATCH 11/30] refactor: add MainLayout and stubs for AboutPage and NotFoundPage --- package-lock.json | 38 ++++++++++++++++++++++++++++++++++++++ package.json | 1 + src/components/Header.tsx | 2 -- src/layouts/MainLayout.tsx | 15 +++++++++++++++ src/pages/AboutPage.tsx | 3 +++ src/pages/NotFoundPage.tsx | 9 +++++++++ 6 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/layouts/MainLayout.tsx create mode 100644 src/pages/AboutPage.tsx create mode 100644 src/pages/NotFoundPage.tsx diff --git a/package-lock.json b/package-lock.json index e17047e..e2bbfdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@tailwindcss/vite": "^4.1.11", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-router": "^7.7.1", "tailwindcss": "^4.1.11" }, "devDependencies": { @@ -4381,6 +4382,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 +8264,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 +8601,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..b437701 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@tailwindcss/vite": "^4.1.11", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-router": "^7.7.1", "tailwindcss": "^4.1.11" }, "devDependencies": { diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 514418d..c02173a 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -11,5 +11,3 @@ export const Header = () => { ); }; - -export default Header; 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/pages/AboutPage.tsx b/src/pages/AboutPage.tsx new file mode 100644 index 0000000..2dffdac --- /dev/null +++ b/src/pages/AboutPage.tsx @@ -0,0 +1,3 @@ +export const AboutPage = () => { + return

About page

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

+ ); +}; From ccbca58c0371e2f0c6cc099775f8a24c873c8fc1 Mon Sep 17 00:00:00 2001 From: maiano Date: Mon, 28 Jul 2025 00:18:24 +0200 Subject: [PATCH 12/30] feat: add React Router with basic route configuration --- src/App.tsx | 38 +++++++++++++++++++++----------------- src/app/routes.ts | 5 +++++ 2 files changed, 26 insertions(+), 17 deletions(-) create mode 100644 src/app/routes.ts diff --git a/src/App.tsx b/src/App.tsx index 0d5b7cd..eb969c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,24 @@ -import { Component, type ReactNode } from 'react'; -import { Footer } from '@/components/Footer'; -import { Header } from '@/components/Header'; +import { createBrowserRouter, RouterProvider } from 'react-router'; +import { ROUTES } from '@/app/routes'; +import { FallBack } from '@/components/FallBack'; +import { MainLayout } from '@/layouts/MainLayout'; +import { AboutPage } from '@/pages/AboutPage'; import { HomePage } from '@/pages/HomePage'; +import { NotFoundPage } from '@/pages/NotFoundPage'; -class App extends Component { - render(): ReactNode { - return ( -
-
-
- -
-
-
- ); - } -} +const router = createBrowserRouter([ + { + path: ROUTES.HOME, + element: , + errorElement: , + children: [ + { index: true, element: }, + { path: ROUTES.ABOUT, element: }, + { path: ROUTES.NOT_FOUND, element: }, + ], + }, +]); -export default App; +export default function App() { + return ; +} diff --git a/src/app/routes.ts b/src/app/routes.ts new file mode 100644 index 0000000..8a04109 --- /dev/null +++ b/src/app/routes.ts @@ -0,0 +1,5 @@ +export const ROUTES = { + HOME: '/', + ABOUT: '/about', + NOT_FOUND: '*', +} as const; From 3e5dc94ec1e8d4e393e7fa86ae3523e1f57b6160 Mon Sep 17 00:00:00 2001 From: maiano Date: Mon, 28 Jul 2025 00:34:30 +0200 Subject: [PATCH 13/30] test: change test for App component --- src/App.test.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 1c3bb12..577642f 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; -import App from './App'; -import { ErrorBoundary } from '@/components/ErrorBoundary'; +import App from '@/App'; +import { ERROR_UI_STRINGS } from '@/shared/constants/errors'; import { UI_STRINGS } from '@/shared/constants/ui-strings'; describe('App component', () => { @@ -10,17 +10,10 @@ describe('App component', () => { expect(screen.getByText(UI_STRINGS.title)).toBeInTheDocument(); }); - it('throw the error with the button', () => { - render( - App down
}> - - , - ); - + it('throws the error with the button and shows fallback UI', () => { + render(); const errorButton = screen.getByText(UI_STRINGS.errorButton); - fireEvent.click(errorButton); - - expect(screen.getByText(/App down/i)).toBeInTheDocument(); + expect(screen.getByText(ERROR_UI_STRINGS.heading)).toBeInTheDocument(); }); }); From cfbad3710f4f1639808a8d696e7fb3723937ab20 Mon Sep 17 00:00:00 2001 From: maiano Date: Mon, 28 Jul 2025 02:04:46 +0200 Subject: [PATCH 14/30] refactor: migrate MainPage to function component --- src/App.test.tsx | 43 +++++-- src/App.tsx | 3 +- src/hooks/useSearchQuery.ts | 8 ++ src/pages/HomePage/HomePage.loader.ts | 11 ++ src/pages/HomePage/HomePage.test.tsx | 114 +++++++----------- src/pages/HomePage/HomePage.tsx | 161 ++++++++++++-------------- src/tests/renderWithRouter.ts | 0 7 files changed, 166 insertions(+), 174 deletions(-) create mode 100644 src/hooks/useSearchQuery.ts create mode 100644 src/pages/HomePage/HomePage.loader.ts create mode 100644 src/tests/renderWithRouter.ts diff --git a/src/App.test.tsx b/src/App.test.tsx index 577642f..86ddcb6 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,19 +1,38 @@ -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 { ERROR_UI_STRINGS } from '@/shared/constants/errors'; +import { ROUTES } from '@/app/routes'; +import { FallBack } from '@/components/FallBack'; +import { MainLayout } from '@/layouts/MainLayout'; +import { AboutPage } from '@/pages/AboutPage'; +import { HomePage } from '@/pages/HomePage'; +import { homePageLoader } from '@/pages/HomePage/HomePage.loader'; +import { NotFoundPage } from '@/pages/NotFoundPage'; import { UI_STRINGS } from '@/shared/constants/ui-strings'; +const routes = [ + { + path: ROUTES.HOME, + element: , + errorElement: , + children: [ + { index: true, element: , loader: homePageLoader }, + { path: ROUTES.ABOUT, element: }, + { path: ROUTES.NOT_FOUND, element: }, + ], + }, +]; + describe('App component', () => { - it('renders components', () => { - render(); - expect(screen.getByText(UI_STRINGS.title)).toBeInTheDocument(); - }); + it('renders search button from SearchBar', async () => { + const testRouter = createMemoryRouter(routes, { + initialEntries: ['/'], + }); + + render(); - it('throws the error with the button and shows fallback UI', () => { - render(); - const errorButton = screen.getByText(UI_STRINGS.errorButton); - fireEvent.click(errorButton); - expect(screen.getByText(ERROR_UI_STRINGS.heading)).toBeInTheDocument(); + expect( + await screen.findByText(UI_STRINGS.searchButton), + ).toBeInTheDocument(); }); }); diff --git a/src/App.tsx b/src/App.tsx index eb969c5..3649165 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { FallBack } from '@/components/FallBack'; import { MainLayout } from '@/layouts/MainLayout'; import { AboutPage } from '@/pages/AboutPage'; import { HomePage } from '@/pages/HomePage'; +import { homePageLoader } from '@/pages/HomePage/HomePage.loader'; import { NotFoundPage } from '@/pages/NotFoundPage'; const router = createBrowserRouter([ @@ -12,7 +13,7 @@ const router = createBrowserRouter([ element: , errorElement: , children: [ - { index: true, element: }, + { index: true, element: , loader: homePageLoader }, { path: ROUTES.ABOUT, element: }, { path: ROUTES.NOT_FOUND, element: }, ], diff --git a/src/hooks/useSearchQuery.ts b/src/hooks/useSearchQuery.ts new file mode 100644 index 0000000..b0be1dd --- /dev/null +++ b/src/hooks/useSearchQuery.ts @@ -0,0 +1,8 @@ +const SEARCH_KEY = 'rick-and-morty-search'; + +export const useSearchQuery = () => { + const get = () => localStorage.getItem(SEARCH_KEY) || ''; + const set = (value: string) => localStorage.setItem(SEARCH_KEY, value); + + return { get, set }; +}; diff --git a/src/pages/HomePage/HomePage.loader.ts b/src/pages/HomePage/HomePage.loader.ts new file mode 100644 index 0000000..1f65047 --- /dev/null +++ b/src/pages/HomePage/HomePage.loader.ts @@ -0,0 +1,11 @@ +import { fetchCharacters } from '@/shared/utils/fetch-сharacters'; +import { searchStorage } from '@/shared/utils/local-storage'; + +export const homePageLoader = async ({ request }: { request: Request }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get('page') || 1); + const searchQuery = searchStorage.get(); + + const data = await fetchCharacters(searchQuery, page); + return { data, page, searchQuery }; +}; diff --git a/src/pages/HomePage/HomePage.test.tsx b/src/pages/HomePage/HomePage.test.tsx index 1ab1fbc..b238bb7 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 { homePageLoader } from './HomePage.loader'; +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('./HomePage.loader', () => ({ + homePageLoader: () => ({ + data: { + results: mockCharacters.results, + info: mockCharacters.info, + }, + page: 1, + searchQuery: '', + }), })); -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 loader', async () => { + const router = createMemoryRouter( + [ + { + path: '/', + element: , + errorElement: , + children: [ + { + index: true, + element: , + loader: homePageLoader, + }, + ], + }, + ], + { + initialEntries: ['/'], + }, + ); + + 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..43a5ae8 100644 --- a/src/pages/HomePage/HomePage.tsx +++ b/src/pages/HomePage/HomePage.tsx @@ -1,111 +1,100 @@ -import { type ReactNode, Component } from 'react'; +import { useState } from 'react'; +import { useLoaderData } from 'react-router'; import spinner from '@/assets/spinner-gap-thin.svg'; import { CardList } from '@/components/CardList'; import { LoadingOverlay } from '@/components/LoadingOverlay'; import { Pagination } from '@/components/Pagination'; import { SearchBar } from '@/components/SearchBar'; +import { useSearchQuery } from '@/hooks/useSearchQuery'; 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; +type LoaderData = { + data: { results: Character[]; info: ApiInfo }; page: number; searchQuery: string; }; -export class HomePage extends Component { - state: State = { - info: null, - characters: [], - isLoading: false, - hasError: false, - errorMessage: null, - page: 1, - searchQuery: searchStorage.get(), - }; - - componentDidMount(): void { - this.fetchCharacters(this.state.searchQuery); - } +export const HomePage = () => { + const { + data, + page: initialPage, + searchQuery: initialQuery, + } = useLoaderData() as LoaderData; + const { set } = useSearchQuery(); - fetchCharacters = async (search: string, page = 1) => { - this.setState({ isLoading: true, hasError: false, errorMessage: null }); + const [characters, setCharacters] = useState(data.results); + const [info, setInfo] = useState(data.info); + const [page, setPage] = useState(initialPage); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const handleSearch = async (text: string) => { + set(text); try { - const data = await fetchCharacters(search, page); - - this.setState({ - info: data.info, - characters: data.results, - page, - }); - } catch (error) { - this.setState({ - hasError: true, - errorMessage: (error as Error).message, - characters: [], - }); + setIsLoading(true); + const data = await fetchCharacters(text); + setCharacters(data.results); + setInfo(data.info); + setPage(1); + setHasError(false); + setErrorMessage(null); + } catch (err) { + setHasError(true); + setErrorMessage((err as Error).message); } finally { - this.setState({ isLoading: false }); + setIsLoading(false); } }; - handleSearch = (text: string) => { - searchStorage.set(text); - this.fetchCharacters(text); + const handlePageChange = async (nextPage: number) => { + try { + setIsLoading(true); + const data = await fetchCharacters(searchStorage.get(), nextPage); + setCharacters(data.results); + setInfo(data.info); + setPage(nextPage); + } catch (err) { + setHasError(true); + setErrorMessage((err as Error).message); + } finally { + setIsLoading(false); + } }; - handlePageChange = (page: number) => - this.fetchCharacters(searchStorage.get(), page); - - render(): ReactNode { - const { - characters, - isLoading, - hasError, - errorMessage, - page, - info, - searchQuery, - } = this.state; - - return ( -
- - {UI_STRINGS.altLoading} - - + + {UI_STRINGS.altLoading} - {isLoading ? null : hasError ? ( -

- {errorMessage ?? ERROR_UI_STRINGS.unknownError} -

- ) : ( - - )} - {!isLoading && !hasError && ( - - )} -
- ); - } -} + + + {isLoading ? null : hasError ? ( +

+ {errorMessage ?? ERROR_UI_STRINGS.unknownError} +

+ ) : ( + + )} + {!isLoading && !hasError && ( + + )} + + ); +}; diff --git a/src/tests/renderWithRouter.ts b/src/tests/renderWithRouter.ts new file mode 100644 index 0000000..e69de29 From e60385922a64c94e687e4d7cfc3809ab9a8e5a0b Mon Sep 17 00:00:00 2001 From: maiano Date: Thu, 31 Jul 2025 22:02:05 +0200 Subject: [PATCH 15/30] fix: handling of missing data errors and add an ErrorLayout --- src/App.tsx | 8 +++++++- src/layouts/ErrorLayout.tsx | 13 +++++++++++++ src/pages/HomePage/HomePage.tsx | 4 ++++ "src/shared/utils/fetch-\321\201haracters.ts" | 14 +++++++++++++- 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 src/layouts/ErrorLayout.tsx diff --git a/src/App.tsx b/src/App.tsx index 3649165..2dd6c7a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { createBrowserRouter, RouterProvider } from 'react-router'; import { ROUTES } from '@/app/routes'; 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'; @@ -15,9 +16,14 @@ const router = createBrowserRouter([ children: [ { index: true, element: , loader: homePageLoader }, { path: ROUTES.ABOUT, element: }, - { path: ROUTES.NOT_FOUND, element: }, ], }, + { + path: ROUTES.NOT_FOUND, + element: , + errorElement: , + children: [{ path: ROUTES.NOT_FOUND, element: }], + }, ]); export default function App() { 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/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx index 43a5ae8..05d6795 100644 --- a/src/pages/HomePage/HomePage.tsx +++ b/src/pages/HomePage/HomePage.tsx @@ -84,6 +84,10 @@ export const HomePage = () => {

{errorMessage ?? ERROR_UI_STRINGS.unknownError}

+ ) : characters.length === 0 ? ( +

+ {ERROR_UI_STRINGS.notFound} +

) : ( )} 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; From 2b46ca45e44c11fec6dda1ae25daf8fd8cfdeaf9 Mon Sep 17 00:00:00 2001 From: maiano Date: Fri, 1 Aug 2025 02:25:33 +0200 Subject: [PATCH 16/30] fix: 404 test to expect empty result --- src/shared/utils/fetch-characters.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/shared/utils/fetch-characters.test.ts b/src/shared/utils/fetch-characters.test.ts index d9f534c..9fb075c 100644 --- a/src/shared/utils/fetch-characters.test.ts +++ b/src/shared/utils/fetch-characters.test.ts @@ -29,8 +29,16 @@ describe('fetchCharacters', () => { 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, + }, + }); }); }); From 4f61b9451148db0e161fa4950e6a5ead379fc359 Mon Sep 17 00:00:00 2001 From: maiano Date: Fri, 1 Aug 2025 03:49:27 +0200 Subject: [PATCH 17/30] feat: implement generic hook for typed localStorage --- src/hooks/useLocalStorage.ts | 35 +++++++++++++++++++++++ src/hooks/useSearchQuery.ts | 8 ------ src/pages/HomePage/HomePage.tsx | 9 +++--- src/shared/utils/fetch-characters.test.ts | 3 +- src/shared/utils/local-storage.ts | 2 ++ 5 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 src/hooks/useLocalStorage.ts delete mode 100644 src/hooks/useSearchQuery.ts 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/hooks/useSearchQuery.ts b/src/hooks/useSearchQuery.ts deleted file mode 100644 index b0be1dd..0000000 --- a/src/hooks/useSearchQuery.ts +++ /dev/null @@ -1,8 +0,0 @@ -const SEARCH_KEY = 'rick-and-morty-search'; - -export const useSearchQuery = () => { - const get = () => localStorage.getItem(SEARCH_KEY) || ''; - const set = (value: string) => localStorage.setItem(SEARCH_KEY, value); - - return { get, set }; -}; diff --git a/src/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx index 05d6795..fd70279 100644 --- a/src/pages/HomePage/HomePage.tsx +++ b/src/pages/HomePage/HomePage.tsx @@ -5,7 +5,7 @@ import { CardList } from '@/components/CardList'; import { LoadingOverlay } from '@/components/LoadingOverlay'; import { Pagination } from '@/components/Pagination'; import { SearchBar } from '@/components/SearchBar'; -import { useSearchQuery } from '@/hooks/useSearchQuery'; +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'; @@ -24,7 +24,8 @@ export const HomePage = () => { page: initialPage, searchQuery: initialQuery, } = useLoaderData() as LoaderData; - const { set } = useSearchQuery(); + + const [searchQuery, setSearchQuery] = useLocalStorage(searchStorage.key, ''); const [characters, setCharacters] = useState(data.results); const [info, setInfo] = useState(data.info); @@ -34,7 +35,7 @@ export const HomePage = () => { const [errorMessage, setErrorMessage] = useState(null); const handleSearch = async (text: string) => { - set(text); + setSearchQuery(text); try { setIsLoading(true); const data = await fetchCharacters(text); @@ -54,7 +55,7 @@ export const HomePage = () => { const handlePageChange = async (nextPage: number) => { try { setIsLoading(true); - const data = await fetchCharacters(searchStorage.get(), nextPage); + const data = await fetchCharacters(searchQuery, nextPage); setCharacters(data.results); setInfo(data.info); setPage(nextPage); diff --git a/src/shared/utils/fetch-characters.test.ts b/src/shared/utils/fetch-characters.test.ts index 9fb075c..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,7 +21,7 @@ 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, 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) || ''; }, From a4d74f84d202e29f14055dbc85792981b8e370d4 Mon Sep 17 00:00:00 2001 From: maiano Date: Fri, 1 Aug 2025 22:27:37 +0200 Subject: [PATCH 18/30] refactor: move the routes to a separate module --- src/App.test.tsx | 21 +-------------------- src/App.tsx | 27 ++------------------------- src/app/{routes.ts => paths.ts} | 2 +- src/app/routes.tsx | 26 ++++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 46 deletions(-) rename src/app/{routes.ts => paths.ts} (72%) create mode 100644 src/app/routes.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx index 86ddcb6..71726cf 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,28 +1,9 @@ import { render, screen } from '@testing-library/react'; import { RouterProvider, createMemoryRouter } from 'react-router'; import { describe, it, expect } from 'vitest'; -import { ROUTES } from '@/app/routes'; -import { FallBack } from '@/components/FallBack'; -import { MainLayout } from '@/layouts/MainLayout'; -import { AboutPage } from '@/pages/AboutPage'; -import { HomePage } from '@/pages/HomePage'; -import { homePageLoader } from '@/pages/HomePage/HomePage.loader'; -import { NotFoundPage } from '@/pages/NotFoundPage'; +import { routes } from '@/app/routes'; import { UI_STRINGS } from '@/shared/constants/ui-strings'; -const routes = [ - { - path: ROUTES.HOME, - element: , - errorElement: , - children: [ - { index: true, element: , loader: homePageLoader }, - { path: ROUTES.ABOUT, element: }, - { path: ROUTES.NOT_FOUND, element: }, - ], - }, -]; - describe('App component', () => { it('renders search button from SearchBar', async () => { const testRouter = createMemoryRouter(routes, { diff --git a/src/App.tsx b/src/App.tsx index 2dd6c7a..ffe145b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,30 +1,7 @@ import { createBrowserRouter, RouterProvider } from 'react-router'; -import { ROUTES } from '@/app/routes'; -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 { homePageLoader } from '@/pages/HomePage/HomePage.loader'; -import { NotFoundPage } from '@/pages/NotFoundPage'; +import { routes } from '@/app/routes'; -const router = createBrowserRouter([ - { - path: ROUTES.HOME, - element: , - errorElement: , - children: [ - { index: true, element: , loader: homePageLoader }, - { path: ROUTES.ABOUT, element: }, - ], - }, - { - path: ROUTES.NOT_FOUND, - element: , - errorElement: , - children: [{ path: ROUTES.NOT_FOUND, element: }], - }, -]); +const router = createBrowserRouter(routes); export default function App() { return ; diff --git a/src/app/routes.ts b/src/app/paths.ts similarity index 72% rename from src/app/routes.ts rename to src/app/paths.ts index 8a04109..bd536fc 100644 --- a/src/app/routes.ts +++ b/src/app/paths.ts @@ -1,4 +1,4 @@ -export const ROUTES = { +export const PATHS = { HOME: '/', ABOUT: '/about', NOT_FOUND: '*', diff --git a/src/app/routes.tsx b/src/app/routes.tsx new file mode 100644 index 0000000..0d2f229 --- /dev/null +++ b/src/app/routes.tsx @@ -0,0 +1,26 @@ +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 { homePageLoader } from '@/pages/HomePage/HomePage.loader'; +import { NotFoundPage } from '@/pages/NotFoundPage'; + +export const routes = [ + { + path: PATHS.HOME, + element: , + errorElement: , + children: [ + { index: true, element: , loader: homePageLoader }, + { path: PATHS.ABOUT, element: }, + ], + }, + { + path: PATHS.NOT_FOUND, + element: , + errorElement: , + children: [{ path: PATHS.NOT_FOUND, element: }], + }, +]; From 999acb9b1eac49429b47aa798277366d096ab802 Mon Sep 17 00:00:00 2001 From: maiano Date: Fri, 1 Aug 2025 22:44:32 +0200 Subject: [PATCH 19/30] test: add integration test to App tests --- src/App.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/App.test.tsx b/src/App.test.tsx index 71726cf..5058eb0 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -16,4 +16,18 @@ describe('App component', () => { await screen.findByText(UI_STRINGS.searchButton), ).toBeInTheDocument(); }); + + it('renders ErrorLayout with Header and NotFoundPage', async () => { + const testRouter = createMemoryRouter(routes, { + initialEntries: ['/error'], + }); + + render(); + + expect(await screen.findByAltText(UI_STRINGS.altLogo)).toBeInTheDocument(); + + expect( + screen.getByRole('heading', { name: UI_STRINGS.title }), + ).toBeInTheDocument(); + }); }); From b3e20f92b486db3487be3dd4f9b33d62ecd2345b Mon Sep 17 00:00:00 2001 From: maiano Date: Sat, 2 Aug 2025 01:15:38 +0200 Subject: [PATCH 20/30] feat: sync search and pagination with URL --- src/pages/HomePage/HomePage.loader.ts | 2 +- src/pages/HomePage/HomePage.tsx | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/pages/HomePage/HomePage.loader.ts b/src/pages/HomePage/HomePage.loader.ts index 1f65047..97a2354 100644 --- a/src/pages/HomePage/HomePage.loader.ts +++ b/src/pages/HomePage/HomePage.loader.ts @@ -4,7 +4,7 @@ import { searchStorage } from '@/shared/utils/local-storage'; export const homePageLoader = async ({ request }: { request: Request }) => { const url = new URL(request.url); const page = Number(url.searchParams.get('page') || 1); - const searchQuery = searchStorage.get(); + const searchQuery = url.searchParams.get('name') || searchStorage.get(); const data = await fetchCharacters(searchQuery, page); return { data, page, searchQuery }; diff --git a/src/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx index fd70279..8740911 100644 --- a/src/pages/HomePage/HomePage.tsx +++ b/src/pages/HomePage/HomePage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useLoaderData } from 'react-router'; +import { useLoaderData, useSearchParams } from 'react-router'; import spinner from '@/assets/spinner-gap-thin.svg'; import { CardList } from '@/components/CardList'; import { LoadingOverlay } from '@/components/LoadingOverlay'; @@ -22,10 +22,17 @@ export const HomePage = () => { const { data, page: initialPage, - searchQuery: initialQuery, + // searchQuery: initialQuery, } = useLoaderData() as LoaderData; - const [searchQuery, setSearchQuery] = useLocalStorage(searchStorage.key, ''); + const [searchParams, setSearchParams] = useSearchParams(); + // const navigate = useNavigate(); + + const urlSearchQuery = searchParams.get('name') || ''; + const [searchQuery, setSearchQuery] = useLocalStorage( + searchStorage.key, + urlSearchQuery, + ); const [characters, setCharacters] = useState(data.results); const [info, setInfo] = useState(data.info); @@ -36,6 +43,7 @@ export const HomePage = () => { const handleSearch = async (text: string) => { setSearchQuery(text); + setSearchParams({ name: text, page: '1' }); try { setIsLoading(true); const data = await fetchCharacters(text); @@ -53,6 +61,8 @@ export const HomePage = () => { }; const handlePageChange = async (nextPage: number) => { + setSearchParams({ name: searchQuery, page: String(nextPage) }); + try { setIsLoading(true); const data = await fetchCharacters(searchQuery, nextPage); @@ -77,7 +87,7 @@ export const HomePage = () => { /> From 9e59271bb0ed7cddfa891597711a3099c6c826d8 Mon Sep 17 00:00:00 2001 From: maiano Date: Sat, 2 Aug 2025 01:55:33 +0200 Subject: [PATCH 21/30] feat: add navigation links to header --- src/components/Header.tsx | 22 +++++++++++++++++----- src/shared/constants/ui-strings.ts | 2 ++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index c02173a..9c4ea05 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,13 +1,25 @@ +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'; export const Header = () => { return ( -
- {UI_STRINGS.altLogo} -

- {UI_STRINGS.title} -

+
+ + {UI_STRINGS.altLogo} +

+ {UI_STRINGS.title} +

+ +
); }; diff --git a/src/shared/constants/ui-strings.ts b/src/shared/constants/ui-strings.ts index 3f52dc6..d427a14 100644 --- a/src/shared/constants/ui-strings.ts +++ b/src/shared/constants/ui-strings.ts @@ -5,4 +5,6 @@ export const UI_STRINGS = { errorButton: 'Break the Universe', altLoading: 'Loading...', altLogo: 'Logo: Rick and Morty', + home: 'Home Dimension', + about: 'Jerry’s Cabinet', } as const; From a36826d8ef7b85f4e7fabdfc44f49a4f67282ed2 Mon Sep 17 00:00:00 2001 From: maiano Date: Sat, 2 Aug 2025 23:34:59 +0200 Subject: [PATCH 22/30] refactor: migrate data fetching to useCharactersQuery --- package-lock.json | 27 ++++++++ package.json | 1 + src/App.test.tsx | 12 ---- src/App.tsx | 17 ++++- src/app/routes.tsx | 3 +- src/hooks/useCharactersQuery.ts | 8 +++ src/pages/HomePage/HomePage.loader.ts | 11 ---- src/pages/HomePage/HomePage.test.tsx | 16 ++--- src/pages/HomePage/HomePage.tsx | 94 +++++++++------------------ 9 files changed, 91 insertions(+), 98 deletions(-) create mode 100644 src/hooks/useCharactersQuery.ts delete mode 100644 src/pages/HomePage/HomePage.loader.ts diff --git a/package-lock.json b/package-lock.json index e2bbfdf..11af6d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "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", @@ -2624,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", diff --git a/package.json b/package.json index b437701..bbfa817 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "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", diff --git a/src/App.test.tsx b/src/App.test.tsx index 5058eb0..b0b6cf9 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -5,18 +5,6 @@ import { routes } from '@/app/routes'; import { UI_STRINGS } from '@/shared/constants/ui-strings'; describe('App component', () => { - it('renders search button from SearchBar', async () => { - const testRouter = createMemoryRouter(routes, { - initialEntries: ['/'], - }); - - render(); - - expect( - await screen.findByText(UI_STRINGS.searchButton), - ).toBeInTheDocument(); - }); - it('renders ErrorLayout with Header and NotFoundPage', async () => { const testRouter = createMemoryRouter(routes, { initialEntries: ['/error'], diff --git a/src/App.tsx b/src/App.tsx index ffe145b..29cb17a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,23 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createBrowserRouter, RouterProvider } from 'react-router'; import { routes } from '@/app/routes'; const router = createBrowserRouter(routes); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 2 * 60 * 1000, + refetchOnWindowFocus: false, + }, + }, +}); + export default function App() { - return ; + return ( + + + ); + + ); } diff --git a/src/app/routes.tsx b/src/app/routes.tsx index 0d2f229..b8e975a 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -4,7 +4,6 @@ import { ErrorLayout } from '@/layouts/ErrorLayout'; import { MainLayout } from '@/layouts/MainLayout'; import { AboutPage } from '@/pages/AboutPage'; import { HomePage } from '@/pages/HomePage'; -import { homePageLoader } from '@/pages/HomePage/HomePage.loader'; import { NotFoundPage } from '@/pages/NotFoundPage'; export const routes = [ @@ -13,7 +12,7 @@ export const routes = [ element: , errorElement: , children: [ - { index: true, element: , loader: homePageLoader }, + { index: true, element: }, { path: PATHS.ABOUT, element: }, ], }, 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/pages/HomePage/HomePage.loader.ts b/src/pages/HomePage/HomePage.loader.ts deleted file mode 100644 index 97a2354..0000000 --- a/src/pages/HomePage/HomePage.loader.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { fetchCharacters } from '@/shared/utils/fetch-сharacters'; -import { searchStorage } from '@/shared/utils/local-storage'; - -export const homePageLoader = async ({ request }: { request: Request }) => { - const url = new URL(request.url); - const page = Number(url.searchParams.get('page') || 1); - const searchQuery = url.searchParams.get('name') || searchStorage.get(); - - const data = await fetchCharacters(searchQuery, page); - return { data, page, searchQuery }; -}; diff --git a/src/pages/HomePage/HomePage.test.tsx b/src/pages/HomePage/HomePage.test.tsx index b238bb7..ff7484b 100644 --- a/src/pages/HomePage/HomePage.test.tsx +++ b/src/pages/HomePage/HomePage.test.tsx @@ -2,24 +2,25 @@ 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 { homePageLoader } from './HomePage.loader'; import { FallBack } from '@/components/FallBack'; import { MainLayout } from '@/layouts/MainLayout'; import { mockCharacters } from '@/tests/mockCharacters'; -vi.mock('./HomePage.loader', () => ({ - homePageLoader: () => ({ +vi.mock('@/hooks/useCharactersQuery', () => ({ + useCharactersQuery: () => ({ data: { results: mockCharacters.results, info: mockCharacters.info, }, - page: 1, - searchQuery: '', + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), }), })); describe('HomePage via routing', () => { - it('renders characters from loader', async () => { + it('renders characters from query', async () => { const router = createMemoryRouter( [ { @@ -30,13 +31,12 @@ describe('HomePage via routing', () => { { index: true, element: , - loader: homePageLoader, }, ], }, ], { - initialEntries: ['/'], + initialEntries: ['/?name=rick'], }, ); diff --git a/src/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx index 8740911..34ce515 100644 --- a/src/pages/HomePage/HomePage.tsx +++ b/src/pages/HomePage/HomePage.tsx @@ -1,80 +1,46 @@ -import { useState } from 'react'; -import { useLoaderData, useSearchParams } from 'react-router'; +import { useEffect } from 'react'; +import { useSearchParams } from 'react-router'; import spinner from '@/assets/spinner-gap-thin.svg'; import { CardList } from '@/components/CardList'; 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 LoaderData = { - data: { results: Character[]; info: ApiInfo }; - page: number; - searchQuery: string; -}; export const HomePage = () => { - const { - data, - page: initialPage, - // searchQuery: initialQuery, - } = useLoaderData() as LoaderData; - const [searchParams, setSearchParams] = useSearchParams(); - // const navigate = useNavigate(); - - const urlSearchQuery = searchParams.get('name') || ''; - const [searchQuery, setSearchQuery] = useLocalStorage( + const [storedSearchQuery, setStoredSearchQuery] = useLocalStorage( searchStorage.key, - urlSearchQuery, + '', ); - const [characters, setCharacters] = useState(data.results); - const [info, setInfo] = useState(data.info); - const [page, setPage] = useState(initialPage); - const [isLoading, setIsLoading] = useState(false); - const [hasError, setHasError] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); + const searchQueryFromURL = searchParams.get('name') || ''; + const pageFromURL = Number(searchParams.get('page') || 1); - const handleSearch = async (text: string) => { - setSearchQuery(text); - setSearchParams({ name: text, page: '1' }); - try { - setIsLoading(true); - const data = await fetchCharacters(text); - setCharacters(data.results); - setInfo(data.info); - setPage(1); - setHasError(false); - setErrorMessage(null); - } catch (err) { - setHasError(true); - setErrorMessage((err as Error).message); - } finally { - setIsLoading(false); + useEffect(() => { + if (!searchQueryFromURL && storedSearchQuery) { + setSearchParams({ name: storedSearchQuery, page: '1' }); } + }, [searchQueryFromURL, storedSearchQuery, setSearchParams]); + + const searchQuery = searchQueryFromURL || ''; + + const { data, isLoading, isError, error, refetch } = useCharactersQuery( + searchQuery, + pageFromURL, + ); + + const handleSearch = (text: string) => { + setStoredSearchQuery(text); + setSearchParams({ name: text, page: '1' }); }; - const handlePageChange = async (nextPage: number) => { + const handlePageChange = (nextPage: number) => { setSearchParams({ name: searchQuery, page: String(nextPage) }); - - try { - setIsLoading(true); - const data = await fetchCharacters(searchQuery, nextPage); - setCharacters(data.results); - setInfo(data.info); - setPage(nextPage); - } catch (err) { - setHasError(true); - setErrorMessage((err as Error).message); - } finally { - setIsLoading(false); - } }; return ( @@ -91,22 +57,22 @@ export const HomePage = () => { onSearch={handleSearch} isLoading={isLoading} /> - {isLoading ? null : hasError ? ( + {isLoading ? null : isError ? (

- {errorMessage ?? ERROR_UI_STRINGS.unknownError} + {(error as Error)?.message || ERROR_UI_STRINGS.unknownError}

- ) : characters.length === 0 ? ( + ) : data?.results?.length === 0 ? (

{ERROR_UI_STRINGS.notFound}

) : ( - + )} - {!isLoading && !hasError && ( + {!isLoading && !isError && ( )} From 06843d0c328e6020f986bee8d0298b36bf56ad09 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 3 Aug 2025 00:09:03 +0200 Subject: [PATCH 23/30] feat: add fetch-character util --- src/app/routes.tsx | 4 ++++ src/pages/HomePage/HomePage.tsx | 2 +- src/shared/utils/fetch-character.ts | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 src/shared/utils/fetch-character.ts diff --git a/src/app/routes.tsx b/src/app/routes.tsx index b8e975a..aefa795 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -13,6 +13,10 @@ export const routes = [ errorElement: , children: [ { index: true, element: }, + { + path: 'character/:characterId', + element: , + }, { path: PATHS.ABOUT, element: }, ], }, diff --git a/src/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx index 34ce515..bf8066a 100644 --- a/src/pages/HomePage/HomePage.tsx +++ b/src/pages/HomePage/HomePage.tsx @@ -29,7 +29,7 @@ export const HomePage = () => { const searchQuery = searchQueryFromURL || ''; - const { data, isLoading, isError, error, refetch } = useCharactersQuery( + const { data, isLoading, isError, error } = useCharactersQuery( searchQuery, pageFromURL, ); 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; +}; From 48ddf6b1e8ae7f4da6a48a28289fe538536ccf7d Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 3 Aug 2025 00:24:54 +0200 Subject: [PATCH 24/30] feat: add useCharacterQuery hook --- src/components/CharacterDetails.tsx | 0 src/hooks/useCharacterQuery.ts | 10 ++++++++++ 2 files changed, 10 insertions(+) create mode 100644 src/components/CharacterDetails.tsx create mode 100644 src/hooks/useCharacterQuery.ts diff --git a/src/components/CharacterDetails.tsx b/src/components/CharacterDetails.tsx new file mode 100644 index 0000000..e69de29 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, + }); +}; From ba412fe6c3df7dfca6051667f044f5da27c7e7dc Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 3 Aug 2025 09:11:31 +0200 Subject: [PATCH 25/30] feat: implement the CharacterDetails --- src/app/paths.ts | 1 + src/app/routes.tsx | 21 +++++++++--- src/components/Card/Card.tsx | 18 +++++++++-- src/components/CardList/CardList.tsx | 28 ++++++++-------- src/components/CharacterDetails.tsx | 48 ++++++++++++++++++++++++++++ src/pages/HomePage/HomePage.tsx | 33 +++++++++++++------ 6 files changed, 119 insertions(+), 30 deletions(-) diff --git a/src/app/paths.ts b/src/app/paths.ts index bd536fc..c66606d 100644 --- a/src/app/paths.ts +++ b/src/app/paths.ts @@ -1,5 +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 index aefa795..1fa6d6d 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -1,3 +1,4 @@ +import { Navigate } from 'react-router'; import { PATHS } from '@/app/paths'; import { FallBack } from '@/components/FallBack'; import { ErrorLayout } from '@/layouts/ErrorLayout'; @@ -8,18 +9,30 @@ import { NotFoundPage } from '@/pages/NotFoundPage'; export const routes = [ { - path: PATHS.HOME, + path: '/', + element: , + }, + { + path: '/character', element: , errorElement: , children: [ - { index: true, element: }, { - path: 'character/:characterId', + index: true, + element: , + }, + { + path: ':characterId', element: , }, - { path: PATHS.ABOUT, element: }, ], }, + { + path: PATHS.ABOUT, + element: , + errorElement: , + children: [{ index: true, element: }], + }, { path: PATHS.NOT_FOUND, element: , diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index f464c8a..b231a71 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -4,10 +4,12 @@ import type { Character } from '@/types/character'; type CardProps = { character: Character; + onClick?: (id: number) => void; }; -export const Card = ({ character }: CardProps) => { - const { name, status, species, gender, image, origin, location } = character; +export const Card = ({ character, onClick }: CardProps) => { + const { id, name, status, species, gender, image, origin, location } = + character; const getValue = (key: string, value: string): string => { if (value === 'unknown') { switch (key) { @@ -23,7 +25,17 @@ export const Card = ({ character }: CardProps) => { }; return ( -
+
{ + if (e.key === 'Enter' || e.key === ' ') { + onClick?.(id); + } + }} + onClick={() => onClick?.(id)} + role="button" + tabIndex={0} + className="flex flex-col cursor-pointer md:flex-row items-center gap-4 p-4 bg-gray-200 dark:bg-gray-400 max-w-xl" + > {name} void; }; -export const CardList = ({ items }: CardListProps) => { - { - 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 index e69de29..2a71a2d 100644 --- a/src/components/CharacterDetails.tsx +++ b/src/components/CharacterDetails.tsx @@ -0,0 +1,48 @@ +import { useParams, useNavigate, useSearchParams } from 'react-router'; +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'; + +export const CharacterDetails = () => { + const { characterId } = useParams(); + const { + data: character, + isLoading, + isError, + } = useCharacterQuery(characterId || ''); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + const handleClose = () => { + navigate(`/?${searchParams.toString()}`); + }; + + if (isError || !character) + return isLoading + ? null + : isError || ( +

+ {ERROR_UI_STRINGS.unknownError} +

+ ); + + return ( +
+ + {UI_STRINGS.altLoading} + + + +
+ ); +}; diff --git a/src/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx index bf8066a..3077ddd 100644 --- a/src/pages/HomePage/HomePage.tsx +++ b/src/pages/HomePage/HomePage.tsx @@ -1,7 +1,8 @@ import { useEffect } from 'react'; -import { useSearchParams } from 'react-router'; +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'; @@ -12,6 +13,8 @@ import { UI_STRINGS } from '@/shared/constants/ui-strings'; import { searchStorage } from '@/shared/utils/local-storage'; export const HomePage = () => { + const navigate = useNavigate(); + const { characterId } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); const [storedSearchQuery, setStoredSearchQuery] = useLocalStorage( searchStorage.key, @@ -66,15 +69,25 @@ export const HomePage = () => { {ERROR_UI_STRINGS.notFound}

) : ( - - )} - {!isLoading && !isError && ( - +
+
+ + navigate(`/character/${id}?${searchParams.toString()}`) + } + /> + {!isLoading && !isError && ( + + )} +
+ {characterId && } +
)} ); From bf1e2bce3f0b8d38f46a99cad296d5442f93e194 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 3 Aug 2025 10:30:34 +0200 Subject: [PATCH 26/30] fix: auto-close character panel if id is not in current results --- src/pages/HomePage/HomePage.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx index 3077ddd..91eec86 100644 --- a/src/pages/HomePage/HomePage.tsx +++ b/src/pages/HomePage/HomePage.tsx @@ -24,18 +24,28 @@ export const HomePage = () => { const searchQueryFromURL = searchParams.get('name') || ''; const pageFromURL = Number(searchParams.get('page') || 1); + const searchQuery = searchQueryFromURL || ''; + const { data, isLoading, isError, error } = useCharactersQuery( + searchQuery, + pageFromURL, + ); + useEffect(() => { if (!searchQueryFromURL && storedSearchQuery) { setSearchParams({ name: storedSearchQuery, page: '1' }); } }, [searchQueryFromURL, storedSearchQuery, setSearchParams]); - const searchQuery = searchQueryFromURL || ''; + useEffect(() => { + if (!characterId || !data?.results?.length) return; + const isEqual = data.results.some( + (char) => String(char.id) === characterId, + ); - const { data, isLoading, isError, error } = useCharactersQuery( - searchQuery, - pageFromURL, - ); + if (!isEqual) { + navigate(`/?name=${searchQuery}&page=${pageFromURL}`, { replace: true }); + } + }, [characterId, data?.results, navigate, searchQuery, pageFromURL]); const handleSearch = (text: string) => { setStoredSearchQuery(text); From 9f0d3d09159dfea488d46553adfe0587264bea86 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 3 Aug 2025 11:09:55 +0200 Subject: [PATCH 27/30] feat: add variant prop to support details layout --- src/components/Card/Card.tsx | 40 +++++++++++++++++++++-------- src/components/CharacterDetails.tsx | 2 +- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index b231a71..2171861 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -5,9 +5,10 @@ import type { Character } from '@/types/character'; type CardProps = { character: Character; onClick?: (id: number) => void; + variant?: 'list' | 'details'; }; -export const Card = ({ character, onClick }: CardProps) => { +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 => { @@ -24,22 +25,39 @@ export const Card = ({ character, onClick }: CardProps) => { return value; }; + const baseClasses = + 'flex gap-4 p-4 bg-gray-200 dark:bg-gray-400 transition-all animate-fadeIn'; + + 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); - } - }} - onClick={() => onClick?.(id)} - role="button" - tabIndex={0} - className="flex flex-col cursor-pointer md:flex-row items-center gap-4 p-4 bg-gray-200 dark:bg-gray-400 max-w-xl" + onKeyDown={ + isClickable + ? (e) => { + 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}

diff --git a/src/components/CharacterDetails.tsx b/src/components/CharacterDetails.tsx index 2a71a2d..7f8159d 100644 --- a/src/components/CharacterDetails.tsx +++ b/src/components/CharacterDetails.tsx @@ -39,7 +39,7 @@ export const CharacterDetails = () => { alt={UI_STRINGS.altLoading} /> - + From 9da071e8c50ad1c8ba41c3a7ce7b928dacd5d4c5 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 3 Aug 2025 11:33:35 +0200 Subject: [PATCH 28/30] feat: modal layout for CharacterDetails on mobile --- src/pages/HomePage/HomePage.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx index 91eec86..2913975 100644 --- a/src/pages/HomePage/HomePage.tsx +++ b/src/pages/HomePage/HomePage.tsx @@ -79,7 +79,7 @@ export const HomePage = () => { {ERROR_UI_STRINGS.notFound}

) : ( -
+
{ /> )}
- {characterId && } + {characterId && ( +
+ +
+ )} +
+ )} + {characterId && ( +
+
)} From d8dc56d0128608a14880b7e128d75a102092ace4 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 3 Aug 2025 12:06:05 +0200 Subject: [PATCH 29/30] refactor: move navigation logic from CharacterDetails --- src/components/CharacterDetails.tsx | 22 +++++++++++----------- src/pages/HomePage/HomePage.tsx | 12 ++++++++++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/components/CharacterDetails.tsx b/src/components/CharacterDetails.tsx index 7f8159d..bccc85c 100644 --- a/src/components/CharacterDetails.tsx +++ b/src/components/CharacterDetails.tsx @@ -1,4 +1,3 @@ -import { useParams, useNavigate, useSearchParams } from 'react-router'; import spinner from '@/assets/spinner-gap-thin.svg'; import { Button } from '@/components/Button'; import { Card } from '@/components/Card'; @@ -7,19 +6,20 @@ import { useCharacterQuery } from '@/hooks/useCharacterQuery'; import { ERROR_UI_STRINGS } from '@/shared/constants/errors'; import { UI_STRINGS } from '@/shared/constants/ui-strings'; -export const CharacterDetails = () => { - const { characterId } = useParams(); +type CharacterDetailsProps = { + characterId: string; + onClose: () => void; +}; + +export const CharacterDetails = ({ + characterId, + onClose, +}: CharacterDetailsProps) => { const { data: character, isLoading, isError, - } = useCharacterQuery(characterId || ''); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - - const handleClose = () => { - navigate(`/?${searchParams.toString()}`); - }; + } = useCharacterQuery(characterId); if (isError || !character) return isLoading @@ -40,7 +40,7 @@ export const CharacterDetails = () => { /> -
diff --git a/src/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx index 2913975..e09599e 100644 --- a/src/pages/HomePage/HomePage.tsx +++ b/src/pages/HomePage/HomePage.tsx @@ -98,14 +98,22 @@ export const HomePage = () => {
{characterId && (
- + + navigate(`/character?${searchParams.toString()}`) + } + />
)}
)} {characterId && (
- + navigate(`/character?${searchParams.toString()}`)} + />
)} From 97b62c8ec77af1aa8650aab12ee98717a81570e5 Mon Sep 17 00:00:00 2001 From: maiano Date: Sun, 3 Aug 2025 14:21:57 +0200 Subject: [PATCH 30/30] feat: add placeholder message for About page --- src/pages/AboutPage.tsx | 8 +++++++- src/shared/constants/ui-strings.ts | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx index 2dffdac..c50bc96 100644 --- a/src/pages/AboutPage.tsx +++ b/src/pages/AboutPage.tsx @@ -1,3 +1,9 @@ +import { UI_STRINGS } from '@/shared/constants/ui-strings'; + export const AboutPage = () => { - return

About page

; + return ( +

+ {UI_STRINGS.contentAboutPage} +

+ ); }; diff --git a/src/shared/constants/ui-strings.ts b/src/shared/constants/ui-strings.ts index d427a14..85c3478 100644 --- a/src/shared/constants/ui-strings.ts +++ b/src/shared/constants/ui-strings.ts @@ -6,5 +6,7 @@ export const UI_STRINGS = { altLoading: 'Loading...', altLogo: 'Logo: Rick and Morty', home: 'Home Dimension', - about: 'Jerry’s Cabinet', + about: 'Dev Dimension', + contentAboutPage: + 'Meeseeks here! The About page? Yeah... Evil Morty hid it somewhere between dimensions. Still working on it, okay?!', } as const;