├── .eslintrc ├── .github └── FUNDING.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── LICENSE ├── README.md ├── assets └── images │ └── react-clean-architecture-banner.png ├── commitlint.config.js ├── env └── .env.production ├── index.html ├── package.json ├── public ├── favicon.ico └── fonts │ ├── Roboto-Black.ttf │ ├── Roboto-Bold.ttf │ ├── Roboto-Light.ttf │ ├── Roboto-Medium.ttf │ ├── Roboto-Regular.ttf │ └── Roboto-Thin.ttf ├── src ├── @types │ └── env.d.ts ├── AppModule.ts ├── core │ ├── CoreModule.ts │ ├── application │ │ └── UseCase.ts │ ├── domain │ │ ├── EnvToken.ts │ │ ├── enums │ │ │ └── Locale.ts │ │ └── specifications │ │ │ └── IHttpClient.ts │ ├── infrastructure │ │ ├── implementations │ │ │ └── HttpClient.ts │ │ └── models │ │ │ ├── PayloadDto.ts │ │ │ └── ResponseDto.ts │ └── presentation │ │ ├── @types │ │ └── i18next.d.ts │ │ ├── App.tsx │ │ ├── components │ │ ├── AppOverlay.tsx │ │ ├── Button.tsx │ │ ├── Card.tsx │ │ ├── Dialog.tsx │ │ ├── EmptyList.tsx │ │ ├── IconButton.tsx │ │ ├── Loading.tsx │ │ ├── NumberInput.tsx │ │ ├── Select.tsx │ │ ├── Separator.tsx │ │ ├── Switch.tsx │ │ ├── Tabs.tsx │ │ ├── TextInput.tsx │ │ └── Tooltip.tsx │ │ ├── hooks │ │ ├── useContextStore.ts │ │ ├── useEffectOnce.ts │ │ └── useLocaleOptions.ts │ │ ├── i18n │ │ ├── en │ │ │ ├── core.ts │ │ │ └── index.ts │ │ └── index.ts │ │ ├── navigation │ │ └── router.tsx │ │ ├── pages │ │ └── NotFoundPage.tsx │ │ ├── services │ │ └── ToastService.ts │ │ ├── styles │ │ └── index.css │ │ ├── types │ │ ├── BaseDialogProps.ts │ │ ├── BaseFormProps.ts │ │ ├── DialogState.ts │ │ ├── ListState.ts │ │ ├── PaginationState.ts │ │ └── ToastType.ts │ │ └── utils │ │ ├── getObjectFieldErrorMessage.ts │ │ └── withProviders.tsx ├── index.tsx ├── post │ ├── PostModule.ts │ ├── application │ │ ├── types │ │ │ ├── GetPostsPayload.ts │ │ │ └── GetPostsResponse.ts │ │ └── useCases │ │ │ ├── FindPostUseCase.ts │ │ │ └── GetPostsUseCase.ts │ ├── domain │ │ ├── entities │ │ │ └── PostEntity.ts │ │ └── specifications │ │ │ └── IPostRepository.ts │ ├── infrastructure │ │ ├── implementations │ │ │ └── PostRepository.ts │ │ └── models │ │ │ ├── GetPostsQuery.ts │ │ │ └── PostDto.ts │ └── presentation │ │ ├── components │ │ └── PostItem.tsx │ │ ├── i18n │ │ └── en.ts │ │ ├── pages │ │ ├── PostPage.tsx │ │ └── PostsPage.tsx │ │ ├── stores │ │ ├── FindPostStore │ │ │ ├── FindPostStore.ts │ │ │ ├── FindPostStoreContext.ts │ │ │ ├── FindPostStoreProvider.tsx │ │ │ └── useFindPostStore.ts │ │ └── GetPostsStore │ │ │ ├── GetPostsStore.ts │ │ │ ├── GetPostsStoreContext.ts │ │ │ ├── GetPostsStoreProvider.tsx │ │ │ └── useGetPostsStore.ts │ │ └── types │ │ ├── FindPostStoreState.ts │ │ └── GetPostsStoreState.ts └── reportWebVitals.ts ├── tailwind.config.js ├── tsconfig.json ├── vite.config.js └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@carlossalasamper/eslint-config", 4 | "plugin:react/recommended", 5 | "plugin:react-hooks/recommended" 6 | ], 7 | "rules": { 8 | "react/react-in-jsx-scope": "off" 9 | }, 10 | "settings": { 11 | "react": { 12 | "version": "detect" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [carlossalasamper] 2 | buy_me_a_coffee: carlossala95 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .npmrc 4 | .env 5 | .yalc 6 | yalc.lock 7 | yarn-error.log -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn run lint --fix -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Carlos Sala Samper 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Clean Architecture 2 | 3 |

4 | 5 |

6 |

A React scaffold with a clean architecture that is easy to understand.

7 | 8 | ## Features 9 | 10 | - 📁 Clean architecture. Layered file structure 11 | - 🛡️ TypeScript bulletproof typing 12 | - ⚡ Development environment: [Vite](https://vitejs.dev/) 13 | - 🎨 Design System and UI: [Tailwind CSS](https://tailwindcss.com/) + [Headless UI](https://headlessui.com/) 14 | - 🖌️ Code format: [ESLint](https://eslint.org/) 15 | - 🐩 Git hooks: [Husky](https://www.npmjs.com/package/husky) 16 | - 💉 Dependency injection: [Inversiland](https://github.com/inversiland/inversiland) 17 | - 🌍 I18n: [i18next](https://www.i18next.com) 18 | - 🚢 Navigation: [React Router](https://reactrouter.com/en/main) 19 | - 🧰 State Manager: [Mobx](https://mobx.js.org/) 20 | 21 |
22 | 23 | ## 📁 Project File Structure 24 | 25 | > ⚠️ What makes the implementation of the clean architecture concept more difficult in my opinion is that since it is defined theoretically, each person implements it using different terminology or omitting/adding some layers or pieces to simplify it or continue to make it more complex. 26 | 27 | For this reason, I think it is important to emphasize the documentation that accompanies the architecture to avoid obstacles with the rest of the people who are going to work with this system. 28 | 29 | I briefly explain each of the four layers that make up clean architecture within the /src folder: 30 | 31 | ``` 32 | └── /src 33 | ├── AppModule.ts # Dependency injection root module 34 | ├── /core # Core bounded context 35 | │ └── /presentation 36 | └── /post # Post bounded context 37 | ├── /domain 38 | ├── /application 39 | ├── /infrastructure 40 | └── /presentation 41 | ``` 42 | 43 | ### Domain 44 | 45 | This layer contains all the enterprise business rules: entities, specifications... 46 | 47 | ### Application 48 | 49 | This layer contains the use cases of the bounded context. 50 | 51 | ### Infrastructure 52 | 53 | This layer contains the technical details (implementation) of the domain layer and third parties integrations. 54 | 55 | ### Presentation 56 | 57 | This layer contains the React source code: views and controllers (Mobx controllers). 58 | 59 | ### Referencesw 60 | 61 | - https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html 62 | - https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/ 63 | 64 |
65 | 66 | ## Run 67 | 68 | Dev 69 | 70 | ```bash 71 | yarn dev 72 | ``` 73 | 74 | Tailwind dev 75 | 76 | ```bash 77 | yarn tailwindcss:dev 78 | ``` 79 | 80 | Build 81 | 82 | ```bash 83 | yarn build 84 | ``` 85 | 86 | Tailwind build 87 | 88 | ```bash 89 | yarn tailwindcss:build 90 | ``` 91 | 92 |
93 | 94 | ## Support the project 95 | 96 |

