├── .github └── workflows │ └── continuous-integration-workflow.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── docker ├── .dockerignore ├── Dockerfile ├── docker-compose.yml ├── hosts │ ├── mobile.conf │ └── web.conf ├── logs │ └── .gitkeep └── nginx.conf ├── docs ├── ARCHITECTURE.md ├── Application Layers.drawio ├── CODE_CONVENTIONS.md ├── SCRIPTS.md └── icon.svg ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public └── assets │ ├── fonts │ └── .gitkeep │ ├── images │ └── .gitkeep │ └── locales │ ├── en │ ├── translation.mobile.json │ └── translation.web.json │ └── ru │ ├── translation.mobile.json │ └── translation.web.json ├── setup ├── .babelrc ├── env │ ├── .mobile.dev.env │ ├── .mobile.prod.env │ ├── .mobile.stage.env │ ├── .web.dev.env │ ├── .web.prod.env │ └── .web.stage.env ├── husky │ ├── commit-msg │ ├── common.sh │ ├── pre-commit │ └── pre-push ├── jest │ ├── config.ts │ ├── plugins │ │ └── fileTransformer.js │ ├── setup-after-env.ts │ └── setup.ts ├── plopjs │ ├── constants.ts │ ├── domain.ts │ ├── entity.ts │ ├── index.ts │ ├── plopfile.ts │ ├── repo.ts │ ├── service.ts │ └── templates │ │ ├── dto.hbs │ │ ├── dto.index.hbs │ │ ├── entities.index.hbs │ │ ├── entity.hbs │ │ ├── index.hbs │ │ ├── repo.container.hbs │ │ ├── repo.hbs │ │ ├── repo.impl.hbs │ │ ├── repo.index.hbs │ │ ├── repo.response.hbs │ │ ├── service.container.hbs │ │ ├── service.hbs │ │ ├── service.impl.hbs │ │ └── services.index.hbs └── vite │ ├── .swcrc │ ├── build.ts │ ├── config.ts │ ├── config.types.ts │ ├── define.ts │ ├── env.ts │ ├── plugins.ts │ ├── plugins │ └── react-svg │ │ └── index.ts │ ├── server.ts │ └── styles.ts ├── src ├── assets │ └── images │ │ └── logo.svg ├── containers │ ├── config.ts │ ├── containers.ts │ ├── core │ │ └── index.ts │ └── index.ts ├── core │ ├── adapters │ │ ├── axios │ │ │ ├── axios.adapter.ts │ │ │ └── index.ts │ │ ├── browser-cookie │ │ │ ├── __tests__ │ │ │ │ └── browser-cookie.adapter.test.ts │ │ │ ├── browser-cookie.adapter.ts │ │ │ ├── browser-cookie.constants.ts │ │ │ ├── browser-cookie.helpers.ts │ │ │ ├── browser-cookie.types.ts │ │ │ └── index.ts │ │ ├── form │ │ │ ├── index.ts │ │ │ └── react-hook-form.adapter.ts │ │ ├── i18next │ │ │ ├── i18next.adapter.ts │ │ │ ├── i18next.constants.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── local-storage │ │ │ ├── index.ts │ │ │ └── local-storage.adapter.ts │ │ ├── logger │ │ │ ├── index.ts │ │ │ └── logger.adapter.ts │ │ └── session-storage │ │ │ ├── index.ts │ │ │ └── session-storage.adapter.ts │ ├── form │ │ ├── form-api.ts │ │ ├── form-api.types.ts │ │ └── index.ts │ ├── http │ │ ├── endpoint │ │ │ ├── endpoint.ts │ │ │ ├── endpoint.types.ts │ │ │ └── index.ts │ │ ├── http-client.ts │ │ ├── http-method.types.ts │ │ ├── http.types.ts │ │ └── index.ts │ ├── i18n │ │ ├── i18n.constants.ts │ │ ├── i18n.ts │ │ ├── i18n.types.ts │ │ └── index.ts │ ├── logger │ │ ├── index.ts │ │ ├── logger.constants.ts │ │ └── logger.ts │ ├── mobx-store │ │ ├── index.ts │ │ ├── mobx.store.impl.ts │ │ ├── mobx.store.ts │ │ └── notification-store │ │ │ ├── index.ts │ │ │ ├── notification.store.impl.ts │ │ │ └── notification.store.ts │ ├── storage │ │ ├── index.ts │ │ ├── storage.constants.ts │ │ ├── storage.ts │ │ └── storage.types.ts │ ├── utils │ │ ├── __tests__ │ │ │ ├── app-version.test.ts │ │ │ └── build-url.test.ts │ │ ├── app-version.util.ts │ │ ├── backend-urls.util.ts │ │ ├── build-url.util.ts │ │ ├── index.ts │ │ └── is-nullable.util.ts │ └── validators │ │ └── index.ts ├── data │ ├── dao │ │ └── notification │ │ │ ├── index.ts │ │ │ ├── notification.dao.container.ts │ │ │ ├── notification.dao.impl.ts │ │ │ └── notification.dao.ts │ ├── dto │ │ └── auth │ │ │ ├── auth-token.dto.ts │ │ │ └── index.ts │ └── repositories │ │ ├── auth │ │ ├── auth.repo.container.ts │ │ ├── auth.repo.impl.ts │ │ ├── auth.repo.response.ts │ │ ├── auth.repo.ts │ │ ├── index.ts │ │ └── token-axios │ │ │ ├── index.ts │ │ │ ├── token-axios.adapter.ts │ │ │ └── token-axios.container.ts │ │ └── endpoints.ts ├── domain │ ├── auth │ │ ├── auth.types.ts │ │ ├── entities │ │ │ ├── auth-form-fields.entity.ts │ │ │ ├── auth-token.entity.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ └── services │ │ │ ├── auth.service.container.ts │ │ │ ├── auth.service.impl.ts │ │ │ ├── auth.service.ts │ │ │ └── index.ts │ ├── date │ │ ├── date.types.ts │ │ ├── entities │ │ │ ├── date.entity.ts │ │ │ └── index.ts │ │ └── index.ts │ ├── error │ │ ├── entities │ │ │ ├── error.entity.ts │ │ │ └── index.ts │ │ ├── error.types.ts │ │ ├── index.ts │ │ └── services │ │ │ ├── error.service.container.ts │ │ │ ├── error.service.impl.ts │ │ │ ├── error.service.ts │ │ │ └── index.ts │ ├── notification │ │ ├── entities │ │ │ ├── index.ts │ │ │ └── notification.entity.ts │ │ ├── index.ts │ │ ├── notification.types.ts │ │ └── services │ │ │ ├── index.ts │ │ │ ├── notification.service.container.ts │ │ │ ├── notification.service.impl.ts │ │ │ └── notification.service.ts │ └── route │ │ ├── entities │ │ ├── index.ts │ │ └── route.entity.ts │ │ ├── index.ts │ │ ├── route.constants.ts │ │ └── services │ │ ├── index.ts │ │ ├── route.service.container.ts │ │ ├── route.service.impl.ts │ │ └── route.service.ts ├── presentation │ ├── forms │ │ ├── auth │ │ │ ├── auth.form.container.ts │ │ │ ├── auth.form.ts │ │ │ └── index.ts │ │ ├── base.form.impl.ts │ │ └── base.form.ts │ ├── mobile │ │ └── index.tsx │ ├── presenters │ │ └── auth │ │ │ ├── auth.presenter.impl.ts │ │ │ ├── auth.presenter.ts │ │ │ └── index.ts │ ├── shared │ │ ├── components │ │ │ ├── error-boundary │ │ │ │ ├── error-boundary.component.tsx │ │ │ │ ├── error-boundary.types.ts │ │ │ │ └── index.ts │ │ │ ├── i18n │ │ │ │ ├── i18n.hook.ts │ │ │ │ ├── i18n.util.tsx │ │ │ │ ├── index.ts │ │ │ │ └── trans.component.tsx │ │ │ ├── index.ts │ │ │ ├── ioc │ │ │ │ ├── hooks │ │ │ │ │ ├── container.hook.ts │ │ │ │ │ └── injection.hook.ts │ │ │ │ ├── index.ts │ │ │ │ ├── ioc.constants.ts │ │ │ │ ├── ioc.provider.tsx │ │ │ │ └── ioc.types.ts │ │ │ ├── portal │ │ │ │ ├── index.ts │ │ │ │ ├── portal.component.tsx │ │ │ │ └── portal.types.ts │ │ │ └── protected-route │ │ │ │ ├── index.ts │ │ │ │ ├── protected-route.component.tsx │ │ │ │ └── protected-route.types.ts │ │ ├── hoc │ │ │ ├── error-boundary.hoc.tsx │ │ │ ├── hoc.helpers.ts │ │ │ ├── i18n.hoc.tsx │ │ │ └── index.ts │ │ └── index.ts │ └── web │ │ ├── app.component.tsx │ │ ├── components │ │ ├── common │ │ │ ├── box │ │ │ │ ├── box.component.tsx │ │ │ │ ├── box.styles.ts │ │ │ │ ├── box.types.ts │ │ │ │ └── index.ts │ │ │ ├── button │ │ │ │ ├── button.component.tsx │ │ │ │ ├── button.constants.ts │ │ │ │ ├── button.helpers.ts │ │ │ │ ├── button.styles.ts │ │ │ │ ├── button.types.ts │ │ │ │ └── index.ts │ │ │ ├── index.tsx │ │ │ └── input │ │ │ │ ├── index.ts │ │ │ │ ├── input.component.tsx │ │ │ │ ├── input.styles.ts │ │ │ │ └── input.types.tsx │ │ ├── form │ │ │ ├── components │ │ │ │ ├── button.component.tsx │ │ │ │ ├── index.ts │ │ │ │ └── input.component.tsx │ │ │ ├── form.component.tsx │ │ │ ├── form.types.ts │ │ │ ├── hoc │ │ │ │ └── with-form-field.hoc.tsx │ │ │ ├── hooks │ │ │ │ └── use-form-field.hooks.ts │ │ │ └── index.ts │ │ └── index.ts │ │ ├── index.tsx │ │ ├── modules │ │ ├── auth │ │ │ ├── __snapshots__ │ │ │ │ └── auth.module.test.tsx.snap │ │ │ ├── auth.module.test.tsx │ │ │ ├── auth.module.tsx │ │ │ ├── auth.types.ts │ │ │ └── index.ts │ │ └── index.ts │ │ ├── pages │ │ ├── auth │ │ │ ├── auth.page.tsx │ │ │ └── index.ts │ │ └── index.ts │ │ ├── routes │ │ ├── index.ts │ │ ├── routes.component.tsx │ │ └── routes.styled.ts │ │ └── styles │ │ ├── index.ts │ │ └── themes.less └── types │ ├── axios.d.ts │ ├── extensions.d.ts │ ├── global.d.ts │ └── svg.d.ts ├── tsconfig.json └── tsconfig.prod.json /.github/workflows/continuous-integration-workflow.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | checks: 5 | name: Linters 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node-version: ['20.x'] 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Setting up Node.js (v${{ matrix.node-version }}.x) 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: ${{ matrix.node-version }} 16 | - run: npm install --legacy-peer-deps 17 | - run: npm run lint 18 | tests: 19 | name: Tests 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | node-version: ['20.x'] 24 | fail-fast: false 25 | steps: 26 | - uses: actions/checkout@v1 27 | - name: Setting up Node.js (v${{ matrix.node-version }}.x) 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | - run: npm install --legacy-peer-deps 32 | - run: npm run test:ci 33 | build: 34 | name: Build 35 | runs-on: ubuntu-latest 36 | strategy: 37 | matrix: 38 | node-version: ['20.x'] 39 | steps: 40 | - uses: actions/checkout@v1 41 | - name: Setting up Node.js (v${{ matrix.node-version }}.x) 42 | uses: actions/setup-node@v1 43 | with: 44 | node-version: ${{ matrix.node-version }} 45 | - run: npm install --legacy-peer-deps 46 | - run: npm run build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # misc 2 | .DS_Store 3 | .vscode 4 | .idea 5 | .env 6 | .env*.local 7 | .history 8 | 9 | # husky 10 | setup/husky/_ 11 | setup/husky/.gitignore 12 | 13 | dist 14 | dist* 15 | coverage 16 | node_modules 17 | docker/logs/*.log 18 | 19 | yarn.lock 20 | yarn-error.log 21 | 22 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "printWidth": 100, 5 | "semi": true, 6 | "singleQuote": true, 7 | "tabWidth": 2, 8 | "trailingComma": "all", 9 | "endOfLine": "lf" 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Daniil Yankouski 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 |

2 |

3 | Layered Architecture Icon 4 |

5 | React Boilerplate 6 |

7 | 8 |

9 | This React boilerplate offers a solid foundation for building enterprise-level React projects. 10 |

