├── .prettierignore ├── src ├── app │ ├── page.tsx │ ├── favicon.ico │ ├── auth │ │ ├── signup │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── login │ │ │ ├── page.tsx │ │ │ └── LoginForm.tsx │ ├── globals.css │ ├── me │ │ └── page.tsx │ ├── dev │ │ ├── components │ │ │ ├── DemoAppAlerts.tsx │ │ │ ├── DemoAppImage.tsx │ │ │ ├── DemoAppIcon.tsx │ │ │ ├── DemoAppButton.tsx │ │ │ └── DemoAppIconButton.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── about │ │ └── page.tsx │ └── home │ │ └── page.tsx ├── hooks │ ├── index.ts │ ├── event.ts │ ├── auth.ts │ ├── useWindowSize.ts │ └── layout.ts ├── components │ ├── common │ │ ├── AppIcon │ │ │ ├── index.tsx │ │ │ ├── utils.ts │ │ │ ├── icons │ │ │ │ ├── PencilIcon.tsx │ │ │ │ ├── CurrencyIcon.tsx │ │ │ │ └── YellowPlanIcon.tsx │ │ │ ├── AppIcon.tsx │ │ │ ├── config.ts │ │ │ └── AppIcon.test.tsx │ │ ├── AppAlert │ │ │ ├── index.tsx │ │ │ ├── AppAlert.tsx │ │ │ └── AppAlert.test.tsx │ │ ├── AppImage │ │ │ ├── index.tsx │ │ │ ├── AppImage.tsx │ │ │ └── AppImage.test.tsx │ │ ├── AppButton │ │ │ ├── index.tsx │ │ │ ├── AppButton.tsx │ │ │ └── AppButton.test.tsx │ │ ├── AppLoading │ │ │ ├── index.tsx │ │ │ └── AppLoading.tsx │ │ ├── AppIconButton │ │ │ ├── index.tsx │ │ │ ├── AppIconButton.tsx │ │ │ └── AppIconButton.test.tsx │ │ ├── AppLink │ │ │ ├── index.tsx │ │ │ ├── AppLinkNextNavigation.tsx │ │ │ └── AppLink.test.tsx │ │ ├── index.tsx │ │ └── ErrorBoundary.tsx │ ├── index.tsx │ ├── UserInfo │ │ ├── index.tsx │ │ └── UserInfo.tsx │ └── config.ts ├── store │ ├── index.tsx │ ├── config.ts │ ├── AppReducer.ts │ └── AppStore.tsx ├── layout │ ├── components │ │ ├── index.tsx │ │ ├── TopBar.tsx │ │ ├── SideBarNavList.tsx │ │ ├── BottomBar.tsx │ │ ├── SideBarNavItem.tsx │ │ └── SideBar.tsx │ ├── index.tsx │ ├── config.ts │ ├── CurrentLayout.tsx │ ├── PrivateLayout.tsx │ ├── PublicLayout.tsx │ └── TopBarAndSideBarLayout.tsx ├── utils │ ├── index.ts │ ├── sleep.ts │ ├── type.ts │ ├── localStorage.ts │ ├── sessionStorage.ts │ ├── navigation.ts │ ├── text.ts │ └── environment.ts ├── theme │ ├── index.ts │ ├── dark.ts │ ├── light.ts │ ├── MuiThemeProviderForNextJs.tsx │ ├── colors.ts │ └── ThemeProvider.tsx └── config.ts ├── public ├── robots.txt ├── img │ ├── favicon │ │ ├── 16x16.png │ │ ├── 32x32.png │ │ ├── 180x180.png │ │ ├── 192x192.png │ │ └── 512x512.png │ └── logo.svg └── site.webmanifest ├── .eslintrc.json ├── next-env.d.ts ├── jest.setup.ts ├── .gitignore ├── next.config.mjs ├── tsconfig.json ├── .prettierrc.js ├── jest.config.ts ├── .env.sample ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | node_modules 3 | out 4 | styles -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import HomePage from './home/page'; 2 | 3 | export default HomePage; 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /private/ 3 | 4 | User-agent: * 5 | Allow: / 6 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export * from './event'; 3 | export * from './layout'; 4 | -------------------------------------------------------------------------------- /public/img/favicon/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/public/img/favicon/16x16.png -------------------------------------------------------------------------------- /public/img/favicon/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/public/img/favicon/32x32.png -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "import/no-cycle": "error" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public/img/favicon/180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/public/img/favicon/180x180.png -------------------------------------------------------------------------------- /public/img/favicon/192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/public/img/favicon/192x192.png -------------------------------------------------------------------------------- /public/img/favicon/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/public/img/favicon/512x512.png -------------------------------------------------------------------------------- /src/components/common/AppIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import AppIcon from './AppIcon'; 2 | 3 | export { AppIcon as default, AppIcon }; 4 | -------------------------------------------------------------------------------- /src/components/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | 3 | import UserInfo from './UserInfo'; 4 | 5 | export { UserInfo }; 6 | -------------------------------------------------------------------------------- /src/components/UserInfo/index.tsx: -------------------------------------------------------------------------------- 1 | import UserInfo from './UserInfo'; 2 | 3 | export { UserInfo }; 4 | export default UserInfo; 5 | -------------------------------------------------------------------------------- /src/components/common/AppAlert/index.tsx: -------------------------------------------------------------------------------- 1 | import AppAlert from './AppAlert'; 2 | 3 | export { AppAlert as default, AppAlert }; 4 | -------------------------------------------------------------------------------- /src/components/common/AppImage/index.tsx: -------------------------------------------------------------------------------- 1 | import AppImage from './AppImage'; 2 | 3 | export { AppImage as default, AppImage }; 4 | -------------------------------------------------------------------------------- /src/components/common/AppButton/index.tsx: -------------------------------------------------------------------------------- 1 | import AppButton from './AppButton'; 2 | 3 | export { AppButton as default, AppButton }; 4 | -------------------------------------------------------------------------------- /src/components/common/AppLoading/index.tsx: -------------------------------------------------------------------------------- 1 | import AppLoading from './AppLoading'; 2 | 3 | export { AppLoading }; 4 | export default AppLoading; 5 | -------------------------------------------------------------------------------- /src/components/common/AppIconButton/index.tsx: -------------------------------------------------------------------------------- 1 | import AppIconButton from './AppIconButton'; 2 | 3 | export { AppIconButton as default, AppIconButton }; 4 | -------------------------------------------------------------------------------- /src/store/index.tsx: -------------------------------------------------------------------------------- 1 | import { AppStoreProvider, useAppStore, withAppStore } from './AppStore'; 2 | 3 | export { AppStoreProvider, useAppStore, withAppStore }; 4 | -------------------------------------------------------------------------------- /src/layout/components/index.tsx: -------------------------------------------------------------------------------- 1 | import BottomBar from './BottomBar'; 2 | import SideBar from './SideBar'; 3 | import TopBar from './TopBar'; 4 | 5 | export { BottomBar, SideBar, TopBar }; 6 | -------------------------------------------------------------------------------- /src/components/common/AppLink/index.tsx: -------------------------------------------------------------------------------- 1 | import AppLink, { AppLinkForNextProps as AppLinkProps } from './AppLinkNextNavigation'; 2 | 3 | export type { AppLinkProps }; 4 | export { AppLink as default, AppLink }; 5 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /src/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import CurrentLayout from './CurrentLayout'; 2 | import PrivateLayout from './PrivateLayout'; 3 | import PublicLayout from './PublicLayout'; 4 | 5 | export { PublicLayout, PrivateLayout }; 6 | export default CurrentLayout; 7 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './environment'; 2 | export * from './localStorage'; 3 | export * from './navigation'; 4 | export * from './sessionStorage'; 5 | export * from './sleep'; 6 | export * from './type'; 7 | export * from './text'; 8 | -------------------------------------------------------------------------------- /src/app/auth/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | import LoginPage from '../login/page'; 3 | 4 | export const metadata: Metadata = { 5 | title: 'Signup - _TITLE_', 6 | description: '_DESCRIPTION_', 7 | }; 8 | 9 | export default LoginPage; // Just reuse a Login page as a demo... 10 | -------------------------------------------------------------------------------- /src/app/auth/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | /** 4 | * Redirects to default Auth page 5 | * @page Auth 6 | * @redirect /auth 7 | */ 8 | const AuthPage = () => { 9 | redirect('/auth/login'); 10 | // return
Auth Page
; 11 | }; 12 | 13 | export default AuthPage; 14 | -------------------------------------------------------------------------------- /src/components/common/AppIcon/utils.ts: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from 'react'; 2 | 3 | /** 4 | * Props to use with custom SVG icons, similar to AppIcon's Props 5 | */ 6 | export interface IconProps extends SVGAttributes { 7 | color?: string; 8 | icon?: string; 9 | size?: string | number; 10 | title?: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/theme/index.ts: -------------------------------------------------------------------------------- 1 | import AppThemeProvider from './ThemeProvider'; 2 | import DARK_THEME from './dark'; 3 | import LIGHT_THEME from './light'; 4 | 5 | export { 6 | LIGHT_THEME as default, // Change to DARK_THEME if you want to use dark theme as default 7 | DARK_THEME, 8 | LIGHT_THEME, 9 | AppThemeProvider as ThemeProvider, 10 | }; 11 | -------------------------------------------------------------------------------- /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/app/globals.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | html, 8 | body { 9 | max-width: 100vw; 10 | overflow-x: hidden; 11 | /* required for sticky elements: HeaderMobile, and so on */ 12 | max-height: 100vh; 13 | } 14 | 15 | a { 16 | color: inherit; 17 | text-decoration: none; 18 | } 19 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | // Optional: configure or set up a testing framework before each test. 2 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.*` 3 | 4 | // Used for __tests__/testing-library.js 5 | // Learn more: https://github.com/testing-library/jest-dom 6 | import '@testing-library/jest-dom'; 7 | 8 | // To get 'next/router' working with tests 9 | jest.mock('next/router', () => require('next-router-mock')); 10 | -------------------------------------------------------------------------------- /src/app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata, NextPage } from 'next'; 2 | import LoginForm from './LoginForm'; 3 | 4 | /** 5 | * User Login page 6 | * @page Login 7 | */ 8 | const LoginPage: NextPage = () => { 9 | return ( 10 | <> 11 | 12 | 13 | ); 14 | }; 15 | 16 | export const metadata: Metadata = { 17 | title: 'Login - _TITLE_', 18 | description: '_DESCRIPTION_', 19 | }; 20 | 21 | export default LoginPage; 22 | -------------------------------------------------------------------------------- /src/components/common/index.tsx: -------------------------------------------------------------------------------- 1 | import AppAlert from './AppAlert'; 2 | import AppButton from './AppButton'; 3 | import AppIcon from './AppIcon'; 4 | import AppIconButton from './AppIconButton'; 5 | import AppImage from './AppImage'; 6 | import AppLink from './AppLink'; 7 | import AppLoading from './AppLoading'; 8 | import ErrorBoundary from './ErrorBoundary'; 9 | 10 | export { ErrorBoundary, AppAlert, AppButton, AppIcon, AppIconButton, AppImage, AppLink, AppLoading }; 11 | -------------------------------------------------------------------------------- /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/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/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/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/app/me/page.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@mui/material'; 2 | import { NextPage } from 'next'; 3 | import { AppAlert, UserInfo } from '../../components'; 4 | 5 | /** 6 | * Renders User Profile Page 7 | * @page Me 8 | */ 9 | const MeAkaProfilePage: NextPage = () => { 10 | return ( 11 | 12 | This page is under construction 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default MeAkaProfilePage; 19 | -------------------------------------------------------------------------------- /src/theme/MuiThemeProviderForNextJs.tsx: -------------------------------------------------------------------------------- 1 | import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter'; 2 | import { FunctionComponent, PropsWithChildren } from 'react'; 3 | 4 | /** 5 | * Platform-specific ThemeProvider for Next.js 6 | * @component MuiThemeProviderForNextJs 7 | */ 8 | const MuiThemeProviderForNextJs: FunctionComponent = ({ children }) => { 9 | return {children}; 10 | }; 11 | 12 | export default MuiThemeProviderForNextJs; 13 | -------------------------------------------------------------------------------- /src/store/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Data structure of the AppStore state 3 | */ 4 | export interface AppStoreState { 5 | darkMode: boolean; 6 | isAuthenticated: boolean; 7 | currentUser?: object | undefined; 8 | } 9 | 10 | /** 11 | * Initial values for the AppStore state 12 | */ 13 | export const APP_STORE_INITIAL_STATE: AppStoreState = { 14 | darkMode: false, // Overridden by useMediaQuery('(prefers-color-scheme: dark)') in AppStore 15 | isAuthenticated: false, // Overridden in AppStore by checking auth token 16 | }; 17 | -------------------------------------------------------------------------------- /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/CurrentLayout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React, { FunctionComponent, PropsWithChildren } from 'react'; 3 | import { useIsAuthenticated } from '@/hooks'; 4 | import PrivateLayout from './PrivateLayout'; 5 | import PublicLayout from './PublicLayout'; 6 | 7 | /** 8 | * Returns the current Layout component depending on different circumstances. 9 | * @layout CurrentLayout 10 | */ 11 | const CurrentLayout: FunctionComponent = (props) => { 12 | return useIsAuthenticated() ? : ; 13 | }; 14 | 15 | export default CurrentLayout; 16 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const nextConfig = { 4 | output: 'export', // Use this if you want to create "static generated website" (SSG), result in "/out" folder 5 | trailingSlash: true, 6 | images: { unoptimized: true }, 7 | 8 | env: { 9 | // TODO: You can add custom env variables here, also check .env.xxx file 10 | AUTHOR: 'KARPOLAN', 11 | // npm_package_name: process.env.npm_package_name, 12 | // npm_package_version: process.env.npm_package_version, 13 | }, 14 | 15 | reactStrictMode: true, 16 | // reactStrictMode: false, 17 | }; 18 | 19 | export default nextConfig; 20 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { envRequired, getCurrentEnvironment } from '@/utils/environment'; 2 | 3 | export const IS_DEBUG = process.env.NEXT_PUBLIC_DEBUG === 'true'; // Enables logging, etc. 4 | 5 | export const IS_PRODUCTION = getCurrentEnvironment() === 'production'; // Enables analytics, etc. 6 | 7 | // export const PUBLIC_URL = envRequired(process.env.NEXT_PUBLIC_PUBLIC_URL); // Variant 1: .env variable is required 8 | export const PUBLIC_URL = process.env.NEXT_PUBLIC_PUBLIC_URL; // Variant 2: .env variable is optional 9 | 10 | IS_DEBUG && 11 | console.log('@/config', { 12 | IS_DEBUG, 13 | IS_PRODUCTION, 14 | PUBLIC_URL, 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/common/AppAlert/AppAlert.tsx: -------------------------------------------------------------------------------- 1 | import MuiAlert, { AlertProps as MuiAlertProps } from '@mui/material/Alert'; 2 | import { FunctionComponent } from 'react'; 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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | }, 23 | "forceConsistentCasingInFileNames": true 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /.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 | endOfLine: 'lf', // 'lf' for linux, 'crlf' for windows, we need to use 'lf' for git 13 | }; 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/dev/components/DemoAppAlerts.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader } from '@mui/material'; 2 | import { AppAlert } from '@/components'; 3 | 4 | /** 5 | * Renders "Demo Section" for AppButton component 6 | * @component DemoAppButton 7 | */ 8 | const DemoAppButton = () => { 9 | return ( 10 | 11 | 12 | 13 | AppAlert - Info 14 | 15 | AppAlert - Info, variant "outlined" 16 | 17 | AppAlert - Error (default) 18 | 19 | AppAlert - Info, variant "standard" 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default DemoAppButton; 27 | -------------------------------------------------------------------------------- /src/components/common/AppImage/AppImage.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | import NextImage, { ImageProps } from 'next/image'; 3 | 4 | interface AppImageProps extends Omit { 5 | alt?: string; // Make property optional as it was before NextJs v13 6 | } 7 | 8 | /** 9 | * Application wrapper around NextJS image with some default props 10 | * @component AppImage 11 | */ 12 | const AppImage: FunctionComponent = ({ 13 | title, // Note: value has be destructed before usage as default value for other property 14 | alt = title ?? 'Image', 15 | height = 256, 16 | width = 256, 17 | ...restOfProps 18 | }) => { 19 | // Uses custom loader + unoptimized="true" to avoid NextImage warning https://nextjs.org/docs/api-reference/next/image#unoptimized 20 | return ; 21 | }; 22 | 23 | export default AppImage; 24 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | import nextJest from 'next/jest.js'; 3 | 4 | // const nextJest = require('next/jest'); 5 | 6 | const createJestConfig = nextJest({ 7 | // Provide the path to your Next.js app to load next.config.* and .env files in your test environment 8 | dir: './', 9 | }); 10 | 11 | // Add any custom config to be passed to Jest 12 | const customJestConfig: Config = { 13 | coverageProvider: 'v8', 14 | setupFilesAfterEnv: ['/jest.setup.ts'], 15 | moduleNameMapper: { 16 | // Handle module aliases 17 | '^@/(.*)$': '/$1', 18 | }, 19 | // testEnvironment: 'jest-environment-jsdom', 20 | testEnvironment: 'jsdom', 21 | // transform: { 22 | // '.+\\.(css|styl|less|sass|scss)$': 'jest-css-modules-transform', 23 | // }, 24 | }; 25 | 26 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 27 | module.exports = createJestConfig(customJestConfig); 28 | -------------------------------------------------------------------------------- /public/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/hooks/auth.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { sessionStorageGet, sessionStorageDelete } from '@/utils/sessionStorage'; 3 | import { useAppStore } from '../store'; 4 | 5 | /** 6 | * Hook to detect is current user authenticated or not 7 | * @returns {boolean} true if user is authenticated, false otherwise 8 | */ 9 | export function useIsAuthenticated() { 10 | const [state] = useAppStore(); 11 | let result = state.isAuthenticated; 12 | 13 | // TODO: AUTH: replace next line with access token verification 14 | result = Boolean(sessionStorageGet('access_token', '')); 15 | 16 | return result; 17 | } 18 | 19 | /** 20 | * Returns event handler to Logout current user 21 | * @returns {function} calling this event logs out current user 22 | */ 23 | export function useEventLogout() { 24 | const [, dispatch] = useAppStore(); 25 | 26 | return useCallback(() => { 27 | // TODO: AUTH: replace next line with access token saving 28 | sessionStorageDelete('access_token'); 29 | 30 | dispatch({ type: 'LOG_OUT' }); 31 | }, [dispatch]); 32 | } 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/layout/components/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactNode } from 'react'; 2 | import { AppBar, Toolbar, Typography } from '@mui/material'; 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/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, PropsWithChildren } from 'react'; 2 | import { Metadata, Viewport } from 'next'; 3 | import { SimplePaletteColorOptions } from '@mui/material'; 4 | import { AppStoreProvider } from '@/store'; 5 | import defaultTheme, { ThemeProvider } from '@/theme'; 6 | import CurrentLayout from '@/layout'; 7 | import './globals.css'; 8 | 9 | const THEME_COLOR = (defaultTheme.palette?.primary as SimplePaletteColorOptions)?.main || '#FFFFFF'; 10 | 11 | export const viewport: Viewport = { 12 | themeColor: THEME_COLOR, 13 | }; 14 | 15 | export const metadata: Metadata = { 16 | title: '_TITLE_', 17 | description: '_DESCRIPTION_', 18 | manifest: '/site.webmanifest', 19 | // TODO: Add Open Graph metadata 20 | }; 21 | 22 | const RootLayout: FunctionComponent = ({ children }) => { 23 | return ( 24 | 25 | 26 | 27 | 28 | {children} 29 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default RootLayout; 37 | -------------------------------------------------------------------------------- /src/app/dev/page.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { redirect } from 'next/navigation'; 3 | import { Stack, Typography } from '@mui/material'; 4 | import { IS_DEBUG } from '@/config'; 5 | import DemoAppAlert from './components/DemoAppAlerts'; 6 | import DemoAppButton from './components/DemoAppButton'; 7 | import DemoAppIcon from './components/DemoAppIcon'; 8 | import DemoAppIconButton from './components/DemoAppIconButton'; 9 | import DemoAppImage from './components/DemoAppImage'; 10 | 11 | /** 12 | * Renders Development tools when env.NEXT_PUBLIC_DEBUG is true 13 | * @page Dev 14 | */ 15 | const DevPage: NextPage = () => { 16 | if (!IS_DEBUG) { 17 | redirect('/'); 18 | } 19 | 20 | return ( 21 | 22 | 23 | DevTools page 24 | This page is not visible on production. 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default DevPage; 39 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Environment similar to NODE_ENV. 2 | # Analytics and public resources are enabled only in "production" 3 | # How to use: set value to "production" to get fully functional application. 4 | NEXT_PUBLIC_ENV = development 5 | # NEXT_PUBLIC_ENV = preview 6 | # NEXT_PUBLIC_ENV = production 7 | 8 | # Enables additional debug features, no additional debug information if the variable is not set 9 | # How to use: set value to "true" to get more debugging information, but don't do it on production. 10 | NEXT_PUBLIC_DEBUG = true 11 | 12 | # Public URL of the application/website. 13 | # How to use: Do not set any value until you need custom domain for your application. 14 | # NEXT_PUBLIC_PUBLIC_URL = https://xxx.com 15 | # NEXT_PUBLIC_PUBLIC_URL = https://xxx.web.app 16 | NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000 17 | 18 | 19 | # API/Backend basic URL 20 | NEXT_PUBLIC_API_URL = http://localhost:5000 21 | # NEXT_PUBLIC_API_URL = https://dev-api.domain.com 22 | # NEXT_PUBLIC_API_URL = https://api.domain.com 23 | 24 | # EmailJS configuration for contact form 25 | NEXT_PUBLIC_EMAILJS_KEY = user-xxxx 26 | 27 | # Analytics and Advertising 28 | NEXT_PUBLIC_GA_ID = G-... 29 | NEXT_PUBLIC_AMPLITUDE_API_KEY = 69abf7... 30 | NEXT_PUBLIC_ADSENSE_CLIENT_ID = pub-12345... -------------------------------------------------------------------------------- /src/layout/components/SideBarNavList.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, MouseEventHandler } from 'react'; 2 | import List from '@mui/material/List'; 3 | import { LinkToPage } from '@/utils'; 4 | import SideBarNavItem from './SideBarNavItem'; 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/app/dev/components/DemoAppImage.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, Stack } from '@mui/material'; 2 | import { AppImage } from '@/components'; 3 | 4 | /** 5 | * Renders "Demo Section" for AppImage component 6 | * @component DemoAppImage 7 | */ 8 | const DemoAppImage = () => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | {/* */} 16 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default DemoAppImage; 32 | -------------------------------------------------------------------------------- /src/components/common/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 '@/components/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/layout/components/BottomBar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { FunctionComponent, useCallback } from 'react'; 3 | import { useRouter } from 'next/navigation'; 4 | import { BottomNavigation, BottomNavigationAction } from '@mui/material'; 5 | import { LinkToPage } from '@/utils'; 6 | import { AppIcon } from '@/components'; 7 | 8 | interface Props { 9 | items: Array; 10 | } 11 | 12 | /** 13 | * Renders horizontal Navigation Bar using MUI BottomNavigation component 14 | * @component BottomBar 15 | */ 16 | const BottomBar: FunctionComponent = ({ items }) => { 17 | const router = useRouter(); 18 | 19 | const onNavigationChange = useCallback( 20 | (_event: unknown, newValue: string) => { 21 | router.push(newValue); 22 | }, 23 | [router] 24 | ); 25 | 26 | return ( 27 | 32 | {items.map(({ title, path, icon }) => ( 33 | } /> 34 | ))} 35 | 36 | ); 37 | }; 38 | 39 | export default BottomBar; 40 | -------------------------------------------------------------------------------- /src/hooks/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from 'react'; 2 | import { IS_SERVER } from '@/utils'; 3 | 4 | const MOBILE_WINDOWS_SIZE = { width: 720, height: 1280 }; 5 | const DESKTOP_WINDOWS_SIZE = { width: 1920, height: 1080 }; 6 | const DEFAULT_WINDOWS_SIZE = MOBILE_WINDOWS_SIZE ?? DESKTOP_WINDOWS_SIZE; // Mobile-First by default 7 | 8 | type WindowSize = { 9 | width: number; 10 | height: number; 11 | }; 12 | 13 | /** 14 | * Hook to monitor Window (actually Browser) Size using "resize" event listener 15 | * @returns {WindowSize} current window size as {width, height} object 16 | */ 17 | const useWindowSize = (): WindowSize => { 18 | const [windowSize, setWindowSize] = useState(DEFAULT_WINDOWS_SIZE); 19 | 20 | useLayoutEffect(() => { 21 | function handleResize() { 22 | setWindowSize({ 23 | width: window.innerWidth, 24 | height: window.innerHeight, 25 | }); 26 | } 27 | 28 | window.addEventListener('resize', handleResize); 29 | handleResize(); // Get initial/current window size 30 | 31 | return () => window.removeEventListener('resize', handleResize); 32 | }, []); 33 | 34 | return windowSize; 35 | }; 36 | 37 | /** 38 | * The hook will really work in Browser only, so or Server Side Rendering (SSR) we just return DEFAULT_WINDOWS_SIZE 39 | */ 40 | export default IS_SERVER ? () => DEFAULT_WINDOWS_SIZE : useWindowSize; 41 | -------------------------------------------------------------------------------- /src/layout/components/SideBarNavItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { FunctionComponent, MouseEventHandler } from 'react'; 3 | import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; 4 | import { AppIcon, AppLink } from '@/components'; 5 | import { LinkToPage } from '@/utils'; 6 | import { usePathname } from 'next/navigation'; 7 | 8 | interface Props extends LinkToPage { 9 | openInNewTab?: boolean; 10 | selected?: boolean; 11 | onClick?: MouseEventHandler; 12 | } 13 | 14 | /** 15 | * Renders Navigation Item for SideBar, detects current url and sets selected state if needed 16 | * @component SideBarNavItem 17 | */ 18 | const SideBarNavItem: FunctionComponent = ({ 19 | openInNewTab, 20 | icon, 21 | path, 22 | selected: propSelected = false, 23 | subtitle, 24 | title, 25 | onClick, 26 | }) => { 27 | const pathname = usePathname(); 28 | const selected = propSelected || (path && path.length > 1 && pathname.startsWith(path)) || false; 29 | 30 | return ( 31 | 39 | {icon && } 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default SideBarNavItem; 46 | -------------------------------------------------------------------------------- /src/components/common/AppIcon/icons/PencilIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | import { IconProps } from '../utils'; 3 | 4 | const PencilIcon: FunctionComponent = (props) => { 5 | return ( 6 | 7 | 11 | 15 | 16 | 20 | 21 | 25 | 26 | ); 27 | }; 28 | 29 | export default PencilIcon; 30 | -------------------------------------------------------------------------------- /src/store/AppReducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'react'; 2 | import { localStorageSet } from '../utils/localStorage'; 3 | import { AppStoreState } from './config'; 4 | 5 | /** 6 | * Reducer for global AppStore using "Redux styled" actions 7 | * @function AppReducer 8 | * @param {object} state - current/default state 9 | * @param {string} action.type - unique name of the action 10 | * @param {string} action.action - alternate to action.type property, unique name of the action 11 | * @param {*} [action.payload] - optional data object or the function to get data object 12 | */ 13 | const AppReducer: Reducer = (state, action) => { 14 | // console.log('AppReducer() - action:', action); 15 | switch (action.type || action.action) { 16 | case 'CURRENT_USER': 17 | return { 18 | ...state, 19 | currentUser: action?.currentUser || action?.payload, 20 | }; 21 | case 'SIGN_UP': 22 | case 'LOG_IN': 23 | return { 24 | ...state, 25 | isAuthenticated: true, 26 | }; 27 | case 'LOG_OUT': 28 | return { 29 | ...state, 30 | isAuthenticated: false, 31 | currentUser: undefined, // Also reset previous user data 32 | }; 33 | case 'DARK_MODE': { 34 | const darkMode = action?.darkMode ?? action?.payload; 35 | localStorageSet('darkMode', darkMode); 36 | return { 37 | ...state, 38 | darkMode, 39 | }; 40 | } 41 | default: 42 | return state; 43 | } 44 | }; 45 | 46 | export default AppReducer; 47 | -------------------------------------------------------------------------------- /src/components/common/AppIcon/icons/CurrencyIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | import { IconProps } from '../utils'; 3 | 4 | const CurrencyIcon: FunctionComponent = (props) => { 5 | return ( 6 | 7 | 11 | 15 | 16 | 20 | 21 | 25 | 26 | ); 27 | }; 28 | 29 | export default CurrencyIcon; 30 | -------------------------------------------------------------------------------- /src/components/UserInfo/UserInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Stack, Typography } from '@mui/material'; 2 | import { AppLink } from '../common'; 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 {boolean} [showAvatar] - user's avatar picture is shown when true 14 | * @param {object} [user] - logged user data {name, email, avatar...} 15 | */ 16 | const UserInfo = ({ showAvatar = false, user, ...restOfProps }: UserInfoProps) => { 17 | const fullName = user?.name || [user?.nameFirst || '', user?.nameLast || ''].join(' ').trim(); 18 | const srcAvatar = user?.avatar ? user?.avatar : undefined; 19 | const userPhoneOrEmail = user?.phone || (user?.email as string); 20 | 21 | return ( 22 | 23 | {showAvatar ? ( 24 | 25 | 34 | 35 | ) : null} 36 | 37 | {fullName || 'Current User'} 38 | 39 | {userPhoneOrEmail || 'Loading...'} 40 | 41 | ); 42 | }; 43 | 44 | export default UserInfo; 45 | -------------------------------------------------------------------------------- /src/app/about/page.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, Typography } from '@mui/material'; 2 | import { NextPage } from 'next'; 3 | import { AppButton, AppLink } from '@/components'; 4 | 5 | /** 6 | * Renders About Application page 7 | * @page About 8 | */ 9 | const AboutPage: NextPage = () => { 10 | return ( 11 | 12 | 13 | About application 14 | 15 | This application is a mix of{' '} 16 | Create Next App and{' '} 17 | MUI with set of reusable components and utilities to build 18 | professional NextJS application faster. The source code is 19 | available on GitHub. 20 | 21 | 22 | 23 | Open GitHub 24 | 25 | 26 | 27 | 28 | Reusable components 29 | 30 | Demo of reusable components is available on DevTools page 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default AboutPage; 38 | -------------------------------------------------------------------------------- /src/app/auth/login/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Stack } from '@mui/material'; 3 | import { useRouter } from 'next/navigation'; 4 | import { AppButton, AppLink } from '@/components'; 5 | import { useAppStore } from '@/store'; 6 | import { useEventLogout } from '@/hooks'; 7 | import { sessionStorageSet } from '@/utils'; 8 | 9 | /** 10 | * Renders login form for user to authenticate 11 | * @component LoginForm 12 | */ 13 | const LoginForm = () => { 14 | const router = useRouter(); 15 | const [, dispatch] = useAppStore(); 16 | const onLogout = useEventLogout(); 17 | 18 | const onLogin = () => { 19 | // TODO: AUTH: Sample of access token store, replace next line in real application 20 | sessionStorageSet('access_token', 'TODO:_save-real-access-token-here'); 21 | 22 | dispatch({ type: 'LOG_IN' }); 23 | router.replace('/'); // Redirect to home page without ability to go back 24 | }; 25 | 26 | return ( 27 | 28 | Put form controls or add social login buttons here... 29 | 30 | 31 | 32 | Emulate User Login 33 | 34 | 35 | Logout User 36 | 37 | 38 | 39 |
40 | The source code is available at{' '} 41 | GitHub 42 |
43 |
44 | ); 45 | }; 46 | 47 | export default LoginForm; 48 | -------------------------------------------------------------------------------- /src/layout/PrivateLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, PropsWithChildren } from 'react'; 2 | import { IS_DEBUG } from '@/config'; 3 | import { LinkToPage } from '@/utils'; 4 | import TopBarAndSideBarLayout from './TopBarAndSideBarLayout'; 5 | 6 | const TITLE_PRIVATE = '_TITLE_'; // Title for pages after authentication 7 | 8 | /** 9 | * SideBar navigation items with links for Private Layout 10 | */ 11 | const SIDE_BAR_ITEMS: Array = [ 12 | { 13 | title: 'Home', 14 | path: '/', 15 | icon: 'home', 16 | }, 17 | { 18 | title: 'My Profile', 19 | path: '/me', 20 | icon: 'account', 21 | }, 22 | { 23 | title: '404', 24 | path: '/wrong-url', 25 | icon: 'error', 26 | }, 27 | { 28 | title: 'About', 29 | path: '/about', 30 | icon: 'info', 31 | }, 32 | ]; 33 | 34 | // Add debug links 35 | IS_DEBUG && 36 | SIDE_BAR_ITEMS.push({ 37 | title: '[Debug Tools]', 38 | path: '/dev', 39 | icon: 'settings', 40 | }); 41 | 42 | /** 43 | * Renders "Private Layout" composition 44 | * @layout PrivateLayout 45 | */ 46 | const PrivateLayout: FunctionComponent = ({ children }) => { 47 | const title = TITLE_PRIVATE; 48 | document.title = title; // Also Update Tab Title // TODO: Do we need this? Move it to useEffect()? 49 | 50 | return ( 51 | 52 | {children} 53 | {/* Copyright © */} 54 | 55 | ); 56 | }; 57 | 58 | export default PrivateLayout; 59 | -------------------------------------------------------------------------------- /src/app/dev/components/DemoAppIcon.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useState } from 'react'; 3 | import copyToClipboard from 'copy-to-clipboard'; 4 | import { Box, Card, CardContent, CardHeader, Snackbar } from '@mui/material'; 5 | import { AppIconButton } from '@/components'; 6 | import { ICONS } from '@/components/common/AppIcon/config'; 7 | 8 | /** 9 | * Renders "Demo Section" for AppIcon component 10 | * @component DemoAppIcon 11 | */ 12 | const DemoAppIcon = () => { 13 | const [snackbarOpen, setSnackbarOpen] = useState(false); 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | {Object.keys(ICONS).map((icon) => ( 21 | { 26 | copyToClipboard(``); 27 | setSnackbarOpen(true); // Show snackbar 28 | setTimeout(() => setSnackbarOpen(false), 3000); // Hide snackbar after small delay 29 | }} 30 | /> 31 | ))} 32 | 33 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | export default DemoAppIcon; 47 | -------------------------------------------------------------------------------- /src/components/common/AppIcon/AppIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, FunctionComponent, SVGAttributes } from 'react'; 2 | import { APP_ICON_SIZE } from '../../config'; 3 | import { IconName, ICONS } from './config'; 4 | 5 | /** 6 | * Props of the AppIcon component, also can be used for SVG icons 7 | */ 8 | export interface Props extends SVGAttributes { 9 | color?: string; 10 | icon?: IconName | string; 11 | size?: string | number; 12 | title?: string; 13 | } 14 | 15 | /** 16 | * Renders SVG icon by given Icon name 17 | * @component AppIcon 18 | * @param {string} [color] - color of the icon as a CSS color value 19 | * @param {string} [icon] - name of the Icon to render 20 | * @param {string} [title] - title/hint to show when the cursor hovers the icon 21 | * @param {string | number} [size] - size of the icon, default is ICON_SIZE 22 | */ 23 | const AppIcon: FunctionComponent = ({ 24 | color, 25 | icon = 'default', 26 | size = APP_ICON_SIZE, 27 | style, 28 | ...restOfProps 29 | }) => { 30 | const iconName = (icon || 'default').trim().toLowerCase() as IconName; 31 | 32 | let ComponentToRender: ComponentType = ICONS[iconName]; 33 | if (!ComponentToRender) { 34 | console.warn(`AppIcon: icon "${iconName}" is not found!`); 35 | ComponentToRender = ICONS.default; // ICONS['default']; 36 | } 37 | 38 | const propsToRender = { 39 | height: size, 40 | color, 41 | fill: color && 'currentColor', 42 | size, 43 | style: { ...style, color }, 44 | width: size, 45 | ...restOfProps, 46 | }; 47 | 48 | return ; 49 | }; 50 | 51 | export default AppIcon; 52 | -------------------------------------------------------------------------------- /src/theme/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { FunctionComponent, PropsWithChildren, useEffect, useMemo, useState } from 'react'; 3 | import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material/styles'; 4 | 5 | import { useAppStore } from '../store'; 6 | import DARK_THEME from './dark'; 7 | import LIGHT_THEME from './light'; 8 | import MuiThemeProviderForNextJs from './MuiThemeProviderForNextJs'; 9 | import CssBaseline from '@mui/material/CssBaseline'; 10 | 11 | function getThemeByDarkMode(darkMode: boolean) { 12 | return darkMode ? createTheme(DARK_THEME) : createTheme(LIGHT_THEME); 13 | } 14 | 15 | /** 16 | * Renders composition of Emotion's CacheProvider + MUI's ThemeProvider to wrap content of entire App 17 | * The Light or Dark themes applied depending on global .darkMode state 18 | * @component AppThemeProvider 19 | */ 20 | const AppThemeProvider: FunctionComponent = ({ children }) => { 21 | const [state] = useAppStore(); 22 | const [loading, setLoading] = useState(true); 23 | 24 | const currentTheme = useMemo( 25 | () => getThemeByDarkMode(state.darkMode), 26 | [state.darkMode] // Observe AppStore and re-create the theme when .darkMode changes 27 | ); 28 | 29 | useEffect(() => setLoading(false), []); // Set .loading to false when the component is mounted 30 | 31 | if (loading) return null; // Don't render anything until the component is mounted 32 | 33 | return ( 34 | 35 | 36 | 37 | {children} 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default AppThemeProvider; 44 | -------------------------------------------------------------------------------- /src/app/home/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata, NextPage } from 'next'; 2 | import { Stack, Typography } from '@mui/material'; 3 | import { AppLink } from '@/components'; 4 | import DemoAppAlert from '../dev/components/DemoAppAlerts'; 5 | import DemoAppButton from '../dev/components/DemoAppButton'; 6 | import DemoAppIcon from '../dev/components/DemoAppIcon'; 7 | import DemoAppIconButton from '../dev/components/DemoAppIconButton'; 8 | import DemoAppImage from '../dev/components/DemoAppImage'; 9 | 10 | export const metadata: Metadata = { 11 | title: '_TITLE_', 12 | description: '_DESCRIPTION_', 13 | }; 14 | 15 | /** 16 | * Main page of the Application 17 | * @page Home 18 | */ 19 | const Home: NextPage = () => { 20 | return ( 21 | 22 | 23 | About application 24 | 25 | This application is a mix of{' '} 26 | Create Next App and{' '} 27 | MUI with set of reusable components and utilities to build 28 | professional NextJS application faster. The source code is 29 | available on GitHub. 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default Home; 45 | -------------------------------------------------------------------------------- /src/components/common/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Component, ErrorInfo, ReactNode } from 'react'; 3 | 4 | interface Props { 5 | children: ReactNode; 6 | name: string; 7 | } 8 | 9 | interface State { 10 | hasError: boolean; 11 | error?: Error; 12 | errorInfo?: ErrorInfo; 13 | } 14 | 15 | /** 16 | * Error boundary wrapper to save Application parts from falling 17 | * @component ErrorBoundary 18 | * @param {string} [props.name] - name of the wrapped segment, "Error Boundary" by default 19 | */ 20 | class ErrorBoundary extends Component { 21 | static defaultProps = { 22 | name: 'Error Boundary', 23 | }; 24 | 25 | constructor(props: Props) { 26 | super(props); 27 | this.state = { hasError: false }; 28 | } 29 | 30 | static getDerivedStateFromError(error: Error) { 31 | // The next render will show the Error UI 32 | return { hasError: true }; 33 | } 34 | 35 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { 36 | // Save information to help render Error UI 37 | this.setState({ error, errorInfo }); 38 | // TODO: Add log error messages to an error reporting service here 39 | } 40 | 41 | render() { 42 | if (this.state.hasError) { 43 | // Error UI rendering 44 | return ( 45 |
46 |