☕️ Buy me a coffee so the open source party will never end.

97 | 98 |

Buy Me A Coffee

99 | 100 |

101 | YouTube | 102 | Instagram | 103 | Twitter | 104 | Facebook 105 |

106 |

107 | godofprogramming.com 108 |

109 | -------------------------------------------------------------------------------- /assets/images/react-clean-architecture-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlossalasamper/react-clean-architecture/2c2500aab88e1f6bc4544021c33a9188d2b430b3/assets/images/react-clean-architecture-banner.png -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | }; 4 | -------------------------------------------------------------------------------- /env/.env.production: -------------------------------------------------------------------------------- 1 | VITE_BASE_API_URL="https://jsonplaceholder.typicode.com" -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | React Clean Architecture 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-clean-architecture", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "prepare": "husky install", 6 | "dev": "vite --open", 7 | "build": "vite build && yarn tailwindcss:build", 8 | "lint": "eslint ./src --ext .ts,.tsx --max-warnings 0", 9 | "tailwindcss:build": "npx tailwindcss -i ./src/core/presentation/styles/index.css -o ./dist/tailwind.css", 10 | "tailwindcss:dev": "yarn tailwindcss:build --watch" 11 | }, 12 | "dependencies": { 13 | "@headlessui/react": "1.7.5", 14 | "@hookform/resolvers": "^3.1.1", 15 | "axios": "^1.1.3", 16 | "class-transformer": "^0.5.1", 17 | "classnames": "^2.3.2", 18 | "i18next": "~23.4.0", 19 | "inversiland": "^0.6.0", 20 | "mobx": "^6.10.2", 21 | "mobx-react": "^9.0.1", 22 | "react": "18.0.0", 23 | "react-dom": "18.0.0", 24 | "react-helmet-async": "^1.3.0", 25 | "react-hook-form": "^7.45.2", 26 | "react-i18next": "~13.0.2", 27 | "react-icons": "^4.10.1", 28 | "react-router-dom": "^6.18.0", 29 | "react-toastify": "^9.1.3", 30 | "reflect-metadata": "^0.2.2", 31 | "zod": "^3.21.4", 32 | "zod-i18n-map": "~2.14.0" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.12.9", 36 | "@carlossalasamper/eslint-config": "^0.1.4", 37 | "@commitlint/config-conventional": "^17.7.0", 38 | "@rollup/plugin-alias": "^5.0.0", 39 | "@types/react": "^18.2.25", 40 | "@types/react-dom": "~18.2.7", 41 | "@vitejs/plugin-react": "^4.0.3", 42 | "babel-plugin-module-resolver": "^4.1.0", 43 | "babel-plugin-transform-typescript-metadata": "^0.3.2", 44 | "commitlint": "^17.7.1", 45 | "eslint": "^8.26.0", 46 | "eslint-plugin-react": "^7.33.1", 47 | "eslint-plugin-react-hooks": "^4.6.0", 48 | "husky": "^7.0.4", 49 | "prettier": "^3.1.0", 50 | "tailwindcss": "^3.3.3", 51 | "typescript": "~5.1.6", 52 | "vite": "^4.4.7", 53 | "vite-plugin-checker": "^0.6.1", 54 | "web-vitals": "^3.4.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlossalasamper/react-clean-architecture/2c2500aab88e1f6bc4544021c33a9188d2b430b3/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlossalasamper/react-clean-architecture/2c2500aab88e1f6bc4544021c33a9188d2b430b3/public/fonts/Roboto-Black.ttf -------------------------------------------------------------------------------- /public/fonts/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlossalasamper/react-clean-architecture/2c2500aab88e1f6bc4544021c33a9188d2b430b3/public/fonts/Roboto-Bold.ttf -------------------------------------------------------------------------------- /public/fonts/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlossalasamper/react-clean-architecture/2c2500aab88e1f6bc4544021c33a9188d2b430b3/public/fonts/Roboto-Light.ttf -------------------------------------------------------------------------------- /public/fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlossalasamper/react-clean-architecture/2c2500aab88e1f6bc4544021c33a9188d2b430b3/public/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /public/fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlossalasamper/react-clean-architecture/2c2500aab88e1f6bc4544021c33a9188d2b430b3/public/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlossalasamper/react-clean-architecture/2c2500aab88e1f6bc4544021c33a9188d2b430b3/public/fonts/Roboto-Thin.ttf -------------------------------------------------------------------------------- /src/@types/env.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | interface ImportMetaEnv { 5 | readonly VITE_BASE_API_URL: string; 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv; 10 | } 11 | -------------------------------------------------------------------------------- /src/AppModule.ts: -------------------------------------------------------------------------------- 1 | import { module } from "inversiland"; 2 | import CoreModule from "./core/CoreModule"; 3 | import { PostModule } from "./post/PostModule"; 4 | 5 | @module({ 6 | imports: [CoreModule, PostModule], 7 | }) 8 | export default class AppModule {} 9 | -------------------------------------------------------------------------------- /src/core/CoreModule.ts: -------------------------------------------------------------------------------- 1 | import EnvToken from "./domain/EnvToken"; 2 | import HttpClient from "./infrastructure/implementations/HttpClient"; 3 | import { IHttpClientToken } from "./domain/specifications/IHttpClient"; 4 | import { module } from "inversiland"; 5 | import { ToastService } from "./presentation/services/ToastService"; 6 | 7 | @module({ 8 | providers: [ 9 | { 10 | isGlobal: true, 11 | provide: EnvToken, 12 | useValue: import.meta.env, 13 | }, 14 | { 15 | isGlobal: true, 16 | provide: IHttpClientToken, 17 | useClass: HttpClient, 18 | }, 19 | { 20 | useClass: ToastService, 21 | isGlobal: true, 22 | }, 23 | ], 24 | }) 25 | export default class CoreModule {} 26 | -------------------------------------------------------------------------------- /src/core/application/UseCase.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export interface UseCase< 3 | PayloadType = void, 4 | ResponseType extends Promise = Promise 5 | > { 6 | execute(payload: PayloadType): ResponseType; 7 | } 8 | -------------------------------------------------------------------------------- /src/core/domain/EnvToken.ts: -------------------------------------------------------------------------------- 1 | const EnvToken = Symbol(); 2 | 3 | export default EnvToken; 4 | -------------------------------------------------------------------------------- /src/core/domain/enums/Locale.ts: -------------------------------------------------------------------------------- 1 | enum Locale { 2 | English = "en", 3 | Spanish = "es", 4 | } 5 | 6 | export default Locale; 7 | -------------------------------------------------------------------------------- /src/core/domain/specifications/IHttpClient.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from "axios"; 2 | 3 | export const IHttpClientToken = Symbol(); 4 | 5 | export default interface IHttpClient { 6 | get( 7 | url: string, 8 | config?: AxiosRequestConfig 9 | ): Promise; 10 | 11 | post( 12 | url: string, 13 | data?: DataType, 14 | config?: AxiosRequestConfig 15 | ): Promise; 16 | 17 | patch( 18 | url: string, 19 | data?: DataType, 20 | config?: AxiosRequestConfig 21 | ): Promise; 22 | 23 | delete( 24 | url: string, 25 | config?: AxiosRequestConfig 26 | ): Promise; 27 | } 28 | -------------------------------------------------------------------------------- /src/core/infrastructure/implementations/HttpClient.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from "axios"; 2 | import { inject, injectable } from "inversiland"; 3 | import EnvToken from "../../domain/EnvToken"; 4 | import IHttpClient from "../../domain/specifications/IHttpClient"; 5 | 6 | @injectable() 7 | class HttpClient implements IHttpClient { 8 | private axios: typeof axios; 9 | 10 | constructor(@inject(EnvToken) private readonly env: ImportMetaEnv) { 11 | this.axios = axios; 12 | 13 | axios.interceptors.request.use((requestConfig) => { 14 | requestConfig.baseURL = this.env.VITE_BASE_API_URL; 15 | 16 | // TODO: add authentication 17 | 18 | return requestConfig; 19 | }); 20 | 21 | this.axios.interceptors.response.use(undefined, (err) => { 22 | if (err.response) { 23 | if (err.response.status === 401 || err.response.status === 403) { 24 | // TODO: logout 25 | } 26 | } 27 | 28 | return Promise.reject(err); 29 | }); 30 | } 31 | 32 | public get(url: string, config?: AxiosRequestConfig) { 33 | return this.axios 34 | .get(url, config) 35 | .then((response) => response.data); 36 | } 37 | 38 | public post( 39 | url: string, 40 | data?: DataType, 41 | config?: AxiosRequestConfig 42 | ) { 43 | return this.axios 44 | .post(url, data, config) 45 | .then((response) => response.data); 46 | } 47 | 48 | public patch( 49 | url: string, 50 | data?: DataType, 51 | config?: AxiosRequestConfig 52 | ) { 53 | return this.axios 54 | .patch(url, data, config) 55 | .then((response) => response.data); 56 | } 57 | 58 | public delete(url: string, config?: AxiosRequestConfig) { 59 | return this.axios 60 | .delete(url, config) 61 | .then((response) => response.data); 62 | } 63 | } 64 | 65 | export default HttpClient; 66 | -------------------------------------------------------------------------------- /src/core/infrastructure/models/PayloadDto.ts: -------------------------------------------------------------------------------- 1 | import { instanceToPlain } from "class-transformer"; 2 | 3 | export default abstract class PayloadDto { 4 | /** 5 | * @description Maps the domain entity to the infrastructure layer model. This method is used in the constructor. 6 | * @param payload 7 | */ 8 | abstract transform(payload: ApplicationType): unknown; 9 | 10 | constructor(payload: ApplicationType) { 11 | const props = this.transform(payload); 12 | 13 | Object.assign(this, props); 14 | } 15 | 16 | toPlain() { 17 | return instanceToPlain(this, { excludeExtraneousValues: true }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/core/infrastructure/models/ResponseDto.ts: -------------------------------------------------------------------------------- 1 | export default abstract class ResponseDto { 2 | abstract toDomain(): DomainType; 3 | } 4 | -------------------------------------------------------------------------------- /src/core/presentation/@types/i18next.d.ts: -------------------------------------------------------------------------------- 1 | import en from "@/core/presentation/i18n/en"; 2 | 3 | declare module "i18next" { 4 | interface CustomTypeOptions { 5 | defaultNS: "common"; 6 | resources: typeof en; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/core/presentation/App.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from "react-helmet-async"; 2 | import "react-toastify/dist/ReactToastify.css"; 3 | import AppOverlay from "./components/AppOverlay"; 4 | import { RouterProvider } from "react-router-dom"; 5 | import router from "./navigation/router"; 6 | 7 | function App() { 8 | return ( 9 | <> 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /src/core/presentation/components/AppOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { ToastContainer } from "react-toastify"; 2 | 3 | const AppOverlay = () => { 4 | return ( 5 | <> 6 | 7 | 8 | ); 9 | }; 10 | 11 | export default AppOverlay; 12 | -------------------------------------------------------------------------------- /src/core/presentation/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { ButtonHTMLAttributes } from "react"; 3 | import Loading from "./Loading"; 4 | 5 | interface ButtonProps extends ButtonHTMLAttributes { 6 | styleType?: "primary" | "secondary" | "cancel" | "danger"; 7 | size?: "s" | "m" | "l"; 8 | fullWidth?: boolean; 9 | loading?: boolean; 10 | children?: React.ReactNode; 11 | } 12 | 13 | const Button = ({ 14 | styleType = "primary", 15 | size = "m", 16 | loading, 17 | fullWidth, 18 | children, 19 | className, 20 | ...props 21 | }: ButtonProps) => { 22 | const disabled = props.disabled || loading; 23 | 24 | return ( 25 | 50 | ); 51 | }; 52 | 53 | export default Button; 54 | -------------------------------------------------------------------------------- /src/core/presentation/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | interface StatsProps { 4 | children: React.ReactNode; 5 | size?: "s" | "m" | "l"; 6 | variant?: "border" | "shadow"; 7 | } 8 | 9 | const Card = ({ children, size = "m", variant = "border" }: StatsProps) => { 10 | return ( 11 |
20 | {children} 21 |
22 | ); 23 | }; 24 | 25 | export default Card; 26 | -------------------------------------------------------------------------------- /src/core/presentation/components/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog as HeadlessUIDialog, Transition } from "@headlessui/react"; 2 | import { Fragment, ReactNode } from "react"; 3 | import BaseDialogProps from "../types/BaseDialogProps"; 4 | 5 | interface DialogProps extends BaseDialogProps { 6 | children: ReactNode; 7 | } 8 | 9 | const Dialog = (props: DialogProps) => { 10 | return ( 11 | 12 | 17 | 26 |
27 | 28 | 29 |
30 |
31 | 40 | 41 | {props.title && ( 42 | 46 | {props.title} 47 | 48 | )} 49 |
{props.children}
50 |
51 |
52 |
53 |
54 | 55 | 56 | ); 57 | }; 58 | 59 | export default Dialog; 60 | -------------------------------------------------------------------------------- /src/core/presentation/components/EmptyList.tsx: -------------------------------------------------------------------------------- 1 | import { MdSearchOff } from "react-icons/md"; 2 | 3 | interface EmptyListProps { 4 | message: string; 5 | } 6 | 7 | const EmptyList = ({ message }: EmptyListProps) => { 8 | return ( 9 |
10 | 11 | {message} 12 |
13 | ); 14 | }; 15 | 16 | export default EmptyList; 17 | -------------------------------------------------------------------------------- /src/core/presentation/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { MouseEvent } from "react"; 3 | import { IconType } from "react-icons/lib"; 4 | 5 | interface IconButtonProps { 6 | styleType?: "primary" | "secondary" | "transparent"; 7 | size?: "s" | "m" | "l"; 8 | onClick: (e: MouseEvent) => void; 9 | className?: string; 10 | disabled?: boolean; 11 | IconComponent: IconType; 12 | } 13 | 14 | const IconButton = ({ 15 | styleType = "primary", 16 | size = "m", 17 | className, 18 | onClick, 19 | disabled, 20 | IconComponent, 21 | }: IconButtonProps) => { 22 | return ( 23 | 42 | ); 43 | }; 44 | 45 | export default IconButton; 46 | -------------------------------------------------------------------------------- /src/core/presentation/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { CgSpinnerTwoAlt } from "react-icons/cg"; 3 | 4 | interface LoadingProps { 5 | size?: number; 6 | className?: string; 7 | } 8 | 9 | const Loading = ({ className, size = 24 }: LoadingProps) => { 10 | return ( 11 | 15 | ); 16 | }; 17 | 18 | export default Loading; 19 | -------------------------------------------------------------------------------- /src/core/presentation/components/NumberInput.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { InputHTMLAttributes, forwardRef, useState } from "react"; 3 | import { MdErrorOutline } from "react-icons/md"; 4 | 5 | type NumberInputProps = Omit, "type"> & { 6 | error?: string; 7 | label?: string; 8 | hideErrorMessage?: boolean; 9 | PrependIconComponent?: React.ComponentType<{ className?: string }>; 10 | AppendIconComponent?: React.ComponentType<{ className?: string }>; 11 | onChange?: (value: number) => void; 12 | }; 13 | 14 | const NumberInput = forwardRef( 15 | ( 16 | { 17 | hideErrorMessage, 18 | error, 19 | PrependIconComponent, 20 | AppendIconComponent, 21 | label, 22 | ...props 23 | }, 24 | ref 25 | ) => { 26 | const [isFocused, setIsFocused] = useState(false); 27 | 28 | return ( 29 |
30 | {label && } 31 | 32 |
40 | {PrependIconComponent && ( 41 | 42 | )} 43 | { 47 | props.onChange?.(Number(e.target.value)); 48 | }} 49 | onFocus={(e) => { 50 | setIsFocused(true); 51 | props.onFocus?.(e); 52 | }} 53 | onBlur={(e) => { 54 | setIsFocused(false); 55 | props.onBlur?.(e); 56 | }} 57 | className={classNames( 58 | "bg-transparent py-3 w-full block rounded-md outline-none", 59 | props.disabled && "text-gray-500" 60 | )} 61 | type="number" 62 | /> 63 | 64 | {AppendIconComponent && ( 65 | 66 | )} 67 |
68 | 69 |
75 | 76 | {error} 77 |
78 |
79 | ); 80 | } 81 | ); 82 | 83 | NumberInput.displayName = "TextInput"; 84 | 85 | export default NumberInput; 86 | -------------------------------------------------------------------------------- /src/core/presentation/components/Select.tsx: -------------------------------------------------------------------------------- 1 | import { SelectHTMLAttributes, forwardRef, useState } from "react"; 2 | import classNames from "classnames"; 3 | import { MdErrorOutline, MdExpandMore } from "react-icons/md"; 4 | 5 | interface SelectProps extends SelectHTMLAttributes { 6 | options: { value: string; label: string }[]; 7 | error?: string; 8 | label?: string; 9 | hideErrorMessage?: boolean; 10 | } 11 | 12 | const Select = forwardRef( 13 | ({ className, options, error, hideErrorMessage, label, ...props }, ref) => { 14 | const [isFocused, setIsFocused] = useState(false); 15 | 16 | return ( 17 |
18 | {label && } 19 | 20 |
26 | 49 | 50 | 51 |
52 | 53 |
59 | 60 | {error} 61 |
62 |
63 | ); 64 | } 65 | ); 66 | 67 | Select.displayName = "Select"; 68 | 69 | export default Select; 70 | -------------------------------------------------------------------------------- /src/core/presentation/components/Separator.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | interface SeparatorProps { 4 | className?: string; 5 | inverse?: boolean; 6 | } 7 | const Separator = ({ className, inverse = false }: SeparatorProps) => { 8 | return ( 9 |
16 | ); 17 | }; 18 | 19 | export default Separator; 20 | -------------------------------------------------------------------------------- /src/core/presentation/components/Switch.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { Switch as HeadlessSwitch } from "@headlessui/react"; 3 | import "jsoneditor-react/es/editor.min.css"; 4 | import { MdErrorOutline } from "react-icons/md"; 5 | import { forwardRef } from "react"; 6 | 7 | interface SwitchProps { 8 | value: boolean; 9 | onChange: (value: boolean) => void; 10 | hideErrorMessage?: boolean; 11 | error?: string; 12 | label?: string; 13 | className?: string; 14 | } 15 | 16 | const Switch = forwardRef( 17 | ({ value, onChange, hideErrorMessage, error, label, className }, _ref) => { 18 | return ( 19 |
20 | {label && } 21 | 22 | 28 | 34 | 35 |
41 | 42 | {error} 43 |
44 |
45 | ); 46 | } 47 | ); 48 | 49 | Switch.displayName = "Switch"; 50 | 51 | export default Switch; 52 | -------------------------------------------------------------------------------- /src/core/presentation/components/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { Tab } from "@headlessui/react"; 2 | import classNames from "classnames"; 3 | import { Fragment, ReactNode } from "react"; 4 | import { IconType } from "react-icons/lib"; 5 | 6 | interface TabsProps { 7 | tabs: { 8 | key: string; 9 | label: string; 10 | IconComponent: IconType; 11 | render: () => ReactNode; 12 | disabled?: boolean; 13 | }[]; 14 | } 15 | 16 | const Tabs = ({ tabs }: TabsProps) => { 17 | return ( 18 | 19 | 20 | {tabs.map((tab) => ( 21 | 22 | {({ selected }) => ( 23 | 36 | )} 37 | 38 | ))} 39 | 40 | 41 | 42 | {tabs.map((tab) => ( 43 | {tab.render()} 44 | ))} 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default Tabs; 51 | -------------------------------------------------------------------------------- /src/core/presentation/components/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { InputHTMLAttributes, forwardRef, useState } from "react"; 3 | import { MdErrorOutline } from "react-icons/md"; 4 | 5 | interface TextInputProps extends InputHTMLAttributes { 6 | label?: string; 7 | error?: string; 8 | hideErrorMessage?: boolean; 9 | PrependIconComponent?: React.ComponentType<{ className?: string }>; 10 | AppendIconComponent?: React.ComponentType<{ className?: string }>; 11 | } 12 | 13 | const TextInput = forwardRef( 14 | ( 15 | { 16 | hideErrorMessage, 17 | error, 18 | PrependIconComponent, 19 | AppendIconComponent, 20 | label, 21 | ...props 22 | }, 23 | ref 24 | ) => { 25 | const [isFocused, setIsFocused] = useState(false); 26 | 27 | return ( 28 |
29 | {label && } 30 | 31 |
39 | {PrependIconComponent && ( 40 | 41 | )} 42 | { 46 | setIsFocused(true); 47 | props.onFocus?.(e); 48 | }} 49 | onBlur={(e) => { 50 | setIsFocused(false); 51 | props.onBlur?.(e); 52 | }} 53 | className={classNames( 54 | "bg-transparent py-3 w-full block rounded-md outline-none", 55 | props.disabled && "text-gray-500" 56 | )} 57 | /> 58 | 59 | {AppendIconComponent && ( 60 | 61 | )} 62 |
63 | 64 |
70 | 71 | {error} 72 |
73 |
74 | ); 75 | } 76 | ); 77 | 78 | TextInput.displayName = "TextInput"; 79 | 80 | export default TextInput; 81 | -------------------------------------------------------------------------------- /src/core/presentation/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Popover, Transition } from "@headlessui/react"; 2 | import classNames from "classnames"; 3 | import { Fragment, ReactNode, useCallback, useRef } from "react"; 4 | 5 | interface TooltipProps { 6 | renderTrigger: () => ReactNode; 7 | renderContent: () => ReactNode; 8 | } 9 | 10 | const Tooltip = ({ renderTrigger, renderContent }: TooltipProps) => { 11 | const triggerRef = useRef(null); 12 | const handleEnter = (open: boolean) => { 13 | !open && triggerRef.current?.click(); 14 | }; 15 | const handleLeave = useCallback((open: boolean) => { 16 | open && triggerRef.current?.click(); 17 | }, []); 18 | 19 | return ( 20 | 21 | {({ open }) => ( 22 |
handleEnter(open)} 24 | onMouseLeave={() => handleLeave(open)} 25 | > 26 | 33 | {renderTrigger()} 34 | 35 | 36 | 45 | 46 |
47 | {renderContent()} 48 |
49 |
50 |
51 |
52 | )} 53 |
54 | ); 55 | }; 56 | 57 | export default Tooltip; 58 | -------------------------------------------------------------------------------- /src/core/presentation/hooks/useContextStore.ts: -------------------------------------------------------------------------------- 1 | import { Context, useContext } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | export const useContextStore = ( 5 | Context: Context 6 | ): Exclude, null> => { 7 | const { t } = useTranslation(["core"]); 8 | const store = useContext(Context); 9 | 10 | if (!store) { 11 | throw new Error( 12 | t("errors.contextNotProvided", { 13 | contextName: Context.displayName, 14 | }) 15 | ); 16 | } 17 | 18 | return store; 19 | }; 20 | -------------------------------------------------------------------------------- /src/core/presentation/hooks/useEffectOnce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export function useEffectOnce(callback: () => void) { 4 | const didRun = useRef(false); 5 | useEffect(() => { 6 | if (!didRun.current) { 7 | callback(); 8 | didRun.current = true; 9 | } 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/core/presentation/hooks/useLocaleOptions.ts: -------------------------------------------------------------------------------- 1 | import Locale from "@/core/domain/enums/Locale"; 2 | import { ChangeEvent, useCallback, useMemo, useState } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | const useLocaleSelect = () => { 6 | const { t } = useTranslation(["core"]); 7 | const localeOptions = useMemo(() => { 8 | return Object.values(Locale).map((locale) => ({ 9 | value: locale, 10 | label: t(`core:enums.Locale.${locale}`), 11 | })); 12 | }, [t]); 13 | const [selectedLocale, setSelectedLocale] = useState(Locale.English); 14 | const onLocaleSelectChanged = useCallback( 15 | (event: ChangeEvent) => { 16 | setSelectedLocale(event.target.value as Locale); 17 | }, 18 | [] 19 | ); 20 | 21 | return { 22 | localeOptions, 23 | selectedLocale, 24 | setSelectedLocale, 25 | onLocaleSelectChanged, 26 | }; 27 | }; 28 | 29 | export default useLocaleSelect; 30 | -------------------------------------------------------------------------------- /src/core/presentation/i18n/en/core.ts: -------------------------------------------------------------------------------- 1 | import Locale from "@/core/domain/enums/Locale"; 2 | 3 | const core = { 4 | pages: { 5 | NotFoundPage: { 6 | head: { 7 | title: "Error 404", 8 | }, 9 | message: "Page not found", 10 | backHomeButton: "Back to home", 11 | }, 12 | }, 13 | components: {}, 14 | errors: { 15 | contextNotProvided: "{{contextName}} is not provided.", 16 | }, 17 | enums: { 18 | Locale: { 19 | [Locale.English]: "English", 20 | [Locale.Spanish]: "Spanish", 21 | }, 22 | }, 23 | }; 24 | 25 | export default core; 26 | -------------------------------------------------------------------------------- /src/core/presentation/i18n/en/index.ts: -------------------------------------------------------------------------------- 1 | import zod from "zod-i18n-map/locales/en/zod.json"; 2 | import core from "./core"; 3 | import post from "@/post/presentation/i18n/en"; 4 | 5 | export default { 6 | zod, 7 | core, 8 | post, 9 | } as const; 10 | -------------------------------------------------------------------------------- /src/core/presentation/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import i18next from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | import { z } from "zod"; 4 | import { zodI18nMap } from "zod-i18n-map"; 5 | import en from "./en"; 6 | 7 | i18next.use(initReactI18next).init({ 8 | react: { 9 | useSuspense: false, 10 | }, 11 | resources: { 12 | en, 13 | }, 14 | lng: "en", 15 | fallbackLng: "en", 16 | }); 17 | z.setErrorMap(zodI18nMap); 18 | -------------------------------------------------------------------------------- /src/core/presentation/navigation/router.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from "react-router-dom"; 2 | import NotFoundPage from "../pages/NotFoundPage"; 3 | import PostsPage from "@/post/presentation/pages/PostsPage"; 4 | import PostPage from "@/post/presentation/pages/PostPage"; 5 | 6 | const router = createBrowserRouter([ 7 | { 8 | path: "/", 9 | element: , 10 | }, 11 | { 12 | path: "/posts/:id", 13 | element: , 14 | }, 15 | { 16 | path: "*", 17 | element: , 18 | }, 19 | ]); 20 | 21 | export default router; 22 | -------------------------------------------------------------------------------- /src/core/presentation/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from "react-helmet-async"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useCallback } from "react"; 4 | import { useNavigate } from "react-router-dom"; 5 | import Button from "../components/Button"; 6 | 7 | const NotFoundPage = () => { 8 | const { t } = useTranslation(["core"]); 9 | const navigate = useNavigate(); 10 | const onBackHomeButtonClicked = useCallback(() => { 11 | navigate("/"); 12 | }, [navigate]); 13 | 14 | return ( 15 | <> 16 | 17 | 18 |
19 |

404

20 |

{t("pages.NotFoundPage.message")}

21 | 22 | 25 |
26 | 27 | ); 28 | }; 29 | 30 | export default NotFoundPage; 31 | -------------------------------------------------------------------------------- /src/core/presentation/services/ToastService.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversiland"; 2 | import { toast } from "react-toastify"; 3 | 4 | @injectable() 5 | export class ToastService { 6 | public success(message: string) { 7 | toast.success(message); 8 | } 9 | 10 | public info(message: string) { 11 | toast.info(message); 12 | } 13 | 14 | public warn(message: string) { 15 | toast.warn(message); 16 | } 17 | 18 | public error(message: string) { 19 | toast.error(message); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/core/presentation/styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: "Roboto"; 7 | src: url("/fonts/Roboto-Thin.ttf") format("truetype"); 8 | font-weight: 100; 9 | font-style: normal; 10 | } 11 | 12 | @font-face { 13 | font-family: "Roboto"; 14 | src: url("/fonts/Roboto-Light.ttf") format("truetype"); 15 | font-weight: 300; 16 | font-style: normal; 17 | } 18 | 19 | @font-face { 20 | font-family: "Roboto"; 21 | src: url("/fonts/Roboto-Regular.ttf") format("truetype"); 22 | font-weight: 400; 23 | font-style: normal; 24 | } 25 | 26 | @font-face { 27 | font-family: "Roboto"; 28 | src: url("/fonts/Roboto-Medium.ttf") format("truetype"); 29 | font-weight: 500; 30 | font-style: normal; 31 | } 32 | 33 | @font-face { 34 | font-family: "Roboto"; 35 | src: url("/fonts/Roboto-Bold.ttf") format("truetype"); 36 | font-weight: 700; 37 | font-style: normal; 38 | } 39 | 40 | @font-face { 41 | font-family: "Roboto"; 42 | src: url("/fonts/Roboto-Black.ttf") format("truetype"); 43 | font-weight: 900; 44 | font-style: normal; 45 | } 46 | 47 | * { 48 | font-family: "Roboto", sans-serif; 49 | } 50 | 51 | html { 52 | @apply h-full; 53 | } 54 | 55 | body { 56 | @apply h-full; 57 | } 58 | 59 | #root { 60 | @apply h-full flex flex-col; 61 | } 62 | 63 | h1 { 64 | @apply font-bold text-4xl text-center mb-6; 65 | } 66 | h2 { 67 | @apply font-semibold text-3xl mb-5; 68 | } 69 | h3 { 70 | @apply font-semibold text-2xl mb-4; 71 | } 72 | h4 { 73 | @apply font-semibold text-xl mb-4; 74 | } 75 | h5 { 76 | @apply font-semibold text-lg mb-3; 77 | } 78 | h6 { 79 | @apply font-semibold text-base mb-2; 80 | } 81 | 82 | h1, 83 | h2, 84 | h3, 85 | h4, 86 | h5, 87 | h6 { 88 | @apply tracking-wide; 89 | } 90 | -------------------------------------------------------------------------------- /src/core/presentation/types/BaseDialogProps.ts: -------------------------------------------------------------------------------- 1 | export default interface BaseDialogProps { 2 | title?: string; 3 | isOpen: boolean; 4 | onClose: () => void; 5 | } 6 | -------------------------------------------------------------------------------- /src/core/presentation/types/BaseFormProps.ts: -------------------------------------------------------------------------------- 1 | export default interface BaseFormProps { 2 | className?: string; 3 | onFormLoadingChange?: (isLoading: boolean) => void; 4 | onFormSubmitSuccess?: () => void; 5 | } 6 | -------------------------------------------------------------------------------- /src/core/presentation/types/DialogState.ts: -------------------------------------------------------------------------------- 1 | export default interface DialogState { 2 | isOpen: boolean; 3 | payload: PayloadType; 4 | } 5 | -------------------------------------------------------------------------------- /src/core/presentation/types/ListState.ts: -------------------------------------------------------------------------------- 1 | import PaginationState from "./PaginationState"; 2 | 3 | export default interface ListState< 4 | ResultItemType, 5 | FiltersType = Record 6 | > { 7 | isLoading: boolean; 8 | results: ResultItemType[]; 9 | count: number; 10 | filters: FiltersType; 11 | pagination: PaginationState; 12 | } 13 | -------------------------------------------------------------------------------- /src/core/presentation/types/PaginationState.ts: -------------------------------------------------------------------------------- 1 | export default interface PaginationState { 2 | page: number; 3 | pageSize: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/core/presentation/types/ToastType.ts: -------------------------------------------------------------------------------- 1 | export enum ToastType { 2 | Success = "success", 3 | Info = "info", 4 | Warning = "warning", 5 | Error = "error", 6 | } 7 | -------------------------------------------------------------------------------- /src/core/presentation/utils/getObjectFieldErrorMessage.ts: -------------------------------------------------------------------------------- 1 | export default function getObjectFieldErrorMessage( 2 | fieldError: object | undefined 3 | ): string | undefined { 4 | const message = fieldError && JSON.stringify(fieldError, null, 2); 5 | 6 | return message; 7 | } 8 | -------------------------------------------------------------------------------- /src/core/presentation/utils/withProviders.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentType, PropsWithChildren } from "react"; 2 | 3 | export const withProviders = 4 | ( 5 | ...providers: ( 6 | | ComponentType 7 | | [ComponentType, Record] 8 | )[] 9 | ) => 10 | (WrappedComponent: ComponentType) => 11 | (props: Record) => 12 | providers.reduceRight((acc, prov) => { 13 | const Provider = Array.isArray(prov) ? prov[0] : prov; 14 | const providerProps = Array.isArray(prov) ? prov[1] : {}; 15 | 16 | return {acc}; 17 | }, ); 18 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | 4 | import "./core/presentation/styles/index.css"; 5 | import "./core/presentation/i18n/index"; 6 | import AppModule from "./AppModule"; 7 | import reportWebVitals from "./reportWebVitals"; 8 | import { HelmetProvider } from "react-helmet-async"; 9 | import { Inversiland } from "inversiland"; 10 | import App from "./core/presentation/App"; 11 | 12 | Inversiland.options.logLevel = import.meta.env.DEV ? "debug" : "info"; 13 | Inversiland.options.defaultScope = "Singleton"; 14 | Inversiland.run(AppModule); 15 | 16 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | 24 | // If you want to start measuring performance in your app, pass a function 25 | // to log results (for example: reportWebVitals(console.log)) 26 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 27 | reportWebVitals(); 28 | -------------------------------------------------------------------------------- /src/post/PostModule.ts: -------------------------------------------------------------------------------- 1 | import { getModuleContainer, module } from "inversiland"; 2 | import FindPostUseCase from "./application/useCases/FindPostUseCase"; 3 | import GetPostsUseCase from "./application/useCases/GetPostsUseCase"; 4 | import { IPostRepositoryToken } from "./domain/specifications/IPostRepository"; 5 | import PostRepository from "./infrastructure/implementations/PostRepository"; 6 | import { FindPostStore } from "./presentation/stores/FindPostStore/FindPostStore"; 7 | import { GetPostsStore } from "./presentation/stores/GetPostsStore/GetPostsStore"; 8 | 9 | @module({ 10 | providers: [ 11 | { 12 | provide: IPostRepositoryToken, 13 | useClass: PostRepository, 14 | }, 15 | FindPostUseCase, 16 | GetPostsUseCase, 17 | { 18 | useClass: GetPostsStore, 19 | scope: "Transient", 20 | }, 21 | { 22 | useClass: FindPostStore, 23 | scope: "Transient", 24 | }, 25 | ], 26 | }) 27 | export class PostModule {} 28 | 29 | export const postModuleContainer = getModuleContainer(PostModule); 30 | -------------------------------------------------------------------------------- /src/post/application/types/GetPostsPayload.ts: -------------------------------------------------------------------------------- 1 | export default interface GetPostsPayload { 2 | page: number; 3 | pageSize: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/post/application/types/GetPostsResponse.ts: -------------------------------------------------------------------------------- 1 | import PostEntity from "@/post/domain/entities/PostEntity"; 2 | 3 | export default interface GetPostsResponse { 4 | results: PostEntity[]; 5 | count: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/post/application/useCases/FindPostUseCase.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from "inversiland"; 2 | import { 3 | IPostRepository, 4 | IPostRepositoryToken, 5 | } from "@/post/domain/specifications/IPostRepository"; 6 | 7 | @injectable() 8 | export default class FindPostUseCase { 9 | constructor( 10 | @inject(IPostRepositoryToken) 11 | private readonly postRepository: IPostRepository 12 | ) {} 13 | 14 | public execute(id: number) { 15 | return this.postRepository.find(id); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/post/application/useCases/GetPostsUseCase.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IPostRepository, 3 | IPostRepositoryToken, 4 | } from "@/post/domain/specifications/IPostRepository"; 5 | import GetPostsPayload from "@/post/application/types/GetPostsPayload"; 6 | import { injectable, inject } from "inversiland"; 7 | import { UseCase } from "@/core/application/UseCase"; 8 | import GetPostsResponse from "../types/GetPostsResponse"; 9 | 10 | @injectable() 11 | export default class GetPostsUseCase 12 | implements UseCase> 13 | { 14 | constructor( 15 | @inject(IPostRepositoryToken) 16 | private readonly postRepository: IPostRepository 17 | ) {} 18 | 19 | public execute(data: GetPostsPayload) { 20 | return this.postRepository.get(data); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/post/domain/entities/PostEntity.ts: -------------------------------------------------------------------------------- 1 | export default interface PostEntity { 2 | id: number; 3 | userId: number; 4 | title: string; 5 | body: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/post/domain/specifications/IPostRepository.ts: -------------------------------------------------------------------------------- 1 | import PostEntity from "../entities/PostEntity"; 2 | import GetPostsPayload from "../../application/types/GetPostsPayload"; 3 | import GetPostsResponse from "@/post/application/types/GetPostsResponse"; 4 | 5 | export const IPostRepositoryToken = Symbol(); 6 | 7 | export interface IPostRepository { 8 | find: (id: number) => Promise; 9 | get: (data: GetPostsPayload) => Promise; 10 | } 11 | -------------------------------------------------------------------------------- /src/post/infrastructure/implementations/PostRepository.ts: -------------------------------------------------------------------------------- 1 | import GetPostsPayload from "@/post/application/types/GetPostsPayload"; 2 | import { injectable, inject } from "inversiland"; 3 | import { IPostRepository } from "../../domain/specifications/IPostRepository"; 4 | import GetPostsResponse from "@/post/application/types/GetPostsResponse"; 5 | import PostDto from "../models/PostDto"; 6 | import { plainToInstance } from "class-transformer"; 7 | import IHttpClient, { 8 | IHttpClientToken, 9 | } from "@/core/domain/specifications/IHttpClient"; 10 | 11 | @injectable() 12 | class PostRepository implements IPostRepository { 13 | private readonly baseUrl = "/posts"; 14 | 15 | constructor( 16 | @inject(IHttpClientToken) private readonly httpClient: IHttpClient 17 | ) {} 18 | 19 | public async find(id: number) { 20 | const response = await this.httpClient.get(`${this.baseUrl}/${id}`); 21 | const responseDto = plainToInstance(PostDto, response); 22 | 23 | return responseDto.toDomain(); 24 | } 25 | 26 | public async get({}: GetPostsPayload): Promise { 27 | const posts = await this.httpClient.get(this.baseUrl); 28 | const response: GetPostsResponse = { 29 | results: posts.map((post) => plainToInstance(PostDto, post).toDomain()), 30 | count: posts.length, 31 | }; 32 | 33 | return response; 34 | } 35 | } 36 | 37 | export default PostRepository; 38 | -------------------------------------------------------------------------------- /src/post/infrastructure/models/GetPostsQuery.ts: -------------------------------------------------------------------------------- 1 | import PayloadDto from "@/core/infrastructure/models/PayloadDto"; 2 | import GetPostsPayload from "@/post/application/types/GetPostsPayload"; 3 | import { Expose } from "class-transformer"; 4 | 5 | export default class GetPostsQuery extends PayloadDto { 6 | @Expose() 7 | page!: number; 8 | 9 | @Expose() 10 | pageSize!: number; 11 | 12 | transform(payload: GetPostsPayload) { 13 | return { 14 | page: payload.page, 15 | pageSize: payload.pageSize, 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/post/infrastructure/models/PostDto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from "class-transformer"; 2 | import ResponseDto from "@/core/infrastructure/models/ResponseDto"; 3 | import PostEntity from "@/post/domain/entities/PostEntity"; 4 | 5 | export default class PostDto extends ResponseDto { 6 | @Expose() 7 | id!: number; 8 | 9 | @Expose() 10 | userId!: number; 11 | 12 | @Expose() 13 | title!: string; 14 | 15 | @Expose() 16 | body!: string; 17 | 18 | toDomain() { 19 | return { 20 | id: this.id, 21 | userId: this.userId, 22 | title: this.title, 23 | body: this.body, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/post/presentation/components/PostItem.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "react-router-dom"; 2 | import PostEntity from "@/post/domain/entities/PostEntity"; 3 | 4 | interface PostItemProps { 5 | post: PostEntity; 6 | } 7 | 8 | const PostItem = ({ post }: PostItemProps) => { 9 | const { title, body } = post; 10 | const navigate = useNavigate(); 11 | const onClick = () => { 12 | navigate(`/posts/${post.id}`); 13 | }; 14 | 15 | return ( 16 |
17 |
18 | {title} 19 | {body} 20 |
21 |
22 | ); 23 | }; 24 | 25 | export default PostItem; 26 | -------------------------------------------------------------------------------- /src/post/presentation/i18n/en.ts: -------------------------------------------------------------------------------- 1 | const post = { 2 | pages: { 3 | PostsPage: { 4 | loading: "Loading...", 5 | }, 6 | PostPage: { 7 | loading: "Loading...", 8 | }, 9 | }, 10 | }; 11 | 12 | export default post; 13 | -------------------------------------------------------------------------------- /src/post/presentation/pages/PostPage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import { observer } from "mobx-react"; 4 | import { useTranslation } from "react-i18next"; 5 | import { withProviders } from "@/core/presentation/utils/withProviders"; 6 | import { FindPostStoreProvider } from "../stores/FindPostStore/FindPostStoreProvider"; 7 | import { useFindPostStore } from "../stores/FindPostStore/useFindPostStore"; 8 | 9 | const PostPage = observer(() => { 10 | const { id } = useParams<{ id: string }>(); 11 | const { t } = useTranslation(["post"]); 12 | const findPostStore = useFindPostStore(); 13 | const { post, isLoading } = findPostStore; 14 | 15 | useEffect(() => { 16 | if (id) { 17 | findPostStore.findPost(parseInt(id)); 18 | } 19 | }, [findPostStore, id]); 20 | 21 | return isLoading ? ( 22 |

{t("post:pages.PostPage.loading")}

23 | ) : ( 24 |
25 | {post?.title} 26 | {post?.body} 27 |
28 | ); 29 | }); 30 | 31 | export default withProviders(FindPostStoreProvider)(PostPage); 32 | -------------------------------------------------------------------------------- /src/post/presentation/pages/PostsPage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { observer } from "mobx-react"; 3 | import PostItem from "../components/PostItem"; 4 | import { withProviders } from "@/core/presentation/utils/withProviders"; 5 | import { useTranslation } from "react-i18next"; 6 | import { GetPostsStoreProvider } from "../stores/GetPostsStore/GetPostsStoreProvider"; 7 | import { useGetPostsStore } from "../stores/GetPostsStore/useGetPostsStore"; 8 | 9 | const PostsPage = observer(() => { 10 | const { t } = useTranslation(["post"]); 11 | const getPostsStore = useGetPostsStore(); 12 | const { results, isLoading } = getPostsStore; 13 | 14 | useEffect(() => { 15 | getPostsStore.getPosts(); 16 | }, [getPostsStore]); 17 | 18 | return ( 19 |
20 | {isLoading ? ( 21 |

{t("post:pages.PostsPage.loading")}

22 | ) : ( 23 |
24 | {results.map((post) => ( 25 | 26 | ))} 27 |
28 | )} 29 |
30 | ); 31 | }); 32 | 33 | export default withProviders(GetPostsStoreProvider)(PostsPage); 34 | -------------------------------------------------------------------------------- /src/post/presentation/stores/FindPostStore/FindPostStore.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from "inversiland"; 2 | import { makeAutoObservable } from "mobx"; 3 | import FindPostStoreState from "../../types/FindPostStoreState"; 4 | import PostEntity from "@/post/domain/entities/PostEntity"; 5 | import FindPostUseCase from "@/post/application/useCases/FindPostUseCase"; 6 | 7 | @injectable() 8 | export class FindPostStore implements FindPostStoreState { 9 | isLoading = false; 10 | post: PostEntity | null = null; 11 | 12 | constructor( 13 | @inject(FindPostUseCase) 14 | private findPostUseCase: FindPostUseCase 15 | ) { 16 | makeAutoObservable(this); 17 | } 18 | 19 | setIsLoading(isLoading: boolean) { 20 | this.isLoading = isLoading; 21 | } 22 | 23 | setPost(post: PostEntity | null) { 24 | this.post = post; 25 | } 26 | 27 | async findPost(id: number) { 28 | try { 29 | this.setIsLoading(true); 30 | this.setPost(await this.findPostUseCase.execute(id)); 31 | } catch (error) { 32 | } finally { 33 | this.setIsLoading(false); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/post/presentation/stores/FindPostStore/FindPostStoreContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { FindPostStore } from "./FindPostStore"; 3 | 4 | export const FindPostStoreContext = createContext(null); 5 | 6 | FindPostStoreContext.displayName = "FindPostStoreContext"; 7 | -------------------------------------------------------------------------------- /src/post/presentation/stores/FindPostStore/FindPostStoreProvider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react"; 2 | import { FindPostStoreContext } from "./FindPostStoreContext"; 3 | import { FindPostStore } from "./FindPostStore"; 4 | import { postModuleContainer } from "@/post/PostModule"; 5 | 6 | export const FindPostStoreProvider = ({ children }: PropsWithChildren) => { 7 | return ( 8 | 11 | {children} 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/post/presentation/stores/FindPostStore/useFindPostStore.ts: -------------------------------------------------------------------------------- 1 | import { FindPostStore } from "./FindPostStore"; 2 | import { FindPostStoreContext } from "./FindPostStoreContext"; 3 | import { useContextStore } from "@/core/presentation/hooks/useContextStore"; 4 | 5 | export const useFindPostStore = (): FindPostStore => { 6 | const store = useContextStore(FindPostStoreContext); 7 | 8 | return store; 9 | }; 10 | -------------------------------------------------------------------------------- /src/post/presentation/stores/GetPostsStore/GetPostsStore.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from "inversiland"; 2 | import { makeAutoObservable } from "mobx"; 3 | import { ToastService } from "@/core/presentation/services/ToastService"; 4 | import GetPostsStoreState from "../../types/GetPostsStoreState"; 5 | import GetPostsUseCase from "@/post/application/useCases/GetPostsUseCase"; 6 | import GetPostsPayload from "@/post/application/types/GetPostsPayload"; 7 | 8 | @injectable() 9 | export class GetPostsStore implements GetPostsStoreState { 10 | isLoading = false; 11 | results = [] as GetPostsStoreState["results"]; 12 | count = 0; 13 | filters = {}; 14 | pagination = { 15 | page: 1, 16 | pageSize: 25, 17 | }; 18 | 19 | constructor( 20 | @inject(ToastService) private readonly toastService: ToastService, 21 | @inject(GetPostsUseCase) 22 | private readonly getPostsUseCase: GetPostsUseCase 23 | ) { 24 | makeAutoObservable(this); 25 | } 26 | 27 | get pageCount() { 28 | return Math.ceil(this.count / this.pagination.pageSize); 29 | } 30 | 31 | get isEmpty(): boolean { 32 | return this.results.length === 0; 33 | } 34 | 35 | setIsLoading = (isLoading: boolean) => { 36 | this.isLoading = isLoading; 37 | }; 38 | 39 | setResults = (results: GetPostsStoreState["results"]) => { 40 | this.results = results; 41 | }; 42 | 43 | setCount = (count: GetPostsStoreState["count"]) => { 44 | this.count = count; 45 | }; 46 | 47 | mergeFilters = (payload: Partial) => { 48 | Object.assign(this.filters, payload); 49 | }; 50 | 51 | mergePagination = ( 52 | payload: Partial 53 | ): void => { 54 | Object.assign(this.pagination, payload); 55 | }; 56 | 57 | async getPosts() { 58 | const payload: GetPostsPayload = { 59 | ...this.filters, 60 | ...this.pagination, 61 | }; 62 | 63 | this.setIsLoading(true); 64 | 65 | return this.getPostsUseCase 66 | .execute(payload) 67 | .then((response) => { 68 | this.setResults(response.results); 69 | this.setCount(response.count); 70 | }) 71 | .catch((error) => { 72 | this.toastService.error(error.message); 73 | }) 74 | .finally(() => { 75 | this.setIsLoading(false); 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/post/presentation/stores/GetPostsStore/GetPostsStoreContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { GetPostsStore } from "./GetPostsStore"; 3 | 4 | export const GetPostsStoreContext = createContext(null); 5 | 6 | GetPostsStoreContext.displayName = "GetPostsStoreContext"; 7 | -------------------------------------------------------------------------------- /src/post/presentation/stores/GetPostsStore/GetPostsStoreProvider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react"; 2 | import { GetPostsStore } from "./GetPostsStore"; 3 | import { GetPostsStoreContext } from "./GetPostsStoreContext"; 4 | import { postModuleContainer } from "@/post/PostModule"; 5 | 6 | export const GetPostsStoreProvider = ({ children }: PropsWithChildren) => { 7 | return ( 8 | 11 | {children} 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/post/presentation/stores/GetPostsStore/useGetPostsStore.ts: -------------------------------------------------------------------------------- 1 | import { useContextStore } from "@/core/presentation/hooks/useContextStore"; 2 | import { GetPostsStore } from "./GetPostsStore"; 3 | import { GetPostsStoreContext } from "./GetPostsStoreContext"; 4 | 5 | export const useGetPostsStore = (): GetPostsStore => { 6 | const store = useContextStore(GetPostsStoreContext); 7 | 8 | return store; 9 | }; 10 | -------------------------------------------------------------------------------- /src/post/presentation/types/FindPostStoreState.ts: -------------------------------------------------------------------------------- 1 | import PostEntity from "@/post/domain/entities/PostEntity"; 2 | 3 | export default interface FindPostStoreState { 4 | isLoading: boolean; 5 | post: PostEntity | null; 6 | } 7 | -------------------------------------------------------------------------------- /src/post/presentation/types/GetPostsStoreState.ts: -------------------------------------------------------------------------------- 1 | import ListState from "@/core/presentation/types/ListState"; 2 | import PostEntity from "@/post/domain/entities/PostEntity"; 3 | 4 | type GetPostsStoreState = ListState; 5 | 6 | export default GetPostsStoreState; 7 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportCallback } from "web-vitals"; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportCallback) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals").then(({ onCLS, onFID, onFCP, onLCP, onTTFB }) => { 6 | onCLS(onPerfEntry); 7 | onFID(onPerfEntry); 8 | onFCP(onPerfEntry); 9 | onLCP(onPerfEntry); 10 | onTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.tsx"], 4 | plugins: [], 5 | }; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "jsx": "react-jsx", 5 | "experimentalDecorators": true, 6 | "emitDecoratorMetadata": true, 7 | "esModuleInterop": true, 8 | "baseUrl": ".", 9 | "lib": ["ES2022", "dom"], 10 | "module": "ES2022", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "paths": { 15 | "@/*": ["src/*"] 16 | }, 17 | "noEmit": true 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { checker } from "vite-plugin-checker"; 3 | import alias from "@rollup/plugin-alias"; 4 | import { defineConfig } from "vite"; 5 | import path from "path"; 6 | 7 | export default defineConfig({ 8 | envDir: "./env", 9 | plugins: [ 10 | react(), 11 | checker({ 12 | typescript: true, 13 | }), 14 | alias({ 15 | entries: { 16 | "@": path.resolve(__dirname, "./src"), 17 | }, 18 | }), 19 | ], 20 | }); 21 | --------------------------------------------------------------------------------