├── .env ├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── .prettierrc ├── README.md ├── index.html ├── jest.config.cjs ├── package.json ├── public ├── .htaccess ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.tsx ├── RootComponent.tsx ├── __tests__ │ ├── components │ │ └── DateDisplay.test.tsx │ └── pages │ │ ├── HomePage.test.tsx │ │ └── NotFoundPage.test.tsx ├── components │ └── DateDisplay.tsx ├── index.tsx ├── pages │ ├── HomePage.tsx │ └── NotFoundPage.tsx ├── resources │ ├── api-constants.ts │ └── routes-constants.ts ├── serviceWorker.ts ├── setupTests.ts ├── store │ ├── actions │ │ ├── data.ts │ │ └── thunkActions.ts │ └── reducers │ │ ├── data.ts │ │ └── store.ts ├── styles │ ├── _mixins.sass │ ├── _variables.sass │ └── main.sass ├── types │ └── reducers.ts ├── utility │ ├── customAxios.ts │ └── functions.ts └── vite-env.d.ts ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | SERVER_OPEN_BROWSER=true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | # Ignore artifacts: 3 | build/ 4 | coverage/ 5 | 6 | vite-env.d.ts 7 | vite.config.ts 8 | jest.config.js -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 4 | plugins: ['@typescript-eslint', 'react-hooks', 'prettier'], 5 | parserOptions: { 6 | ecmaVersion: 2018, 7 | sourceType: 'module', 8 | ecmaFeatures: { 9 | jsx: true 10 | } 11 | }, 12 | env: { 13 | browser: true, 14 | node: true, 15 | es6: true, 16 | jest: true 17 | }, 18 | rules: { 19 | '@typescript-eslint/naming-convention': [ 20 | 'warn', 21 | { 22 | selector: 'parameter', 23 | format: ['camelCase'], 24 | trailingUnderscore: 'allowSingleOrDouble' 25 | }, 26 | { 27 | selector: 'class', 28 | format: ['PascalCase'] 29 | }, 30 | { 31 | selector: 'enum', 32 | format: ['PascalCase', 'UPPER_CASE'] 33 | }, 34 | { 35 | selector: 'enumMember', 36 | format: ['PascalCase', 'UPPER_CASE'] 37 | }, 38 | { 39 | selector: 'interface', 40 | format: ['PascalCase'] 41 | }, 42 | { 43 | selector: 'typeAlias', 44 | format: ['PascalCase'] 45 | }, 46 | { 47 | selector: 'typeParameter', 48 | format: ['PascalCase'] 49 | } 50 | ], 51 | 'react-hooks/rules-of-hooks': 'warn', 52 | 'react-hooks/exhaustive-deps': 'warn' 53 | }, 54 | settings: { 55 | react: { 56 | version: 'detect' 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | 11 | /coverage 12 | 13 | # production 14 | 15 | /build 16 | 17 | # misc 18 | 19 | .eslintcache 20 | .DS_Store 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log\* 29 | 30 | yarn.lock 31 | package-lock.json 32 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 160, 3 | "tabWidth": 4, 4 | "singleQuote": true, 5 | "semi": false, 6 | "trailingComma": "none", 7 | "bracketSpacing": true 8 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

New React App