{this.props.name} - Something went wrong

47 |
48 | {this.state?.error?.toString()} 49 |
50 | {this.state?.errorInfo?.componentStack} 51 |
52 |
53 | ); 54 | } 55 | 56 | // Normal UI rendering 57 | return this.props.children; 58 | } 59 | } 60 | 61 | export default ErrorBoundary; 62 | -------------------------------------------------------------------------------- /src/utils/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { IS_SERVER } from './environment'; 2 | 3 | /** 4 | * Smartly reads value from localStorage 5 | */ 6 | export function localStorageGet(name: string, defaultValue: any = ''): string { 7 | if (IS_SERVER) { 8 | return defaultValue; // We don't have access to localStorage on the server 9 | } 10 | 11 | const valueFromStore = localStorage.getItem(name); 12 | if (valueFromStore === null) return defaultValue; // No value in store, return default one 13 | 14 | try { 15 | const jsonParsed = JSON.parse(valueFromStore); 16 | if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) { 17 | return jsonParsed; // We successfully parse JS value from the store 18 | } 19 | } catch (error) {} 20 | 21 | return valueFromStore; // Return string value as it is 22 | } 23 | 24 | /** 25 | * Smartly writes value into localStorage 26 | */ 27 | export function localStorageSet(name: string, value: any) { 28 | if (IS_SERVER) { 29 | return; // Do nothing on server side 30 | } 31 | if (typeof value === 'undefined') { 32 | return; // Do not store undefined values 33 | } 34 | let valueAsString: string; 35 | if (typeof value === 'object') { 36 | valueAsString = JSON.stringify(value); 37 | } else { 38 | valueAsString = String(value); 39 | } 40 | 41 | localStorage.setItem(name, valueAsString); 42 | } 43 | 44 | /** 45 | * Deletes value by name from localStorage, if specified name is empty entire localStorage is cleared. 46 | */ 47 | export function localStorageDelete(name: string) { 48 | if (IS_SERVER) { 49 | return; // Do nothing on server side 50 | } 51 | if (name) { 52 | localStorage.removeItem(name); 53 | } else { 54 | localStorage.clear(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/sessionStorage.ts: -------------------------------------------------------------------------------- 1 | import { IS_SERVER } from './environment'; 2 | 3 | /** 4 | * Smartly reads value from sessionStorage 5 | */ 6 | export function sessionStorageGet(name: string, defaultValue: any = ''): string { 7 | if (IS_SERVER) { 8 | return defaultValue; // We don't have access to sessionStorage on the server 9 | } 10 | 11 | const valueFromStore = sessionStorage.getItem(name); 12 | if (valueFromStore === null) return defaultValue; // No value in store, return default one 13 | 14 | try { 15 | const jsonParsed = JSON.parse(valueFromStore); 16 | if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) { 17 | return jsonParsed; // We successfully parse JS value from the store 18 | } 19 | } catch (error) {} 20 | 21 | return valueFromStore; // Return string value as it is 22 | } 23 | 24 | /** 25 | * Smartly writes value into sessionStorage 26 | */ 27 | export function sessionStorageSet(name: string, value: any) { 28 | if (IS_SERVER) { 29 | return; // Do nothing on server side 30 | } 31 | if (typeof value === 'undefined') { 32 | return; // Do not store undefined values 33 | } 34 | let valueAsString: string; 35 | if (typeof value === 'object') { 36 | valueAsString = JSON.stringify(value); 37 | } else { 38 | valueAsString = String(value); 39 | } 40 | 41 | sessionStorage.setItem(name, valueAsString); 42 | } 43 | 44 | /** 45 | * Deletes value by name from sessionStorage, if specified name is empty entire sessionStorage is cleared. 46 | */ 47 | export function sessionStorageDelete(name: string) { 48 | if (IS_SERVER) { 49 | return; // Do nothing on server side 50 | } 51 | if (name) { 52 | sessionStorage.removeItem(name); 53 | } else { 54 | sessionStorage.clear(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-mui-starter-ts", 3 | "version": "0.2.9", 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 | "nextjs", 13 | "react", 14 | "typescript", 15 | "mui", 16 | "material", 17 | "material ui" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/karpolan/nextjs-mui-starter-ts.git" 22 | }, 23 | "scripts": { 24 | "dev": "next dev", 25 | "build": "next build", 26 | "format": "prettier ./ --write", 27 | "lint": "next lint", 28 | "start": "next start", 29 | "test": "jest --watch", 30 | "test:ci": "jest --ci", 31 | "type": "tsc" 32 | }, 33 | "dependencies": { 34 | "@emotion/cache": "latest", 35 | "@emotion/react": "latest", 36 | "@emotion/server": "latest", 37 | "@emotion/styled": "latest", 38 | "@mui/icons-material": "latest", 39 | "@mui/material": "latest", 40 | "@mui/material-nextjs": "latest", 41 | "clsx": "latest", 42 | "copy-to-clipboard": "latest", 43 | "next": "latest", 44 | "react": "latest", 45 | "react-dom": "latest" 46 | }, 47 | "devDependencies": { 48 | "@testing-library/jest-dom": "latest", 49 | "@testing-library/react": "latest", 50 | "@testing-library/user-event": "latest", 51 | "@types/jest": "latest", 52 | "@types/node": "latest", 53 | "@types/react": "latest", 54 | "@types/react-dom": "latest", 55 | "eslint": "latest", 56 | "eslint-config-next": "latest", 57 | "jest": "latest", 58 | "jest-environment-jsdom": "latest", 59 | "next-router-mock": "latest", 60 | "prettier": "latest", 61 | "ts-node": "latest", 62 | "typescript": "^5" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/navigation.ts: -------------------------------------------------------------------------------- 1 | import { IS_BROWSER } from './environment'; 2 | 3 | export const EXTERNAL_LINK_PROPS = { 4 | target: '_blank', 5 | rel: 'noopener noreferrer', 6 | }; 7 | 8 | /** 9 | * Disables "Back" button for current page 10 | * Usage: Call function in useEffect( ,[]) or directly 11 | */ 12 | export function disableBackNavigation() { 13 | window.history.pushState(null, '', window.location.href); 14 | window.onpopstate = function () { 15 | window.history.go(1); 16 | }; 17 | } 18 | 19 | /** 20 | * Navigates to the specified URL with options 21 | */ 22 | export function navigateTo(url: string, replaceInsteadOfPush = false, optionalTitle = '') { 23 | if (replaceInsteadOfPush) { 24 | window.history.replaceState(null, optionalTitle, url); 25 | } else { 26 | window.history.pushState(null, optionalTitle, url); 27 | } 28 | } 29 | 30 | /** 31 | * For smooth scrolling to the specified element with optional offset 32 | * @param {object} destinationElement - DOM element to scroll to 33 | * @param {number} [verticalOffset] - optional vertical offset 34 | * @param {object} [scrollingElement] - optional scrolling element, defaults to .window 35 | * @param {string} [behavior] - optional scroll behavior, defaults to 'smooth' 36 | */ 37 | export function scrollIntoViewAdjusted( 38 | destinationElement: Element | HTMLElement | null, 39 | verticalOffset = 0, 40 | scrollingElement?: Element | HTMLElement | null, 41 | behavior: 'auto' | 'instant' | 'smooth' | undefined = 'smooth' 42 | ) { 43 | if (!IS_BROWSER || !destinationElement) { 44 | return; 45 | } 46 | 47 | const rect = destinationElement.getBoundingClientRect(); 48 | if (!rect || typeof rect.top === 'undefined') { 49 | return; 50 | } 51 | 52 | const top = rect.top - verticalOffset; 53 | const elementToScroll = scrollingElement ?? window; 54 | elementToScroll.scrollBy({ top, behavior }); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/common/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/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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Starter project for Next.js with App Router + Material UI using TypeScript 2 | 3 | Mix of [Create Next App](https://nextjs.org/docs/pages/api-reference/create-next-app) and [MUI](https://mui.com) with set of reusable components and utilities to build professional NextJS application faster. 4 | 5 | - [Source Code](https://github.com/karpolan/nextjs-mui-starter-ts) 6 | - [Online Demo](https://nextjs-mui-starter-ts.vercel.app) 7 | 8 | _Warning: if your are planning to use **Pages Router** (not **App Router**) then use [this template](https://github.com/karpolan/nextjs-with-pages-mui-starter-ts)_ 9 | 10 | ## How to use 11 | 12 | 1. Clone or download the repo from: https://github.com/karpolan/nextjs-mui-starter-ts 13 | 2. Copy `.env.sample` file into `.env` file 14 | 3. Replace `_TITLE_` and `_DESCRIPTION_` in all files with own texts 15 | 4. Check and resolve all `// TODO: ` directives 16 | 5. Add your own code :) 17 | 18 | ## Available Scripts 19 | 20 | In the project directory, you can run: 21 | 22 | ### `npm run dev` or `yarn dev` 23 | 24 | Runs the app in the development mode.
25 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 26 | 27 | The page will reload if you make edits.
28 | You will also see any lint errors in the console. 29 | 30 | ### `npm run lint` or `yarn lint` 31 | 32 | Checks the code for errors and missing things 33 | 34 | ### `npm run format` or `yarn format` 35 | 36 | Formats the code according to `./prettierrc.js` config 37 | 38 | ### `npm test` or `yarn test` 39 | 40 | Launches the test runner in the interactive watch mode.
41 | 42 | ### `npm run build` or `yarn build` 43 | 44 | Builds the app for production or local development to the `.next` folder.
45 | 46 | ## Deploy on Vercel 47 | 48 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 49 | 50 | Check out our [Next.js deployment documentation]https://nextjs.org/docs/pages/building-your-application/deploying) for more details. 51 | -------------------------------------------------------------------------------- /src/components/common/AppImage/AppImage.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { randomText } from '@/utils'; 3 | import AppImage from './AppImage'; 4 | 5 | const ComponentToTest = AppImage; 6 | 7 | /** 8 | * Tests for component 9 | */ 10 | describe(' component', () => { 11 | const src = 'https:/domain.com/image.jpg'; 12 | 13 | it('renders itself', () => { 14 | const testId = randomText(8); 15 | render(); 16 | const image = screen.getByTestId(testId); 17 | expect(image).toBeDefined(); 18 | expect(image).toHaveAttribute('src', src); 19 | expect(image).toHaveAttribute('alt', 'Image'); // Default prop value 20 | expect(image).toHaveAttribute('height', '256'); // Default prop value 21 | expect(image).toHaveAttribute('width', '256'); // Default prop value 22 | }); 23 | 24 | it('supports .width and .height props', () => { 25 | const testId = randomText(8); 26 | const height = 345; 27 | const width = 123; 28 | render(); 29 | const image = screen.getByTestId(testId); 30 | expect(image).toBeDefined(); 31 | expect(image).toHaveAttribute('height', String(height)); 32 | expect(image).toHaveAttribute('width', String(width)); 33 | }); 34 | 35 | it('supports .title property', () => { 36 | const testId = randomText(8); 37 | const title = randomText(16); 38 | render(); 39 | const image = screen.getByTestId(testId); 40 | expect(image).toBeDefined(); 41 | expect(image).toHaveAttribute('title', title); 42 | expect(image).toHaveAttribute('alt', title); // When title is provided, it is used as alt 43 | }); 44 | 45 | it('supports .alt property even when .title is provided', () => { 46 | const testId = randomText(8); 47 | const title = randomText(16); 48 | const alt = randomText(32); 49 | render(); 50 | const image = screen.getByTestId(testId); 51 | expect(image).toBeDefined(); 52 | expect(image).toHaveAttribute('alt', alt); 53 | expect(image).toHaveAttribute('title', title); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/layout/PublicLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, PropsWithChildren } from 'react'; 2 | import { Stack } from '@mui/material'; 3 | import { IS_DEBUG } from '@/config'; 4 | import { LinkToPage } from '@/utils'; 5 | import { useIsMobile } from '@/hooks'; 6 | import { BottomBar } from './components'; 7 | import TopBarAndSideBarLayout from './TopBarAndSideBarLayout'; 8 | import { BOTTOM_BAR_DESKTOP_VISIBLE } from './config'; 9 | 10 | // TODO: change to your app name or other word 11 | const TITLE_PUBLIC = 'Unauthorized - _TITLE_'; // Title for pages without/before authentication 12 | 13 | /** 14 | * SideBar navigation items with links for Public Layout 15 | */ 16 | const SIDE_BAR_ITEMS: Array = [ 17 | { 18 | title: 'Log In', 19 | path: '/auth/login', 20 | icon: 'login', 21 | }, 22 | { 23 | title: 'Sign Up', 24 | path: '/auth/signup', 25 | icon: 'signup', 26 | }, 27 | { 28 | title: 'About', 29 | path: '/about', 30 | icon: 'info', 31 | }, 32 | ]; 33 | 34 | // Add debug links 35 | IS_DEBUG && 36 | SIDE_BAR_ITEMS.push({ 37 | title: '[Debug Tools]', 38 | path: '/dev', 39 | icon: 'settings', 40 | }); 41 | 42 | /** 43 | * BottomBar navigation items with links for Public Layout 44 | */ 45 | const BOTTOM_BAR_ITEMS: Array = [ 46 | { 47 | title: 'Log In', 48 | path: '/auth/login', 49 | icon: 'login', 50 | }, 51 | { 52 | title: 'Sign Up', 53 | path: '/auth/signup', 54 | icon: 'signup', 55 | }, 56 | { 57 | title: 'About', 58 | path: '/about', 59 | icon: 'info', 60 | }, 61 | ]; 62 | 63 | /** 64 | * Renders "Public Layout" composition 65 | * @layout PublicLayout 66 | */ 67 | const PublicLayout: FunctionComponent = ({ children }) => { 68 | const onMobile = useIsMobile(); 69 | const bottomBarVisible = onMobile || BOTTOM_BAR_DESKTOP_VISIBLE; 70 | 71 | const title = TITLE_PUBLIC; 72 | document.title = title; // Also Update Tab Title // TODO: Do we need this? Move it to useEffect()? 73 | 74 | return ( 75 | 76 | {children} 77 | {bottomBarVisible && } 78 | 79 | ); 80 | }; 81 | 82 | export default PublicLayout; 83 | -------------------------------------------------------------------------------- /src/components/common/AppIcon/config.ts: -------------------------------------------------------------------------------- 1 | // SVG assets 2 | import PencilIcon from './icons/PencilIcon'; 3 | // MUI Icons 4 | import DefaultIcon from '@mui/icons-material/MoreHoriz'; 5 | import SettingsIcon from '@mui/icons-material/Settings'; 6 | import VisibilityIcon from '@mui/icons-material/Visibility'; 7 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; 8 | import MenuIcon from '@mui/icons-material/Menu'; 9 | import CloseIcon from '@mui/icons-material/Close'; 10 | import DayNightIcon from '@mui/icons-material/Brightness4'; 11 | import NightIcon from '@mui/icons-material/Brightness3'; 12 | import DayIcon from '@mui/icons-material/Brightness5'; 13 | import SearchIcon from '@mui/icons-material/Search'; 14 | import InfoIcon from '@mui/icons-material/Info'; 15 | import HomeIcon from '@mui/icons-material/Home'; 16 | import AccountCircle from '@mui/icons-material/AccountCircle'; 17 | import PersonAddIcon from '@mui/icons-material/PersonAdd'; 18 | import PersonIcon from '@mui/icons-material/Person'; 19 | import ExitToAppIcon from '@mui/icons-material/ExitToApp'; 20 | import NotificationsIcon from '@mui/icons-material/NotificationsOutlined'; 21 | import DangerousIcon from '@mui/icons-material/Dangerous'; 22 | 23 | /** 24 | * List of all available Icon names 25 | */ 26 | export type IconName = keyof typeof ICONS; 27 | 28 | /** 29 | * How to use: 30 | * 1. Import all required React, MUI or other SVG icons into this file. 31 | * 2. Add icons with "unique lowercase names" into ICONS object. Lowercase is a must! 32 | * 3. Use icons everywhere in the App by their names in component 33 | * Important: properties of ICONS object MUST be lowercase! 34 | * Note: You can use camelCase or UPPERCASE in the component 35 | */ 36 | export const ICONS /* Note: Setting type disables property autocomplete :( was - : Record */ = { 37 | default: DefaultIcon, 38 | logo: PencilIcon, 39 | close: CloseIcon, 40 | menu: MenuIcon, 41 | settings: SettingsIcon, 42 | visibilityon: VisibilityIcon, 43 | visibilityoff: VisibilityOffIcon, 44 | daynight: DayNightIcon, 45 | night: NightIcon, 46 | day: DayIcon, 47 | search: SearchIcon, 48 | info: InfoIcon, 49 | home: HomeIcon, 50 | account: AccountCircle, 51 | signup: PersonAddIcon, 52 | login: PersonIcon, 53 | logout: ExitToAppIcon, 54 | notifications: NotificationsIcon, 55 | error: DangerousIcon, 56 | }; 57 | -------------------------------------------------------------------------------- /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 | /** 9 | * Returns the value of the environment variable with the given name, raises an error if it is required and not set. 10 | * Note: My not work with Next.js on client-side code. 11 | * @param {string} name - The name of the environment variable to get: e.g. XXX_YYY_PUBLIC_URL 12 | * @param {boolean} [isRequired] - Whether the environment variable is required or not. 13 | * @param {string} [defaultValue] - The default value to return if the environment variable is not set. 14 | * @returns {string} The value of the environment variable with the given name. 15 | */ 16 | export function envGet( 17 | name: string, 18 | isRequired = false, 19 | defaultValue: string | undefined = undefined 20 | ): string | undefined { 21 | let variable = process.env[name]; // Classic way 22 | // let variable = import.meta.env[name]; // Vite way 23 | 24 | if (typeof variable === 'undefined') { 25 | if (isRequired) { 26 | throw new Error(`Missing process.env.${name} variable`); 27 | } 28 | variable = defaultValue; 29 | } 30 | return variable; 31 | } 32 | 33 | /** 34 | * Verifies existence of environment variables, raises an error if it is required and not set. 35 | * @example const MY_VARIABLE = requireEnv(process.env.MY_VARIABLE); 36 | * @param {string} [passProcessDotEnvDotValueNameHere] - Pass a value of process.env.MY_VARIABLE here, not just a name! 37 | * @returns {string} The value of incoming parameter. 38 | * @throws Error "Missing .env variable!" 39 | */ 40 | export function envRequired(passProcessDotEnvDotValueNameHere: string | undefined): string { 41 | if (typeof passProcessDotEnvDotValueNameHere === 'undefined') { 42 | throw new Error('Missing .env variable!'); 43 | } 44 | return passProcessDotEnvDotValueNameHere; 45 | } 46 | 47 | export function getCurrentVersion(): string { 48 | return process.env.npm_package_version ?? process.env.NEXT_PUBLIC_VERSION ?? 'unknown'; 49 | } 50 | 51 | export function getCurrentEnvironment(): string { 52 | return process.env.NEXT_PUBLIC_ENV ?? process.env?.NODE_ENV ?? 'development'; 53 | } 54 | -------------------------------------------------------------------------------- /src/store/AppStore.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { 3 | createContext, 4 | useReducer, 5 | useContext, 6 | FunctionComponent, 7 | PropsWithChildren, 8 | Dispatch, 9 | ComponentType, 10 | } from 'react'; 11 | // import useMediaQuery from '@mui/material/useMediaQuery'; 12 | import AppReducer from './AppReducer'; 13 | import { localStorageGet } from '../utils/localStorage'; 14 | import { IS_SERVER } from '../utils/environment'; 15 | import { APP_STORE_INITIAL_STATE, AppStoreState } from './config'; 16 | 17 | /** 18 | * Instance of React Context for global AppStore 19 | */ 20 | export type AppContextReturningType = [AppStoreState, Dispatch]; 21 | const AppContext = createContext([APP_STORE_INITIAL_STATE, () => null]); 22 | 23 | /** 24 | * Main global Store as HOC with React Context API 25 | * @component AppStoreProvider 26 | * import {AppStoreProvider} from './store' 27 | * ... 28 | * 29 | * 30 | * 31 | */ 32 | const AppStoreProvider: FunctionComponent = ({ children }) => { 33 | // const prefersDarkMode = IS_SERVER ? false : useMediaQuery('(prefers-color-scheme: dark)'); // Note: Conditional hook is bad idea :( 34 | const prefersDarkMode = IS_SERVER ? false : window.matchMedia('(prefers-color-scheme: dark)').matches; 35 | const previousDarkMode = IS_SERVER ? false : Boolean(localStorageGet('darkMode', false)); 36 | // const tokenExists = Boolean(loadToken()); 37 | 38 | const initialState: AppStoreState = { 39 | ...APP_STORE_INITIAL_STATE, 40 | darkMode: previousDarkMode || prefersDarkMode, 41 | // isAuthenticated: tokenExists, 42 | }; 43 | const value: AppContextReturningType = useReducer(AppReducer, initialState); 44 | 45 | return {children}; 46 | }; 47 | 48 | /** 49 | * Hook to use the AppStore in functional components 50 | * @hook useAppStore 51 | * import {useAppStore} from './store' 52 | * ... 53 | * const [state, dispatch] = useAppStore(); 54 | * OR 55 | * const [state] = useAppStore(); 56 | */ 57 | const useAppStore = (): AppContextReturningType => useContext(AppContext); 58 | 59 | /** 60 | * HOC to inject the ApStore to class component, also works for functional components 61 | * @hok withAppStore 62 | * import {withAppStore} from './store' 63 | * ... 64 | * class MyComponent 65 | * 66 | * render () { 67 | * const [state, dispatch] = this.props.appStore; 68 | * ... 69 | * } 70 | * ... 71 | * export default withAppStore(MyComponent) 72 | */ 73 | interface WithAppStoreProps { 74 | appStore: AppContextReturningType; 75 | } 76 | const withAppStore = (Component: ComponentType): FunctionComponent => 77 | function ComponentWithAppStore(props) { 78 | return ; 79 | }; 80 | 81 | export { AppStoreProvider, useAppStore, withAppStore }; 82 | -------------------------------------------------------------------------------- /src/components/common/AppIcon/AppIcon.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import AppIcon from './AppIcon'; 3 | import { APP_ICON_SIZE } from '../../config'; 4 | import { randomColor, randomText } from '@/utils'; 5 | import { ICONS } from './config'; 6 | 7 | const ComponentToTest = AppIcon; 8 | 9 | /** 10 | * Tests for component 11 | */ 12 | describe(' component', () => { 13 | it('renders itself', () => { 14 | const testId = randomText(8); 15 | render(); 16 | const svg = screen.getByTestId(testId); 17 | expect(svg).toBeDefined(); 18 | expect(svg).toHaveAttribute('data-icon', 'default'); 19 | expect(svg).toHaveAttribute('size', String(APP_ICON_SIZE)); // default size 20 | expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set 21 | expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se 22 | }); 23 | 24 | it('supports .color property', () => { 25 | const testId = randomText(8); 26 | const color = randomColor(); // Note: 'rgb(255, 128, 0)' format is used by react-icons npm, so tests may fail 27 | render(); 28 | const svg = screen.getByTestId(testId); 29 | expect(svg).toHaveAttribute('data-icon', 'default'); 30 | // expect(svg).toHaveAttribute('color', color); // TODO: Looks like MUI Icons exclude .color property from rendering 31 | expect(svg).toHaveStyle(`color: ${color}`); 32 | expect(svg).toHaveAttribute('fill', 'currentColor'); // .fill must be 'currentColor' when .color property is set 33 | }); 34 | 35 | it('supports .icon property', () => { 36 | // Verify that all icons are supported 37 | for (const icon of Object.keys(ICONS)) { 38 | const testId = randomText(8); 39 | render(); 40 | const svg = screen.getByTestId(testId); 41 | expect(svg).toBeDefined(); 42 | expect(svg).toHaveAttribute('data-icon', icon.toLowerCase()); 43 | } 44 | }); 45 | 46 | it('supports .size property', () => { 47 | const testId = randomText(8); 48 | const size = Math.floor(Math.random() * 128) + 1; 49 | render(); 50 | const svg = screen.getByTestId(testId); 51 | expect(svg).toHaveAttribute('size', String(size)); 52 | expect(svg).toHaveAttribute('height', String(size)); 53 | expect(svg).toHaveAttribute('width', String(size)); 54 | }); 55 | 56 | it('supports .title property', () => { 57 | const testId = randomText(8); 58 | const title = randomText(16); 59 | render(); 60 | const svg = screen.getByTestId(testId); 61 | expect(svg).toBeDefined(); 62 | expect(svg).toHaveAttribute('title', title); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/app/dev/components/DemoAppButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useState } from 'react'; 3 | import copyToClipboard from 'copy-to-clipboard'; 4 | import { Card, CardContent, CardHeader, Snackbar } from '@mui/material'; 5 | import { AppButton } from '@/components'; 6 | import { AppButtonProps } from '@/components/common/AppButton/AppButton'; 7 | 8 | /** 9 | * Same as AppButton but with onClick handler that copies JSX code to Clipboard 10 | * @component InternalAppButton 11 | */ 12 | const InternalAppButton = (props: AppButtonProps) => { 13 | const [snackbarOpen, setSnackbarOpen] = useState(false); 14 | 15 | const onClick = () => { 16 | const { color, endIcon, href, startIcon, size, title, to } = props; 17 | 18 | const propsToPass = [ 19 | color && `color="${color}"`, 20 | endIcon && `endIcon="${endIcon}"`, 21 | href && `href="${href}"`, 22 | startIcon && `startIcon="${startIcon}"`, 23 | size && `size="${size}"`, 24 | title && `title="${title}"`, 25 | to && `to="${to}"`, 26 | ] 27 | .filter(Boolean) 28 | .join(' '); 29 | 30 | const code = ``; 31 | copyToClipboard(code); 32 | setSnackbarOpen(true); // Show snackbar 33 | setTimeout(() => setSnackbarOpen(false), 3000); // Hide snackbar after small delay 34 | }; 35 | 36 | return ( 37 | <> 38 | 39 | 47 | 48 | ); 49 | }; 50 | 51 | /** 52 | * Renders "Demo Section" for AppButton component 53 | * @component DemoAppButton 54 | */ 55 | const DemoAppButton = () => { 56 | return ( 57 | 58 | 62 | 63 | primary 64 | secondary 65 | success 66 | error 67 | info 68 | warning 69 | 70 | Red 71 | 72 | 73 | Green 74 | 75 | 76 | Blue 77 | 78 | 79 | #f0f 80 | 81 | 82 | rgba(255, 0, 255, 0.5) 83 | 84 | 85 | 86 | ); 87 | }; 88 | 89 | export default DemoAppButton; 90 | -------------------------------------------------------------------------------- /src/hooks/layout.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect, useState } from 'react'; 3 | import useWindowsSize from './useWindowSize'; 4 | import { useMediaQuery, useTheme } from '@mui/material'; 5 | import { IS_SERVER } from '@/utils'; 6 | 7 | export const MOBILE_SCREEN_MAX_WIDTH = 600; // Sync with https://mui.com/material-ui/customization/breakpoints/ 8 | export const SERVER_SIDE_MOBILE_FIRST = true; // true - for mobile, false - for desktop 9 | 10 | /** 11 | * Hook to detect onMobile vs. onDesktop using "resize" event listener 12 | * @returns {boolean} true when on onMobile, false when on onDesktop 13 | */ 14 | export function useIsMobileByWindowsResizing() { 15 | const theme = useTheme(); 16 | const { width } = useWindowsSize(); 17 | const onMobile = width <= theme.breakpoints?.values?.sm ?? MOBILE_SCREEN_MAX_WIDTH; 18 | return onMobile; 19 | } 20 | 21 | /** 22 | * Hook to detect onMobile vs. onDesktop using Media Query 23 | * @returns {boolean} true when on onMobile, false when on onDesktop 24 | */ 25 | function useIsMobileByMediaQuery() { 26 | // const onMobile = useMediaQuery({ maxWidth: MOBILE_SCREEN_MAX_WIDTH }); 27 | const theme = useTheme(); 28 | const onMobile = useMediaQuery(theme.breakpoints.down('sm')); 29 | return onMobile; 30 | } 31 | 32 | /** 33 | * Hook to detect onMobile vs. onDesktop with Next.js workaround 34 | * @returns {boolean} true when on onMobile, false when on onDesktop 35 | */ 36 | function useIsMobileForNextJs() { 37 | // const onMobile = useOnMobileByWindowsResizing(); 38 | const onMobile = useIsMobileByMediaQuery(); 39 | const [onMobileDelayed, setOnMobileDelayed] = useState(SERVER_SIDE_MOBILE_FIRST); 40 | 41 | useEffect(() => { 42 | setOnMobileDelayed(onMobile); // Next.js don't allow to use useOnMobileXxx() directly, so we need to use this workaround 43 | }, [onMobile]); 44 | 45 | return onMobileDelayed; 46 | } 47 | 48 | /** 49 | * Hook to apply "onMobile" vs. "onDesktop" class to document.body depending on screen size. 50 | * Due to SSR/SSG we can not set 'app-layout onMobile' or 'app-layout onDesktop' on the server 51 | * If we modify className using JS, we will got Warning: Prop `className` did not match. Server: "app-layout" Client: "app-layout onDesktop" 52 | * So we have to apply document.body.class using the hook :) 53 | * Note: Use this hook one time only! In main App or Layout component 54 | */ 55 | function useMobileOrDesktopByChangingBodyClass() { 56 | // const onMobile = useOnMobileByWindowsResizing(); 57 | const onMobile = useIsMobileByMediaQuery(); 58 | 59 | useEffect(() => { 60 | if (onMobile) { 61 | document.body.classList.remove('onDesktop'); 62 | document.body.classList.add('onMobile'); 63 | } else { 64 | document.body.classList.remove('onMobile'); 65 | document.body.classList.add('onDesktop'); 66 | } 67 | }, [onMobile]); 68 | } 69 | 70 | /** 71 | * We need a "smart export wrappers", because we can not use hooks on the server side 72 | */ 73 | // export const useOnMobile = IS_SERVER ? () => SERVER_SIDE_IS_MOBILE_VALUE : useOnMobileByWindowsResizing; 74 | // export const useOnMobile = IS_SERVER ? () => SERVER_SIDE_IS_MOBILE_VALUE : useOnMobileByMediaQuery; 75 | export const useIsMobile = IS_SERVER ? () => SERVER_SIDE_MOBILE_FIRST : useIsMobileForNextJs; 76 | export const useBodyClassForMobileOrDesktop = IS_SERVER ? () => undefined : useMobileOrDesktopByChangingBodyClass; 77 | -------------------------------------------------------------------------------- /src/layout/components/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, useCallback, MouseEvent } from 'react'; 2 | import { Stack, Divider, Drawer, DrawerProps, FormControlLabel, Switch, Tooltip } from '@mui/material'; 3 | import { useAppStore } from '@/store'; 4 | import { LinkToPage } from '@/utils'; 5 | import { useEventLogout, useEventSwitchDarkMode, useIsAuthenticated, useIsMobile } from '@/hooks'; 6 | import { AppIconButton, UserInfo } from '@/components'; 7 | import { SIDE_BAR_WIDTH, TOP_BAR_DESKTOP_HEIGHT } from '../config'; 8 | import SideBarNavList from './SideBarNavList'; 9 | 10 | export interface SideBarProps extends Pick { 11 | items: Array; 12 | } 13 | 14 | /** 15 | * Renders SideBar with Menu and User details 16 | * Actually for Authenticated users only, rendered in "Private Layout" 17 | * @component SideBar 18 | * @param {string} anchor - 'left' or 'right' 19 | * @param {boolean} open - the Drawer is visible when true 20 | * @param {string} variant - variant of the Drawer, one of 'permanent', 'persistent', 'temporary' 21 | * @param {function} onClose - called when the Drawer is closing 22 | */ 23 | const SideBar: FunctionComponent = ({ anchor, open, variant, items, onClose, ...restOfProps }) => { 24 | const [state] = useAppStore(); 25 | // const isAuthenticated = state.isAuthenticated; // Variant 1 26 | const isAuthenticated = useIsAuthenticated(); // Variant 2 27 | const onMobile = useIsMobile(); 28 | 29 | const onSwitchDarkMode = useEventSwitchDarkMode(); 30 | const onLogout = useEventLogout(); 31 | 32 | const handleAfterLinkClick = useCallback( 33 | (event: MouseEvent) => { 34 | if (variant === 'temporary' && typeof onClose === 'function') { 35 | onClose(event, 'backdropClick'); 36 | } 37 | }, 38 | [variant, onClose] 39 | ); 40 | 41 | return ( 42 | 55 | 63 | {isAuthenticated && ( 64 | <> 65 | 66 | 67 | 68 | )} 69 | 70 | 71 | 72 | 73 | 74 | 83 | 84 | } 87 | /> 88 | 89 | 90 | {isAuthenticated && } 91 | 92 | 93 | 94 | ); 95 | }; 96 | 97 | export default SideBar; 98 | -------------------------------------------------------------------------------- /src/components/common/AppIconButton/AppIconButton.tsx: -------------------------------------------------------------------------------- 1 | import { ElementType, FunctionComponent, useMemo } from 'react'; 2 | import { Tooltip, IconButton, IconButtonProps, TooltipProps } from '@mui/material'; 3 | import AppIcon from '../AppIcon'; 4 | import AppLink from '../AppLink'; 5 | import { alpha } from '@mui/material'; 6 | import { Props } from '../AppIcon/AppIcon'; 7 | import { IconName } from '../AppIcon/config'; 8 | 9 | export const MUI_ICON_BUTTON_COLORS = [ 10 | 'inherit', 11 | 'default', 12 | 'primary', 13 | 'secondary', 14 | 'success', 15 | 'error', 16 | 'info', 17 | 'warning', 18 | ]; 19 | 20 | export interface AppIconButtonProps extends Omit { 21 | color?: string; // Not only 'inherit' | 'default' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning', 22 | icon?: IconName | string; 23 | iconProps?: Partial; 24 | // Missing props 25 | component?: ElementType; // Could be RouterLink, AppLink, , etc. 26 | to?: string; // Link prop 27 | href?: string; // Link prop 28 | openInNewTab?: boolean; // Link prop 29 | tooltipProps?: Partial; 30 | } 31 | 32 | /** 33 | * Renders MUI IconButton with SVG image by given Icon name 34 | * @param {string} [color] - color of background and hover effect. Non MUI values is also accepted. 35 | * @param {boolean} [disabled] - the IconButton is not active when true, also the Tooltip is not rendered. 36 | * @param {string} [href] - external link URI 37 | * @param {string} [icon] - name of Icon to render inside the IconButton 38 | * @param {object} [iconProps] - additional props to pass into the AppIcon component 39 | * @param {boolean} [openInNewTab] - link will be opened in new tab when true 40 | * @param {string} [size] - size of the button: 'small', 'medium' or 'large' 41 | * @param {Array | func | object} [sx] - additional CSS styles to apply to the button 42 | * @param {string} [title] - when set, the IconButton is rendered inside Tooltip with this text 43 | * @param {string} [to] - internal link URI 44 | * @param {object} [tooltipProps] - additional props to pass into the Tooltip component 45 | */ 46 | const AppIconButton: FunctionComponent = ({ 47 | color = 'default', 48 | component, 49 | children, 50 | disabled, 51 | icon, 52 | iconProps, 53 | sx, 54 | title, 55 | tooltipProps, 56 | ...restOfProps 57 | }) => { 58 | const componentToRender = !component && (restOfProps?.href || restOfProps?.to) ? AppLink : component ?? IconButton; 59 | 60 | const isMuiColor = useMemo(() => MUI_ICON_BUTTON_COLORS.includes(color), [color]); 61 | 62 | const iconButtonToRender = useMemo(() => { 63 | const colorToRender = isMuiColor ? (color as IconButtonProps['color']) : 'default'; 64 | const sxToRender = { 65 | ...sx, 66 | ...(!isMuiColor && { 67 | color: color, 68 | ':hover': { 69 | backgroundColor: alpha(color, 0.04), 70 | }, 71 | }), 72 | }; 73 | return ( 74 | 81 | 82 | {children} 83 | 84 | ); 85 | }, [color, componentToRender, children, disabled, icon, isMuiColor, sx, iconProps, restOfProps]); 86 | 87 | // When title is set, wrap the IconButton with Tooltip. 88 | // Note: when IconButton is disabled the Tooltip is not working, so we don't need it 89 | return title && !disabled ? ( 90 | 91 | {iconButtonToRender} 92 | 93 | ) : ( 94 | iconButtonToRender 95 | ); 96 | }; 97 | 98 | export default AppIconButton; 99 | -------------------------------------------------------------------------------- /src/components/common/AppButton/AppButton.tsx: -------------------------------------------------------------------------------- 1 | import { ElementType, FunctionComponent, ReactNode, useMemo } from 'react'; 2 | import Button, { ButtonProps } from '@mui/material/Button'; 3 | import AppIcon from '../AppIcon'; 4 | import AppLink from '../AppLink'; 5 | import { APP_BUTTON_VARIANT } from '../../config'; 6 | 7 | const MUI_BUTTON_COLORS = ['inherit', 'primary', 'secondary', 'success', 'error', 'info', 'warning']; 8 | 9 | const DEFAULT_SX_VALUES = { 10 | margin: 1, // By default the AppButton has theme.spacing(1) margin on all sides 11 | }; 12 | 13 | export interface AppButtonProps extends Omit { 14 | color?: string; // Not only 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning', 15 | endIcon?: string | ReactNode; 16 | label?: string; // Alternate to .text 17 | text?: string; // Alternate to .label 18 | startIcon?: string | ReactNode; 19 | // Missing props 20 | component?: ElementType; // Could be RouterLink, AppLink, , etc. 21 | to?: string; // Link prop 22 | href?: string; // Link prop 23 | openInNewTab?: boolean; // Link prop 24 | underline?: 'none' | 'hover' | 'always'; // Link prop 25 | } 26 | 27 | /** 28 | * Application styled Material UI Button with Box around to specify margins using props 29 | * @component AppButton 30 | * @param {string} [color] - when passing MUI value ('primary', 'secondary', and so on), it is color of the button body, otherwise it is color of text and icons 31 | * @param {string} [children] - content to render, overrides .label and .text props 32 | * @param {string | ReactNode} [endIcon] - name of AppIcon or ReactNode to show after the button label 33 | * @param {string} [href] - external link URI 34 | * @param {string} [label] - text to render, alternate to .text 35 | * @param {boolean} [openInNewTab] - link will be opened in new tab when true 36 | * @param {string | ReactNode} [startIcon] - name of AppIcon or ReactNode to show before the button label 37 | * @param {Array | func | object} [sx] - additional CSS styles to apply to the button 38 | * @param {string} [text] - text to render, alternate to .label 39 | * @param {string} [to] - internal link URI 40 | * @param {string} [underline] - controls underline style when button used as link, one of 'none', 'hover', or 'always' 41 | * @param {string} [variant] - MUI variant of the button, one of 'text', 'outlined', or 'contained' 42 | */ 43 | const AppButton: FunctionComponent = ({ 44 | children, 45 | color: propColor = 'inherit', 46 | component: propComponent, 47 | endIcon, 48 | label, 49 | startIcon, 50 | sx: propSx = DEFAULT_SX_VALUES, 51 | text, 52 | underline = 'none', 53 | variant = APP_BUTTON_VARIANT, 54 | ...restOfProps 55 | }) => { 56 | const iconStart: ReactNode = useMemo( 57 | () => (!startIcon ? undefined : typeof startIcon === 'string' ? : startIcon), 58 | [startIcon] 59 | ); 60 | 61 | const iconEnd: ReactNode = useMemo( 62 | () => (!endIcon ? undefined : typeof endIcon === 'string' ? : endIcon), 63 | [endIcon] 64 | ); 65 | 66 | const isMuiColor = useMemo(() => MUI_BUTTON_COLORS.includes(propColor), [propColor]); 67 | 68 | const componentToRender = 69 | !propComponent && (restOfProps?.href || restOfProps?.to) ? AppLink : propComponent ?? Button; 70 | 71 | const colorToRender = isMuiColor ? (propColor as ButtonProps['color']) : 'inherit'; 72 | const sxToRender = { 73 | ...propSx, 74 | ...(isMuiColor ? {} : { color: propColor }), 75 | }; 76 | 77 | return ( 78 | 89 | ); 90 | }; 91 | 92 | export default AppButton; 93 | -------------------------------------------------------------------------------- /src/app/dev/components/DemoAppIconButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useState } from 'react'; 3 | import copyToClipboard from 'copy-to-clipboard'; 4 | import { Box, Card, CardContent, CardHeader, Snackbar, Tooltip } from '@mui/material'; 5 | import AppIconButton, { AppIconButtonProps } from '@/components/common/AppIconButton/AppIconButton'; 6 | 7 | /** 8 | * Same as AppIconButton but with onClick handler that copies JSX code to Clipboard 9 | * @component InternalAppIconButton 10 | */ 11 | const InternalAppIconButton = (props: AppIconButtonProps) => { 12 | const [snackbarOpen, setSnackbarOpen] = useState(false); 13 | 14 | const onClick = () => { 15 | const { icon, color, href, size, title, to } = props; 16 | 17 | const propsToPass = [ 18 | icon && `icon="${icon}"`, 19 | color && `color="${color}"`, 20 | href && `href="${href}"`, 21 | size && `size="${size}"`, 22 | title && `title="${title}"`, 23 | to && `to="${to}"`, 24 | ] 25 | .filter(Boolean) 26 | .join(' '); 27 | 28 | const code = ``; 29 | copyToClipboard(code); 30 | setSnackbarOpen(true); // Show snackbar 31 | setTimeout(() => setSnackbarOpen(false), 3000); // Hide snackbar after small delay 32 | }; 33 | 34 | return ( 35 | <> 36 | 37 | 45 | 46 | ); 47 | }; 48 | 49 | /** 50 | * Renders "Demo Section" for AppIconButton component 51 | * @component DemoAppIconButton 52 | */ 53 | const DemoAppIconButton = () => { 54 | return ( 55 | 56 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 83 | 90 | 91 | 96 | 97 | 98 | 99 | ); 100 | }; 101 | 102 | export default DemoAppIconButton; 103 | -------------------------------------------------------------------------------- /src/layout/TopBarAndSideBarLayout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { FunctionComponent, useMemo, useState } from 'react'; 3 | import { Stack, StackProps } from '@mui/material'; 4 | import { IS_DEBUG } from '@/config'; 5 | import { AppIconButton, ErrorBoundary } from '@/components'; 6 | import { useAppStore } from '@/store'; 7 | import { LinkToPage } from '@/utils'; 8 | import { useEventSwitchDarkMode, useIsMobile } from '@/hooks'; 9 | import { TopBar } from './components'; 10 | import SideBar, { SideBarProps } from './components/SideBar'; 11 | import { 12 | SIDE_BAR_DESKTOP_ANCHOR, 13 | SIDE_BAR_MOBILE_ANCHOR, 14 | SIDE_BAR_WIDTH, 15 | TOP_BAR_DESKTOP_HEIGHT, 16 | TOP_BAR_MOBILE_HEIGHT, 17 | } from './config'; 18 | 19 | interface Props extends StackProps { 20 | sidebarItems: Array; 21 | title: string; 22 | variant: 'sidebarAlwaysTemporary' | 'sidebarPersistentOnDesktop' | 'sidebarAlwaysPersistent'; 23 | } 24 | 25 | /** 26 | * Renders "TopBar and SideBar" composition 27 | * @layout TopBarAndSideBarLayout 28 | */ 29 | const TopBarAndSideBarLayout: FunctionComponent = ({ children, sidebarItems, title, variant }) => { 30 | const [state] = useAppStore(); 31 | const [sidebarVisible, setSidebarVisible] = useState(false); // TODO: Verify is default value is correct 32 | const onMobile = useIsMobile(); 33 | const onSwitchDarkMode = useEventSwitchDarkMode(); 34 | 35 | const sidebarProps = useMemo((): Partial => { 36 | const anchor = onMobile ? SIDE_BAR_MOBILE_ANCHOR : SIDE_BAR_DESKTOP_ANCHOR; 37 | let open = sidebarVisible; 38 | let sidebarVariant: SideBarProps['variant'] = 'temporary'; 39 | switch (variant) { 40 | case 'sidebarAlwaysTemporary': 41 | break; 42 | case 'sidebarPersistentOnDesktop': 43 | open = onMobile ? sidebarVisible : true; 44 | sidebarVariant = onMobile ? 'temporary' : 'persistent'; 45 | break; 46 | case 'sidebarAlwaysPersistent': 47 | open = true; 48 | sidebarVariant = 'persistent'; 49 | break; 50 | } 51 | return { anchor, open, variant: sidebarVariant }; 52 | }, [onMobile, sidebarVisible, variant]); 53 | 54 | const stackStyles = useMemo( 55 | () => ({ 56 | minHeight: '100vh', // Full screen height 57 | paddingTop: onMobile ? TOP_BAR_MOBILE_HEIGHT : TOP_BAR_DESKTOP_HEIGHT, 58 | paddingLeft: 59 | sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('left') 60 | ? SIDE_BAR_WIDTH 61 | : undefined, 62 | paddingRight: 63 | sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('right') 64 | ? SIDE_BAR_WIDTH 65 | : undefined, 66 | }), 67 | [onMobile, sidebarProps] 68 | ); 69 | 70 | const onSideBarOpen = () => { 71 | if (!sidebarVisible) setSidebarVisible(true); // Don't re-render Layout when SideBar is already open 72 | }; 73 | 74 | const onSideBarClose = () => { 75 | if (sidebarVisible) setSidebarVisible(false); // Don't re-render Layout when SideBar is already closed 76 | }; 77 | 78 | const LogoButton = ( 79 | 85 | ); 86 | 87 | const DarkModeButton = ( 88 | 94 | ); 95 | 96 | // Note: useMemo() is not needed for startNode, endNode. We need respect store.darkMode and so on. 97 | const { startNode, endNode } = sidebarProps?.anchor?.includes('left') 98 | ? { startNode: LogoButton, endNode: DarkModeButton } 99 | : { startNode: DarkModeButton, endNode: LogoButton }; 100 | 101 | IS_DEBUG && 102 | console.log('Render ', { 103 | onMobile, 104 | darkMode: state.darkMode, 105 | sidebarProps, 106 | }); 107 | 108 | return ( 109 | 110 | 111 | 112 | 113 | 114 | 115 | 123 | {children} 124 | 125 | 126 | ); 127 | }; 128 | 129 | export default TopBarAndSideBarLayout; 130 | -------------------------------------------------------------------------------- /src/components/common/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 { capitalize, randomColor, randomText } from '@/utils'; 5 | import { ICONS } from '../AppIcon/config'; 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/common/AppLink/AppLinkNextNavigation.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | // See: https://github.com/mui-org/material-ui/blob/6b18675c7e6204b77f4c469e113f62ee8be39178/examples/nextjs-with-typescript/src/Link.tsx 3 | /* eslint-disable jsx-a11y/anchor-has-content */ 4 | import { AnchorHTMLAttributes, forwardRef } from 'react'; 5 | import clsx from 'clsx'; 6 | import { usePathname } from 'next/navigation'; 7 | import NextLink, { LinkProps as NextLinkProps } from 'next/link'; 8 | import MuiLink, { LinkProps as MuiLinkProps } from '@mui/material/Link'; 9 | import { APP_LINK_COLOR, APP_LINK_UNDERLINE } from '../../config'; 10 | 11 | export const EXTERNAL_LINK_PROPS = { 12 | target: '_blank', 13 | rel: 'noopener noreferrer', 14 | }; 15 | 16 | /** 17 | * Props for NextLinkComposed component 18 | */ 19 | interface NextLinkComposedProps 20 | extends Omit, 'href'>, 21 | Omit { 22 | to: NextLinkProps['href']; 23 | linkAs?: NextLinkProps['as']; 24 | href?: NextLinkProps['href']; 25 | } 26 | 27 | /** 28 | * NextJS composed link to use with Material UI 29 | * @NextLinkComposed NextLinkComposed 30 | */ 31 | const NextLinkComposed = forwardRef(function NextLinkComposed( 32 | { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, ...restOfProps }, 33 | ref 34 | ) { 35 | return ( 36 | 46 | 47 | 48 | ); 49 | }); 50 | 51 | /** 52 | * Props for AppLinkForNext component 53 | */ 54 | export type AppLinkForNextProps = { 55 | activeClassName?: string; 56 | as?: NextLinkProps['as']; 57 | href?: string | NextLinkProps['href']; 58 | noLinkStyle?: boolean; 59 | to?: string | NextLinkProps['href']; 60 | openInNewTab?: boolean; 61 | } & Omit & 62 | Omit; 63 | 64 | /** 65 | * Material UI link for NextJS 66 | * A styled version of the Next.js Link component: https://nextjs.org/docs/#with-link 67 | * @component AppLinkForNext 68 | * @param {string} [activeClassName] - class name for active link, applied when the router.pathname matches .href or .to props 69 | * @param {string} [as] - passed to NextJS Link component in .as prop 70 | * @param {string} [className] - class name for tag or NextJS Link component 71 | * @param {object|function} children - content to wrap with tag 72 | * @param {string} [color] - color of the link 73 | * @param {boolean} [noLinkStyle] - when true, link will not have MUI styles 74 | * @param {string} [to] - internal link URI 75 | * @param {string} [href] - external link URI 76 | * @param {boolean} [openInNewTab] - link will be opened in new tab when true 77 | * @param {string} [underline] - controls "underline" style of the MUI link: 'hover' | 'always' | 'none' 78 | */ 79 | const AppLinkForNext = forwardRef(function Link(props, ref) { 80 | const { 81 | activeClassName = 'active', // This class is applied to the Link component when the router.pathname matches the href/to prop 82 | as: linkAs, 83 | className: classNameProps, 84 | href, 85 | noLinkStyle, 86 | role, // Link don't have roles, so just exclude it from ...restOfProps 87 | color = APP_LINK_COLOR, 88 | underline = APP_LINK_UNDERLINE, 89 | to, 90 | sx, 91 | openInNewTab = Boolean(href), // Open external links in new Tab by default 92 | ...restOfProps 93 | } = props; 94 | const currentPath = usePathname(); 95 | const destination = to ?? href ?? ''; 96 | const pathname = typeof destination === 'string' ? destination : destination.pathname; 97 | const className = clsx(classNameProps, { 98 | [activeClassName]: pathname == currentPath && activeClassName, 99 | }); 100 | 101 | const isExternal = 102 | typeof destination === 'string' && (destination.startsWith('http') || destination.startsWith('mailto:')); 103 | 104 | const propsToRender = { 105 | color, 106 | underline, // 'hover' | 'always' | 'none' 107 | ...(openInNewTab && EXTERNAL_LINK_PROPS), 108 | ...restOfProps, 109 | }; 110 | 111 | if (isExternal) { 112 | if (noLinkStyle) { 113 | return ; 114 | } 115 | 116 | return ; 117 | } 118 | 119 | if (noLinkStyle) { 120 | return ; 121 | } 122 | 123 | return ( 124 | 133 | ); 134 | }); 135 | 136 | export default AppLinkForNext; 137 | -------------------------------------------------------------------------------- /src/components/common/AppButton/AppButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | import { render, screen, within } from '@testing-library/react'; 3 | import { ThemeProvider } 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