├── .circleci
└── config.yml
├── .editorconfig
├── .env
├── .env.example
├── .eslintrc.js
├── .gitattributes
├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .gitlab-ci.yml
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── .stylelintrc
├── .travis.yml
├── .whitesource
├── LICENSE
├── README.md
├── _config.yml
├── bin
├── cli.js
└── copy.js
├── index.html
├── package.json
├── public
├── favicon.ico
└── icon-512x512.png
├── readme.svg
├── renovate.json
├── src
├── app.tsx
├── assets
│ ├── images
│ │ └── react.svg
│ ├── locale
│ │ ├── de.json
│ │ ├── de.po
│ │ ├── translations.json
│ │ └── translations.pot
│ └── styles
│ │ ├── app.scss
│ │ ├── functions.scss
│ │ ├── mixins.scss
│ │ └── settings.scss
├── components
│ ├── button
│ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ ├── index.scss
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ ├── field
│ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ ├── footer
│ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ ├── index.scss
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ ├── header
│ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ ├── index.scss
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ ├── icon
│ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ ├── index.scss
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ ├── index.ts
│ ├── login-form
│ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ ├── password-reset-form
│ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ ├── signup-form
│ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ ├── test-store-provider
│ │ └── index.tsx
│ └── wrapper
│ │ ├── __snapshots__
│ │ └── index.test.tsx.snap
│ │ ├── index.scss
│ │ ├── index.test.tsx
│ │ └── index.tsx
├── containers
│ ├── .boilerplate
│ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ ├── index.scss
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ ├── auth
│ │ ├── index.ts
│ │ ├── login
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ │ ├── password-reset
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ │ └── signup
│ │ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ ├── home
│ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ ├── index.scss
│ │ ├── index.test.tsx
│ │ └── index.tsx
│ └── not-found
│ │ ├── __snapshots__
│ │ └── index.test.tsx.snap
│ │ ├── index.scss
│ │ ├── index.test.tsx
│ │ └── index.tsx
├── custom.d.ts
├── i18n
│ ├── gettext-converter.mjs
│ ├── index.ts
│ ├── locales.mjs
│ └── scanner-config.js
├── index.tsx
├── loadables.tsx
├── store
│ ├── branches
│ │ ├── .boilerplate
│ │ │ ├── enums.ts
│ │ │ ├── interfaces.ts
│ │ │ ├── reducer.ts
│ │ │ └── sagas.ts
│ │ └── auth
│ │ │ ├── enums.ts
│ │ │ ├── interfaces.ts
│ │ │ ├── reducer.ts
│ │ │ └── sagas.ts
│ ├── enums.ts
│ ├── index.ts
│ ├── interfaces.ts
│ ├── root-reducer.ts
│ ├── sagas.ts
│ └── selectors.ts
└── utilities
│ ├── api.ts
│ ├── constants.ts
│ ├── enums.ts
│ ├── helpers.ts
│ ├── hooks.ts
│ ├── index.ts
│ ├── interfaces.ts
│ └── local-storage.ts
├── test-config
├── DateMock.js
├── FileMock.js
├── StyleMock.js
├── index.js
├── jest.config.js
└── tsconfig.json
├── tsconfig.json
├── vite.config.ts
├── workbox-config.js
└── yarn.lock
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 | jobs:
3 | build:
4 | docker:
5 | - image: circleci/node:lts
6 | steps:
7 | - checkout
8 | - run:
9 | name: autoreconf
10 | command: sudo apt install automake
11 | - run:
12 | name: install
13 | command: yarn
14 | - run:
15 | name: lint
16 | command: yarn lint
17 | - run:
18 | name: test:coverage
19 | command: yarn test:coverage
20 | - run:
21 | name: build
22 | command: yarn build
23 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | end_of_line = lf
7 | indent_style = tab
8 | indent_size = 4
9 | insert_final_newline = true
10 | trim_trailing_whitespace = true
11 |
12 | [{*.json,*.yml}]
13 | indent_style = space
14 | indent_size = 2
15 |
16 | [*.md]
17 | trim_trailing_whitespace = false
18 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | # ENV Variable must be prefixed with VITE_ in order to be accessible
2 | # for reference https://vitejs.dev/guide/env-and-mode.html#env-files
3 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # ENV Variable must be prefixed with VITE_ in order to be accessible
2 | # for reference https://vitejs.dev/guide/env-and-mode.html#env-files
3 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | node: true,
5 | 'jest/globals': true
6 | },
7 | extends: ['prettier', 'plugin:react/recommended', 'plugin:@typescript-eslint/recommended'],
8 | parser: '@typescript-eslint/parser',
9 | parserOptions: {
10 | project: './tsconfig.json',
11 | sourceType: 'module',
12 | tsconfigRootDir: __dirname
13 | },
14 | plugins: ['jest', '@typescript-eslint'],
15 | ignorePatterns: ['bin/*', '*.js', '*.mjs'],
16 | rules: {
17 | 'react/display-name': 'off',
18 | '@typescript-eslint/no-explicit-any': 'off'
19 | },
20 | settings: {
21 | react: {
22 | version: 'detect'
23 | }
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | ## GITATTRIBUTES FOR WEB PROJECTS
2 | #
3 | # These settings are for any web project.
4 | #
5 | # Details per file setting:
6 | # text These files should be normalized (i.e. convert CRLF to LF).
7 | # binary These files are binary and should be left untouched.
8 | #
9 | # Note that binary is a macro for -text -diff.
10 | ######################################################################
11 |
12 | ## AUTO-DETECT
13 | ## Handle line endings automatically for files detected as
14 | ## text and leave all files detected as binary untouched.
15 | ## This will handle all files NOT defined below.
16 | * text=auto
17 |
18 | ## SOURCE CODE
19 | *.bat text eol=crlf
20 | *.coffee text
21 | *.css text
22 | *.htm text
23 | *.html text
24 | *.inc text
25 | *.ini text
26 | *.js text
27 | *.json text
28 | *.jsx text
29 | *.less text
30 | *.od text
31 | *.onlydata text
32 | *.php text
33 | *.pl text
34 | *.py text
35 | *.rb text
36 | *.sass text
37 | *.scm text
38 | *.scss text
39 | *.sh text eol=lf
40 | *.sql text
41 | *.styl text
42 | *.tag text
43 | *.ts text
44 | *.tsx text
45 | *.xml text
46 | *.xhtml text
47 |
48 | ## DOCKER
49 | *.dockerignore text
50 | Dockerfile text
51 |
52 | ## DOCUMENTATION
53 | *.markdown text
54 | *.md text
55 | *.mdwn text
56 | *.mdown text
57 | *.mkd text
58 | *.mkdn text
59 | *.mdtxt text
60 | *.mdtext text
61 | *.txt text
62 | AUTHORS text
63 | CHANGELOG text
64 | CHANGES text
65 | CONTRIBUTING text
66 | COPYING text
67 | copyright text
68 | *COPYRIGHT* text
69 | INSTALL text
70 | license text
71 | LICENSE text
72 | NEWS text
73 | readme text
74 | *README* text
75 | TODO text
76 |
77 | ## TEMPLATES
78 | *.dot text
79 | *.ejs text
80 | *.haml text
81 | *.handlebars text
82 | *.hbs text
83 | *.hbt text
84 | *.jade text
85 | *.latte text
86 | *.mustache text
87 | *.njk text
88 | *.phtml text
89 | *.tmpl text
90 | *.tpl text
91 | *.twig text
92 |
93 | ## LINTERS
94 | .csslintrc text
95 | .eslintrc text
96 | .htmlhintrc text
97 | .jscsrc text
98 | .jshintrc text
99 | .jshintignore text
100 | .stylelintrc text
101 |
102 | ## CONFIGS
103 | *.bowerrc text
104 | *.cnf text
105 | *.conf text
106 | *.config text
107 | .browserslistrc text
108 | .editorconfig text
109 | .gitattributes text
110 | .gitconfig text
111 | .htaccess text
112 | *.npmignore text
113 | *.yaml text
114 | *.yml text
115 | browserslist text
116 | Makefile text
117 | makefile text
118 |
119 | ## HEROKU
120 | Procfile text
121 | .slugignore text
122 |
123 | ## GRAPHICS
124 | *.ai binary
125 | *.bmp binary
126 | *.eps binary
127 | *.gif binary
128 | *.ico binary
129 | *.jng binary
130 | *.jp2 binary
131 | *.jpg binary
132 | *.jpeg binary
133 | *.jpx binary
134 | *.jxr binary
135 | *.pdf binary
136 | *.png binary
137 | *.psb binary
138 | *.psd binary
139 | *.svg text
140 | *.svgz binary
141 | *.tif binary
142 | *.tiff binary
143 | *.wbmp binary
144 | *.webp binary
145 |
146 | ## AUDIO
147 | *.kar binary
148 | *.m4a binary
149 | *.mid binary
150 | *.midi binary
151 | *.mp3 binary
152 | *.ogg binary
153 | *.ra binary
154 |
155 | ## VIDEO
156 | *.3gpp binary
157 | *.3gp binary
158 | *.as binary
159 | *.asf binary
160 | *.asx binary
161 | *.fla binary
162 | *.flv binary
163 | *.m4v binary
164 | *.mng binary
165 | *.mov binary
166 | *.mp4 binary
167 | *.mpeg binary
168 | *.mpg binary
169 | *.ogv binary
170 | *.swc binary
171 | *.swf binary
172 | *.webm binary
173 |
174 | ## ARCHIVES
175 | *.7z binary
176 | *.gz binary
177 | *.jar binary
178 | *.rar binary
179 | *.tar binary
180 | *.zip binary
181 |
182 | ## FONTS
183 | *.ttf binary
184 | *.eot binary
185 | *.otf binary
186 | *.woff binary
187 | *.woff2 binary
188 |
189 | ## EXECUTABLES
190 | *.exe binary
191 | *.pyc binary
192 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - name: Use Node.js LTS
11 | uses: actions/setup-node@v4
12 | with:
13 | node-version: lts/*
14 | - run: yarn
15 | - run: yarn lint
16 | - run: yarn test:coverage
17 | - run: yarn build
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Dependency directory
9 | node_modules/
10 |
11 | # Misc
12 | .DS_Store
13 | .DS_Store?
14 | ._*
15 | .Spotlight-V100
16 | .Trashes
17 | ehthumbs.db
18 | Thumbs.db
19 | .env
20 |
21 | # Build
22 | dist/
23 |
24 | # Coverage
25 | coverage/
26 |
27 | # Project specific
28 | .vscode/
29 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | image: node:lts
2 | before_script:
3 | - yarn
4 | cache:
5 | paths:
6 | - node_modules/
7 | build:
8 | script:
9 | - yarn lint
10 | - yarn test:coverage
11 | - yarn build
12 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/*
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "tabWidth": 4,
4 | "useTabs": true,
5 | "semi": true,
6 | "singleQuote": true,
7 | "jsxSingleQuote": false,
8 | "trailingComma": "none",
9 | "bracketSpacing": true,
10 | "jsxBracketSameLine": false,
11 | "arrowParens": "avoid",
12 | "proseWrap": "preserve"
13 | }
14 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["stylelint-config-recommended", "stylelint-config-standard-scss"],
3 | "plugins": ["stylelint-scss", "stylelint-no-unsupported-browser-features"],
4 | "rules": {}
5 | }
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | sudo: false
3 | node_js:
4 | - 'lts/*'
5 | install:
6 | - yarn
7 | script:
8 | - yarn lint
9 | - yarn test:coverage
10 | - yarn build
11 |
--------------------------------------------------------------------------------
/.whitesource:
--------------------------------------------------------------------------------
1 | {
2 | "checkRunSettings": {
3 | "vulnerableCheckRunConclusionLevel": "success"
4 | },
5 | "issueSettings": {
6 | "minSeverityLevel": "LOW"
7 | }
8 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018-Present Three 11 LTD
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 | [](https://github.com/three11/react-template-ts/issues)
5 | [](https://github.com/three11/react-template-ts/commits/master)
6 | [](https://travis-ci.org/three11/react-template-ts)
7 | [](https://github.com/three11/react-template-ts)
8 |
9 | # Create Awesome React Application
10 |
11 | > Opinionated React starter template using TypeScript, Redux, React Router, Redux Saga, SCSS, PostCSS and more, offering PWA and offline capabilities and many more.
12 |
13 | ## Dependencies
14 |
15 | In order to use this setup you need to have installed the following dependencies:
16 |
17 | 1. Node - min v8.15.0
18 | 2. NPM - min v5.6.0
19 | or
20 | 3. Yarn - min v1.3.2
21 | 4. Bash terminal (Default on OSX/Linux, GitBash or similar on Windows)
22 |
23 | ## One line zero config installation
24 |
25 | ```sh
26 | npx create-react-app-ts && yarn && yarn start
27 | ```
28 |
29 | **Just a quick note:** You should manually create a `.gitignore` file if you plan on keeping your project in Git.
30 |
31 | ## Download
32 |
33 | You can download this setup [directly](https://github.com/three11/react-template-ts/archive/master.zip) and extract it.
34 |
35 | Then navigate to the `react-template-ts` folder and proceed with the rest of the instructions.
36 |
37 | ## Install
38 |
39 | ```sh
40 | yarn
41 |
42 | # or
43 |
44 | npm i
45 | ```
46 |
47 | ## Develop
48 |
49 | ```sh
50 | yarn start
51 |
52 | # or
53 |
54 | npm start
55 | ```
56 |
57 | ## Build
58 |
59 | ```sh
60 | yarn build
61 |
62 | # or
63 |
64 | npm run build
65 | ```
66 |
67 | ## Lint
68 |
69 | ```sh
70 | yarn lint
71 |
72 | # or
73 |
74 | npm run lint
75 | ```
76 |
77 | ## Test
78 |
79 | ```sh
80 | yarn test
81 |
82 | # or
83 |
84 | npm run test
85 | ```
86 |
87 | ## Details
88 |
89 | 1. Folder structure:
90 |
91 | ```
92 | 📦 project
93 | ┣ 📂 assets - all fonts, images, videos, translation files, etc
94 | ┣ ┣ 📂 locale - all translations
95 | ┣ ┣ 📂 styles - all shared stylesheets
96 | ┃ ┃ ┗ 📜 app.scss - Application's global SCSS entry point
97 | ┃ ┃ ┗ 📜 mixins.scss - Application's SCSS mixins
98 | ┃ ┃ ┗ 📜 functions.scss - Application's SCSS functions
99 | ┃ ┃ ┗ 📜 settings.scss - Application's SCSS settings (variables, etc)
100 | ┣ 📂 components - stateless components
101 | ┣ 📂 containers - statefull components. Each container can export more than one component. An example folder structure is included in (`src/containers/.boilerplate`)
102 | ┣ 📂 i18n - configuration settings for i18n (internationalization)
103 | ┣ 📂 store - The application Redux store
104 | ┣ ┣ 📂 branches - all store branches
105 | ┣ ┣ ┣ ┣ 📂 $BRANCH - A branch in the Redux store
106 | ┃ ┃ ┃ ┗ 📜 enums.ts - Each branch has its own enums
107 | ┃ ┃ ┃ ┗ 📜 interfaces.ts - Each branch has its own interfaces
108 | ┃ ┃ ┃ ┗ 📜 reducer.ts - The branch reducer
109 | ┃ ┃ ┃ ┗ 📜 selectors.ts - The branch selectors (hooks)
110 | ┃ ┃ ┃ ┗ 📜 sagas.ts - The branch sagas
111 | ┃ ┗ 📜 enums.ts - Store's enums
112 | ┃ ┗ 📜 index.ts - Application's main store
113 | ┃ ┗ 📜 interfaces.ts - Store's interfaces
114 | ┃ ┗ 📜 root-reducer.ts - Application's root reducer
115 | ┃ ┗ 📜 sagas.ts - Application's sagas
116 | ┣ 📂 utilities - helpers and utility functions
117 | ┗ 📜 app.tsx - Application's main component
118 | ┗ 📜 custom.d.ts - Custom type definitions
119 | ┗ 📜 index.html - Application's HTML file
120 | ┗ 📜 index.tsx - The main entry point
121 | ┗ 📜 loadables.tsx - Code split and lazy loaded components
122 | ```
123 |
124 | 2. Latest EcmaScript support
125 |
126 | - Usage of the latest features in EcmaScript
127 | - Using [TypeScript](https://www.typescriptlang.org/) to transpile to ES5
128 | - Minification of the bundled file
129 | - Source maps
130 |
131 | 3. Aliases: Checkout the aliases property in the `vite.config.ts` and `tsconfig.json` files.
132 | 4. SCSS usage.
133 | 5. Lint your files: ESLint (with TypeScript ESLint installed and configured) and Stylelint included
134 | 6. Tests using Jest and React testing library. The Test environment has been configured so you don't have to
135 | 7. PWA ready - Install as a native app on Android and iOS
136 | 8. Code splitting and lazy loading
137 | 9. i18n included:
138 | 1. add your locales in `/src/i18n/locales`
139 | 2. add your po files which are based on the `translations.pot` file located in `/src/assets/locale`
140 | 3. run `yarn locale` to generate `${locale}.json` file from your `${locale}.po` file.
141 | 4. update your UI to reflect the newly added locale
142 | 10. Prerendering - All pages are prerendered based on defined routes. This is included in the build step and needs **no additional configuration**.
143 |
144 | ## Supported Browsers
145 |
146 | This setup uses [Browserslist](https://github.com/browserslist/browserslist) to target browsers.
147 |
148 | The default list of supported browsers is listed in the `package.json` file:
149 |
150 | ```json
151 | {
152 | "browserslist": ["> 1%", "last 2 versions"]
153 | }
154 | ```
155 |
156 | This means that supported browsers vary based on current usage data and current browser versions.
157 |
158 | In general, this setup supports the two most recent versions of all browsers.
159 |
160 | ## Bonus
161 |
162 | The start template contains a ready-to-use auth flow with Login, Logout, Sign up and Forgotten password forms with validation included. The auth flow includes also route guarding and redirects based on auth status. Please take a look at the `/src/containers/auth` folder for more details.
163 |
164 | The starting files also include ready-to-use layout components such as `Header`, `Footer`, `Wrapper`, `Button`, `Icon` and form `Field`s.
165 |
166 | ## LICENSE
167 |
168 | MIT
169 |
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-slate
--------------------------------------------------------------------------------
/bin/cli.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Node dependencies
5 | */
6 | const { join } = require('path');
7 |
8 | /**
9 | * Internal dependencies
10 | */
11 | const { copyDir } = require('./copy');
12 |
13 | const shouldSkip = name => name === 'node_modules' || name === 'bin' || name === '.github';
14 |
15 | copyDir(join(__dirname, '../'), process.env.PWD, shouldSkip);
16 |
17 | console.log('Your awesome React App is now setup! Run "npm i" or "yarn" to continue');
18 |
--------------------------------------------------------------------------------
/bin/copy.js:
--------------------------------------------------------------------------------
1 | const { join } = require('path');
2 |
3 | const {
4 | lstatSync,
5 | mkdirSync,
6 | symlinkSync,
7 | readdirSync,
8 | readlinkSync,
9 | createReadStream,
10 | createWriteStream
11 | } = require('fs');
12 |
13 | const mkdir = dir => {
14 | try {
15 | // @ts-ignore
16 | mkdirSync(dir, 0755);
17 | } catch (e) {
18 | if (e.code !== 'EEXIST') {
19 | throw e;
20 | }
21 | }
22 | };
23 |
24 | const copy = (src, dest) => {
25 | const from = createReadStream(src);
26 | const to = createWriteStream(dest);
27 |
28 | from.pipe(to);
29 | };
30 |
31 | const copyDir = (src, dest, filter) => {
32 | mkdir(dest);
33 |
34 | const files = readdirSync(src);
35 |
36 | for (const file of files) {
37 | const from = join(src, file);
38 | const to = join(dest, file);
39 | const current = lstatSync(from);
40 |
41 | if (typeof filter === 'function' && filter(file)) {
42 | continue;
43 | }
44 |
45 | if (current.isDirectory()) {
46 | copyDir(from, to);
47 | } else if (current.isSymbolicLink()) {
48 | const symlink = readlinkSync(from);
49 |
50 | symlinkSync(symlink, to);
51 | } else {
52 | copy(from, to);
53 | }
54 | }
55 | };
56 |
57 | module.exports = { mkdir, copy, copyDir };
58 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | React Template
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "create-react-app-ts",
3 | "version": "2.1.0",
4 | "description": "Scalable starter boilerplate for React applications",
5 | "main": "./src/index.tsx",
6 | "bin": {
7 | "create-react-app-ts": "./bin/cli.js"
8 | },
9 | "scripts": {
10 | "svg": "svgo-viewbox -i ./src/assets",
11 | "start": "vite --open",
12 | "build": "vite build",
13 | "tsc": "tsc --noEmit --skipLibCheck",
14 | "lint": "yarn lint:ts && yarn lint:scss",
15 | "lint:ts": "eslint 'src/**/*.{ts,tsx}'",
16 | "lint:scss": "stylelint './src/**/*.scss' --config .stylelintrc",
17 | "test": "jest --config=./test-config/jest.config.js --runInBand --coverage --env jsdom",
18 | "test:coverage": "yarn test --coverage",
19 | "locale:scan": "i18next-scanner --config ./src/i18n/scanner-config.js './src/**/*.{ts,tsx}'",
20 | "locale:pot": "node ./src/i18n/gettext-converter.mjs jsonToPot",
21 | "locale:po": "node ./src/i18n/gettext-converter.mjs poToJson",
22 | "locale": "yarn locale:scan && yarn locale:pot && yarn locale:po",
23 | "run-dist": "yarn build && npx ecstatic ./dist --root=./dist --host=localhost --port=8080 --baseDir=/"
24 | },
25 | "repository": {
26 | "type": "git",
27 | "url": "git@github.com:three11/react-template.git"
28 | },
29 | "keywords": [
30 | "React",
31 | "Starter",
32 | "Template",
33 | "SPA",
34 | "JavaScript"
35 | ],
36 | "authors": [
37 | {
38 | "name": "Three 11 Ltd",
39 | "email": "hello@three-11.com",
40 | "role": "Developer"
41 | },
42 | {
43 | "name": "Alexander Panayotov",
44 | "email": "alexander.panayotov@gmail.com",
45 | "role": "Developer"
46 | },
47 | {
48 | "name": "Atanas Atanasov",
49 | "email": "scriptex.bg@gmail.com",
50 | "role": "Developer"
51 | }
52 | ],
53 | "license": "MIT",
54 | "bugs": {
55 | "url": "https://github.com/three11/react-template/issues"
56 | },
57 | "homepage": "https://github.com/three11/react-template#readme",
58 | "dependencies": {
59 | "@loadable/component": "5.16.4",
60 | "@redux-devtools/extension": "3.3.0",
61 | "axios": "1.7.9",
62 | "date-fns": "3.6.0",
63 | "i18next": "23.16.8",
64 | "i18next-browser-languagedetector": "7.2.2",
65 | "normalize.css": "8.0.1",
66 | "react": "18.3.1",
67 | "react-dom": "18.3.1",
68 | "react-hook-form": "7.54.0",
69 | "react-i18next": "14.1.3",
70 | "react-inlinesvg": "4.1.5",
71 | "react-redux": "9.1.2",
72 | "react-router": "6.28.0",
73 | "react-router-dom": "6.28.0",
74 | "redux": "4.2.1",
75 | "redux-saga": "1.3.0",
76 | "scss-goodies": "2.2.0"
77 | },
78 | "devDependencies": {
79 | "@rollup/plugin-alias": "5.1.1",
80 | "@testing-library/jest-dom": "6.6.3",
81 | "@testing-library/react": "14.3.1",
82 | "@types/jest": "29.5.14",
83 | "@types/loadable__component": "5.13.9",
84 | "@types/node": "20.17.9",
85 | "@types/postcss-flexbugs-fixes": "5.0.3",
86 | "@types/react": "18.3.14",
87 | "@types/react-dom": "18.3.3",
88 | "@types/react-loadable": "5.5.11",
89 | "@types/react-redux": "7.1.34",
90 | "@types/react-router": "5.1.20",
91 | "@types/react-router-dom": "5.3.3",
92 | "@types/redux-mock-store": "1.5.0",
93 | "@typescript-eslint/eslint-plugin": "6.21.0",
94 | "@typescript-eslint/parser": "6.21.0",
95 | "@vitejs/plugin-react": "4.3.4",
96 | "autoprefixer": "10.4.20",
97 | "cssnano": "6.1.2",
98 | "eslint": "8.57.1",
99 | "eslint-config-prettier": "9.1.0",
100 | "eslint-plugin-jest": "28.9.0",
101 | "eslint-plugin-react": "7.37.2",
102 | "i18next-conv": "14.1.0",
103 | "i18next-scanner": "4.6.0",
104 | "jest": "29.7.0",
105 | "jest-environment-jsdom": "29.7.0",
106 | "jest-localstorage-mock": "2.4.26",
107 | "postcss": "8.4.49",
108 | "postcss-flexbugs-fixes": "5.0.2",
109 | "redux-mock-store": "1.5.5",
110 | "sass": "1.82.0",
111 | "stylelint": "16.11.0",
112 | "stylelint-config-recommended": "14.0.1",
113 | "stylelint-config-standard-scss": "12.0.0",
114 | "stylelint-no-unsupported-browser-features": "8.0.2",
115 | "stylelint-scss": "6.10.0",
116 | "svgo": "3.3.2",
117 | "svgo-add-viewbox": "3.1.0",
118 | "svgo-viewbox": "3.0.0",
119 | "ts-jest": "29.2.5",
120 | "ts-node": "10.9.2",
121 | "tslib": "2.8.1",
122 | "typescript": "5.7.2",
123 | "vite": "5.4.11",
124 | "vite-plugin-prerender": "1.0.8",
125 | "vite-plugin-pwa": "0.21.1",
126 | "workbox-build": "7.3.0",
127 | "workbox-window": "7.3.0"
128 | },
129 | "browserslist": [
130 | "> 1%",
131 | "last 2 versions"
132 | ]
133 | }
134 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/three11/react-template-ts/67de957307295c4d877f47b2cf427ce78282f8a0/public/favicon.ico
--------------------------------------------------------------------------------
/public/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/three11/react-template-ts/67de957307295c4d877f47b2cf427ce78282f8a0/public/icon-512x512.png
--------------------------------------------------------------------------------
/readme.svg:
--------------------------------------------------------------------------------
1 |
61 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["config:base", ":automergePatch", ":automergeMinor", ":automergeBranch", ":disableDependencyDashboard"],
3 | "travis": {
4 | "enabled": true
5 | },
6 | "assignees": ["@scriptex", "@alpanayotov"],
7 | "labels": ["dependencies"],
8 | "rebaseWhen": "conflicted",
9 | "vulnerabilityAlerts": {
10 | "labels": ["security"],
11 | "assignees": ["@scriptex", "@alpanayotov"]
12 | },
13 | "major": {
14 | "automerge": false
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/app.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Routes, Route } from 'react-router-dom';
3 |
4 | import * as Loadables from './loadables';
5 | import { Routes as AppRoutes, isLoggedIn } from '@utilities';
6 |
7 | import './assets/styles/app.scss';
8 |
9 | export const App = () => (
10 |
11 | } />
12 | {!isLoggedIn() ? } /> : null}
13 | {!isLoggedIn() ? } /> : null}
14 | {!isLoggedIn() ? } /> : null}
15 | } />
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/src/assets/images/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/locale/de.json:
--------------------------------------------------------------------------------
1 | {
2 | "All rights reserved.": "Alle Rechte vorbehalten.",
3 | "About": "Über",
4 | "Settings": "Einstellungen",
5 | "Logout": "Abmeldung",
6 | "This field is required.": "Dieses Feld muss ausgefüllt werden.",
7 | "Invalid email address.": "Ungültige E-Mail-Adresse.",
8 | "Email Address": "E-Mail-Adresse",
9 | "The password should contain least 8 characters.": "Das Passwort sollte mindestens 8 Zeichen enthalten.",
10 | "Password": "Passwort",
11 | "Login": "Anmeldung",
12 | "New password": "Neues Passwort",
13 | "The passwords do not match": "Die Passwörter stimmen nicht überein",
14 | "Confirm new password": "Neues Passwort bestätigen",
15 | "Reset password": "Passwort zurücksetzen",
16 | "Confirm password": "Passwort bestätigen",
17 | "Username": "Benutzername",
18 | "First name": "Vorname",
19 | "Last name": "Nachname",
20 | "Unspecified": "Nicht spezifiziert",
21 | "Male": "Mann",
22 | "Female": "Frau",
23 | "Gender": "Geschlecht",
24 | "Avatar image URL": "URL des Avatar-Bildes",
25 | "Sign up": "Anmelden",
26 | "Don't have an account?": "Sie haben kein Konto?",
27 | "Forgot password?": "Haben Sie Ihr Passwort vergessen?",
28 | "You can also": "Sie können auch",
29 | "or": "oder",
30 | "Already have an account?": "Haben Sie bereits ein Konto?",
31 | "Homepage": "Startseite",
32 | "Page not found": "Seite nicht gefunden"
33 | }
--------------------------------------------------------------------------------
/src/assets/locale/de.po:
--------------------------------------------------------------------------------
1 | msgid ""
2 | msgstr ""
3 | "Project-Id-Version: i18next-conv\n"
4 | "mime-version: 1.0\n"
5 | "Content-Type: text/plain; charset=UTF-8\n"
6 | "Content-Transfer-Encoding: 8bit\n"
7 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
8 | "POT-Creation-Date: 2020-09-10T12:35:09.604Z\n"
9 | "PO-Revision-Date: 2020-09-10 15:37+0300\n"
10 | "Language-Team: \n"
11 | "MIME-Version: 1.0\n"
12 | "X-Generator: Poedit 2.4.1\n"
13 | "Last-Translator: \n"
14 | "Language: en\n"
15 |
16 | msgid "All rights reserved."
17 | msgstr "Alle Rechte vorbehalten."
18 |
19 | msgid "About"
20 | msgstr "Über"
21 |
22 | msgid "Settings"
23 | msgstr "Einstellungen"
24 |
25 | msgid "Logout"
26 | msgstr "Abmeldung"
27 |
28 | msgid "This field is required."
29 | msgstr "Dieses Feld muss ausgefüllt werden."
30 |
31 | msgid "Invalid email address."
32 | msgstr "Ungültige E-Mail-Adresse."
33 |
34 | msgid "Email Address"
35 | msgstr "E-Mail-Adresse"
36 |
37 | msgid "The password should contain least 8 characters."
38 | msgstr "Das Passwort sollte mindestens 8 Zeichen enthalten."
39 |
40 | msgid "Password"
41 | msgstr "Passwort"
42 |
43 | msgid "Login"
44 | msgstr "Anmeldung"
45 |
46 | msgid "New password"
47 | msgstr "Neues Passwort"
48 |
49 | msgid "The passwords do not match"
50 | msgstr "Die Passwörter stimmen nicht überein"
51 |
52 | msgid "Confirm new password"
53 | msgstr "Neues Passwort bestätigen"
54 |
55 | msgid "Reset password"
56 | msgstr "Passwort zurücksetzen"
57 |
58 | msgid "Confirm password"
59 | msgstr "Passwort bestätigen"
60 |
61 | msgid "Username"
62 | msgstr "Benutzername"
63 |
64 | msgid "First name"
65 | msgstr "Vorname"
66 |
67 | msgid "Last name"
68 | msgstr "Nachname"
69 |
70 | msgid "Unspecified"
71 | msgstr "Nicht spezifiziert"
72 |
73 | msgid "Male"
74 | msgstr "Mann"
75 |
76 | msgid "Female"
77 | msgstr "Frau"
78 |
79 | msgid "Gender"
80 | msgstr "Geschlecht"
81 |
82 | msgid "Avatar image URL"
83 | msgstr "URL des Avatar-Bildes"
84 |
85 | msgid "Sign up"
86 | msgstr "Anmelden"
87 |
88 | msgid "Don't have an account?"
89 | msgstr "Sie haben kein Konto?"
90 |
91 | msgid "Forgot password?"
92 | msgstr "Haben Sie Ihr Passwort vergessen?"
93 |
94 | msgid "You can also"
95 | msgstr "Sie können auch"
96 |
97 | msgid "or"
98 | msgstr "oder"
99 |
100 | msgid "Already have an account?"
101 | msgstr "Haben Sie bereits ein Konto?"
102 |
103 | msgid "Homepage"
104 | msgstr "Startseite"
105 |
106 | msgid "Page not found"
107 | msgstr "Seite nicht gefunden"
108 |
--------------------------------------------------------------------------------
/src/assets/locale/translations.json:
--------------------------------------------------------------------------------
1 | {
2 | "All rights reserved.": "All rights reserved.",
3 | "About": "About",
4 | "Settings": "Settings",
5 | "Logout": "Logout",
6 | "This field is required.": "This field is required.",
7 | "Invalid email address.": "Invalid email address.",
8 | "Email Address": "Email Address",
9 | "The password should contain least 8 characters.": "The password should contain least 8 characters.",
10 | "Password": "Password",
11 | "Login": "Login",
12 | "New password": "New password",
13 | "The passwords do not match": "The passwords do not match",
14 | "Confirm new password": "Confirm new password",
15 | "Reset password": "Reset password",
16 | "Confirm password": "Confirm password",
17 | "Username": "Username",
18 | "First name": "First name",
19 | "Last name": "Last name",
20 | "Unspecified": "Unspecified",
21 | "Male": "Male",
22 | "Female": "Female",
23 | "Gender": "Gender",
24 | "Avatar image URL": "Avatar image URL",
25 | "Sign up": "Sign up",
26 | "Don't have an account?": "Don't have an account?",
27 | "Forgot password?": "Forgot password?",
28 | "You can also": "You can also",
29 | "or": "or",
30 | "Already have an account?": "Already have an account?",
31 | "Homepage": "Homepage",
32 | "Page not found": "Page not found"
33 | }
34 |
--------------------------------------------------------------------------------
/src/assets/locale/translations.pot:
--------------------------------------------------------------------------------
1 | msgid ""
2 | msgstr ""
3 | "Project-Id-Version: i18next-conv\n"
4 | "mime-version: 1.0\n"
5 | "Content-Type: text/plain; charset=utf-8\n"
6 | "Content-Transfer-Encoding: 8bit\n"
7 | "Plural-Forms: nplurals=2; plural=(n != 1)\n"
8 | "POT-Creation-Date: 2022-08-26T09:41:59.560Z\n"
9 | "PO-Revision-Date: 2022-08-26T09:41:59.560Z\n"
10 |
11 | msgid "All rights reserved."
12 | msgstr "All rights reserved."
13 |
14 | msgid "About"
15 | msgstr "About"
16 |
17 | msgid "Settings"
18 | msgstr "Settings"
19 |
20 | msgid "Logout"
21 | msgstr "Logout"
22 |
23 | msgid "This field is required."
24 | msgstr "This field is required."
25 |
26 | msgid "Invalid email address."
27 | msgstr "Invalid email address."
28 |
29 | msgid "Email Address"
30 | msgstr "Email Address"
31 |
32 | msgid "The password should contain least 8 characters."
33 | msgstr "The password should contain least 8 characters."
34 |
35 | msgid "Password"
36 | msgstr "Password"
37 |
38 | msgid "Login"
39 | msgstr "Login"
40 |
41 | msgid "New password"
42 | msgstr "New password"
43 |
44 | msgid "The passwords do not match"
45 | msgstr "The passwords do not match"
46 |
47 | msgid "Confirm new password"
48 | msgstr "Confirm new password"
49 |
50 | msgid "Reset password"
51 | msgstr "Reset password"
52 |
53 | msgid "Confirm password"
54 | msgstr "Confirm password"
55 |
56 | msgid "Username"
57 | msgstr "Username"
58 |
59 | msgid "First name"
60 | msgstr "First name"
61 |
62 | msgid "Last name"
63 | msgstr "Last name"
64 |
65 | msgid "Unspecified"
66 | msgstr "Unspecified"
67 |
68 | msgid "Male"
69 | msgstr "Male"
70 |
71 | msgid "Female"
72 | msgstr "Female"
73 |
74 | msgid "Gender"
75 | msgstr "Gender"
76 |
77 | msgid "Avatar image URL"
78 | msgstr "Avatar image URL"
79 |
80 | msgid "Sign up"
81 | msgstr "Sign up"
82 |
83 | msgid "Don't have an account?"
84 | msgstr "Don't have an account?"
85 |
86 | msgid "Forgot password?"
87 | msgstr "Forgot password?"
88 |
89 | msgid "You can also"
90 | msgstr "You can also"
91 |
92 | msgid "or"
93 | msgstr "or"
94 |
95 | msgid "Already have an account?"
96 | msgstr "Already have an account?"
97 |
98 | msgid "Homepage"
99 | msgstr "Homepage"
100 |
101 | msgid "Page not found"
102 | msgstr "Page not found"
103 |
--------------------------------------------------------------------------------
/src/assets/styles/app.scss:
--------------------------------------------------------------------------------
1 | @import 'normalize.css';
2 |
3 | * {
4 | box-sizing: border-box;
5 |
6 | &::before,
7 | &::after {
8 | box-sizing: inherit;
9 | }
10 | }
11 |
12 | html,
13 | body,
14 | #app,
15 | .o-wrapper {
16 | height: 100%;
17 | }
18 |
19 | body {
20 | font-family: 'Open Sans', Helvetica, Tahoma, Arial, sans-serif;
21 | font-size: 1rem;
22 | line-height: 1.25;
23 | color: $color-base;
24 | background: #fff;
25 | }
26 |
27 | a {
28 | color: inherit;
29 | text-decoration: none;
30 | transition: all $timing $easing;
31 | }
32 |
33 | img {
34 | border: 0;
35 | display: inline-block;
36 | vertical-align: top;
37 | }
38 |
--------------------------------------------------------------------------------
/src/assets/styles/functions.scss:
--------------------------------------------------------------------------------
1 | @function str-split($string, $separator) {
2 | // empty array/list
3 | $result: ();
4 |
5 | // first index of separator in string
6 | $index: string.index($string, $separator);
7 |
8 | // loop through string
9 | @while $index != null {
10 | // get the substring from the first character to the separator
11 | $item: string.slice($string, 1, $index - 1);
12 |
13 | // push item to array
14 | $result: string.append($result, $item);
15 |
16 | // remove item and separator from string
17 | $string: string.slice($string, $index + 1);
18 |
19 | // find new index of separator
20 | $index: string.index($string, $separator);
21 | }
22 |
23 | // add the remaining string to list (the last item)
24 | $result: string.append($result, $string);
25 |
26 | @return $result;
27 | }
28 |
--------------------------------------------------------------------------------
/src/assets/styles/mixins.scss:
--------------------------------------------------------------------------------
1 | @mixin flex($align, $justify) {
2 | display: flex;
3 | align-items: $align;
4 | justify-content: $justify;
5 | }
6 |
7 | @mixin flex-row($wrap: wrap, $align: stretch, $justify: space-between) {
8 | @include flex($align, $justify);
9 |
10 | flex-flow: row $wrap;
11 | }
12 |
13 | @mixin flex-column($wrap: nowrap, $align: stretch, $justify: space-between) {
14 | @include flex($align, $justify);
15 |
16 | flex-flow: column $wrap;
17 | }
18 |
19 | @mixin small-desktop {
20 | @media (max-width: #{$small-desktop}) {
21 | @content;
22 | }
23 | }
24 |
25 | @mixin desktop-only {
26 | @media (calc($tablet-landscape + 1) <= width) {
27 | @content;
28 | }
29 | }
30 |
31 | @mixin tablet-landscape {
32 | @media (max-width: #{$tablet-landscape}) {
33 | @content;
34 | }
35 | }
36 |
37 | @mixin tablet-landscape-only {
38 | @media (calc($tablet-portrait + 1) <= width <= $tablet-landscape) {
39 | @content;
40 | }
41 | }
42 |
43 | @mixin tablet-portrait {
44 | @media (max-width: #{$tablet-portrait}) {
45 | @content;
46 | }
47 | }
48 |
49 | @mixin tablet-portrait-only {
50 | @media (calc($mobile + 1) <= width <= $tablet-portrait) {
51 | @content;
52 | }
53 | }
54 |
55 | @mixin mobile {
56 | @media (max-width: #{$mobile}) {
57 | @content;
58 | }
59 | }
60 |
61 | @mixin hover {
62 | &:hover {
63 | @media (-ms-high-contrast: none), (-ms-high-contrast: active), (-moz-touch-enabled: 0), (hover) {
64 | @content;
65 | }
66 | }
67 | }
68 |
69 | @mixin centered {
70 | position: absolute;
71 | inset: 0;
72 | margin: auto;
73 | }
74 |
75 | @mixin transition($properties, $will-change: true, $timing: $timing, $easing: $easing) {
76 | $transition: '';
77 |
78 | @each $property in str-split($properties, ',') {
79 | $single: $property + ' ' + $timing + ' ' + $easing + ',';
80 | $transition: $transition + $single;
81 | }
82 |
83 | transition: $transition;
84 |
85 | @if $will-change == true and $properties != 'all' {
86 | will-change: string.unquote($properties);
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/assets/styles/settings.scss:
--------------------------------------------------------------------------------
1 | @import 'mixins';
2 |
3 | $color-base: #333;
4 | $color-white: #fff;
5 | $color-black: #000;
6 | $color-error: #f00;
7 | $color-action: #00f;
8 | $color-warning: #ff0;
9 | $timing: 0.4s;
10 | $easing: ease-in-out;
11 | $small-desktop: 1439px;
12 | $tablet-landscape: 1279px;
13 | $tablet-portrait: 1023px;
14 | $mobile: 767px;
15 |
--------------------------------------------------------------------------------
/src/components/button/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`should render successfully 1`] = `
4 |
5 |
8 |
9 | `;
10 |
--------------------------------------------------------------------------------
/src/components/button/index.scss:
--------------------------------------------------------------------------------
1 | .c-btn {
2 | font-family: sans-serif;
3 | font-size: 1.25rem;
4 | line-height: 1.875;
5 | color: $color-white;
6 | min-width: 10rem;
7 | display: inline-block;
8 | vertical-align: middle;
9 | padding: 0.5rem 1.5rem;
10 | border: 1px solid $color-action;
11 | background: $color-action;
12 | border-radius: 0.3125rem;
13 | box-shadow: none;
14 | cursor: pointer;
15 | transition: all $timing $easing;
16 |
17 | @include hover {
18 | color: $color-action;
19 | background: $color-white;
20 | }
21 |
22 | &--outline {
23 | color: $color-action;
24 | background-color: transparent;
25 |
26 | @include hover {
27 | color: $color-white;
28 | background-color: $color-action;
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/button/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { Button } from '.';
5 |
6 | test('should render successfully', () => {
7 | const { asFragment } = render();
8 |
9 | expect(asFragment()).toMatchSnapshot();
10 | expect(true).toBeTruthy();
11 | });
12 |
--------------------------------------------------------------------------------
/src/components/button/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | import './index.scss';
5 |
6 | interface Props {
7 | as?: React.ElementType;
8 | to?: string;
9 | children?: any;
10 | className?: string;
11 | href?: string;
12 | [x: string]: any;
13 | }
14 |
15 | export const Button: React.FunctionComponent> = ({
16 | as: As = 'button',
17 | to,
18 | href,
19 | children,
20 | className,
21 | ...rest
22 | }: Props) => {
23 | const linkProps = !!to ? { to } : !!href ? { href } : {};
24 | const HTMLElement = !!to ? Link : !!href ? 'a' : As;
25 |
26 | return (
27 |
28 | {children}
29 |
30 | );
31 | };
32 |
33 | export default Button;
34 |
--------------------------------------------------------------------------------
/src/components/field/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Field component should render successfully 1`] = `
4 |
5 |
27 |
28 | `;
29 |
--------------------------------------------------------------------------------
/src/components/field/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { Field } from '.';
5 |
6 | test('Field component should render successfully', () => {
7 | const { asFragment } = render(
8 | jest.fn()}
10 | type="email"
11 | name="email"
12 | label="Email Address"
13 | error={{}}
14 | placeholder="someone@example.com"
15 | />
16 | );
17 |
18 | expect(asFragment()).toMatchSnapshot();
19 | });
20 |
--------------------------------------------------------------------------------
/src/components/field/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | interface Props {
4 | readonly type: string;
5 | readonly name: string;
6 | readonly label: string;
7 | readonly error: any;
8 | readonly options?: string[];
9 | readonly register: any;
10 | readonly placeholder?: string;
11 | }
12 |
13 | export const Field: React.FunctionComponent = ({
14 | type,
15 | name,
16 | label,
17 | error,
18 | options,
19 | register,
20 | placeholder
21 | }: Props) => (
22 |
23 |
24 |
25 |
26 | {error && {error.message}
}
27 |
28 |
29 | {type === 'select' ? (
30 |
38 | ) : type === 'textarea' ? (
39 |
40 | ) : (
41 |
42 | )}
43 |
44 | );
45 |
46 | export default Field;
47 |
--------------------------------------------------------------------------------
/src/components/footer/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Footer component should render successfully 1`] = `
4 |
5 |
27 |
28 | `;
29 |
--------------------------------------------------------------------------------
/src/components/footer/index.scss:
--------------------------------------------------------------------------------
1 | .c-footer {
2 | text-align: right;
3 | width: 100%;
4 | padding: 1rem 0;
5 |
6 | button {
7 | margin-left: 0.5rem;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/footer/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { Footer } from '.';
5 |
6 | test('Footer component should render successfully', () => {
7 | const { asFragment } = render();
8 |
9 | expect(asFragment()).toMatchSnapshot();
10 | });
11 |
--------------------------------------------------------------------------------
/src/components/footer/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { format } from 'date-fns';
3 | import { useDispatch } from 'react-redux';
4 | import { useTranslation } from 'react-i18next';
5 |
6 | import { i18n, locales } from '@i18n';
7 | import { AuthActionType } from '@store/enums';
8 |
9 | import './index.scss';
10 |
11 | // codebeat:disable[LOC]
12 | export const Footer: React.FunctionComponent = () => {
13 | const { t } = useTranslation();
14 | const dispatch = useDispatch();
15 | const currentLang = i18n.language;
16 |
17 | const onLanguageChange = (locale: string) => {
18 | dispatch({
19 | type: AuthActionType.SET_LOCALE_REQUEST,
20 | payload: { locale }
21 | });
22 | };
23 |
24 | return (
25 |
41 | );
42 | };
43 | // codebeat:enable[LOC]
44 |
45 | export default Footer;
46 |
--------------------------------------------------------------------------------
/src/components/header/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Header component should render successfully 1`] = `
4 |
5 |
39 |
40 | `;
41 |
--------------------------------------------------------------------------------
/src/components/header/index.scss:
--------------------------------------------------------------------------------
1 | .c-header {
2 | width: 100%;
3 | }
4 |
5 | .c-nav {
6 | ul {
7 | @include flex-row(nowrap, center, flex-start);
8 |
9 | padding: 0;
10 | margin: 0;
11 | list-style: none outside none;
12 | }
13 |
14 | li {
15 | + li {
16 | padding-left: 1rem;
17 | }
18 | }
19 |
20 | a {
21 | color: inherit;
22 | text-decoration: none;
23 |
24 | @include hover {
25 | &:not(.c-btn) {
26 | color: $color-action;
27 | text-decoration: underline;
28 | }
29 | }
30 | }
31 |
32 | .c-btn {
33 | font-size: 100%;
34 | min-width: 0;
35 | }
36 | }
37 |
38 | .c-logo {
39 | width: 4rem;
40 |
41 | svg {
42 | width: 100%;
43 | height: 3.625rem;
44 | display: block;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/header/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { Header } from '.';
5 | import { initialState } from '@store/branches/auth/reducer';
6 | import { TestStoreProvider } from '@components';
7 |
8 | test('Header component should render successfully', () => {
9 | const { asFragment } = render(
10 |
17 |
18 |
19 | );
20 |
21 | expect(asFragment()).toMatchSnapshot();
22 | });
23 |
--------------------------------------------------------------------------------
/src/components/header/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { useTranslation } from 'react-i18next';
4 | import { Link, NavLink, useNavigate } from 'react-router-dom';
5 |
6 | import { Routes } from '@utilities';
7 | import { Icon, Button } from '@components';
8 | import { useAppSelector } from '@store/selectors';
9 | import { AuthActionType } from '@store/enums';
10 |
11 | import Logo from '@assets/images/react.svg';
12 |
13 | import './index.scss';
14 |
15 | export const Header: React.FunctionComponent = () => {
16 | const { t } = useTranslation();
17 | const dispatch = useDispatch();
18 | const navigate = useNavigate();
19 | const token = useAppSelector(state => state.auth.token);
20 |
21 | const Nav = (): JSX.Element => (
22 |
59 | );
60 |
61 | return (
62 |
71 | );
72 | };
73 |
74 | export default Header;
75 |
--------------------------------------------------------------------------------
/src/components/icon/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Icon component should render successfully 1`] = ``;
4 |
--------------------------------------------------------------------------------
/src/components/icon/index.scss:
--------------------------------------------------------------------------------
1 | .c-svg-icon {
2 | display: block;
3 |
4 | svg {
5 | width: 100%;
6 | height: auto;
7 | display: block;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/icon/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { Icon } from '.';
5 |
6 | test('Icon component should render successfully', () => {
7 | const { asFragment } = render();
8 |
9 | expect(asFragment()).toMatchSnapshot();
10 | });
11 |
--------------------------------------------------------------------------------
/src/components/icon/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import SVG from 'react-inlinesvg';
3 |
4 | import './index.scss';
5 |
6 | interface Props {
7 | readonly src: string;
8 | readonly className?: string;
9 | }
10 |
11 | export const Icon: React.FunctionComponent = ({ src, className }: Props) => {
12 | const classes = ['c-svg-icon'];
13 |
14 | if (className) {
15 | classes.push(className);
16 | }
17 |
18 | return ;
19 | };
20 |
21 | export default Icon;
22 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Button } from './button';
2 | export { default as Field } from './field';
3 | export { default as Footer } from './footer';
4 | export { default as Header } from './header';
5 | export { default as Icon } from './icon';
6 | export { default as LoginForm } from './login-form';
7 | export { default as PasswordResetForm } from './password-reset-form';
8 | export { default as SignupForm } from './signup-form';
9 | export { default as TestStoreProvider } from './test-store-provider';
10 | export { default as Wrapper } from './wrapper';
11 |
--------------------------------------------------------------------------------
/src/components/login-form/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`LoginForm component should render successfully 1`] = `
4 |
5 |
56 |
57 | `;
58 |
--------------------------------------------------------------------------------
/src/components/login-form/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { LoginForm } from '.';
5 | import { TestStoreProvider } from '@components';
6 | import { initialState } from '@store/branches/auth/reducer';
7 |
8 | test('LoginForm component should render successfully', () => {
9 | const { asFragment } = render(
10 |
11 | jest.fn()} />
12 |
13 | );
14 |
15 | expect(asFragment()).toMatchSnapshot();
16 | });
17 |
--------------------------------------------------------------------------------
/src/components/login-form/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useForm } from 'react-hook-form';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | import { Field, Button } from '@components';
6 | import { useAppSelector } from '@store/selectors';
7 | import { EMAIL_REGEX, PASSWORD_REGEX } from '@utilities';
8 |
9 | interface Props {
10 | readonly onSubmit: (values: any) => void;
11 | readonly children?: React.ReactNode | React.ReactNode[];
12 | }
13 |
14 | // codebeat:disable[LOC]
15 | export const LoginForm: React.FunctionComponent = (props: Props) => {
16 | const { t } = useTranslation();
17 | const required = t('This field is required.');
18 | const { loading, loginError } = useAppSelector(state => state.auth);
19 | const {
20 | register,
21 | handleSubmit,
22 | formState: { errors }
23 | } = useForm({
24 | mode: 'onBlur'
25 | });
26 |
27 | return (
28 |
69 | );
70 | };
71 | // codebeat:enable[LOC]
72 |
73 | export default LoginForm;
74 |
--------------------------------------------------------------------------------
/src/components/password-reset-form/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`PasswordResetForm component should render successfully 1`] = `
4 |
5 |
75 |
76 | `;
77 |
--------------------------------------------------------------------------------
/src/components/password-reset-form/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { initialState } from '@store/branches/auth/reducer';
5 | import { PasswordResetForm } from '.';
6 | import { TestStoreProvider } from '@components';
7 |
8 | test('PasswordResetForm component should render successfully', () => {
9 | const { asFragment } = render(
10 |
11 | jest.fn()} />
12 |
13 | );
14 |
15 | expect(asFragment()).toMatchSnapshot();
16 | });
17 |
--------------------------------------------------------------------------------
/src/components/password-reset-form/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useForm } from 'react-hook-form';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | import { Field, Button } from '@components';
6 | import { useAppSelector } from '@store/selectors';
7 | import { EMAIL_REGEX, PASSWORD_REGEX } from '@utilities';
8 |
9 | interface Props {
10 | readonly onSubmit: (values: any) => void;
11 | readonly children?: React.ReactNode | React.ReactNode[];
12 | }
13 |
14 | // codebeat:disable[LOC]
15 | export const PasswordResetForm: React.FunctionComponent = (props: Props) => {
16 | const { t } = useTranslation();
17 | const required = t('This field is required.');
18 | const { loading, passwordResetError } = useAppSelector(state => state.auth);
19 | const {
20 | watch,
21 | register,
22 | handleSubmit,
23 | formState: { errors }
24 | } = useForm({
25 | mode: 'onBlur'
26 | });
27 |
28 | return (
29 |
82 | );
83 | };
84 | // codebeat:enable[LOC]
85 |
86 | export default PasswordResetForm;
87 |
--------------------------------------------------------------------------------
/src/components/signup-form/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`SignupForm component should render successfully 1`] = `
4 |
5 |
196 |
197 | `;
198 |
--------------------------------------------------------------------------------
/src/components/signup-form/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { SignupForm } from '.';
5 | import { initialState } from '@store/branches/auth/reducer';
6 | import { TestStoreProvider } from '@components';
7 |
8 | test('SignupForm component should render successfully', () => {
9 | const { asFragment } = render(
10 |
11 | jest.fn()} />
12 |
13 | );
14 |
15 | expect(asFragment()).toMatchSnapshot();
16 | });
17 |
--------------------------------------------------------------------------------
/src/components/signup-form/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useForm } from 'react-hook-form';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | import { Field, Button } from '@components';
6 | import { useAppSelector } from '@store/selectors';
7 | import { EMAIL_REGEX, PASSWORD_REGEX } from '@utilities';
8 |
9 | interface Props {
10 | readonly onSubmit: (values: any) => void;
11 | readonly children?: React.ReactNode | React.ReactNode[];
12 | }
13 |
14 | // codebeat:disable[LOC]
15 | export const SignupForm: React.FunctionComponent = (props: Props) => {
16 | const { t } = useTranslation();
17 | const required = t('This field is required.');
18 | const { loading, signupError } = useAppSelector(state => state.auth);
19 | const {
20 | watch,
21 | register,
22 | handleSubmit,
23 | formState: { errors }
24 | } = useForm({
25 | mode: 'onBlur'
26 | });
27 |
28 | return (
29 |
133 | );
134 | };
135 | // codebeat:enable[LOC]
136 |
137 | export default SignupForm;
138 |
--------------------------------------------------------------------------------
/src/components/test-store-provider/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Provider } from 'react-redux';
3 | import { MemoryRouter } from 'react-router';
4 | import configureMockStore from 'redux-mock-store';
5 |
6 | import { RootState } from '@store/interfaces';
7 |
8 | interface Props {
9 | state: Partial;
10 | children: string | React.ReactNode | React.ReactNode[];
11 | }
12 |
13 | export const TestStoreProvider: React.FC> = ({ state, children }: Props) => (
14 |
15 | {children}
16 |
17 | );
18 |
19 | export default TestStoreProvider;
20 |
--------------------------------------------------------------------------------
/src/components/wrapper/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Wrapper component should render successfully 1`] = `
4 |
5 |
8 |
42 |
45 | Test content
46 |
47 |
69 |
70 |
71 | `;
72 |
--------------------------------------------------------------------------------
/src/components/wrapper/index.scss:
--------------------------------------------------------------------------------
1 | .o-main {
2 | width: 100%;
3 | flex: 1;
4 | }
5 |
6 | .o-shell {
7 | max-width: 75.125rem;
8 | padding-right: 1rem;
9 | padding-left: 1rem;
10 | margin-right: auto;
11 | margin-left: auto;
12 |
13 | &--flex {
14 | display: flex;
15 | flex-flow: row wrap;
16 | align-items: center;
17 | justify-content: space-between;
18 | }
19 | }
20 |
21 | .o-wrapper {
22 | height: 100%;
23 | display: flex;
24 | flex-flow: column nowrap;
25 | align-items: flex-start;
26 | justify-content: flex-start;
27 | }
28 |
29 | .c-form {
30 | max-width: 25rem;
31 | flex: 0 0 25rem;
32 | padding: 2rem 0;
33 | margin: auto;
34 |
35 | &--signup {
36 | max-width: 51rem;
37 | flex: 0 0 51rem;
38 | }
39 |
40 | h2 {
41 | text-align: center;
42 | font-weight: 600;
43 | margin: 0 0 2rem;
44 | }
45 |
46 | &__col {
47 | flex: 0 0 calc(50% - 0.5rem);
48 |
49 | &s {
50 | @include flex-row(nowrap, flex-start, space-between);
51 | }
52 | }
53 |
54 | &__hint {
55 | text-align: center;
56 |
57 | a {
58 | color: $color-action;
59 | text-decoration: underline;
60 |
61 | @include hover {
62 | text-decoration: none;
63 | }
64 | }
65 | }
66 |
67 | &__error {
68 | color: $color-error;
69 | margin: 0;
70 |
71 | &--api {
72 | margin-bottom: 1rem;
73 | }
74 | }
75 |
76 | &__field {
77 | margin-bottom: 1rem;
78 |
79 | &--error {
80 | input,
81 | select,
82 | textarea {
83 | background-color: rgba($color-error, 0.05);
84 | border-color: $color-error;
85 | }
86 | }
87 |
88 | &--select {
89 | position: relative;
90 |
91 | select {
92 | padding-right: 2.5rem;
93 | }
94 |
95 | &::after {
96 | content: '';
97 | width: 0;
98 | height: 0;
99 | display: block;
100 | position: absolute;
101 | bottom: 1.25rem;
102 | right: 1.25rem;
103 | z-index: 2;
104 | border-width: 0.5rem 0.325rem 0;
105 | border-style: solid;
106 | border-color: currentcolor transparent transparent;
107 | }
108 | }
109 |
110 | label,
111 | input,
112 | select,
113 | textarea {
114 | display: block;
115 | }
116 |
117 | label {
118 | font-size: 100%;
119 | display: block;
120 |
121 | &[for] {
122 | cursor: pointer;
123 | }
124 | }
125 |
126 | select {
127 | appearance: none;
128 | }
129 |
130 | textarea {
131 | height: 200px;
132 | resize: none;
133 | overflow: hidden auto;
134 | -webkit-overflow-scrolling: touch;
135 | }
136 |
137 | input,
138 | select,
139 | textarea {
140 | font-size: 0.875rem;
141 | line-height: 1.2857;
142 | width: 100%;
143 | height: 48px;
144 | padding: 0.875rem 1rem;
145 | border: 1px solid color.adjust($color-black, $lightness: 20%);
146 | border-radius: 0.5rem;
147 | background-color: $color-white;
148 | box-shadow: none;
149 | transition: all $timing $easing;
150 |
151 | &:focus {
152 | border-color: color.adjust($color-black, $lightness: 40%);
153 | outline: 0 none;
154 | box-shadow: 0 0 0.5rem rgba($color-black, 0.25);
155 | }
156 | }
157 |
158 | &-head {
159 | font-size: 0.75rem;
160 | line-height: 1.5;
161 | margin-bottom: 0.25rem;
162 |
163 | @include flex-row(nowrap, center, space-between);
164 | }
165 | }
166 |
167 | .c-btn {
168 | font-size: 1.125rem;
169 | max-width: 12rem;
170 | display: block;
171 | margin: 1rem auto 2rem;
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/components/wrapper/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { Wrapper } from '.';
5 | import { initialState } from '@store/branches/auth/reducer';
6 | import { TestStoreProvider } from '@components';
7 |
8 | test('Wrapper component should render successfully', () => {
9 | const { asFragment } = render(
10 |
11 | Test content
12 |
13 | );
14 |
15 | expect(asFragment()).toMatchSnapshot();
16 | });
17 |
--------------------------------------------------------------------------------
/src/components/wrapper/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { Header, Footer } from '@components';
4 |
5 | import './index.scss';
6 |
7 | interface Props {
8 | readonly children?: React.ReactNode | React.ReactNode[];
9 | readonly className?: string;
10 | }
11 |
12 | export const Wrapper: React.FunctionComponent = ({ children, className }: Props) => {
13 | const classes = ['o-wrapper'];
14 |
15 | if (className) {
16 | classes.push(className);
17 | }
18 |
19 | return (
20 |
21 |
22 |
23 | {children}
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default Wrapper;
31 |
--------------------------------------------------------------------------------
/src/containers/.boilerplate/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Page component should render successfully 1`] = `
4 |
5 |
6 | Boilerplate
7 |
8 |
9 | `;
10 |
--------------------------------------------------------------------------------
/src/containers/.boilerplate/index.scss:
--------------------------------------------------------------------------------
1 | div {
2 | display: contents;
3 | }
4 |
--------------------------------------------------------------------------------
/src/containers/.boilerplate/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { Page } from '.';
5 |
6 | test('Page component should render successfully', () => {
7 | const { asFragment } = render();
8 |
9 | expect(asFragment()).toMatchSnapshot();
10 | });
11 |
--------------------------------------------------------------------------------
/src/containers/.boilerplate/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | import './index.scss';
5 |
6 | export const Page: React.FunctionComponent = () => {
7 | const { t } = useTranslation();
8 |
9 | return {t('Boilerplate')}
;
10 | };
11 |
12 | export default Page;
13 |
--------------------------------------------------------------------------------
/src/containers/auth/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Login } from './login';
2 | export { default as PasswordReset } from './password-reset';
3 | export { default as Signup } from './signup';
4 |
--------------------------------------------------------------------------------
/src/containers/auth/login/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Login component should render successfully 1`] = `
4 |
5 |
8 |
42 |
45 |
116 |
117 |
139 |
140 |
141 | `;
142 |
--------------------------------------------------------------------------------
/src/containers/auth/login/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { Login } from '.';
5 | import { initialState } from '@store/branches/auth/reducer';
6 | import { TestStoreProvider } from '@components';
7 |
8 | test('Login component should render successfully', () => {
9 | const { asFragment } = render(
10 |
11 |
12 |
13 | );
14 |
15 | expect(asFragment()).toMatchSnapshot();
16 | });
17 |
--------------------------------------------------------------------------------
/src/containers/auth/login/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { useTranslation } from 'react-i18next';
4 | import { Link, useNavigate } from 'react-router-dom';
5 |
6 | import { Routes } from '@utilities';
7 | import { AuthActionType } from '@store/enums';
8 | import { Wrapper, LoginForm } from '@components';
9 |
10 | export const Login: React.FunctionComponent = () => {
11 | const { t } = useTranslation();
12 | const dispatch = useDispatch();
13 | const navigate = useNavigate();
14 |
15 | return (
16 |
17 | {
19 | dispatch({
20 | type: AuthActionType.LOGIN_REQUEST,
21 | payload: {
22 | ...payload,
23 | redirect: (): void => navigate(Routes.BASE)
24 | }
25 | });
26 | }}
27 | >
28 |
29 | {t("Don't have an account?")} {t('Sign up')}
30 |
31 |
32 |
33 | {t('Forgot password?')} {t('Reset password')}
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | export default Login;
41 |
--------------------------------------------------------------------------------
/src/containers/auth/password-reset/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`PasswordReset component should render successfully 1`] = `
4 |
5 |
8 |
42 |
45 |
131 |
132 |
154 |
155 |
156 | `;
157 |
--------------------------------------------------------------------------------
/src/containers/auth/password-reset/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { initialState } from '@store/branches/auth/reducer';
5 | import { PasswordReset } from '.';
6 | import { TestStoreProvider } from '@components';
7 |
8 | test('PasswordReset component should render successfully', () => {
9 | const { asFragment } = render(
10 |
11 |
12 |
13 | );
14 |
15 | expect(asFragment()).toMatchSnapshot();
16 | });
17 |
--------------------------------------------------------------------------------
/src/containers/auth/password-reset/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { useTranslation } from 'react-i18next';
4 | import { Link, useNavigate } from 'react-router-dom';
5 |
6 | import { Routes } from '@utilities';
7 | import { AuthActionType } from '@store/enums';
8 | import { Wrapper, PasswordResetForm } from '@components';
9 |
10 | export const PasswordReset: React.FunctionComponent = () => {
11 | const { t } = useTranslation();
12 | const dispatch = useDispatch();
13 | const navigate = useNavigate();
14 |
15 | return (
16 |
17 | {
19 | dispatch({
20 | type: AuthActionType.PASSWORD_RESET_REQUEST,
21 | payload: {
22 | ...payload,
23 | redirect: (): void => navigate(Routes.LOGIN)
24 | }
25 | });
26 | }}
27 | >
28 |
29 | {t('You can also')} {t('Sign up')} {t('or')}{' '}
30 | {t('Login')}
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | export default PasswordReset;
38 |
--------------------------------------------------------------------------------
/src/containers/auth/signup/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Signup component should render successfully 1`] = `
4 |
5 |
8 |
42 |
45 |
246 |
247 |
269 |
270 |
271 | `;
272 |
--------------------------------------------------------------------------------
/src/containers/auth/signup/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { Signup } from '.';
5 | import { initialState } from '@store/branches/auth/reducer';
6 | import { TestStoreProvider } from '@components';
7 |
8 | test('Signup component should render successfully', () => {
9 | const { asFragment } = render(
10 |
11 |
12 |
13 | );
14 |
15 | expect(asFragment()).toMatchSnapshot();
16 | });
17 |
--------------------------------------------------------------------------------
/src/containers/auth/signup/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { useTranslation } from 'react-i18next';
4 | import { Link, useNavigate } from 'react-router-dom';
5 |
6 | import { Routes } from '@utilities';
7 | import { AuthActionType } from '@store/enums';
8 | import { Wrapper, SignupForm } from '@components';
9 |
10 | export const Signup: React.FunctionComponent = () => {
11 | const { t } = useTranslation();
12 | const dispatch = useDispatch();
13 | const navigate = useNavigate();
14 |
15 | return (
16 |
17 | {
19 | dispatch({
20 | type: AuthActionType.SIGNUP_REQUEST,
21 | payload: {
22 | ...payload,
23 | redirect: (): void => navigate(Routes.LOGIN)
24 | }
25 | });
26 | }}
27 | >
28 |
29 | {t('Already have an account?')} {t('Login')}
30 |
31 |
32 |
33 | );
34 | };
35 |
36 | export default Signup;
37 |
--------------------------------------------------------------------------------
/src/containers/home/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Home component should render successfully 1`] = `
4 |
5 |
8 |
42 |
45 |
48 | Homepage
49 |
50 |
51 |
73 |
74 |
75 | `;
76 |
--------------------------------------------------------------------------------
/src/containers/home/index.scss:
--------------------------------------------------------------------------------
1 | .o-wrapper {
2 | max-width: 400px;
3 | margin: auto;
4 | display: flex;
5 | flex-flow: row nowrap;
6 | align-items: center;
7 | justify-content: space-between;
8 |
9 | small {
10 | font-size: 1.25rem;
11 | text-align: center;
12 | flex: 1;
13 | }
14 | }
15 |
16 | .c-svg-icon {
17 | width: 200px;
18 | height: 200px;
19 | margin: auto;
20 | }
21 |
--------------------------------------------------------------------------------
/src/containers/home/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { Home } from '.';
5 | import { initialState } from '@store/branches/auth/reducer';
6 | import { TestStoreProvider } from '@components';
7 |
8 | test('Home component should render successfully', () => {
9 | const { asFragment } = render(
10 |
11 |
12 |
13 | );
14 |
15 | expect(asFragment()).toMatchSnapshot();
16 | });
17 |
--------------------------------------------------------------------------------
/src/containers/home/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | import { Wrapper } from '@components';
5 |
6 | export const Home: React.FunctionComponent = () => {
7 | const { t } = useTranslation();
8 |
9 | return (
10 |
11 | {t('Homepage')}
12 |
13 | );
14 | };
15 |
16 | export default Home;
17 |
--------------------------------------------------------------------------------
/src/containers/not-found/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`NotFound component should render successfully 1`] = `
4 |
5 |
8 |
42 |
45 |
48 | 404
49 |
50 | Page not found
51 |
52 |
53 |
75 |
76 |
77 | `;
78 |
--------------------------------------------------------------------------------
/src/containers/not-found/index.scss:
--------------------------------------------------------------------------------
1 | .c-not-found {
2 | font-size: 3rem;
3 | line-height: 1.5;
4 | color: $color-error;
5 | text-align: center;
6 | height: 100%;
7 |
8 | @include flex-row(wrap, center, center);
9 | }
10 |
--------------------------------------------------------------------------------
/src/containers/not-found/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { NotFound } from '.';
5 | import { initialState } from '@store/branches/auth/reducer';
6 | import { TestStoreProvider } from '@components';
7 |
8 | test('NotFound component should render successfully', () => {
9 | const { asFragment } = render(
10 |
11 |
12 |
13 | );
14 |
15 | expect(asFragment()).toMatchSnapshot();
16 | });
17 |
--------------------------------------------------------------------------------
/src/containers/not-found/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | import { Wrapper } from '@components';
5 |
6 | import './index.scss';
7 |
8 | export const NotFound: React.FunctionComponent = () => {
9 | const { t } = useTranslation();
10 |
11 | return (
12 |
13 |
14 | 404
15 |
16 | {t('Page not found')}
17 |
18 |
19 | );
20 | };
21 |
22 | export default NotFound;
23 |
--------------------------------------------------------------------------------
/src/custom.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.svg' {
2 | const content: string;
3 | export default content;
4 | }
5 |
6 | declare module '*.jpg' {
7 | const content: string;
8 | export default content;
9 | }
10 |
11 | declare module '*.png' {
12 | const content: string;
13 | export default content;
14 | }
15 |
16 | declare module '*.json' {
17 | const content: string;
18 | export default content;
19 | }
20 |
21 | // eslint-disable-next-line no-var
22 | declare var module: any;
23 |
--------------------------------------------------------------------------------
/src/i18n/gettext-converter.mjs:
--------------------------------------------------------------------------------
1 | import { readFileSync, writeFileSync } from 'fs';
2 | import { i18nextToPot, gettextToI18next } from 'i18next-conv';
3 |
4 | import locales from './locales.mjs';
5 |
6 | const basePath = './src/assets/locale/';
7 |
8 | const save = target => result => writeFileSync(target, result);
9 |
10 | const jsonToPot = () => {
11 | i18nextToPot('en', readFileSync(basePath + 'translations.json'), undefined).then(
12 | save(basePath + 'translations.pot')
13 | );
14 | };
15 |
16 | const poToJson = () => {
17 | for (const locale of locales) {
18 | const path = basePath + locale;
19 |
20 | gettextToI18next(locale, readFileSync(path + '.po'), undefined).then(save(path + '.json'));
21 | }
22 | };
23 |
24 | if (process.argv.length > 0) {
25 | switch (process.argv[2]) {
26 | case 'jsonToPot':
27 | jsonToPot();
28 | break;
29 | case 'poToJson':
30 | poToJson();
31 | break;
32 | default:
33 | console.error(process.argv[2] + ' did not match');
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/i18n/index.ts:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next';
2 | import LanguageDetector from 'i18next-browser-languagedetector';
3 | import { initReactI18next } from 'react-i18next';
4 |
5 | import * as en from '../assets/locale/translations.json';
6 | import * as de from '../assets/locale/de.json';
7 |
8 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
9 | // @ts-ignore
10 | import { default as locales } from './locales.mjs';
11 |
12 | const resources = {
13 | en: {
14 | translation: en
15 | },
16 | de: {
17 | translation: de
18 | }
19 | };
20 |
21 | i18n.use(initReactI18next)
22 | .use(LanguageDetector)
23 | .init({
24 | lng: localStorage.getItem('locale') || 'en',
25 | backend: {
26 | loadPath: '../assets/locale'
27 | },
28 | preload: locales,
29 | resources,
30 | keySeparator: false,
31 | interpolation: {
32 | escapeValue: false
33 | },
34 | detection: {
35 | order: ['navigator', 'localStorage', 'htmlTag'],
36 | caches: ['localStorage'],
37 | htmlTag: document.documentElement,
38 | lookupLocalStorage: 'locale'
39 | }
40 | });
41 |
42 | export { i18n, locales };
43 | export default i18n;
44 |
--------------------------------------------------------------------------------
/src/i18n/locales.mjs:
--------------------------------------------------------------------------------
1 | export default ['de'];
2 |
--------------------------------------------------------------------------------
/src/i18n/scanner-config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | options: {
3 | debug: false,
4 | func: {
5 | list: ['i18next.t', 'i18n.t', 'this.props.t', 'props.t', 't'],
6 | extensions: ['.ts', '.tsx']
7 | },
8 | trans: {
9 | component: 'Trans',
10 | i18nKey: 'i18nKey',
11 | defaultsKey: 'defaults',
12 | extensions: ['.js', '.jsx'],
13 | fallbackKey: (_, value) => value
14 | },
15 | defaultValue: (lng, ns, key) => key,
16 | resource: {
17 | loadPath: 'src/assets/locale/translations.json',
18 | savePath: 'src/assets/locale/translations.json',
19 | jsonIndent: 2,
20 | lineEnding: '\n'
21 | },
22 | nsSeparator: false,
23 | keySeparator: false,
24 | interpolation: {
25 | prefix: '{{',
26 | suffix: '}}'
27 | },
28 | removeUnusedKeys: true
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Provider } from 'react-redux';
3 | import { createRoot } from 'react-dom/client';
4 | import { BrowserRouter } from 'react-router-dom';
5 |
6 | import { App } from './app';
7 | import { removeItems } from '@utilities';
8 | import { AuthActionType } from '@store/enums';
9 | import { configureStore } from '@store/index';
10 |
11 | export const store = configureStore();
12 |
13 | const node: HTMLElement | null = document.getElementById('app') || document.createElement('div');
14 | const root = createRoot(node);
15 |
16 | const renderRoot = (Application: any): void => {
17 | root.render(
18 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | removeItems();
27 |
28 | store.dispatch({ type: AuthActionType.RESET_AUTH });
29 |
30 | renderRoot(App);
31 |
--------------------------------------------------------------------------------
/src/loadables.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import loadable, { LoadableComponent } from '@loadable/component';
3 |
4 | const fallback = ;
5 |
6 | // prettier-ignore
7 | export const Login: LoadableComponent = loadable(() => import('@containers/auth/login'), { fallback });
8 |
9 | // prettier-ignore
10 | export const Signup: LoadableComponent = loadable(() => import('@containers/auth/signup'), { fallback });
11 |
12 | // prettier-ignore
13 | export const PasswordReset: LoadableComponent = loadable(() => import('@containers/auth/password-reset'), { fallback });
14 |
15 | // prettier-ignore
16 | export const Home: LoadableComponent = loadable(() => import('@containers/home'), { fallback });
17 |
18 | // prettier-ignore
19 | export const NotFound: LoadableComponent = loadable(() => import('@containers/not-found'), { fallback });
20 |
--------------------------------------------------------------------------------
/src/store/branches/.boilerplate/enums.ts:
--------------------------------------------------------------------------------
1 | export enum ExampleActionType {
2 | REQUEST = 'REQUEST',
3 | SUCCESS = 'SUCCESS',
4 | FAILED = 'FAILED'
5 | }
6 |
--------------------------------------------------------------------------------
/src/store/branches/.boilerplate/interfaces.ts:
--------------------------------------------------------------------------------
1 | import { ExampleActionType } from './enums';
2 |
3 | export interface ExampleState {
4 | error: boolean;
5 | loading: boolean;
6 | }
7 |
8 | export interface ExampleAction {
9 | type: ExampleActionType;
10 | payload: Partial;
11 | }
12 |
--------------------------------------------------------------------------------
/src/store/branches/.boilerplate/reducer.ts:
--------------------------------------------------------------------------------
1 | import { ExampleActionType } from './enums';
2 | import { ExampleState, ExampleAction } from './interfaces';
3 |
4 | export const initialState: ExampleState = {
5 | error: false,
6 | loading: true
7 | };
8 |
9 | export default (state = initialState, { type, payload }: ExampleAction): ExampleState => {
10 | switch (type) {
11 | case ExampleActionType.REQUEST:
12 | return {
13 | ...state,
14 | error: false,
15 | loading: true
16 | };
17 | case ExampleActionType.FAILED:
18 | return {
19 | ...state,
20 | error: true,
21 | loading: false
22 | };
23 | case ExampleActionType.SUCCESS:
24 | return {
25 | ...state,
26 | error: false,
27 | loading: false
28 | };
29 |
30 | default:
31 | return state;
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/src/store/branches/.boilerplate/sagas.ts:
--------------------------------------------------------------------------------
1 | import { put, call, takeLatest, CallEffect, PutEffect, ForkEffect } from 'redux-saga/effects';
2 |
3 | import { ExampleAction } from './interfaces';
4 | import { ExampleActionType } from './enums';
5 |
6 | type ExampleSafaEffect = Generator | PutEffect>;
7 | type ExampleSagaForEffect = Generator>;
8 |
9 | export function* exampleEffect(): ExampleSafaEffect {
10 | try {
11 | const payload: any = yield call(() => true);
12 |
13 | yield put({ type: ExampleActionType.SUCCESS, payload });
14 | } catch (error) {
15 | yield put({
16 | type: ExampleActionType.FAILED,
17 | payload: {
18 | error
19 | }
20 | });
21 | }
22 | }
23 |
24 | export function* exampleSaga(): ExampleSagaForEffect {
25 | yield takeLatest(ExampleActionType.REQUEST, exampleEffect);
26 | }
27 |
--------------------------------------------------------------------------------
/src/store/branches/auth/enums.ts:
--------------------------------------------------------------------------------
1 | export enum AuthActionType {
2 | LOGIN_REQUEST = 'LOGIN_REQUEST',
3 | LOGIN_SUCCESS = 'LOGIN_SUCCESS',
4 | LOGIN_FAILED = 'LOGIN_FAILED',
5 |
6 | LOGOUT_REQUEST = 'LOGOUT_REQUEST',
7 | LOGOUT_SUCCESS = 'LOGOUT_SUCCESS',
8 | LOGOUT_FAILED = 'LOGOUT_FAILED',
9 |
10 | SIGNUP_REQUEST = 'SIGNUP_REQUEST',
11 | SIGNUP_SUCCESS = 'SIGNUP_SUCCESS',
12 | SIGNUP_FAILED = 'SIGNUP_FAILED',
13 |
14 | PASSWORD_RESET_REQUEST = 'PASSWORD_RESET_REQUEST',
15 | PASSWORD_RESET_SUCCESS = 'PASSWORD_RESET_SUCCESS',
16 | PASSWORD_RESET_FAILED = 'PASSWORD_RESET_FAILED',
17 |
18 | SET_LOCALE_REQUEST = 'SET_LOCALE_REQUEST',
19 | SET_LOCALE_FAILED = 'SET_LOCALE_FAILED',
20 | SET_LOCALE_SUCCESS = 'SET_LOCALE_SUCCESS',
21 |
22 | RESET_AUTH = 'RESET_AUTH'
23 | }
24 |
--------------------------------------------------------------------------------
/src/store/branches/auth/interfaces.ts:
--------------------------------------------------------------------------------
1 | import { AuthActionType } from './enums';
2 |
3 | export interface AuthState {
4 | token: string;
5 | locale: string;
6 | loading: boolean;
7 | threshold: number;
8 | loginError: string;
9 | logoutError: string;
10 | signupError: string;
11 | refreshToken: string;
12 | passwordResetError: string;
13 | [x: string]: any;
14 | }
15 |
16 | export interface AuthAction {
17 | type: AuthActionType;
18 | payload?: Partial;
19 | }
20 |
--------------------------------------------------------------------------------
/src/store/branches/auth/reducer.ts:
--------------------------------------------------------------------------------
1 | import { TOKEN_KEY, REFRESH_TOKEN_KEY, TOKEN_THRESHOLD_KEY } from '@utilities';
2 |
3 | import { AuthActionType } from './enums';
4 | import { AuthState, AuthAction } from './interfaces';
5 |
6 | export const initialState: AuthState = {
7 | token: localStorage.getItem(TOKEN_KEY) || '',
8 | locale: 'en',
9 | loading: false,
10 | threshold: Number(localStorage.getItem(TOKEN_THRESHOLD_KEY)) || 0,
11 | loginError: '',
12 | logoutError: '',
13 | signupError: '',
14 | refreshToken: localStorage.getItem(REFRESH_TOKEN_KEY) || '',
15 | passwordResetError: ''
16 | };
17 |
18 | export default (state = initialState, { type, payload }: AuthAction): AuthState => {
19 | switch (type) {
20 | case AuthActionType.LOGIN_REQUEST:
21 | case AuthActionType.LOGOUT_REQUEST:
22 | case AuthActionType.SIGNUP_REQUEST:
23 | case AuthActionType.SET_LOCALE_REQUEST:
24 | case AuthActionType.PASSWORD_RESET_REQUEST:
25 | return {
26 | ...state,
27 | loading: true
28 | };
29 | case AuthActionType.LOGIN_FAILED:
30 | case AuthActionType.LOGOUT_FAILED:
31 | case AuthActionType.SIGNUP_FAILED:
32 | case AuthActionType.SET_LOCALE_FAILED:
33 | case AuthActionType.PASSWORD_RESET_FAILED:
34 | return {
35 | ...state,
36 | ...payload,
37 | loading: false
38 | };
39 | case AuthActionType.LOGIN_SUCCESS:
40 | case AuthActionType.LOGOUT_SUCCESS:
41 | case AuthActionType.SIGNUP_SUCCESS:
42 | case AuthActionType.SET_LOCALE_SUCCESS:
43 | case AuthActionType.PASSWORD_RESET_SUCCESS:
44 | return {
45 | ...state,
46 | ...payload,
47 | loading: false
48 | };
49 | case AuthActionType.RESET_AUTH:
50 | return initialState;
51 | default:
52 | return state;
53 | }
54 | };
55 |
--------------------------------------------------------------------------------
/src/store/branches/auth/sagas.ts:
--------------------------------------------------------------------------------
1 | import { AnyAction } from 'redux';
2 | import { put, call, takeLatest, CallEffect, PutEffect, ForkEffect } from 'redux-saga/effects';
3 |
4 | import { i18n } from '@i18n';
5 | import { AuthAction } from './interfaces';
6 | import { AuthActionType } from './enums';
7 | import { login, logout, setItems, saveLocale, removeItems, passwordReset, setDocumentLang } from '@utilities';
8 |
9 | type AuthSagaEffect = Generator | PutEffect>;
10 | type AuthSagaForkEffect = Generator>;
11 |
12 | export function* loginEffect(action: AnyAction): AuthSagaEffect {
13 | try {
14 | const { email, password, redirect } = action.payload;
15 | const data: any = yield call(login, { email, password });
16 |
17 | const payload = {
18 | token: data.access_token,
19 | threshold: data.threshold || 3600,
20 | refreshToken: data.refresh_token
21 | };
22 |
23 | yield put({ type: AuthActionType.LOGIN_SUCCESS, payload });
24 | yield call(setItems, payload);
25 |
26 | redirect();
27 | } catch (loginError: any) {
28 | yield put({
29 | type: AuthActionType.LOGIN_FAILED,
30 | payload: {
31 | loginError
32 | }
33 | });
34 |
35 | yield call(removeItems);
36 | }
37 | }
38 |
39 | export function* loginSaga(): AuthSagaForkEffect {
40 | yield takeLatest(AuthActionType.LOGIN_REQUEST, loginEffect);
41 | }
42 |
43 | export function* logoutEffect(action: AnyAction): AuthSagaEffect {
44 | try {
45 | yield call(logout);
46 |
47 | const payload = {
48 | token: '',
49 | threshold: 0,
50 | refreshToken: ''
51 | };
52 |
53 | yield put({ type: AuthActionType.LOGOUT_SUCCESS, payload });
54 | yield call(removeItems);
55 |
56 | action.payload.redirect();
57 | } catch (logoutError: any) {
58 | yield put({
59 | type: AuthActionType.LOGOUT_FAILED,
60 | payload: {
61 | logoutError
62 | }
63 | });
64 | }
65 | }
66 |
67 | export function* logoutSaga(): AuthSagaForkEffect {
68 | yield takeLatest(AuthActionType.LOGOUT_REQUEST, logoutEffect);
69 | }
70 |
71 | export function* passwordResetEffect(action: AnyAction): AuthSagaEffect {
72 | try {
73 | const { email, password, redirect } = action.payload;
74 |
75 | yield call(passwordReset, { email, password });
76 | yield put({ type: AuthActionType.PASSWORD_RESET_SUCCESS });
77 |
78 | redirect();
79 | } catch (passwordResetError: any) {
80 | yield put({
81 | type: AuthActionType.PASSWORD_RESET_FAILED,
82 | payload: {
83 | passwordResetError
84 | }
85 | });
86 |
87 | yield call(removeItems);
88 | }
89 | }
90 |
91 | export function* passwordResetSaga(): AuthSagaForkEffect {
92 | yield takeLatest(AuthActionType.LOGIN_REQUEST, loginEffect);
93 | }
94 |
95 | export function* signupEffect(action: AnyAction): AuthSagaEffect {
96 | try {
97 | const { redirect, ...signupData } = action.payload;
98 | const data: any = yield call(login, signupData);
99 |
100 | const payload = {
101 | token: data.access_token,
102 | threshold: data.threshold || 3600,
103 | refreshToken: data.refresh_token
104 | };
105 |
106 | yield put({ type: AuthActionType.SIGNUP_SUCCESS, payload });
107 | yield call(setItems, payload);
108 |
109 | redirect();
110 | } catch (signupError: any) {
111 | yield put({
112 | type: AuthActionType.SIGNUP_FAILED,
113 | payload: {
114 | signupError
115 | }
116 | });
117 |
118 | yield call(removeItems);
119 | }
120 | }
121 |
122 | export function* signupSaga(): AuthSagaForkEffect {
123 | yield takeLatest(AuthActionType.SIGNUP_REQUEST, signupEffect);
124 | }
125 |
126 | export function* localeEffect(action: AuthAction): AuthSagaEffect {
127 | try {
128 | const locale = action.payload?.locale;
129 |
130 | if (!locale) {
131 | yield put({
132 | type: AuthActionType.SET_LOCALE_FAILED
133 | });
134 |
135 | return;
136 | }
137 |
138 | i18n.changeLanguage(locale);
139 | saveLocale(locale);
140 | setDocumentLang(locale);
141 |
142 | yield put({
143 | type: AuthActionType.SET_LOCALE_SUCCESS,
144 | payload: { locale }
145 | });
146 | } catch (error) {
147 | yield put({
148 | type: AuthActionType.SET_LOCALE_FAILED
149 | });
150 | }
151 | }
152 |
153 | export function* localeSaga(): AuthSagaForkEffect {
154 | yield takeLatest(AuthActionType.SET_LOCALE_REQUEST, localeEffect);
155 | }
156 |
--------------------------------------------------------------------------------
/src/store/enums.ts:
--------------------------------------------------------------------------------
1 | export * from './branches/auth/enums';
2 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { composeWithDevTools } from '@redux-devtools/extension';
2 | import { Store, createStore, applyMiddleware } from 'redux';
3 | import createSagaMiddleware, { Saga, SagaMiddleware } from 'redux-saga';
4 |
5 | import sagas from './sagas';
6 | import rootReducer from './root-reducer';
7 | import { initialState as auth } from '@store/branches/auth/reducer';
8 |
9 | const initialState = {
10 | auth
11 | };
12 |
13 | export const sagaMiddleware: SagaMiddleware = createSagaMiddleware();
14 |
15 | export function configureStore(): Store {
16 | const store: Store = createStore(
17 | rootReducer(),
18 | initialState,
19 | composeWithDevTools(applyMiddleware(sagaMiddleware))
20 | );
21 |
22 | sagas.forEach((saga: Saga) => {
23 | sagaMiddleware.run(saga);
24 | });
25 |
26 | return store;
27 | }
28 |
--------------------------------------------------------------------------------
/src/store/interfaces.ts:
--------------------------------------------------------------------------------
1 | import { store } from '..';
2 |
3 | export * from './branches/auth/interfaces';
4 |
5 | export type RootState = ReturnType;
6 |
--------------------------------------------------------------------------------
/src/store/root-reducer.ts:
--------------------------------------------------------------------------------
1 | import { Reducer, combineReducers } from 'redux';
2 |
3 | import auth from '@store/branches/auth/reducer';
4 |
5 | export default (): Reducer =>
6 | combineReducers({
7 | auth
8 | });
9 |
--------------------------------------------------------------------------------
/src/store/sagas.ts:
--------------------------------------------------------------------------------
1 | import { loginSaga, logoutSaga, localeSaga, passwordResetSaga } from '@store/branches/auth/sagas';
2 |
3 | export default [loginSaga, logoutSaga, localeSaga, passwordResetSaga];
4 |
--------------------------------------------------------------------------------
/src/store/selectors.ts:
--------------------------------------------------------------------------------
1 | import { TypedUseSelectorHook, useSelector } from 'react-redux';
2 |
3 | import { RootState } from '@store/interfaces';
4 |
5 | export const useAppSelector: TypedUseSelectorHook = useSelector;
6 |
--------------------------------------------------------------------------------
/src/utilities/api.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosResponse } from 'axios';
2 |
3 | import { handleItem, setThreshold } from './local-storage';
4 | import { AuthRequest, SignupRequest } from './interfaces';
5 | import { API_URL, REFRESH_TOKEN_KEY, TOKEN_KEY, TOKEN_THRESHOLD_KEY } from './constants';
6 |
7 | export const http = axios.create({
8 | baseURL: API_URL
9 | });
10 |
11 | export const post = (endpoint: string, data?: unknown): Promise =>
12 | new Promise((resolve, reject) =>
13 | http
14 | .post(endpoint, data)
15 | .then(resolve)
16 | .catch(e => reject(e.response.data))
17 | );
18 |
19 | export const get = (endpoint: string): Promise =>
20 | new Promise((resolve, reject) =>
21 | http
22 | .get(endpoint)
23 | .then(resolve)
24 | .catch(e => reject(e.response.data))
25 | );
26 |
27 | export const patch = (endpoint: string, data: T): Promise =>
28 | new Promise((resolve, reject) =>
29 | http
30 | .patch(endpoint, data)
31 | .then(resolve)
32 | .catch(e => reject(e.response.data))
33 | );
34 |
35 | export const login = (data: AuthRequest): Promise => post('passport/basic/login', data);
36 |
37 | export const logout = (): Promise => post('user/logout');
38 |
39 | export const passwordReset = (data: Partial): Promise => post('user/reset-password', data);
40 |
41 | export const signup = (data: SignupRequest): Promise => post('passport/basic/signup', data);
42 |
43 | http.interceptors.request.use(config => {
44 | if (config.headers) {
45 | config.headers['Authorization'] = localStorage.getItem(TOKEN_KEY) || '';
46 | }
47 |
48 | return config;
49 | }, Promise.reject);
50 |
51 | http.interceptors.response.use(
52 | response => response,
53 | error => {
54 | const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
55 |
56 | if (!refreshToken) {
57 | return Promise.reject(error);
58 | }
59 |
60 | const originalRequest = error.config;
61 |
62 | if (error.response.status === 401 && !originalRequest._retry) {
63 | originalRequest._retry = true;
64 |
65 | if (http.defaults.headers) {
66 | http.defaults.headers['Authorization'] = 'Bearer ' + refreshToken;
67 | }
68 |
69 | return post('auth/token')
70 | .then(res => {
71 | const { access_token, threshold } = res.data;
72 |
73 | handleItem(TOKEN_KEY, access_token);
74 | handleItem(TOKEN_THRESHOLD_KEY, setThreshold(threshold));
75 |
76 | if (http.defaults.headers) {
77 | http.defaults.headers['Authorization'] = access_token;
78 | }
79 |
80 | return http(originalRequest);
81 | })
82 | .catch(Promise.reject);
83 | }
84 |
85 | return Promise.reject(error);
86 | }
87 | );
88 |
--------------------------------------------------------------------------------
/src/utilities/constants.ts:
--------------------------------------------------------------------------------
1 | export const API_URL = 'https://my-awesome-api.com/rest/v1/';
2 |
3 | export const TOKEN_KEY = 'access-token';
4 | export const REFRESH_TOKEN_KEY = 'refresh-token';
5 | export const TOKEN_THRESHOLD_KEY = 'access-token-threshold';
6 |
7 | /* eslint-disable */
8 | export const EMAIL_REGEX = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/;
9 | export const PASSWORD_REGEX = /.{8,}/;
10 | /* eslint-enable */
11 |
--------------------------------------------------------------------------------
/src/utilities/enums.ts:
--------------------------------------------------------------------------------
1 | export enum Routes {
2 | BASE = '/',
3 | ABOUT = '/about',
4 | LOGIN = '/login',
5 | SIGNUP = '/signup',
6 | SETTINGS = '/settings',
7 | PASSWORD_RESET = '/reset-password'
8 | }
9 |
--------------------------------------------------------------------------------
/src/utilities/helpers.ts:
--------------------------------------------------------------------------------
1 | import { getUnixTime } from 'date-fns';
2 |
3 | import { Routes } from './enums';
4 | import { removeItems } from './local-storage';
5 | import { TOKEN_KEY, TOKEN_THRESHOLD_KEY } from './constants';
6 |
7 | export const getAccessToken = (): string => localStorage.getItem(TOKEN_KEY) || '';
8 |
9 | export const isLoggedIn = (): boolean => {
10 | const threshold = Number(localStorage.getItem(TOKEN_THRESHOLD_KEY));
11 |
12 | if (!threshold) {
13 | return false;
14 | }
15 |
16 | const now = getUnixTime(new Date());
17 |
18 | if (now >= threshold) {
19 | removeItems();
20 |
21 | window.location.href = Routes.LOGIN;
22 |
23 | return false;
24 | }
25 |
26 | return !!getAccessToken() && window.location.pathname !== Routes.LOGIN;
27 | };
28 |
29 | export const saveLocale = (locale: string): void => localStorage.setItem('locale', locale);
30 |
31 | export const setDocumentLang = (locale: string): void => document.documentElement.setAttribute('lang', locale);
32 |
--------------------------------------------------------------------------------
/src/utilities/hooks.ts:
--------------------------------------------------------------------------------
1 | // Custom hooks
2 | export {};
3 |
--------------------------------------------------------------------------------
/src/utilities/index.ts:
--------------------------------------------------------------------------------
1 | export * from './api';
2 | export * from './constants';
3 | export * from './enums';
4 | export * from './helpers';
5 | export * from './interfaces';
6 | export * from './local-storage';
7 |
--------------------------------------------------------------------------------
/src/utilities/interfaces.ts:
--------------------------------------------------------------------------------
1 | export interface AuthRequest {
2 | email: string;
3 | password: string;
4 | }
5 |
6 | export interface SignupRequest extends AuthRequest {
7 | gender: string;
8 | username: string;
9 | image_url: string;
10 | last_name: string;
11 | first_name: string;
12 | }
13 |
--------------------------------------------------------------------------------
/src/utilities/local-storage.ts:
--------------------------------------------------------------------------------
1 | import { add, getUnixTime } from 'date-fns';
2 |
3 | import { AuthAction } from '@store/interfaces';
4 | import { TOKEN_KEY, TOKEN_THRESHOLD_KEY, REFRESH_TOKEN_KEY } from './constants';
5 |
6 | export const setThreshold = (time: number): string =>
7 | getUnixTime(
8 | add(new Date(), {
9 | seconds: time || 3600
10 | })
11 | ).toString();
12 |
13 | export const handleItem = (key: string, value?: string): void => {
14 | if (value) {
15 | localStorage.setItem(key, value);
16 | } else {
17 | localStorage.removeItem(key);
18 | }
19 | };
20 |
21 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
22 | export const setItems = (data: AuthAction['payload']): void => {
23 | handleItem(TOKEN_KEY, data!.token);
24 | handleItem(TOKEN_THRESHOLD_KEY, setThreshold(data!.threshold!));
25 | handleItem(REFRESH_TOKEN_KEY, data!.refreshToken);
26 | };
27 | /* eslint-enable @typescript-eslint/no-non-null-assertion */
28 |
29 | export const removeItems = (): void => {
30 | handleItem(TOKEN_KEY);
31 | handleItem(TOKEN_THRESHOLD_KEY);
32 | handleItem(REFRESH_TOKEN_KEY);
33 | };
34 |
--------------------------------------------------------------------------------
/test-config/DateMock.js:
--------------------------------------------------------------------------------
1 | const constantDate = new Date('2017-12-31T23:59:59');
2 |
3 | /**
4 | * Date constructor will now return
5 | * the same date each time it is called
6 | */
7 | global.Date = class extends Date {
8 | constructor(date) {
9 | super(date);
10 | if (date) {
11 | return date;
12 | }
13 |
14 | return constantDate;
15 | }
16 | };
17 |
18 | const time = new Date().getTime();
19 |
20 | export default time;
21 |
--------------------------------------------------------------------------------
/test-config/FileMock.js:
--------------------------------------------------------------------------------
1 | const { basename } = require('path');
2 |
3 | module.exports = {
4 | /**
5 | * @param {any} _
6 | * @param {string} filename
7 | */
8 | process(_, filename) {
9 | return {
10 | code: `module.exports = ${JSON.stringify(basename(filename))};`
11 | };
12 | }
13 | };
14 |
--------------------------------------------------------------------------------
/test-config/StyleMock.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | process() {
3 | return 'style-mock';
4 | }
5 | };
6 |
--------------------------------------------------------------------------------
/test-config/index.js:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | const jestMock = (path, mock) => {
3 | jest.mock(path, () => ({
4 | ...jest.requireActual(path),
5 | ...mock
6 | }));
7 | };
8 |
9 | jest.mock('react-inlinesvg');
10 |
11 | jest.mock('axios', () => ({
12 | __esModule: true,
13 | default: {
14 | create: jest.fn(() => ({
15 | get: jest.fn(() => Promise.resolve()),
16 | post: jest.fn(() => Promise.resolve()),
17 | patch: jest.fn(() => Promise.resolve()),
18 | interceptors: {
19 | request: {
20 | use: jest.fn()
21 | },
22 | response: {
23 | use: jest.fn()
24 | }
25 | }
26 | }))
27 | }
28 | }));
29 |
30 | jest.mock('react-i18next', () => ({
31 | Trans: ({ children }) => children,
32 | useTranslation: () => ({ t: key => key }),
33 | withTranslation: () => y => y
34 | }));
35 |
36 | jest.mock('i18next', () => ({
37 | __esModule: true,
38 | default: {
39 | use: () => ({
40 | init: () => jest.fn()
41 | }),
42 | changeLanguage: () => jest.fn()
43 | }
44 | }));
45 |
46 | jest.mock('@i18n', () => ({
47 | __esModule: true,
48 | default: {
49 | use: () => ({
50 | init: () => jest.fn()
51 | }),
52 | language: 'en',
53 | changeLanguage: () => jest.fn()
54 | },
55 | i18n: {
56 | use: () => ({
57 | init: () => jest.fn()
58 | }),
59 | language: 'en',
60 | changeLanguage: () => jest.fn()
61 | },
62 | locales: ['de']
63 | }));
64 |
65 | jestMock('react-redux', {
66 | useDispatch: () => jest.fn()
67 | });
68 |
69 | jestMock('react-router-dom', {
70 | useNavigate: () => jest.fn()
71 | });
72 |
--------------------------------------------------------------------------------
/test-config/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | verbose: false,
3 | rootDir: '..',
4 | moduleDirectories: ['/node_modules', '/src'],
5 | moduleFileExtensions: ['js', 'json', 'jsx', 'node', 'ts', 'tsx'],
6 | moduleNameMapper: {
7 | '^@src/(.*)$': '/src/$1',
8 | '^@root/(.*)$': '/$1',
9 | '^@i18n': '/src/i18n',
10 | '^@store/(.*)$': '/src/store/$1',
11 | '^@assets/(.*)$': '/src/assets/$1',
12 | '^@utilities': '/src/utilities',
13 | '^@utilities/(.*)$': '/src/utilities/$1',
14 | '^@components': '/src/components',
15 | '^@containers/(.*)$': '/src/containers/$1',
16 | '\\.(css|less|sass|scss)$': '/test-config/StyleMock.js',
17 | '\\.(jpg|jpeg|png|gif|ico|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
18 | '/test-config/FileMock.js'
19 | },
20 | setupFiles: ['jest-localstorage-mock', '/test-config/index.js'],
21 | transform: {
22 | '\\.svg$': '/test-config/FileMock.js',
23 | '\\.tsx?$': [
24 | 'ts-jest',
25 | {
26 | tsconfig: '/test-config/tsconfig.json'
27 | }
28 | ]
29 | },
30 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
31 | testEnvironment: 'jsdom',
32 | testPathIgnorePatterns: ['/node_modules/', '/dist/', '/test-config/']
33 | };
34 |
--------------------------------------------------------------------------------
/test-config/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "esModuleInterop": true,
5 | "isolatedModules": false
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "alwaysStrict": true,
4 | "allowSyntheticDefaultImports": true,
5 | "allowJs": false,
6 | "baseUrl": "./",
7 | "checkJs": false,
8 | "declaration": false,
9 | "emitDecoratorMetadata": true,
10 | "experimentalDecorators": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "importHelpers": true,
13 | "isolatedModules": true,
14 | "jsx": "react",
15 | "lib": ["dom", "ESNext"],
16 | "module": "ESNext",
17 | "moduleResolution": "Node",
18 | "noEmit": false,
19 | "noEmitHelpers": true,
20 | "noEmitOnError": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noImplicitAny": true,
23 | "noImplicitReturns": true,
24 | "noImplicitThis": true,
25 | "noStrictGenericChecks": false,
26 | "paths": {
27 | "@i18n": ["./src/i18n"],
28 | "@store/*": ["./src/store/*"],
29 | "@assets/*": ["./src/assets/*"],
30 | "@utilities": ["./src/utilities"],
31 | "@utilities/*": ["./src/utilities/*"],
32 | "@components": ["./src/components"],
33 | "@containers/*": ["./src/containers/*"],
34 | "@src/*": ["./src/*"],
35 | "@root/*": ["./*"],
36 | },
37 | "pretty": true,
38 | "removeComments": false,
39 | "strict": true,
40 | "strictBindCallApply": true,
41 | "strictFunctionTypes": true,
42 | "strictPropertyInitialization": true,
43 | "strictNullChecks": true,
44 | "sourceMap": true,
45 | "target": "ESNext",
46 | "types": ["vite/client", "jest"]
47 | },
48 | "compileOnSave": false,
49 | "include": ["src/**/*", "./vite.config.ts"],
50 | "exclude": ["node_modules"]
51 | }
52 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import alias from '@rollup/plugin-alias';
3 | import { join } from 'node:path';
4 | import { Routes } from './src/utilities/enums';
5 | import { VitePWA } from 'vite-plugin-pwa';
6 | import autoprefixer from 'autoprefixer';
7 | import cssNanoPlugin from 'cssnano';
8 | import vitePrerender from 'vite-plugin-prerender';
9 | import { defineConfig } from 'vite';
10 | import postcssFlexbugsFixes from 'postcss-flexbugs-fixes';
11 |
12 | export default defineConfig({
13 | plugins: [
14 | react(),
15 | VitePWA({
16 | registerType: 'autoUpdate',
17 | includeAssets: ['favicon.ico'],
18 | manifest: {
19 | name: 'React Template',
20 | short_name: 'React TPL',
21 | description: 'A React application!',
22 | theme_color: '#333333',
23 | icons: [
24 | {
25 | src: 'icon-512x512.png',
26 | sizes: '512x512',
27 | type: 'image/png'
28 | }
29 | ]
30 | }
31 | }),
32 | vitePrerender({
33 | staticDir: join(__dirname, 'dist'),
34 | routes: Object.values(Routes)
35 | }),
36 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
37 | // @ts-ignore
38 | alias({
39 | entries: {
40 | '@src': '/src',
41 | '@i18n': '/src/i18n',
42 | '@store': '/src/store',
43 | '@mocks': '/src/__mocks__',
44 | '@assets': '/src/assets',
45 | '@components': '/src/components',
46 | '@containers': '/src/containers',
47 | '@utilities': '/src/utilities'
48 | }
49 | })
50 | ],
51 | css: {
52 | preprocessorOptions: {
53 | scss: {
54 | additionalData: `
55 | @use 'sass:list';
56 | @use 'sass:color';
57 | @use 'sass:string';
58 | @import "./src/assets/styles/settings.scss";
59 | `
60 | }
61 | },
62 | postcss: {
63 | plugins: [autoprefixer, postcssFlexbugsFixes as any, cssNanoPlugin]
64 | }
65 | }
66 | });
67 |
--------------------------------------------------------------------------------
/workbox-config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | swDest: 'dist/service-worker.js',
3 | sourcemap: false,
4 | skipWaiting: true,
5 | globIgnores: [],
6 | globPatterns: ['**/*.{js,css,png,svg,jpg,gif,json,woff,woff2,eot,ico,webmanifest}'],
7 | clientsClaim: true,
8 | globDirectory: 'dist',
9 | maximumFileSizeToCacheInBytes: 8000000
10 | };
11 |
--------------------------------------------------------------------------------