2 | 3 | # Usage 4 | 5 | To clone and use this template type the following commands: 6 | 7 | ```sh 8 | npx degit chrisuser/vite-complete-react-app my-app 9 | ``` 10 | 11 | ```sh 12 | cd my-app 13 | ``` 14 | 15 | Then, based on your package manager: 16 | 17 | ## npm 18 | 19 | ```sh 20 | npm install 21 | ``` 22 | 23 | ```sh 24 | npm run dev 25 | ``` 26 | 27 | ## yarn 28 | 29 | ```sh 30 | yarn 31 | ``` 32 | 33 | ```sh 34 | yarn dev 35 | ``` 36 | 37 |
38 | 39 | > [!TIP] 40 | > Remember to update the project name inside the `package.json` file. 41 | 42 |
43 | 44 | ## ⚗️ Technologies list 45 | 46 | - [TypeScript](https://www.typescriptlang.org/) 47 | - [Sass](https://sass-lang.com/) 48 | - [Redux Toolkit](https://redux-toolkit.js.org/) 49 | - [Router](https://reactrouter.com/) 50 | - [Axios](https://axios-http.com/) 51 | - [Moment](https://momentjs.com/) 52 | - [ESlint](https://eslint.org/) & [Prettier](https://prettier.io/) 53 | 54 | --- 55 | 56 |
57 | 58 | > [!TIP] 59 | > After cloning the repo you can delete all the previous text for a cleaner README. 60 | 61 |
62 | 63 | This is a blank README file that you can customize at your needs.\ 64 | Describe your project, how it works and how to contribute to it. 65 | 66 |
67 | 68 | # 🚀 Available Scripts 69 | 70 | In the project directory, you can run: 71 | 72 |
73 | 74 | ## ⚡️ start 75 | 76 | ``` 77 | npm start 78 | ``` 79 | 80 | or 81 | 82 | ``` 83 | yarn start 84 | ``` 85 | 86 | Runs the app in the development mode.\ 87 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 88 | 89 |
90 | 91 | ## 🧪 test 92 | 93 | ``` 94 | npm test 95 | ``` 96 | 97 | or 98 | 99 | ``` 100 | yarn test 101 | ``` 102 | 103 | Launches the test runner in the interactive watch mode. 104 | 105 |
106 | 107 | ## 🦾 build 108 | 109 | ``` 110 | npm build 111 | ``` 112 | 113 | or 114 | 115 | ``` 116 | yarn build 117 | ``` 118 | 119 | Builds the app for production to the `build` folder.\ 120 | It correctly bundles React in production mode and optimizes the build for the best performance. 121 | 122 | The build is minified and the filenames include the hashes. 123 | 124 |
125 | 126 | ## 🧶 lint 127 | 128 | ``` 129 | npm lint 130 | ``` 131 | 132 | or 133 | 134 | ``` 135 | yarn lint 136 | ``` 137 | 138 | Creates a `.eslintcache` file in which ESLint cache is stored. Running this command can dramatically improve ESLint's running time by ensuring that only changed files are linted. 139 | 140 |
141 | 142 | ## 🎯 format 143 | 144 | ``` 145 | npm format 146 | ``` 147 | 148 | or 149 | 150 | ``` 151 | yarn format 152 | ``` 153 | 154 | Checks if your files are formatted. This command will output a human-friendly message and a list of unformatted files, if any. 155 | 156 |
157 | 158 | # 🧬 Project structure 159 | 160 | This is the structure of the files in the project: 161 | 162 | ```sh 163 | │ 164 | ├── public # public files (favicon, .htaccess, manifest, ...) 165 | ├── src # source files 166 | │ ├── __tests__ # all test files 167 | │ ├── components 168 | │ ├── pages 169 | │ ├── resources # images, constants and other static resources 170 | │ ├── store # Redux store 171 | │ │ ├── actions # store's actions 172 | │ │ └── reducers # store's reducers 173 | │ ├── styles 174 | │ ├── types # data interfaces 175 | │ ├── utility # utilities functions and custom components 176 | │ ├── App.tsx 177 | │ ├── index.tsx 178 | │ ├── RootComponent.tsx # React component with all the routes 179 | │ ├── serviceWorker.ts 180 | │ ├── setupTests.ts 181 | │ └── vite-env.d.ts 182 | ├── .env 183 | ├── .eslintignore 184 | ├── .eslintrc.js 185 | ├── .gitignore 186 | ├── .prettierrc 187 | ├── index.html 188 | ├── jest.config.cjs 189 | ├── package.json 190 | ├── README.md 191 | ├── tsconfig.json 192 | └── vite.config.json 193 | ``` 194 | 195 | # 📖 Learn More 196 | 197 | You can learn more in the [Vite documentation](https://vitejs.dev/guide/). 198 | 199 | To learn React, check out the [React documentation](https://reactjs.org/). 200 | 201 | # 202 | 203 |

Bootstrapped with Vite.

