├── .env.sample ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── README.md ├── package-lock.json ├── package.json ├── public ├── _redirects ├── favicon.ico ├── img │ └── favicon │ │ ├── 16x16.png │ │ ├── 180x180.png │ │ ├── 192x192.png │ │ ├── 32x32.png │ │ ├── 512x512.png │ │ ├── favicon.ico │ │ └── favicon.svg ├── index.html ├── robots.txt └── site.webmanifest ├── src ├── App.tsx ├── components │ ├── AppAlert │ │ ├── AppAlert.test.tsx │ │ ├── AppAlert.tsx │ │ └── index.tsx │ ├── AppButton │ │ ├── AppButton.test.tsx │ │ ├── AppButton.tsx │ │ └── index.tsx │ ├── AppForm │ │ ├── AppForm.tsx │ │ └── index.tsx │ ├── AppIcon │ │ ├── AppIcon.test.tsx │ │ ├── AppIcon.tsx │ │ ├── icons │ │ │ └── LogoIcon.tsx │ │ └── index.tsx │ ├── AppIconButton │ │ ├── AppIconButton.test.tsx │ │ ├── AppIconButton.tsx │ │ └── index.tsx │ ├── AppLink │ │ ├── AppLink.test.tsx │ │ ├── AppLink.tsx │ │ └── index.tsx │ ├── AppLoading │ │ ├── AppLoading.tsx │ │ └── index.tsx │ ├── AppView │ │ ├── AppView.tsx │ │ └── index.tsx │ ├── ErrorBoundary.tsx │ ├── UserInfo │ │ ├── UserInfo.tsx │ │ └── index.tsx │ ├── config.ts │ ├── dialogs │ │ ├── CommonDialog.tsx │ │ ├── CompositionDialog.tsx │ │ ├── components │ │ │ ├── AppDialogTitle.tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ └── utils.ts │ └── index.tsx ├── hooks │ ├── auth.ts │ ├── authFirebase.ts │ ├── event.ts │ ├── index.ts │ └── layout.ts ├── index.css ├── index.tsx ├── layout │ ├── BottomBar │ │ ├── BottomBar.tsx │ │ └── index.tsx │ ├── PrivateLayout.tsx │ ├── PublicLayout.tsx │ ├── SideBar │ │ ├── SideBar.tsx │ │ ├── SideBarNavItem.tsx │ │ ├── SideBarNavList.tsx │ │ └── index.tsx │ ├── TopBar │ │ ├── TopBar.tsx │ │ └── index.tsx │ ├── config.ts │ └── index.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts ├── routes │ ├── PrivateRoutes.tsx │ ├── PublicRoutes.tsx │ ├── Routes.tsx │ └── index.tsx ├── setupTests.ts ├── store │ ├── AppReducer.ts │ ├── AppStore.tsx │ └── index.tsx ├── theme │ ├── AppThemeProvider.tsx │ ├── colors.ts │ ├── createEmotionCache.ts │ ├── dark.ts │ ├── index.ts │ └── light.ts ├── utils │ ├── date.ts │ ├── environment.ts │ ├── form.ts │ ├── index.ts │ ├── localStorage.ts │ ├── navigation.ts │ ├── path.ts │ ├── sessionStorage.ts │ ├── sleep.ts │ ├── style.ts │ ├── text.ts │ └── type.ts └── views │ ├── About │ ├── AboutView.tsx │ ├── DialogsSection.tsx │ └── index.tsx │ ├── Auth │ ├── Login │ │ ├── LoginEmailView.tsx │ │ └── index.tsx │ ├── Recovery │ │ ├── RecoveryPasswordView.tsx │ │ └── index.tsx │ ├── Signup │ │ ├── ConfirmEmailView.tsx │ │ ├── SignupView.tsx │ │ └── index.tsx │ └── index.tsx │ ├── Dev │ ├── DevView.tsx │ └── index.tsx │ ├── NotFoundView.tsx │ ├── NotImplementedView.tsx │ ├── Welcome │ ├── WelcomeView.tsx │ └── index.tsx │ └── index.tsx └── tsconfig.json /.env.sample: -------------------------------------------------------------------------------- 1 | 2 | # Debug mode 3 | REACT_APP_DEBUG = true 4 | 5 | # API/Backend basic URL 6 | REACT_APP_URL_API = http://localhost:3030/api 7 | 8 | # Runtime helpers 9 | REACT_APP_VERSION = $npm_package_version 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.test.* 2 | **/*.spec.* -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['react-app', 'react-app/jest'], 3 | }; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | public 4 | out 5 | styles -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, // max 120 chars in line, code is easy to read 3 | useTabs: false, // use spaces instead of tabs 4 | tabWidth: 2, // "visual width" of of the "tab" 5 | trailingComma: 'es5', // add trailing commas in objects, arrays, etc. 6 | semi: true, // add ; when needed 7 | singleQuote: true, // '' for stings instead of "" 8 | bracketSpacing: true, // import { some } ... instead of import {some} ... 9 | arrowParens: 'always', // braces even for single param in arrow functions (a) => { } 10 | jsxSingleQuote: false, // "" for react props, like in html 11 | jsxBracketSameLine: false, // pretty JSX 12 | }; 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IMPORTANT UPDATE 2024-05-01 2 | 3 | Better use [Vite + React + MUI starter](https://github.com/karpolan/react-mui-vite-ts) instead of this one. 4 | 5 | Reasons: 6 | - [Create React App](https://create-react-app.dev/) is **not popular** anymore. 7 | - Development and debugging using **[Vite](https://vitejs.dev/)** is 10x faster than with Webpack. 8 | 9 | 10 | # React + Material UI + Auth starter using TypeScript 11 | 12 | The **TypeScript** edition of **Create React App** plus **Material UI** with set of **reusable components** and utilities to build professional **React Application** faster. 13 | 14 | - [Source Code](https://github.com/karpolan/react-typescript-material-ui-with-auth-starter) 15 | - [Online Demo](https://react-typescript-material.netlify.app/) 16 | 17 | ## How to use 18 | 19 | 1. Clone or download the repo from: https://github.com/karpolan/react-typescript-material-ui-with-auth-starter 20 | 2. Replace `_TITLE_` and `_DESCRIPTION_` in all files with own texts 21 | 3. Create **Favorite Icon** images and put them into `/public/img/favicon`, the **favicon.ico** file should be paced into root of `/public` folder. 22 | 4. Add your own code :) 23 | 24 | ## Available Scripts 25 | 26 | In the project directory, you can run: 27 | 28 | ### `npm start` or `npm run dev` 29 | 30 | Runs the app in the development mode.
31 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 32 | 33 | ### `npm run type` 34 | 35 | Checks the code for errors and missing things using **TypeScript compiler** 36 | 37 | ### `npm run lint` 38 | 39 | Checks the code for errors and missing things using **ESLint** 40 | 41 | ### `npm run format` 42 | 43 | Formats the code according to `./prettierrc.js` config 44 | 45 | ### `npm test` 46 | 47 | Launches the test runner in the interactive watch mode.\ 48 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 49 | 50 | ### `npm run build` 51 | 52 | Builds the app for production to the `build` folder.\ 53 | It correctly bundles React in production mode and optimizes the build for the best performance. 54 | 55 | The build is minified and the filenames include the hashes.\ 56 | Your app is ready to be deployed! 57 | 58 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-typescript-material", 3 | "version": "2.1.19", 4 | "description": "_DESCRIPTION_", 5 | "author": { 6 | "name": "Anton Karpenko", 7 | "email": "i@karpolan.com", 8 | "url": "https://karpolan.com" 9 | }, 10 | "private": true, 11 | "keywords": [ 12 | "react", 13 | "typescript", 14 | "material", 15 | "mui" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "put your repo url here..." 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "dev": "react-scripts start", 25 | "dev:update": "npm i && react-scripts start", 26 | "format": "prettier ./src --write", 27 | "format:all": "prettier . --write", 28 | "lint": "eslint ./src --ext .js,.ts,.tsx", 29 | "type": "tsc", 30 | "test": "react-scripts test" 31 | }, 32 | "dependencies": { 33 | "@emotion/react": "latest", 34 | "@emotion/styled": "latest", 35 | "@mui/icons-material": "latest", 36 | "@mui/material": "latest", 37 | "date-fns": "latest", 38 | "react": "latest", 39 | "react-dom": "latest", 40 | "react-router-dom": "latest", 41 | "react-scripts": "latest", 42 | "validate.js": "latest", 43 | "web-vitals": "latest" 44 | }, 45 | "devDependencies": { 46 | "@testing-library/jest-dom": "latest", 47 | "@testing-library/react": "latest", 48 | "@testing-library/user-event": "latest", 49 | "@tsconfig/create-react-app": "latest", 50 | "@types/jest": "latest", 51 | "@types/node": "latest", 52 | "@types/react": "latest", 53 | "@types/react-dom": "latest", 54 | "@types/react-router-dom": "latest", 55 | "prettier": "latest", 56 | "typescript": "latest" 57 | }, 58 | "browserslist": { 59 | "production": [ 60 | ">0.2%", 61 | "not dead", 62 | "not op_mini all" 63 | ], 64 | "development": [ 65 | "last 1 chrome version", 66 | "last 1 firefox version", 67 | "last 1 safari version" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-aethrix/react-material-ui-starter-with-auth/9d2a59726c0dfdc791352b7f87a332aa3b4910d3/public/favicon.ico -------------------------------------------------------------------------------- /public/img/favicon/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-aethrix/react-material-ui-starter-with-auth/9d2a59726c0dfdc791352b7f87a332aa3b4910d3/public/img/favicon/16x16.png -------------------------------------------------------------------------------- /public/img/favicon/180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-aethrix/react-material-ui-starter-with-auth/9d2a59726c0dfdc791352b7f87a332aa3b4910d3/public/img/favicon/180x180.png -------------------------------------------------------------------------------- /public/img/favicon/192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-aethrix/react-material-ui-starter-with-auth/9d2a59726c0dfdc791352b7f87a332aa3b4910d3/public/img/favicon/192x192.png -------------------------------------------------------------------------------- /public/img/favicon/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-aethrix/react-material-ui-starter-with-auth/9d2a59726c0dfdc791352b7f87a332aa3b4910d3/public/img/favicon/32x32.png -------------------------------------------------------------------------------- /public/img/favicon/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-aethrix/react-material-ui-starter-with-auth/9d2a59726c0dfdc791352b7f87a332aa3b4910d3/public/img/favicon/512x512.png -------------------------------------------------------------------------------- /public/img/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-aethrix/react-material-ui-starter-with-auth/9d2a59726c0dfdc791352b7f87a332aa3b4910d3/public/img/favicon/favicon.ico -------------------------------------------------------------------------------- /public/img/favicon/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | _TITLE_ 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "_TITLE_", 3 | "short_name": "_TITLE_", 4 | "description": "_DESCRIPTION_", 5 | "start_url": ".", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff", 8 | "display": "standalone", 9 | "orientation": "portrait", 10 | "icons": [ 11 | { 12 | "src": "favicon.ico?v=1.0", 13 | "sizes": "48x48 32x32 16x16", 14 | "type": "image/x-icon" 15 | }, 16 | { "src": "img/favicon/16x16.png?v=1.0", "sizes": "16x16", "type": "image/png" }, 17 | { "src": "img/favicon/32x32.png?v=1.0", "sizes": "32x32", "type": "image/png" }, 18 | { "src": "img/favicon/180x180.png?v=1.0", "sizes": "180x180", "type": "image/png" }, 19 | { "src": "img/favicon/192x192.png?v=1.0", "sizes": "192x192", "type": "image/png" }, 20 | { "src": "img/favicon/512x512.png?v=1.0", "sizes": "512x512", "type": "image/png" } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { AppThemeProvider } from './theme'; 2 | import { AppStore } from './store'; 3 | import { ErrorBoundary } from './components'; 4 | import Routes from './routes'; 5 | 6 | /** 7 | * Root Application Component 8 | * @component MainApp 9 | */ 10 | const MainApp = () => { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default MainApp; 23 | -------------------------------------------------------------------------------- /src/components/AppAlert/AppAlert.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import AppAlert from './AppAlert'; 3 | import { capitalize, randomText } from '../../utils'; 4 | import { AlertProps } from '@mui/material'; 5 | 6 | const ComponentToTest = AppAlert; 7 | 8 | /** 9 | * Tests for component 10 | */ 11 | describe(' component', () => { 12 | it('renders itself', () => { 13 | const testId = randomText(8); 14 | render(); 15 | const alert = screen.getByTestId(testId); 16 | expect(alert).toBeDefined(); 17 | expect(alert).toHaveAttribute('role', 'alert'); 18 | expect(alert).toHaveClass('MuiAlert-root'); 19 | }); 20 | 21 | it('supports .severity property', () => { 22 | const SEVERITIES = ['error', 'info', 'success', 'warning']; 23 | for (const severity of SEVERITIES) { 24 | const testId = randomText(8); 25 | const severity = 'success'; 26 | render( 27 | 32 | ); 33 | const alert = screen.getByTestId(testId); 34 | expect(alert).toBeDefined(); 35 | expect(alert).toHaveClass(`MuiAlert-filled${capitalize(severity)}`); 36 | } 37 | }); 38 | 39 | it('supports .variant property', () => { 40 | const VARIANTS = ['filled', 'outlined', 'standard']; 41 | for (const variant of VARIANTS) { 42 | const testId = randomText(8); 43 | render( 44 | 49 | ); 50 | const alert = screen.getByTestId(testId); 51 | expect(alert).toBeDefined(); 52 | expect(alert).toHaveClass(`MuiAlert-${variant}Warning`); 53 | } 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/components/AppAlert/AppAlert.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | import MuiAlert, { AlertProps as MuiAlertProps } from '@mui/material/Alert'; 3 | import { APP_ALERT_SEVERITY, APP_ALERT_VARIANT } from '../config'; 4 | 5 | /** 6 | * Application styled Alert component 7 | * @component AppAlert 8 | */ 9 | const AppAlert: FunctionComponent = ({ 10 | severity = APP_ALERT_SEVERITY, 11 | variant = APP_ALERT_VARIANT, 12 | onClose, 13 | ...restOfProps 14 | }) => { 15 | return ; 16 | }; 17 | 18 | export default AppAlert; 19 | -------------------------------------------------------------------------------- /src/components/AppAlert/index.tsx: -------------------------------------------------------------------------------- 1 | import AppAlert from './AppAlert'; 2 | 3 | export { AppAlert as default, AppAlert }; 4 | -------------------------------------------------------------------------------- /src/components/AppButton/AppButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | import { render, screen, within } from '@testing-library/react'; 3 | import { AppThemeProvider, createEmotionCache } from '../../theme'; 4 | import AppButton, { AppButtonProps } from './AppButton'; 5 | import DefaultIcon from '@mui/icons-material/MoreHoriz'; 6 | import { randomText, capitalize } from '../../utils'; 7 | 8 | /** 9 | * AppButton wrapped with Theme Provider 10 | */ 11 | const ComponentToTest: FunctionComponent = (props) => ( 12 | 13 | 14 | 15 | ); 16 | 17 | /** 18 | * Test specific color for AppButton 19 | * @param {string} colorName - name of the color, one of ColorName type 20 | * @param {string} [expectedClassName] - optional value to be found in className (color "true" may use "success" class name) 21 | * @param {boolean} [ignoreClassName] - optional flag to ignore className (color "inherit" doesn't use any class name) 22 | */ 23 | function testButtonColor(colorName: string, ignoreClassName = false, expectedClassName = colorName) { 24 | it(`supports "${colorName}" color`, () => { 25 | const testId = randomText(8); 26 | let text = `${colorName} button`; 27 | render( 28 | 33 | {text} 34 | 35 | ); 36 | 37 | let button = screen.getByTestId(testId); 38 | expect(button).toBeDefined(); 39 | // console.log('button.className:', button?.className); 40 | if (!ignoreClassName) { 41 | expect(button?.className?.includes('MuiButton-root')).toBeTruthy(); 42 | expect(button?.className?.includes('MuiButton-contained')).toBeTruthy(); 43 | expect(button?.className?.includes(`MuiButton-contained${capitalize(expectedClassName)}`)).toBeTruthy(); // Check for "MuiButton-contained[Primary| Secondary |...]" class 44 | } 45 | }); 46 | } 47 | 48 | describe(' component', () => { 49 | // beforeEach(() => {}); 50 | 51 | it('renders itself', () => { 52 | let text = 'sample button'; 53 | const testId = randomText(8); 54 | render({text}); 55 | const button = screen.getByTestId(testId); 56 | expect(button).toBeDefined(); 57 | expect(button).toHaveAttribute('role', 'button'); 58 | expect(button).toHaveAttribute('type', 'button'); // not "submit" or "input" by default 59 | }); 60 | 61 | it('has .margin style by default', () => { 62 | let text = 'button with default margin'; 63 | const testId = randomText(8); 64 | render({text}); 65 | const button = screen.getByTestId(testId); 66 | expect(button).toBeDefined(); 67 | expect(button).toHaveStyle('margin: 8px'); // Actually it is theme.spacing(1) value 68 | }); 69 | 70 | it('supports .className property', () => { 71 | let text = 'button with specific class'; 72 | let className = 'someClassName'; 73 | const testId = randomText(8); 74 | render( 75 | 76 | {text} 77 | 78 | ); 79 | const button = screen.getByTestId(testId); 80 | expect(button).toBeDefined(); 81 | expect(button).toHaveClass(className); 82 | }); 83 | 84 | it('supports .label property', () => { 85 | let text = 'button with label'; 86 | render(); 87 | let span = screen.getByText(text); 88 | expect(span).toBeDefined(); 89 | let button = span.closest('button'); // parent 89 | ); 90 | }; 91 | 92 | export default AppButton; 93 | -------------------------------------------------------------------------------- /src/components/AppButton/index.tsx: -------------------------------------------------------------------------------- 1 | import AppButton from './AppButton'; 2 | 3 | export { AppButton as default, AppButton }; 4 | -------------------------------------------------------------------------------- /src/components/AppForm/AppForm.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, FormHTMLAttributes, FunctionComponent } from 'react'; 2 | import { Box, Grid } from '@mui/material'; 3 | 4 | interface Props extends FormHTMLAttributes { 5 | children: ReactNode; 6 | } 7 | 8 | /** 9 | * Application styled Form container 10 | * @component AppForm 11 | */ 12 | const AppForm: FunctionComponent = ({ children, ...resOfProps }) => { 13 | return ( 14 |
15 | 16 | 17 | {children} 18 | 19 | 20 |
21 | ); 22 | }; 23 | 24 | export default AppForm; 25 | -------------------------------------------------------------------------------- /src/components/AppForm/index.tsx: -------------------------------------------------------------------------------- 1 | import AppForm from './AppForm'; 2 | 3 | export { AppForm as default, AppForm }; 4 | -------------------------------------------------------------------------------- /src/components/AppIcon/AppIcon.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import AppIcon, { ICONS } from './AppIcon'; 3 | import { APP_ICON_SIZE } from '../config'; 4 | import { randomColor, randomText } from '../../utils'; 5 | 6 | const ComponentToTest = AppIcon; 7 | 8 | /** 9 | * Tests for component 10 | */ 11 | describe(' component', () => { 12 | it('renders itself', () => { 13 | const testId = randomText(8); 14 | render(); 15 | const svg = screen.getByTestId(testId); 16 | expect(svg).toBeDefined(); 17 | expect(svg).toHaveAttribute('data-icon', 'default'); 18 | expect(svg).toHaveAttribute('size', String(APP_ICON_SIZE)); // default size 19 | expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set 20 | expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se 21 | }); 22 | 23 | it('supports .color property', () => { 24 | const testId = randomText(8); 25 | const color = randomColor(); // Note: 'rgb(255, 128, 0)' format is used by react-icons npm, so tests may fail 26 | render(); 27 | const svg = screen.getByTestId(testId); 28 | expect(svg).toHaveAttribute('data-icon', 'default'); 29 | // expect(svg).toHaveAttribute('color', color); // TODO: Looks like MUI Icons exclude .color property from rendering 30 | expect(svg).toHaveStyle(`color: ${color}`); 31 | expect(svg).toHaveAttribute('fill', 'currentColor'); // .fill must be 'currentColor' when .color property is set 32 | }); 33 | 34 | it('supports .icon property', () => { 35 | // Verify that all icons are supported 36 | for (const icon of Object.keys(ICONS)) { 37 | const testId = randomText(8); 38 | render(); 39 | const svg = screen.getByTestId(testId); 40 | expect(svg).toBeDefined(); 41 | expect(svg).toHaveAttribute('data-icon', icon.toLowerCase()); 42 | } 43 | }); 44 | 45 | it('supports .size property', () => { 46 | const testId = randomText(8); 47 | const size = Math.floor(Math.random() * 128) + 1; 48 | render(); 49 | const svg = screen.getByTestId(testId); 50 | expect(svg).toHaveAttribute('size', String(size)); 51 | expect(svg).toHaveAttribute('height', String(size)); 52 | expect(svg).toHaveAttribute('width', String(size)); 53 | }); 54 | 55 | it('supports .title property', () => { 56 | const testId = randomText(8); 57 | const title = randomText(16); 58 | render(); 59 | const svg = screen.getByTestId(testId); 60 | expect(svg).toBeDefined(); 61 | expect(svg).toHaveAttribute('title', title); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/components/AppIcon/AppIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, FunctionComponent, SVGAttributes } from 'react'; 2 | import { APP_ICON_SIZE } from '../config'; 3 | // SVG assets 4 | import LogoIcon from './icons/LogoIcon'; 5 | // Material Icons 6 | import DefaultIcon from '@mui/icons-material/MoreHoriz'; 7 | import SettingsIcon from '@mui/icons-material/Settings'; 8 | import VisibilityIcon from '@mui/icons-material/Visibility'; 9 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; 10 | import MenuIcon from '@mui/icons-material/Menu'; 11 | import CloseIcon from '@mui/icons-material/Close'; 12 | import DayNightIcon from '@mui/icons-material/Brightness4'; 13 | import NightIcon from '@mui/icons-material/Brightness3'; 14 | import DayIcon from '@mui/icons-material/Brightness5'; 15 | import SearchIcon from '@mui/icons-material/Search'; 16 | import InfoIcon from '@mui/icons-material/Info'; 17 | import HomeIcon from '@mui/icons-material/Home'; 18 | import AccountCircle from '@mui/icons-material/AccountCircle'; 19 | import PersonAddIcon from '@mui/icons-material/PersonAdd'; 20 | import PersonIcon from '@mui/icons-material/Person'; 21 | import ExitToAppIcon from '@mui/icons-material/ExitToApp'; 22 | import NotificationsIcon from '@mui/icons-material/NotificationsOutlined'; 23 | 24 | /** 25 | * How to use: 26 | * 1. Import all required MUI or other SVG icons into this file. 27 | * 2. Add icons with "unique lowercase names" into ICONS object. 28 | * 3. Use icons everywhere in the App by their names in component 29 | * Important: properties of ICONS object MUST be lowercase! 30 | * Note: You can use camelCase or UPPERCASE in the component 31 | */ 32 | export const ICONS: Record = { 33 | default: DefaultIcon, 34 | logo: LogoIcon, 35 | close: CloseIcon, 36 | menu: MenuIcon, 37 | settings: SettingsIcon, 38 | visibilityon: VisibilityIcon, 39 | visibilityoff: VisibilityOffIcon, 40 | daynight: DayNightIcon, 41 | night: NightIcon, 42 | day: DayIcon, 43 | search: SearchIcon, 44 | info: InfoIcon, 45 | home: HomeIcon, 46 | account: AccountCircle, 47 | signup: PersonAddIcon, 48 | login: PersonIcon, 49 | logout: ExitToAppIcon, 50 | notifications: NotificationsIcon, 51 | }; 52 | 53 | export interface AppIconProps extends SVGAttributes { 54 | color?: string; 55 | icon?: string; 56 | size?: string | number; 57 | title?: string; 58 | } 59 | 60 | /** 61 | * Renders SVG icon by given Icon name 62 | * @component AppIcon 63 | * @param {string} [color] - color of the icon as a CSS color value 64 | * @param {string} [icon] - name of the Icon to render 65 | * @param {string} [title] - title/hint to show when the cursor hovers the icon 66 | * @param {string | number} [size] - size of the icon, default is ICON_SIZE 67 | */ 68 | const AppIcon: FunctionComponent = ({ 69 | color, 70 | icon = 'default', 71 | size = APP_ICON_SIZE, 72 | style, 73 | ...restOfProps 74 | }) => { 75 | const iconName = (icon || 'default').trim().toLowerCase(); 76 | 77 | let ComponentToRender = ICONS[iconName]; 78 | if (!ComponentToRender) { 79 | console.warn(`AppIcon: icon "${iconName}" is not found!`); 80 | ComponentToRender = DefaultIcon; 81 | } 82 | 83 | const propsToRender = { 84 | height: size, 85 | color, 86 | fill: color && 'currentColor', 87 | size, 88 | style: { ...style, color }, 89 | width: size, 90 | ...restOfProps, 91 | }; 92 | 93 | return ; 94 | }; 95 | 96 | export default AppIcon; 97 | -------------------------------------------------------------------------------- /src/components/AppIcon/icons/LogoIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | import { AppIconProps } from '../AppIcon'; 3 | 4 | const LogoIcon: FunctionComponent = (props) => { 5 | return ( 6 | 17 | 18 | 27 | 28 | 41 | 42 | ); 43 | }; 44 | 45 | export default LogoIcon; 46 | -------------------------------------------------------------------------------- /src/components/AppIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import AppIcon from './AppIcon'; 2 | 3 | export { AppIcon as default, AppIcon }; 4 | -------------------------------------------------------------------------------- /src/components/AppIconButton/AppIconButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react'; 2 | import AppIconButton, { MUI_ICON_BUTTON_COLORS } from './AppIconButton'; 3 | import { APP_ICON_SIZE } from '../config'; 4 | import { ObjectPropByName, capitalize, randomColor, randomText } from '../../utils'; 5 | import { ICONS } from '../AppIcon/AppIcon'; 6 | 7 | const ComponentToTest = AppIconButton; 8 | 9 | function randomPropertyName(obj: object): string { 10 | const objectProperties = Object.keys(obj); 11 | const propertyName = objectProperties[Math.floor(Math.random() * objectProperties.length)]; 12 | return propertyName; 13 | } 14 | 15 | function randomPropertyValue(obj: object): unknown { 16 | const propertyName = randomPropertyName(obj); 17 | return (obj as ObjectPropByName)[propertyName]; 18 | } 19 | 20 | /** 21 | * Tests for component 22 | */ 23 | describe(' component', () => { 24 | it('renders itself', () => { 25 | const testId = randomText(8); 26 | render(); 27 | 28 | // Button 29 | const button = screen.getByTestId(testId); 30 | expect(button).toBeDefined(); 31 | expect(button).toHaveAttribute('role', 'button'); 32 | expect(button).toHaveAttribute('type', 'button'); 33 | 34 | // Icon 35 | const svg = button.querySelector('svg'); 36 | expect(svg).toBeDefined(); 37 | expect(svg).toHaveAttribute('data-icon', 'default'); // default icon 38 | expect(svg).toHaveAttribute('size', String(APP_ICON_SIZE)); // default size 39 | expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set 40 | expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se 41 | }); 42 | 43 | it('supports .color property', () => { 44 | for (const color of [...MUI_ICON_BUTTON_COLORS, randomColor(), randomColor(), randomColor()]) { 45 | const testId = randomText(8); 46 | const icon = randomPropertyName(ICONS) as string; 47 | render(); 48 | 49 | // Button 50 | const button = screen.getByTestId(testId); 51 | expect(button).toBeDefined(); 52 | 53 | if (color == 'default') { 54 | return; // Nothing to test for default color 55 | } 56 | 57 | if (MUI_ICON_BUTTON_COLORS.includes(color)) { 58 | expect(button).toHaveClass(`MuiIconButton-color${capitalize(color)}`); 59 | } else { 60 | expect(button).toHaveStyle({ color: color }); 61 | } 62 | } 63 | }); 64 | 65 | it('supports .disable property', () => { 66 | const testId = randomText(8); 67 | const title = randomText(16); 68 | render(); 69 | 70 | // Button 71 | const button = screen.getByTestId(testId); 72 | expect(button).toBeDefined(); 73 | expect(button).toHaveAttribute('aria-disabled', 'true'); 74 | expect(button).toHaveClass('Mui-disabled'); 75 | }); 76 | 77 | it('supports .icon property', () => { 78 | // Verify that all icons are supported 79 | for (const icon of Object.keys(ICONS)) { 80 | const testId = randomText(8); 81 | render(); 82 | 83 | // Button 84 | const button = screen.getByTestId(testId); 85 | expect(button).toBeDefined(); 86 | 87 | // Icon 88 | const svg = button.querySelector('svg'); 89 | expect(button).toBeDefined(); 90 | expect(svg).toHaveAttribute('data-icon', icon.toLowerCase()); 91 | } 92 | }); 93 | 94 | it('supports .size property', () => { 95 | const sizes = ['small', 'medium', 'large'] as const; // as IconButtonProps['size'][]; 96 | for (const size of sizes) { 97 | const testId = randomText(8); 98 | render(); 99 | 100 | // Button 101 | const button = screen.getByTestId(testId); 102 | expect(button).toBeDefined(); 103 | expect(button).toHaveClass(`MuiIconButton-size${capitalize(size)}`); // MuiIconButton-sizeSmall | MuiIconButton-sizeMedium | MuiIconButton-sizeLarge 104 | } 105 | }); 106 | 107 | it('supports .title property', async () => { 108 | const testId = randomText(8); 109 | const title = randomText(16); 110 | render(); 111 | 112 | // Button 113 | const button = screen.getByTestId(testId); 114 | expect(button).toBeDefined(); 115 | expect(button).toHaveAttribute('aria-label', title); 116 | 117 | // Emulate mouseover event to show tooltip 118 | await fireEvent(button, new MouseEvent('mouseover', { bubbles: true })); 119 | 120 | // Tooltip is rendered in a separate div, so we need to find it by role 121 | const tooltip = await screen.findByRole('tooltip'); 122 | expect(tooltip).toBeDefined(); 123 | expect(tooltip).toHaveTextContent(title); 124 | expect(tooltip).toHaveClass('MuiTooltip-popper'); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /src/components/AppIconButton/AppIconButton.tsx: -------------------------------------------------------------------------------- 1 | import { ElementType, FunctionComponent, useMemo } from 'react'; 2 | import { Tooltip, IconButton, IconButtonProps } from '@mui/material'; 3 | import AppIcon from '../AppIcon'; 4 | import AppLink from '../AppLink'; 5 | import { alpha } from '@mui/material'; 6 | 7 | export const MUI_ICON_BUTTON_COLORS = [ 8 | 'inherit', 9 | 'default', 10 | 'primary', 11 | 'secondary', 12 | 'success', 13 | 'error', 14 | 'info', 15 | 'warning', 16 | ]; 17 | 18 | interface Props extends Omit { 19 | color?: string; // Not only 'inherit' | 'default' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning', 20 | icon?: string; 21 | // Missing props 22 | component?: ElementType; // Could be RouterLink, AppLink, , etc. 23 | to?: string; // Link prop 24 | href?: string; // Link prop 25 | openInNewTab?: boolean; // Link prop 26 | } 27 | 28 | /** 29 | * Renders MUI IconButton with SVG image by given Icon name 30 | * @component AppIconButton 31 | * @param {string} [color] - color of background and hover effect. Non MUI values is also accepted. 32 | * @param {boolean} [disabled] - the IconButton is not active when true, also the Tooltip is not rendered. 33 | * @param {string} [href] - external link URI 34 | * @param {string} [icon] - name of Icon to render inside the IconButton 35 | * @param {boolean} [openInNewTab] - link will be opened in new tab when true 36 | * @param {string} [size] - size of the button: 'small', 'medium' or 'large' 37 | * @param {Array | func | object} [sx] - additional CSS styles to apply to the button 38 | * @param {string} [title] - when set, the IconButton is rendered inside Tooltip with this text 39 | * @param {string} [to] - internal link URI 40 | */ 41 | const AppIconButton: FunctionComponent = ({ 42 | color = 'default', 43 | component, 44 | children, 45 | disabled, 46 | icon, 47 | sx, 48 | title, 49 | ...restOfProps 50 | }) => { 51 | const componentToRender = !component && (restOfProps?.href || restOfProps?.to) ? AppLink : component ?? IconButton; 52 | 53 | const isMuiColor = useMemo(() => MUI_ICON_BUTTON_COLORS.includes(color), [color]); 54 | 55 | const IconButtonToRender = useMemo(() => { 56 | const colorToRender = isMuiColor ? (color as IconButtonProps['color']) : 'default'; 57 | const sxToRender = { 58 | ...sx, 59 | ...(isMuiColor 60 | ? {} 61 | : { 62 | color: color, 63 | ':hover': { 64 | backgroundColor: alpha(color, 0.04), 65 | }, 66 | }), 67 | }; 68 | return ( 69 | 76 | 77 | {children} 78 | 79 | ); 80 | }, [color, componentToRender, children, disabled, icon, isMuiColor, sx, restOfProps]); 81 | 82 | // When title is set, wrap the IconButton with Tooltip. 83 | // Note: when IconButton is disabled the Tooltip is not working, so we don't need it 84 | return title && !disabled ? {IconButtonToRender} : IconButtonToRender; 85 | }; 86 | 87 | export default AppIconButton; 88 | -------------------------------------------------------------------------------- /src/components/AppIconButton/index.tsx: -------------------------------------------------------------------------------- 1 | import AppIconButton from './AppIconButton'; 2 | 3 | export { AppIconButton as default, AppIconButton }; 4 | -------------------------------------------------------------------------------- /src/components/AppLink/AppLink.test.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from 'react-router-dom'; 2 | import { FunctionComponent } from 'react'; 3 | import { render, screen } from '@testing-library/react'; 4 | import AppLink, { AppLinkProps } from './'; 5 | import { randomColor } from '../../utils'; 6 | 7 | /** 8 | * AppLink wrapped with BrowserRouter 9 | */ 10 | const ComponentToTest: FunctionComponent = (props) => ( 11 | 12 | 13 | 14 | ); 15 | 16 | /** 17 | * Tests for component 18 | */ 19 | describe(' component', () => { 20 | it('renders itself', () => { 21 | const text = 'sample text'; 22 | const url = 'https://example.com/'; 23 | render({text}); 24 | const link = screen.getByText(text); 25 | expect(link).toBeDefined(); 26 | expect(link).toHaveAttribute('href', url); 27 | expect(link).toHaveTextContent(text); 28 | }); 29 | 30 | it('supports external link', () => { 31 | const text = 'external link'; 32 | const url = 'https://example.com/'; 33 | render({text}); 34 | const link = screen.getByText(text); 35 | expect(link).toBeDefined(); 36 | expect(link).toHaveAttribute('href', url); 37 | expect(link).toHaveTextContent(text); 38 | expect(link).toHaveAttribute('target', '_blank'); // Open external links in new Tab by default 39 | expect(link).toHaveAttribute('rel'); // For links opened in new Tab rel="noreferrer noopener" is required 40 | const rel = (link as any)?.rel; 41 | expect(rel.includes('noreferrer')).toBeTruthy(); // ref="noreferrer" check 42 | expect(rel.includes('noopener')).toBeTruthy(); // rel="noreferrer check 43 | }); 44 | 45 | it('supports internal link', () => { 46 | const text = 'internal link'; 47 | const url = '/internal-link'; 48 | render({text}); 49 | const link = screen.getByText(text); 50 | expect(link).toBeDefined(); 51 | expect(link).toHaveAttribute('href', url); 52 | expect(link).toHaveTextContent(text); 53 | expect(link).not.toHaveAttribute('target'); 54 | expect(link).not.toHaveAttribute('rel'); 55 | }); 56 | 57 | it('supports .openInNewTab property', () => { 58 | // External link with openInNewTab={false} 59 | let text = 'external link in same tab'; 60 | let url = 'https://example.com/'; 61 | render( 62 | 63 | {text} 64 | 65 | ); 66 | let link = screen.getByText(text); 67 | expect(link).toBeDefined(); 68 | expect(link).toHaveAttribute('href', url); 69 | expect(link).toHaveTextContent(text); 70 | expect(link).not.toHaveAttribute('target'); 71 | expect(link).not.toHaveAttribute('rel'); 72 | 73 | // Internal link with openInNewTab={true} 74 | text = 'internal link in new tab'; 75 | url = '/internal-link-in-new-tab'; 76 | render( 77 | 78 | {text} 79 | 80 | ); 81 | link = screen.getByText(text); 82 | expect(link).toBeDefined(); 83 | expect(link).toHaveAttribute('href', url); 84 | expect(link).toHaveTextContent(text); 85 | expect(link).toHaveAttribute('target', '_blank'); // Open links in new Tab 86 | expect(link).toHaveAttribute('rel'); // For links opened in new Tab rel="noreferrer noopener" is required 87 | const rel = (link as any)?.rel; 88 | expect(rel.includes('noreferrer')).toBeTruthy(); // ref="noreferrer" check 89 | expect(rel.includes('noopener')).toBeTruthy(); // rel="noreferrer check 90 | }); 91 | 92 | it('supports .className property', () => { 93 | let text = 'internal link with specific class'; 94 | let url = '/internal-link-with-class'; 95 | let className = 'someClassName'; 96 | render( 97 | 98 | {text} 99 | 100 | ); 101 | let link = screen.getByText(text); 102 | expect(link).toBeDefined(); 103 | expect(link).toHaveClass(className); 104 | }); 105 | 106 | it('supports .color property', () => { 107 | // Check several times with random colors 108 | for (let i = 1; i < 5; i++) { 109 | let text = `link #${i} with .color property`; 110 | let url = '/internal-link-with-color'; 111 | let color = randomColor(); 112 | render( 113 | 114 | {text} 115 | 116 | ); 117 | let link = screen.getByText(text); 118 | expect(link).toBeDefined(); 119 | expect(link).toHaveStyle(`color: ${color}`); 120 | } 121 | }); 122 | 123 | it('supports .underline property', () => { 124 | // Enumerate all possible values 125 | ['hover', 'always', 'none'].forEach((underline) => { 126 | let text = `link with .underline == "${underline}"`; 127 | let url = '/internal-link-with-underline'; 128 | render( 129 | 130 | {text} 131 | 132 | ); 133 | let link = screen.getByText(text); 134 | expect(link).toBeDefined(); 135 | underline === 'none' 136 | ? expect(link).toHaveStyle('text-decoration: none') 137 | : expect(link).toHaveStyle('text-decoration: underline'); 138 | // TODO: make "hover" test with "mouse moving" 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/components/AppLink/AppLink.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, ReactNode } from 'react'; 2 | import { Link as RouterLink } from 'react-router-dom'; 3 | import MuiLink, { LinkProps as MuiLinkProps } from '@mui/material/Link'; 4 | import { APP_LINK_COLOR, APP_LINK_UNDERLINE } from '../config'; 5 | 6 | export const EXTERNAL_LINK_PROPS = { 7 | target: '_blank', 8 | rel: 'noopener noreferrer', 9 | }; 10 | 11 | export interface AppLinkProps extends MuiLinkProps { 12 | children: ReactNode; 13 | to?: string; 14 | href?: string; 15 | openInNewTab?: boolean; 16 | } 17 | 18 | /** 19 | * Restyled Link for navigation in the App, support internal links by "to" prop and external links by "href" prop 20 | * @component AppLink 21 | * @param {object|function} children - content to wrap with tag 22 | * @param {string} [to] - internal link URI 23 | * @param {string} [href] - external link URI 24 | * @param {boolean} [openInNewTab] - link will be opened in new tab when true 25 | */ 26 | const AppLink = forwardRef( 27 | ( 28 | { 29 | children, 30 | color = APP_LINK_COLOR, 31 | underline = APP_LINK_UNDERLINE, 32 | to = '', 33 | href, 34 | openInNewTab = Boolean(href), // Open external links in new Tab by default 35 | ...restOfProps 36 | }, 37 | ref 38 | ) => { 39 | const propsToRender = { 40 | color, 41 | underline, 42 | ...(openInNewTab && EXTERNAL_LINK_PROPS), 43 | ...restOfProps, 44 | }; 45 | return href ? ( 46 | 47 | {children} 48 | 49 | ) : ( 50 | 51 | {children} 52 | 53 | ); 54 | } 55 | ); 56 | 57 | export default AppLink; 58 | -------------------------------------------------------------------------------- /src/components/AppLink/index.tsx: -------------------------------------------------------------------------------- 1 | import AppLink, { AppLinkProps } from './AppLink'; 2 | 3 | export type { AppLinkProps }; 4 | export { AppLink as default, AppLink }; 5 | -------------------------------------------------------------------------------- /src/components/AppLoading/AppLoading.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | import { CircularProgress, CircularProgressProps, LinearProgress, Stack, StackProps } from '@mui/material'; 3 | import { APP_LOADING_COLOR, APP_LOADING_SIZE, APP_LOADING_TYPE } from '../config'; 4 | 5 | interface Props extends StackProps { 6 | color?: CircularProgressProps['color']; 7 | size?: number | string; 8 | type?: 'circular' | 'linear'; 9 | value?: number; 10 | } 11 | 12 | /** 13 | * Renders MI circular progress centered inside Stack 14 | * @component AppLoading 15 | * @prop {string} [size] - size of the progress component. Numbers means pixels, string can be '2.5rem' 16 | */ 17 | const AppLoading: FunctionComponent = ({ 18 | color = APP_LOADING_COLOR, 19 | size = APP_LOADING_SIZE, 20 | type = APP_LOADING_TYPE, 21 | value, 22 | ...restOfProps 23 | }) => { 24 | const alignItems = type === 'linear' ? undefined : 'center'; 25 | return ( 26 | 27 | {type === 'linear' ? ( 28 | 29 | ) : ( 30 | 31 | )} 32 | 33 | ); 34 | }; 35 | 36 | export default AppLoading; 37 | -------------------------------------------------------------------------------- /src/components/AppLoading/index.tsx: -------------------------------------------------------------------------------- 1 | import AppLoading from './AppLoading'; 2 | 3 | export { AppLoading as default, AppLoading }; 4 | -------------------------------------------------------------------------------- /src/components/AppView/AppView.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, PropsWithChildren } from 'react'; 2 | import { Stack, StackProps, useMediaQuery, useTheme } from '@mui/material'; 3 | import { CONTENT_MAX_WIDTH, CONTENT_MIN_WIDTH } from '../config'; 4 | 5 | /** 6 | * Renders View container composition with limited width 7 | * @component AppView 8 | */ 9 | const AppView: FunctionComponent> = ({ children, minWidth, ...restOfProps }) => { 10 | const theme = useTheme(); 11 | const onSmallScreens = useMediaQuery(theme.breakpoints.down('sm')); 12 | const minWidthToRender = onSmallScreens ? '100%' : minWidth ?? CONTENT_MIN_WIDTH; 13 | 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | 21 | export default AppView; 22 | -------------------------------------------------------------------------------- /src/components/AppView/index.tsx: -------------------------------------------------------------------------------- 1 | import AppView from './AppView'; 2 | 3 | export { AppView as default, AppView as ViewContainer }; 4 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ErrorInfo, ReactNode } from 'react'; 2 | 3 | interface Props { 4 | children: ReactNode; 5 | name: string; 6 | } 7 | 8 | interface State { 9 | hasError: boolean; 10 | error?: Error; 11 | errorInfo?: ErrorInfo; 12 | } 13 | 14 | /** 15 | * Error boundary wrapper to save Application parts from falling 16 | * @component ErrorBoundary 17 | * @param {string} [props.name] - name of the wrapped segment, "Error Boundary" by default 18 | */ 19 | class ErrorBoundary extends Component { 20 | static defaultProps = { 21 | name: 'Error Boundary', 22 | }; 23 | 24 | constructor(props: Props) { 25 | super(props); 26 | this.state = { hasError: false }; 27 | } 28 | 29 | static getDerivedStateFromError(error: Error) { 30 | // The next render will show the Error UI 31 | return { hasError: true }; 32 | } 33 | 34 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { 35 | // Save information to help render Error UI 36 | this.setState({ error, errorInfo }); 37 | // TODO: Add log error messages to an error reporting service here 38 | } 39 | 40 | render() { 41 | if (this.state.hasError) { 42 | // Error UI rendering 43 | return ( 44 |
45 |

{this.props.name} - Something went wrong

46 |
47 | {this.state?.error?.toString()} 48 |
49 | {this.state?.errorInfo?.componentStack} 50 |
51 |
52 | ); 53 | } 54 | 55 | // Normal UI rendering 56 | return this.props.children; 57 | } 58 | } 59 | 60 | export default ErrorBoundary; 61 | -------------------------------------------------------------------------------- /src/components/UserInfo/UserInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Stack, Typography } from '@mui/material'; 2 | import AppLink from '../AppLink'; 3 | 4 | interface UserInfoProps { 5 | className?: string; 6 | showAvatar?: boolean; 7 | user?: any; 8 | } 9 | 10 | /** 11 | * Renders User info with Avatar 12 | * @component UserInfo 13 | * @param {string} [className] - optional className for
tag 14 | * @param {boolean} [showAvatar] - user's avatar picture is shown when true 15 | * @param {object} [user] - logged user data {name, email, avatar...} 16 | */ 17 | const UserInfo = ({ className, showAvatar = false, user, ...restOfProps }: UserInfoProps) => { 18 | const fullName = user?.name || [user?.nameFirst || '', user?.nameLast || ''].join(' ').trim(); 19 | const srcAvatar = user?.avatar ? user?.avatar : undefined; 20 | const userPhoneOrEmail = user?.phone || (user?.email as string); 21 | 22 | return ( 23 | 24 | {showAvatar ? ( 25 | 26 | 35 | 36 | ) : null} 37 | 38 | {fullName || 'Current User'} 39 | 40 | {userPhoneOrEmail || 'Loading...'} 41 | 42 | ); 43 | }; 44 | 45 | export default UserInfo; 46 | -------------------------------------------------------------------------------- /src/components/UserInfo/index.tsx: -------------------------------------------------------------------------------- 1 | import UserInfo from './UserInfo'; 2 | 3 | export { UserInfo as default, UserInfo }; 4 | -------------------------------------------------------------------------------- /src/components/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Components configuration 3 | */ 4 | export const CONTENT_MAX_WIDTH = 800; 5 | export const CONTENT_MIN_WIDTH = 320; // CONTENT_MAX_WIDTH - Sidebar width 6 | 7 | /** 8 | * AppAlert and AppSnackBarAlert components 9 | */ 10 | export const APP_ALERT_SEVERITY = 'error'; // 'error' | 'info'| 'success' | 'warning' 11 | export const APP_ALERT_VARIANT = 'filled'; // 'filled' | 'outlined' | 'standard' 12 | 13 | /** 14 | * AppButton component 15 | */ 16 | export const APP_BUTTON_VARIANT = 'contained'; // | 'text' | 'outlined' 17 | export const APP_BUTTON_MARGIN = 1; 18 | 19 | /** 20 | * AppIcon component 21 | */ 22 | export const APP_ICON_SIZE = 24; 23 | 24 | /** 25 | * AppLink component 26 | */ 27 | export const APP_LINK_COLOR = 'textSecondary'; // 'primary' // 'secondary' 28 | export const APP_LINK_UNDERLINE = 'hover'; // 'always 29 | 30 | /** 31 | * AppLoading component 32 | */ 33 | export const APP_LOADING_COLOR = 'primary'; // 'secondary' 34 | export const APP_LOADING_SIZE = '3rem'; // 40 35 | export const APP_LOADING_TYPE = 'circular'; // 'linear'; // 'circular' 36 | 37 | /** 38 | * AppSection component 39 | */ 40 | export const APP_SECTION_VARIANT = 'subtitle2'; // 'subtitle1' | 'body1' | 'h6' 41 | 42 | /** 43 | * AppSnackBar and AppSnackBarProvider components 44 | */ 45 | export const APP_SNACKBAR_MAX_COUNT = 5; // Used in AppSnackBarProvider from notistack npm 46 | export const APP_SNACKBAR_AUTO_HIDE_DURATION = 3000; // Set to null if want to disable AutoHide feature 47 | export const APP_SNACKBAR_ANCHOR_ORIGIN_VERTICAL = 'bottom'; // 'bottom | 'top' 48 | export const APP_SNACKBAR_ANCHOR_ORIGIN_HORIZONTAL = 'center'; // 'center' | 'left' | 'right' 49 | -------------------------------------------------------------------------------- /src/components/dialogs/CommonDialog.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactNode, SyntheticEvent, useCallback } from 'react'; 2 | import { Dialog, DialogActions, DialogContent, DialogProps } from '@mui/material'; 3 | import { AppButton } from '..'; 4 | import { AppDialogTitle } from './components'; 5 | import { ColorName } from '../../utils/style'; 6 | import { useDialogMinWidth } from './utils'; 7 | 8 | interface Props extends DialogProps { 9 | data?: unknown; 10 | title?: string; 11 | text?: string; 12 | body?: ReactNode; 13 | hideCancelButton?: boolean; 14 | confirmButtonText?: string; 15 | confirmButtonColor?: ColorName; 16 | onConfirm?: (data: unknown) => void; 17 | onClose?: (event: SyntheticEvent) => void; 18 | } 19 | 20 | /** 21 | * Shows generic "Common" dialog 22 | * @component CommonDialog 23 | * @param {function} props.onConfirm - event for Confirm button, called as onConfirm(data) 24 | * @param {function} props.onClose - event for Close and Cancel buttons and the backdrop 25 | */ 26 | const CommonDialog: FunctionComponent = ({ 27 | open = false, // Don't show dialog by default 28 | data, // optional data passed to onConfirm callback 29 | title = 'Missing title...', 30 | text = 'Text is missing...', 31 | body, // JSX to render instead of .text 32 | hideCancelButton = false, 33 | confirmButtonText = 'Confirm', 34 | confirmButtonColor = 'primary', 35 | onConfirm, 36 | onClose, 37 | ...restOfProps 38 | }) => { 39 | const paperMinWidth = useDialogMinWidth(); 40 | 41 | const handleOnConfirm = useCallback(() => { 42 | if (onConfirm && typeof onConfirm === 'function') { 43 | onConfirm(data); 44 | } 45 | }, [data, onConfirm]); 46 | 47 | return ( 48 | 59 | 60 | {title} 61 | 62 | {body || text} 63 | 64 | {!hideCancelButton && Cancel} 65 | 66 | {confirmButtonText} 67 | 68 | 69 | 70 | ); 71 | }; 72 | 73 | export default CommonDialog; 74 | -------------------------------------------------------------------------------- /src/components/dialogs/CompositionDialog.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactNode, SyntheticEvent } from 'react'; 2 | import { Box, Dialog, DialogActions, DialogContent, DialogProps } from '@mui/material'; 3 | import { AppDialogTitle } from './components'; 4 | import { useDialogMinWidth } from './utils'; 5 | 6 | interface Props extends DialogProps { 7 | title?: string; 8 | content?: ReactNode; 9 | actions?: ReactNode; 10 | onClose?: (event: SyntheticEvent) => void; 11 | } 12 | 13 | /** 14 | * Makes composition of Content and Actions inside the Dialog. 15 | * @component CompositionDialog 16 | */ 17 | const CompositionDialog: FunctionComponent = ({ 18 | actions, 19 | open = false, // Don't show dialog by default 20 | children = null, 21 | content = null, 22 | title = 'Missing title...', 23 | onClose, 24 | ...restOfProps 25 | }) => { 26 | const paperMinWidth = useDialogMinWidth(); 27 | 28 | return ( 29 | 40 | 41 | {title} 42 | 43 | 44 | {/* Box is temporary fix for https://github.com/mui-org/material-ui/issues/27851 */} 45 | {/* TODO: verify do we still need this fix */} 46 | 47 | {content} 48 | {children} 49 | 50 | 51 | {actions} 52 | 53 | ); 54 | }; 55 | 56 | export default CompositionDialog; 57 | -------------------------------------------------------------------------------- /src/components/dialogs/components/AppDialogTitle.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, SyntheticEvent } from 'react'; 2 | import { DialogTitle, DialogTitleProps, Typography, Stack, useTheme } from '@mui/material'; 3 | import { AppIconButton } from '../../'; 4 | 5 | interface Props extends DialogTitleProps { 6 | onClose?: (event: SyntheticEvent) => void; 7 | } 8 | 9 | /** 10 | * Renders Material UI Dialog Title with optional (x) button to close the dialog 11 | * @param {function} [onClose] - when set the (x) button added to Dialog Title and event called on button click 12 | */ 13 | const AppDialogTitle: FunctionComponent = ({ children, onClose, ...props }) => { 14 | const theme = useTheme(); 15 | return ( 16 | 17 | 18 | 26 | {children} 27 | 28 | 29 | {Boolean(onClose) ? ( 30 | 42 | ) : null} 43 | 44 | ); 45 | }; 46 | 47 | export default AppDialogTitle; 48 | -------------------------------------------------------------------------------- /src/components/dialogs/components/index.tsx: -------------------------------------------------------------------------------- 1 | import AppDialogTitle from './AppDialogTitle'; 2 | 3 | export { AppDialogTitle }; 4 | -------------------------------------------------------------------------------- /src/components/dialogs/index.tsx: -------------------------------------------------------------------------------- 1 | import CommonDialog from './CommonDialog'; 2 | import CompositionDialog from './CompositionDialog'; 3 | 4 | export { CommonDialog, CompositionDialog }; 5 | -------------------------------------------------------------------------------- /src/components/dialogs/utils.ts: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@mui/material'; 2 | import { useOnWideScreen } from '../../hooks/layout'; 3 | 4 | /** 5 | * Returns the width of the dialog's body based on the screen size 6 | * @returns {number} width of the dialog's body 7 | */ 8 | export function useDialogMinWidth() { 9 | const theme = useTheme(); 10 | const onWideScreen = useOnWideScreen(); 11 | const paperMinWidth = onWideScreen ? theme.breakpoints.values.md / 2 : theme.breakpoints.values.sm / 2; 12 | return paperMinWidth; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/index.tsx: -------------------------------------------------------------------------------- 1 | import AppAlert from './AppAlert'; 2 | import AppButton from './AppButton'; 3 | import AppForm from './AppForm'; 4 | import AppIcon from './AppIcon'; 5 | import AppIconButton from './AppIconButton'; 6 | import AppLink from './AppLink'; 7 | import AppLoading from './AppLoading'; 8 | import AppView from './AppView'; 9 | import ErrorBoundary from './ErrorBoundary'; 10 | 11 | export { ErrorBoundary, AppAlert, AppForm, AppButton, AppIcon, AppIconButton, AppLink, AppLoading, AppView }; 12 | -------------------------------------------------------------------------------- /src/hooks/auth.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { useAppStore } from '../store'; 4 | 5 | type CurrentUser = { 6 | id?: string; 7 | email?: string; 8 | phone?: string; 9 | avatar?: string; 10 | name?: string; 11 | }; 12 | 13 | /** 14 | * Hook to get currently logged user 15 | * @returns {object | undefined} user data as object or undefined if user is not logged in 16 | */ 17 | export function useCurrentUser(): CurrentUser | undefined { 18 | const [state] = useAppStore(); 19 | return state.currentUser; 20 | } 21 | 22 | /** 23 | * Hook to detect is current user authenticated or not 24 | * @returns {boolean} true if user is authenticated, false otherwise 25 | */ 26 | export function useIsAuthenticated() { 27 | const [state] = useAppStore(); 28 | let result = state.isAuthenticated; 29 | 30 | // TODO: AUTH: add access token verification or other authentication check here 31 | // result = Boolean(sessionStorageGet('access_token', '')); 32 | 33 | return result; 34 | } 35 | 36 | /** 37 | * Returns event handler to Logout current user 38 | * @returns {function} calling this event logs out current user 39 | */ 40 | export function useEventLogout() { 41 | const navigate = useNavigate(); 42 | const [, dispatch] = useAppStore(); 43 | 44 | return useCallback(() => { 45 | // TODO: AUTH: add auth and tokens cleanup here 46 | // sessionStorageDelete('access_token'); 47 | 48 | dispatch({ type: 'LOG_OUT' }); 49 | navigate('/', { replace: true }); // Redirect to home page by reloading the App 50 | }, [dispatch, navigate]); 51 | } 52 | 53 | /** 54 | * Adds watchdog and calls different callbacks on user login and logout 55 | * @param {function} afterLogin callback to call after user login 56 | * @param {function} afterLogout callback to call after user logout 57 | */ 58 | export function useAuthWatchdog(afterLogin: () => void, afterLogout: () => void) { 59 | const [state, dispatch] = useAppStore(); 60 | 61 | useEffect(() => { 62 | if (state.isAuthenticated) { 63 | afterLogin?.(); 64 | } else { 65 | afterLogout?.(); 66 | } 67 | }, [state.isAuthenticated, dispatch, afterLogin, afterLogout]); 68 | } 69 | -------------------------------------------------------------------------------- /src/hooks/authFirebase.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | import { useAppStore } from '../store'; 3 | import { User as FirebaseUser, getAuth, onAuthStateChanged, signOut } from 'firebase/auth'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | type CurrentUser = FirebaseUser | null | undefined; // Firebase User can be null, we also support undefined 7 | 8 | /** 9 | * Hook to get currently logged user 10 | * @returns {object | undefined} user data as object or undefined if user is not logged in 11 | */ 12 | export function useCurrentUser(): CurrentUser { 13 | let result; 14 | try { 15 | const auth = getAuth(); 16 | result = auth.currentUser; 17 | } catch (error) { 18 | // Do nothing 19 | } 20 | return result; 21 | } 22 | 23 | /** 24 | * Hook to detect is current user authenticated or not 25 | * @returns {boolean} true if user is authenticated, false otherwise 26 | */ 27 | export function useIsAuthenticated(): boolean { 28 | const currentUser = useCurrentUser(); 29 | return Boolean(currentUser); 30 | } 31 | 32 | /** 33 | * Returns event handler to Logout current user 34 | * @returns {function} calling this event logs out current user 35 | */ 36 | export function useEventLogout(): () => void { 37 | const navigate = useNavigate(); 38 | const [, dispatch] = useAppStore(); 39 | 40 | return useCallback(async () => { 41 | // TODO: AUTH: add auth and tokens cleanup here 42 | 43 | // Firebase sign out 44 | try { 45 | const auth = getAuth(); 46 | await signOut(auth); 47 | } catch (error) { 48 | console.error(error); 49 | } 50 | 51 | dispatch({ type: 'LOG_OUT' }); 52 | navigate('/', { replace: true }); // Redirect to home page by reloading the App 53 | }, [dispatch, navigate]); 54 | } 55 | 56 | /** 57 | * Adds Firebase Auth watchdog and calls different callbacks on login and logout 58 | * @param {function} afterLogin callback to call after user login 59 | * @param {function} afterLogout callback to call after user logout 60 | */ 61 | export function useAuthWatchdog(afterLogin: () => void, afterLogout: () => void) { 62 | const [, dispatch] = useAppStore(); 63 | 64 | useEffect(() => { 65 | const auth = getAuth(); 66 | onAuthStateChanged(auth, (firebaseUser) => { 67 | if (firebaseUser) { 68 | // Add Firebase User to AppStore 69 | console.warn('Firebase user is logged in - uid:', firebaseUser?.uid); 70 | dispatch({ type: 'LOG_IN', payload: firebaseUser }); 71 | // Call callback if any 72 | afterLogin?.(); 73 | } else { 74 | // Remove Firebase User from AppStore 75 | console.warn('Firebase user is logged out'); 76 | dispatch({ type: 'LOG_OUT' }); 77 | // Call callback if any 78 | afterLogout?.(); 79 | } 80 | }); 81 | }, [dispatch, afterLogin, afterLogout]); 82 | } 83 | -------------------------------------------------------------------------------- /src/hooks/event.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useAppStore } from '../store'; 3 | 4 | /** 5 | * Returns event handler to toggle Dark/Light modes 6 | * @returns {function} calling this event toggles dark/light mode 7 | */ 8 | export function useEventSwitchDarkMode() { 9 | const [state, dispatch] = useAppStore(); 10 | 11 | return useCallback(() => { 12 | dispatch({ 13 | type: 'DARK_MODE', 14 | payload: !state.darkMode, 15 | }); 16 | }, [state, dispatch]); 17 | } 18 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; // OR './authFirebase'; 2 | export * from './event'; 3 | export * from './layout'; 4 | -------------------------------------------------------------------------------- /src/hooks/layout.ts: -------------------------------------------------------------------------------- 1 | import { useMediaQuery, useTheme } from '@mui/material'; 2 | import { useCallback, useEffect, useState } from 'react'; 3 | 4 | /** 5 | * Hook to detect onMobile vs. onDesktop using "resize" event listener 6 | * @returns {boolean} true when on onMobile, false when on onDesktop 7 | */ 8 | export function useOnMobileByTrackingWindowsResize() { 9 | const theme = useTheme(); 10 | const [onMobile, setOnMobile] = useState(false); 11 | 12 | const handleResize = useCallback(() => { 13 | setOnMobile(window.innerWidth < theme.breakpoints.values.sm); // sx, sm are "onMobile" 14 | }, [theme.breakpoints.values.sm]); 15 | 16 | useEffect(() => { 17 | window.addEventListener('resize', handleResize); // Set resize listener 18 | 19 | return () => { 20 | window.removeEventListener('resize', handleResize); // Remove resize listener 21 | }; 22 | }, [handleResize]); 23 | 24 | return onMobile; 25 | } 26 | 27 | /** 28 | * Hook to detect onMobile vs. onDesktop using Media Query 29 | * @returns {boolean} true when on onMobile, false when on onDesktop 30 | */ 31 | export function useOnMobileByMediaQuery() { 32 | const theme = useTheme(); 33 | return useMediaQuery(theme.breakpoints.down('sm')); 34 | } 35 | 36 | // export const useOnMobile = useOnMobileByTrackingWindowsResize; 37 | export const useOnMobile = useOnMobileByMediaQuery; 38 | 39 | /** 40 | * Hook to detect Wide Screen (lg, xl) using Media Query 41 | * @returns {boolean} true when on screen is wide enough 42 | */ 43 | export function useOnWideScreen() { 44 | const theme = useTheme(); 45 | return useMediaQuery(theme.breakpoints.up('md')); 46 | } 47 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 4 | 'Droid Sans', 'Helvetica Neue', sans-serif; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | } 8 | 9 | code { 10 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 11 | } 12 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import MainApp from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /src/layout/BottomBar/BottomBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, FunctionComponent, useCallback } from 'react'; 2 | import { useLocation, useNavigate } from 'react-router'; 3 | import { BottomNavigation, BottomNavigationAction } from '@mui/material'; 4 | import AppIcon from '../../components/AppIcon'; 5 | import { LinkToPage } from '../../utils/type'; 6 | 7 | interface Props { 8 | items: Array; 9 | } 10 | 11 | /** 12 | * Renders horizontal Navigation Bar using MUI BottomNavigation component 13 | * @component BottomBar 14 | */ 15 | const BottomBar: FunctionComponent = ({ items }) => { 16 | const navigate = useNavigate(); 17 | const location = useLocation(); 18 | 19 | const onNavigationChange = useCallback( 20 | (event: ChangeEvent<{}>, newValue: string) => { 21 | navigate(newValue); 22 | }, 23 | [navigate] 24 | ); 25 | 26 | return ( 27 | 32 | {items.map(({ title, path, icon }) => ( 33 | } /> 34 | ))} 35 | 36 | ); 37 | }; 38 | 39 | export default BottomBar; 40 | -------------------------------------------------------------------------------- /src/layout/BottomBar/index.tsx: -------------------------------------------------------------------------------- 1 | import BottomBar from './BottomBar'; 2 | 3 | export { BottomBar as default, BottomBar }; 4 | -------------------------------------------------------------------------------- /src/layout/PrivateLayout.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, FunctionComponent, PropsWithChildren } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Stack } from '@mui/material'; 4 | import { AppIconButton, ErrorBoundary } from '../components'; 5 | import { LinkToPage } from '../utils/type'; 6 | import { useOnMobile } from '../hooks/layout'; 7 | import { 8 | SIDE_BAR_DESKTOP_ANCHOR, 9 | SIDE_BAR_MOBILE_ANCHOR, 10 | SIDE_BAR_WIDTH, 11 | TOP_BAR_DESKTOP_HEIGHT, 12 | TOP_BAR_MOBILE_HEIGHT, 13 | } from './config'; 14 | import TopBar from './TopBar'; 15 | import SideBar from './SideBar'; 16 | 17 | // TODO: change to your app name or other word 18 | const TITLE_PRIVATE = '_TITLE_ app'; // Title for pages after authentication 19 | 20 | /** 21 | * SideBar navigation items with links 22 | */ 23 | const SIDE_BAR_ITEMS: Array = [ 24 | { 25 | title: 'Home', 26 | path: '/', 27 | icon: 'home', 28 | }, 29 | { 30 | title: 'Profile (404)', 31 | path: '/user', 32 | icon: 'account', 33 | }, 34 | { 35 | title: 'About', 36 | path: '/about', 37 | icon: 'info', 38 | }, 39 | ]; 40 | 41 | if (process.env.REACT_APP_DEBUG === 'true') { 42 | SIDE_BAR_ITEMS.push({ 43 | title: '[Debug Tools]', 44 | path: '/dev', 45 | icon: 'settings', 46 | }); 47 | } 48 | 49 | /** 50 | * Renders "Private Layout" composition 51 | * @layout PrivateLayout 52 | */ 53 | const PrivateLayout: FunctionComponent = ({ children }) => { 54 | const navigation = useNavigate(); 55 | const [sideBarVisible, setSideBarVisible] = useState(false); 56 | const onMobile = useOnMobile(); 57 | 58 | // Variant 1 - Sidebar is static on desktop and is a drawer on mobile 59 | const sidebarOpen = onMobile ? sideBarVisible : true; 60 | const sidebarVariant = onMobile ? 'temporary' : 'persistent'; 61 | 62 | // Variant 2 - Sidebar is drawer on mobile and desktop 63 | // const sidebarOpen = sideBarVisible; 64 | // const sidebarVariant = 'temporary'; 65 | 66 | const title = TITLE_PRIVATE; 67 | document.title = title; // Also Update Tab Title 68 | 69 | const onLogoClick = useCallback(() => { 70 | // Navigate to first SideBar's item or to '/' when clicking on Logo/Menu icon when SideBar is already visible 71 | navigation(SIDE_BAR_ITEMS?.[0]?.path || '/'); 72 | }, [navigation]); 73 | 74 | const onSideBarOpen = () => { 75 | if (!sideBarVisible) setSideBarVisible(true); // Don't re-render Layout when SideBar is already open 76 | }; 77 | 78 | const onSideBarClose = () => { 79 | if (sideBarVisible) setSideBarVisible(false); // Don't re-render Layout when SideBar is already closed 80 | }; 81 | 82 | // console.log( 83 | // 'Render using PrivateLayout, onMobile:', 84 | // onMobile, 85 | // 'sidebarOpen:', 86 | // sidebarOpen, 87 | // 'sidebarVariant:', 88 | // sidebarVariant 89 | // ); 90 | 91 | return ( 92 | 101 | 102 | } 104 | title={title} 105 | /> 106 | 107 | 114 | 115 | 116 | 125 | {children} 126 | 127 | 128 | ); 129 | }; 130 | 131 | export default PrivateLayout; 132 | -------------------------------------------------------------------------------- /src/layout/PublicLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, PropsWithChildren, useCallback, useState } from 'react'; 2 | import { Stack } from '@mui/material/'; 3 | import { useAppStore } from '../store/AppStore'; 4 | import { ErrorBoundary, AppIconButton } from '../components'; 5 | import { LinkToPage } from '../utils/type'; 6 | import { useOnMobile } from '../hooks/layout'; 7 | import { BOTTOM_BAR_DESKTOP_VISIBLE, TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config'; 8 | import { useEventSwitchDarkMode } from '../hooks/event'; 9 | import TopBar from './TopBar'; 10 | import SideBar from './SideBar'; 11 | import BottomBar from './BottomBar'; 12 | 13 | // TODO: change to your app name or other word 14 | const TITLE_PUBLIC = '_TITLE_ app'; // Title for pages without/before authentication 15 | 16 | /** 17 | * SideBar navigation items with links 18 | */ 19 | const SIDE_BAR_ITEMS: Array = [ 20 | { 21 | title: 'Log In', 22 | path: '/auth/login', 23 | icon: 'login', 24 | }, 25 | { 26 | title: 'Sign Up', 27 | path: '/auth/signup', 28 | icon: 'signup', 29 | }, 30 | { 31 | title: 'About', 32 | path: '/about', 33 | icon: 'info', 34 | }, 35 | ]; 36 | 37 | if (process.env.REACT_APP_DEBUG === 'true') { 38 | SIDE_BAR_ITEMS.push({ 39 | title: '[Debug Tools]', 40 | path: '/dev', 41 | icon: 'settings', 42 | }); 43 | } 44 | 45 | /** 46 | * BottomBar navigation items with links 47 | */ 48 | const BOTTOM_BAR_ITEMS: Array = [ 49 | { 50 | title: 'Log In', 51 | path: '/auth/login', 52 | icon: 'login', 53 | }, 54 | { 55 | title: 'Sign Up', 56 | path: '/auth/signup', 57 | icon: 'signup', 58 | }, 59 | { 60 | title: 'About', 61 | path: '/about', 62 | icon: 'info', 63 | }, 64 | ]; 65 | 66 | /** 67 | * Renders "Public Layout" composition 68 | * @layout PublicLayout 69 | */ 70 | const PublicLayout: FunctionComponent = ({ children }) => { 71 | const onMobile = useOnMobile(); 72 | const onSwitchDarkMode = useEventSwitchDarkMode(); 73 | const [sideBarVisible, setSideBarVisible] = useState(false); 74 | const [state] = useAppStore(); 75 | const bottomBarVisible = onMobile || BOTTOM_BAR_DESKTOP_VISIBLE; 76 | 77 | // Variant 1 - Sidebar is static on desktop and is a drawer on mobile 78 | // const sidebarOpen = onMobile ? sideBarVisible : true; 79 | // const sidebarVariant = onMobile ? 'temporary' : 'persistent'; 80 | 81 | // Variant 2 - Sidebar is drawer on mobile and desktop 82 | const sidebarOpen = sideBarVisible; 83 | const sidebarVariant = 'temporary'; 84 | 85 | const title = TITLE_PUBLIC; 86 | document.title = title; // Also Update Tab Title 87 | 88 | const onSideBarOpen = useCallback(() => { 89 | if (!sideBarVisible) setSideBarVisible(true); // Don't re-render Layout when SideBar is already open 90 | }, [sideBarVisible]); 91 | 92 | const onSideBarClose = useCallback(() => { 93 | if (sideBarVisible) setSideBarVisible(false); // Don't re-render Layout when SideBar is already closed 94 | }, [sideBarVisible]); 95 | 96 | // console.log( 97 | // 'Render using PublicLayout, onMobile:', 98 | // onMobile, 99 | // 'sidebarOpen:', 100 | // sidebarOpen, 101 | // 'sidebarVariant:', 102 | // sidebarVariant 103 | // ); 104 | 105 | return ( 106 | 112 | 113 | } 115 | title={title} 116 | endNode={ 117 | 123 | } 124 | /> 125 | 126 | 133 | 134 | 135 | 142 | {children} 143 | 144 | 145 | {bottomBarVisible && } 146 | 147 | ); 148 | }; 149 | 150 | export default PublicLayout; 151 | -------------------------------------------------------------------------------- /src/layout/SideBar/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, useCallback, MouseEvent } from 'react'; 2 | import { Stack, Divider, Drawer, DrawerProps, FormControlLabel, Switch, Tooltip } from '@mui/material'; 3 | import { AppIconButton } from '../../components'; 4 | import { useAppStore } from '../../store/AppStore'; 5 | import { LinkToPage } from '../../utils/type'; 6 | import { useEventLogout, useEventSwitchDarkMode, useIsAuthenticated, useOnMobile } from '../../hooks'; 7 | import SideBarNavList from './SideBarNavList'; 8 | import { SIDE_BAR_WIDTH, TOP_BAR_DESKTOP_HEIGHT } from '../config'; 9 | import UserInfo from '../../components/UserInfo'; 10 | 11 | interface Props extends Pick { 12 | items: Array; 13 | } 14 | 15 | /** 16 | * Renders SideBar with Menu and User details 17 | * Actually for Authenticated users only, rendered in "Private Layout" 18 | * @component SideBar 19 | * @param {string} anchor - 'left' or 'right' 20 | * @param {boolean} open - the Drawer is visible when true 21 | * @param {string} variant - variant of the Drawer, one of 'permanent', 'persistent', 'temporary' 22 | * @param {function} onClose - called when the Drawer is closing 23 | */ 24 | const SideBar: FunctionComponent = ({ anchor, open, variant, items, onClose, ...restOfProps }) => { 25 | const [state] = useAppStore(); 26 | // const isAuthenticated = state.isAuthenticated; // Variant 1 27 | const isAuthenticated = useIsAuthenticated(); // Variant 2 28 | const onMobile = useOnMobile(); 29 | 30 | const onSwitchDarkMode = useEventSwitchDarkMode(); 31 | const onLogout = useEventLogout(); 32 | 33 | const handleAfterLinkClick = useCallback( 34 | (event: MouseEvent) => { 35 | if (variant === 'temporary' && typeof onClose === 'function') { 36 | onClose(event, 'backdropClick'); 37 | } 38 | }, 39 | [variant, onClose] 40 | ); 41 | 42 | return ( 43 | 56 | 64 | {isAuthenticated && ( 65 | <> 66 | 67 | 68 | 69 | )} 70 | 71 | 72 | 73 | 74 | 75 | 84 | 85 | } 88 | /> 89 | 90 | 91 | {isAuthenticated && } 92 | 93 | 94 | 95 | ); 96 | }; 97 | 98 | export default SideBar; 99 | -------------------------------------------------------------------------------- /src/layout/SideBar/SideBarNavItem.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, MouseEventHandler } from 'react'; 2 | import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; 3 | import { AppIcon, AppLink } from '../../components'; 4 | import { LinkToPage } from '../../utils/type'; 5 | import { useLocation } from 'react-router'; 6 | 7 | interface Props extends LinkToPage { 8 | openInNewTab?: boolean; 9 | selected?: boolean; 10 | onClick?: MouseEventHandler; 11 | } 12 | 13 | /** 14 | * Renders Navigation Item for SideBar, detects current url and sets selected state if needed 15 | * @component SideBarNavItem 16 | */ 17 | const SideBarNavItem: FunctionComponent = ({ 18 | openInNewTab, 19 | icon, 20 | path, 21 | selected: propSelected = false, 22 | subtitle, 23 | title, 24 | onClick, 25 | }) => { 26 | const location = useLocation(); 27 | const selected = propSelected || (path && path.length > 1 && location.pathname.startsWith(path)) || false; 28 | 29 | return ( 30 | 38 | {icon && } 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default SideBarNavItem; 45 | -------------------------------------------------------------------------------- /src/layout/SideBar/SideBarNavList.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, MouseEventHandler } from 'react'; 2 | import List from '@mui/material/List'; 3 | import SideBarNavItem from './SideBarNavItem'; 4 | import { LinkToPage } from '../../utils/type'; 5 | 6 | interface Props { 7 | items: Array; 8 | showIcons?: boolean; 9 | onClick?: MouseEventHandler; 10 | } 11 | 12 | /** 13 | * Renders list of Navigation Items inside SideBar 14 | * @component SideBarNavList 15 | * @param {array} items - list of objects to render as navigation items 16 | * @param {boolean} [showIcons] - icons in navigation items are visible when true 17 | * @param {function} [onAfterLinkClick] - optional callback called when some navigation item was clicked 18 | */ 19 | const SideBarNavList: FunctionComponent = ({ items, showIcons, onClick, ...restOfProps }) => { 20 | return ( 21 | 22 | {items.map(({ icon, path, title }) => ( 23 | 30 | ))} 31 | 32 | ); 33 | }; 34 | 35 | export default SideBarNavList; 36 | -------------------------------------------------------------------------------- /src/layout/SideBar/index.tsx: -------------------------------------------------------------------------------- 1 | import SideBar from './SideBar'; 2 | 3 | export { SideBar as default, SideBar }; 4 | -------------------------------------------------------------------------------- /src/layout/TopBar/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, Toolbar, Typography } from '@mui/material'; 2 | import { FunctionComponent, ReactNode } from 'react'; 3 | 4 | interface Props { 5 | endNode?: ReactNode; 6 | startNode?: ReactNode; 7 | title?: string; 8 | } 9 | 10 | /** 11 | * Renders TopBar composition 12 | * @component TopBar 13 | */ 14 | const TopBar: FunctionComponent = ({ endNode, startNode, title = '', ...restOfProps }) => { 15 | return ( 16 | 25 | 26 | {startNode} 27 | 28 | 37 | {title} 38 | 39 | 40 | {endNode} 41 | 42 | 43 | ); 44 | }; 45 | 46 | export default TopBar; 47 | -------------------------------------------------------------------------------- /src/layout/TopBar/index.tsx: -------------------------------------------------------------------------------- 1 | import TopBar from './TopBar'; 2 | 3 | export { TopBar as default, TopBar }; 4 | -------------------------------------------------------------------------------- /src/layout/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Layout configuration 3 | */ 4 | 5 | /** 6 | * SideBar configuration 7 | */ 8 | export const SIDE_BAR_MOBILE_ANCHOR = 'right'; // 'right'; 9 | export const SIDE_BAR_DESKTOP_ANCHOR = 'left'; // 'right'; 10 | export const SIDE_BAR_WIDTH = '240px'; 11 | 12 | /** 13 | * TopBar configuration 14 | */ 15 | export const TOP_BAR_MOBILE_HEIGHT = '56px'; 16 | export const TOP_BAR_DESKTOP_HEIGHT = '64px'; 17 | 18 | /** 19 | * BottomBar configuration 20 | */ 21 | export const BOTTOM_BAR_DESKTOP_VISIBLE = false; // true; 22 | -------------------------------------------------------------------------------- /src/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import PrivateLayout from './PrivateLayout'; 2 | import PublicLayout from './PublicLayout'; 3 | 4 | export { PublicLayout as default, PublicLayout, PrivateLayout }; 5 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/routes/PrivateRoutes.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, Route, Routes } from 'react-router-dom'; 2 | import { PrivateLayout } from '../layout'; 3 | import { NotFoundView } from '../views'; 4 | import AboutView from '../views/About'; 5 | import DevView from '../views/Dev'; 6 | import WelcomeView from '../views/Welcome'; 7 | 8 | /** 9 | * List of routes available for authenticated users 10 | * Also renders the "Private Layout" composition 11 | * @routes PrivateRoutes 12 | */ 13 | const PrivateRoutes = () => { 14 | return ( 15 | 16 | 17 | } /> 18 | } 22 | /> 23 | } /> 24 | {process.env.REACT_APP_DEBUG && } />} 25 | } /> 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default PrivateRoutes; 32 | -------------------------------------------------------------------------------- /src/routes/PublicRoutes.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | import { PublicLayout } from '../layout'; 3 | import { NotFoundView } from '../views'; 4 | import AboutView from '../views/About'; 5 | import DevView from '../views/Dev'; 6 | import LoginEmailView from '../views/Auth/Login/LoginEmailView'; 7 | import AuthRoutes from '../views/Auth'; 8 | 9 | /** 10 | * List of routes available for anonymous users 11 | * Also renders the "Public Layout" composition 12 | * @routes PublicRoutes 13 | */ 14 | const PublicRoutes = () => { 15 | return ( 16 | 17 | 18 | } /> 19 | } /> 20 | } /> 21 | {process.env.REACT_APP_DEBUG === 'true' && } />} 22 | } /> 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default PublicRoutes; 29 | -------------------------------------------------------------------------------- /src/routes/Routes.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import { AppLoading } from '../components'; 4 | import { useAuthWatchdog, useIsAuthenticated } from '../hooks'; 5 | import PublicRoutes from './PublicRoutes'; 6 | import PrivateRoutes from './PrivateRoutes'; 7 | 8 | /** 9 | * Renders routes depending on Authenticated or Anonymous users 10 | * @component Routes 11 | */ 12 | const Routes = () => { 13 | const [loading, setLoading] = useState(true); 14 | const [refresh, setRefresh] = useState(0); 15 | const isAuthenticated = useIsAuthenticated(); 16 | 17 | const afterLogin = useCallback(() => { 18 | setRefresh((old) => old + 1); // Force re-render 19 | setLoading(false); 20 | }, []); 21 | 22 | const afterLogout = useCallback(() => { 23 | setRefresh((old) => old + 1); // Force re-render 24 | setLoading(false); 25 | }, []); 26 | 27 | // Create Auth watchdog, that calls our callbacks wen user is logged in or logged out 28 | useAuthWatchdog(afterLogin, afterLogout); 29 | 30 | if (loading) { 31 | return ; 32 | } 33 | 34 | console.log(`Routes() - isAuthenticated: ${isAuthenticated}, refreshCount: ${refresh}`); 35 | return ( 36 | {isAuthenticated ? : } 37 | ); 38 | }; 39 | export default Routes; 40 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import Routes from './Routes'; 2 | 3 | export { Routes as default, Routes }; 4 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/store/AppReducer.ts: -------------------------------------------------------------------------------- 1 | import { localStorageSet } from '../utils/localStorage'; 2 | import { AppStoreState } from './AppStore'; 3 | 4 | /** 5 | * Reducer for global AppStore using "Redux styled" actions 6 | * @param {object} state - current/default state 7 | * @param {string} action.type - unique name of the action 8 | * @param {*} [action.payload] - optional data object or the function to get data object 9 | */ 10 | const AppReducer: React.Reducer = (state, action) => { 11 | // console.log('AppReducer() - action:', action); 12 | switch (action.type || action.action) { 13 | case 'CURRENT_USER': 14 | return { 15 | ...state, 16 | currentUser: action?.currentUser || action?.payload, 17 | }; 18 | case 'SIGN_UP': 19 | case 'LOG_IN': 20 | return { 21 | ...state, 22 | isAuthenticated: true, 23 | }; 24 | case 'LOG_OUT': 25 | return { 26 | ...state, 27 | isAuthenticated: false, 28 | currentUser: undefined, // Also reset previous user data 29 | }; 30 | case 'DARK_MODE': { 31 | const darkMode = action?.darkMode ?? action?.payload; 32 | localStorageSet('darkMode', darkMode); 33 | return { 34 | ...state, 35 | darkMode, 36 | }; 37 | } 38 | default: 39 | return state; 40 | } 41 | }; 42 | 43 | export default AppReducer; 44 | -------------------------------------------------------------------------------- /src/store/AppStore.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useReducer, 4 | useContext, 5 | FunctionComponent, 6 | Dispatch, 7 | ComponentType, 8 | PropsWithChildren, 9 | } from 'react'; 10 | import useMediaQuery from '@mui/material/useMediaQuery'; 11 | import AppReducer from './AppReducer'; 12 | import { localStorageGet } from '../utils/localStorage'; 13 | 14 | /** 15 | * AppState structure and initial values 16 | */ 17 | export interface AppStoreState { 18 | darkMode: boolean; 19 | isAuthenticated: boolean; 20 | currentUser?: object | undefined; 21 | } 22 | const INITIAL_APP_STATE: AppStoreState = { 23 | darkMode: false, // Overridden by useMediaQuery('(prefers-color-scheme: dark)') in AppStore 24 | isAuthenticated: false, // Overridden in AppStore by checking auth token 25 | }; 26 | 27 | /** 28 | * Instance of React Context for global AppStore 29 | */ 30 | type AppContextReturningType = [AppStoreState, Dispatch]; 31 | const AppContext = createContext([INITIAL_APP_STATE, () => null]); 32 | 33 | /** 34 | * Main global Store as HOC with React Context API 35 | * 36 | * import {AppStoreProvider} from './store' 37 | * ... 38 | * 39 | * 40 | * 41 | */ 42 | const AppStoreProvider: FunctionComponent = ({ children }) => { 43 | const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); 44 | const previousDarkMode = Boolean(localStorageGet('darkMode')); 45 | // const tokenExists = Boolean(loadToken()); 46 | 47 | const initialState: AppStoreState = { 48 | ...INITIAL_APP_STATE, 49 | darkMode: previousDarkMode || prefersDarkMode, 50 | // isAuthenticated: tokenExists, 51 | }; 52 | const value: AppContextReturningType = useReducer(AppReducer, initialState); 53 | 54 | return {children}; 55 | }; 56 | 57 | /** 58 | * Hook to use the AppStore in functional components 59 | * 60 | * import {useAppStore} from './store' 61 | * ... 62 | * const [state, dispatch] = useAppStore(); 63 | */ 64 | const useAppStore = (): AppContextReturningType => useContext(AppContext); 65 | 66 | /** 67 | * HOC to inject the ApStore to class component, also works for functional components 68 | * 69 | * import {withAppStore} from './store' 70 | * ... 71 | * class MyComponent 72 | * ... 73 | * export default withAppStore(MyComponent) 74 | */ 75 | interface WithAppStoreProps { 76 | store: object; 77 | } 78 | const withAppStore = 79 | (Component: ComponentType): FunctionComponent => 80 | (props) => { 81 | return ; 82 | }; 83 | 84 | export { AppStoreProvider as AppStore, AppContext, useAppStore, withAppStore }; 85 | -------------------------------------------------------------------------------- /src/store/index.tsx: -------------------------------------------------------------------------------- 1 | import { AppStore, AppContext, useAppStore, withAppStore } from './AppStore'; 2 | 3 | export { AppStore as default, AppStore, AppContext, useAppStore, withAppStore }; 4 | -------------------------------------------------------------------------------- /src/theme/AppThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, useMemo, PropsWithChildren } from 'react'; 2 | import { CacheProvider, EmotionCache } from '@emotion/react'; 3 | import { createTheme, CssBaseline, ThemeProvider } from '@mui/material'; 4 | import { useAppStore } from '../store'; 5 | import DARK_THEME from './dark'; 6 | import LIGHT_THEME from './light'; 7 | import createEmotionCache from './createEmotionCache'; 8 | 9 | function getThemeByDarkMode(darkMode: boolean) { 10 | return darkMode ? createTheme(DARK_THEME) : createTheme(LIGHT_THEME); 11 | } 12 | 13 | // Client-side cache, shared for the whole session of the user in the browser. 14 | const CLIENT_SIDE_EMOTION_CACHE = createEmotionCache(); 15 | 16 | interface Props extends PropsWithChildren { 17 | emotionCache?: EmotionCache; // You can omit it if you don't want to use Emotion styling library 18 | } 19 | 20 | /** 21 | * Renders composition of Emotion's CacheProvider + MUI's ThemeProvider to wrap content of entire App 22 | * The Light or Dark themes applied depending on global .darkMode state 23 | * @param {EmotionCache} [emotionCache] - shared Emotion's cache to use in the App 24 | */ 25 | const AppThemeProvider: FunctionComponent = ({ children, emotionCache = CLIENT_SIDE_EMOTION_CACHE }) => { 26 | const [state] = useAppStore(); 27 | 28 | const theme = useMemo( 29 | () => getThemeByDarkMode(state.darkMode), 30 | [state.darkMode] // Observe AppStore and re-create the theme when .darkMode changes 31 | ); 32 | 33 | return ( 34 | 35 | {/* use this instead of Emotion's if you want to use alternate styling library */} 36 | 37 | 38 | {children} 39 | 40 | {/* */} 41 | 42 | ); 43 | }; 44 | 45 | export default AppThemeProvider; 46 | -------------------------------------------------------------------------------- /src/theme/colors.ts: -------------------------------------------------------------------------------- 1 | import { PaletteOptions, SimplePaletteColorOptions } from '@mui/material'; 2 | 3 | const COLOR_PRIMARY: SimplePaletteColorOptions = { 4 | main: '#64B5F6', 5 | contrastText: '#000000', 6 | // light: '#64B5F6', 7 | // dark: '#64B5F6', 8 | }; 9 | 10 | const COLOR_SECONDARY: SimplePaletteColorOptions = { 11 | main: '#EF9A9A', 12 | contrastText: '#000000', 13 | // light: '#EF9A9A', 14 | // dark: '#EF9A9A', 15 | }; 16 | 17 | /** 18 | * MUI colors set to use in theme.palette 19 | */ 20 | export const PALETTE_COLORS: Partial = { 21 | primary: COLOR_PRIMARY, 22 | secondary: COLOR_SECONDARY, 23 | // error: COLOR_ERROR, 24 | // warning: COLOR_WARNING; 25 | // info: COLOR_INFO; 26 | // success: COLOR_SUCCESS; 27 | }; 28 | -------------------------------------------------------------------------------- /src/theme/createEmotionCache.ts: -------------------------------------------------------------------------------- 1 | import createCache, { EmotionCache } from '@emotion/cache'; 2 | 3 | /** 4 | * Creates an emotion cache with .prepend option set to true. 5 | * This moves MUI styles to the top of the so they're loaded first. 6 | * It allows overriding MUI styles with other styling solutions, like CSS modules. 7 | * @returns {EmotionCache} 8 | */ 9 | export function createEmotionCache(): EmotionCache { 10 | return createCache({ key: 'css', prepend: true }); 11 | } 12 | 13 | export default createEmotionCache; 14 | -------------------------------------------------------------------------------- /src/theme/dark.ts: -------------------------------------------------------------------------------- 1 | import { ThemeOptions } from '@mui/material'; 2 | import { PALETTE_COLORS } from './colors'; 3 | 4 | /** 5 | * MUI theme options for "Dark Mode" 6 | */ 7 | export const DARK_THEME: ThemeOptions = { 8 | palette: { 9 | mode: 'dark', 10 | // background: { 11 | // paper: '#424242', // Gray 800 - Background of "Paper" based component 12 | // default: '#121212', 13 | // }, 14 | ...PALETTE_COLORS, 15 | }, 16 | }; 17 | 18 | export default DARK_THEME; 19 | -------------------------------------------------------------------------------- /src/theme/index.ts: -------------------------------------------------------------------------------- 1 | import AppThemeProvider from './AppThemeProvider'; 2 | import DARK_THEME from './dark'; 3 | import LIGHT_THEME from './light'; 4 | 5 | export * from './createEmotionCache'; 6 | export { 7 | LIGHT_THEME as default, // Change to DARK_THEME if you want to use dark theme as default 8 | DARK_THEME, 9 | LIGHT_THEME, 10 | AppThemeProvider, 11 | }; 12 | -------------------------------------------------------------------------------- /src/theme/light.ts: -------------------------------------------------------------------------------- 1 | import { ThemeOptions } from '@mui/material'; 2 | import { PALETTE_COLORS } from './colors'; 3 | 4 | /** 5 | * MUI theme options for "Light Mode" 6 | */ 7 | export const LIGHT_THEME: ThemeOptions = { 8 | palette: { 9 | mode: 'light', 10 | // background: { 11 | // paper: '#f5f5f5', // Gray 100 - Background of "Paper" based component 12 | // default: '#FFFFFF', 13 | // }, 14 | ...PALETTE_COLORS, 15 | }, 16 | }; 17 | 18 | export default LIGHT_THEME; 19 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | 3 | export const FORMAT_DATE_TIME = 'yyyy-MM-dd HH:mm:ss'; 4 | export const FORMAT_DATE_ONLY = 'yyyy-MM-dd'; 5 | export const FORMAT_TIME_ONLY = 'HH:mm:ss'; 6 | 7 | /** 8 | * Main Data and Time conversion utility to keep formats the same across entire Application 9 | * @param {string|object} dateOrString - date to show as UTC string or Date object instance 10 | * @param {string} [dateFormat] - time conversion template in 'date-fns' format, `FORMAT_DATE_TIME` by default 11 | * @param {string} [fallbackValue] - optional fallback value if data conversion is not possible 12 | */ 13 | export function dateToString(dateOrString: string | Date, dateFormat = FORMAT_DATE_TIME, fallbackValue = ''): string { 14 | const date = typeof dateOrString === 'object' ? dateOrString : new Date(dateOrString); 15 | let result; 16 | try { 17 | result = format(date, dateFormat); 18 | } catch (error) { 19 | result = fallbackValue; 20 | } 21 | return result; 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/environment.ts: -------------------------------------------------------------------------------- 1 | export const IS_SERVER = typeof window === 'undefined'; 2 | export const IS_BROWSER = typeof window !== 'undefined' && typeof window?.document !== 'undefined'; 3 | /* eslint-disable no-restricted-globals */ 4 | export const IS_WEBWORKER = 5 | typeof self === 'object' && self.constructor && self.constructor.name === 'DedicatedWorkerGlobalScope'; 6 | /* eslint-enable no-restricted-globals */ 7 | 8 | export function getCurrentVersion(): string { 9 | return process.env?.npm_package_version ?? process.env.REACT_APP_VERSION ?? 'unknown'; 10 | } 11 | 12 | export function getCurrentEnvironment(): string { 13 | return process.env?.NODE_ENV ?? 'development'; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/form.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, SyntheticEvent, ChangeEvent } from 'react'; 2 | import validate from 'validate.js'; 3 | import { ObjectPropByName } from './type'; 4 | 5 | // Same props to style Input, TextField, and so on across the Application 6 | export const SHARED_CONTROL_PROPS = { 7 | variant: 'outlined', 8 | margin: 'normal', // 'dense', 'none' 9 | fullWidth: true, 10 | } as const; 11 | 12 | // "Schema" for formState 13 | interface FormState { 14 | values: object; // List of Input Values as string|boolean 15 | touched?: object; // List of Inputs have been touched as boolean 16 | errors?: object; // List of Errors for every field as array[] of strings 17 | } 18 | 19 | /** 20 | * Basic object to use as initial value for formState 21 | * Usage: const [formState, setFormState] = useState(DEFAULT_FORM_STATE); 22 | */ 23 | export const DEFAULT_FORM_STATE: FormState = { 24 | values: {}, 25 | touched: {}, 26 | errors: {}, 27 | }; 28 | 29 | /** 30 | * Reusable event to cancel the default behavior 31 | */ 32 | export const eventPreventDefault = (event: SyntheticEvent) => { 33 | event.preventDefault(); 34 | }; 35 | 36 | /** 37 | * Verifies does the From field with given Name has the Error 38 | */ 39 | export const formHasError = (formState: FormState, fieldName: string): boolean => { 40 | return Boolean( 41 | (formState.touched as ObjectPropByName)[fieldName] && (formState.errors as ObjectPropByName)[fieldName] 42 | ); 43 | }; 44 | 45 | /** 46 | * Returns text of "top most" Error for the Form field by given Name. 47 | * Returns null if there is no Error. 48 | */ 49 | export const formGetError = (formState: FormState, fieldName: string): string => { 50 | return formHasError(formState, fieldName) ? (formState.errors as ObjectPropByName)[fieldName]?.[0] : null; 51 | }; 52 | 53 | // Params for useAppForm() hook 54 | interface UseAppFormParams { 55 | validationSchema: object; 56 | initialValues: object; 57 | } 58 | 59 | // Return type for useAppForm() hook 60 | 61 | interface UseAppFormReturn { 62 | formState: FormState; 63 | setFormState: (formState: FormState) => void; 64 | onFieldChange: (event: ChangeEvent) => void; 65 | fieldGetError: (fieldName: string) => string; 66 | fieldHasError: (fieldName: string) => boolean; 67 | isFormValid: () => boolean; 68 | isFormTouched: () => boolean; 69 | } 70 | 71 | /** 72 | * Application "standard" From as Hook 73 | * Note: the "name" prop of all Form controls must be set! We use event.target?.name for binding data. 74 | * Usage: const [formState, setFormState, onFieldChange, fieldGetError, fieldHasError] = useAppForm({ 75 | validationSchema: XXX_FORM_SCHEMA, 76 | initialValues: {name: 'John Doe'}, 77 | }); 78 | * @param {object} options.validationSchema - validation schema in 'validate.js' format 79 | * @param {object} [options.initialValues] - optional initialization data for formState.values 80 | */ 81 | export function useAppForm({ validationSchema, initialValues = {} }: UseAppFormParams): UseAppFormReturn { 82 | // Validate params 83 | if (!validationSchema) { 84 | throw new Error('useAppForm() - the option `validationSchema` is required'); 85 | } 86 | if (typeof validationSchema !== 'object') { 87 | throw new Error('useAppForm() - the option `validationSchema` should be an object'); 88 | } 89 | if (typeof initialValues !== 'object') { 90 | throw new Error('useAppForm() - the option `initialValues` should be an object'); 91 | } 92 | 93 | // Create Form state and apply initialValues if set 94 | const [formState, setFormState] = useState({ ...DEFAULT_FORM_STATE, values: initialValues }); 95 | 96 | // Validation by 'validate.js' on every formState.values change 97 | useEffect(() => { 98 | const errors = validate(formState.values, validationSchema); 99 | setFormState((currentFormState) => ({ 100 | ...currentFormState, 101 | errors: errors || {}, 102 | })); 103 | }, [validationSchema, formState.values]); 104 | 105 | // Event to call on every Input change. Note: the "name" props of the Input control must be set! 106 | const onFieldChange = useCallback((event: ChangeEvent) => { 107 | const name = event.target?.name; 108 | const value = 109 | event.target?.type === 'checkbox' 110 | ? event.target?.checked // Checkbox Input 111 | : event.target?.value; // Any other Input 112 | 113 | setFormState((formState) => ({ 114 | ...formState, 115 | values: { 116 | ...formState.values, 117 | [name]: value, 118 | }, 119 | touched: { 120 | ...formState.touched, 121 | [name]: true, 122 | }, 123 | })); 124 | }, []); 125 | 126 | // Returns text of "top most" Error for the Field by given Name or null 127 | const fieldGetError = (fieldName: string): string => formGetError(formState, fieldName); 128 | 129 | // Verifies does the Field with given Name has the Error 130 | const fieldHasError = (fieldName: string): boolean => formHasError(formState, fieldName); 131 | 132 | // Verifies does form has any error 133 | const isFormValid = () => Object.keys(formState?.errors ?? {}).length < 1; 134 | 135 | // Verifies does any of the form fields has been touched 136 | const isFormTouched = () => Object.keys(formState?.touched ?? {}).length > 0; 137 | 138 | // Return state and methods 139 | return { 140 | formState, 141 | isFormValid, 142 | isFormTouched, 143 | onFieldChange, 144 | fieldGetError, 145 | fieldHasError, 146 | setFormState, 147 | }; 148 | } 149 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './environment'; 2 | export * from './date'; 3 | export * from './form'; 4 | export * from './localStorage'; 5 | export * from './navigation'; 6 | export * from './sessionStorage'; 7 | export * from './path'; 8 | export * from './sleep'; 9 | export * from './type'; 10 | export * from './text'; 11 | -------------------------------------------------------------------------------- /src/utils/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { IS_SERVER } from './environment'; 2 | 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | 5 | /** 6 | * Smartly reads value from localStorage 7 | */ 8 | export function localStorageGet(name: string, defaultValue: any = ''): any { 9 | if (IS_SERVER) { 10 | return defaultValue; // We don't have access to localStorage on the server 11 | } 12 | 13 | const valueFromStore = localStorage.getItem(name); 14 | if (valueFromStore === null) return defaultValue; // No value in store, return default one 15 | 16 | try { 17 | const jsonParsed: unknown = JSON.parse(valueFromStore); 18 | if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) { 19 | return jsonParsed; // We successfully parse JS value from the store 20 | } 21 | } catch (error) { 22 | // Do nothing 23 | } 24 | 25 | return valueFromStore; // Return string value as it is 26 | } 27 | 28 | /** 29 | * Smartly writes value into localStorage 30 | */ 31 | export function localStorageSet(name: string, value: any) { 32 | if (IS_SERVER) { 33 | return; // Do nothing on server side 34 | } 35 | if (typeof value === 'undefined') { 36 | return; // Do not store undefined values 37 | } 38 | let valueAsString: string; 39 | if (typeof value === 'object') { 40 | valueAsString = JSON.stringify(value); 41 | } else { 42 | valueAsString = String(value); 43 | } 44 | 45 | localStorage.setItem(name, valueAsString); 46 | } 47 | 48 | /* eslint-enable @typescript-eslint/no-explicit-any */ 49 | 50 | /** 51 | * Deletes value by name from localStorage, if specified name is empty entire localStorage is cleared. 52 | */ 53 | export function localStorageDelete(name: string) { 54 | if (IS_SERVER) { 55 | return; // Do nothing on server side 56 | } 57 | if (name) { 58 | localStorage.removeItem(name); 59 | } else { 60 | localStorage.clear(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/navigation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Disables "Back" button for current page 3 | * Usage: Call function in useEffect( ,[]) or directly 4 | */ 5 | export function disableBackNavigation() { 6 | window.history.pushState(null, '', window.location.href); 7 | window.onpopstate = function () { 8 | window.history.go(1); 9 | }; 10 | } 11 | 12 | /** 13 | * Navigates to the specified URL with options 14 | */ 15 | export function navigateTo(url: string, replaceInsteadOfPush = false, optionalTitle = '') { 16 | if (replaceInsteadOfPush) { 17 | window.history.replaceState(null, optionalTitle, url); 18 | } else { 19 | window.history.pushState(null, optionalTitle, url); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/path.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks last char of the string is it a slash or not. 3 | * @param {string} path - the path to check. 4 | * @returns {boolean} true if last char is a slash. 5 | */ 6 | export function hasTrailingSlash(path: string): boolean { 7 | return ( 8 | typeof path === 'string' && (path?.charAt(path?.length - 1) === '/' || path?.charAt(path?.length - 1) === '\\') 9 | ); 10 | } 11 | 12 | /** 13 | * Adds a slash to the path if it doesn't have one. 14 | */ 15 | export function addTrailingSlash(path: string): string { 16 | return hasTrailingSlash(path) ? path : path + '/'; 17 | } 18 | 19 | /** 20 | * Removes ending slash from the path if it has one. 21 | */ 22 | export function removeTrailingSlash(path: string): string { 23 | return hasTrailingSlash(path) ? path.slice(0, -1) : path; 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/sessionStorage.ts: -------------------------------------------------------------------------------- 1 | import { IS_SERVER } from './environment'; 2 | 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | 5 | /** 6 | * Smartly reads value from sessionStorage 7 | */ 8 | export function sessionStorageGet(name: string, defaultValue: any = ''): any { 9 | if (IS_SERVER) { 10 | return defaultValue; // We don't have access to sessionStorage on the server 11 | } 12 | 13 | const valueFromStore = sessionStorage.getItem(name); 14 | if (valueFromStore === null) return defaultValue; // No value in store, return default one 15 | 16 | try { 17 | const jsonParsed: unknown = JSON.parse(valueFromStore); 18 | if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) { 19 | return jsonParsed; // We successfully parse JS value from the store 20 | } 21 | } catch (error) { 22 | // Do nothing 23 | } 24 | 25 | return valueFromStore; // Return string value as it is 26 | } 27 | 28 | /** 29 | * Smartly writes value into sessionStorage 30 | */ 31 | export function sessionStorageSet(name: string, value: any) { 32 | if (IS_SERVER) { 33 | return; // Do nothing on server side 34 | } 35 | if (typeof value === 'undefined') { 36 | return; // Do not store undefined values 37 | } 38 | let valueAsString: string; 39 | if (typeof value === 'object') { 40 | valueAsString = JSON.stringify(value); 41 | } else { 42 | valueAsString = String(value); 43 | } 44 | 45 | sessionStorage.setItem(name, valueAsString); 46 | } 47 | 48 | /* eslint-enable @typescript-eslint/no-explicit-any */ 49 | 50 | /** 51 | * Deletes value by name from sessionStorage, if specified name is empty entire sessionStorage is cleared. 52 | */ 53 | export function sessionStorageDelete(name: string) { 54 | if (IS_SERVER) { 55 | return; // Do nothing on server side 56 | } 57 | if (name) { 58 | sessionStorage.removeItem(name); 59 | } else { 60 | sessionStorage.clear(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Delays code executions for specific amount of time. Must be called with await! 3 | * @param {number} interval - number of milliseconds to wait for 4 | */ 5 | export async function sleep(interval = 1000) { 6 | return new Promise((resolve) => setTimeout(resolve, interval)); 7 | } 8 | 9 | export default sleep; 10 | -------------------------------------------------------------------------------- /src/utils/style.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@mui/material/styles'; 2 | 3 | export type ColorName = 4 | | 'default' // MUI 5.x removes 'default' form Button, we need to fix this 5 | | 'primary' 6 | | 'secondary' 7 | | 'error' // Missing in MUI 4.x 8 | | 'warning' // Missing in MUI 4.x 9 | | 'info' // Missing in MUI 4.x 10 | | 'success' // Missing in MUI 4.x 11 | | 'false' // Missing in MUI 5.x 12 | | 'true'; // Missing in MUI 5.x 13 | 14 | /** 15 | * Makes style to use for Material UI Paper components across the App 16 | */ 17 | export const paperStyle = (theme: Theme) => ({ 18 | paddingTop: theme.spacing(1), 19 | paddingBottom: theme.spacing(1), 20 | paddingLeft: theme.spacing(2), 21 | paddingRight: theme.spacing(2), 22 | }); 23 | 24 | /** 25 | * Makes style for Forms across the App 26 | */ 27 | export const formStyle = (theme: Theme) => ({ 28 | width: '100%', 29 | maxWidth: '40rem', // 640px 30 | }); 31 | 32 | /** 33 | * Makes style to use with Material UI dialogs across the App 34 | */ 35 | export const dialogStyles = ( 36 | theme: Theme 37 | ): { xButton: any; paper: any; formControl: any; content: any; actions: any } => ({ 38 | xButton: { 39 | position: 'absolute', 40 | right: theme.spacing(0.5), 41 | top: theme.spacing(0.5), 42 | }, 43 | paper: { 44 | [theme.breakpoints.up('md')]: { 45 | minWidth: theme.breakpoints.values.md / 2, 46 | }, 47 | [theme.breakpoints.down('md')]: { 48 | minWidth: theme.breakpoints.values.sm / 2, 49 | }, 50 | }, 51 | formControl: { 52 | marginTop: theme.spacing(1), 53 | marginBottom: theme.spacing(1), 54 | }, 55 | content: { 56 | paddingTop: theme.spacing(1), 57 | paddingBottom: theme.spacing(1), 58 | }, 59 | actions: { 60 | paddingLeft: theme.spacing(3), 61 | paddingRight: theme.spacing(3), 62 | }, 63 | }); 64 | 65 | /** 66 | * Makes "filled" styles for Material UI names 'primary', 'secondary', 'warning', and so on 67 | */ 68 | export const filledStylesByNames = (theme: Theme) => ({ 69 | // Standard MUI names 70 | default: { 71 | // MUI 5.x removes 'default' color from Button, we need to fix this 72 | backgroundColor: theme.palette.grey[300], 73 | color: 'rgba(0, 0, 0, 0.87)', // Value as theme.palette.text.primary in Light Mode 74 | }, 75 | primary: { 76 | backgroundColor: theme.palette.primary.main, 77 | color: theme.palette.primary.contrastText, 78 | }, 79 | secondary: { 80 | backgroundColor: theme.palette.secondary.main, 81 | color: theme.palette.secondary.contrastText, 82 | }, 83 | error: { 84 | backgroundColor: theme.palette.error.main, 85 | color: theme.palette.error.contrastText, 86 | }, 87 | warning: { 88 | backgroundColor: theme.palette.warning.main, 89 | color: theme.palette.warning.contrastText, 90 | }, 91 | info: { 92 | backgroundColor: theme.palette.info.main, 93 | color: theme.palette.info.contrastText, 94 | }, 95 | success: { 96 | backgroundColor: theme.palette.success.main, 97 | color: theme.palette.success.contrastText, 98 | }, 99 | // Boolean 100 | false: { 101 | backgroundColor: theme.palette.error.main, 102 | color: theme.palette.error.contrastText, 103 | }, 104 | true: { 105 | backgroundColor: theme.palette.success.main, 106 | color: theme.palette.success.contrastText, 107 | }, 108 | }); 109 | 110 | /** 111 | * Makes "text" styles for Material UI names 'primary', 'secondary', 'warning', etc. 112 | * Also adds 'true' and 'false' classes 113 | */ 114 | export const textStylesByNames = (theme: Theme) => ({ 115 | // Standard MUI names 116 | default: {}, 117 | primary: { 118 | color: theme.palette.primary.main, 119 | }, 120 | secondary: { 121 | color: theme.palette.secondary.main, 122 | }, 123 | error: { 124 | color: theme.palette.error.main, 125 | }, 126 | warning: { 127 | color: theme.palette.warning.main, 128 | }, 129 | info: { 130 | color: theme.palette.info.main, 131 | }, 132 | success: { 133 | color: theme.palette.success.main, 134 | }, 135 | // Boolean 136 | false: { 137 | color: theme.palette.error.main, 138 | }, 139 | true: { 140 | color: theme.palette.success.main, 141 | }, 142 | }); 143 | 144 | /** 145 | * Makes "filled" + "hover" (like in Buttons) styles for Material UI names 'primary', 'secondary', 'warning', and so on 146 | * Note: Fully compatible with variant="contained" only 147 | */ 148 | export const buttonStylesByNames = (theme: Theme) => ({ 149 | // Standard MUI names 150 | default: { 151 | // MUI 5.x removes 'default' color from Button, we need to fix this 152 | backgroundColor: theme.palette.grey[300], 153 | color: 'rgba(0, 0, 0, 0.87)', // Value as theme.palette.text.primary in Light Mode 154 | '&:hover': { 155 | backgroundColor: theme.palette.grey[400], // It was '#d5d5d5' in MUI 4.x 156 | color: 'rgba(0, 0, 0, 0.87)', // Value as theme.palette.text.primary in Light Mode 157 | }, 158 | '&:disabled': { 159 | backgroundColor: theme.palette.grey[300], // In live MUI 4.x project lite: rgba(0, 0, 0, 0.12) dark: rgba(255, 255, 255, 0.12) 160 | color: 'rgba(0, 0, 0, 0.26)', // In live MUI 4.x project lite: rgba(0, 0, 0, 0.26) dark: rgba(255, 255, 255, 0.3) 161 | }, 162 | }, 163 | primary: { 164 | backgroundColor: theme.palette.primary.main, 165 | color: theme.palette.primary.contrastText, 166 | '&:hover': { 167 | backgroundColor: theme.palette.primary.dark, 168 | color: theme.palette.primary.contrastText, 169 | }, 170 | '&:disabled': { 171 | backgroundColor: theme.palette.primary.light, 172 | }, 173 | }, 174 | secondary: { 175 | backgroundColor: theme.palette.secondary.main, 176 | color: theme.palette.secondary.contrastText, 177 | '&:hover': { 178 | backgroundColor: theme.palette.secondary.dark, 179 | color: theme.palette.secondary.contrastText, 180 | }, 181 | '&:disabled': { 182 | backgroundColor: theme.palette.secondary.light, 183 | }, 184 | }, 185 | error: { 186 | backgroundColor: theme.palette.error.main, 187 | color: theme.palette.error.contrastText, 188 | '&:hover': { 189 | backgroundColor: theme.palette.error.dark, 190 | color: theme.palette.error.contrastText, 191 | }, 192 | '&:disabled': { 193 | backgroundColor: theme.palette.error.light, 194 | }, 195 | }, 196 | warning: { 197 | backgroundColor: theme.palette.warning.main, 198 | color: theme.palette.warning.contrastText, 199 | '&:hover': { 200 | backgroundColor: theme.palette.warning.dark, 201 | color: theme.palette.warning.contrastText, 202 | }, 203 | '&:disabled': { 204 | backgroundColor: theme.palette.warning.light, 205 | }, 206 | }, 207 | info: { 208 | backgroundColor: theme.palette.info.main, 209 | color: theme.palette.info.contrastText, 210 | '&:hover': { 211 | backgroundColor: theme.palette.info.dark, 212 | color: theme.palette.info.contrastText, 213 | }, 214 | '&:disabled': { 215 | backgroundColor: theme.palette.info.light, 216 | }, 217 | }, 218 | success: { 219 | backgroundColor: theme.palette.success.main, 220 | color: theme.palette.success.contrastText, 221 | '&:hover': { 222 | backgroundColor: theme.palette.success.dark, 223 | color: theme.palette.success.contrastText, 224 | }, 225 | '&:disabled': { 226 | backgroundColor: theme.palette.success.light, 227 | }, 228 | }, 229 | // Boolean 230 | false: { 231 | backgroundColor: theme.palette.error.main, 232 | color: theme.palette.error.contrastText, 233 | '&:hover': { 234 | backgroundColor: theme.palette.error.dark, 235 | color: theme.palette.error.contrastText, 236 | }, 237 | '&:disabled': { 238 | backgroundColor: theme.palette.error.light, 239 | }, 240 | }, 241 | true: { 242 | backgroundColor: theme.palette.success.main, 243 | color: theme.palette.success.contrastText, 244 | '&:hover': { 245 | backgroundColor: theme.palette.success.dark, 246 | color: theme.palette.success.contrastText, 247 | }, 248 | '&:disabled': { 249 | backgroundColor: theme.palette.success.light, 250 | }, 251 | }, 252 | }); 253 | -------------------------------------------------------------------------------- /src/utils/text.ts: -------------------------------------------------------------------------------- 1 | export const CHARS_NUMERIC = '0123456789'; 2 | export const CHARS_ALPHA_LOWER = 'abcdefghijklmnopqrstuvwxyz'; 3 | export const CHARS_ALPHA_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 4 | export const CHARS_ALPHA_NUMERIC = CHARS_NUMERIC + CHARS_ALPHA_LOWER + CHARS_ALPHA_UPPER; 5 | 6 | /** 7 | * Generate a random string of a given length using a given set of characters 8 | * @param {number} length - the length of the string to generate 9 | * @param {string} [allowedChars] - the set of characters to use in the string, defaults to all alphanumeric characters in upper and lower case + numbers 10 | * @returns {string} - the generated string 11 | */ 12 | export function randomText(length: number, allowedChars = CHARS_ALPHA_NUMERIC) { 13 | let result = ''; 14 | const charLength = allowedChars.length; 15 | let counter = 0; 16 | while (counter < length) { 17 | result += allowedChars.charAt(Math.floor(Math.random() * charLength)); 18 | counter += 1; 19 | } 20 | return result; 21 | } 22 | /** 23 | * Compare two strings including null and undefined values 24 | * @param {string} a - the first string to compare 25 | * @param {string} b - the second string to compare 26 | * @returns {boolean} - true if the strings are the same or both null or undefined, false otherwise 27 | */ 28 | export function compareTexts(a: string | null | undefined, b: string | null | undefined) { 29 | if (a === undefined || a === null || a === '') { 30 | return b === undefined || b === null || b === ''; 31 | } 32 | return a === b; 33 | } 34 | 35 | /** 36 | * Capitalize the first letter of a string 37 | * @param {string} s - the string to capitalize 38 | * @returns {string} - the capitalized string 39 | */ 40 | export const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.substring(1); 41 | 42 | /** 43 | * Generate a random color as #RRGGBB value 44 | * @returns {string} - the generated color 45 | */ 46 | export function randomColor() { 47 | const color = Math.floor(Math.random() * 16777215).toString(16); 48 | return '#' + color; 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/type.ts: -------------------------------------------------------------------------------- 1 | // Helper to read object's properties as obj['name'] 2 | export type ObjectPropByName = Record; 3 | 4 | /** 5 | * Data for "Page Link" in SideBar adn other UI elements 6 | */ 7 | export type LinkToPage = { 8 | icon?: string; // Icon name to use as 9 | path?: string; // URL to navigate to 10 | title?: string; // Title or primary text to display 11 | subtitle?: string; // Sub-title or secondary text to display 12 | }; 13 | -------------------------------------------------------------------------------- /src/views/About/AboutView.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardActions, CardContent, CardHeader, Divider, Grid, Typography } from '@mui/material'; 2 | import { AppButton, AppLink, AppIconButton, AppView } from '../../components'; 3 | import DialogsSection from './DialogsSection'; 4 | 5 | /** 6 | * Renders "About" view 7 | * url: /about 8 | * @page About 9 | */ 10 | const AboutView = () => { 11 | return ( 12 | 13 | 14 | 15 | 16 | Detailed description of the application here... 17 | 18 | 19 | OK 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | MUI default MUI inherit{' '} 34 | MUI primary MUI secondary{' '} 35 | MUI textPrimary{' '} 36 | MUI textSecondary MUI error
37 | Internal Link   38 | 39 | Internal Link in New Tab 40 | {' '} 41 |   42 | External Link   43 | 44 | External Link in Same Tab 45 | {' '} 46 |   47 |
48 | 49 | 50 | 51 |
52 |
53 |
54 | 55 | 56 | 57 | 58 | 59 | Default 60 | Disabled 61 | Primary 62 | Secondary 63 | Error 64 | Warning 65 | Info 66 | Success 67 | #FF8C00 68 | rgb(50, 205, 50) 69 | 70 | Inherit 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 92 | 97 | 98 | {/* */} 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | MUI Typo h1 107 | MUI Typography h2 108 | MUI Typography h3 109 | MUI Typography h4 110 | MUI Typography h5 111 | MUI Typography h6 112 | 113 | MUI Typography subtitle1 114 | MUI Typography subtitle2 115 | MUI Typography caption 116 | 117 | 118 | MUI Typography body1 - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor 119 | incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco 120 | laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit 121 | esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa 122 | qui officia deserunt mollit anim id est laborum. 123 | 124 | 125 | 126 | MUI Typography body2 - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor 127 | incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco 128 | laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit 129 | esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa 130 | qui officia deserunt mollit anim id est laborum. 131 | 132 | 133 | MUI Typography overline 134 | 135 | MUI Typography button 136 | 137 | 138 | 139 |
140 | ); 141 | }; 142 | 143 | export default AboutView; 144 | -------------------------------------------------------------------------------- /src/views/About/DialogsSection.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useState, ReactNode, useCallback } from 'react'; 2 | import { Card, CardHeader, Grid, TextField } from '@mui/material'; 3 | import { AppButton, AppIconButton } from '../../components'; 4 | import { 5 | CommonDialog as MessageDialog, 6 | CommonDialog as ConfirmationDialog, 7 | CompositionDialog as EmailEditDialog, 8 | } from '../../components/dialogs'; 9 | 10 | /** 11 | * Renders demo section for Dialogs 12 | */ 13 | const DialogsSection = () => { 14 | const [modal, setModal] = useState(null); 15 | const [openEmailDialog, setOpenEmailDialog] = useState(false); 16 | const [email, setEmail] = useState('i@karpolan.com'); 17 | 18 | const onDialogClose = useCallback(() => { 19 | setModal(null); 20 | }, []); 21 | 22 | const onMessageDialogConfirm = useCallback((data: unknown) => { 23 | console.info('onMessageDialogConfirm() - data:', data); 24 | setModal(null); 25 | }, []); 26 | 27 | const onMessageDialogOpen = () => { 28 | setModal( 29 | 42 | ); 43 | }; 44 | 45 | const onConfirmDialogConfirm = useCallback((data: unknown) => { 46 | console.info('onConfirmDialogConfirm() - data:', data); 47 | setModal(null); 48 | }, []); 49 | 50 | const onConfirmDialogOpen = () => { 51 | const dialogData = { 52 | id: 123, 53 | name: 'Sample data for Confirm Dialog', 54 | }; 55 | setModal( 56 | 62 |
JSX content can be easily added into the dialog via props.body
63 |
64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 76 | 81 | 82 | {/* */} 83 |
84 |
85 |
86 | The props.body takes precedence over props.text. So JSX content is rendered, but the text is ignored 87 |
88 | 89 | } 90 | text="!!! This text will not be rendered !!!" 91 | confirmButtonText="Confirm and do something" 92 | onClose={onDialogClose} 93 | onConfirm={onConfirmDialogConfirm} 94 | /> 95 | ); 96 | }; 97 | 98 | const onEditEmailDialogClose = useCallback((data: unknown) => { 99 | setOpenEmailDialog(false); 100 | }, []); 101 | 102 | const onEmailChange = (event: ChangeEvent) => { 103 | setEmail(event.target.value); 104 | }; 105 | 106 | const onEditEmailDialogOpen = () => { 107 | setOpenEmailDialog(true); 108 | }; 109 | 110 | return ( 111 | <> 112 | {modal} 113 | {openEmailDialog && ( 114 | 120 | 121 |

This is CompositionDialog with JSX in props.content and props.actions

122 |
123 | } 124 | actions={ 125 | <> 126 | Cancel 127 | 128 | OK 129 | 130 | 131 | } 132 | /> 133 | )} 134 | 135 | 136 | 137 | 138 | 139 | 140 | 146 |
147 |
148 |
149 | 150 | ); 151 | }; 152 | 153 | export default DialogsSection; 154 | -------------------------------------------------------------------------------- /src/views/About/index.tsx: -------------------------------------------------------------------------------- 1 | import AboutView from './AboutView'; 2 | 3 | export { AboutView as default, AboutView }; 4 | -------------------------------------------------------------------------------- /src/views/Auth/Login/LoginEmailView.tsx: -------------------------------------------------------------------------------- 1 | import { SyntheticEvent, useCallback, useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Button, Grid, TextField, Card, CardHeader, CardContent, InputAdornment } from '@mui/material'; 4 | import { useAppStore } from '../../../store'; 5 | import { AppButton, AppLink, AppIconButton, AppAlert, AppForm } from '../../../components'; 6 | import { useAppForm, SHARED_CONTROL_PROPS, eventPreventDefault } from '../../../utils/form'; 7 | 8 | const VALIDATE_FORM_LOGIN_EMAIL = { 9 | email: { 10 | presence: true, 11 | email: true, 12 | }, 13 | password: { 14 | presence: true, 15 | length: { 16 | minimum: 8, 17 | maximum: 32, 18 | message: 'must be between 8 and 32 characters', 19 | }, 20 | }, 21 | }; 22 | 23 | interface FormStateValues { 24 | email: string; 25 | password: string; 26 | } 27 | 28 | /** 29 | * Renders "Login with Email" view for Login flow 30 | * url: /auth/login/email 31 | * @page LoginEmail 32 | */ 33 | const LoginEmailView = () => { 34 | const navigate = useNavigate(); 35 | const [, dispatch] = useAppStore(); 36 | const { formState, onFieldChange, fieldGetError, fieldHasError, isFormValid } = useAppForm({ 37 | validationSchema: VALIDATE_FORM_LOGIN_EMAIL, 38 | initialValues: { email: '', password: '' } as FormStateValues, 39 | }); 40 | const [showPassword, setShowPassword] = useState(false); 41 | const [error, setError] = useState(); 42 | const values = formState.values as FormStateValues; // Typed alias to formState.values as the "Source of Truth" 43 | 44 | const handleShowPasswordClick = useCallback(() => { 45 | setShowPassword((oldValue) => !oldValue); 46 | }, []); 47 | 48 | const handleFormSubmit = useCallback( 49 | async (event: SyntheticEvent) => { 50 | event.preventDefault(); 51 | 52 | const result = true; // await api.auth.loginWithEmail(values); 53 | if (!result) { 54 | setError('Please check email and password'); 55 | return; 56 | } 57 | 58 | dispatch({ type: 'LOG_IN' }); 59 | navigate('/', { replace: true }); 60 | }, 61 | [dispatch, /*values,*/ navigate] 62 | ); 63 | 64 | const handleCloseError = useCallback(() => setError(undefined), []); 65 | 66 | return ( 67 | 68 | 69 | 70 | 71 | 81 | 94 | 101 | 102 | ), 103 | }} 104 | /> 105 | {error ? ( 106 | 107 | {error} 108 | 109 | ) : null} 110 | 111 | 112 | Login with Email 113 | 114 | 117 | 118 | 119 | 120 | 121 | ); 122 | }; 123 | 124 | export default LoginEmailView; 125 | -------------------------------------------------------------------------------- /src/views/Auth/Login/index.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | import { NotFoundView } from '../..'; 3 | import LoginEmailView from './LoginEmailView'; 4 | 5 | /** 6 | * Routes for "Login" flow 7 | * url: /auth/login/* 8 | */ 9 | const LoginRoutes = () => { 10 | return ( 11 | 12 | } /> 13 | } /> 14 | } /> 15 | 16 | ); 17 | }; 18 | 19 | export default LoginRoutes; 20 | -------------------------------------------------------------------------------- /src/views/Auth/Recovery/RecoveryPasswordView.tsx: -------------------------------------------------------------------------------- 1 | import { SyntheticEvent, useCallback, useState } from 'react'; 2 | import { Grid, TextField, Card, CardHeader, CardContent } from '@mui/material'; 3 | import { AppButton, AppAlert, AppForm } from '../../../components'; 4 | import { useAppForm, SHARED_CONTROL_PROPS } from '../../../utils/form'; 5 | 6 | const VALIDATE_FORM_RECOVERY_PASSWORD = { 7 | email: { 8 | presence: true, 9 | email: true, 10 | }, 11 | }; 12 | 13 | interface FormStateValues { 14 | email: string; 15 | } 16 | 17 | interface Props { 18 | email?: string; 19 | } 20 | 21 | /** 22 | * Renders "Recover Password" view for Login flow 23 | * url: /uth/recovery/password 24 | * @page RecoveryPassword 25 | * @param {string} [props.email] - pre-populated email in case the user already enters it 26 | */ 27 | const RecoveryPasswordView = ({ email = '' }: Props) => { 28 | const { formState, onFieldChange, fieldGetError, fieldHasError, isFormValid } = useAppForm({ 29 | validationSchema: VALIDATE_FORM_RECOVERY_PASSWORD, 30 | initialValues: { email } as FormStateValues, 31 | }); 32 | const [message, setMessage] = useState(); 33 | const values = formState.values as FormStateValues; // Typed alias to formState.values as the "Source of Truth" 34 | 35 | const handleFormSubmit = async (event: SyntheticEvent) => { 36 | event.preventDefault(); 37 | 38 | // await api.auth.recoverPassword(values); 39 | 40 | //Show message with instructions for the user 41 | setMessage('Email with instructions has been sent to your address'); 42 | }; 43 | 44 | const handleCloseError = useCallback(() => setMessage(undefined), []); 45 | 46 | return ( 47 | 48 | 49 | 50 | 51 | 61 | 62 | {message ? ( 63 | 64 | {message} 65 | 66 | ) : null} 67 | 68 | 69 | 70 | Send Password Recovery Email 71 | 72 | 73 | 74 | 75 | 76 | ); 77 | }; 78 | 79 | export default RecoveryPasswordView; 80 | -------------------------------------------------------------------------------- /src/views/Auth/Recovery/index.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | import { NotFoundView } from '../..'; 3 | import RecoveryPasswordView from './RecoveryPasswordView'; 4 | 5 | /** 6 | * Routes for "Recovery" flow 7 | * url: /auth/recovery/* 8 | */ 9 | const RecoveryRoutes = () => { 10 | return ( 11 | 12 | } /> 13 | } /> 14 | } /> 15 | 16 | ); 17 | }; 18 | 19 | export default RecoveryRoutes; 20 | -------------------------------------------------------------------------------- /src/views/Auth/Signup/ConfirmEmailView.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState, useEffect } from 'react'; 2 | import { useLocation } from 'react-router-dom'; 3 | import { Card, CardHeader, CardContent, TextField } from '@mui/material'; 4 | import { SHARED_CONTROL_PROPS } from '../../../utils/form'; 5 | import { AppAlert, AppForm } from '../../../components'; 6 | 7 | const TOKEN_QUERY_PARAM = 'token'; 8 | 9 | /** 10 | * Renders "Confirm Email" view for Signup flow 11 | * url: /auth/signup/confirm-email 12 | * @page ConfirmEmail 13 | */ 14 | const ConfirmEmailView = () => { 15 | const [email, setEmail] = useState(''); 16 | const [error, setError] = useState(); 17 | 18 | function useQuery() { 19 | return new URLSearchParams(useLocation().search); 20 | } 21 | 22 | const token = useQuery().get(TOKEN_QUERY_PARAM) || ''; 23 | console.log('ConfirmEmailView() - token:', token); 24 | 25 | useEffect(() => { 26 | // Component Mount 27 | let componentMounted = true; 28 | 29 | async function fetchData() { 30 | //TODO: Call any Async API here 31 | if (!componentMounted) return; // Component was unmounted during the API call 32 | //TODO: Verify API call here 33 | 34 | setEmail('example@domain.com'); 35 | } 36 | fetchData(); // Call API asynchronously 37 | 38 | return () => { 39 | // Component Un-mount 40 | componentMounted = false; 41 | }; 42 | }, []); 43 | 44 | const handleCloseError = useCallback(() => setError(undefined), []); 45 | 46 | return ( 47 | 48 | 49 | 50 | 51 | 52 | {error ? ( 53 | 54 | {error} 55 | 56 | ) : null} 57 | 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default ConfirmEmailView; 64 | -------------------------------------------------------------------------------- /src/views/Auth/Signup/SignupView.tsx: -------------------------------------------------------------------------------- 1 | import { SyntheticEvent, useCallback, useState, useEffect } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { 4 | Grid, 5 | TextField, 6 | Card, 7 | CardHeader, 8 | CardContent, 9 | Checkbox, 10 | FormControlLabel, 11 | InputAdornment, 12 | LinearProgress, 13 | } from '@mui/material'; 14 | import { useAppStore } from '../../../store'; 15 | import { AppButton, AppIconButton, AppAlert, AppForm } from '../../../components'; 16 | import { useAppForm, SHARED_CONTROL_PROPS, eventPreventDefault } from '../../../utils/form'; 17 | 18 | const VALIDATE_FORM_SIGNUP = { 19 | email: { 20 | email: true, 21 | presence: true, 22 | }, 23 | phone: { 24 | type: 'string', 25 | format: { 26 | pattern: '^$|[- .+()0-9]+', // Note: We have to allow empty in the pattern 27 | message: 'should contain numbers', 28 | }, 29 | // length: { 30 | // is: 10, 31 | // message: 'must be exactly 10 digits', 32 | // }, 33 | }, 34 | firstName: { 35 | type: 'string', 36 | presence: { allowEmpty: false }, 37 | format: { 38 | pattern: '^[A-Za-z ]+$', // Note: Allow only alphabets and space 39 | message: 'should contain only alphabets', 40 | }, 41 | }, 42 | lastName: { 43 | type: 'string', 44 | presence: { allowEmpty: false }, 45 | format: { 46 | pattern: '^[A-Za-z ]+$', // Note: Allow only alphabets and space 47 | message: 'should contain only alphabets', 48 | }, 49 | }, 50 | password: { 51 | presence: true, 52 | length: { 53 | minimum: 8, 54 | maximum: 32, 55 | message: 'must be between 8 and 32 characters', 56 | }, 57 | }, 58 | }; 59 | 60 | const VALIDATE_EXTENSION = { 61 | confirmPassword: { 62 | equality: 'password', 63 | }, 64 | }; 65 | 66 | interface FormStateValues { 67 | firstName: string; 68 | lastName: string; 69 | email: string; 70 | phone: string; 71 | password: string; 72 | confirmPassword?: string; 73 | } 74 | 75 | /** 76 | * Renders "Signup" view 77 | * url: /auth/signup 78 | * @page Signup 79 | */ 80 | const SignupView = () => { 81 | const navigate = useNavigate(); 82 | const [, dispatch] = useAppStore(); 83 | const [validationSchema, setValidationSchema] = useState({ 84 | ...VALIDATE_FORM_SIGNUP, 85 | ...VALIDATE_EXTENSION, 86 | }); 87 | const { formState, onFieldChange, fieldGetError, fieldHasError, isFormValid } = useAppForm({ 88 | validationSchema: validationSchema, // the state value, so could be changed in time 89 | initialValues: { 90 | firstName: '', 91 | lastName: '', 92 | email: '', 93 | phone: '', 94 | password: '', 95 | confirmPassword: '', 96 | } as FormStateValues, 97 | }); 98 | const [showPassword, setShowPassword] = useState(false); 99 | const [agree, setAgree] = useState(false); 100 | const [loading, setLoading] = useState(true); 101 | const [error, setError] = useState(); 102 | const values = formState.values as FormStateValues; // Typed alias to formState.values as the "Source of Truth" 103 | 104 | useEffect(() => { 105 | // Component Mount 106 | let componentMounted = true; 107 | 108 | async function fetchData() { 109 | //TODO: Call any Async API here 110 | if (!componentMounted) return; // Component was unmounted during the API call 111 | //TODO: Verify API call here 112 | 113 | setLoading(false); // Reset "Loading..." indicator 114 | } 115 | fetchData(); // Call API asynchronously 116 | 117 | return () => { 118 | // Component Un-mount 119 | componentMounted = false; 120 | }; 121 | }, []); 122 | 123 | useEffect(() => { 124 | // Update Validation Schema when Show/Hide password changed 125 | let newSchema; 126 | if (showPassword) { 127 | newSchema = VALIDATE_FORM_SIGNUP; // Validation without .confirmPassword 128 | } else { 129 | newSchema = { ...VALIDATE_FORM_SIGNUP, ...VALIDATE_EXTENSION }; // Full validation 130 | } 131 | setValidationSchema(newSchema); 132 | }, [showPassword]); 133 | 134 | const handleShowPasswordClick = useCallback(() => { 135 | setShowPassword((oldValue) => !oldValue); 136 | }, []); 137 | 138 | const handleAgreeClick = useCallback(() => { 139 | setAgree((oldValue) => !oldValue); 140 | }, []); 141 | 142 | const handleFormSubmit = useCallback( 143 | async (event: SyntheticEvent) => { 144 | event.preventDefault(); 145 | 146 | const apiResult = true; // await api.auth.signup(values); 147 | 148 | if (!apiResult) { 149 | setError('Can not create user for given email, if you already have account please sign in'); 150 | return; // Unsuccessful signup 151 | } 152 | 153 | dispatch({ type: 'SIGN_UP' }); 154 | return navigate('/', { replace: true }); 155 | }, 156 | [dispatch, /*values,*/ navigate] 157 | ); 158 | 159 | const handleCloseError = useCallback(() => setError(undefined), []); 160 | 161 | if (loading) return ; 162 | 163 | return ( 164 | 165 | 166 | 167 | 168 | 178 | 188 | 198 | 208 | 221 | 228 | 229 | ), 230 | }} 231 | /> 232 | {!showPassword && ( 233 | 244 | )} 245 | } 247 | label="You must agree with Terms of Use and Privacy Policy to use our service" 248 | /> 249 | 250 | {error ? ( 251 | 252 | {error} 253 | 254 | ) : null} 255 | 256 | 257 | 258 | Confirm and Sign Up 259 | 260 | 261 | 262 | 263 | 264 | ); 265 | }; 266 | 267 | export default SignupView; 268 | -------------------------------------------------------------------------------- /src/views/Auth/Signup/index.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | import { NotFoundView } from '../..'; 3 | import SignupView from './SignupView'; 4 | import ConfirmEmailView from './ConfirmEmailView'; 5 | 6 | /** 7 | * Routes for "Signup" flow 8 | * url: /auth/signup/* 9 | */ 10 | const SignupRoutes = () => { 11 | return ( 12 | 13 | } /> 14 | } /> 15 | } /> 16 | 17 | ); 18 | }; 19 | 20 | export default SignupRoutes; 21 | -------------------------------------------------------------------------------- /src/views/Auth/index.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | import { NotFoundView } from '..'; 3 | import SignupRoutes from './Signup'; 4 | import LoginRoutes from './Login'; 5 | import RecoveryRoutes from './Recovery'; 6 | 7 | /** 8 | * Routes for "Auth" flow 9 | * url: /auth/* 10 | */ 11 | const AuthRoutes = () => { 12 | return ( 13 | 14 | } /> 15 | } /> 16 | } /> 17 | } /> 18 | } /> 19 | 20 | ); 21 | }; 22 | 23 | export default AuthRoutes; 24 | -------------------------------------------------------------------------------- /src/views/Dev/DevView.tsx: -------------------------------------------------------------------------------- 1 | import { AppView } from '../../components'; 2 | 3 | /** 4 | * Renders Development tools when env.REACT_APP_DEBUG is true 5 | * url: /dev 6 | * @page Dev 7 | */ 8 | const DevView = () => { 9 | if (process.env.REACT_APP_DEBUG !== 'true') return null; // Hide this page on when env.REACT_APP_DEBUG is not set 10 | 11 | return Debug controls and components on this page...; 12 | }; 13 | 14 | export default DevView; 15 | -------------------------------------------------------------------------------- /src/views/Dev/index.tsx: -------------------------------------------------------------------------------- 1 | import DevView from './DevView'; 2 | 3 | export { DevView as default, DevView }; 4 | -------------------------------------------------------------------------------- /src/views/NotFoundView.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | import { Stack, Typography } from '@mui/material'; 3 | import { AppAlert, AppButton, AppLink, AppView } from '../components'; 4 | 5 | /** 6 | * "Not Found" aka "Error 404" view 7 | * url: any unknown :) 8 | * @page NotFoundView 9 | */ 10 | const NotFoundView = () => { 11 | const navigate = useNavigate(); 12 | 13 | const onClose = () => { 14 | navigate('/', { replace: true }); 15 | }; 16 | 17 | return ( 18 | 19 | Page not found! 20 | 21 | Requested address is unknown, please check your URL or go to the home page. 22 | 23 | 24 | Error 404 - Page not found 25 | 26 | 27 | Go to Home Page 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default NotFoundView; 34 | -------------------------------------------------------------------------------- /src/views/NotImplementedView.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | import { Link, useLocation, useParams } from 'react-router-dom'; 3 | 4 | interface Props { 5 | name?: string; 6 | } 7 | 8 | /** 9 | * Boilerplate for non-implemented Views 10 | */ 11 | const NotImplementedView: FunctionComponent = ({ name }) => { 12 | const location = useLocation(); 13 | const { id: paramId } = useParams(); 14 | const componentName = name || 'View'; 15 | 16 | return ( 17 |
18 |

{componentName} is under construction

19 |

20 | This view is not implemented yet. Go to home page 21 |

22 |

23 | You've called the {location?.pathname} url 24 | {paramId && ( 25 | 26 | {' '} 27 | where {paramId} is a parameter 28 | 29 | )} 30 |

31 |
32 | ); 33 | }; 34 | 35 | export default NotImplementedView; 36 | -------------------------------------------------------------------------------- /src/views/Welcome/WelcomeView.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from '@mui/material'; 2 | import { AppLink, AppView } from '../../components'; 3 | 4 | /** 5 | * Renders "Welcome" view 6 | * url: / 7 | * @page Welcome 8 | */ 9 | const WelcomeView = () => { 10 | return ( 11 | 12 | Welcome to React App with MUI 13 | 14 |
This is Welcome page, put your content here....
15 |
16 | Take a look on samples of components at About Page 17 |
18 |
19 | ); 20 | }; 21 | 22 | export default WelcomeView; 23 | -------------------------------------------------------------------------------- /src/views/Welcome/index.tsx: -------------------------------------------------------------------------------- 1 | import WelcomeView from './WelcomeView'; 2 | 3 | export { WelcomeView as default, WelcomeView }; 4 | -------------------------------------------------------------------------------- /src/views/index.tsx: -------------------------------------------------------------------------------- 1 | import NotImplementedView from './NotImplementedView'; 2 | import NotFoundView from './NotFoundView'; 3 | 4 | export { NotFoundView, NotImplementedView as UserView }; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/create-react-app/tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["es6", "dom", "dom.iterable", "esnext"], 5 | "module": "esnext", 6 | "target": "esnext" 7 | }, 8 | "include": ["src"] 9 | } 10 | --------------------------------------------------------------------------------