├── .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 | 
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 |
33 |
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 |
--------------------------------------------------------------------------------