204 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | React App 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx$': 'ts-jest', 5 | '^.+\\.ts$': 'ts-jest' 6 | }, 7 | testRegex: '(/__tests__/.*.(test|spec)).(jsx?|tsx?)$', 8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 9 | collectCoverage: true, 10 | collectCoverageFrom: ['/src/**/*.{ts,tsx}'], 11 | coverageDirectory: '/coverage/', 12 | coveragePathIgnorePatterns: ['(tests/.*.mock).(jsx?|tsx?)$', '(.*).d.ts$'], 13 | moduleNameMapper: { 14 | '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2|svg)$': 'identity-obj-proxy' 15 | }, 16 | verbose: true, 17 | testTimeout: 30000 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-complete-react-app", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "author": { 6 | "name": "Cristiano Raimondi", 7 | "url": "https://github.com/chrisuser" 8 | }, 9 | "dependencies": { 10 | "@reduxjs/toolkit": "^2.5.0", 11 | "@testing-library/jest-dom": "^6.6.3", 12 | "@testing-library/react": "^16.1.0", 13 | "@testing-library/user-event": "^14.5.2", 14 | "axios": "^1.7.9", 15 | "eslint-plugin-prettier": "^5.2.1", 16 | "moment": "^2.30.1", 17 | "react": "^19.0.0", 18 | "react-dom": "^19.0.0", 19 | "react-redux": "^9.2.0", 20 | "react-router": "^7.1.1", 21 | "redux-persist": "^6.0.0", 22 | "sass": "^1.83.0", 23 | "sass-loader": "^16.0.4" 24 | }, 25 | "scripts": { 26 | "start": "vite --port 3000 --open", 27 | "dev": "vite --port 3000 --open", 28 | "build": "vite build", 29 | "serve": "vite preview --open", 30 | "test": "jest", 31 | "test:coverage": "jest --silent --watchAll=false --coverage", 32 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 33 | "format": "prettier --check ." 34 | }, 35 | "eslintConfig": { 36 | "extends": "react-app" 37 | }, 38 | "browserslist": [ 39 | "defaults", 40 | "not IE 11" 41 | ], 42 | "devDependencies": { 43 | "@testing-library/dom": "^10.4.0", 44 | "@types/jest": "^29.5.14", 45 | "@types/node": "^22.10.2", 46 | "@types/react": "^19.0.2", 47 | "@types/react-dom": "^19.0.2", 48 | "@types/react-redux": "^7.1.34", 49 | "@typescript-eslint/eslint-plugin": "^8.18.2", 50 | "@typescript-eslint/parser": "^8.18.2", 51 | "@vitejs/plugin-react": "^4.3.4", 52 | "eslint": "^8.56.0", 53 | "eslint-plugin-react-hooks": "^5.1.0", 54 | "identity-obj-proxy": "^3.0.0", 55 | "jest": "^29.7.0", 56 | "jest-environment-jsdom": "^29.7.0", 57 | "prettier": "^3.4.2", 58 | "ts-jest": "^29.2.5", 59 | "typescript": "^5.7.2", 60 | "vite": "^6.0.6", 61 | "vite-plugin-env-compatible": "^2.0.1", 62 | "vite-plugin-html": "^3.2.2", 63 | "vitest": "^2.1.8" 64 | }, 65 | "homepage": "." 66 | } 67 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | Options -MultiViews 3 | 4 | RewriteEngine On 5 | 6 | RewriteCond %{REQUEST_FILENAME} !-f 7 | RewriteRule ^ index.html [QSA,L] 8 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisUser/vite-complete-react-app/eb42f81b768f6729b8f61c84722bd42be6d5fcae/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisUser/vite-complete-react-app/eb42f81b768f6729b8f61c84722bd42be6d5fcae/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisUser/vite-complete-react-app/eb42f81b768f6729b8f61c84722bd42be6d5fcae/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Provider } from 'react-redux' 3 | import { PersistGate } from 'redux-persist/integration/react' 4 | import RootComponent from './RootComponent' 5 | import { persistor, store } from './store/reducers/store' 6 | 7 | const App: React.FC = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | 17 | export default App 18 | -------------------------------------------------------------------------------- /src/RootComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter as Router, Route, Routes } from 'react-router' 3 | import HomePage from './pages/HomePage' 4 | import NotFoundPage from './pages/NotFoundPage' 5 | import { ROUTES } from './resources/routes-constants' 6 | import './styles/main.sass' 7 | 8 | const RootComponent: React.FC = () => { 9 | return ( 10 | 11 | 12 | } /> 13 | } /> 14 | 15 | 16 | ) 17 | } 18 | 19 | export default RootComponent 20 | -------------------------------------------------------------------------------- /src/__tests__/components/DateDisplay.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { render, screen, waitFor } from '@testing-library/react' 5 | import '@testing-library/jest-dom' 6 | import DateDisplay from '../../components/DateDisplay' 7 | 8 | test('renders current date', () => { 9 | render() 10 | ;async () => { 11 | const timeFormat = screen.getByText(/GMT/i) 12 | await waitFor(() => expect(timeFormat).toBeInTheDocument()) 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /src/__tests__/pages/HomePage.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { render, screen } from '@testing-library/react' 5 | import '@testing-library/jest-dom' 6 | import HomePage from '../../pages/HomePage' 7 | 8 | test('renders hello world message', () => { 9 | render() 10 | const greetings = screen.getByText(/Hello world/i) 11 | expect(greetings).toBeInTheDocument() 12 | }) 13 | -------------------------------------------------------------------------------- /src/__tests__/pages/NotFoundPage.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { render, screen } from '@testing-library/react' 5 | import { TextEncoder, TextDecoder } from 'util' 6 | 7 | Object.assign(global, { TextDecoder, TextEncoder }) 8 | 9 | import '@testing-library/jest-dom' 10 | import { BrowserRouter as Router } from 'react-router' 11 | import NotFoundPage from '../../pages/NotFoundPage' 12 | 13 | test('renders error message', () => { 14 | render( 15 | 16 | 17 | 18 | ) 19 | const errorMessage = screen.getByText(/Oops 404!/i) 20 | expect(errorMessage).toBeInTheDocument() 21 | }) 22 | -------------------------------------------------------------------------------- /src/components/DateDisplay.tsx: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | import React, { useEffect, useState } from 'react' 3 | 4 | const DateDisplay: React.FC = () => { 5 | const [date, setDate] = useState('') 6 | 7 | /** 8 | * On component render sets the date state to current date and time 9 | */ 10 | useEffect(() => { 11 | const interval = setInterval(() => { 12 | setDate(moment().toDate().toString()) 13 | }, 1000) 14 | return () => clearInterval(interval) 15 | }, []) 16 | 17 | return ( 18 |
19 | {date} 20 |
21 | ) 22 | } 23 | 24 | export default DateDisplay 25 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from './App' 4 | import * as serviceWorker from './serviceWorker' 5 | 6 | const root = createRoot(document.getElementById('root')!) // createRoot(container!) if you use TypeScript 7 | root.render() 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister() 13 | -------------------------------------------------------------------------------- /src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import DateDisplay from '../components/DateDisplay' 3 | 4 | const HomePage: React.FC = () => { 5 | return ( 6 |
7 |

