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