11 | 12 | ## Quick Overview 13 | This React boilerplate provides a foundation for developing web, mobile, and desktop applications using TypeScript, with a focus on clean and maintainable architecture. 14 | 15 | > Of course you can use another framework :) 16 | 17 | ## Getting started 18 | 19 | - Clone repository 20 | - Run `npm install` to install dependencies 21 | - Run `npm run build` to build project 22 | 23 | ## Docs 24 | 25 | - [Technologies Used](#technologies-used) 26 | - [Project Scripts](./docs/SCRIPTS.md "Project Scripts Documentation") 27 | - [Architecture](./docs/ARCHITECTURE.md "Project Architecture Documentation") 28 | - [Code Conventions](./docs/CODE_CONVENTIONS.md "Project Code Conventions") *In progress* 29 | 30 | ## Technologies Used 31 | - Core 32 | - [TypeScript](http://www.typescriptlang.org/) 33 | - [InversifyJS](https://github.com/inversify/InversifyJS) 34 | - [MobX](https://mobx.js.org/README.html) 35 | - [Axios](https://github.com/axios/axios) 36 | - Web 37 | - [React](https://reactjs.org/) 38 | - [React Router](https://reactrouter.com/) 39 | - [React Hook Form](https://react-hook-form.com/) 40 | - [Styled Components](https://styled-components.com/) 41 | - Testing 42 | - [Jest](https://jestjs.io) 43 | - [React Testing Library](https://testing-library.com/docs/react-testing-library/intro) 44 | - Code Quality 45 | - [Prettier](https://prettier.io/) 46 | - [ESLint](https://eslint.org/) 47 | - Bundlers 48 | - [ViteJs](https://vitejs.dev/) 49 | 50 | ## System Requirements 51 | - [Node.js 16+](https://nodejs.org/en/download/) 52 | 53 | ## Contributing 54 | 55 | We welcome contributions to this project. If you're interested in making a change, please open a pull request for review and consideration. 56 | 57 | ## License 58 | 59 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 60 | -------------------------------------------------------------------------------- /docker/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.git 4 | **/.gitignore 5 | **/.project 6 | **/.settings 7 | **/.toolstarget 8 | **/.vs 9 | **/.vscode 10 | **/*.*proj.user 11 | **/*.dbmdl 12 | **/*.jfm 13 | **/azds.yaml 14 | **/charts 15 | **/docker-compose* 16 | **/compose* 17 | **/Dockerfile* 18 | **/node_modules 19 | **/npm-debug.log 20 | **/obj 21 | **/secrets.dev.yaml 22 | **/values.dev.yaml 23 | README.md 24 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copy NGINX configuration 2 | FROM nginx 3 | COPY ./nginx.conf /etc/nginx/nginx.conf 4 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '0.1' 2 | name: "react-project-container" 3 | 4 | services: 5 | nginx-proxy: 6 | container_name: 'nginx-proxy' 7 | image: jwilder/nginx-proxy 8 | ports: 9 | - "80:80" 10 | volumes: 11 | - "/var/run/docker.sock:/tmp/docker.sock:ro" 12 | 13 | nginx: 14 | container_name: 'nginx' 15 | image: nginx:alpine 16 | environment: 17 | - VIRTUAL_HOST=fe.loc,m.fe.loc 18 | build: 19 | dockerfile: ./Dockerfile 20 | ports: 21 | - "8080:80" 22 | restart: always 23 | volumes: 24 | - ./hosts/:/etc/nginx/conf.d/ 25 | - ./logs/:/var/log/nginx/ 26 | - ./../:/var/www/ 27 | -------------------------------------------------------------------------------- /docker/hosts/mobile.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name m.fe.loc; 4 | 5 | access_log /var/log/nginx/m.fe.access.log; 6 | error_log /var/log/nginx/m.fe.error.log; 7 | 8 | root /var/www/dist/mobile/; 9 | index index.html; 10 | 11 | rewrite ^/(.*)/$ /$1 permanent; 12 | 13 | location / { 14 | try_files $uri $uri/ /index.html; 15 | } 16 | } -------------------------------------------------------------------------------- /docker/hosts/web.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name fe.loc; 4 | 5 | access_log /var/log/nginx/fe.access.log; 6 | error_log /var/log/nginx/fe.error.log; 7 | 8 | root /var/www/dist/web/; 9 | index index.html; 10 | 11 | rewrite ^/(.*)/$ /$1 permanent; 12 | 13 | location / { 14 | try_files $uri $uri/ /index.html; 15 | } 16 | } -------------------------------------------------------------------------------- /docker/logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qdanik/react-clean-architecture-boilerplate/70de87c89d0f5c4ab7e976a8767a454d7681c712/docker/logs/.gitkeep -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | 3 | error_log /var/log/nginx/error.log warn; 4 | pid /var/run/nginx.pid; 5 | 6 | worker_processes auto; 7 | worker_rlimit_nofile 4096; 8 | 9 | events { 10 | worker_connections 4096; 11 | multi_accept on; 12 | } 13 | 14 | http { 15 | include /etc/nginx/mime.types; 16 | default_type application/octet-stream; 17 | 18 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 19 | '$status $body_bytes_sent "$http_referer" ' 20 | '"$http_user_agent" "$http_x_forwarded_for"'; 21 | 22 | access_log /var/log/nginx/access.log main; 23 | error_log /var/log/nginx/error.log; 24 | 25 | sendfile on; 26 | #tcp_nopush on; 27 | 28 | keepalive_timeout 600; 29 | proxy_connect_timeout 600; 30 | proxy_send_timeout 600; 31 | send_timeout 600; 32 | uwsgi_read_timeout 600; 33 | 34 | # This is the main geonode conf 35 | charset utf-8; 36 | 37 | # max upload size 38 | client_max_body_size 100G; 39 | client_body_buffer_size 256K; 40 | large_client_header_buffers 4 64k; 41 | proxy_read_timeout 600s; 42 | 43 | fastcgi_hide_header Set-Cookie; 44 | 45 | etag on; 46 | 47 | # compression 48 | gzip on; 49 | gzip_vary on; 50 | gzip_proxied any; 51 | gzip_http_version 1.1; 52 | gzip_disable "MSIE [1-6]\."; 53 | gzip_buffers 16 8k; 54 | gzip_min_length 1100; 55 | gzip_comp_level 6; 56 | gzip_types 57 | text/plain 58 | text/css 59 | text/js 60 | text/xml 61 | text/javascript 62 | application/javascript 63 | application/x-javascript 64 | application/json 65 | application/xml 66 | application/rss+xml 67 | image/svg+xml; 68 | 69 | include /etc/nginx/conf.d/*.conf; 70 | } -------------------------------------------------------------------------------- /docs/ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | > All information below is a recommendations based on my previous experience. 4 | 5 | ## Core 6 | 7 | The main part of the application that implements common features that are independent of the project. 8 | 9 | ## Domain 10 | 11 | The domain layer implements it is all about project business logic and these objects can be classified looking at their design time characteristics: 12 | 13 | ### Domain Entities 14 | 15 | It is all about modelled business objects, attributes and their relationships. 16 | 17 | ### Domain Services 18 | 19 | It defines services that can be implemented in the same layer or in separate layers. This gives an abstraction for certain features as logging, exception handling, that can be managed differently depending on the environment that will use the domain layer. 20 | 21 | ### Domain Logic 22 | 23 | Implementation of logic linked to the business objects, as validation rules, factories and repositories. 24 | 25 | ## Data Access 26 | 27 | An architectural layer that consists of data-access objects and which implements the data access(using Adapter Pattern), hiding the details of implementation within the tier. 28 | 29 | ## Data Transfer Object 30 | 31 | An object that carries data between layers. 32 | 33 | > For example: You retrieve data and wanted to pass into Domain Layer. Domain layer waiting for Domain Entities, in such case we will implement DTO that converted Data Access Response into Domain Entity. 34 | 35 | ## Presentation 36 | 37 | The Presentation Layer is responsible for providing the user interface to the end user. 38 | 39 | ### Presenters 40 | 41 | It retrieves data from domain services, and formats it for display in the view (using mappers on the same level). 42 | 43 | ### Forms 44 | 45 | It implements forms behavior and provide API for work with it. 46 | 47 | ### [Any Additional Sub Layers] 48 | 49 | Above are described most popular sub-layers, you can also create your own sub-layers for the implementation like a Charts, Tables and etc. 50 | 51 | ### Web 52 | 53 | This layer is responsible for the implementation of the Web User Interface and is independent of the framework. 54 | 55 | > For example it can be Mobile layer implemented with react-native, or Desktop layer with Electron. 56 | 57 | -------------------------------------------------------------------------------- /docs/Application Layers.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /docs/CODE_CONVENTIONS.md: -------------------------------------------------------------------------------- 1 | # TypeScript coding 2 | 3 | 4 | ## Table of contents 5 | 6 | * [Typescript coding style guide](#typescript-coding-style-guide) 7 | * [Naming](#naming) 8 | * [Naming Conventions](#naming-conventions) 9 | * [Naming Booleans](#naming-booleans) 10 | * [Brackets](#brackets) 11 | * [Spaces](#spaces) 12 | * [Semicolons](#semicolons) 13 | * [Code Comments](#code-comments) 14 | * [Barrels](#barrels) 15 | * [GIT Conventions](#git-conventions) 16 | 17 | ## Typescript coding style guide 18 | 19 | ### Naming 20 | 21 | The name of a variable, function, or class, should answer all the big questions. It should tell you why it exists, what it does, and how it is used. If a name requires a comment, then the name does not reveal its intent. 22 | 23 | **Use meaningful variable names.** 24 | 25 | Distinguish names in such a way that the reader knows what the differences offer. 26 | 27 | Bad: 28 | 29 | ``` typescript 30 | function isBetween(a1: number, a2: number, a3: number): boolean { 31 | return a2 <= a1 && a1 <= a3; 32 | } 33 | ``` 34 | 35 | Good: 36 | 37 | ``` typescript 38 | function isBetween(value: number, left: number, right: number): boolean { 39 | return left <= value && value <= right; 40 | } 41 | ``` 42 | 43 | **Use pronounceable variable names** 44 | 45 | If you can't pronounce it, you can't discuss it without sounding weird. 46 | 47 | Bad: 48 | 49 | ``` typescript 50 | class Subs { 51 | public ccId: number; 52 | public billingAddrId: number; 53 | public shippingAddrId: number; 54 | } 55 | ``` 56 | 57 | Good: 58 | 59 | ``` typescript 60 | class Subscription { 61 | public creditCardId: number; 62 | public billingAddressId: number; 63 | public shippingAddressId: number; 64 | } 65 | ``` 66 | 67 | **Avoid mental mapping** 68 | 69 | Explicit is better than implicit.
70 | *Clarity is king.* 71 | 72 | Bad: 73 | 74 | ``` typescript 75 | const u = getUser(); 76 | const s = getSubscription(); 77 | const t = charge(u, s); 78 | ``` 79 | 80 | Good: 81 | 82 | ``` typescript 83 | const user = getUser(); 84 | const subscription = getSubscription(); 85 | const transaction = charge(user, subscription); 86 | ``` 87 | 88 | **Don't add unneeded context** 89 | 90 | If your class/type/object name tells you something, don't repeat that in your variable name. 91 | 92 | Bad: 93 | 94 | ``` typescript 95 | type Car = { 96 | carMake: string; 97 | carModel: string; 98 | carColor: string; 99 | } 100 | 101 | function print(car: Car): void { 102 | console.log(`${car.carMake} ${car.carModel} (${car.carColor})`); 103 | } 104 | ``` 105 | 106 | Good: 107 | 108 | ``` typescript 109 | type Car = { 110 | make: string; 111 | model: string; 112 | color: string; 113 | } 114 | 115 | function print(car: Car): void { 116 | console.log(`${car.make} ${car.model} (${car.color})`); 117 | } 118 | ``` 119 | 120 | ### Naming Conventions 121 | 122 | * Use camelCase for variable and function names 123 | 124 | Bad: 125 | 126 | ``` typescript 127 | var FooVar; 128 | function BarFunc() { } 129 | ``` 130 | 131 | Good: 132 | 133 | ``` typescript 134 | var fooVar; 135 | function barFunc() { } 136 | ``` 137 | 138 | * Use camelCase of class members, interface members, methods and methods parameters 139 | 140 | Bad: 141 | 142 | ``` typescript 143 | class Foo { 144 | Bar: number; 145 | Baz() { } 146 | } 147 | ``` 148 | 149 | Good: 150 | 151 | ``` typescript 152 | class Foo { 153 | bar: number; 154 | baz() { } 155 | } 156 | ``` 157 | 158 | * Use PascalCase for class names and interface names. 159 | 160 | Bad: 161 | 162 | ``` typescript 163 | class foo { } 164 | ``` 165 | 166 | Good: 167 | 168 | ``` typescript 169 | class Foo { } 170 | ``` 171 | 172 | * Use PascalCase for enums and camelCase for enum members 173 | 174 | Bad: 175 | 176 | ``` typescript 177 | enum notificationTypes { 178 | Default = 0, 179 | Info = 1, 180 | Success = 2, 181 | Error = 3, 182 | Warning = 4 183 | } 184 | ``` 185 | 186 | Good: 187 | 188 | ``` typescript 189 | enum NotificationTypes { 190 | default = 0, 191 | info = 1, 192 | success = 2, 193 | error = 3, 194 | warning = 4 195 | } 196 | ``` 197 | 198 | ### Naming Booleans 199 | 200 | * Don't use negative names for boolean variables. 201 | 202 | Bad: 203 | 204 | ``` typescript 205 | const isNotEnabled = true; 206 | ``` 207 | 208 | Good: 209 | 210 | ``` typescript 211 | const isEnabled = false; 212 | ``` 213 | 214 | * A prefix like is, are, or has helps every developer to distinguish a boolean from another variable by just looking at it 215 | 216 | Bad: 217 | 218 | ``` typescript 219 | const enabled = false; 220 | ``` 221 | 222 | Good: 223 | 224 | ``` typescript 225 | const isEnabled = false; 226 | ``` 227 | 228 | ### Brackets 229 | 230 | * **OTBS** (one true brace style). [Wikipedia](https://en.wikipedia.org/wiki/Indentation_style#Variant:_1TBS_(OTBS)) 231 | 232 | The one true brace style is one of the most common brace styles in TypeScript, in which the opening brace of a block is placed on the same line as its corresponding statement or declaration. 233 | 234 | ``` typescript 235 | if (foo) { 236 | bar(); 237 | } else { 238 | baz(); 239 | } 240 | ``` 241 | 242 | * Do not omit curly brackets 243 | 244 | * **Always** wrap the body of the statement in curly brackets. 245 | 246 | ### Spaces 247 | 248 | Use 2 spaces. Not tabs. 249 | 250 | ### Semicolons 251 | 252 | Use semicolons. 253 | 254 | ### Code Comments 255 | 256 | > So when you find yourself in a position where you need to write a comment, think it through and see whether there isn't some way to turn the tables and express yourself in code. Every time you express yourself in code, you should pat yourself on the back. Everytime you write a comment, you should grimace and feel the failure of your ability of expression. 257 | 258 | **Bad Comments** 259 | 260 | Most comments fall into this category. Usually they are crutches or excuses for poor code or justifications for insufficient decisions, amounting to little more than the programmer talking to himself. 261 | 262 | **Mumbling** 263 | 264 | Plopping in a comment just because you feel you should or because the process requires it, is a hack. If you decide to write a comment, then spend the time necessary to make sure it is the best comment you can write. 265 | 266 | **Noise Comments** 267 | 268 | Sometimes you see comments that are nothing but noise. They restate the obvious and provide no new information. 269 | 270 | ``` typescript 271 | // redirect to the Contact Details screen 272 | this.router.navigateByUrl(`/${ROOT}/contact`); 273 | ``` 274 | 275 | ``` typescript 276 | // self explanatory, parse ... 277 | this.parseProducts(products); 278 | ``` 279 | 280 | **Scary noise** 281 | 282 | ``` typescript 283 | /** The name. */ 284 | private name; 285 | 286 | /** The version. */ 287 | private version; 288 | 289 | /** The licenceName. */ 290 | private licenceName; 291 | 292 | /** The version. */ 293 | private info; 294 | ``` 295 | 296 | Read these comments again more carefully. Do you see the cut-paste error? If authors aren't paying attention when comments are written (or pasted), why should readers be expected to profit from them? 297 | 298 | **TODO Comments** 299 | 300 | In general, TODO comments are a big risk. We may see something that we want to do later so we drop a quick **// TODO: Replace this method** thinking we'll come back to it but never do. 301 | 302 | If you're going to write a TODO comment, you should link to your external issue tracker. 303 | 304 | There are valid use cases for a TODO comment. Perhaps you're working on a big feature and you want to make a pull request that only fixes part of it. You also want to call out some refactoring that still needs to be done, but that you'll fix in another PR. 305 | 306 | ``` typescript 307 | // TODO: Consolidate both of these classes. PURCHASE-123 308 | ``` 309 | 310 | This is actionable because it forces us to go to our issue tracker and create a ticket. That is less likely to get lost than a code comment that will potentially never be seen again. 311 | 312 | **Comments can sometimes be useful** 313 | 314 | * When explaining why something is being implemented in a particular way. 315 | * When explaining complex algorithms (when all other methods for simplifying the algorithm have been tried and come up short). 316 | 317 | **Comment conventions** 318 | 319 | * Write comments in *English*. 320 | 321 | * Do not add empty comments 322 | 323 | * Begin single-line comments with a single space 324 | 325 | Good: 326 | 327 | ``` typescript 328 | // Single-line comment 329 | ``` 330 | 331 | Bad: 332 | 333 | ``` typescript 334 | //Single-line comment 335 | // Single-line comment 336 | ``` 337 | 338 | * Write single-line comments properly 339 | 340 | * Single-line comments should always be preceded by a single blank line. 341 | * Single-line comments should never be followed by blank line(s). 342 | 343 | Good: 344 | 345 | ``` typescript 346 | const x; 347 | 348 | // This comment is valid 349 | const y; 350 | ``` 351 | 352 | Bad: 353 | 354 | ``` typescript 355 | const x; 356 | 357 | // This comment is not valid 358 | 359 | const y; 360 | ``` 361 | ``` typescript 362 | const x; 363 | // This comment is not valid 364 | 365 | const y; 366 | ``` 367 | 368 | * Do not write embedded comments 369 | 370 | * Do not write comments between declaration of statement and opening curly brackets. 371 | * Place comments above statements, or within statement body. 372 | 373 | Good: 374 | 375 | ``` typescript 376 | // This method does something.. 377 | public method() { 378 | } 379 | ``` 380 | 381 | Bad: 382 | 383 | ``` typescript 384 | public method() { // This method does something.. 385 | } 386 | ``` 387 | 388 | ``` typescript 389 | public method() { 390 | // This method does something.. 391 | } 392 | ``` 393 | 394 | ### Barrels 395 | 396 | > A barrel is a way to rollup exports from several modules into a single convenience module. The barrel itself is a module file that re-exports selected exports of other modules. 397 | 398 | > **import noise** - this is an issue seen in languages where there are dependencies that need to be "imported", "required", or "included" and the first (1 - n) lines are non functional code. 399 | 400 | Example of a barrel file: 401 | 402 | ``` typescript 403 | export * from './product-added-dialog'; 404 | export * from './website-selector'; 405 | export * from './product-family-selector'; 406 | export * from './individual-product-selector'; 407 | export * from './license-type-selector'; 408 | export * from './period-and-quantity-selector'; 409 | ``` 410 | 411 | How to use it inside components: 412 | 413 | Good: 414 | 415 | ``` typescript 416 | import { ProductAddedDialog, WebsiteSelector, ProductFamilySelector } from 'presentation/components'; 417 | ``` 418 | 419 | Bad: 420 | 421 | ``` typescript 422 | import { ProductAddedDialog } from 'presentation/components/product-added-dialog'; 423 | import { WebsiteSelector } from 'presentation/components/website-selector'; 424 | import { ProductFamilySelector } from 'presentation/components/product-family-selector'; 425 | ``` 426 | 427 | * Barrel files are named index.ts by convention 428 | * Do not import a barrel in the files that are already used in that barrel because this leads to circular dependency 429 | 430 | 431 | ## GIT Conventions 432 | 433 | [Click to see commits conventional](https://www.conventionalcommits.org/en/v1.0.0/) 434 | 435 | 436 | -------------------------------------------------------------------------------- /docs/SCRIPTS.md: -------------------------------------------------------------------------------- 1 | # Project Scripts 2 | 3 | All available scripts for project. 4 | 5 | `npm install` - install dependencies; 6 | 7 | ## Running Application 8 | 9 | * type - web | mobile. Default: web; 10 | 11 | `npm run start:[type]` - run dev server; 12 | 13 | `npm run preview` - run build preview server; 14 | 15 | `npm run build:[web|mobile]` - build target application. 16 | 17 | `npm run build` - build all applications. 18 | 19 | `npm run start:stage` - run dev server with staged ENV config; 20 | 21 | ## Running Linters 22 | 23 | `npm run lint` - check project using eslint and tslint; 24 | 25 | `npm run eslint` - check project using eslint; 26 | 27 | - `npm run eslint --fix` - fix all possible eslint issues; 28 | 29 | `npm run tslint` - check project using tslint. 30 | 31 | ## Running Tests 32 | 33 | `npm run test` - run all project tests; 34 | 35 | `npm run test:ci` - run all project tests and collect coverage. 36 | 37 | ## Running Generators 38 | 39 | `npm run generator` - run and follow instructions; 40 | -------------------------------------------------------------------------------- /docs/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | const { fixupConfigRules, fixupPluginRules } = require('@eslint/compat'); 4 | 5 | const typescriptEslint = require('@typescript-eslint/eslint-plugin'); 6 | const path = require('eslint-plugin-path'); 7 | const prettier = require('eslint-plugin-prettier'); 8 | const sortKeysFix = require('eslint-plugin-sort-keys-fix'); 9 | const pluginImport = require('eslint-plugin-import'); 10 | const simpleImportSort = require('eslint-plugin-simple-import-sort'); 11 | const unusedImports = require('eslint-plugin-unused-imports'); 12 | const globals = require('globals'); 13 | const tsParser = require('@typescript-eslint/parser'); 14 | const js = require('@eslint/js'); 15 | 16 | const { FlatCompat } = require('@eslint/eslintrc'); 17 | 18 | const compat = new FlatCompat({ 19 | allConfig: js.configs.all, 20 | baseDirectory: __dirname, 21 | recommendedConfig: js.configs.recommended, 22 | }); 23 | 24 | module.exports = [ 25 | { 26 | ignores: [ 27 | 'node_modules/*', 28 | '**/package.json', 29 | '**/package-lock.json', 30 | '**/yarn.lock', 31 | '**/yarn-error.log', 32 | ], 33 | }, 34 | ...fixupConfigRules( 35 | compat.extends( 36 | 'airbnb', 37 | 'airbnb-typescript', 38 | 'airbnb/hooks', 39 | 'plugin:react/recommended', 40 | 'plugin:prettier/recommended', 41 | 'plugin:import/typescript', 42 | 'plugin:@typescript-eslint/recommended', 43 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 44 | ), 45 | ), 46 | { 47 | languageOptions: { 48 | ecmaVersion: 5, 49 | 50 | globals: { 51 | ...globals.browser, 52 | ...globals.node, 53 | ...globals.jest, 54 | }, 55 | parser: tsParser, 56 | parserOptions: { 57 | ecmaFeatures: { 58 | experimentalObjectRestSpread: true, 59 | jsx: true, 60 | legacyDecorators: true, 61 | }, 62 | 63 | project: './tsconfig.json', 64 | 65 | useJSXTextNode: true, 66 | }, 67 | 68 | sourceType: 'module', 69 | }, 70 | plugins: { 71 | '@typescript-eslint': fixupPluginRules(typescriptEslint), 72 | import: fixupPluginRules(pluginImport), 73 | path, 74 | prettier: fixupPluginRules(prettier), 75 | 'simple-import-sort': simpleImportSort, 76 | 'sort-keys-fix': sortKeysFix, 77 | 'unused-imports': unusedImports, 78 | }, 79 | rules: { 80 | '@typescript-eslint/array-type': 'off', 81 | 82 | '@typescript-eslint/member-delimiter-style': 'error', 83 | '@typescript-eslint/no-explicit-any': 'warn', 84 | '@typescript-eslint/no-floating-promises': 'off', 85 | '@typescript-eslint/no-namespace': 'off', 86 | '@typescript-eslint/no-unnecessary-type-constraint': 'off', 87 | '@typescript-eslint/no-unsafe-assignment': 'off', 88 | '@typescript-eslint/no-unsafe-call': 'off', 89 | '@typescript-eslint/no-unsafe-member-access': 'off', 90 | '@typescript-eslint/no-unused-vars': 'off', 91 | '@typescript-eslint/unbound-method': 'off', 92 | 'class-methods-use-this': 'off', 93 | 'consistent-return': 'off', 94 | curly: 'error', 95 | 'guard-for-in': 'off', 96 | 'import/extensions': 'off', 97 | 'import/no-cycle': 'off', 98 | 'import/no-extraneous-dependencies': 'off', 99 | 'import/no-unresolved': 'error', 100 | 'import/no-useless-path-segments': 'off', 101 | 'import/order': 'off', 102 | 'import/prefer-default-export': 'off', 103 | 'jsx-a11y/control-has-associated-label': 'off', 104 | 105 | 'linebreak-style': 'off', 106 | 107 | 'lines-between-class-members': 'error', 108 | 'newline-before-return': 'error', 109 | 'no-bitwise': 'off', 110 | 111 | 'no-console': 'error', 112 | 113 | 'no-multiple-empty-lines': [ 114 | 'error', 115 | { 116 | max: 1, 117 | maxEOF: 1, 118 | }, 119 | ], 120 | 'no-param-reassign': 'off', 121 | 'no-plusplus': 'off', 122 | 'no-prototype-builtins': 'off', 123 | 'no-restricted-syntax': 'off', 124 | 'no-sequences': 'off', 125 | 'no-shadow': 'off', 126 | 'no-underscore-dangle': 'off', 127 | 'no-unused-expressions': 'off', 128 | 'no-unused-vars': 'off', 129 | 'no-useless-constructor': 'off', 130 | 'object-curly-newline': 'off', 131 | 'object-curly-spacing': 'off', 132 | 'object-property-newline': 'off', 133 | 'one-var': 'off', 134 | 'one-var-declaration-per-line': 'error', 135 | 'path/no-relative-imports': [ 136 | 'error', 137 | { 138 | maxDepth: 2, 139 | suggested: false, 140 | }, 141 | ], 142 | 'prefer-const': 'error', 143 | 'prettier/prettier': 'error', 144 | 'quote-props': ['error', 'as-needed'], 145 | 'react-hooks/exhaustive-deps': 'warn', 146 | 'react-hooks/rules-of-hooks': 'error', 147 | 'react/destructuring-assignment': 'off', 148 | 149 | 'react/forbid-prop-types': 'off', 150 | 151 | 'react/function-component-definition': 'off', 152 | 'react/jsx-props-no-spreading': 'off', 153 | 'react/no-unused-prop-types': 'warn', 154 | 'react/prefer-stateless-function': 'off', 155 | 'react/prop-types': 'warn', 156 | 'react/require-default-props': 'off', 157 | 'require-await': 'error', 158 | 'simple-import-sort/imports': [ 159 | 'error', 160 | { 161 | groups: [ 162 | ['^\\u0000', '^react$', '^react', '^@?\\w', '^__mocks__(/.*|$)'], 163 | ['^(containers|core|data|domain|presentation|types)(/.*|$)'], 164 | ['^\\.\\.(?!/?$)', '^\\.\\./?$', '^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], 165 | ], 166 | }, 167 | ], 168 | 'sort-keys-fix/sort-keys-fix': 'error', 169 | 'unused-imports/no-unused-imports': 'error', 170 | 'unused-imports/no-unused-vars': [ 171 | 'warn', 172 | { 173 | args: 'after-used', 174 | argsIgnorePattern: '^_', 175 | vars: 'all', 176 | varsIgnorePattern: '^_', 177 | }, 178 | ], 179 | }, 180 | 181 | settings: { 182 | 'import/resolver': { 183 | node: { 184 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 185 | moduleDirectory: ['node_modules', 'src/', './'], 186 | }, 187 | }, 188 | 189 | react: { 190 | version: 'detect', 191 | }, 192 | }, 193 | }, 194 | ]; 195 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | <%- title %> Example Project 12 | 13 | 14 |
15 |
16 | 17 | <%- injectScript %> 18 | 19 | 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-project-template", 3 | "author": { 4 | "name": "qDanik", 5 | "email": "qdanik@yandex.ru", 6 | "url": "https://vk.com/qdanik" 7 | }, 8 | "engines": { 9 | "node": ">= 20" 10 | }, 11 | "version": "4.0.0", 12 | "main": "src/presentation/web/index.tsx", 13 | "scripts": { 14 | "preview": "platform=web vite preview --config ./setup/vite/config.ts --mode=preview", 15 | "start": "platform=web vite --config ./setup/vite/config.ts --host --mode=dev", 16 | "start:web:stage": "platform=web vite --config ./setup/vite/config.ts --host --mode=stage", 17 | "build:web": "platform=web vite --config ./setup/vite/config.ts build --mode=prod", 18 | "preview:mobile": "platform=mobile vite preview --config ./setup/vite/config.ts --mode=preview", 19 | "start:mobile": "platform=mobile vite --config ./setup/vite/config.ts --host --mode=dev", 20 | "start:mobile:stage": "platform=mobile vite --config ./setup/vite/config.ts --host --mode=stage", 21 | "build:mobile": "platform=mobile vite --config ./setup/vite/config.ts build --mode=prod", 22 | "build": "npm run build:web && npm run build:mobile", 23 | "lint": "npm run eslint && npm run tslint", 24 | "eslint": "eslint \"./{src,setup}/**/*.{ts,tsx}\" --max-warnings 0", 25 | "eslint:fix": "eslint \"./{src,setup}/**/*.{ts,tsx}\" --max-warnings 0 --fix", 26 | "eslint:perf": "TIMING=1 npm run eslint", 27 | "tslint": "tsc --pretty --noEmit", 28 | "test": "jest --config ./setup/jest/config.ts --verbose --runInBand", 29 | "test:ci": "jest --config ./setup/jest/config.ts --no-cache --coverage", 30 | "postinstall": "husky ./setup/husky && chmod ug+x ./setup/husky/*", 31 | "pre-commit": "lint-staged", 32 | "pre-push": "npm run build", 33 | "commitlint": "commitlint -e", 34 | "generate": "node --no-warnings --experimental-loader esbuild-node-loader ./setup/plopjs/index.ts", 35 | "docker:up": "docker-compose -f ./docker/docker-compose.yml up -d --build", 36 | "docker:stop": "docker-compose -f ./docker/docker-compose.yml stop", 37 | "docker:down": "docker-compose -f ./docker/docker-compose.yml down" 38 | }, 39 | "dependencies": { 40 | "ahooks": "^3.8.0", 41 | "axios": "^1.7.2", 42 | "date-fns": "^3.6.0", 43 | "i18next": "^23.11.5", 44 | "i18next-browser-languagedetector": "^8.0.0", 45 | "i18next-http-backend": "^2.5.2", 46 | "i18next-resources-to-backend": "^1.2.1", 47 | "inversify": "^6.0.2", 48 | "lodash": "^4.17.21", 49 | "mobx": "^6.13.0", 50 | "mobx-react": "^9.1.1", 51 | "react": "^18.3.1", 52 | "react-dom": "^18.3.1", 53 | "react-helmet": "^6.1.0", 54 | "react-hook-form": "^7.52.1", 55 | "react-i18next": "^14.1.2", 56 | "react-router": "^6.24.1", 57 | "react-router-dom": "^6.24.1", 58 | "reflect-metadata": "^0.2.2", 59 | "styled-components": "^6.1.11", 60 | "yup": "^1.4.0" 61 | }, 62 | "devDependencies": { 63 | "@babel/plugin-proposal-decorators": "^7.24.7", 64 | "@babel/plugin-transform-react-jsx": "^7.24.7", 65 | "@babel/preset-env": "^7.24.7", 66 | "@babel/preset-typescript": "^7.24.7", 67 | "@babel/register": "^7.24.6", 68 | "@commitlint/cli": "^19.3.0", 69 | "@commitlint/config-conventional": "^19.2.2", 70 | "@eslint/compat": "^1.1.0", 71 | "@eslint/eslintrc": "^3.1.0", 72 | "@eslint/js": "^9.6.0", 73 | "@svgr/core": "^8.1.0", 74 | "@svgr/plugin-jsx": "^8.1.0", 75 | "@svgr/plugin-svgo": "^8.1.0", 76 | "@testing-library/dom": "^10.3.1", 77 | "@testing-library/jest-dom": "^6.4.6", 78 | "@testing-library/react": "^16.0.0", 79 | "@types/jest": "^29.5.12", 80 | "@types/lodash": "^4.17.6", 81 | "@types/node": "^20.14.10", 82 | "@types/react-dom": "^18.3.0", 83 | "@types/styled-components": "^5.1.34", 84 | "@typescript-eslint/eslint-plugin": "^7.15.0", 85 | "@typescript-eslint/parser": "^7.15.0", 86 | "@vitejs/plugin-react": "^4.3.1", 87 | "@vitejs/plugin-react-swc": "^3.7.0", 88 | "dotenv": "^16.4.5", 89 | "esbuild-node-loader": "^0.8.0", 90 | "eslint": "^9.6.0", 91 | "eslint-config-airbnb": "^19.0.4", 92 | "eslint-config-airbnb-typescript": "^18.0.0", 93 | "eslint-config-prettier": "^9.1.0", 94 | "eslint-plugin-import": "^2.29.1", 95 | "eslint-plugin-jsx-a11y": "^6.9.0", 96 | "eslint-plugin-path": "^1.3.0", 97 | "eslint-plugin-prettier": "^5.1.3", 98 | "eslint-plugin-react": "^7.34.3", 99 | "eslint-plugin-react-hooks": "^4.6.2", 100 | "eslint-plugin-simple-import-sort": "12.1.1", 101 | "eslint-plugin-sort-keys-fix": "^1.1.2", 102 | "eslint-plugin-unused-imports": "^4.0.0", 103 | "globals": "^15.8.0", 104 | "husky": "^9.0.11", 105 | "jest": "^29.7.0", 106 | "jest-environment-jsdom": "^29.7.0", 107 | "jest-styled-components": "^7.2.0", 108 | "less": "^4.2.0", 109 | "lint-staged": "^15.2.7", 110 | "plop": "^4.0.1", 111 | "prettier": "^3.3.2", 112 | "pretty-format": "^29.7.0", 113 | "rollup-plugin-delete": "^2.0.0", 114 | "ts-jest": "^29.1.5", 115 | "ts-node": "^10.9.2", 116 | "typescript": "^5.5.3", 117 | "vite": "^5.3.3", 118 | "vite-plugin-compression": "^0.5.1", 119 | "vite-plugin-html": "^3.2.2", 120 | "vite-plugin-mkcert": "^1.17.5", 121 | "vite-tsconfig-paths": "^4.3.2" 122 | }, 123 | "optionalDependencies": { 124 | "@rollup/rollup-linux-x64-gnu": "^4.18.1" 125 | }, 126 | "commitlint": { 127 | "extends": [ 128 | "@commitlint/config-conventional" 129 | ] 130 | }, 131 | "eslintConfig": { 132 | "extends": [ 133 | "./setup/eslint.config.js" 134 | ] 135 | }, 136 | "babel": { 137 | "extends": "./setup/.babelrc" 138 | }, 139 | "lint-staged": { 140 | "*.{ts,tsx}": [ 141 | "jest --config ./setup/jest/config.ts --silent --watchAll=false --coverage=false --findRelatedTests --passWithNoTests", 142 | "eslint \"./{src,setup}/**/*.{ts,tsx}\" --max-warnings 0 --fix" 143 | ] 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /public/assets/fonts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qdanik/react-clean-architecture-boilerplate/70de87c89d0f5c4ab7e976a8767a454d7681c712/public/assets/fonts/.gitkeep -------------------------------------------------------------------------------- /public/assets/images/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qdanik/react-clean-architecture-boilerplate/70de87c89d0f5c4ab7e976a8767a454d7681c712/public/assets/images/.gitkeep -------------------------------------------------------------------------------- /public/assets/locales/en/translation.mobile.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth.form.labels.login": "Input Login [Mobile]", 3 | "button.login": "Sign In [Mobile]", 4 | "welcome": "Hello world! [Mobile]" 5 | } -------------------------------------------------------------------------------- /public/assets/locales/en/translation.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth.form.labels.login": "Input Login [WEB]", 3 | "button.login": "Sign In [WEB]", 4 | "welcome": "Hello world! [WEB]" 5 | } -------------------------------------------------------------------------------- /public/assets/locales/ru/translation.mobile.json: -------------------------------------------------------------------------------- 1 | { 2 | "welcome": "Привет мир!", 3 | "auth.form.labels.login": "Введите логин", 4 | "button.login": "Войти" 5 | } -------------------------------------------------------------------------------- /public/assets/locales/ru/translation.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "welcome": "Привет мир!", 3 | "auth.form.labels.login": "Введите логин", 4 | "button.login": "Войти" 5 | } -------------------------------------------------------------------------------- /setup/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "targets": { "node": "current" } }], 4 | "@babel/preset-typescript" 5 | ], 6 | "plugins": [ 7 | ["babel-plugin-styled-components", { 8 | "minify": false, 9 | "transpileTemplateLiterals": false 10 | }], 11 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 12 | ["@babel/plugin-proposal-class-properties", { "loose": true }] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /setup/env/.mobile.dev.env: -------------------------------------------------------------------------------- 1 | BACKEND_URL=PROXY_SERVER_URL 2 | BACKEND_PROXIES=/token 3 | 4 | DEV_SERVER_HOST=127.0.0.1 5 | DEV_SERVER_PORT=3001 6 | 7 | AUTH_TOKEN=AUTH_MOBILE_TOKEN -------------------------------------------------------------------------------- /setup/env/.mobile.prod.env: -------------------------------------------------------------------------------- 1 | BACKEND_URL=PROXY_SERVER_URL 2 | BACKEND_PROXIES=/token 3 | 4 | DEV_SERVER_HOST=127.0.0.1 5 | DEV_SERVER_PORT=3001 6 | 7 | AUTH_TOKEN=AUTH_MOBILE_PROD_TOKEN -------------------------------------------------------------------------------- /setup/env/.mobile.stage.env: -------------------------------------------------------------------------------- 1 | BACKEND_URL=PROXY_SERVER_URL 2 | BACKEND_PROXIES=/token 3 | 4 | DEV_SERVER_HOST=127.0.0.1 5 | DEV_SERVER_PORT=3001 6 | 7 | AUTH_TOKEN=AUTH_MOBILE_STAGE_TOKEN -------------------------------------------------------------------------------- /setup/env/.web.dev.env: -------------------------------------------------------------------------------- 1 | BACKEND_URL=PROXY_SERVER_URL 2 | BACKEND_PROXIES=/token 3 | 4 | DEV_SERVER_HOST=127.0.0.1 5 | DEV_SERVER_PORT=3000 6 | 7 | AUTH_TOKEN=AUTH_DEV_TOKEN -------------------------------------------------------------------------------- /setup/env/.web.prod.env: -------------------------------------------------------------------------------- 1 | BACKEND_URL=PROXY_SERVER_URL 2 | BACKEND_PROXIES=/token 3 | 4 | DEV_SERVER_HOST=127.0.0.1 5 | DEV_SERVER_PORT=3000 6 | 7 | AUTH_TOKEN=AUTH_PROD_TOKEN -------------------------------------------------------------------------------- /setup/env/.web.stage.env: -------------------------------------------------------------------------------- 1 | BACKEND_URL=PROXY_SERVER_URL 2 | BACKEND_PROXIES=/token 3 | 4 | DEV_SERVER_HOST=127.0.0.1 5 | DEV_SERVER_PORT=3000 6 | 7 | AUTH_TOKEN=AUTH_STAGE_TOKEN -------------------------------------------------------------------------------- /setup/husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/common.sh" 3 | . "$(dirname "$0")/_/husky.sh" 4 | 5 | npm run commitlint 6 | -------------------------------------------------------------------------------- /setup/husky/common.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ "$OSTYPE" =~ ^darwin ]]; then 4 | export NVM_DIR="$HOME/.nvm" 5 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 6 | fi 7 | 8 | if [[ "$OSTYPE" =~ ^msys ]]; then 9 | command_exists () { 10 | command -v "$1" >/dev/null 2>&1 11 | } 12 | 13 | # Workaround for Windows 10, Git Bash and Yarn 14 | if command_exists winpty && test -t 1; then 15 | exec < /dev/tty 16 | fi 17 | fi -------------------------------------------------------------------------------- /setup/husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/common.sh" 3 | . "$(dirname "$0")/_/husky.sh" 4 | 5 | npm run pre-commit 6 | -------------------------------------------------------------------------------- /setup/husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/common.sh" 3 | . "$(dirname "$0")/_/husky.sh" 4 | 5 | npm run pre-push 6 | -------------------------------------------------------------------------------- /setup/jest/config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | process.env.TZ = 'UTC'; 4 | 5 | const config: Config.InitialOptions = { 6 | collectCoverageFrom: ['/src/{domain,data,core}/**/*.ts'], 7 | coverageDirectory: 'coverage', 8 | coveragePathIgnorePatterns: [ 9 | '/node_modules', 10 | '/setup', 11 | '/assets', 12 | '/src/presentation/{mobile,web}/index.tsx', 13 | '/src/presentation/{mobile,web}/app.component.tsx', 14 | '.d.ts', 15 | '.mock.ts', 16 | 'index.ts', 17 | ], 18 | coverageThreshold: { 19 | global: { 20 | branches: 0, 21 | functions: 0, 22 | lines: 0, 23 | statements: 0, 24 | }, 25 | }, 26 | globals: { 27 | APP_PLATFORM: 'web', 28 | AUTH_TOKEN: '', 29 | IS_DEV: false, 30 | UI_VERSION: '0.0.123', 31 | }, 32 | moduleDirectories: ['/node_modules', '/node_modules/@types', '/src'], 33 | preset: 'ts-jest', 34 | rootDir: '../../', 35 | roots: ['/src'], 36 | setupFiles: ['/setup/jest/setup.ts'], 37 | setupFilesAfterEnv: ['/setup/jest/setup-after-env.ts'], 38 | testEnvironment: 'jsdom', 39 | testRegex: ['.test.ts$', '.spec.ts$', '.test.tsx$', '.spec.tsx$'], 40 | transform: { 41 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 42 | '/setup/jest/plugins/fileTransformer.js', 43 | '^.+\\.(tsx|ts)?$': ['ts-jest', { tsconfig: '/tsconfig.json' }], 44 | }, 45 | }; 46 | 47 | export default config; 48 | -------------------------------------------------------------------------------- /setup/jest/plugins/fileTransformer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { basename } = require('path'); 3 | const { camelCase, capitalize } = require('lodash'); 4 | const { compose } = require('lodash/fp'); 5 | 6 | /** 7 | * 8 | * @param {any} _ 9 | * @param {string} filename 10 | * @returns {{code: string}} 11 | */ 12 | function process(_, filename) { 13 | const name = compose( 14 | capitalize, 15 | camelCase, 16 | value => value.replace(/\./g, ' '), 17 | basename, 18 | )(filename); 19 | 20 | return { 21 | code: `module.exports = ${JSON.stringify(name)};`, 22 | }; 23 | } 24 | 25 | module.exports = { 26 | process, 27 | }; 28 | -------------------------------------------------------------------------------- /setup/jest/setup-after-env.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import '@testing-library/jest-dom'; 3 | import 'jest-styled-components'; 4 | -------------------------------------------------------------------------------- /setup/jest/setup.ts: -------------------------------------------------------------------------------- 1 | // render 2 | export * from '@testing-library/react'; 3 | -------------------------------------------------------------------------------- /setup/plopjs/constants.ts: -------------------------------------------------------------------------------- 1 | export const ROOT_PATH = '../../'; 2 | 3 | export const REPO_DIR = `${ROOT_PATH}src/data/repositories/{{dashCase domainName}}/`; 4 | export const REPO_PATH = `${REPO_DIR}{{dashCase repoName}}`; 5 | 6 | export const DOMAIN_PATH = `${ROOT_PATH}src/domain/{{dashCase domainName}}`; 7 | export const SERVICE_DIR = `${DOMAIN_PATH}/services/`; 8 | export const SERVICE_PATH = `${SERVICE_DIR}{{dashCase serviceName}}`; 9 | 10 | export const ENTITIES_DIR = `${DOMAIN_PATH}/entities/`; 11 | export const DTO_DIR = `${ROOT_PATH}src/data/dto/{{dashCase domainName}}/`; 12 | 13 | export const PROMPTS = { 14 | domainName: { 15 | message: 'Domain name:', 16 | name: 'domainName', 17 | type: 'input', 18 | }, 19 | entityName: { 20 | message: 'Entity name (Press enter to skip):', 21 | name: 'entityName', 22 | type: 'input', 23 | }, 24 | hasEntities: { 25 | message: 'Should generate entities?', 26 | name: 'hasEntities', 27 | type: 'confirm', 28 | }, 29 | hasRepo: { 30 | message: 'Should create a repository?', 31 | name: 'hasRepo', 32 | type: 'confirm', 33 | }, 34 | repoName: { 35 | message: 'Repo name:', 36 | name: 'repoName', 37 | type: 'input', 38 | }, 39 | serviceName: { 40 | message: 'Service name (Press enter to skip):', 41 | name: 'serviceName', 42 | type: 'input', 43 | }, 44 | }; 45 | 46 | export const infinityPrompt = (inquirer, prompt): Promise => { 47 | const result: string[] = []; 48 | 49 | async function handlerPrompt(): Promise { 50 | const promptResult = await inquirer.prompt([prompt]); 51 | const answer: string = promptResult[prompt.name]; 52 | 53 | if (answer) { 54 | result.push(answer); 55 | 56 | return handlerPrompt(); 57 | } 58 | 59 | return Promise.resolve(result); 60 | } 61 | 62 | return handlerPrompt(); 63 | }; 64 | -------------------------------------------------------------------------------- /setup/plopjs/domain.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, PlopGeneratorConfig } from 'plop'; 2 | 3 | import { DOMAIN_PATH, infinityPrompt, PROMPTS } from './constants'; 4 | import { getEntitiesActions } from './entity'; 5 | import { getRepositoriesActions } from './repo'; 6 | import { getServiceActions } from './service'; 7 | 8 | type Data = { 9 | domainName: string; 10 | entities?: string[]; 11 | hasRepo: boolean; 12 | }; 13 | 14 | const config: PlopGeneratorConfig = { 15 | actions: (data: Data) => { 16 | const hasEntities = Boolean(data?.entities?.length); 17 | 18 | if (!data.domainName) { 19 | throw Error('Domain Name is required option.'); 20 | } 21 | 22 | const actionData = { 23 | domainName: data.domainName, 24 | entities: data.entities || [], 25 | hasEntities, 26 | serviceName: data.domainName, 27 | services: [data.domainName], 28 | }; 29 | 30 | const actions: ActionType[] = [ 31 | { 32 | data: actionData, 33 | path: `${DOMAIN_PATH}/index.ts`, 34 | templateFile: './templates/index.hbs', 35 | type: 'add', 36 | }, 37 | ]; 38 | 39 | return [ 40 | ...actions, 41 | ...getEntitiesActions({ actionData, data, hasEntities }), 42 | ...getServiceActions({ actionData, data: actionData }), 43 | ...getRepositoriesActions({ 44 | data: { 45 | domainName: data.domainName, 46 | repositories: [data.domainName], 47 | }, 48 | }), 49 | ]; 50 | }, 51 | description: 'Add new domain', 52 | prompts: async (inquirer): Promise => { 53 | const { domainName, hasEntities, hasRepo } = await inquirer.prompt([ 54 | PROMPTS.domainName, 55 | PROMPTS.hasEntities, 56 | PROMPTS.hasRepo, 57 | ]); 58 | 59 | if (!hasEntities) { 60 | return { domainName, hasRepo }; 61 | } 62 | 63 | const entities = await infinityPrompt(inquirer, PROMPTS.entityName); 64 | 65 | return { 66 | domainName, 67 | entities, 68 | hasRepo, 69 | }; 70 | }, 71 | }; 72 | 73 | export default config; 74 | -------------------------------------------------------------------------------- /setup/plopjs/entity.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, PlopGeneratorConfig } from 'plop'; 2 | 3 | import { DTO_DIR, ENTITIES_DIR, infinityPrompt, PROMPTS } from './constants'; 4 | 5 | type Data = { 6 | entities: string[]; 7 | domainName: string; 8 | }; 9 | 10 | export const getEntitiesActions = ({ data, actionData, hasEntities = true }): ActionType[] => { 11 | if (!hasEntities) { 12 | return []; 13 | } 14 | const actions: ActionType[] = [ 15 | { 16 | data: actionData, 17 | path: `${ENTITIES_DIR}index.ts`, 18 | skipIfExists: true, 19 | template: '', 20 | type: 'add', 21 | }, 22 | { 23 | data: actionData, 24 | path: `${DTO_DIR}index.ts`, 25 | skipIfExists: true, 26 | template: '', 27 | type: 'add', 28 | }, 29 | { 30 | data: actionData, 31 | path: `${ENTITIES_DIR}index.ts`, 32 | pattern: /$/gi, 33 | templateFile: './templates/entities.index.hbs', 34 | type: 'modify', 35 | }, 36 | { 37 | data: actionData, 38 | path: `${DTO_DIR}index.ts`, 39 | pattern: /$/gi, 40 | templateFile: './templates/dto.index.hbs', 41 | type: 'modify', 42 | }, 43 | ]; 44 | 45 | data.entities.forEach(entity => { 46 | const entityData = { 47 | ...actionData, 48 | entity, 49 | }; 50 | actions.push({ 51 | data: entityData, 52 | path: `${ENTITIES_DIR}{{dashCase entity}}.entity.ts`, 53 | templateFile: './templates/entity.hbs', 54 | type: 'add', 55 | }); 56 | actions.push({ 57 | data: entityData, 58 | path: `${DTO_DIR}{{dashCase entity}}.dto.ts`, 59 | templateFile: './templates/dto.hbs', 60 | type: 'add', 61 | }); 62 | }); 63 | 64 | return actions; 65 | }; 66 | 67 | const config: PlopGeneratorConfig = { 68 | actions: (data: Data): ActionType[] => { 69 | const hasEntities = Boolean(data?.entities?.length); 70 | 71 | if (!data.domainName) { 72 | throw Error('Domain Name is required option.'); 73 | } 74 | 75 | if (!hasEntities) { 76 | throw Error('Entities is required option.'); 77 | } 78 | const actionData = { 79 | domainName: data.domainName, 80 | entities: data.entities || [], 81 | }; 82 | 83 | return getEntitiesActions({ actionData, data }); 84 | }, 85 | description: 'Add new domain entities', 86 | prompts: async (inquirer): Promise => { 87 | const { domainName } = await inquirer.prompt([PROMPTS.domainName]); 88 | const entities = await infinityPrompt(inquirer, PROMPTS.entityName); 89 | 90 | return { 91 | domainName, 92 | entities, 93 | }; 94 | }, 95 | }; 96 | 97 | export default config; 98 | -------------------------------------------------------------------------------- /setup/plopjs/index.ts: -------------------------------------------------------------------------------- 1 | import minimist from 'minimist'; 2 | import path from 'path'; 3 | import { Plop, run } from 'plop'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | const args = process.argv.slice(2); 7 | const argv = minimist(args); 8 | const dirname = path.dirname(fileURLToPath(import.meta.url)); 9 | 10 | Plop.prepare( 11 | { 12 | completion: argv.completion, 13 | configPath: path.join(dirname, './plopfile.ts'), 14 | cwd: argv.cwd, 15 | preload: argv.preload || [], 16 | }, 17 | env => { 18 | run(env, undefined, true).catch((error: string) => { 19 | throw new Error(error); 20 | }); 21 | }, 22 | ); 23 | -------------------------------------------------------------------------------- /setup/plopjs/plopfile.ts: -------------------------------------------------------------------------------- 1 | import { NodePlopAPI } from 'plop'; 2 | 3 | import domain from './domain'; 4 | import entity from './entity'; 5 | import repo from './repo'; 6 | import service from './service'; 7 | 8 | export default (plop: NodePlopAPI) => { 9 | plop.setGenerator('domain', domain); 10 | plop.setGenerator('entity', entity); 11 | plop.setGenerator('service', service); 12 | plop.setGenerator('repo', repo); 13 | // plop.setGenerator('store', addDomain); 14 | }; 15 | -------------------------------------------------------------------------------- /setup/plopjs/repo.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, PlopGeneratorConfig } from 'plop'; 2 | 3 | import { infinityPrompt, PROMPTS, REPO_DIR, REPO_PATH } from './constants'; 4 | 5 | type Data = { 6 | domainName: string; 7 | repositories?: string[]; 8 | }; 9 | 10 | export const getRepositoriesActions = ({ data }): ActionType[] => { 11 | if (!data.repositories.length) { 12 | return []; 13 | } 14 | const actions: ActionType[] = [ 15 | { 16 | data, 17 | path: `${REPO_DIR}index.ts`, 18 | skipIfExists: true, 19 | template: '', 20 | type: 'add', 21 | }, 22 | { 23 | data, 24 | path: `${REPO_DIR}index.ts`, 25 | pattern: /$/gi, 26 | templateFile: './templates/repo.index.hbs', 27 | type: 'modify', 28 | }, 29 | ]; 30 | 31 | data.repositories.forEach(repoName => { 32 | const repoData = { 33 | ...data, 34 | repoName, 35 | }; 36 | actions.push({ 37 | data: repoData, 38 | path: `${REPO_PATH}.repo.ts`, 39 | templateFile: './templates/repo.hbs', 40 | type: 'add', 41 | }); 42 | actions.push({ 43 | data: repoData, 44 | path: `${REPO_PATH}.repo.impl.ts`, 45 | templateFile: './templates/repo.impl.hbs', 46 | type: 'add', 47 | }); 48 | actions.push({ 49 | data: repoData, 50 | path: `${REPO_PATH}.repo.container.ts`, 51 | templateFile: './templates/repo.container.hbs', 52 | type: 'add', 53 | }); 54 | actions.push({ 55 | data: repoData, 56 | path: `${REPO_PATH}.repo.response.ts`, 57 | templateFile: './templates/repo.response.hbs', 58 | type: 'add', 59 | }); 60 | }); 61 | 62 | return actions; 63 | }; 64 | 65 | const config: PlopGeneratorConfig = { 66 | actions: (data: Data) => { 67 | if (!data.domainName) { 68 | throw Error('Domain Name is required option.'); 69 | } 70 | 71 | if (!data.repositories.length) { 72 | throw Error('Repo Name is required option.'); 73 | } 74 | 75 | return getRepositoriesActions({ data }); 76 | }, 77 | description: 'Add new repositories', 78 | prompts: async (inquirer): Promise => { 79 | const { domainName } = await inquirer.prompt([PROMPTS.domainName]); 80 | 81 | const repositories = await infinityPrompt(inquirer, PROMPTS.repoName); 82 | 83 | return { 84 | domainName, 85 | repositories, 86 | }; 87 | }, 88 | }; 89 | 90 | export default config; 91 | -------------------------------------------------------------------------------- /setup/plopjs/service.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from 'node-plop'; 2 | import { PlopGeneratorConfig } from 'plop'; 3 | 4 | import { infinityPrompt, PROMPTS, SERVICE_DIR, SERVICE_PATH } from './constants'; 5 | 6 | type Data = { 7 | services: string[]; 8 | domainName: string; 9 | }; 10 | 11 | export const getServiceActions = ({ data, actionData }): ActionType[] => { 12 | const actions: ActionType[] = []; 13 | 14 | data.services.forEach(service => { 15 | const serviceData = { 16 | ...actionData, 17 | serviceName: service, 18 | }; 19 | actions.push({ 20 | abortOnFail: true, 21 | data: serviceData, 22 | path: `${SERVICE_PATH}.service.ts`, 23 | templateFile: './templates/service.hbs', 24 | type: 'add', 25 | }); 26 | actions.push({ 27 | abortOnFail: true, 28 | data: serviceData, 29 | path: `${SERVICE_PATH}.service.impl.ts`, 30 | templateFile: './templates/service.impl.hbs', 31 | type: 'add', 32 | }); 33 | actions.push({ 34 | abortOnFail: true, 35 | data: serviceData, 36 | path: `${SERVICE_PATH}.service.container.ts`, 37 | templateFile: './templates/service.container.hbs', 38 | type: 'add', 39 | }); 40 | }); 41 | 42 | return [ 43 | ...actions, 44 | { 45 | data: actionData, 46 | path: `${SERVICE_DIR}/index.ts`, 47 | skipIfExists: true, 48 | template: '', 49 | type: 'add', 50 | }, 51 | { 52 | data: actionData, 53 | path: `${SERVICE_DIR}/index.ts`, 54 | pattern: /$/gi, 55 | templateFile: './templates/services.index.hbs', 56 | type: 'modify', 57 | }, 58 | ]; 59 | }; 60 | 61 | const config: PlopGeneratorConfig = { 62 | actions: (data: Data) => { 63 | const hasServices = Boolean(data?.services?.length); 64 | 65 | if (!data.domainName) { 66 | throw Error('Domain Name is required option.'); 67 | } 68 | 69 | if (!hasServices) { 70 | throw Error('Services is required option.'); 71 | } 72 | const actionData = { 73 | domainName: data.domainName, 74 | services: data.services || [], 75 | }; 76 | 77 | return getServiceActions({ actionData, data }); 78 | }, 79 | description: 'Add new domain services', 80 | prompts: async (inquirer): Promise => { 81 | const { domainName } = await inquirer.prompt([PROMPTS.domainName]); 82 | const services = await infinityPrompt(inquirer, PROMPTS.serviceName); 83 | 84 | return { 85 | domainName, 86 | services, 87 | }; 88 | }, 89 | }; 90 | 91 | export default config; 92 | -------------------------------------------------------------------------------- /setup/plopjs/templates/dto.hbs: -------------------------------------------------------------------------------- 1 | import { {{pascalCase entity}} } from 'domain/{{dashCase domainName}}'; 2 | 3 | export class {{pascalCase entity}}Dto { 4 | static mapToEntity(value): {{pascalCase entity}} { 5 | return new {{pascalCase entity}}(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /setup/plopjs/templates/dto.index.hbs: -------------------------------------------------------------------------------- 1 | {{#each entities}} 2 | export * from './{{dashCase this}}.dto'; 3 | {{/each}} -------------------------------------------------------------------------------- /setup/plopjs/templates/entities.index.hbs: -------------------------------------------------------------------------------- 1 | {{#each entities}} 2 | export * from './{{dashCase this}}.entity'; 3 | {{/each}} -------------------------------------------------------------------------------- /setup/plopjs/templates/entity.hbs: -------------------------------------------------------------------------------- 1 | export class {{pascalCase entity}} { 2 | constructor() {} 3 | } 4 | -------------------------------------------------------------------------------- /setup/plopjs/templates/index.hbs: -------------------------------------------------------------------------------- 1 | {{#if hasEntities }} 2 | export * from './entities'; 3 | export * from './services'; 4 | {{else}} 5 | export * from './services'; 6 | {{/if}} -------------------------------------------------------------------------------- /setup/plopjs/templates/repo.container.hbs: -------------------------------------------------------------------------------- 1 | import { AppContainer } from 'containers'; 2 | import { ServiceIdentifier } from 'containers/config'; 3 | 4 | import { {{pascalCase repoName}}Repo } from './{{dashCase repoName}}.repo'; 5 | import { {{pascalCase repoName}}RepoImpl } from './{{dashCase repoName}}.repo.impl'; 6 | import { {{pascalCase repoName}}Response } from './{{dashCase repoName}}.repo.response'; 7 | 8 | const {{pascalCase repoName}}RepoType: ServiceIdentifier<{{pascalCase repoName}}Repo> = Symbol('{{pascalCase repoName}}Repo'); 9 | 10 | AppContainer.bind({{pascalCase repoName}}RepoType).to({{pascalCase repoName}}RepoImpl); 11 | 12 | export { {{pascalCase repoName}}RepoType, {{pascalCase repoName}}RepoImpl }; 13 | export type { {{pascalCase repoName}}Repo, {{pascalCase repoName}}Response }; 14 | -------------------------------------------------------------------------------- /setup/plopjs/templates/repo.hbs: -------------------------------------------------------------------------------- 1 | export interface {{pascalCase repoName}}Repo { 2 | // add tour methods here 3 | example: () => Promise; 4 | } 5 | -------------------------------------------------------------------------------- /setup/plopjs/templates/repo.impl.hbs: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from 'containers/config'; 2 | import { HttpClient, HttpClientType } from 'core/http'; 3 | import { Logger, LoggerType } from 'core/logger'; 4 | 5 | import { {{pascalCase repoName}}Repo } from './{{dashCase repoName}}.repo'; 6 | 7 | @Injectable() 8 | export class {{pascalCase repoName}}RepoImpl implements {{pascalCase repoName}}Repo { 9 | constructor( 10 | @Inject(LoggerType) private readonly _logger: Logger, 11 | @Inject(HttpClientType) private readonly _http: HttpClient, 12 | ) {} 13 | 14 | example(): Promise { 15 | this._logger.info('{{pascalCase repoName}}Repo.example', { ping: true }); 16 | 17 | return Promise.resolve(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /setup/plopjs/templates/repo.index.hbs: -------------------------------------------------------------------------------- 1 | {{#each repositories}} 2 | export * from './{{dashCase this}}.repo.container'; 3 | {{/each}} -------------------------------------------------------------------------------- /setup/plopjs/templates/repo.response.hbs: -------------------------------------------------------------------------------- 1 | export namespace {{pascalCase repoName}}Response { 2 | // add your responses here 3 | // Example: export interface FirstResponse {} 4 | } 5 | -------------------------------------------------------------------------------- /setup/plopjs/templates/service.container.hbs: -------------------------------------------------------------------------------- 1 | import { AppContainer } from 'containers'; 2 | import { ServiceIdentifier } from 'containers/config'; 3 | 4 | import { {{pascalCase serviceName}}Service } from './{{dashCase serviceName}}.service'; 5 | import { {{pascalCase serviceName}}ServiceImpl } from './{{dashCase serviceName}}.service.impl'; 6 | 7 | const {{pascalCase serviceName}}ServiceType: ServiceIdentifier<{{pascalCase serviceName}}Service> = Symbol('{{pascalCase serviceName}}Service'); 8 | 9 | AppContainer.bind({{pascalCase serviceName}}ServiceType).to({{pascalCase serviceName}}ServiceImpl); 10 | 11 | export { {{pascalCase serviceName}}ServiceImpl, {{pascalCase serviceName}}ServiceType }; 12 | export type { {{pascalCase serviceName}}Service }; 13 | -------------------------------------------------------------------------------- /setup/plopjs/templates/service.hbs: -------------------------------------------------------------------------------- 1 | export interface {{pascalCase serviceName}}Service { 2 | // to add methods here 3 | } 4 | -------------------------------------------------------------------------------- /setup/plopjs/templates/service.impl.hbs: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from 'containers/config'; 2 | 3 | import { {{pascalCase serviceName}}Service } from './{{dashCase serviceName}}.service'; 4 | 5 | @Injectable() 6 | export class {{pascalCase serviceName}}ServiceImpl implements {{pascalCase serviceName}}Service { 7 | // implement your methods here 8 | } 9 | -------------------------------------------------------------------------------- /setup/plopjs/templates/services.index.hbs: -------------------------------------------------------------------------------- 1 | {{#each services}} 2 | export * from './{{dashCase this}}.service.container'; 3 | {{/each}} -------------------------------------------------------------------------------- /setup/vite/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript", 6 | "tsx": true, 7 | "decorators": true, 8 | "dynamicImport": true 9 | }, 10 | "target": "es5" 11 | } 12 | } -------------------------------------------------------------------------------- /setup/vite/build.ts: -------------------------------------------------------------------------------- 1 | import folderDelete from 'rollup-plugin-delete'; 2 | import { BuildOptions } from 'vite'; 3 | 4 | import { VitePlatform } from './config.types'; 5 | 6 | const getAssetsPath = (type: string, extname: string): string => 7 | `assets/${type}/[name]-[hash]${extname}`; 8 | 9 | const assetFileNames = (assetInfo: { name: string }): string => { 10 | let extType = assetInfo?.name?.split('.').at(1); 11 | 12 | if (!extType) { 13 | extType = 'files'; 14 | } 15 | 16 | if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) { 17 | extType = 'images'; 18 | } 19 | 20 | return getAssetsPath(extType, '[extname]'); 21 | }; 22 | 23 | const manualChunks = (id: string | string[]): string | void => { 24 | if (id.includes('node_modules')) { 25 | return 'vendor'; 26 | } 27 | 28 | if (id.includes('web')) { 29 | return 'web'; 30 | } 31 | }; 32 | 33 | export const getBuildConfig = (platform: VitePlatform): BuildOptions => { 34 | const outputDir = `dist/${platform}`; 35 | 36 | return { 37 | chunkSizeWarningLimit: 1000, 38 | emptyOutDir: false, 39 | minify: true, 40 | outDir: outputDir, 41 | rollupOptions: { 42 | output: { 43 | assetFileNames, 44 | chunkFileNames: getAssetsPath('js', '.js'), 45 | entryFileNames: getAssetsPath('js', '.js'), 46 | manualChunks, 47 | }, 48 | plugins: [folderDelete({ hook: 'buildStart', runOnce: true, targets: outputDir })], 49 | }, 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /setup/vite/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, UserConfig } from 'vite'; 2 | 3 | import { getBuildConfig } from './build'; 4 | import { ViteMode, VitePlatform } from './config.types'; 5 | import { getBuildDefines, getDevDefines } from './define'; 6 | import { getEnvConfig } from './env'; 7 | import { getBuildPlugins, getDevPlugins, getPreviewPlugins } from './plugins'; 8 | import { getServerConfig } from './server'; 9 | import styles from './styles'; 10 | 11 | const defaultConfig: UserConfig = { 12 | css: styles, 13 | }; 14 | 15 | // https://vitejs.dev/config/ 16 | export default defineConfig(({ command, mode = 'dev' }) => { 17 | const envPlatform = (process?.env?.platform || '') as VitePlatform; 18 | 19 | const platform = envPlatform || 'web'; 20 | const envConfig = getEnvConfig(mode as ViteMode, envPlatform); 21 | 22 | if (command === 'build') { 23 | envConfig.IS_DEV = false; 24 | 25 | return { 26 | ...defaultConfig, 27 | build: getBuildConfig(platform), 28 | define: getBuildDefines(envConfig, platform), 29 | mode: 'production', 30 | plugins: getBuildPlugins(envConfig, platform), 31 | }; 32 | } 33 | 34 | if (mode === 'preview') { 35 | envConfig.IS_DEV = true; 36 | 37 | return { 38 | base: './', 39 | build: getBuildConfig(platform), 40 | plugins: getPreviewPlugins(envConfig, platform), 41 | preview: getServerConfig(envConfig), 42 | }; 43 | } 44 | envConfig.IS_DEV = true; 45 | 46 | return { 47 | ...defaultConfig, 48 | define: getDevDefines(envConfig, platform), 49 | mode: 'development', 50 | plugins: getDevPlugins(envConfig, platform), 51 | server: getServerConfig(envConfig), 52 | }; 53 | }); 54 | -------------------------------------------------------------------------------- /setup/vite/config.types.ts: -------------------------------------------------------------------------------- 1 | export type ViteMode = 'prod' | 'stage' | 'dev' | 'preview'; 2 | export type VitePlatform = 'mobile' | 'web'; 3 | 4 | export type ViteEnvConfig = { 5 | IS_DEV: boolean; 6 | AUTH_TOKEN: string; 7 | DEV_SERVER_HOST: string; 8 | DEV_SERVER_PORT: string; 9 | BACKEND_URL: string; 10 | BACKEND_PROXIES: string; 11 | }; 12 | -------------------------------------------------------------------------------- /setup/vite/define.ts: -------------------------------------------------------------------------------- 1 | import { ViteEnvConfig, VitePlatform } from './config.types'; 2 | 3 | const getDefaultDefines = (env: ViteEnvConfig, platform: VitePlatform) => ({ 4 | APP_PLATFORM: JSON.stringify(platform), 5 | AUTH_TOKEN: JSON.stringify(env.AUTH_TOKEN), 6 | BACKEND_URL: JSON.stringify(env.BACKEND_URL), 7 | IS_DEV: JSON.stringify(process.env.NODE_ENV === 'development'), 8 | UI_VERSION: JSON.stringify(process.env.npm_package_version), 9 | }); 10 | 11 | export const getDevDefines = (env: ViteEnvConfig, platform: VitePlatform) => ({ 12 | ...getDefaultDefines(env, platform), 13 | }); 14 | 15 | export const getBuildDefines = (env: ViteEnvConfig, platform: VitePlatform) => ({ 16 | ...getDefaultDefines(env, platform), 17 | }); 18 | -------------------------------------------------------------------------------- /setup/vite/env.ts: -------------------------------------------------------------------------------- 1 | import DotEnv from 'dotenv'; 2 | 3 | import { ViteEnvConfig, ViteMode, VitePlatform } from './config.types'; 4 | 5 | export const getEnvConfig = (mode: ViteMode, platform: VitePlatform): ViteEnvConfig => { 6 | const envFile = mode === 'preview' ? 'dev' : mode; 7 | const fileName = `${platform ? `.${platform}` : ''}.${envFile}.env`; 8 | const path = `./setup/env/${fileName}`; 9 | const result = DotEnv.config({ debug: true, override: false, path }); 10 | 11 | if (result.error) { 12 | throw result.error; 13 | } 14 | 15 | const config: ViteEnvConfig = { 16 | AUTH_TOKEN: process.env.AUTH_TOKEN, 17 | BACKEND_PROXIES: process.env.BACKEND_PROXIES, 18 | BACKEND_URL: process.env.BACKEND_URL, 19 | DEV_SERVER_HOST: process.env.DEV_SERVER_HOST, 20 | DEV_SERVER_PORT: process.env.DEV_SERVER_PORT, 21 | IS_DEV: false, 22 | }; 23 | 24 | // eslint-disable-next-line no-console 25 | console.log('[ENV_CONFIG]', config); 26 | 27 | return config; 28 | }; 29 | -------------------------------------------------------------------------------- /setup/vite/plugins.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react-swc'; 2 | import { PluginOption } from 'vite'; 3 | import viteCompression from 'vite-plugin-compression'; 4 | import { createHtmlPlugin } from 'vite-plugin-html'; 5 | import mkcert from 'vite-plugin-mkcert'; 6 | import tsconfigPaths from 'vite-tsconfig-paths'; 7 | 8 | import reactSvgPlugin from './plugins/react-svg'; 9 | import { ViteEnvConfig, VitePlatform } from './config.types'; 10 | 11 | const getBasePlugins = (env: ViteEnvConfig, platform: VitePlatform): PluginOption[] => [ 12 | tsconfigPaths(), 13 | reactSvgPlugin({ 14 | defaultExport: 'component', 15 | expandProps: 'end', 16 | memo: true, 17 | ref: true, 18 | replaceAttrValues: null, 19 | svgProps: null, 20 | svgo: true, 21 | svgoConfig: { 22 | plugins: [ 23 | { 24 | name: 'removeAttrs', 25 | params: { 26 | attrs: '', 27 | }, 28 | }, 29 | ], 30 | }, 31 | titleProp: false, 32 | }), 33 | createHtmlPlugin({ 34 | inject: { 35 | data: { 36 | injectScript: ``, 37 | title: platform.toUpperCase(), 38 | }, 39 | }, 40 | minify: false, 41 | }), 42 | ]; 43 | 44 | export const getDevPlugins = (env: ViteEnvConfig, platform: VitePlatform): PluginOption[] => [ 45 | ...getBasePlugins(env, platform), 46 | react({ tsDecorators: true }), 47 | mkcert(), 48 | ]; 49 | 50 | export const getPreviewPlugins = (env: ViteEnvConfig, platform: VitePlatform): PluginOption[] => [ 51 | ...getBasePlugins(env, platform), 52 | mkcert(), 53 | ]; 54 | 55 | export const getBuildPlugins = (env: ViteEnvConfig, platform: VitePlatform): PluginOption[] => [ 56 | ...getBasePlugins(env, platform), 57 | viteCompression({ 58 | filter: (file: string): boolean => file.includes('.js'), 59 | }), 60 | ]; 61 | -------------------------------------------------------------------------------- /setup/vite/plugins/react-svg/index.ts: -------------------------------------------------------------------------------- 1 | import { Config, transform } from '@svgr/core'; 2 | import { PathOrFileDescriptor, readFileSync } from 'fs'; 3 | import { Plugin, TransformResult } from 'vite'; 4 | 5 | type PluginOptions = Config & { 6 | defaultExport: 'url' | 'component'; 7 | }; 8 | 9 | function compileSvg(source: string, id: string, options: Config): Promise { 10 | return transform( 11 | source, 12 | { 13 | ...options, 14 | jsx: { 15 | babelConfig: { 16 | plugins: [ 17 | [ 18 | '@babel/plugin-transform-react-jsx', 19 | { 20 | useBuiltIns: true, 21 | }, 22 | ], 23 | ], 24 | }, 25 | }, 26 | plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'], 27 | runtimeConfig: false, 28 | }, 29 | { 30 | filePath: id, 31 | }, 32 | ); 33 | } 34 | 35 | export default (options: PluginOptions): Plugin => { 36 | const { 37 | defaultExport = 'url', 38 | svgoConfig, 39 | expandProps, 40 | svgo, 41 | ref, 42 | memo, 43 | replaceAttrValues, 44 | svgProps, 45 | titleProp, 46 | } = options; 47 | 48 | const cache = new Map(); 49 | const svgRegex = /\.svg(?:\?(component|url))?$/; 50 | 51 | return { 52 | name: 'react-svg', 53 | async transform(source: string, id: string, isBuild): Promise { 54 | const svgRegexResult = id.match(svgRegex); 55 | 56 | if (svgRegexResult) { 57 | const type = svgRegexResult[1]; 58 | 59 | if ((defaultExport === 'url' && typeof type === 'undefined') || type === 'url') { 60 | return source as unknown as TransformResult; 61 | } 62 | 63 | if ( 64 | (defaultExport === 'component' && typeof type === 'undefined') || 65 | type === 'component' 66 | ) { 67 | const idWithoutQuery = id.replace('.svg?component', '.svg'); 68 | let result = cache.get(idWithoutQuery); 69 | 70 | if (!result) { 71 | const code = readFileSync(idWithoutQuery).toString(); 72 | 73 | result = await compileSvg(code, idWithoutQuery, { 74 | expandProps, 75 | memo, 76 | ref, 77 | replaceAttrValues, 78 | svgProps, 79 | svgo, 80 | svgoConfig, 81 | titleProp, 82 | }); 83 | 84 | if (isBuild) { 85 | cache.set(idWithoutQuery, result); 86 | } 87 | } 88 | 89 | return result as unknown as TransformResult; 90 | } 91 | } 92 | }, 93 | }; 94 | }; 95 | -------------------------------------------------------------------------------- /setup/vite/server.ts: -------------------------------------------------------------------------------- 1 | import { ServerOptions } from 'vite'; 2 | 3 | import { ViteEnvConfig } from './config.types'; 4 | 5 | export const getServerConfig = (env: ViteEnvConfig): ServerOptions => ({ 6 | host: env.DEV_SERVER_HOST, 7 | open: true, 8 | port: Number(env.DEV_SERVER_PORT), 9 | proxy: (env.BACKEND_PROXIES.split(',') || []).reduce( 10 | (acc, url) => ({ 11 | ...acc, 12 | [url]: { 13 | changeOrigin: true, 14 | rewrite: (path: string): string => path, 15 | secure: true, 16 | target: env.BACKEND_URL, 17 | }, 18 | }), 19 | {}, 20 | ), 21 | }); 22 | -------------------------------------------------------------------------------- /setup/vite/styles.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | preprocessorOptions: { 3 | less: { 4 | javascriptEnabled: true, 5 | }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /src/containers/config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import type { interfaces } from 'inversify'; 3 | import * as inversify from 'inversify'; 4 | import { ServiceIdentifierOrFunc } from 'inversify/lib/annotation/lazy_service_identifier'; 5 | 6 | export type ServiceIdentifier = interfaces.ServiceIdentifier; 7 | 8 | export class Container extends inversify.Container {} 9 | 10 | export class ContainerModule extends inversify.ContainerModule {} 11 | 12 | export const Injectable = inversify.injectable; 13 | 14 | export const Inject = inversify.inject; 15 | 16 | export const Named = inversify.named; 17 | 18 | export const PostConstruct = inversify.postConstruct; 19 | 20 | export const InjectNamed = 21 | (serviceIdentifier: ServiceIdentifierOrFunc, name: string | number | symbol) => 22 | (target: Target, targetKey: string, index?: number): void => { 23 | Inject(serviceIdentifier)(target, targetKey, index); 24 | Named(name)(target, targetKey, index); 25 | }; 26 | -------------------------------------------------------------------------------- /src/containers/containers.ts: -------------------------------------------------------------------------------- 1 | import { Container } from './config'; 2 | import { coreModules } from './core'; 3 | 4 | export const container = new Container({ 5 | autoBindInjectable: true, 6 | }); 7 | 8 | container.load(coreModules); 9 | -------------------------------------------------------------------------------- /src/containers/core/index.ts: -------------------------------------------------------------------------------- 1 | import { ContainerModule } from 'inversify'; 2 | 3 | import { 4 | AxiosAdapter, 5 | BrowserCookieAdapter, 6 | I18nextAdapter, 7 | LocalStorageAdapter, 8 | ReactHookFormAdapter, 9 | SessionStorageAdapter, 10 | WebLoggerAdapter, 11 | } from 'core/adapters'; 12 | import { FormType } from 'core/form'; 13 | import { HttpClientType } from 'core/http'; 14 | import { I18nType } from 'core/i18n'; 15 | import { LoggerType } from 'core/logger'; 16 | import { MobxStoreImpl, MobxStoreType } from 'core/mobx-store'; 17 | import { CookieStorageName, LocalStorageName, SessionStorageName, StorageType } from 'core/storage'; 18 | 19 | export const coreModules = new ContainerModule(bind => { 20 | bind(LoggerType).to(WebLoggerAdapter); 21 | bind(MobxStoreType).to(MobxStoreImpl).inSingletonScope(); 22 | bind(StorageType).to(BrowserCookieAdapter).whenTargetNamed(CookieStorageName); 23 | bind(StorageType).to(LocalStorageAdapter).whenTargetNamed(LocalStorageName); 24 | bind(StorageType).to(SessionStorageAdapter).whenTargetNamed(SessionStorageName); 25 | bind(HttpClientType).to(AxiosAdapter); 26 | bind(FormType).to(ReactHookFormAdapter); 27 | bind(I18nType).to(I18nextAdapter).inSingletonScope(); 28 | }); 29 | -------------------------------------------------------------------------------- /src/containers/index.ts: -------------------------------------------------------------------------------- 1 | export { container as AppContainer } from './containers'; 2 | -------------------------------------------------------------------------------- /src/core/adapters/axios/axios.adapter.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | 3 | import { Inject, Injectable, PostConstruct } from 'containers/config'; 4 | import { HttpClient, HttpInterceptorManager } from 'core/http'; 5 | import { I18n, I18nType } from 'core/i18n'; 6 | import { Logger, LoggerType } from 'core/logger'; 7 | import { clearUrl, getBackendUrl } from 'core/utils'; 8 | 9 | @Injectable() 10 | export class AxiosAdapter implements HttpClient { 11 | protected _http: AxiosInstance; 12 | 13 | constructor( 14 | @Inject(LoggerType) private readonly _logger: Logger, 15 | @Inject(I18nType) private readonly _i18n: I18n, 16 | private readonly baseUrl: string = getBackendUrl(), 17 | ) {} 18 | 19 | @PostConstruct() 20 | initialize(): void { 21 | const config = this.getConfig(); 22 | this._http = axios.create(config); 23 | this.setResponseInterceptors(value => Promise.resolve(value), this._errorInterceptor); 24 | } 25 | 26 | private _errorInterceptor = (error: AxiosResponse): Promise => { 27 | const { 28 | config: { method, url, data = null }, 29 | } = error; 30 | this._logger?.error(`[AxiosAdapter.${method}]`, { data, url }); 31 | 32 | return Promise.reject(error); 33 | }; 34 | 35 | private _getUrl = (url: string): string => { 36 | return new URL(clearUrl(`${this.baseUrl}${url}`)).toString(); 37 | }; 38 | 39 | getConfig = (): AxiosRequestConfig => { 40 | return { 41 | headers: { 42 | 'Accept-Language': this._i18n?.getLanguage(), 43 | }, 44 | withCredentials: true, 45 | }; 46 | }; 47 | 48 | getXmlConfig = (): AxiosRequestConfig => { 49 | const defaultConfig = this.getConfig(); 50 | 51 | return { 52 | ...defaultConfig, 53 | headers: { 54 | ...defaultConfig.headers, 55 | 'X-Requested-With': 'XMLHttpRequest', 56 | }, 57 | }; 58 | }; 59 | 60 | delete>(url: string, config?: AxiosRequestConfig): Promise { 61 | return this._http.delete(this._getUrl(url), config); 62 | } 63 | 64 | get>(url: string, config?: AxiosRequestConfig): Promise { 65 | return this._http.get(this._getUrl(url), config); 66 | } 67 | 68 | head>(url: string, config?: AxiosRequestConfig): Promise { 69 | return this._http.head(this._getUrl(url), config); 70 | } 71 | 72 | options>(url: string, config?: AxiosRequestConfig): Promise { 73 | return this._http.options(this._getUrl(url), config); 74 | } 75 | 76 | patch>( 77 | url: string, 78 | data?: D, 79 | config?: AxiosRequestConfig, 80 | ): Promise { 81 | return this._http.patch(this._getUrl(url), data, config); 82 | } 83 | 84 | post>( 85 | url: string, 86 | data?: D, 87 | config?: AxiosRequestConfig, 88 | ): Promise { 89 | return this._http.post(this._getUrl(url), data, config); 90 | } 91 | 92 | put>( 93 | url: string, 94 | data?: D, 95 | config?: AxiosRequestConfig, 96 | ): Promise { 97 | return this._http.put(this._getUrl(url), data, config); 98 | } 99 | 100 | setResponseInterceptors: HttpInterceptorManager = ( 101 | responseInterceptor, 102 | errorInterceptor, 103 | ) => { 104 | return this._http.interceptors.response.use(responseInterceptor, errorInterceptor); 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /src/core/adapters/axios/index.ts: -------------------------------------------------------------------------------- 1 | export * from './axios.adapter'; 2 | -------------------------------------------------------------------------------- /src/core/adapters/browser-cookie/__tests__/browser-cookie.adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { BrowserCookieAdapter } from '../browser-cookie.adapter'; 2 | 3 | describe('BrowserCookieAdapter', () => { 4 | let browserCookieAdapter: BrowserCookieAdapter; 5 | 6 | beforeEach(() => { 7 | browserCookieAdapter = new BrowserCookieAdapter(); 8 | }); 9 | 10 | it('set cookie value', () => { 11 | browserCookieAdapter.set('testSetKey', 'testSetValue'); 12 | 13 | expect(window.document.cookie).toStrictEqual('testSetKey=testSetValue'); 14 | browserCookieAdapter.remove('testSetKey'); 15 | }); 16 | 17 | it('get existed cookie value', () => { 18 | browserCookieAdapter.set('testGetExistedKey', 'testGetExistedValue'); 19 | const result = browserCookieAdapter.get('testGetExistedKey'); 20 | 21 | expect(result).toBe('testGetExistedValue'); 22 | browserCookieAdapter.remove('testGetExistedKey'); 23 | }); 24 | 25 | it('get unknown cookie value', () => { 26 | const result = browserCookieAdapter.get('testGetUnknownKey'); 27 | 28 | expect(result).toBe(null); 29 | }); 30 | 31 | it('remove cookie', () => { 32 | browserCookieAdapter.set('testRemoveKey', 'testRemoveValue'); 33 | expect(window.document.cookie).toBe('testRemoveKey=testRemoveValue'); 34 | 35 | browserCookieAdapter.remove('testRemoveKey'); 36 | expect(window.document.cookie).toBe(''); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/core/adapters/browser-cookie/browser-cookie.adapter.ts: -------------------------------------------------------------------------------- 1 | import reduce from 'lodash/reduce'; 2 | 3 | import { Injectable } from 'containers/config'; 4 | import { Storage } from 'core/storage'; 5 | 6 | import { COOKIE_REGEX } from './browser-cookie.constants'; 7 | import { correctKey } from './browser-cookie.helpers'; 8 | import { CookieOptions } from './browser-cookie.types'; 9 | 10 | @Injectable() 11 | export class BrowserCookieAdapter implements Storage { 12 | get cookie(): string { 13 | return window.document.cookie; 14 | } 15 | 16 | set cookie(value: string) { 17 | window.document.cookie = value; 18 | } 19 | 20 | get(key: string): string { 21 | const matches = this.cookie.match( 22 | new RegExp(`(?:^|; )${key.replace(COOKIE_REGEX, '\\$1')}=([^;]*)`), 23 | ); 24 | 25 | return matches ? decodeURIComponent(matches[1]) : null; 26 | } 27 | 28 | remove(key: string): this { 29 | this.set(key, '', { 30 | expires: null, 31 | maxAge: -1, 32 | }); 33 | 34 | return this; 35 | } 36 | 37 | set(key: string, value: string, options = {} as CookieOptions): void { 38 | const cookieKeyValue = this._getKeyValue(key, value); 39 | const cookieOptions = this._convertOptions(options); 40 | 41 | this.cookie = `${cookieKeyValue}${cookieOptions}`; 42 | } 43 | 44 | private _convertOptions(options: CookieOptions): string { 45 | return reduce( 46 | { 47 | path: '/', 48 | ...options, 49 | }, 50 | (acc, value, key) => { 51 | const prepend = `${acc}; ${correctKey(key)}`; 52 | 53 | if (value === true) { 54 | return prepend; 55 | } 56 | 57 | if (value instanceof Date) { 58 | return `${prepend}=${value.toUTCString()}`; 59 | } 60 | 61 | return `${prepend}=${String(value)}`; 62 | }, 63 | '', 64 | ); 65 | } 66 | 67 | private _getKeyValue(key: string, value: string): string { 68 | return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/core/adapters/browser-cookie/browser-cookie.constants.ts: -------------------------------------------------------------------------------- 1 | export const COOKIE_REGEX = /([.$?*|{}()[\]\\/+^])/g; 2 | -------------------------------------------------------------------------------- /src/core/adapters/browser-cookie/browser-cookie.helpers.ts: -------------------------------------------------------------------------------- 1 | export function correctKey(key: string): string { 2 | switch (key) { 3 | case 'maxAge': 4 | return 'max-age'; 5 | default: 6 | return key; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/core/adapters/browser-cookie/browser-cookie.types.ts: -------------------------------------------------------------------------------- 1 | export enum CookieSameSite { 2 | Strict = 'strict', 3 | Lax = 'lax', 4 | } 5 | 6 | export type CookieOptions = Partial<{ 7 | domain: string; 8 | expires: ExpireType; 9 | maxAge: number; 10 | path: string; 11 | sameSite: CookieSameSite; 12 | secure: boolean; 13 | httpOnly: boolean; 14 | }>; 15 | -------------------------------------------------------------------------------- /src/core/adapters/browser-cookie/index.ts: -------------------------------------------------------------------------------- 1 | export * from './browser-cookie.adapter'; 2 | -------------------------------------------------------------------------------- /src/core/adapters/form/index.ts: -------------------------------------------------------------------------------- 1 | export * from './react-hook-form.adapter'; 2 | -------------------------------------------------------------------------------- /src/core/adapters/form/react-hook-form.adapter.ts: -------------------------------------------------------------------------------- 1 | import { FieldPath, FieldPathValue, UseFormReturn } from 'react-hook-form'; 2 | 3 | import { Injectable } from 'containers/config'; 4 | import { Form } from 'core/form'; 5 | import { FormApiErrors } from 'core/form/form-api.types'; 6 | 7 | @Injectable() 8 | export class ReactHookFormAdapter> 9 | implements Form 10 | { 11 | context: Context; 12 | 13 | setContext = (value: Context): void => { 14 | this.context = value; 15 | }; 16 | 17 | getValue = < 18 | TFieldName extends FieldPath = FieldPath, 19 | TFieldValue extends FieldPathValue = FieldPathValue, 20 | >( 21 | name: TFieldName, 22 | ): TFieldValue => { 23 | return this.context.getValues(name as unknown as FieldPath) as TFieldValue; 24 | }; 25 | 26 | getValues = (): Values => { 27 | return this.context.getValues(); 28 | }; 29 | 30 | setValue = < 31 | TFieldName extends FieldPath = FieldPath, 32 | TFieldValue extends FieldPathValue = FieldPathValue, 33 | >( 34 | name: TFieldName, 35 | value: TFieldValue, 36 | ): void => { 37 | this.context.setValue(name, value); 38 | }; 39 | 40 | getErrors(): FormApiErrors { 41 | return this.context.formState.errors as FormApiErrors; 42 | } 43 | 44 | resetForm(): void { 45 | this.context.reset(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/core/adapters/i18next/i18next.adapter.ts: -------------------------------------------------------------------------------- 1 | import { initReactI18next } from 'react-i18next'; 2 | import i18nextInstance, { i18n as i18next } from 'i18next'; 3 | import languageDetector from 'i18next-browser-languagedetector'; 4 | import HttpBackend from 'i18next-http-backend'; 5 | import { action, computed, makeAutoObservable, observable } from 'mobx'; 6 | 7 | import { Injectable } from 'containers/config'; 8 | import { I18n, I18nLanguages } from 'core/i18n'; 9 | 10 | import { I18N_CONFIG } from './i18next.constants'; 11 | 12 | @Injectable() 13 | export class I18nextAdapter implements I18n { 14 | private _instance: i18next = i18nextInstance; 15 | 16 | @observable private _loading = true; 17 | 18 | @observable private _language: I18nLanguages = I18nLanguages.en; 19 | 20 | constructor() { 21 | makeAutoObservable(this); 22 | this.initialize(); 23 | } 24 | 25 | private initialize = async (): Promise => { 26 | this._setLoading(true); 27 | 28 | try { 29 | await this._instance 30 | .use(HttpBackend) 31 | .use(languageDetector) 32 | .use(initReactI18next) 33 | .init(I18N_CONFIG); 34 | 35 | return await this._instance.reloadResources(); 36 | } catch (error) { 37 | return await Promise.reject(error); 38 | } finally { 39 | this._setLoading(false); 40 | } 41 | }; 42 | 43 | @action private _setLoading = (value: boolean): void => { 44 | this._loading = value; 45 | }; 46 | 47 | @action private _setLanguage = async (value: I18nLanguages): Promise => { 48 | await this._instance.changeLanguage(value); 49 | await this._instance.reloadResources(); 50 | this._language = value; 51 | }; 52 | 53 | @computed isLoading = (): boolean => { 54 | return this._loading; 55 | }; 56 | 57 | @computed getLanguage = (): I18nLanguages => { 58 | return this._language || (this._instance.language as I18nLanguages); 59 | }; 60 | 61 | isInitialized = (): boolean => { 62 | return this._instance.isInitialized; 63 | }; 64 | 65 | getInstance = (): i18next => { 66 | return this._instance; 67 | }; 68 | 69 | changeLanguage = async (language: I18nLanguages): Promise => { 70 | try { 71 | await this._setLanguage(language); 72 | 73 | return true; 74 | } catch (error) { 75 | await this._setLanguage(I18nLanguages.en); 76 | 77 | return false; 78 | } 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /src/core/adapters/i18next/i18next.constants.ts: -------------------------------------------------------------------------------- 1 | import { InitOptions } from 'i18next'; 2 | 3 | import { I18nLanguages, I18nNamespaces } from 'core/i18n'; 4 | import { StorageKeys } from 'core/storage'; 5 | 6 | export const I18N_CONFIG: InitOptions = { 7 | appendNamespaceToMissingKey: true, 8 | backend: { 9 | addPath: null, 10 | loadPath: `/assets/locales/{{lng}}/{{ns}}.${APP_PLATFORM}.json`, 11 | }, 12 | cleanCode: true, 13 | contextSeparator: '__', 14 | debug: IS_DEV, 15 | defaultNS: I18nNamespaces.translation, 16 | detection: { 17 | lookupFromPathIndex: 0, 18 | lookupLocalStorage: StorageKeys.Language, 19 | order: ['path', 'localStorage', 'navigator'], 20 | }, 21 | fallbackLng: I18nLanguages.en, 22 | 23 | initImmediate: true, 24 | interpolation: { 25 | escapeValue: false, 26 | }, 27 | keySeparator: false, 28 | load: 'languageOnly', 29 | missingKeyNoValueFallbackToKey: true, 30 | ns: Object.values(I18nNamespaces), 31 | nsSeparator: ':', 32 | 33 | pluralSeparator: '_', 34 | react: { 35 | useSuspense: false, 36 | }, 37 | 38 | resources: {}, 39 | saveMissing: true, 40 | simplifyPluralSuffix: true, 41 | supportedLngs: Object.values(I18nLanguages), 42 | }; 43 | -------------------------------------------------------------------------------- /src/core/adapters/i18next/index.ts: -------------------------------------------------------------------------------- 1 | export * from './i18next.adapter'; 2 | -------------------------------------------------------------------------------- /src/core/adapters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './axios'; 2 | export * from './logger'; 3 | export * from './browser-cookie'; 4 | export * from './local-storage'; 5 | export * from './session-storage'; 6 | export * from './form'; 7 | export * from './i18next'; 8 | -------------------------------------------------------------------------------- /src/core/adapters/local-storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './local-storage.adapter'; 2 | -------------------------------------------------------------------------------- /src/core/adapters/local-storage/local-storage.adapter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from 'containers/config'; 2 | import { Storage } from 'core/storage'; 3 | 4 | @Injectable() 5 | export class LocalStorageAdapter implements Storage { 6 | constructor(private readonly _storage: globalThis.Storage = window.localStorage) {} 7 | 8 | private _notify(eventInit: StorageEventInit) { 9 | window.dispatchEvent(new StorageEvent('storage', { ...eventInit, storageArea: this._storage })); 10 | } 11 | 12 | private _getStorageCallback(callback: () => void) { 13 | return (event: StorageEvent): void => { 14 | if (event.storageArea !== this._storage) { 15 | return; 16 | } 17 | 18 | callback(); 19 | }; 20 | } 21 | 22 | get(key: string): string { 23 | return this._storage.getItem(key); 24 | } 25 | 26 | remove(key: string): void { 27 | const oldValue = this.get(key); 28 | this._storage.removeItem(key); 29 | 30 | this._notify({ 31 | key, 32 | newValue: null, 33 | oldValue, 34 | }); 35 | } 36 | 37 | set(key: string, value: string): void { 38 | const oldValue = this.get(key); 39 | this._storage.setItem(key, value); 40 | 41 | this._notify({ 42 | key, 43 | newValue: value, 44 | oldValue, 45 | }); 46 | } 47 | 48 | listen = (callback: () => void): (() => void) => { 49 | const storageCallback = this._getStorageCallback(callback); 50 | window.addEventListener('storage', storageCallback); 51 | 52 | return () => { 53 | window.removeEventListener('storage', storageCallback); 54 | }; 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/core/adapters/logger/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger.adapter'; 2 | -------------------------------------------------------------------------------- /src/core/adapters/logger/logger.adapter.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { Injectable } from 'containers/config'; 3 | import { Logger } from 'core/logger'; 4 | 5 | @Injectable() 6 | export class WebLoggerAdapter implements Logger { 7 | error(...args: Args[]): void { 8 | if (!IS_DEV) { 9 | return; 10 | } 11 | 12 | console.error(...args); 13 | } 14 | 15 | warn(...args: Args[]): void { 16 | if (!IS_DEV) { 17 | return; 18 | } 19 | 20 | console.warn(...args); 21 | } 22 | 23 | info(module: string, ...args: Args[]): void { 24 | if (!IS_DEV) { 25 | return; 26 | } 27 | 28 | console.group(`[${module}]`); 29 | console.log(...args); 30 | console.groupEnd(); 31 | } 32 | 33 | debug(...args: Args[]): void { 34 | if (!IS_DEV) { 35 | return; 36 | } 37 | 38 | console.debug(...args); 39 | } 40 | 41 | trace(...args: Args[]): void { 42 | if (!IS_DEV) { 43 | return; 44 | } 45 | 46 | console.trace(...args); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/core/adapters/session-storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './session-storage.adapter'; 2 | -------------------------------------------------------------------------------- /src/core/adapters/session-storage/session-storage.adapter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from 'containers/config'; 2 | import { Storage } from 'core/storage'; 3 | 4 | @Injectable() 5 | export class SessionStorageAdapter implements Storage { 6 | constructor(private readonly _storage: globalThis.Storage = window.sessionStorage) {} 7 | 8 | private _notify(eventInit: StorageEventInit) { 9 | window.dispatchEvent(new StorageEvent('storage', { ...eventInit, storageArea: this._storage })); 10 | } 11 | 12 | private _getStorageCallback(callback: () => void) { 13 | return (event: StorageEvent): void => { 14 | if (event.storageArea !== this._storage) { 15 | return; 16 | } 17 | 18 | callback(); 19 | }; 20 | } 21 | 22 | get(key: string): string { 23 | return this._storage.getItem(key); 24 | } 25 | 26 | remove(key: string): void { 27 | const oldValue = this.get(key); 28 | this._storage.removeItem(key); 29 | 30 | this._notify({ 31 | key, 32 | newValue: null, 33 | oldValue, 34 | }); 35 | } 36 | 37 | set(key: string, value: string): void { 38 | const oldValue = this.get(key); 39 | this._storage.setItem(key, value); 40 | 41 | this._notify({ 42 | key, 43 | newValue: value, 44 | oldValue, 45 | }); 46 | } 47 | 48 | listen = (callback: () => void): (() => void) => { 49 | const storageCallback = this._getStorageCallback(callback); 50 | window.addEventListener('storage', storageCallback); 51 | 52 | return () => { 53 | window.removeEventListener('storage', storageCallback); 54 | }; 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/core/form/form-api.ts: -------------------------------------------------------------------------------- 1 | import { FieldPath, FieldPathValue } from 'react-hook-form'; 2 | 3 | import { ServiceIdentifier } from 'containers/config'; 4 | 5 | import { FormApiErrors } from './form-api.types'; 6 | 7 | export const FormType: ServiceIdentifier
= Symbol('FormType'); 8 | 9 | export interface Form { 10 | setContext(value: Context): void; 11 | 12 | getValue< 13 | TFieldName extends FieldPath = FieldPath, 14 | TFieldValue extends FieldPathValue = FieldPathValue, 15 | >( 16 | name: TFieldName, 17 | ): TFieldValue; 18 | 19 | setValue< 20 | TFieldName extends FieldPath = FieldPath, 21 | TFieldValue extends FieldPathValue = FieldPathValue, 22 | >( 23 | name: TFieldName, 24 | value: TFieldValue, 25 | ): void; 26 | 27 | getValues(): Values; 28 | 29 | getErrors(): FormApiErrors; 30 | 31 | resetForm(): void; 32 | } 33 | -------------------------------------------------------------------------------- /src/core/form/form-api.types.ts: -------------------------------------------------------------------------------- 1 | export type FormApiErrors = Record; 2 | -------------------------------------------------------------------------------- /src/core/form/index.ts: -------------------------------------------------------------------------------- 1 | export * from './form-api'; 2 | -------------------------------------------------------------------------------- /src/core/http/endpoint/endpoint.ts: -------------------------------------------------------------------------------- 1 | import { buildUrl } from 'core/utils'; 2 | 3 | import { EndpointInterface } from './endpoint.types'; 4 | 5 | export class Endpoint implements EndpointInterface { 6 | constructor(private readonly _baseUrl: string) {} 7 | 8 | get baseUrl(): string { 9 | return this._baseUrl; 10 | } 11 | 12 | toUrl(map?: T extends string ? Record : never): string { 13 | if (map) { 14 | return buildUrl(this.baseUrl, map); 15 | } 16 | 17 | return this.baseUrl; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/core/http/endpoint/endpoint.types.ts: -------------------------------------------------------------------------------- 1 | export interface EndpointInterface { 2 | readonly baseUrl: string; 3 | 4 | toUrl(): string; 5 | toUrl(map: Record): string; 6 | } 7 | -------------------------------------------------------------------------------- /src/core/http/endpoint/index.ts: -------------------------------------------------------------------------------- 1 | export * from './endpoint'; 2 | export * from './endpoint.types'; 3 | -------------------------------------------------------------------------------- /src/core/http/http-client.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise } from 'axios'; 2 | 3 | import { ServiceIdentifier } from 'containers/config'; 4 | 5 | import { HttpRequestConfig } from './http.types'; 6 | import { 7 | HttpClientDeleteMethod, 8 | HttpClientGetMethod, 9 | HttpClientHeadMethod, 10 | HttpClientOptionsMethod, 11 | HttpClientPatchMethod, 12 | HttpClientPostMethod, 13 | HttpClientPutMethod, 14 | } from './http-method.types'; 15 | 16 | export const HttpClientType: ServiceIdentifier = Symbol('HttpClient'); 17 | export const HttpClientAdapterType: ServiceIdentifier> = 18 | Symbol('HttpClientAdapter'); 19 | 20 | export interface HttpClient { 21 | getConfig: () => THttpConfig; 22 | 23 | post: HttpClientPostMethod; 24 | get: HttpClientGetMethod; 25 | put: HttpClientPutMethod; 26 | delete: HttpClientDeleteMethod; 27 | head: HttpClientHeadMethod; 28 | options: HttpClientOptionsMethod; 29 | patch: HttpClientPatchMethod; 30 | } 31 | 32 | export interface HttpClientAdapter { 33 | execute: (config: C) => AxiosPromise; 34 | } 35 | -------------------------------------------------------------------------------- /src/core/http/http-method.types.ts: -------------------------------------------------------------------------------- 1 | import { HttpResponse } from './http.types'; 2 | 3 | export type HttpInterceptorManager = ( 4 | onFulfilled?: (value: TResponse) => Promise, 5 | onRejected?: (error: TResponse) => Promise, 6 | ) => number; 7 | 8 | export type HttpClientPostMethod = ( 9 | url: string, 10 | data?: TData, 11 | config?: TConfig, 12 | ) => Promise>; 13 | 14 | export type HttpClientGetMethod = ( 15 | url: string, 16 | config?: TConfig, 17 | ) => Promise>; 18 | 19 | export type HttpClientPutMethod = ( 20 | url: string, 21 | data?: TData, 22 | config?: TConfig, 23 | ) => Promise>; 24 | 25 | export type HttpClientDeleteMethod = ( 26 | url: string, 27 | config?: TConfig, 28 | ) => Promise>; 29 | 30 | export type HttpClientHeadMethod = ( 31 | url: string, 32 | config?: TConfig, 33 | ) => Promise>; 34 | 35 | export type HttpClientOptionsMethod = ( 36 | url: string, 37 | config?: TConfig, 38 | ) => Promise>; 39 | 40 | export type HttpClientPatchMethod = < 41 | TResponse, 42 | TData = unknown, 43 | TConfig = THttpConfig, 44 | >( 45 | url: string, 46 | data?: TData, 47 | config?: TConfig, 48 | ) => Promise>; 49 | -------------------------------------------------------------------------------- /src/core/http/http.types.ts: -------------------------------------------------------------------------------- 1 | import { AxiosHeaders, RawAxiosRequestHeaders } from 'axios'; 2 | 3 | export enum HttpMethods { 4 | POST = 'post', 5 | PUT = 'put', 6 | GET = 'get', 7 | DELETE = 'delete', 8 | HEAD = 'head', 9 | OPTIONS = 'options', 10 | PATCH = 'patch', 11 | PURGE = 'purge', 12 | LINK = 'link', 13 | UNLINK = 'unlink', 14 | } 15 | 16 | export enum HttpTokenTypes { 17 | Bearer = 'Bearer', 18 | Basic = 'Basic', 19 | } 20 | 21 | export type HttpHeaders = RawAxiosRequestHeaders | AxiosHeaders; 22 | 23 | export type HttpParams = Record; 24 | 25 | export interface HttpResponseData { 26 | [index: number]: 27 | | string 28 | | null 29 | | boolean 30 | | number 31 | | Record 32 | | Array; 33 | } 34 | 35 | export type HttpRequestConfig = Partial<{ 36 | url: string; 37 | method: string | HttpMethods; 38 | baseURL: string; 39 | headers: HttpHeaders; 40 | params: HttpParams; 41 | data: HttpResponseData; 42 | }>; 43 | 44 | export interface HttpResponse { 45 | data: T; 46 | status: number; 47 | statusText: string; 48 | headers: HttpHeaders; 49 | config: HttpRequestConfig; 50 | } 51 | -------------------------------------------------------------------------------- /src/core/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http-client'; 2 | export * from './http.types'; 3 | export * from './http-method.types'; 4 | -------------------------------------------------------------------------------- /src/core/i18n/i18n.constants.ts: -------------------------------------------------------------------------------- 1 | import { i18n as I18NextType } from 'i18next'; 2 | 3 | import { ServiceIdentifier } from 'containers/config'; 4 | 5 | import { I18n } from './i18n'; 6 | 7 | export const I18nType: ServiceIdentifier> = Symbol('I18n'); 8 | -------------------------------------------------------------------------------- /src/core/i18n/i18n.ts: -------------------------------------------------------------------------------- 1 | import { I18nLanguages } from './i18n.types'; 2 | 3 | export interface I18n { 4 | getInstance(): InstanceType; 5 | isInitialized(): boolean; 6 | isLoading(): boolean; 7 | changeLanguage(language: I18nLanguages): Promise; 8 | getLanguage(): I18nLanguages; 9 | } 10 | -------------------------------------------------------------------------------- /src/core/i18n/i18n.types.ts: -------------------------------------------------------------------------------- 1 | export enum I18nLanguages { 2 | en = 'en', 3 | ru = 'ru', 4 | } 5 | 6 | export enum I18nNamespaces { 7 | translation = 'translation', 8 | } 9 | -------------------------------------------------------------------------------- /src/core/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export * from './i18n.constants'; 2 | export * from './i18n.types'; 3 | export * from './i18n'; 4 | -------------------------------------------------------------------------------- /src/core/logger/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger'; 2 | export * from './logger.constants'; 3 | -------------------------------------------------------------------------------- /src/core/logger/logger.constants.ts: -------------------------------------------------------------------------------- 1 | import { ServiceIdentifier } from 'containers/config'; 2 | 3 | import { Logger } from './logger'; 4 | 5 | export const LoggerType: ServiceIdentifier = Symbol('Logger'); 6 | -------------------------------------------------------------------------------- /src/core/logger/logger.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | error>(...args: Args): void; 3 | warn>(...args: Args): void; 4 | info>(module: string, ...args: Args): void; 5 | debug>(...args: Args): void; 6 | trace>(...args: Args): void; 7 | } 8 | -------------------------------------------------------------------------------- /src/core/mobx-store/index.ts: -------------------------------------------------------------------------------- 1 | import { ServiceIdentifier } from 'containers/config'; 2 | 3 | import { MobxStore } from './mobx.store'; 4 | import { MobxStoreImpl } from './mobx.store.impl'; 5 | 6 | const MobxStoreType: ServiceIdentifier = Symbol('MobxStore'); 7 | 8 | export { MobxStoreImpl, MobxStoreType }; 9 | export type { MobxStore }; 10 | -------------------------------------------------------------------------------- /src/core/mobx-store/mobx.store.impl.ts: -------------------------------------------------------------------------------- 1 | import { MobxStore } from './mobx.store'; 2 | import { NotificationStoreImpl } from './notification-store'; 3 | 4 | export class MobxStoreImpl implements MobxStore { 5 | notifications = new NotificationStoreImpl(); 6 | } 7 | -------------------------------------------------------------------------------- /src/core/mobx-store/mobx.store.ts: -------------------------------------------------------------------------------- 1 | import { NotificationStore } from './notification-store'; 2 | 3 | export interface MobxStore { 4 | notifications: NotificationStore; 5 | } 6 | -------------------------------------------------------------------------------- /src/core/mobx-store/notification-store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './notification.store'; 2 | export * from './notification.store.impl'; 3 | -------------------------------------------------------------------------------- /src/core/mobx-store/notification-store/notification.store.impl.ts: -------------------------------------------------------------------------------- 1 | import { action, computed, makeAutoObservable, observable } from 'mobx'; 2 | 3 | import { Notification } from 'domain/notification/entities'; 4 | 5 | import { NotificationStore } from './notification.store'; 6 | 7 | export class NotificationStoreImpl implements NotificationStore { 8 | @observable private _data: Notification[] = []; 9 | 10 | constructor() { 11 | makeAutoObservable(this); 12 | } 13 | 14 | @computed list(): Notification[] { 15 | return this._data; 16 | } 17 | 18 | @action save(entity: Notification): Notification { 19 | this._data.push(entity); 20 | 21 | return entity; 22 | } 23 | 24 | @action delete(entity: Notification): Notification { 25 | this._data = this._data.filter(notification => notification.id !== entity.id); 26 | 27 | return entity; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/core/mobx-store/notification-store/notification.store.ts: -------------------------------------------------------------------------------- 1 | import { Notification } from 'domain/notification'; 2 | 3 | export interface NotificationStore { 4 | list(): Notification[]; 5 | save(entity: Notification): Notification; 6 | delete(entity: Notification): Notification; 7 | } 8 | -------------------------------------------------------------------------------- /src/core/storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './storage'; 2 | export * from './storage.constants'; 3 | export * from './storage.types'; 4 | -------------------------------------------------------------------------------- /src/core/storage/storage.constants.ts: -------------------------------------------------------------------------------- 1 | import { ServiceIdentifier } from 'containers/config'; 2 | 3 | import { Storage } from './storage'; 4 | 5 | export const StorageType: ServiceIdentifier = Symbol('Storage'); 6 | 7 | export const CookieStorageName = Symbol('CookieStorage'); 8 | export const LocalStorageName = Symbol('LocalStorage'); 9 | export const SessionStorageName = Symbol('SessionStorage'); 10 | -------------------------------------------------------------------------------- /src/core/storage/storage.ts: -------------------------------------------------------------------------------- 1 | export interface Storage { 2 | get: (key: string) => string; 3 | set: (key: string, value: string, ...args: unknown[]) => void; 4 | remove: (key: string) => void; 5 | } 6 | -------------------------------------------------------------------------------- /src/core/storage/storage.types.ts: -------------------------------------------------------------------------------- 1 | export enum StorageKeys { 2 | Theme = 'AppTheme', 3 | LoggedIn = 'isLoggedIn', 4 | Language = 'i18nextLng', 5 | } 6 | -------------------------------------------------------------------------------- /src/core/utils/__tests__/app-version.test.ts: -------------------------------------------------------------------------------- 1 | import { appVersion } from '../app-version.util'; 2 | 3 | describe('AppVersionUtil', () => { 4 | let spyConsoleInfo; 5 | 6 | beforeEach(() => { 7 | spyConsoleInfo = jest.spyOn(console, 'info'); 8 | }); 9 | 10 | afterEach(() => { 11 | spyConsoleInfo.mockReset(); 12 | spyConsoleInfo.mockRestore(); 13 | jest.restoreAllMocks(); 14 | }); 15 | 16 | it('should send info about current version to console', () => { 17 | appVersion(); 18 | 19 | expect(spyConsoleInfo).toHaveBeenCalledWith( 20 | `%cApplication version: %c0.0.123`, 21 | 'color: #209cee', 22 | 'color: #00d1b2', 23 | ); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/core/utils/__tests__/build-url.test.ts: -------------------------------------------------------------------------------- 1 | import { buildUrl } from '../build-url.util'; 2 | 3 | describe('BuildUrlUtil', () => { 4 | it('should convert endpoint url with options to url', () => { 5 | const result = buildUrl('/url/{value}/test/{value1}', { value: 1, value1: '2' }); 6 | 7 | expect(result).toBe('/url/1/test/2'); 8 | }); 9 | 10 | it(`shouldn't set any values to url`, () => { 11 | const result = buildUrl('/url/{value}/test/{value1}', {}); 12 | 13 | expect(result).toBe('/url/{value}/test/{value1}'); 14 | }); 15 | 16 | it(`shouldn't call reduce if mapper is empty`, () => { 17 | const result = buildUrl('/url/{value}/test/{value1}'); 18 | 19 | expect(result).toBe('/url/{value}/test/{value1}'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/core/utils/app-version.util.ts: -------------------------------------------------------------------------------- 1 | export function appVersion(): void { 2 | /* eslint-disable no-console */ 3 | if (typeof document !== 'undefined') { 4 | console.info(`%cApplication version: %c${UI_VERSION}`, 'color: #209cee', 'color: #00d1b2'); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/core/utils/backend-urls.util.ts: -------------------------------------------------------------------------------- 1 | export const clearUrl = (baseUrl: string): string => { 2 | const prefix = baseUrl.startsWith('https') ? 'https://' : 'http://'; 3 | const url = baseUrl.replace(prefix, '').replaceAll('//', '/'); 4 | 5 | return `${prefix}${url}`; 6 | }; 7 | 8 | export const getBackendUrl = (): string => { 9 | // see vite/server.ts for proxy config to /api 10 | return !IS_DEV ? BACKEND_URL : window.location.origin; 11 | }; 12 | -------------------------------------------------------------------------------- /src/core/utils/build-url.util.ts: -------------------------------------------------------------------------------- 1 | export function buildUrl(baseUrl: string, mapper?: Record): string { 2 | if (!mapper) { 3 | return baseUrl; 4 | } 5 | 6 | return Object.keys(mapper).reduce( 7 | (acc, key) => acc.replace(new RegExp(`\\{${key}\\}`, 'g'), mapper?.[key].toString()), 8 | baseUrl, 9 | ); 10 | } 11 | 12 | export function buildRoute(baseUrl: string, mapper?: Record): string { 13 | if (!mapper) { 14 | return baseUrl; 15 | } 16 | 17 | return Object.keys(mapper).reduce( 18 | (acc, key) => acc.replace(new RegExp(`\\:${key}`, 'g'), mapper?.[key].toString()), 19 | baseUrl, 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/core/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app-version.util'; 2 | export * from './backend-urls.util'; 3 | export * from './build-url.util'; 4 | export * from './is-nullable.util'; 5 | -------------------------------------------------------------------------------- /src/core/utils/is-nullable.util.ts: -------------------------------------------------------------------------------- 1 | import isNill from 'lodash/isNil'; 2 | 3 | export function isNullable(value: unknown): boolean { 4 | return isNill(value) || value === 'null'; 5 | } 6 | -------------------------------------------------------------------------------- /src/core/validators/index.ts: -------------------------------------------------------------------------------- 1 | // custom yup validators should be storing here 2 | // Example: not-earlier-or-same.validator.ts 3 | 4 | /* 5 | addMethod(date, 'notEarlierOrSame', function (field: string, message: string) { 6 | return this.test('notEarlierOrSame', message, (value: Date, context) => { 7 | const earlierField = context?.parent?.[field]; 8 | 9 | return value && earlierField 10 | ? isAfter(value, earlierField) || isEqual(value, earlierField) 11 | : true; 12 | }); 13 | }); 14 | */ 15 | 16 | // ! Don't forgot to declare your custom method 17 | // File Location: typings/global.d.ts or create a new typings/yup.d.ts 18 | /* 19 | declare module 'yup' { 20 | class DateSchema { 21 | notEarlierOrSame(field: string, msg: string): this; 22 | } 23 | } 24 | */ 25 | -------------------------------------------------------------------------------- /src/data/dao/notification/index.ts: -------------------------------------------------------------------------------- 1 | export * from './notification.dao.container'; 2 | -------------------------------------------------------------------------------- /src/data/dao/notification/notification.dao.container.ts: -------------------------------------------------------------------------------- 1 | import { AppContainer } from 'containers'; 2 | import { ServiceIdentifier } from 'containers/config'; 3 | 4 | import { NotificationDao } from './notification.dao'; 5 | import { NotificationDaoImpl } from './notification.dao.impl'; 6 | 7 | const NotificationDaoType: ServiceIdentifier = Symbol('NotificationDao'); 8 | 9 | AppContainer.bind(NotificationDaoType).to(NotificationDaoImpl); 10 | 11 | export { NotificationDaoType, NotificationDaoImpl }; 12 | export type { NotificationDao }; 13 | -------------------------------------------------------------------------------- /src/data/dao/notification/notification.dao.impl.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from 'containers/config'; 2 | import { MobxStore, MobxStoreType } from 'core/mobx-store'; 3 | import { Notification } from 'domain/notification'; 4 | 5 | import { NotificationDao } from './notification.dao'; 6 | 7 | @Injectable() 8 | export class NotificationDaoImpl implements NotificationDao { 9 | constructor(@Inject(MobxStoreType) private readonly _store: MobxStore) {} 10 | 11 | getList(): Notification[] { 12 | return this._store.notifications.list(); 13 | } 14 | 15 | save(entity: Notification): Notification { 16 | return this._store.notifications.save(entity); 17 | } 18 | 19 | remove(entity: Notification): Notification { 20 | return this._store.notifications.delete(entity); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/data/dao/notification/notification.dao.ts: -------------------------------------------------------------------------------- 1 | import { Notification } from 'domain/notification'; 2 | 3 | export interface NotificationDao { 4 | getList(): Notification[]; 5 | save(entity: Notification): Notification; 6 | remove(entity: Notification): void; 7 | } 8 | -------------------------------------------------------------------------------- /src/data/dto/auth/auth-token.dto.ts: -------------------------------------------------------------------------------- 1 | import { AuthResponse } from 'data/repositories/auth'; 2 | import { AuthToken } from 'domain/auth'; 3 | 4 | export class AuthTokenDto { 5 | static mapToEntity(value: AuthResponse.Login): AuthToken { 6 | return new AuthToken( 7 | value.access_token, 8 | value.token_type, 9 | value.refresh_token, 10 | value.expires_in, 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/data/dto/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth-token.dto'; 2 | -------------------------------------------------------------------------------- /src/data/repositories/auth/auth.repo.container.ts: -------------------------------------------------------------------------------- 1 | import { AppContainer } from 'containers'; 2 | import { ServiceIdentifier } from 'containers/config'; 3 | 4 | import { AuthRepo } from './auth.repo'; 5 | import { AuthRepoImpl } from './auth.repo.impl'; 6 | import { AuthResponse } from './auth.repo.response'; 7 | 8 | const AuthRepoType: ServiceIdentifier = Symbol('AuthRepo'); 9 | 10 | AppContainer.bind(AuthRepoType).to(AuthRepoImpl); 11 | 12 | export { AuthRepoType, AuthRepoImpl }; 13 | export type { AuthRepo, AuthResponse }; 14 | -------------------------------------------------------------------------------- /src/data/repositories/auth/auth.repo.impl.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from 'containers/config'; 2 | import { HttpClient, HttpClientType } from 'core/http'; 3 | import { Logger, LoggerType } from 'core/logger'; 4 | import { AuthTokenDto } from 'data/dto/auth'; 5 | import { AuthToken } from 'domain/auth'; 6 | 7 | import { AuthRepo } from './auth.repo'; 8 | import { AuthResponse } from './auth.repo.response'; 9 | 10 | @Injectable() 11 | export class AuthRepoImpl implements AuthRepo { 12 | constructor( 13 | @Inject(LoggerType) private readonly _logger: Logger, 14 | @Inject(HttpClientType) private readonly _http: HttpClient, 15 | ) {} 16 | 17 | login(login: string, password: string): Promise { 18 | const mockResponse: AuthResponse.Login = { 19 | access_token: `${login}_${password}`, 20 | expires_in: -1, 21 | refresh_token: `${login}_${password}`, 22 | scope: 'app', 23 | token_type: 'example', 24 | }; 25 | this._logger.info('AuthRepoImpl.login', mockResponse); 26 | 27 | return Promise.resolve(AuthTokenDto.mapToEntity(mockResponse)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/data/repositories/auth/auth.repo.response.ts: -------------------------------------------------------------------------------- 1 | export namespace AuthResponse { 2 | export interface Login { 3 | access_token: string; 4 | token_type: string; 5 | refresh_token: string; 6 | expires_in: number; 7 | scope: string; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/data/repositories/auth/auth.repo.ts: -------------------------------------------------------------------------------- 1 | import { AuthToken } from 'domain/auth'; 2 | 3 | export interface AuthRepo { 4 | login(login: string, password: string): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/data/repositories/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.repo.container'; 2 | -------------------------------------------------------------------------------- /src/data/repositories/auth/token-axios/index.ts: -------------------------------------------------------------------------------- 1 | export * from './token-axios.container'; 2 | -------------------------------------------------------------------------------- /src/data/repositories/auth/token-axios/token-axios.adapter.ts: -------------------------------------------------------------------------------- 1 | import { AxiosHeaders } from 'axios'; 2 | 3 | import { Injectable, InjectNamed, PostConstruct } from 'containers/config'; 4 | import { AxiosAdapter } from 'core/adapters'; 5 | import { HttpTokenTypes } from 'core/http'; 6 | import { LocalStorageName, Storage, StorageType } from 'core/storage'; 7 | import { AuthTokens } from 'domain/auth'; 8 | 9 | @Injectable() 10 | export class TokenAxiosAdapter extends AxiosAdapter { 11 | @InjectNamed(StorageType, LocalStorageName) private readonly _storage: Storage; 12 | 13 | @PostConstruct() 14 | initialize(): void { 15 | this._http.interceptors.request.use(config => { 16 | const headers = this._getHeaders(config?.headers as AxiosHeaders); 17 | 18 | return { 19 | ...config, 20 | headers, 21 | }; 22 | }); 23 | } 24 | 25 | private _getHeaders(headers?: AxiosHeaders): AxiosHeaders { 26 | const accessToken = this._storage.get(AuthTokens.ACCESS); 27 | 28 | if (!accessToken || !headers) { 29 | throw new Error('Access token not found!'); 30 | } 31 | 32 | headers.set('Authorization', `${HttpTokenTypes.Bearer} ${accessToken}`); 33 | 34 | return headers; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/data/repositories/auth/token-axios/token-axios.container.ts: -------------------------------------------------------------------------------- 1 | import { AppContainer } from 'containers'; 2 | import { ServiceIdentifier } from 'containers/config'; 3 | import { HttpClient } from 'core/http'; 4 | 5 | import { TokenAxiosAdapter } from './token-axios.adapter'; 6 | 7 | export const AuthHttpClientType: ServiceIdentifier = Symbol('TokenHttpClient'); 8 | 9 | AppContainer.bind(AuthHttpClientType).to(TokenAxiosAdapter); 10 | -------------------------------------------------------------------------------- /src/data/repositories/endpoints.ts: -------------------------------------------------------------------------------- 1 | import assign from 'lodash/assign'; 2 | 3 | import { Endpoint } from 'core/http/endpoint'; 4 | 5 | export const API = { 6 | login: new Endpoint('/login'), 7 | user: assign(new Endpoint('/user/{userId}'), { 8 | settings: new Endpoint('/user/{userId}/settings'), 9 | }), 10 | }; 11 | -------------------------------------------------------------------------------- /src/domain/auth/auth.types.ts: -------------------------------------------------------------------------------- 1 | export enum AuthTokens { 2 | ACCESS = 'ACCESS_TOKEN', 3 | } 4 | -------------------------------------------------------------------------------- /src/domain/auth/entities/auth-form-fields.entity.ts: -------------------------------------------------------------------------------- 1 | export class AuthFormFields { 2 | constructor( 3 | public login: string, 4 | public password: string, 5 | ) {} 6 | } 7 | -------------------------------------------------------------------------------- /src/domain/auth/entities/auth-token.entity.ts: -------------------------------------------------------------------------------- 1 | export class AuthToken { 2 | constructor( 3 | private readonly _accessToken: string, 4 | private readonly _tokenType: string, 5 | private readonly _refreshToken: string, 6 | private readonly _expires: number, 7 | ) {} 8 | 9 | get accessToken(): string { 10 | return this._accessToken; 11 | } 12 | 13 | get tokenType(): string { 14 | return this._tokenType; 15 | } 16 | 17 | get refreshToken(): string { 18 | return this._refreshToken; 19 | } 20 | 21 | get expires(): number { 22 | return this._expires; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/domain/auth/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth-form-fields.entity'; 2 | export * from './auth-token.entity'; 3 | -------------------------------------------------------------------------------- /src/domain/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities'; 2 | export * from './services'; 3 | export * from './auth.types'; 4 | -------------------------------------------------------------------------------- /src/domain/auth/services/auth.service.container.ts: -------------------------------------------------------------------------------- 1 | import { AppContainer } from 'containers'; 2 | import { ServiceIdentifier } from 'containers/config'; 3 | 4 | import { AuthService } from './auth.service'; 5 | import { AuthServiceImpl } from './auth.service.impl'; 6 | 7 | const AuthServiceType: ServiceIdentifier = Symbol('AuthService'); 8 | 9 | AppContainer.bind(AuthServiceType).to(AuthServiceImpl); 10 | 11 | export { AuthServiceImpl, AuthServiceType }; 12 | export type { AuthService }; 13 | -------------------------------------------------------------------------------- /src/domain/auth/services/auth.service.impl.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from 'containers/config'; 2 | import { AuthRepo, AuthRepoType } from 'data/repositories/auth'; 3 | 4 | import { AuthToken } from '../entities/auth-token.entity'; 5 | import { AuthService } from './auth.service'; 6 | 7 | @Injectable() 8 | export class AuthServiceImpl implements AuthService { 9 | constructor(@Inject(AuthRepoType) private readonly _authRepo: AuthRepo) {} 10 | 11 | login(login: string, password: string): Promise { 12 | return this._authRepo.login(login, password); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/domain/auth/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { AuthToken } from '../entities/auth-token.entity'; 2 | 3 | export interface AuthService { 4 | login(login: string, password: string): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/auth/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.service.container'; 2 | -------------------------------------------------------------------------------- /src/domain/date/date.types.ts: -------------------------------------------------------------------------------- 1 | export enum DateFormats { 2 | DATE = 'yyyy-MM-dd', 3 | DATE_AND_TIME = 'yyyy-MM-dd HH:mm:ss', 4 | } 5 | -------------------------------------------------------------------------------- /src/domain/date/entities/date.entity.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns/format'; 2 | 3 | import { DateFormats } from '../date.types'; 4 | 5 | export class DateEntity { 6 | constructor( 7 | private readonly _date: Date, 8 | private readonly _format: DateFormats, 9 | ) {} 10 | 11 | formatBy(dateFormat: DateFormats): string { 12 | return format(this._date, dateFormat); 13 | } 14 | 15 | toString(): string { 16 | return this.formatBy(this._format); 17 | } 18 | 19 | static parse(date: Date, dateFormat: DateFormats = DateFormats.DATE): DateEntity { 20 | return new DateEntity(date, dateFormat); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/domain/date/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './date.entity'; 2 | -------------------------------------------------------------------------------- /src/domain/date/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities'; 2 | export * from './date.types'; 3 | -------------------------------------------------------------------------------- /src/domain/error/entities/error.entity.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from 'lodash'; 2 | 3 | import { HttpRequestConfig } from 'core/http'; 4 | 5 | import { ErrorCodes, StatusCodes } from '../error.types'; 6 | 7 | export class Error { 8 | constructor( 9 | private readonly _statusCode: number, 10 | private readonly _code: ErrorCodes, 11 | private readonly _message: string, 12 | private readonly _details: string, 13 | private readonly _config: HttpRequestConfig, 14 | ) {} 15 | 16 | get statusCode(): number { 17 | return this._statusCode; 18 | } 19 | 20 | get code(): ErrorCodes { 21 | return this._code; 22 | } 23 | 24 | get message(): string { 25 | return this._message; 26 | } 27 | 28 | get details(): string { 29 | return this._details; 30 | } 31 | 32 | get config(): HttpRequestConfig { 33 | return this._config; 34 | } 35 | 36 | isUnauthorized(): boolean { 37 | return isEqual(this.statusCode, StatusCodes.UNAUTHORIZED); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/domain/error/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error.entity'; 2 | -------------------------------------------------------------------------------- /src/domain/error/error.types.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequestConfig, HttpResponse } from 'core/http'; 2 | 3 | export type ErrorResponse = Error | HttpResponse; 4 | 5 | export enum ErrorCodes { 6 | ERROR = 'ERROR', 7 | BUSINESS_EXCEPTION = 'BUSINESS_EXCEPTION', 8 | } 9 | 10 | export enum StatusCodes { 11 | UNAUTHORIZED = 401, 12 | } 13 | -------------------------------------------------------------------------------- /src/domain/error/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities'; 2 | export * from './services'; 3 | export * from './error.types'; 4 | -------------------------------------------------------------------------------- /src/domain/error/services/error.service.container.ts: -------------------------------------------------------------------------------- 1 | import { AppContainer } from 'containers'; 2 | import { ServiceIdentifier } from 'containers/config'; 3 | 4 | import { ErrorService } from './error.service'; 5 | import { ErrorServiceImpl } from './error.service.impl'; 6 | 7 | const ErrorServiceType: ServiceIdentifier = Symbol('ErrorService'); 8 | 9 | AppContainer.bind(ErrorServiceType).to(ErrorServiceImpl); 10 | 11 | export { ErrorServiceImpl, ErrorServiceType }; 12 | export type { ErrorService }; 13 | -------------------------------------------------------------------------------- /src/domain/error/services/error.service.impl.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from 'containers/config'; 2 | import { Notification, NotificationService, NotificationServiceType } from 'domain/notification'; 3 | 4 | import { Error } from '../entities/error.entity'; 5 | import { ErrorResponse } from '../error.types'; 6 | import { ErrorService } from './error.service'; 7 | 8 | @Injectable() 9 | export class ErrorServiceImpl implements ErrorService { 10 | constructor( 11 | @Inject(NotificationServiceType) private readonly _notificationService: NotificationService, 12 | ) {} 13 | 14 | handle = (error: Error): Promise => { 15 | if (error?.isUnauthorized()) { 16 | // is unauth 17 | } 18 | 19 | this._notificationService.error(new Notification(error?.message)); 20 | 21 | return Promise.reject(error); 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/domain/error/services/error.service.ts: -------------------------------------------------------------------------------- 1 | import { Error } from '../entities/error.entity'; 2 | import { ErrorResponse } from '../error.types'; 3 | 4 | export interface ErrorService { 5 | handle: (error: Error) => Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/domain/error/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error.service.container'; 2 | -------------------------------------------------------------------------------- /src/domain/notification/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './notification.entity'; 2 | -------------------------------------------------------------------------------- /src/domain/notification/entities/notification.entity.ts: -------------------------------------------------------------------------------- 1 | import { NotificationType } from '../notification.types'; 2 | 3 | export class Notification { 4 | private _id: string; 5 | 6 | private _type: NotificationType = NotificationType.Info; 7 | 8 | constructor( 9 | private readonly _message?: string, 10 | private readonly _title?: string, 11 | ) { 12 | this._id = String(+new Date()); 13 | } 14 | 15 | get title(): string { 16 | return this._title; 17 | } 18 | 19 | get message(): string { 20 | return this._message; 21 | } 22 | 23 | get type(): NotificationType { 24 | return this._type; 25 | } 26 | 27 | get id(): string { 28 | return this._id; 29 | } 30 | 31 | setType(type: NotificationType): this { 32 | this._type = type; 33 | 34 | return this; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/domain/notification/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities'; 2 | export * from './services'; 3 | export * from './notification.types'; 4 | -------------------------------------------------------------------------------- /src/domain/notification/notification.types.ts: -------------------------------------------------------------------------------- 1 | export enum NotificationType { 2 | Success = 'success', 3 | Info = 'info', 4 | Error = 'error', 5 | Warn = 'warn', 6 | } 7 | 8 | export const DEFAULT_NOTIFICATION_DELAY = 5000; 9 | -------------------------------------------------------------------------------- /src/domain/notification/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './notification.service.container'; 2 | -------------------------------------------------------------------------------- /src/domain/notification/services/notification.service.container.ts: -------------------------------------------------------------------------------- 1 | import { AppContainer } from 'containers'; 2 | import { ServiceIdentifier } from 'containers/config'; 3 | 4 | import { NotificationService } from './notification.service'; 5 | import { NotificationServiceImpl } from './notification.service.impl'; 6 | 7 | const NotificationServiceType: ServiceIdentifier = 8 | Symbol('NotificationServiceType'); 9 | 10 | AppContainer.bind(NotificationServiceType).to(NotificationServiceImpl); 11 | 12 | export { NotificationServiceType, NotificationServiceImpl }; 13 | export type { NotificationService }; 14 | -------------------------------------------------------------------------------- /src/domain/notification/services/notification.service.impl.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from 'containers/config'; 2 | import { NotificationDao, NotificationDaoType } from 'data/dao/notification'; 3 | 4 | import { Notification } from '../entities/notification.entity'; 5 | import { DEFAULT_NOTIFICATION_DELAY, NotificationType } from '../notification.types'; 6 | import { NotificationService } from './notification.service'; 7 | 8 | @Injectable() 9 | export class NotificationServiceImpl implements NotificationService { 10 | constructor(@Inject(NotificationDaoType) private readonly notifications: NotificationDao) {} 11 | 12 | #closeWithDelay(entity: Notification, duration: number = DEFAULT_NOTIFICATION_DELAY) { 13 | setTimeout(() => { 14 | this.close(entity); 15 | }, duration); 16 | } 17 | 18 | private _add(entity: Notification, type: NotificationType): Notification { 19 | const notification = entity.setType(type); 20 | 21 | this.notifications.save(notification); 22 | this.#closeWithDelay(notification); 23 | 24 | return notification; 25 | } 26 | 27 | success(entity: Notification): Notification { 28 | return this._add(entity, NotificationType.Success); 29 | } 30 | 31 | error(entity: Notification): Notification { 32 | return this._add(entity, NotificationType.Error); 33 | } 34 | 35 | warn(entity: Notification): Notification { 36 | return this._add(entity, NotificationType.Warn); 37 | } 38 | 39 | info(entity: Notification): Notification { 40 | return this._add(entity, NotificationType.Info); 41 | } 42 | 43 | close(entity: Notification): void { 44 | this.notifications.remove(entity); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/domain/notification/services/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Notification } from '../entities'; 2 | 3 | export interface NotificationService { 4 | success(entity: Notification): Notification; 5 | error(entity: Notification): Notification; 6 | warn(entity: Notification): Notification; 7 | info(entity: Notification): Notification; 8 | close(entity: Notification): void; 9 | } 10 | -------------------------------------------------------------------------------- /src/domain/route/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './route.entity'; 2 | -------------------------------------------------------------------------------- /src/domain/route/entities/route.entity.ts: -------------------------------------------------------------------------------- 1 | import some from 'lodash/some'; 2 | 3 | import { buildRoute, isNullable } from 'core/utils'; 4 | 5 | export class Route { 6 | constructor(private readonly _baseUrl: string) {} 7 | 8 | get baseUrl(): string { 9 | return this._baseUrl; 10 | } 11 | 12 | toUrl(map: Record, searchParams?: URLSearchParams): string { 13 | const url = buildRoute(this._baseUrl, map); 14 | 15 | if (url && searchParams) { 16 | return `${url}?${searchParams.toString()}`; 17 | } 18 | 19 | return url; 20 | } 21 | 22 | toSafeUrl(map: Record, searchParams?: URLSearchParams): string { 23 | if (some(map, isNullable)) { 24 | return null; 25 | } 26 | 27 | return this.toUrl(map, searchParams); 28 | } 29 | 30 | toString(): string { 31 | return this._baseUrl; 32 | } 33 | 34 | isEquals(route: Route): boolean { 35 | return this.baseUrl === route.baseUrl; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/domain/route/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities'; 2 | export * from './services'; 3 | export * from './route.constants'; 4 | -------------------------------------------------------------------------------- /src/domain/route/route.constants.ts: -------------------------------------------------------------------------------- 1 | import assign from 'lodash/assign'; 2 | 3 | import { Route } from './entities'; 4 | 5 | export const ROUTES = { 6 | signIn: new Route('/sign-in'), 7 | signOut: new Route('/sign-out'), 8 | users: assign(new Route('/users'), { 9 | list: new Route('/users/list'), 10 | stats: new Route('/users/stats'), 11 | }), 12 | }; 13 | -------------------------------------------------------------------------------- /src/domain/route/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './route.service.container'; 2 | -------------------------------------------------------------------------------- /src/domain/route/services/route.service.container.ts: -------------------------------------------------------------------------------- 1 | import { AppContainer } from 'containers'; 2 | import { ServiceIdentifier } from 'containers/config'; 3 | 4 | import { RouteService } from './route.service'; 5 | import { RouteServiceImpl } from './route.service.impl'; 6 | 7 | const RouteServiceType: ServiceIdentifier = Symbol('RouteService'); 8 | 9 | AppContainer.bind(RouteServiceType).to(RouteServiceImpl); 10 | 11 | export { RouteServiceImpl, RouteServiceType }; 12 | export type { RouteService }; 13 | -------------------------------------------------------------------------------- /src/domain/route/services/route.service.impl.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from 'containers/config'; 2 | 3 | import { RouteService } from './route.service'; 4 | 5 | @Injectable() 6 | export class RouteServiceImpl implements RouteService { 7 | redirectToSignIn(): void { 8 | return null; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/domain/route/services/route.service.ts: -------------------------------------------------------------------------------- 1 | export interface RouteService { 2 | redirectToSignIn(): void; 3 | } 4 | -------------------------------------------------------------------------------- /src/presentation/forms/auth/auth.form.container.ts: -------------------------------------------------------------------------------- 1 | import { AppContainer } from 'containers'; 2 | import { ServiceIdentifier } from 'containers/config'; 3 | import { AuthFormFields } from 'domain/auth'; 4 | 5 | import { BaseForm } from '../base.form'; 6 | import { AuthForm, AuthFormSubmitResponse } from './auth.form'; 7 | 8 | const AuthFormType: ServiceIdentifier> = 9 | Symbol('AuthForm'); 10 | 11 | AppContainer.bind(AuthFormType).to(AuthForm); 12 | export { AuthForm, AuthFormType }; 13 | export type { AuthFormSubmitResponse }; 14 | -------------------------------------------------------------------------------- /src/presentation/forms/auth/auth.form.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { Inject, Injectable } from 'containers/config'; 4 | import { AuthFormFields, AuthService, AuthServiceType, AuthToken } from 'domain/auth'; 5 | 6 | import { ChangeForm } from '../base.form'; 7 | import { BaseFormImpl } from '../base.form.impl'; 8 | 9 | export type AuthFormSubmitResponse = Promise; 10 | 11 | type ValidationSchema = yup.ObjectSchema; 12 | 13 | @Injectable() 14 | export class AuthForm extends BaseFormImpl { 15 | constructor(@Inject(AuthServiceType) private readonly _authService: AuthService) { 16 | super(); 17 | } 18 | 19 | getInitialValues(): AuthFormFields { 20 | return new AuthFormFields(null, null); 21 | } 22 | 23 | validationSchema(): ValidationSchema { 24 | return yup.object().shape({ 25 | login: yup.string().required().nullable().email().label('Login'), 26 | password: yup.string().required().nullable().label('Password'), 27 | }); 28 | } 29 | 30 | handleSubmit = (values: AuthFormFields): AuthFormSubmitResponse => { 31 | return this._authService.login(values?.login, values?.password); 32 | }; 33 | 34 | handleChange = (values: AuthFormFields): ChangeForm => { 35 | return { 36 | fields: values, 37 | }; 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/presentation/forms/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.form.container'; 2 | -------------------------------------------------------------------------------- /src/presentation/forms/base.form.impl.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from 'containers/config'; 2 | import { Form, FormType } from 'core/form'; 3 | 4 | import { BaseForm, ChangeForm } from './base.form'; 5 | 6 | @Injectable() 7 | export abstract class BaseFormImpl 8 | implements BaseForm 9 | { 10 | @Inject(FormType) api: Form; 11 | 12 | getInitialValues(): Values { 13 | throw new Error('Method getInitialValues is not implemented'); 14 | } 15 | 16 | validationSchema(): ValidationSchema { 17 | throw new Error('Method validationSchema is not implemented'); 18 | } 19 | 20 | handleChange = (_?: Values): ChangeForm => { 21 | throw new Error(`Method onChange is not implemented!`); 22 | }; 23 | 24 | handleSubmit = (_?: Values): SubmitResponse => { 25 | throw new Error('Method onSubmit is not implemented'); 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/presentation/forms/base.form.ts: -------------------------------------------------------------------------------- 1 | import { Form } from 'core/form'; 2 | 3 | export type ChangeForm = { 4 | fields?: Values; 5 | }; 6 | 7 | export interface BaseForm { 8 | api: Form; 9 | 10 | getInitialValues(): Values; 11 | 12 | validationSchema(): ValidationSchema; 13 | 14 | handleChange(values?: Values): ChangeForm; 15 | 16 | handleSubmit(values?: Values): SubmitResponse; 17 | } 18 | -------------------------------------------------------------------------------- /src/presentation/mobile/index.tsx: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import React from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | 5 | import { App } from 'presentation/web/app.component'; 6 | 7 | const container = document.getElementById('root'); 8 | const root = createRoot(container); 9 | 10 | root.render(); 11 | -------------------------------------------------------------------------------- /src/presentation/presenters/auth/auth.presenter.impl.ts: -------------------------------------------------------------------------------- 1 | import { i18n as I18NextType, TFunction } from 'i18next'; 2 | 3 | import { Inject, Injectable } from 'containers/config'; 4 | import { I18n, I18nType } from 'core/i18n'; 5 | import { AuthFormFields } from 'domain/auth'; 6 | import { AuthFormSubmitResponse, AuthFormType } from 'presentation/forms/auth'; 7 | import { BaseForm } from 'presentation/forms/base.form'; 8 | 9 | import { AuthPresenter } from './auth.presenter'; 10 | 11 | @Injectable() 12 | export class AuthPresenterImpl implements AuthPresenter { 13 | constructor( 14 | @Inject(AuthFormType) private readonly _form: BaseForm, 15 | @Inject(I18nType) private readonly _i18n: I18n, 16 | ) {} 17 | 18 | public get form(): BaseForm { 19 | return this._form; 20 | } 21 | 22 | public get t(): TFunction { 23 | return this._i18n.getInstance().t; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/presentation/presenters/auth/auth.presenter.ts: -------------------------------------------------------------------------------- 1 | import { TFunction } from 'i18next'; 2 | 3 | import { AuthFormFields } from 'domain/auth'; 4 | import { AuthFormSubmitResponse } from 'presentation/forms/auth'; 5 | import { BaseForm } from 'presentation/forms/base.form'; 6 | 7 | export interface AuthPresenter { 8 | form: BaseForm; 9 | t: TFunction; 10 | } 11 | -------------------------------------------------------------------------------- /src/presentation/presenters/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { AppContainer } from 'containers'; 2 | import { ServiceIdentifier } from 'containers/config'; 3 | 4 | import type { AuthPresenter } from './auth.presenter'; 5 | import { AuthPresenterImpl } from './auth.presenter.impl'; 6 | 7 | const AuthPresenterType: ServiceIdentifier = Symbol('AuthPresenter'); 8 | 9 | AppContainer.bind(AuthPresenterType).to(AuthPresenterImpl); 10 | 11 | export { AuthPresenter, AuthPresenterType, AuthPresenterImpl }; 12 | -------------------------------------------------------------------------------- /src/presentation/shared/components/error-boundary/error-boundary.component.tsx: -------------------------------------------------------------------------------- 1 | import React, { ErrorInfo, ReactNode } from 'react'; 2 | 3 | import { LoggerType } from 'core/logger'; 4 | 5 | import { useInjection } from '../ioc'; 6 | import { ErrorBoundaryProps, ErrorBoundaryState } from './error-boundary.types'; 7 | 8 | class ErrorBoundaryComponent extends React.Component { 9 | constructor(props: ErrorBoundaryProps) { 10 | super(props); 11 | this.state = { hasError: false }; 12 | } 13 | 14 | static getDerivedStateFromError() { 15 | return { hasError: true }; 16 | } 17 | 18 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { 19 | this.props.logger.error('ErrorBoundary', { error, errorInfo }); 20 | } 21 | 22 | render() { 23 | if (this.state.hasError) { 24 | return

Oops, something went wrong.

; 25 | } 26 | 27 | return this.props.children; 28 | } 29 | } 30 | 31 | export function ErrorBoundary(props: { children: ReactNode }) { 32 | const logger = useInjection(LoggerType); 33 | 34 | return {props.children}; 35 | } 36 | -------------------------------------------------------------------------------- /src/presentation/shared/components/error-boundary/error-boundary.types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import { Logger } from 'core/logger'; 4 | 5 | export type ErrorBoundaryProps = { 6 | logger: Logger; 7 | children: ReactNode; 8 | }; 9 | 10 | export type ErrorBoundaryState = { 11 | hasError: boolean; 12 | }; 13 | -------------------------------------------------------------------------------- /src/presentation/shared/components/error-boundary/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error-boundary.component'; 2 | -------------------------------------------------------------------------------- /src/presentation/shared/components/i18n/i18n.hook.ts: -------------------------------------------------------------------------------- 1 | import { i18n as I18NextType, TFunction } from 'i18next'; 2 | 3 | import { I18n, I18nLanguages, I18nType } from 'core/i18n'; 4 | 5 | import { useInjection } from '../ioc'; 6 | 7 | export function useI18nAdapter(): { 8 | t: TFunction; 9 | i18n: I18NextType; 10 | adapter: I18n; 11 | language: I18nLanguages; 12 | exists: (key: string) => boolean; 13 | } { 14 | const i18next: I18n = useInjection(I18nType); 15 | const instance = i18next.getInstance(); 16 | 17 | return { 18 | adapter: i18next, 19 | exists: instance.exists, 20 | i18n: instance, 21 | language: i18next.getLanguage(), 22 | t: instance.t, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/presentation/shared/components/i18n/i18n.util.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | import { Trans } from './trans.component'; 4 | 5 | export const translate = (key: string): ReactElement => ; 6 | -------------------------------------------------------------------------------- /src/presentation/shared/components/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export * from './i18n.hook'; 2 | export * from './i18n.util'; 3 | export * from './trans.component'; 4 | -------------------------------------------------------------------------------- /src/presentation/shared/components/i18n/trans.component.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { observer } from 'mobx-react'; 4 | 5 | type Props = { 6 | i18nKey: string; 7 | }; 8 | 9 | export const Trans: React.FC = observer(({ i18nKey }): ReactElement => { 10 | const { t } = useTranslation(); 11 | 12 | return <>{t(i18nKey)}; 13 | }); 14 | -------------------------------------------------------------------------------- /src/presentation/shared/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error-boundary'; 2 | export * from './i18n'; 3 | export * from './ioc'; 4 | export * from './portal'; 5 | export * from './protected-route'; 6 | -------------------------------------------------------------------------------- /src/presentation/shared/components/ioc/hooks/container.hook.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { Container } from 'containers/config'; 4 | 5 | import { Context } from '../ioc.constants'; 6 | 7 | export function useContainer(): Container { 8 | const { container } = useContext(Context); 9 | 10 | if (!container) { 11 | throw new Error('The container should not be null'); 12 | } 13 | 14 | if (!(container instanceof Container)) { 15 | throw new Error('The container should have the "Container" instance'); 16 | } 17 | 18 | return container; 19 | } 20 | -------------------------------------------------------------------------------- /src/presentation/shared/components/ioc/hooks/injection.hook.ts: -------------------------------------------------------------------------------- 1 | import { ServiceIdentifier } from 'containers/config'; 2 | 3 | import { useContainer } from './container.hook'; 4 | 5 | export function useInjection(identifier: ServiceIdentifier): T { 6 | const container = useContainer(); 7 | 8 | try { 9 | return container.get(identifier) || container.getAll(identifier)?.[0]; 10 | } catch (e) { 11 | // eslint-disable-next-line no-console 12 | console.error(`[UseInjection]: Unable to load ${String(identifier)} service!`); 13 | 14 | return null; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/presentation/shared/components/ioc/index.ts: -------------------------------------------------------------------------------- 1 | import { Context } from './ioc.constants'; 2 | import { Provider } from './ioc.provider'; 3 | 4 | export { useInjection } from './hooks/injection.hook'; 5 | 6 | export const IoC = { 7 | Context, 8 | Provider, 9 | }; 10 | -------------------------------------------------------------------------------- /src/presentation/shared/components/ioc/ioc.constants.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ContainerContext } from './ioc.types'; 4 | 5 | export const Context = React.createContext({ container: null }); 6 | -------------------------------------------------------------------------------- /src/presentation/shared/components/ioc/ioc.provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import { Context } from './ioc.constants'; 4 | import { IoCProps } from './ioc.types'; 5 | 6 | export function Provider({ container, children }: IoCProps): React.ReactElement { 7 | const providerValue = useMemo(() => ({ container }), [container]); 8 | 9 | return {children}; 10 | } 11 | -------------------------------------------------------------------------------- /src/presentation/shared/components/ioc/ioc.types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Container, ContainerModule } from 'containers/config'; 4 | 5 | export type InjectContainers = ContainerModule | ContainerModule[]; 6 | 7 | export interface ContainerContext { 8 | container: Container | null; 9 | } 10 | 11 | export interface IoCProps { 12 | container: Container; 13 | children: React.ReactNode | React.ReactNode[]; 14 | } 15 | 16 | export interface InjectContainersProps { 17 | containers: ContainerModule[]; 18 | children: React.ReactNode | React.ReactNode[]; 19 | } 20 | -------------------------------------------------------------------------------- /src/presentation/shared/components/portal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './portal.component'; 2 | -------------------------------------------------------------------------------- /src/presentation/shared/components/portal/portal.component.tsx: -------------------------------------------------------------------------------- 1 | import { ReactPortal, useLayoutEffect, useState } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | import { PortalProps } from './portal.types'; 5 | 6 | export const Portal = ({ target, children }: PortalProps): ReactPortal => { 7 | const [element, setElement] = useState( 8 | (target && target.current) || document.getElementById('portals'), 9 | ); 10 | 11 | useLayoutEffect(() => { 12 | if (target) { 13 | setElement(target.current); 14 | } 15 | }, [target]); 16 | 17 | return createPortal(children, element); 18 | }; 19 | -------------------------------------------------------------------------------- /src/presentation/shared/components/portal/portal.types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode, RefObject } from 'react'; 2 | 3 | export type PortalProps = { 4 | target?: RefObject; 5 | children: ReactNode; 6 | }; 7 | -------------------------------------------------------------------------------- /src/presentation/shared/components/protected-route/index.ts: -------------------------------------------------------------------------------- 1 | export * from './protected-route.component'; 2 | -------------------------------------------------------------------------------- /src/presentation/shared/components/protected-route/protected-route.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Navigate, Outlet } from 'react-router-dom'; 3 | 4 | import { ROUTES } from 'domain/route'; 5 | 6 | import { ProtectedRouteProps } from './protected-route.types'; 7 | 8 | export const ProtectedRoute: React.FC = ({ 9 | user, 10 | redirectPath = ROUTES.signIn.toString(), 11 | children, 12 | roles, 13 | }) => { 14 | if (!user || (roles && !user?.hasRoles(roles))) { 15 | return ; 16 | } 17 | 18 | return children || ; 19 | }; 20 | -------------------------------------------------------------------------------- /src/presentation/shared/components/protected-route/protected-route.types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type ProtectedRouteProps = { 4 | user: { hasRoles(roles: Array): boolean }; 5 | redirectPath?: string; 6 | children?: React.ReactElement; 7 | roles?: Array; 8 | }; 9 | -------------------------------------------------------------------------------- /src/presentation/shared/hoc/error-boundary.hoc.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, ReactElement } from 'react'; 2 | import { observer } from 'mobx-react'; 3 | 4 | import { ErrorBoundary, getDisplayName } from 'presentation/shared'; 5 | 6 | export function withErrorBoundary(WrappedComponent: FunctionComponent): FunctionComponent { 7 | const WithErrorBoundary: FunctionComponent = observer((props: T): ReactElement => { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | }); 14 | WithErrorBoundary.displayName = `withErrorBoundary(${getDisplayName(WrappedComponent)})`; 15 | 16 | return WithErrorBoundary; 17 | } 18 | -------------------------------------------------------------------------------- /src/presentation/shared/hoc/hoc.helpers.ts: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | 3 | export function getDisplayName(WrappedComponent: FunctionComponent): string { 4 | return WrappedComponent.displayName || WrappedComponent.name || 'Component'; 5 | } 6 | -------------------------------------------------------------------------------- /src/presentation/shared/hoc/i18n.hoc.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, ReactElement } from 'react'; 2 | import { I18nextProvider } from 'react-i18next'; 3 | import { observer } from 'mobx-react'; 4 | 5 | import { useI18nAdapter } from '../components'; 6 | import { getDisplayName } from './hoc.helpers'; 7 | 8 | export function withI18n(WrappedComponent: FunctionComponent): FunctionComponent { 9 | const WithI18n: FunctionComponent = observer((props: T): ReactElement => { 10 | const { adapter, i18n } = useI18nAdapter(); 11 | 12 | if (adapter.isLoading()) { 13 | return null; 14 | } 15 | 16 | return ( 17 | 18 | 19 | 20 | ); 21 | }); 22 | WithI18n.displayName = `WithI18n(${getDisplayName(WrappedComponent)})`; 23 | 24 | return WithI18n; 25 | } 26 | -------------------------------------------------------------------------------- /src/presentation/shared/hoc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error-boundary.hoc'; 2 | export * from './i18n.hoc'; 3 | export * from './hoc.helpers'; 4 | -------------------------------------------------------------------------------- /src/presentation/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './hoc'; 2 | export * from './components'; 3 | -------------------------------------------------------------------------------- /src/presentation/web/app.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { AppContainer } from 'containers'; 4 | import { IoC } from 'presentation/shared'; 5 | import { AppRoutes } from 'presentation/web/routes'; 6 | 7 | import { GlobalStyles } from './styles'; 8 | 9 | export function App(): React.ReactElement { 10 | return ( 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/box/box.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { BoxWrapper } from './box.styles'; 4 | import { BoxProps } from './box.types'; 5 | 6 | export function Box({ className, children }: BoxProps): React.ReactElement { 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/box/box.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const BoxWrapper = styled.div` 4 | background: var(--gray4); 5 | box-shadow: var(--shadow); 6 | border-radius: 15px; 7 | `; 8 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/box/box.types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type BoxProps = { 4 | className?: string; 5 | children: React.ReactElement | React.ReactElement[]; 6 | }; 7 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/box/index.ts: -------------------------------------------------------------------------------- 1 | export * from './box.component'; 2 | export * from './box.types'; 3 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/button/button.component.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import isFunction from 'lodash/isFunction'; 3 | 4 | import { ButtonWrapper, LoaderWrapper } from './button.styles'; 5 | import { ButtonProps } from './button.types'; 6 | 7 | export function Button(props: ButtonProps): React.ReactElement { 8 | const { children, loading, disabled, onClick, ...rest } = props; 9 | 10 | const handleClick = useCallback(() => { 11 | if (disabled || loading || !isFunction(onClick)) { 12 | return; 13 | } 14 | 15 | onClick(); 16 | }, [disabled, loading, onClick]); 17 | 18 | return ( 19 | 20 | {loading && {/* */}} 21 | {children} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/button/button.constants.ts: -------------------------------------------------------------------------------- 1 | import { ButtonVariants } from './button.types'; 2 | 3 | export const DEFAULT_HEIGHT = '52px'; 4 | export const DEFAULT_WIDTH = '100%'; 5 | 6 | export const BorderRadiusMapper = { 7 | [ButtonVariants.Round]: DEFAULT_HEIGHT, 8 | [ButtonVariants.HalfRound]: '15px', 9 | }; 10 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/button/button.helpers.ts: -------------------------------------------------------------------------------- 1 | import { BorderRadiusMapper } from './button.constants'; 2 | import { ButtonVariants } from './button.types'; 3 | 4 | export function getBorderRadiusByVariant(variant: ButtonVariants): string { 5 | return variant in BorderRadiusMapper ? BorderRadiusMapper[variant] : '0'; 6 | } 7 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/button/button.styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | import { DEFAULT_HEIGHT, DEFAULT_WIDTH } from './button.constants'; 4 | import { getBorderRadiusByVariant } from './button.helpers'; 5 | import { ButtonVariants, ButtonWrapperProps } from './button.types'; 6 | 7 | export const ButtonWrapper = styled.button` 8 | position: relative; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | flex-wrap: wrap; 13 | height: ${({ height = DEFAULT_HEIGHT }) => height}; 14 | width: ${({ width = DEFAULT_WIDTH }) => width}; 15 | padding: 4px 5px 0; 16 | font-size: 20px; 17 | font-weight: 600; 18 | text-transform: uppercase; 19 | border-radius: ${({ variant = ButtonVariants.Round }) => getBorderRadiusByVariant(variant)}; 20 | transition: box-shadow 0.2s; 21 | overflow: hidden; 22 | 23 | ${({ disabled, isLoading }) => 24 | !disabled && 25 | !isLoading && 26 | css` 27 | &:hover { 28 | box-shadow: lightgray; 29 | } 30 | `} 31 | 32 | ${({ disabled }) => 33 | disabled 34 | ? css` 35 | color: gray; 36 | background-color: lightgray; 37 | ` 38 | : css` 39 | color: white; 40 | background-color: orange; 41 | `} 42 | `; 43 | 44 | export const LoaderWrapper = styled.div` 45 | position: absolute; 46 | left: 0; 47 | top: 0; 48 | width: 100%; 49 | height: 100%; 50 | display: flex; 51 | align-items: center; 52 | justify-content: center; 53 | background-color: lightgray; 54 | `; 55 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/button/button.types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export enum ButtonVariants { 4 | Round = 'round', 5 | HalfRound = 'halfRound', 6 | } 7 | 8 | export type ButtonWrapperProps = Partial<{ 9 | height: string; 10 | width: string; 11 | className: string; 12 | variant: ButtonVariants; 13 | onClick: () => void; 14 | disabled: boolean; 15 | isLoading: boolean; 16 | }>; 17 | 18 | export enum ButtonType { 19 | submit = 'submit', 20 | reset = 'reset', 21 | button = 'button', 22 | } 23 | 24 | export interface ButtonProps extends Omit { 25 | children: React.ReactNode; 26 | type?: ButtonType; 27 | loading?: boolean; 28 | } 29 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './button.types'; 2 | export * from './button.component'; 3 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './box'; 2 | export * from './button'; 3 | export * from './input'; 4 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/input/index.ts: -------------------------------------------------------------------------------- 1 | export * from './input.component'; 2 | export * from './input.types'; 3 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/input/input.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Label, StyledInput, Wrapper } from './input.styles'; 4 | import { InputProps } from './input.types'; 5 | 6 | export function Input(props: InputProps): React.ReactElement { 7 | const { label, value, onChange, placeholder, inputRef, ...rest } = props; 8 | 9 | return ( 10 | 11 | 12 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/input/input.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Wrapper = styled.label` 4 | display: flex; 5 | flex-direction: column; 6 | margin-bottom: 20px; 7 | width: 100%; 8 | `; 9 | 10 | export const Label = styled.span` 11 | display: block; 12 | margin-left: 15px; 13 | margin-bottom: 2px; 14 | color: var(--gray); 15 | font-size: 16px; 16 | font-weight: 600; 17 | `; 18 | 19 | export const StyledInput = styled.input` 20 | position: relative; 21 | display: block; 22 | max-height: 70px; 23 | padding: 15px; 24 | margin: 2px 0; 25 | color: black; 26 | font-size: 16px; 27 | font-weight: 600; 28 | border: none; 29 | border-radius: 15px; 30 | background-color: lightgray; 31 | border: 1px solid transparent; 32 | transition: border 0.2s; 33 | outline: none; 34 | 35 | &:focus { 36 | border: 1px solid orange !important; 37 | } 38 | 39 | &::-webkit-input-placeholder { 40 | color: gray; 41 | font-weight: 500; 42 | } 43 | `; 44 | -------------------------------------------------------------------------------- /src/presentation/web/components/common/input/input.types.tsx: -------------------------------------------------------------------------------- 1 | export type InputProps = { 2 | label?: string; 3 | value: string; 4 | placeholder?: string; 5 | inputRef?: React.Ref; 6 | onChange: (event: React.ChangeEvent) => void; 7 | }; 8 | -------------------------------------------------------------------------------- /src/presentation/web/components/form/components/button.component.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { useFormState } from 'react-hook-form'; 3 | 4 | import { Button as BaseButton } from 'presentation/web/components/common'; 5 | import { ButtonProps } from 'presentation/web/components/common/button/button.types'; 6 | 7 | export function FormButton(props: ButtonProps): React.ReactElement { 8 | const formik = useFormState(); 9 | 10 | const disabled = useMemo(() => !formik.isValid, [formik.isValid]); 11 | const loading = useMemo(() => formik.isSubmitting, [formik.isSubmitting]); 12 | 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /src/presentation/web/components/form/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './button.component'; 2 | export * from './input.component'; 3 | -------------------------------------------------------------------------------- /src/presentation/web/components/form/components/input.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Input as BaseInput, InputProps } from 'presentation/web/components/common'; 4 | 5 | import { FieldProps, FormComponentProps, withFormField } from '../hoc/with-form-field.hoc'; 6 | 7 | export const FormInput = withFormField(function FormInput( 8 | props: FormComponentProps, 9 | ): React.ReactElement { 10 | const { label, placeholder, onChange, field, ...rest } = props; 11 | const { state, api } = field; 12 | 13 | const handleChange = (event: React.ChangeEvent): void => { 14 | api?.onChange?.(event); 15 | onChange?.(event); 16 | }; 17 | 18 | return ( 19 | 27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /src/presentation/web/components/form/form.component.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useEffect } from 'react'; 2 | import { DefaultValues, FormProvider, useForm } from 'react-hook-form'; 3 | import compose from 'lodash/fp/compose'; 4 | 5 | import { FormProps } from './form.types'; 6 | 7 | export function Form( 8 | props: FormProps, 9 | ): ReactElement> { 10 | const { children, entity } = props; 11 | const form = useForm({ 12 | defaultValues: (entity?.getInitialValues() || {}) as DefaultValues, 13 | }); 14 | const { handleSubmit } = form; 15 | 16 | const onSubmit = () => { 17 | handleSubmit(data => { 18 | entity?.handleSubmit(data); 19 | }); 20 | }; 21 | 22 | useEffect(() => { 23 | entity?.api?.setContext(form); 24 | }, [entity?.api, form]); 25 | 26 | return ( 27 | 28 | compose(entity.handleChange, entity.api.getValues)()} 31 | > 32 | {children instanceof Function ? children(form) : children} 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/presentation/web/components/form/form.types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { 3 | DeepMap, 4 | DeepPartial, 5 | FieldError, 6 | FieldPath, 7 | RefCallBack, 8 | RegisterOptions, 9 | UseFormReturn, 10 | } from 'react-hook-form'; 11 | 12 | import { BaseForm } from 'presentation/forms/base.form'; 13 | 14 | type FormChildren = 15 | | ReactNode 16 | | ReactNode[] 17 | | { (formApi: UseFormReturn): ReactNode | ReactNode[] }; 18 | 19 | export type FormProps = { 20 | entity: BaseForm; 21 | children: FormChildren; 22 | }; 23 | 24 | export type FormFieldName = FieldPath>; 25 | 26 | export type FormFieldState = { 27 | ref: RefCallBack; 28 | name: FormFieldName; 29 | value: TValue; 30 | error: DeepMap, FieldError>; 31 | touched: boolean; 32 | submitting: boolean; 33 | dirty: boolean; 34 | }; 35 | 36 | export type FormFieldApi = { 37 | onChange: (...event: unknown[]) => void; 38 | onBlur: () => void; 39 | }; 40 | 41 | export type FormFieldHookResult = [FormFieldState, FormFieldApi]; 42 | 43 | export type FormFieldHook = { 44 | (name: FormFieldName): FormFieldHookResult; 45 | (name: FormFieldName, options: RegisterOptions): FormFieldHookResult; 46 | }; 47 | -------------------------------------------------------------------------------- /src/presentation/web/components/form/hoc/with-form-field.hoc.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, ReactElement } from 'react'; 2 | import { RegisterOptions } from 'react-hook-form'; 3 | 4 | import { getDisplayName } from 'presentation/shared'; 5 | 6 | import { FormFieldApi, FormFieldName, FormFieldState } from '../form.types'; 7 | import { useFormField } from '../hooks/use-form-field.hooks'; 8 | 9 | export type FieldProps = { 10 | name: FormFieldName; 11 | options?: RegisterOptions; 12 | }; 13 | 14 | type FormFieldProps = { 15 | field: { 16 | api: FormFieldApi; 17 | state: FormFieldState; 18 | }; 19 | }; 20 | 21 | export type FormComponentProps = Partial & 22 | FormFieldProps; 23 | 24 | type GetComponentProps = 25 | TProps extends FormComponentProps ? Partial : TProps; 26 | 27 | export function withFormField, TValue>( 28 | WrappedComponent: FunctionComponent, 29 | ): FunctionComponent> { 30 | function WithFormField(props: GetComponentProps): ReactElement { 31 | const { name, options } = props; 32 | const [fieldState, fieldApi] = useFormField(name, options); 33 | const elementProps = { 34 | ...props, 35 | field: { 36 | api: fieldApi, 37 | state: fieldState, 38 | }, 39 | } as WrappedProps; 40 | 41 | return React.createElement(WrappedComponent, elementProps); 42 | } 43 | WithFormField.displayName = `withFormField(${getDisplayName(WrappedComponent)})`; 44 | 45 | return WithFormField; 46 | } 47 | -------------------------------------------------------------------------------- /src/presentation/web/components/form/hooks/use-form-field.hooks.ts: -------------------------------------------------------------------------------- 1 | import { useController, useFormContext } from 'react-hook-form'; 2 | 3 | import { FormFieldHook, FormFieldHookResult, FormFieldName } from '../form.types'; 4 | 5 | export const useFormField: FormFieldHook = ( 6 | name: FormFieldName, 7 | ): FormFieldHookResult => { 8 | const { control } = useFormContext(); 9 | const { fieldState, formState, field } = useController({ 10 | control, 11 | name, 12 | }); 13 | const { error, isTouched } = fieldState; 14 | const { isSubmitting, isDirty } = formState; 15 | const { ref, value, onBlur, onChange } = field; 16 | 17 | return [ 18 | { 19 | dirty: isDirty, 20 | error, 21 | name, 22 | ref, 23 | submitting: isSubmitting, 24 | touched: isTouched, 25 | value, 26 | }, 27 | { 28 | onBlur, 29 | onChange, 30 | }, 31 | ]; 32 | }; 33 | -------------------------------------------------------------------------------- /src/presentation/web/components/form/index.ts: -------------------------------------------------------------------------------- 1 | export * from './form.component'; 2 | export * from './components'; 3 | -------------------------------------------------------------------------------- /src/presentation/web/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './form'; 2 | export * from './common'; 3 | -------------------------------------------------------------------------------- /src/presentation/web/index.tsx: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import React from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | 5 | import { App } from 'presentation/web/app.component'; 6 | 7 | const container = document.getElementById('root'); 8 | const root = createRoot(container); 9 | 10 | root.render(); 11 | -------------------------------------------------------------------------------- /src/presentation/web/modules/auth/__snapshots__/auth.module.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`auth.module should match snapshot 1`] = ` 4 | .c3 { 5 | position: relative; 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | flex-wrap: wrap; 10 | height: 52px; 11 | width: 100%; 12 | padding: 4px 5px 0; 13 | font-size: 20px; 14 | font-weight: 600; 15 | text-transform: uppercase; 16 | border-radius: 52px; 17 | transition: box-shadow 0.2s; 18 | overflow: hidden; 19 | color: gray; 20 | background-color: lightgray; 21 | } 22 | 23 | .c0 { 24 | display: flex; 25 | flex-direction: column; 26 | margin-bottom: 20px; 27 | width: 100%; 28 | } 29 | 30 | .c1 { 31 | display: block; 32 | margin-left: 15px; 33 | margin-bottom: 2px; 34 | color: var(--gray); 35 | font-size: 16px; 36 | font-weight: 600; 37 | } 38 | 39 | .c2 { 40 | position: relative; 41 | display: block; 42 | max-height: 70px; 43 | padding: 15px; 44 | margin: 2px 0; 45 | color: black; 46 | font-size: 16px; 47 | font-weight: 600; 48 | border: none; 49 | border-radius: 15px; 50 | background-color: lightgray; 51 | border: 1px solid transparent; 52 | transition: border 0.2s; 53 | outline: none; 54 | } 55 | 56 | .c2:focus { 57 | border: 1px solid orange!important; 58 | } 59 | 60 | .c2::-webkit-input-placeholder { 61 | color: gray; 62 | font-weight: 500; 63 | } 64 | 65 |
66 | 69 | 81 |