Hello world!

8 | 9 |
10 | ) 11 | } 12 | 13 | export default HomePage 14 | -------------------------------------------------------------------------------- /src/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useNavigate } from 'react-router' 3 | import { ROUTES } from '../resources/routes-constants' 4 | 5 | const NotFoundPage: React.FC = () => { 6 | const navigate = useNavigate() 7 | 8 | /** 9 | * Call this function to redirect the user to the homepage. 10 | */ 11 | const redirectToHomePage = () => { 12 | navigate(ROUTES.HOMEPAGE_ROUTE) 13 | } 14 | 15 | return ( 16 |
17 |

Oops 404!

18 | redirectToHomePage()}> 19 | Homepage 20 | 21 |
22 | ) 23 | } 24 | 25 | export default NotFoundPage 26 | -------------------------------------------------------------------------------- /src/resources/api-constants.ts: -------------------------------------------------------------------------------- 1 | const baseUrl = 'http://exampleurl' 2 | 3 | export const getData = (userId: number): string => { 4 | return baseUrl + '/data/' + userId 5 | } -------------------------------------------------------------------------------- /src/resources/routes-constants.ts: -------------------------------------------------------------------------------- 1 | export const ROUTES = { 2 | HOMEPAGE_ROUTE: '/' 3 | } -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This lets the app load faster on subsequent visits in production, and gives 2 | // it offline capabilities. However, it also means that developers (and users) 3 | // will only see deployed updates on subsequent visits to a page, after all the 4 | // existing tabs open on the page have been closed, since previously cached 5 | // resources are updated in the background. 6 | 7 | // To learn more about the benefits of this model and instructions on how to 8 | // opt-in, read https://bit.ly/CRA-PWA 9 | 10 | const isLocalhost = Boolean( 11 | window.location.hostname === 'localhost' || 12 | window.location.hostname === '[::1]' || 13 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) 14 | ) 15 | 16 | type Config = { 17 | onSuccess?: (registration: ServiceWorkerRegistration) => void 18 | onUpdate?: (registration: ServiceWorkerRegistration) => void 19 | } 20 | 21 | export const register = (config?: Config): void => { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | const publicUrl = new URL(process.env.PUBLIC_URL || '', window.location.href) 24 | if (publicUrl.origin !== window.location.origin) { 25 | return 26 | } 27 | window.addEventListener('load', () => { 28 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js` 29 | if (isLocalhost) { 30 | checkValidServiceWorker(swUrl, config) 31 | navigator.serviceWorker.ready.then(() => { 32 | console.log('This web app is being served cache-first by a service ' + 'worker. To learn more, visit https://bit.ly/CRA-PWA') 33 | }) 34 | } else { 35 | registerValidSW(swUrl, config) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | function registerValidSW(swUrl: string, config?: Config) { 42 | navigator.serviceWorker 43 | .register(swUrl) 44 | .then((registration) => { 45 | registration.onupdatefound = () => { 46 | const installingWorker = registration.installing 47 | if (installingWorker == null) { 48 | return 49 | } 50 | installingWorker.onstatechange = () => { 51 | if (installingWorker.state === 'installed') { 52 | if (navigator.serviceWorker.controller) { 53 | console.log('New content is available and will be used when all ' + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.') 54 | if (config && config.onUpdate) { 55 | config.onUpdate(registration) 56 | } 57 | } else { 58 | console.log('Content is cached for offline use.') 59 | if (config && config.onSuccess) { 60 | config.onSuccess(registration) 61 | } 62 | } 63 | } 64 | } 65 | } 66 | }) 67 | .catch((error) => { 68 | console.error('Error during service worker registration:', error) 69 | }) 70 | } 71 | 72 | function checkValidServiceWorker(swUrl: string, config?: Config) { 73 | fetch(swUrl, { 74 | headers: { 'Service-Worker': 'script' } 75 | }) 76 | .then((response) => { 77 | const contentType = response.headers.get('content-type') 78 | if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) { 79 | navigator.serviceWorker.ready.then((registration) => { 80 | registration.unregister().then(() => { 81 | window.location.reload() 82 | }) 83 | }) 84 | } else { 85 | registerValidSW(swUrl, config) 86 | } 87 | }) 88 | .catch(() => { 89 | console.log('No internet connection found. App is running in offline mode.') 90 | }) 91 | } 92 | 93 | export const unregister = (): void => { 94 | if ('serviceWorker' in navigator) { 95 | navigator.serviceWorker.ready 96 | .then((registration) => { 97 | registration.unregister() 98 | }) 99 | .catch((error) => { 100 | console.error(error.message) 101 | }) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import { expect, afterEach } from 'vitest' 2 | import { cleanup } from '@testing-library/react' 3 | import * as matchers from '@testing-library/jest-dom/matchers' 4 | 5 | expect.extend(matchers) 6 | afterEach(cleanup) 7 | -------------------------------------------------------------------------------- /src/store/actions/data.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from '@reduxjs/toolkit' 2 | 3 | export const setContents = createAction('data/setContents') 4 | -------------------------------------------------------------------------------- /src/store/actions/thunkActions.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { createAsyncThunk } from '@reduxjs/toolkit' 3 | import { AppDispatch } from '../reducers/store' 4 | import { setContents } from './data' 5 | 6 | export const getData = createAsyncThunk('groupedActions/getData', async (string, { dispatch }) => { 7 | try { 8 | const data: any[] = await axios.get(`https://fakeurl.fake/${string}`).then((response) => response.data.results) 9 | dispatch(setContents(data)) 10 | } catch (e) { 11 | console.error(e) 12 | throw e 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /src/store/reducers/data.ts: -------------------------------------------------------------------------------- 1 | import { createReducer } from '@reduxjs/toolkit' 2 | import { setContents } from '../actions/data' 3 | 4 | interface DataReducer { 5 | contents: string[] 6 | } 7 | 8 | const initialState: DataReducer = { 9 | contents: [] 10 | } 11 | 12 | const dataReducer = createReducer(initialState, (builder) => { 13 | builder.addCase(setContents, (state, action) => { 14 | state.contents = action.payload 15 | }) 16 | }) 17 | 18 | export default dataReducer 19 | -------------------------------------------------------------------------------- /src/store/reducers/store.ts: -------------------------------------------------------------------------------- 1 | import { ThunkDispatch, UnknownAction, combineReducers, configureStore } from '@reduxjs/toolkit' 2 | import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist' 3 | import storage from 'redux-persist/lib/storage' // defaults to localStorage 4 | import data from './data' 5 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' 6 | 7 | const rootReducer = combineReducers({ 8 | data 9 | }) 10 | 11 | const persistedReducer = persistReducer( 12 | { 13 | key: 'root', 14 | storage, 15 | whitelist: ['data'] 16 | }, 17 | rootReducer 18 | ) 19 | 20 | export const store = configureStore({ 21 | reducer: persistedReducer, 22 | middleware: (getDefaultMiddleware) => 23 | getDefaultMiddleware({ 24 | serializableCheck: { 25 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] 26 | } 27 | }) 28 | }) 29 | 30 | export type RootState = ReturnType 31 | export type AppDispatch = ThunkDispatch 32 | 33 | export const useAppDispatch = () => useDispatch() 34 | export const useAppSelector: TypedUseSelectorHook = useSelector 35 | 36 | export const persistor = persistStore(store) 37 | -------------------------------------------------------------------------------- /src/styles/_mixins.sass: -------------------------------------------------------------------------------- 1 | $phone-size: 600px 2 | $tablet-size: 992px 3 | $desktop-size: 1200px 4 | 5 | =flexbox($justify, $align, $direction, $wrap) 6 | display: flex 7 | justify-content: $justify 8 | align-items: $align 9 | flex-direction: $direction 10 | flex-wrap: $wrap 11 | 12 | =rectangle($width, $height) 13 | width: $width 14 | height: $height 15 | 16 | =square($size) 17 | +rectangle($size, $size) 18 | 19 | @mixin breakpoint($class) 20 | @if $class == 'desktop' 33 | @media (min-width: $desktop-size) 34 | @content 35 | -------------------------------------------------------------------------------- /src/styles/_variables.sass: -------------------------------------------------------------------------------- 1 | // Cold colors 2 | $aqua: #7fdbff 3 | $blue: #0074d9 4 | $navy: #001f3f 5 | $teal: #39cccc 6 | $green: #2ecc40 7 | $olive: #3d9970 8 | $lime: #01ff70 9 | 10 | // Warm colors 11 | $yellow: #ffdc00 12 | $orange: #ff851b 13 | $red: #ff4136 14 | $fuchsia: #f012be 15 | $purple: #b10dc9 16 | $maroon: #85144b 17 | 18 | // Grayscale 19 | $white: #ffffff 20 | $silver: #dddddd 21 | $gray: #aaaaaa 22 | $black: #111111 23 | 24 | $main-background-color: $white 25 | $main-text-color: $black 26 | $divider-color: rgba($black, .14) 27 | 28 | $main-text-size: 14px 29 | $main-border-radius: 6px 30 | 31 | $small-space: 16px 32 | $medium-space: 32px 33 | $large-space: 64px 34 | -------------------------------------------------------------------------------- /src/styles/main.sass: -------------------------------------------------------------------------------- 1 | @use './_variables' as * 2 | @use './_mixins' as * 3 | 4 | * 5 | box-sizing: border-box 6 | outline: none 7 | 8 | html, 9 | body 10 | height: 100% 11 | overflow-x: hidden 12 | 13 | body 14 | position: relative 15 | margin: 0 16 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif 17 | -webkit-font-smoothing: antialiased 18 | -moz-osx-font-smoothing: grayscale 19 | color: $main-text-color 20 | background-color: $main-background-color 21 | font-size: $main-text-size 22 | 23 | code 24 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace 25 | -------------------------------------------------------------------------------- /src/types/reducers.ts: -------------------------------------------------------------------------------- 1 | export interface ReducerData { 2 | contents: string[] 3 | } 4 | 5 | export type ReduxActionData = { 6 | type: any 7 | payload?: T 8 | } 9 | 10 | export type ReduxAction = (data: T) => ReduxActionData 11 | -------------------------------------------------------------------------------- /src/utility/customAxios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const CustomAxios = axios.create() 4 | 5 | const toCamelCase: any = (object: any) => { 6 | let transformedObject = object 7 | if (typeof object === 'object' && object !== null) { 8 | if (object instanceof Array) { 9 | transformedObject = object.map(toCamelCase) 10 | } else { 11 | transformedObject = {} 12 | for (const key in object) { 13 | if (object[key] !== undefined) { 14 | const newKey = key.replace(/(_\w)|(-\w)/g, (k) => k[1].toUpperCase()) 15 | transformedObject[newKey] = toCamelCase(object[key]) 16 | } 17 | } 18 | } 19 | } 20 | return transformedObject 21 | } 22 | 23 | export const toSnackCase: any = (object: any) => { 24 | let transformedObject = object 25 | if (typeof object === 'object' && object !== null) { 26 | if (object instanceof Array) { 27 | transformedObject = object.map(toSnackCase) 28 | } else { 29 | transformedObject = {} 30 | for (const key in object) { 31 | if (object[key] !== undefined) { 32 | const newKey = key 33 | .replace(/\.?([A-Z]+)/g, function (_, y) { 34 | return '_' + y.toLowerCase() 35 | }) 36 | .replace(/^_/, '') 37 | transformedObject[newKey] = toSnackCase(object[key]) 38 | } 39 | } 40 | } 41 | } 42 | return transformedObject 43 | } 44 | 45 | CustomAxios.interceptors.response.use( 46 | (response) => { 47 | response.data = toCamelCase(response.data) 48 | return response 49 | }, 50 | (error) => { 51 | return Promise.reject(error) 52 | } 53 | ) 54 | 55 | CustomAxios.interceptors.request.use( 56 | (config) => { 57 | config.data = toSnackCase(config.data) 58 | return config 59 | }, 60 | (error) => { 61 | return Promise.reject(error) 62 | } 63 | ) 64 | 65 | export default CustomAxios 66 | -------------------------------------------------------------------------------- /src/utility/functions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This function can be used anywhere in the app to greet the user 3 | * @param userName The user's first name 4 | * @returns A kind greeting message 5 | */ 6 | export const sayHello = (userName: string): string => { 7 | return 'Welcome ' + userName + '!' 8 | } 9 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "paths": { 19 | "~/*": ["./src/*"] 20 | }, 21 | "baseUrl": "." 22 | }, 23 | "include": ["./src"] 24 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { defineConfig, loadEnv } from 'vite' 3 | import react from '@vitejs/plugin-react' 4 | import envCompatible from 'vite-plugin-env-compatible' 5 | import { createHtmlPlugin } from 'vite-plugin-html' 6 | 7 | const ENV_PREFIX = 'REACT_APP_' 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig(({ mode }) => { 11 | const env = loadEnv(mode, 'env', ENV_PREFIX) 12 | 13 | return { 14 | plugins: [ 15 | react(), 16 | envCompatible({ prefix: ENV_PREFIX }), 17 | createHtmlPlugin({ 18 | inject: { 19 | data: { 20 | env: { 21 | NODE_ENV: process.env.NODE_ENV, 22 | REACT_APP_CLIENT_TOKEN: process.env.REACT_APP_CLIENT_TOKEN, 23 | REACT_APP_ENV: process.env.REACT_APP_ENV 24 | } 25 | } 26 | }, 27 | minify: true 28 | }) 29 | ], 30 | test: { 31 | globals: true, 32 | environment: 'jsdom', 33 | setupFiles: ['./src/setupTests.js'] 34 | }, 35 | resolve: { 36 | alias: { 37 | '~': path.resolve(__dirname, 'src') 38 | } 39 | }, 40 | server: { 41 | port: 3000, 42 | open: env.SERVER_OPEN_BROWSER === 'true' 43 | }, 44 | build: { 45 | outDir: 'build' 46 | } 47 | } 48 | }) 49 | --------------------------------------------------------------------------------