├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public └── vite.svg ├── src ├── App.css ├── App.tsx ├── Common │ ├── AppLayout │ │ └── AppLayout.tsx │ ├── ErrorOverlay │ │ └── ErrorOverlay.tsx │ ├── GlobalLoader │ │ └── GlobalLoader.tsx │ └── Navbar │ │ └── Navbar.tsx ├── api │ ├── characters.api.ts │ ├── episodes.api.ts │ └── index.ts ├── app.test.tsx ├── assets │ └── react.svg ├── contexts │ └── userContext.tsx ├── features │ ├── auth │ │ ├── AuthLayout │ │ │ └── AuthLayout.tsx │ │ ├── SignInPage │ │ │ └── SignInPage.tsx │ │ ├── SignupPage │ │ │ └── SignupPage.tsx │ │ └── VerifyEmailPage │ │ │ └── VerifyEmailPage.tsx │ ├── characters │ │ ├── CharacterDetailsPage │ │ │ ├── CharacterDetailsPage.tsx │ │ │ └── characterDetailsQuery.ts │ │ └── CharactersPage │ │ │ ├── CharactersFilters.tsx │ │ │ ├── CharactersFiltersContext.tsx │ │ │ ├── CharactersList.tsx │ │ │ ├── CharactersPage.tsx │ │ │ ├── __tests__ │ │ │ ├── characters-filters.test.tsx │ │ │ └── characters-page.test.tsx │ │ │ └── charactersPageQuery.ts │ ├── episodes │ │ ├── EpisodeDetailsModal.tsx │ │ ├── EpisodeDetailsPage.tsx │ │ ├── EpisodesPage.tsx │ │ ├── episodeDetailsQuery.ts │ │ └── episodesPageQuery.ts │ └── home │ │ └── HomePage.tsx ├── index.css ├── main.tsx ├── mocks │ ├── handlers │ │ ├── characters.mockHandlers.ts │ │ └── index.ts │ ├── helpers │ │ └── utils.ts │ └── server.ts ├── router │ ├── createLoader.ts │ ├── lazyRoutesComponents.ts │ ├── rootRouter.tsx │ └── routes.ts ├── services │ └── firebase │ │ ├── auth.ts │ │ └── index.ts ├── utils │ ├── apiClient.ts │ ├── consts.ts │ ├── helpers │ │ ├── index.ts │ │ ├── misc.ts │ │ └── withProviders.tsx │ ├── tests.utils.tsx │ ├── types │ │ └── typeUtils.ts │ └── wrapper.tsx └── vite-env.d.ts ├── tailwind.config.cjs ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest └── setupTests.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .prettierrc.js 4 | .eslintrc.js 5 | env.d.ts 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:react/recommended", 5 | "plugin:jsx-a11y/recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:import/typescript", 8 | "plugin:react/jsx-runtime", 9 | "plugin:prettier/recommended", 10 | "plugin:testing-library/react", 11 | "prettier" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": "latest", 19 | "sourceType": "module" 20 | }, 21 | "plugins": [ 22 | "react", 23 | "@typescript-eslint", 24 | "import", 25 | "jsx-a11y", 26 | "react-hooks", 27 | "prettier", 28 | "testing-library" 29 | ], 30 | "rules": { 31 | "@typescript-eslint/no-explicit-any": "off", 32 | "@typescript-eslint/no-non-null-assertion": "off", 33 | "@typescript-eslint/no-unused-vars": [ 34 | "warn", 35 | { 36 | "argsIgnorePattern": "^_", 37 | "varsIgnorePattern": "^_", 38 | "caughtErrorsIgnorePattern": "^_" 39 | } 40 | ], 41 | 42 | "react-hooks/rules-of-hooks": "error", 43 | "react-hooks/exhaustive-deps": "warn", 44 | 45 | "prettier/prettier": [ 46 | "warn", 47 | { 48 | "endOfLine": "auto", 49 | "singleQuote": true 50 | } 51 | ] 52 | }, 53 | "overrides": [ 54 | { 55 | "files": [ 56 | "**/__tests__/**/*.[jt]s?(x)", 57 | "**/?(*.)+(spec|test).[jt]s?(x)", 58 | "src/setupTests.js" 59 | ], 60 | "extends": ["plugin:testing-library/react"] 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | TODOs.md -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .prettierrc.js 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsxSingleQuote": true, 3 | "singleQuote": true, 4 | "semi": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 100, 8 | "bracketSameLine": false, 9 | "useTabs": false, 10 | "arrowParens": "always", 11 | "endOfLine": "auto" 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Router + Query experiment 2 | 3 | A small project where I'm playing around with how React Router new data loading features would work with React-Query library. 4 | 5 | The project uses vite + typescript + react-router + react-query + vitest 6 | 7 | ### Code Structure 8 | 9 | Merging this 2 packages together introduced quiet a bit of verbosity, so I needed to create quiet a bit of abstractions & helper functions to improve & reduce the amount of boilerplate, 10 | 11 | However, I believe that it can still be improved a bit. 12 | So if you have any idea or advice for a place where things can be improved, please open a PR or an issue. 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-router-query-test", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "test": "vitest --run", 10 | "test:watch": "vitest", 11 | "test:ui": "vitest --ui", 12 | "preview": "vite preview", 13 | "lint": "npx eslint src", 14 | "lint:fix": "npm run lint -- --fix", 15 | "prettier": "npx prettier src --check", 16 | "prettier:fix": "npm run prettier -- --write", 17 | "format": "npm run prettier:fix && npm run lint:fix" 18 | }, 19 | "dependencies": { 20 | "@tanstack/react-query": "^4.19.1", 21 | "axios": "^1.2.1", 22 | "firebase": "^9.15.0", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-dropdown": "^1.11.0", 26 | "react-modal": "^3.16.1", 27 | "react-router-dom": "^6.4.4", 28 | "react-select": "^5.7.0", 29 | "react-spinners": "^0.13.7", 30 | "rickmortyapi": "^2.0.1", 31 | "tailwind-merge": "^1.8.0" 32 | }, 33 | "devDependencies": { 34 | "@testing-library/dom": "^8.19.0", 35 | "@testing-library/jest-dom": "^5.16.5", 36 | "@testing-library/react": "^13.4.0", 37 | "@testing-library/user-event": "^14.4.3", 38 | "@types/node": "^18.11.11", 39 | "@types/react": "^18.0.24", 40 | "@types/react-dom": "^18.0.8", 41 | "@types/react-modal": "^3.13.1", 42 | "@typescript-eslint/eslint-plugin": "^5.46.1", 43 | "@typescript-eslint/parser": "^5.46.1", 44 | "@vitejs/plugin-react": "^2.2.0", 45 | "@vitest/ui": "^0.25.8", 46 | "autoprefixer": "^10.4.13", 47 | "cross-fetch": "^3.1.5", 48 | "eslint": "^8.29.0", 49 | "eslint-config-prettier": "^8.5.0", 50 | "eslint-plugin-import": "^2.26.0", 51 | "eslint-plugin-jsx-a11y": "^6.6.1", 52 | "eslint-plugin-prettier": "^4.2.1", 53 | "eslint-plugin-react": "^7.31.11", 54 | "eslint-plugin-react-hooks": "^4.6.0", 55 | "eslint-plugin-testing-library": "^5.9.1", 56 | "jsdom": "^20.0.3", 57 | "msw": "^0.49.1", 58 | "postcss": "^8.4.19", 59 | "prettier": "^2.8.1", 60 | "tailwindcss": "^3.2.4", 61 | "typescript": "^4.6.4", 62 | "vite": "^3.2.3", 63 | "vitest": "^0.25.6" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #page-container { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | } 6 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import { RootRouter } from './router/rootRouter'; 3 | 4 | function App() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | 12 | export default App; 13 | -------------------------------------------------------------------------------- /src/Common/AppLayout/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Outlet } from 'react-router-dom'; 3 | import GlobalLoader from '../GlobalLoader/GlobalLoader'; 4 | import Navbar from '../Navbar/Navbar'; 5 | import HashLoader from 'react-spinners/HashLoader'; 6 | 7 | export default function AppLayout() { 8 | return ( 9 | <> 10 | 11 | 12 |
13 | 16 | {' '} 17 |

Loading page...

18 |
19 | } 20 | > 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/Common/ErrorOverlay/ErrorOverlay.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, ReactNode } from 'react'; 2 | import { useRouteError } from 'react-router-dom'; 3 | 4 | interface Prosp extends PropsWithChildren { 5 | defaultTitle?: string; 6 | defaultBody?: string; 7 | } 8 | 9 | export default function ErrorOverlay({ defaultTitle, defaultBody, children }: Prosp) { 10 | const error = useRouteError(); 11 | console.log(error); 12 | 13 | const errorInfo = getErrorInfo(error, { defaultTitle, defaultBody }); 14 | 15 | return ( 16 |
17 | {children ? ( 18 | children 19 | ) : ( 20 | <> 21 |

{errorInfo.title}

22 |

{errorInfo.body}

23 | 24 | )} 25 |
26 | ); 27 | } 28 | 29 | const getErrorInfo = ( 30 | error: unknown, 31 | options?: Partial<{ defaultTitle: string; defaultBody: string }>, 32 | ) => { 33 | let title = options?.defaultTitle ?? 'Ooops'; 34 | let body = options?.defaultBody ?? 'Someothing unexpected happened, please try again'; 35 | 36 | if (error && typeof error === 'object') { 37 | if ('status' in error) title = String(error.status); 38 | if ('data' in error) body = String(error.data); 39 | } 40 | 41 | return { title, body }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/Common/GlobalLoader/GlobalLoader.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigation } from 'react-router-dom'; 2 | import { HashLoader } from 'react-spinners'; 3 | 4 | export default function GlobalLoader() { 5 | const navigation = useNavigation(); 6 | 7 | return navigation.state === 'loading' ? ( 8 |
9 | {navigation.location?.state?.loadingText ?? 'Fetching new data...'}{' '} 10 | 11 |
12 | ) : null; 13 | } 14 | -------------------------------------------------------------------------------- /src/Common/Navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { useAuthUser } from '@/contexts/userContext'; 2 | import { logout } from '@/services/firebase/auth'; 3 | import { Link, NavLink } from 'react-router-dom'; 4 | import { twMerge } from 'tailwind-merge'; 5 | 6 | export default function Navbar() { 7 | const user = useAuthUser(); 8 | 9 | const clickLogout = () => logout(); 10 | 11 | return ( 12 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/api/characters.api.ts: -------------------------------------------------------------------------------- 1 | import { delay, removeUndefinedFromObject } from '@/utils/helpers'; 2 | import axios from 'axios'; 3 | import { Info, Character } from 'rickmortyapi/dist/interfaces'; 4 | 5 | type Filters = { 6 | status: 'alive' | 'dead' | 'unknown'; 7 | gender: 'male' | 'female' | 'genderless' | 'unknown'; 8 | }; 9 | 10 | export const apiRoutes = { 11 | getCharacters: 'https://rickandmortyapi.com/api/character', 12 | getCharacterById: (id: number) => `https://rickandmortyapi.com/api/character/${id}`, 13 | }; 14 | 15 | export async function getCharacters(_filters?: Partial) { 16 | await delay(); 17 | let url = apiRoutes.getCharacters; 18 | const filters = removeUndefinedFromObject(_filters); 19 | const hasFilters = filters && Object.keys(filters).length > 0; 20 | 21 | if (hasFilters) { 22 | Object.entries(filters).forEach(([key, value], idx) => { 23 | if (idx === 0) url += '?'; 24 | else url += '&'; 25 | 26 | url += `${key}=${value}`; 27 | }); 28 | } 29 | 30 | const res = await axios.get(url); 31 | 32 | if (res.data.error) throw new Error(res.data.error); 33 | 34 | return res.data as Info; 35 | } 36 | 37 | export async function getCharacterById(id: number) { 38 | await delay(); 39 | const res = await axios.get(apiRoutes.getCharacterById(id)); 40 | 41 | if (res.data.error) throw new Error(res.data.error); 42 | 43 | return res.data as Character; 44 | } 45 | -------------------------------------------------------------------------------- /src/api/episodes.api.ts: -------------------------------------------------------------------------------- 1 | import { delay } from '@/utils/helpers'; 2 | import { Episode, Info } from 'rickmortyapi/dist/interfaces'; 3 | 4 | export async function getAllEpisodes() { 5 | await delay(); 6 | return fetch(apiRoutes.getAllEpisodes).then((res) => res.json()) as Promise>; 7 | } 8 | 9 | export async function getEpisodeById(id: number) { 10 | await delay(); 11 | const res = await fetch(apiRoutes.getEpisodeById(id)); 12 | const json = await res.json(); 13 | 14 | if (!res.ok) 15 | throw new Response(json.error, { 16 | status: res.status, 17 | statusText: res.statusText, 18 | }); 19 | 20 | return json as Episode; 21 | } 22 | 23 | export const apiRoutes = { 24 | getAllEpisodes: 'https://rickandmortyapi.com/api/episode', 25 | getEpisodeById: (id: number) => `https://rickandmortyapi.com/api/episode/${id}`, 26 | }; 27 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import * as charactersApi from './characters.api'; 2 | import * as episodesApi from './episodes.api'; 3 | 4 | const { apiRoutes: charactersRoutes, ...characters } = charactersApi; 5 | const { apiRoutes: episodesRoutes, ...episodes } = episodesApi; 6 | 7 | export const API_ROUTES = { 8 | ...charactersRoutes, 9 | ...episodesRoutes, 10 | }; 11 | 12 | export const API = { 13 | characters, 14 | episodes, 15 | }; 16 | -------------------------------------------------------------------------------- /src/app.test.tsx: -------------------------------------------------------------------------------- 1 | import App from './App'; 2 | import { render, screen } from '@/utils/tests.utils'; 3 | 4 | describe('App', () => { 5 | it('renders headline', () => { 6 | render(); 7 | 8 | const logo = screen.getByText(/Router \+ Query \+ Rick & Morty/i); 9 | expect(logo).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/contexts/userContext.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/services/firebase/auth'; 2 | import { User } from 'firebase/auth'; 3 | import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; 4 | 5 | interface State { 6 | user: User | null; 7 | } 8 | 9 | const Context = createContext(null); 10 | 11 | export const UserContextProvider = ({ children }: { children: ReactNode }) => { 12 | const [user, setUser] = useState(null); 13 | 14 | useEffect(() => { 15 | auth.onAuthStateChanged((user) => { 16 | setUser(user); 17 | }); 18 | }, []); 19 | 20 | return {children}; 21 | }; 22 | 23 | export const useAuthUser = () => { 24 | const res = useContext(Context); 25 | if (!res) throw new Error('No provider was found at parents'); 26 | return res.user; 27 | }; 28 | -------------------------------------------------------------------------------- /src/features/auth/AuthLayout/AuthLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | 3 | export default function AuthLayout() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/features/auth/SignInPage/SignInPage.tsx: -------------------------------------------------------------------------------- 1 | import { useAuthUser } from '@/contexts/userContext'; 2 | import { logInWithEmailAndPassword, signInWithGoogle } from '@/services/firebase/auth'; 3 | import { sendEmailVerification } from 'firebase/auth'; 4 | import { FormEvent, useEffect, useRef } from 'react'; 5 | import { Link, useNavigate } from 'react-router-dom'; 6 | 7 | export default function SignInPage() { 8 | const formRef = useRef(null!); 9 | 10 | const clickSignInGoogle = () => { 11 | signInWithGoogle(); 12 | }; 13 | 14 | const navigate = useNavigate(); 15 | 16 | const user = useAuthUser(); 17 | 18 | useEffect(() => { 19 | if (!user) return; 20 | 21 | if (user.emailVerified) navigate('/'); 22 | else navigate('/auth/verify-email'); 23 | }, [navigate, user]); 24 | 25 | const clickSubmitForm = (e: FormEvent) => { 26 | e.preventDefault(); 27 | const data = new FormData(e.currentTarget); 28 | const email = data.get('email'), 29 | password = data.get('password'); 30 | 31 | if (!email || !password) return alert('Form value are incorrect'); 32 | 33 | logInWithEmailAndPassword(email.toString(), password.toString()); 34 | }; 35 | 36 | return ( 37 |
38 |

Sign In

39 |

Using Email & Password:

40 |
41 | 42 | 43 | 46 |
47 |

OR

48 |
49 | 55 | 61 |
62 |

63 | Dont have an account yet?{' '} 64 | 65 | Create One 66 | 67 |

68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/features/auth/SignupPage/SignupPage.tsx: -------------------------------------------------------------------------------- 1 | import { registerWithEmailAndPassword } from '@/services/firebase/auth'; 2 | import { sendEmailVerification } from 'firebase/auth'; 3 | import { FormEvent } from 'react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | export default function SignUpPage() { 7 | const navigate = useNavigate(); 8 | 9 | const clickSubmitForm = (e: FormEvent) => { 10 | e.preventDefault(); 11 | const data = new FormData(e.currentTarget); 12 | const email = data.get('email'), 13 | name = data.get('name'), 14 | password = data.get('password'); 15 | 16 | if (!name || !email || !password) return alert('Form value are incorrect'); 17 | 18 | registerWithEmailAndPassword(name?.toString(), email.toString(), password.toString()).then( 19 | (user) => { 20 | if (user) { 21 | sendEmailVerification(user) 22 | .then(() => { 23 | navigate('/auth/verify-email'); 24 | }) 25 | .catch((err: any) => { 26 | console.log(err.message); 27 | }); 28 | } 29 | }, 30 | ); 31 | }; 32 | 33 | return ( 34 |
35 |

Sign Up

36 |
37 | 38 | 39 | 40 | 43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/features/auth/VerifyEmailPage/VerifyEmailPage.tsx: -------------------------------------------------------------------------------- 1 | import { useAuthUser } from '@/contexts/userContext'; 2 | import { sendEmailVerification } from 'firebase/auth'; 3 | import { useEffect } from 'react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | export default function VerifyEmail() { 7 | const user = useAuthUser(); 8 | const navigate = useNavigate(); 9 | 10 | const sendEmail = () => { 11 | if (user?.email) sendEmailVerification(user); 12 | }; 13 | 14 | useEffect(() => { 15 | const interval = setInterval(() => { 16 | if (user?.emailVerified) navigate('/'); 17 | else user?.reload(); 18 | }, 1000); 19 | return () => clearInterval(interval); 20 | }, [navigate, user]); 21 | 22 | return ( 23 |
24 |

Verify Your Email

25 |

26 | We sent a verification code to your email: {user?.email} 27 |

28 |

29 | Please check your inbox & spam folder, then click the link there, then come back to this 30 | page 31 |

32 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/features/characters/CharacterDetailsPage/CharacterDetailsPage.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLoaderData, useParams } from 'react-router-dom'; 2 | import { characterDetailsQuery, LoaderData } from './characterDetailsQuery'; 3 | import { useQuery } from '@tanstack/react-query'; 4 | 5 | export default function CharacterDetailsPage() { 6 | const data = useLoaderData() as LoaderData; 7 | const params = useParams(); 8 | 9 | const query = useQuery({ 10 | ...characterDetailsQuery(Number(params.characterId)), 11 | initialData: data, 12 | }); 13 | 14 | return ( 15 |
16 | 17 | ⬅️ Back 18 | 19 |

{query.data.name}

20 | 21 |
22 |

{query.data.species}

23 |

{query.data.status}

24 |

{query.data.gender}

25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/features/characters/CharacterDetailsPage/characterDetailsQuery.ts: -------------------------------------------------------------------------------- 1 | import { API } from '@/api'; 2 | import { createLoader } from '@/router/createLoader'; 3 | import { LoaderReturnType } from '@/utils/types/typeUtils'; 4 | 5 | export const characterDetailsQuery = (id: number) => ({ 6 | queryKey: ['character', id], 7 | queryFn: () => API.characters.getCharacterById(id), 8 | }); 9 | 10 | export type LoaderData = LoaderReturnType; 11 | 12 | export const characterDetailsLoader = createLoader(({ params }) => 13 | characterDetailsQuery(Number(params.characterId)), 14 | ); 15 | -------------------------------------------------------------------------------- /src/features/characters/CharactersPage/CharactersFilters.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Select from 'react-select'; 3 | import { DotLoader } from 'react-spinners'; 4 | import { useCharactersFilter } from './CharactersFiltersContext'; 5 | 6 | const statusOptions = [ 7 | { 8 | label: 'Alive', 9 | value: 'alive', 10 | }, 11 | { 12 | label: 'Dead', 13 | value: 'dead', 14 | }, 15 | { 16 | label: 'Unknown', 17 | value: 'unknown', 18 | }, 19 | ] as const; 20 | 21 | const genderOptions = [ 22 | { 23 | label: 'Male', 24 | value: 'male', 25 | }, 26 | { 27 | label: 'Female', 28 | value: 'female', 29 | }, 30 | { 31 | label: 'Genderless', 32 | value: 'genderless', 33 | }, 34 | { 35 | label: 'Unknown', 36 | value: 'unknown', 37 | }, 38 | ] as const; 39 | 40 | interface Props { 41 | isLoading?: boolean; 42 | } 43 | 44 | export default function CharactersFilters(props: Props) { 45 | const filters = useCharactersFilter(); 46 | 47 | return ( 48 |
49 | {props.isLoading && ( 50 |
54 | 55 |
56 | )} 57 |

Filters

58 |
59 |
60 | 61 | filters.setGender(option?.value)} 84 | value={genderOptions.find((o) => o.value === filters.gender)} 85 | placeholder='All' 86 | isClearable 87 | formatOptionLabel={(option) => ( 88 | {option.label} 89 | )} 90 | classNames={{ 91 | option: () => '!text-gray-900', 92 | }} 93 | /> 94 |
95 |
96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/features/characters/CharactersPage/CharactersFiltersContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, FC, useContext, useState } from 'react'; 2 | 3 | type StatusFilter = 'alive' | 'dead' | 'unknown'; 4 | type GenderFilter = 'male' | 'female' | 'genderless' | 'unknown'; 5 | 6 | interface ContextState { 7 | status: StatusFilter | undefined; 8 | gender: GenderFilter | undefined; 9 | setStatus: (value: this['status']) => void; 10 | setGender: (value: this['gender']) => void; 11 | } 12 | 13 | const Context = createContext(null); 14 | 15 | export const CharactersFiltersProvider: FC<{ children: ReactNode }> = ({ children }) => { 16 | const [status, setStatus] = useState(); 17 | const [gender, setGender] = useState(); 18 | 19 | return ( 20 | {children} 21 | ); 22 | }; 23 | 24 | export const useCharactersFilter = () => { 25 | const value = useContext(Context); 26 | if (!value) throw Error("Can't use context without provider"); 27 | return value; 28 | }; 29 | -------------------------------------------------------------------------------- /src/features/characters/CharactersPage/CharactersList.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import { Character } from 'rickmortyapi/dist/interfaces'; 3 | 4 | interface Props { 5 | characters?: Character[]; 6 | isLoading?: boolean; 7 | } 8 | 9 | export default function CharactersList({ characters }: Props) { 10 | if (!characters || characters.length === 0) 11 | return ( 12 |
13 |

Nothing here to show...

14 |
15 | ); 16 | 17 | return ( 18 |
    19 | {characters.map((character) => ( 20 |
  • 21 | 26 | 27 |
    28 |

    {character.name}

    29 |

    {character.species}

    30 |

    {character.status}

    31 |

    {character.gender}

    32 | 33 |
    34 | 40 |
    41 | 42 |
  • 43 | ))} 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/features/characters/CharactersPage/CharactersPage.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLoaderData } from 'react-router-dom'; 2 | import { charactersPageQuery, LoaderData } from './charactersPageQuery'; 3 | import { useQuery } from '@tanstack/react-query'; 4 | import CharactersList from './CharactersList'; 5 | import CharactersFilters from './CharactersFilters'; 6 | import { withProviders } from '@/utils/helpers'; 7 | import { CharactersFiltersProvider, useCharactersFilter } from './CharactersFiltersContext'; 8 | 9 | function CharactersPage() { 10 | const data = useLoaderData() as LoaderData; 11 | 12 | const { status, gender } = useCharactersFilter(); 13 | 14 | const query = useQuery({ 15 | ...charactersPageQuery({ filters: { gender, status } }), 16 | initialData: () => (!status && !gender ? data : undefined), 17 | keepPreviousData: true, 18 | }); 19 | 20 | if (!query.data) return <>; 21 | 22 | return ( 23 |
24 |

Explore Characters

25 |
26 | 27 | 32 |
33 |
34 | ); 35 | } 36 | 37 | export default withProviders(CharactersFiltersProvider)(CharactersPage); 38 | -------------------------------------------------------------------------------- /src/features/characters/CharactersPage/__tests__/characters-filters.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@/utils/tests.utils'; 2 | import CharactersFilters from '../CharactersFilters'; 3 | import { CharactersFiltersProvider } from '../CharactersFiltersContext'; 4 | import userEvent from '@testing-library/user-event'; 5 | 6 | describe('Characters Filters', () => { 7 | it('Renders correctly', () => { 8 | renderWithProviders(); 9 | 10 | const statusSelect = queries.statusSelect(); 11 | const genderSelect = queries.genderSelect(); 12 | 13 | expect(statusSelect).toBeInTheDocument(); 14 | expect(genderSelect).toBeInTheDocument(); 15 | }); 16 | 17 | it('Changes values correctly', async () => { 18 | renderWithProviders(); 19 | 20 | const statusSelect = queries.statusSelect(); 21 | const genderSelect = queries.genderSelect(); 22 | 23 | userEvent.click(statusSelect); 24 | 25 | const aliveOption = await screen.findByTestId(`select-option Alive`); 26 | await userEvent.click(aliveOption); 27 | 28 | userEvent.click(genderSelect); 29 | 30 | const maleOption = await screen.findByTestId(`select-option Male`); 31 | await userEvent.click(maleOption); 32 | 33 | expect(screen.getByText(/alive/i)).toBeInTheDocument(); 34 | 35 | expect(screen.getByText(/male/i)).toBeInTheDocument(); 36 | }); 37 | }); 38 | 39 | const renderWithProviders = () => 40 | render( 41 | 42 | 43 | , 44 | ); 45 | 46 | const queries = { 47 | statusSelect: () => screen.getByLabelText(/status/i), 48 | genderSelect: () => screen.getByLabelText(/gender/i), 49 | }; 50 | -------------------------------------------------------------------------------- /src/features/characters/CharactersPage/__tests__/characters-page.test.tsx: -------------------------------------------------------------------------------- 1 | import { MOCKS_OVERRIDES } from '@/mocks/handlers'; 2 | import { server } from '@/mocks/server'; 3 | import { createRouter, RootRouter } from '@/router/rootRouter'; 4 | import { appRoutes } from '@/router/routes'; 5 | import { 6 | getByText, 7 | render, 8 | screen, 9 | userEvent, 10 | waitForElementToBeRemoved, 11 | within, 12 | } from '@/utils/tests.utils'; 13 | import { RouterProvider } from 'react-router-dom'; 14 | 15 | describe('Characters Page', () => { 16 | it('renders correctly', async () => { 17 | renderWithProviders(); 18 | expect(await screen.findByText(/Character 1/i)).toBeInTheDocument(); 19 | }); 20 | 21 | it('filters changes listed results', async () => { 22 | renderWithProviders(); 23 | 24 | expect(await screen.findAllByText(/Alive/i)).toBeDefined(); 25 | 26 | const statusFilterSelect = await screen.findByLabelText(/status/i); 27 | await userEvent.click(statusFilterSelect); 28 | 29 | const deadOption = await screen.findByTestId(`select-option Dead`); 30 | await userEvent.click(deadOption); 31 | 32 | await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); 33 | 34 | const charactersCards = queries.charactersItems(); 35 | 36 | charactersCards.forEach((card) => { 37 | const { queryByText, getByText } = within(card); 38 | 39 | expect(queryByText(/alive/i)).not.toBeInTheDocument(); 40 | expect(getByText(/dead/i)).toBeInTheDocument(); 41 | }); 42 | }); 43 | 44 | it('renders empty message', async () => { 45 | server.use(MOCKS_OVERRIDES.characters.getCharacters(() => [])); 46 | renderWithProviders(); 47 | expect(await screen.findByText(/Nothing here to show/i)).toBeInTheDocument(); 48 | }); 49 | 50 | it('renders error message', async () => { 51 | server.use( 52 | MOCKS_OVERRIDES.characters.getCharacters(() => { 53 | throw new Error(); 54 | }), 55 | ); 56 | renderWithProviders(); 57 | expect(await screen.findByText(/error happened/i)).toBeInTheDocument(); 58 | }); 59 | }); 60 | 61 | const renderWithProviders = () => { 62 | render(); 63 | }; 64 | 65 | const queries = { 66 | charactersContainer: () => 67 | screen.getByRole('list', { 68 | name: /characters/i, 69 | }), 70 | charactersItems() { 71 | return within(this.charactersContainer()).getAllByRole('listitem'); 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /src/features/characters/CharactersPage/charactersPageQuery.ts: -------------------------------------------------------------------------------- 1 | import { API } from '@/api'; 2 | import { createLoader } from '@/router/createLoader'; 3 | import { LoaderReturnType } from '@/utils/types/typeUtils'; 4 | 5 | export const charactersPageQuery = (options?: { 6 | filters: Parameters[0]; 7 | }) => ({ 8 | queryKey: ['characters', 'list', { filters: options?.filters }], 9 | queryFn: () => API.characters.getCharacters(options?.filters), 10 | }); 11 | 12 | export type LoaderData = LoaderReturnType; 13 | 14 | export const charactersPageLoader = createLoader(() => charactersPageQuery()); 15 | -------------------------------------------------------------------------------- /src/features/episodes/EpisodeDetailsModal.tsx: -------------------------------------------------------------------------------- 1 | import ReactModal from 'react-modal'; 2 | import { useLoaderData, useNavigate, useParams } from 'react-router-dom'; 3 | import { episodeDetailsQuery, LoaderData } from './episodeDetailsQuery'; 4 | import { useQuery } from '@tanstack/react-query'; 5 | 6 | export default function EpisodeDetailsModal() { 7 | const data = useLoaderData() as LoaderData; 8 | const params = useParams(); 9 | const navigate = useNavigate(); 10 | 11 | const query = useQuery({ 12 | ...episodeDetailsQuery(Number(params.episodeId)), 13 | initialData: data, 14 | }); 15 | 16 | return ( 17 | navigate('..', { relative: 'path' })} 20 | overlayClassName='bg-gray-500 bg-opacity-70 fixed inset-0' 21 | className='bg-gray-800 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 p-24 rounded' 22 | > 23 |

{query.data.name}

24 |

{query.data.episode}

25 |

{query.data.air_date}

26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/features/episodes/EpisodeDetailsPage.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLoaderData, useParams } from 'react-router-dom'; 2 | import { episodeDetailsQuery, LoaderData } from './episodeDetailsQuery'; 3 | import { useQuery } from '@tanstack/react-query'; 4 | 5 | export default function EpisodeDetailsPage() { 6 | const data = useLoaderData() as LoaderData; 7 | const params = useParams(); 8 | 9 | const query = useQuery({ 10 | ...episodeDetailsQuery(Number(params.episodeId)), 11 | initialData: data, 12 | }); 13 | 14 | console.log(data); 15 | 16 | if (!query.data) return

404

; 17 | 18 | return ( 19 |
20 | 21 | ⬅️ Back 22 | 23 |

{query.data.name}

24 |

{query.data.episode}

25 |

{query.data.air_date}

26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/features/episodes/EpisodesPage.tsx: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Suspense } from 'react'; 3 | import { 4 | Await, 5 | Link, 6 | matchPath, 7 | matchRoutes, 8 | Outlet, 9 | useLoaderData, 10 | useLocation, 11 | } from 'react-router-dom'; 12 | import EpisodeDetailsModal from './EpisodeDetailsModal'; 13 | import EpisodeDetailsPage from './EpisodeDetailsPage'; 14 | import { LoaderData } from './episodesPageQuery'; 15 | 16 | export default function EpisodesPageWrapper() { 17 | const { pathname, state } = useLocation(); 18 | 19 | const onDetailsPage = isOnDetailsPage(pathname); 20 | const openAsModal = state?.openModal; 21 | 22 | const whatToShow = onDetailsPage ? (openAsModal ? 'list + modal' : 'details_page') : 'list_page'; 23 | 24 | if (whatToShow === 'list + modal') 25 | return ( 26 | <> 27 | 28 | 29 | 30 | ); 31 | 32 | if (whatToShow === 'details_page') return ; 33 | 34 | if (whatToShow === 'list_page') return ; 35 | 36 | throw Error('URL invalid. Please go back to the episodes page & try again.'); 37 | } 38 | 39 | function EpisodesListPage() { 40 | const { data } = useLoaderData() as LoaderData; 41 | 42 | return ( 43 |
44 |

Episodes

45 | Loading episodes (deferred)...

48 | } 49 | > 50 | 51 | {(resolved: typeof data) => ( 52 |
    53 | {resolved.results?.map((episode) => ( 54 |
  • 55 | 63 |
    64 |

    {episode.name}

    65 |

    {episode.episode}

    66 |

    67 | {episode.air_date} 68 |

    69 | 72 |
    73 | 74 |
  • 75 | ))} 76 |
77 | )} 78 |
79 |
80 |
81 | ); 82 | } 83 | 84 | function isOnDetailsPage(pathname: string) { 85 | return matchPath('/episodes/:episodeId', pathname); 86 | } 87 | -------------------------------------------------------------------------------- /src/features/episodes/episodeDetailsQuery.ts: -------------------------------------------------------------------------------- 1 | import { API } from '@/api'; 2 | import { createLoader } from '@/router/createLoader'; 3 | import { LoaderReturnType } from '@/utils/types/typeUtils'; 4 | 5 | export const episodeDetailsQuery = (id: number) => ({ 6 | queryKey: ['episode', id], 7 | queryFn: () => API.episodes.getEpisodeById(id), 8 | }); 9 | 10 | export type LoaderData = LoaderReturnType; 11 | 12 | export const episodeDetailsLoader = createLoader(({ params }) => 13 | episodeDetailsQuery(Number(params.episodeId)), 14 | ); 15 | -------------------------------------------------------------------------------- /src/features/episodes/episodesPageQuery.ts: -------------------------------------------------------------------------------- 1 | import { API } from '@/api'; 2 | import { createDeferredLoader, createLoader } from '@/router/createLoader'; 3 | import { DeferredLoaderReturnType } from '@/utils/types/typeUtils'; 4 | 5 | export const episodesPageQuery = () => ({ 6 | queryKey: ['episodes'], 7 | queryFn: API.episodes.getAllEpisodes, 8 | }); 9 | 10 | export type LoaderData = DeferredLoaderReturnType; 11 | 12 | export const episodesPageLoader = createDeferredLoader(() => episodesPageQuery()); 13 | -------------------------------------------------------------------------------- /src/features/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function HomePage() { 4 | return
Home Page
; 5 | } 6 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 7 | font-size: 16px; 8 | line-height: 24px; 9 | font-weight: 400; 10 | 11 | color-scheme: light dark; 12 | color: rgba(255, 255, 255, 0.87); 13 | background-color: theme(colors.gray.700); 14 | 15 | font-synthesis: none; 16 | text-rendering: optimizeLegibility; 17 | -webkit-font-smoothing: antialiased; 18 | -moz-osx-font-smoothing: grayscale; 19 | -webkit-text-size-adjust: 100%; 20 | } 21 | 22 | html { 23 | scrollbar-gutter: stable; 24 | } 25 | 26 | body { 27 | /* background-color: theme(colors.blue.500); */ 28 | margin: 0; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import './index.css'; 5 | import { Wrapper } from './utils/wrapper'; 6 | 7 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 8 | 9 | 10 | 11 | 12 | , 13 | ); 14 | -------------------------------------------------------------------------------- /src/mocks/handlers/characters.mockHandlers.ts: -------------------------------------------------------------------------------- 1 | import { graphql, rest } from 'msw'; 2 | import { Character } from 'rickmortyapi/dist/interfaces'; 3 | import { createOverrideHandler, wrapWithInfo } from '../helpers/utils'; 4 | import { API_ROUTES } from '@/api'; 5 | 6 | // Mock Data 7 | const characters: Partial[] = [ 8 | { 9 | id: 1, 10 | name: 'Character 1', 11 | gender: 'Male', 12 | status: 'Alive', 13 | }, 14 | { 15 | id: 2, 16 | name: 'Character 2', 17 | gender: 'Female', 18 | status: 'Dead', 19 | }, 20 | { 21 | id: 3, 22 | name: 'Character 3', 23 | gender: 'Genderless', 24 | status: 'unknown', 25 | }, 26 | { 27 | id: 4, 28 | name: 'Character 4', 29 | gender: 'unknown', 30 | status: 'Alive', 31 | }, 32 | { 33 | id: 5, 34 | name: 'Character 5', 35 | gender: 'Male', 36 | status: 'Dead', 37 | }, 38 | { 39 | id: 6, 40 | name: 'Character 6', 41 | gender: 'Female', 42 | status: 'unknown', 43 | }, 44 | { 45 | id: 7, 46 | name: 'Character 7', 47 | gender: 'Genderless', 48 | status: 'Alive', 49 | }, 50 | ]; 51 | 52 | export const charactersApiHandlers = [ 53 | rest.get(API_ROUTES.getCharacters, (req, res, ctx) => { 54 | const statusFilter = req.url.searchParams.get('status'); 55 | const genderFilter = req.url.searchParams.get('gender'); 56 | 57 | const filteredItems = characters 58 | .filter((item) => 59 | statusFilter ? item.status?.toLowerCase() === statusFilter.toLowerCase() : true, 60 | ) 61 | .filter((item) => 62 | genderFilter ? item.gender?.toLowerCase() === genderFilter.toLowerCase() : true, 63 | ); 64 | 65 | return res(ctx.status(200), ctx.json(wrapWithInfo(filteredItems))); 66 | }), 67 | ]; 68 | 69 | export const charactersOverrides = { 70 | getCharacters: createOverrideHandler[]>('get', API_ROUTES.getCharacters, { 71 | wrapResponse: wrapWithInfo, 72 | }), 73 | }; 74 | -------------------------------------------------------------------------------- /src/mocks/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { charactersApiHandlers, charactersOverrides } from './characters.mockHandlers'; 2 | 3 | export const handlers = [...charactersApiHandlers]; 4 | 5 | export const MOCKS_OVERRIDES = { 6 | characters: charactersOverrides, 7 | }; 8 | -------------------------------------------------------------------------------- /src/mocks/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | export function wrapWithInfo(data: T[]) { 4 | return { 5 | info: { 6 | count: data.length, 7 | pages: 1, 8 | next: null, 9 | prev: null, 10 | }, 11 | results: data, 12 | }; 13 | } 14 | 15 | export const createOverrideHandler = 16 | | object>( 17 | request: keyof typeof rest, 18 | url: string, 19 | options?: { wrapResponse?: (res: any) => any }, 20 | ) => 21 | (mockFn: () => T) => 22 | rest[request](url, (req, res, ctx) => { 23 | try { 24 | const result = mockFn(); 25 | return res( 26 | ctx.status(200), 27 | ctx.json(options?.wrapResponse ? options?.wrapResponse(result) : result), 28 | ); 29 | } catch (error) { 30 | const status = (error as any).status ?? 500; 31 | const data = (error as any).data ?? ''; 32 | 33 | return res( 34 | ctx.status(status), 35 | ctx.json({ 36 | error: data, 37 | }), 38 | ); 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /src/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | import { handlers } from './handlers'; 3 | 4 | export const server = setupServer(...handlers); 5 | -------------------------------------------------------------------------------- /src/router/createLoader.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryFunction } from '@tanstack/react-query'; 2 | import { defer, LoaderFunctionArgs } from 'react-router-dom'; 3 | 4 | export function createLoader( 5 | createQueryFn: (args: LoaderFunctionArgs) => { 6 | queryKey: unknown[]; 7 | queryFn: QueryFunction; 8 | }, 9 | ) { 10 | return (queryClient: QueryClient) => async (args: LoaderFunctionArgs) => { 11 | const query = createQueryFn(args); 12 | // ⬇️ return data or fetch it 13 | return queryClient.getQueryData(query.queryKey) ?? (await queryClient.fetchQuery(query)); 14 | }; 15 | } 16 | 17 | export function createDeferredLoader( 18 | createQueryFn: (args: LoaderFunctionArgs) => { 19 | queryKey: unknown[]; 20 | queryFn: QueryFunction; 21 | }, 22 | ) { 23 | return (queryClient: QueryClient) => async (args: LoaderFunctionArgs) => { 24 | const query = createQueryFn(args); 25 | // ⬇️ return data or fetch it 26 | return defer({ 27 | data: queryClient.getQueryData(query.queryKey) ?? queryClient.fetchQuery(query), 28 | }); 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/router/lazyRoutesComponents.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /src/router/rootRouter.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Route, 3 | createRoutesFromElements, 4 | createBrowserRouter, 5 | RouterProvider, 6 | useLocation, 7 | matchRoutes, 8 | Navigate, 9 | createMemoryRouter, 10 | } from 'react-router-dom'; 11 | import React, { useState } from 'react'; 12 | import AppLayout from '../Common/AppLayout/AppLayout'; 13 | import { getQueryClient, queryClient } from '@/utils/apiClient'; 14 | import { characterDetailsLoader } from '@/features/characters/CharacterDetailsPage/characterDetailsQuery'; 15 | import { charactersPageLoader } from '@/features/characters/CharactersPage/charactersPageQuery'; 16 | import { episodeDetailsLoader } from '@/features/episodes/episodeDetailsQuery'; 17 | import { episodesPageLoader } from '@/features/episodes/episodesPageQuery'; 18 | import ErrorOverlay from '@/Common/ErrorOverlay/ErrorOverlay'; 19 | import { CONSTS } from '@/utils/consts'; 20 | import { QueryClient, useQueryClient } from '@tanstack/react-query'; 21 | import SignInPage from '@/features/auth/SignInPage/SignInPage'; 22 | import AuthLayout from '@/features/auth/AuthLayout/AuthLayout'; 23 | import SignUpPage from '@/features/auth/SignupPage/SignupPage'; 24 | import VerifyEmail from '@/features/auth/VerifyEmailPage/VerifyEmailPage'; 25 | 26 | const HomePage = React.lazy(() => import('../features/home/HomePage')); 27 | 28 | const CharactersPage = React.lazy( 29 | () => import('../features/characters/CharactersPage/CharactersPage'), 30 | ); 31 | const CharacterDetailsPage = React.lazy( 32 | () => import('../features/characters/CharacterDetailsPage/CharacterDetailsPage'), 33 | ); 34 | 35 | const EpisodesPage = React.lazy(() => import('../features/episodes/EpisodesPage')); 36 | const EpisodeDetailsPage = React.lazy(() => import('../features/episodes/EpisodeDetailsPage')); 37 | 38 | const createRoutes = (queryClient: QueryClient) => 39 | createRoutesFromElements( 40 | } 42 | errorElement={ 43 | 47 | } 48 | > 49 | } /> 50 | } 53 | > 54 | } 57 | loader={characterDetailsLoader(queryClient)} 58 | /> 59 | } loader={charactersPageLoader(queryClient)} /> 60 | 61 | 62 | } 65 | loader={episodesPageLoader(queryClient)} 66 | errorElement={} 67 | > 68 | } 71 | loader={episodeDetailsLoader(queryClient)} 72 | /> 73 | 74 | }> 75 | } /> 76 | } /> 77 | } /> 78 | 79 | , 80 | ); 81 | 82 | type CreateRouterOptions = Parameters[1]; 83 | 84 | export const createRouter = (client: QueryClient, options?: CreateRouterOptions) => { 85 | const routes = createRoutes(client); 86 | 87 | return CONSTS.isTestEnv 88 | ? createMemoryRouter(routes, options) 89 | : createBrowserRouter(routes, options); 90 | }; 91 | 92 | export const RootRouter = (props: CreateRouterOptions) => { 93 | const client = useQueryClient(); 94 | const [router] = useState(() => createRouter(client, { initialEntries: props?.initialEntries })); 95 | 96 | return ; 97 | }; 98 | -------------------------------------------------------------------------------- /src/router/routes.ts: -------------------------------------------------------------------------------- 1 | export const appRoutes = { 2 | charactersPage: '/characters', 3 | characterDetailsPage: (id: number) => { 4 | return `/characters/${id}`; 5 | }, 6 | episodesPage: '/episodes', 7 | episodeDetailsPage: (id: number) => { 8 | return `/episodes/${id}`; 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/services/firebase/auth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createUserWithEmailAndPassword, 3 | getAuth, 4 | GoogleAuthProvider, 5 | signInWithEmailAndPassword, 6 | signInWithPopup, 7 | signOut, 8 | User, 9 | } from 'firebase/auth'; 10 | import { getFirestore, query, getDocs, collection, where, addDoc } from 'firebase/firestore'; 11 | import { useEffect, useState } from 'react'; 12 | import { firebaseApp } from '.'; 13 | 14 | export const auth = getAuth(firebaseApp); 15 | // const db = getFirestore(firebaseApp); 16 | 17 | const googleProvider = new GoogleAuthProvider(); 18 | 19 | export const signInWithGoogle = async () => { 20 | try { 21 | const res = await signInWithPopup(auth, googleProvider); 22 | const user = res.user; 23 | // const q = query(collection(db, 'users'), where('uid', '==', user.uid)); 24 | // const docs = await getDocs(q); 25 | // if (docs.docs.length === 0) { 26 | // await addDoc(collection(db, 'users'), { 27 | // uid: user.uid, 28 | // name: user.displayName, 29 | // authProvider: 'google', 30 | // email: user.email, 31 | // }); 32 | // } 33 | console.log(user); 34 | } catch (err: any) { 35 | console.error(err); 36 | alert(err.message); 37 | } 38 | }; 39 | 40 | export const logInWithEmailAndPassword = async (email: string, password: string) => { 41 | try { 42 | const res = await signInWithEmailAndPassword(auth, email, password); 43 | return res.user; 44 | } catch (err: any) { 45 | console.error(err); 46 | alert(err.message); 47 | } 48 | }; 49 | 50 | export const logout = () => { 51 | signOut(auth); 52 | }; 53 | 54 | export const registerWithEmailAndPassword = async ( 55 | name: string, 56 | email: string, 57 | password: string, 58 | ) => { 59 | try { 60 | const res = await createUserWithEmailAndPassword(auth, email, password); 61 | return res.user; 62 | } catch (err: any) { 63 | console.error(err); 64 | alert(err.message); 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /src/services/firebase/index.ts: -------------------------------------------------------------------------------- 1 | // Import the functions you need from the SDKs you need 2 | import { initializeApp } from 'firebase/app'; 3 | // TODO: Add SDKs for Firebase products that you want to use 4 | // https://firebase.google.com/docs/web/setup#available-libraries 5 | 6 | // Your web app's Firebase configuration 7 | const firebaseConfig = { 8 | apiKey: 'AIzaSyBLLd_nqshblYWkdxqSuyi61OBAc1YHjI8', 9 | authDomain: 'my-first-project-df58c.firebaseapp.com', 10 | projectId: 'my-first-project-df58c', 11 | storageBucket: 'my-first-project-df58c.appspot.com', 12 | messagingSenderId: '314784776968', 13 | appId: '1:314784776968:web:7bb5742ff8bdcaf3a162bd', 14 | }; 15 | 16 | // Initialize Firebase 17 | export const firebaseApp = initializeApp(firebaseConfig); 18 | -------------------------------------------------------------------------------- /src/utils/apiClient.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientConfig } from '@tanstack/react-query'; 2 | import { CONSTS } from './consts'; 3 | 4 | export let queryClient: QueryClient; 5 | 6 | export const getQueryClient = () => { 7 | return queryClient; 8 | }; 9 | 10 | const testingConfig: QueryClientConfig = { 11 | defaultOptions: { 12 | queries: { 13 | retry: false, 14 | }, 15 | }, 16 | logger: { 17 | log: console.log, 18 | warn: console.warn, 19 | // ✅ no more errors on the console 20 | // eslint-disable-next-line @typescript-eslint/no-empty-function 21 | error: () => {}, 22 | }, 23 | }; 24 | 25 | export const createQueryClient = () => 26 | (queryClient = new QueryClient(CONSTS.isTestEnv ? testingConfig : {})); 27 | createQueryClient(); 28 | -------------------------------------------------------------------------------- /src/utils/consts.ts: -------------------------------------------------------------------------------- 1 | export const CONSTS = { 2 | isTestEnv: import.meta.env.VITEST, 3 | }; 4 | -------------------------------------------------------------------------------- /src/utils/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './misc'; 2 | export * from './withProviders'; 3 | -------------------------------------------------------------------------------- /src/utils/helpers/misc.ts: -------------------------------------------------------------------------------- 1 | import { CONSTS } from '../consts'; 2 | 3 | export function removeUndefinedFromObject>(obj?: T) { 4 | if (!obj) return undefined; 5 | const result: Partial = { ...obj }; 6 | Object.keys(result).forEach((key) => (result[key] === undefined ? delete result[key] : {})); 7 | return result; 8 | } 9 | 10 | export const delay = (ms = 2000) => 11 | new Promise((res) => setTimeout(res, CONSTS.isTestEnv ? 0 : ms)); 12 | -------------------------------------------------------------------------------- /src/utils/helpers/withProviders.tsx: -------------------------------------------------------------------------------- 1 | export function withProviders(...providers: React.FC[]) { 2 | return (WrappedComponent: React.ComponentType) => (props: T) => 3 | providers.reduceRight((acc, Provider) => { 4 | return {acc}; 5 | }, ); 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/tests.utils.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from 'react'; 2 | import { render, RenderOptions } from '@testing-library/react'; 3 | import { Wrapper } from './wrapper'; 4 | 5 | const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => { 6 | return {children}; 7 | }; 8 | 9 | const customRender = (ui: ReactElement, options?: Omit) => { 10 | return render(ui, { wrapper: AllTheProviders, ...options }); 11 | }; 12 | 13 | export * from '@testing-library/react'; 14 | export { default as userEvent } from '@testing-library/user-event'; 15 | // override render export 16 | export { customRender as render }; 17 | -------------------------------------------------------------------------------- /src/utils/types/typeUtils.ts: -------------------------------------------------------------------------------- 1 | import { QueryFunction } from '@tanstack/react-query'; 2 | 3 | type queryCreatorFunction = (...params: any) => { 4 | queryKey: any; 5 | queryFn: QueryFunction; 6 | }; 7 | 8 | export type LoaderReturnType = Awaited< 9 | ReturnType['queryFn']> 10 | >; 11 | 12 | export type DeferredLoaderReturnType { queryFn: QueryFunction }> = { 13 | data: Awaited['queryFn']>>; 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/wrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { QueryClientProvider } from '@tanstack/react-query'; 3 | import { createQueryClient } from './apiClient'; 4 | import { UserContextProvider } from '@/contexts/userContext'; 5 | 6 | export const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { 7 | const [queryClient] = useState(() => createQueryClient()); 8 | 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | 4 | content: [ 5 | "./index.html", 6 | "./src/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | extend: { 10 | colors:{ 11 | primary:{ 12 | dark: "#fe2221", 13 | light:"#fe2221", 14 | normal:"#fe2221", 15 | } 16 | }, 17 | boxShadow: { 18 | xs: "0px 1px 2px rgba(16, 24, 40, 0.05)", 19 | sm: 20 | "0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06)", 21 | DEFAULT: 22 | "0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06)", 23 | md: 24 | "0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06)", 25 | lg: 26 | "0px 12px 16px -4px rgba(16, 24, 40, 0.1), 0px 4px 6px -2px rgba(16, 24, 40, 0.05)", 27 | xl: 28 | " 0px 20px 24px -4px rgba(16, 24, 40, 0.1), 0px 8px 8px -4px rgba(16, 24, 40, 0.04)", 29 | "2xl": "0px 24px 48px -12px rgba(16, 24, 40, 0.25)", 30 | "3xl": "0px 32px 64px -12px rgba(16, 24, 40, 0.2)", 31 | inner: "inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)", 32 | none: "none", 33 | }, 34 | fontSize: { 35 | h1: ["36px", "50px"], 36 | h2: ["32px", "44px"], 37 | h3: ["28px", "40px"], 38 | h4: ["22px", "31px"], 39 | h5: ["19px", "26px"], 40 | body1: ["24px", "30px"], 41 | body2: ["20px", "28px"], 42 | body3: ["18px", "25px"], 43 | body4: ["16px", "22px"], 44 | body5: ["14px", "19px"], 45 | body6: ["12px", "18px"], 46 | }, 47 | fontFamily: { 48 | sans: ["Inter", "sans-serif"], 49 | }, 50 | 51 | fontWeight: { 52 | light: 400, 53 | regular: 500, 54 | bold: 600, 55 | bolder: 700, 56 | }, 57 | 58 | spacing: { 59 | 4: "4px", 60 | 8: "8px", 61 | 10: "10px", 62 | 12: "12px", 63 | 14: "14px", 64 | 16: "16px", 65 | 20: "20px", 66 | 24: "24px", 67 | 32: "32px", 68 | 36: "36px", 69 | 40: "40px", 70 | 42: "42px", 71 | 48: "48px", 72 | 52: "52px", 73 | 64: "64px", 74 | 80: "80px", 75 | }, 76 | 77 | borderRadius: { 78 | 0: "0", 79 | 4: "4px", 80 | 8: "8px", 81 | 10: "10px", 82 | 12: "12px", 83 | DEFAULT: "12px", 84 | 16: "16px", 85 | 20: "20px", 86 | 24: "24px", 87 | 48: "48px", 88 | full: "50%", 89 | }, 90 | lineHeight: { 91 | 'inherit': "inherit", 92 | 0: '0' 93 | }, 94 | outline: { 95 | primary: ["2px solid #7B61FF", "1px"], 96 | }, 97 | }, 98 | }, 99 | plugins: [], 100 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | } 21 | }, 22 | "include": ["src"], 23 | "references": [{ "path": "./tsconfig.node.json" }] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import { defineConfig } from 'vite'; 5 | import react from '@vitejs/plugin-react'; 6 | import * as path from 'path'; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [react()], 11 | resolve: { 12 | alias: [{ find: '@', replacement: path.resolve(__dirname, 'src') }], 13 | }, 14 | test: { 15 | css: false, 16 | globals: true, 17 | environment: 'jsdom', 18 | setupFiles: ['./vitest/setupTests.js'], 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /vitest/setupTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { expect, afterEach } from 'vitest'; 3 | import matchers from '@testing-library/jest-dom/matchers'; 4 | import { server } from '../src/mocks/server'; 5 | import { fetch } from 'cross-fetch'; 6 | 7 | // extends Vitest's expect method with methods from react-testing-library 8 | expect.extend(matchers); 9 | 10 | global.fetch = fetch; 11 | 12 | beforeAll(() => { 13 | server.listen({ onUnhandledRequest: 'error' }); 14 | }); 15 | 16 | afterAll(() => server.close()); 17 | 18 | afterEach(() => { 19 | server.resetHandlers(); 20 | }); 21 | --------------------------------------------------------------------------------