├── .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 |
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 |
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 |
77 |
78 |
79 |
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 |
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 |
--------------------------------------------------------------------------------