├── .envrc ├── .npmrc ├── public ├── _redirects ├── vite.svg └── Logo.svg ├── src ├── vite-env.d.ts ├── utilities │ ├── interfaces │ │ ├── Request.ts │ │ ├── Common.ts │ │ └── Input.ts │ ├── constant │ │ └── index.ts │ ├── types │ │ ├── Error.ts │ │ ├── Users.ts │ │ └── Auth.ts │ └── helper │ │ └── index.ts ├── test │ ├── setup.ts │ └── views │ │ └── App.test.tsx ├── store │ ├── Common │ │ └── index.ts │ ├── Users │ │ └── selectors.ts │ └── Auth │ │ ├── Register │ │ └── selectors.ts │ │ ├── Common │ │ └── atoms.ts │ │ └── Login │ │ └── selectors.ts ├── hooks │ ├── Auth │ │ └── useAuth.ts │ └── Users │ │ └── useUserCredentials.ts ├── modules │ ├── Home │ │ └── User.tsx │ ├── Common │ │ └── Form.tsx │ └── Auth │ │ ├── Middleware │ │ ├── Auth.tsx │ │ └── Guest.tsx │ │ ├── Common │ │ ├── AuthCheckBox.tsx │ │ └── AuthFooter.tsx │ │ ├── Login │ │ ├── LoginTextField.tsx │ │ └── Content.tsx │ │ └── Register │ │ ├── Content.tsx │ │ └── RegisterTextField.tsx ├── layouts │ ├── Base │ │ └── index.tsx │ └── Auth │ │ └── index.tsx ├── views │ ├── App.tsx │ ├── Auth │ │ ├── Login │ │ │ └── index.tsx │ │ └── Register │ │ │ └── index.tsx │ └── home.tsx ├── service │ ├── Users │ │ └── index.ts │ ├── Token │ │ └── index.ts │ ├── Api │ │ └── index.ts │ └── Auth │ │ └── index.ts ├── router │ └── index.tsx ├── main.tsx ├── components │ ├── Common │ │ ├── TextField.tsx │ │ └── CheckBox.tsx │ └── Loading │ │ └── index.tsx ├── assets │ ├── logo │ │ └── Logo.svg │ └── react.svg └── server │ └── dummy.js ├── .eslintignore ├── vercel.json ├── .env.example ├── .husky └── pre-commit ├── docker-compose.yml ├── .dockerignore ├── .nginx └── default.conf ├── .prettierrc ├── tsconfig.node.json ├── shell.nix ├── .gitignore ├── index.html ├── Dockerfile ├── flake.nix ├── vitest.config.ts ├── tsconfig.json ├── flake.lock ├── vite.config.ts ├── package.json ├── .eslintrc.json └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.css 2 | *.svg 3 | *.json 4 | vite-env.d.ts 5 | vite.config.ts 6 | vitest.config.ts -------------------------------------------------------------------------------- /src/utilities/interfaces/Request.ts: -------------------------------------------------------------------------------- 1 | export interface CommonPayloadInterface { 2 | data: Array; 3 | } 4 | -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import React from "react"; 3 | global.React = React; 4 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [ 3 | { 4 | "src": "/[^.]+", 5 | "dest": "/" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | VITE_APP_TITLE=your app title 3 | VITE_BASE_URL=your base url 4 | VITE_API_URL=your api url -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn test:run 5 | yarn lint:fix 6 | yarn format 7 | -------------------------------------------------------------------------------- /src/store/Common/index.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil"; 2 | 3 | export const ErrorMessage = atom({ 4 | key: "error-message", 5 | default: "", 6 | }); 7 | -------------------------------------------------------------------------------- /src/utilities/constant/index.ts: -------------------------------------------------------------------------------- 1 | export const URL_PATH = { 2 | REGISTER: "/auth/register", 3 | LOGIN: "/auth/login", 4 | DASHBOARD: "/dashboard", 5 | }; 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | boilerplate: 4 | build: ./ 5 | container_name: "boilerplate-containerized" 6 | ports: 7 | - "80:8080" 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .direnv 3 | npm-debug.log 4 | Dockerfile* 5 | docker-compose* 6 | .dockerignore 7 | .git 8 | .gitignore 9 | README.md 10 | LICENSE 11 | .vscode 12 | -------------------------------------------------------------------------------- /src/hooks/Auth/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { isAuthenticated } from "@store/Auth/Common/atoms"; 2 | import { useRecoilValue } from "recoil"; 3 | 4 | export default (): boolean => useRecoilValue(isAuthenticated); 5 | -------------------------------------------------------------------------------- /.nginx/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 0.0.0.0:$PORT; 3 | 4 | location / { 5 | root /usr/share/nginx/html; 6 | index index.html; 7 | try_files $uri $uri/ /index.html; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "printWidth": 100, 5 | "singleQuote": false, 6 | "trailingComma": "all", 7 | "jsxSingleQuote": false, 8 | "bracketSpacing": true 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/utilities/types/Error.ts: -------------------------------------------------------------------------------- 1 | export type ErrorWithMessage = { 2 | message: string; 3 | }; 4 | 5 | export type ErrorComplete = { 6 | response: { 7 | data: { 8 | message: Array | string; 9 | }; 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/utilities/types/Users.ts: -------------------------------------------------------------------------------- 1 | export type UsersMeTypes = { 2 | candidate: object; 3 | departement: string; 4 | email: string; 5 | fullname: string; 6 | grade: string; 7 | id: number; 8 | is_chosen: boolean; 9 | role: number; 10 | }; 11 | -------------------------------------------------------------------------------- /src/hooks/Users/useUserCredentials.ts: -------------------------------------------------------------------------------- 1 | import { UserMe } from "@store/Users/selectors"; 2 | import { UsersMeTypes } from "@util/types/Users"; 3 | import { useRecoilValue } from "recoil"; 4 | 5 | export default (): Array => useRecoilValue(UserMe); 6 | -------------------------------------------------------------------------------- /src/store/Users/selectors.ts: -------------------------------------------------------------------------------- 1 | import UsersService from "@service/Users"; 2 | import { UsersMeTypes } from "@util/types/Users"; 3 | import { selector } from "recoil"; 4 | 5 | export const UserMe = selector>({ 6 | key: "user-me", 7 | get: async () => await UsersService.UserMe(), 8 | }); 9 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} 2 | }: 3 | pkgs.mkShell { 4 | name="sunartha"; 5 | buildInputs = [ 6 | pkgs.nodejs-16_x 7 | pkgs.nodePackages.yarn 8 | ]; 9 | shellHook = '' 10 | export PATH=~/.npm-packages/bin:$PATH 11 | export NODE_PATH=~/.npm-packages/lib/node_modules 12 | ''; 13 | } -------------------------------------------------------------------------------- /src/modules/Home/User.tsx: -------------------------------------------------------------------------------- 1 | import useUserCredentials from "@hooks/Users/useUserCredentials"; 2 | import { FC, ReactElement } from "react"; 3 | 4 | const User: FC = (): ReactElement => { 5 | const userData = useUserCredentials(); 6 | return Hallo {userData.map((x) => x.fullname)}; 7 | }; 8 | 9 | export default User; 10 | -------------------------------------------------------------------------------- /src/layouts/Base/index.tsx: -------------------------------------------------------------------------------- 1 | import { CommonInterface } from "@util/interfaces/Common"; 2 | import { ReactElement, FC } from "react"; 3 | 4 | const BaseLayouts: FC = ({ children }): ReactElement => { 5 | return {children}; 6 | }; 7 | 8 | export default BaseLayouts; 9 | -------------------------------------------------------------------------------- /src/modules/Common/Form.tsx: -------------------------------------------------------------------------------- 1 | import { CommonInterface } from "@util/interfaces/Common"; 2 | import { FC, ReactElement } from "react"; 3 | 4 | const Form: FC = ({ children, onSubmit, className }): ReactElement => { 5 | return ( 6 | 7 | {children} 8 | 9 | ); 10 | }; 11 | 12 | export default Form; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .direnv 12 | dist 13 | dist-ssr 14 | *.local 15 | coverage 16 | *.env 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /src/utilities/interfaces/Common.ts: -------------------------------------------------------------------------------- 1 | import { FormEventHandler, ReactNode, SetStateAction } from "react"; 2 | 3 | export interface CommonInterface { 4 | children: ReactNode; 5 | text?: string; 6 | error?: string; 7 | onChange?: SetStateAction; 8 | onSubmit?: FormEventHandler; 9 | onClick?: SetStateAction; 10 | className?: string; 11 | } 12 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Boilerplate 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/views/App.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement, Suspense } from "react"; 2 | import Router from "@router/index"; 3 | import { RouterProvider } from "react-router"; 4 | import Loading from "@components/Loading"; 5 | 6 | const App: FC = (): ReactElement => { 7 | return ( 8 | }> 9 | ; 10 | 11 | ); 12 | }; 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /src/utilities/interfaces/Input.ts: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes, ChangeEventHandler, HTMLInputTypeAttribute } from "react"; 2 | 3 | export interface InputInterface extends InputHTMLAttributes { 4 | type: HTMLInputTypeAttribute; 5 | label?: string; 6 | name: string; 7 | className?: string; 8 | labelClassName?: string; 9 | placeholder?: string; 10 | required?: boolean; 11 | onChange: ChangeEventHandler; 12 | value: string | undefined; 13 | } 14 | -------------------------------------------------------------------------------- /src/test/views/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { render, screen } from "@testing-library/react"; 3 | import App from "@views/App"; 4 | import { RecoilRoot } from "recoil"; 5 | 6 | describe("Check App", () => { 7 | test("check if app is reandering home", () => { 8 | render( 9 | 10 | 11 | , 12 | ); 13 | const text = screen.getByText(/Welcome/i); 14 | expect(text).toBeDefined; 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:gallium-alpine AS build 2 | 3 | ENV PORT=8080 4 | WORKDIR /app 5 | COPY package.json ./ 6 | COPY yarn.lock ./ 7 | RUN yarn 8 | COPY . . 9 | RUN yarn format 10 | RUN yarn lint:fix 11 | RUN yarn test:run 12 | RUN yarn build 13 | 14 | FROM nginx:alpine AS prod 15 | 16 | COPY --from=build /app/dist /usr/share/nginx/html 17 | COPY ./.nginx/default.conf /etc/nginx/conf.d/default.conf 18 | ENV PORT=8080 19 | CMD sed -i -e 's/$PORT/'"$PORT"'/g' /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;' 20 | -------------------------------------------------------------------------------- /src/store/Auth/Register/selectors.ts: -------------------------------------------------------------------------------- 1 | import AuthService from "@service/Auth"; 2 | import { selector } from "recoil"; 3 | import { AuthPayload } from "@store/Auth/Common/atoms"; 4 | import { getErrorMessage } from "@util/helper"; 5 | 6 | export const Register = selector({ 7 | key: "auth-register", 8 | get: async ({ get }) => { 9 | const payload = get(AuthPayload); 10 | try { 11 | await AuthService.Register(payload); 12 | } catch (e) { 13 | throw getErrorMessage(e); 14 | } 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/store/Auth/Common/atoms.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil"; 2 | import TokenService from "@service/Token"; 3 | import { AuthPayloadTypes } from "@util/types/Auth"; 4 | 5 | export const isAuthenticated = atom({ 6 | key: "is-auth", 7 | default: !!TokenService.getToken(), 8 | }); 9 | 10 | export const AuthPayload = atom({ 11 | key: "auth-credentials", 12 | default: { 13 | email: "", 14 | password: "", 15 | fullname: "", 16 | departement: "", 17 | student_id: "", 18 | grade: "", 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/store/Auth/Login/selectors.ts: -------------------------------------------------------------------------------- 1 | import AuthService from "@service/Auth"; 2 | import { getErrorMessage } from "@util/helper"; 3 | import { selector } from "recoil"; 4 | import { AuthPayload } from "@store/Auth/Common/atoms"; 5 | 6 | export const Login = selector({ 7 | key: "auth-login", 8 | get: async ({ get }) => { 9 | const payload = get(AuthPayload); 10 | try { 11 | await AuthService.Login(payload); 12 | window.location.reload(); 13 | } catch (e) { 14 | throw getErrorMessage(e); 15 | } 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/utilities/types/Auth.ts: -------------------------------------------------------------------------------- 1 | import { ChangeEventHandler } from "react"; 2 | 3 | export type AuthPayloadTypes = { 4 | email: string; 5 | password: string; 6 | fullname?: string; 7 | student_id?: string; 8 | grade?: string; 9 | departement?: string; 10 | }; 11 | 12 | export type AuthCheckBoxType = { 13 | label: string; 14 | subLabel: string; 15 | value: string; 16 | required?: boolean; 17 | onChange: ChangeEventHandler; 18 | }; 19 | 20 | export type AuthFooterType = { 21 | label: string; 22 | subLabel: string; 23 | buttonLabel: string; 24 | url: string; 25 | }; 26 | -------------------------------------------------------------------------------- /src/modules/Auth/Middleware/Auth.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement } from "react"; 2 | import { useLocation, Navigate } from "react-router-dom"; 3 | import useAuth from "@hooks/Auth/useAuth"; 4 | import { CommonInterface } from "@util/interfaces/Common"; 5 | 6 | const Auth: FC = ({ children }): ReactElement => { 7 | const isAuthenticated = useAuth(); 8 | const Location = useLocation(); 9 | 10 | if (!isAuthenticated) { 11 | return ; 12 | } else { 13 | return <>{children}>; 14 | } 15 | }; 16 | 17 | export default Auth; 18 | -------------------------------------------------------------------------------- /src/modules/Auth/Middleware/Guest.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement } from "react"; 2 | import { useLocation, Navigate } from "react-router-dom"; 3 | import useAuth from "@hooks/Auth/useAuth"; 4 | import { CommonInterface } from "@util/interfaces/Common"; 5 | 6 | const Guest: FC = ({ children }): ReactElement => { 7 | const isAuthenticated = useAuth(); 8 | const Location = useLocation(); 9 | 10 | if (isAuthenticated) { 11 | return ; 12 | } else { 13 | return <>{children}>; 14 | } 15 | }; 16 | 17 | export default Guest; 18 | -------------------------------------------------------------------------------- /src/service/Users/index.ts: -------------------------------------------------------------------------------- 1 | import ApiService from "@service/Api"; 2 | import { getErrorMessage } from "@util/helper/index"; 3 | 4 | const UsersService = { 5 | UserMe: async () => { 6 | const requestData = { 7 | method: "get", 8 | headers: { 9 | "Content-Type": "application/json; charset=utf-8", 10 | }, 11 | url: "/users/me/", 12 | }; 13 | try { 14 | ApiService.setHeader(); 15 | const res = await ApiService.customRequest(requestData); 16 | return res.data; 17 | } catch (error) { 18 | throw getErrorMessage(error); 19 | } 20 | }, 21 | }; 22 | export default UsersService; 23 | -------------------------------------------------------------------------------- /src/views/Auth/Login/index.tsx: -------------------------------------------------------------------------------- 1 | import Loading from "@components/Loading"; 2 | import { setTitle } from "@util/helper"; 3 | import { ReactElement, FC, lazy, Suspense } from "react"; 4 | 5 | const Guest = lazy(() => import("@modules/Auth/Middleware/Guest")); 6 | const LoginContent = lazy(() => import("@modules/Auth/Login/Content")); 7 | 8 | const Login: FC = (): ReactElement => { 9 | setTitle("Login"); 10 | return ( 11 | }> 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default Login; 20 | -------------------------------------------------------------------------------- /src/router/index.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from "react"; 2 | import { createBrowserRouter } from "react-router-dom"; 3 | 4 | const Home = lazy(() => import("@views/home")); 5 | const Register = lazy(() => import("@views/Auth/Register")); 6 | const Login = lazy(() => import("@views/Auth/Login")); 7 | 8 | const Router = createBrowserRouter([ 9 | { 10 | path: "auth/login", 11 | element: , 12 | }, 13 | { 14 | path: "auth/register", 15 | element: , 16 | }, 17 | { 18 | path: "dashboard", 19 | element: , 20 | }, 21 | { 22 | path: "/", 23 | element: "Welcome", 24 | }, 25 | ]); 26 | 27 | export default Router; 28 | -------------------------------------------------------------------------------- /src/views/Auth/Register/index.tsx: -------------------------------------------------------------------------------- 1 | import { setTitle } from "@util/helper"; 2 | import { ReactElement, FC, lazy, Suspense } from "react"; 3 | import Loading from "@components/Loading"; 4 | 5 | const Guest = lazy(() => import("@modules/Auth/Middleware/Guest")); 6 | const RegisterContent = lazy(() => import("@modules/Auth/Register/Content")); 7 | 8 | const Register: FC = (): ReactElement => { 9 | setTitle("Register"); 10 | return ( 11 | }> 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default Register; 20 | -------------------------------------------------------------------------------- /src/service/Token/index.ts: -------------------------------------------------------------------------------- 1 | const TOKEN_KEY = "access_token"; 2 | const REFRESH_TOKEN_KEY = "refresh_token"; 3 | 4 | const TokenService = { 5 | getToken() { 6 | return localStorage.getItem(TOKEN_KEY); 7 | }, 8 | 9 | saveToken(accessToken: string) { 10 | localStorage.setItem(TOKEN_KEY, accessToken); 11 | }, 12 | 13 | removeToken() { 14 | localStorage.removeItem(TOKEN_KEY); 15 | }, 16 | 17 | getRefreshToken() { 18 | return localStorage.getItem(REFRESH_TOKEN_KEY); 19 | }, 20 | 21 | saveRefreshToken(refreshToken: string) { 22 | localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); 23 | }, 24 | 25 | removeRefreshToken() { 26 | localStorage.removeItem(REFRESH_TOKEN_KEY); 27 | }, 28 | }; 29 | 30 | export default TokenService; 31 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Sunartha"; 3 | inputs.nixpkgs.url = "github:NixOS/nixpkgs/master"; 4 | inputs.flake-utils.url = "github:numtide/flake-utils"; 5 | 6 | outputs = { self, nixpkgs, flake-utils }: 7 | flake-utils.lib.eachDefaultSystem (system: let 8 | pkgs = nixpkgs.legacyPackages.${system}; 9 | in { 10 | devShell = pkgs.mkShell { 11 | nativeBuildInputs = [ pkgs.bashInteractive ]; 12 | buildInputs = with pkgs; [ 13 | nodejs-18_x 14 | nodePackages.yarn 15 | nodePackages.prettier 16 | nodePackages.typescript 17 | ]; 18 | shellHook = with pkgs; '' 19 | export PATH=~/.npm-packages/bin:$PATH 20 | export NODE_PATH=~/.npm-packages/lib/node_modules 21 | ''; 22 | }; 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "@views/App"; 4 | // eslint-disable-next-line import/no-unresolved 5 | import "uno.css"; 6 | import "@unocss/reset/tailwind.css"; 7 | import ApiService from "@service/Api"; 8 | import { RecoilRoot } from "recoil"; 9 | import TokenService from "@service/Token"; 10 | 11 | ApiService.init(import.meta.env.VITE_API_URL); 12 | 13 | if (TokenService.getToken()) { 14 | ApiService.setHeader(); 15 | ApiService.mount401Interceptor(); 16 | } else { 17 | ApiService.removeHeader(); 18 | ApiService.unmount401Interceptor(); 19 | } 20 | 21 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 22 | 23 | 24 | 25 | 26 | , 27 | ); 28 | -------------------------------------------------------------------------------- /src/components/Common/TextField.tsx: -------------------------------------------------------------------------------- 1 | import { InputInterface } from "@util/interfaces/Input"; 2 | import { FC, ReactElement } from "react"; 3 | 4 | const TextField: FC = ({ 5 | type, 6 | label, 7 | labelClassName, 8 | name, 9 | className, 10 | placeholder, 11 | required = false, 12 | onChange, 13 | value, 14 | }): ReactElement => { 15 | return ( 16 | 17 | 18 | {label} 19 | 20 | 29 | 30 | ); 31 | }; 32 | 33 | export default TextField; 34 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import path from "path"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | environment: "jsdom", 8 | setupFiles: "./src/test/setup.ts", 9 | }, 10 | resolve: { 11 | alias: { 12 | "@": path.resolve(__dirname, "./src"), 13 | "@views": path.resolve(__dirname, "./src/views"), 14 | "@components": path.resolve(__dirname, "./src/components"), 15 | "@test": path.resolve(__dirname, "./src/views/test"), 16 | "@router": path.resolve(__dirname, "./src/router"), 17 | "@assets": path.resolve(__dirname, "./src/assets"), 18 | "@layouts": path.resolve(__dirname, "./src/layouts"), 19 | "@util": path.resolve(__dirname, "./src/utilities"), 20 | "@service": path.resolve(__dirname, "./src/service"), 21 | "@modules": path.resolve(__dirname, "./src/modules"), 22 | "@store": path.resolve(__dirname, "./src/store"), 23 | "@hooks": path.resolve(__dirname, "./src/hooks"), 24 | }, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /src/modules/Auth/Common/AuthCheckBox.tsx: -------------------------------------------------------------------------------- 1 | import { FC, lazy, ReactElement, Suspense } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import type { AuthCheckBoxType } from "@util/types/Auth"; 4 | 5 | const CheckBox = lazy(() => import("@components/Common/CheckBox")); 6 | 7 | const AuthCheckBox: FC = ({ 8 | label, 9 | subLabel, 10 | value, 11 | onChange, 12 | required = true, 13 | }): ReactElement => { 14 | return ( 15 | 16 | 17 | 25 | 29 | {subLabel} 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default AuthCheckBox; 37 | -------------------------------------------------------------------------------- /src/views/home.tsx: -------------------------------------------------------------------------------- 1 | import { setTitle } from "@util/helper/index"; 2 | import { ReactElement, FC, Suspense } from "react"; 3 | import Auth from "@modules/Auth/Middleware/Auth"; 4 | import BaseLayouts from "@layouts/Base"; 5 | import User from "@modules/Home/User"; 6 | import AuthService from "@service/Auth"; 7 | 8 | const Home: FC = (): ReactElement => { 9 | setTitle("Home"); 10 | 11 | const handleLogout = (): void => { 12 | AuthService.Logout(); 13 | }; 14 | 15 | return ( 16 | 17 | 18 | 19 | Welcome Your Authenticated Now 20 | 21 | 22 | 23 | 27 | Logout 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | export default Home; 36 | -------------------------------------------------------------------------------- /src/modules/Auth/Common/AuthFooter.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement, Fragment } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import type { AuthFooterType } from "@util/types/Auth"; 4 | 5 | const AuthFooter: FC = ({ url, label, subLabel, buttonLabel }): ReactElement => { 6 | return ( 7 | 8 | 12 | {buttonLabel} 13 | 14 | 15 | {label} 16 | 20 | {subLabel} 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default AuthFooter; 28 | -------------------------------------------------------------------------------- /src/components/Common/CheckBox.tsx: -------------------------------------------------------------------------------- 1 | import { InputInterface } from "@util/interfaces/Input"; 2 | import { FC, ReactElement } from "react"; 3 | 4 | const CheckBox: FC = ({ 5 | label, 6 | labelClassName = "text-gray-500 dark:text-gray-300", 7 | name, 8 | className = "w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-primary-600 dark:ring-offset-gray-800", 9 | required = false, 10 | onChange, 11 | value, 12 | }): ReactElement => { 13 | return ( 14 | 15 | 16 | 24 | 25 | 26 | 27 | {label} 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default CheckBox; 35 | -------------------------------------------------------------------------------- /src/utilities/helper/index.ts: -------------------------------------------------------------------------------- 1 | import { ErrorComplete, ErrorWithMessage } from "@util/types/Error"; 2 | 3 | export const setTitle = (title: string): void => { 4 | document.title = import.meta.env.VITE_APP_TITLE + " | " + title; 5 | }; 6 | 7 | const isErrorWithMessage = (error: unknown): error is ErrorWithMessage => { 8 | return ( 9 | typeof error === "object" && 10 | error !== null && 11 | "message" in error && 12 | typeof (error as Record).message === "string" 13 | ); 14 | }; 15 | 16 | const toErrorWithMessage = (maybeError: unknown): ErrorWithMessage => { 17 | if (isErrorWithMessage(maybeError)) return maybeError; 18 | try { 19 | return new Error(JSON.stringify(maybeError)); 20 | } catch { 21 | return new Error(String(maybeError)); 22 | } 23 | }; 24 | 25 | export const getErrorMessage = (error: unknown): string => toErrorWithMessage(error).message; 26 | 27 | export const messageParser = (error: unknown): string => `${error}`; 28 | 29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 30 | export const handleError = (error: any): ErrorComplete => error.response.data; 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "./src", 19 | "paths": { 20 | "@modules/*": ["modules/*"], 21 | "@components/*": ["components/*"], 22 | "@util/*": ["utilities/*"], 23 | "@services/*": ["services/*"], 24 | "@hooks/*": ["hooks/*"], 25 | "@assets/*": ["assets/*"], 26 | "@test/*": ["test/*"], 27 | "@views/*": ["views/*"], 28 | "@router/*": ["router/*"], 29 | "@layouts/*": ["layouts/*"], 30 | "@service/*": ["service/*"], 31 | "@store/*": ["store/*"] 32 | } 33 | }, 34 | "include": ["src"], 35 | "references": [{ "path": "./tsconfig.node.json" }] 36 | } 37 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "locked": { 5 | "lastModified": 1659877975, 6 | "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", 7 | "owner": "numtide", 8 | "repo": "flake-utils", 9 | "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "numtide", 14 | "repo": "flake-utils", 15 | "type": "github" 16 | } 17 | }, 18 | "nixpkgs": { 19 | "locked": { 20 | "lastModified": 1663311223, 21 | "narHash": "sha256-xWWkGBlgzG+Vpw1Fv62bj2lT+lSa5M6q8eEmNWn8j/0=", 22 | "owner": "NixOS", 23 | "repo": "nixpkgs", 24 | "rev": "f5357321bace1f2b8f47868414f9ff420cbef8c3", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "owner": "NixOS", 29 | "ref": "master", 30 | "repo": "nixpkgs", 31 | "type": "github" 32 | } 33 | }, 34 | "root": { 35 | "inputs": { 36 | "flake-utils": "flake-utils", 37 | "nixpkgs": "nixpkgs" 38 | } 39 | } 40 | }, 41 | "root": "root", 42 | "version": 7 43 | } 44 | -------------------------------------------------------------------------------- /src/layouts/Auth/index.tsx: -------------------------------------------------------------------------------- 1 | import { CommonInterface } from "@util/interfaces/Common"; 2 | import { ReactElement, FC } from "react"; 3 | 4 | const AuthLayout: FC = ({ children, text, error }): ReactElement => ( 5 | 6 | 7 | 8 | 9 | 10 | {text} 11 | 12 | {error?.length !== 0 && ( 13 | 14 | 15 | {error} 16 | 17 | 18 | )} 19 | {children} 20 | 21 | 22 | 23 | 24 | ); 25 | 26 | export default AuthLayout; 27 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/Auth/Login/LoginTextField.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement, ChangeEvent, Suspense, lazy } from "react"; 2 | import { useRecoilState } from "recoil"; 3 | import { AuthPayload } from "@store/Auth/Common/atoms"; 4 | 5 | const TextField = lazy(() => import("@components/Common/TextField")); 6 | 7 | const LoginTextField: FC = (): ReactElement => { 8 | const [payload, setPayload] = useRecoilState(AuthPayload); 9 | 10 | const InputFields = [ 11 | { 12 | type: "email", 13 | label: "Email", 14 | labelClassName: "input-label-auth", 15 | name: "email", 16 | id: "email", 17 | className: "input-auth", 18 | placeholder: "maulana@psu.org", 19 | required: true, 20 | value: payload["email"], 21 | }, 22 | 23 | { 24 | type: "password", 25 | label: "Password", 26 | labelClassName: "input-label-auth", 27 | name: "password", 28 | id: "password", 29 | className: "input-auth", 30 | placeholder: "*************", 31 | required: true, 32 | value: payload["password"], 33 | }, 34 | ]; 35 | 36 | const onChange = (event: ChangeEvent): void => { 37 | setPayload({ ...payload, [event.target.name]: event.target.value }); 38 | }; 39 | 40 | return ( 41 | 42 | {InputFields.map((field, index) => ( 43 | 44 | ))} 45 | 46 | ); 47 | }; 48 | 49 | export default LoginTextField; 50 | -------------------------------------------------------------------------------- /src/service/Api/index.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import TokenService from "@service/Token"; 3 | import AuthService from "@service/Auth"; 4 | 5 | const ApiService = { 6 | _401interceptor: null || 0, 7 | 8 | init(baseURL: string) { 9 | axios.defaults.baseURL = baseURL; 10 | }, 11 | 12 | setHeader() { 13 | axios.defaults.headers.common["Authorization"] = `Bearer ${TokenService.getToken()}`; 14 | }, 15 | 16 | removeHeader() { 17 | axios.defaults.headers.common = {}; 18 | }, 19 | 20 | customRequest(data: object) { 21 | return axios(data); 22 | }, 23 | 24 | mount401Interceptor() { 25 | this._401interceptor = axios.interceptors.response.use( 26 | (response) => { 27 | return response; 28 | }, 29 | async (error) => { 30 | if (error.request.status === 401) { 31 | if (error.config.url.includes("auth/login")) { 32 | AuthService.Logout(); 33 | throw error; 34 | } else { 35 | try { 36 | const res = await AuthService.RefreshToken(); 37 | TokenService.saveRefreshToken(res?.data?.refresh_token); 38 | TokenService.saveToken(res?.data?.access_token); 39 | } catch (e) { 40 | AuthService.Logout(); 41 | throw error; 42 | } 43 | } 44 | } 45 | throw error; 46 | }, 47 | ); 48 | }, 49 | 50 | unmount401Interceptor() { 51 | axios.interceptors.response.eject(this._401interceptor); 52 | }, 53 | }; 54 | 55 | export default ApiService; 56 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import Unocss from "unocss/vite"; 4 | import path from "path"; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | Unocss({ 10 | shortcuts: { 11 | btn: "py-2 px-4 font-semibold rounded-lg shadow-md", 12 | "input-label-auth": "block mb-2 text-sm font-medium text-gray-900 dark:text-white", 13 | "input-auth": 14 | "bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500", 15 | }, 16 | theme: { 17 | colors: { 18 | primary: "#F8F9FD", 19 | nav: { 20 | primary: "#1B2430", 21 | secondary: "", 22 | }, 23 | }, 24 | }, 25 | }), 26 | ], 27 | resolve: { 28 | alias: { 29 | "@": path.resolve(__dirname, "./src"), 30 | "@views": path.resolve(__dirname, "./src/views"), 31 | "@components": path.resolve(__dirname, "./src/components"), 32 | "@test": path.resolve(__dirname, "./src/test"), 33 | "@router": path.resolve(__dirname, "./src/router"), 34 | "@assets": path.resolve(__dirname, "./src/assets"), 35 | "@layouts": path.resolve(__dirname, "./src/layouts"), 36 | "@util": path.resolve(__dirname, "./src/utilities"), 37 | "@service": path.resolve(__dirname, "./src/service"), 38 | "@modules": path.resolve(__dirname, "./src/modules"), 39 | "@store": path.resolve(__dirname, "./src/store"), 40 | "@hooks": path.resolve(__dirname, "./src/hooks"), 41 | }, 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-vite-boilerplate", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=14.0.0", 8 | "npm": "please-use-yarn", 9 | "yarn": ">=1.22.0" 10 | }, 11 | "scripts": { 12 | "dev": "vite", 13 | "build": "tsc && vite build", 14 | "preview": "vite preview", 15 | "lint": "eslint src/**/*.{ts,tsx}", 16 | "lint:fix": "eslint --fix 'src/**/*.{ts,tsx}'", 17 | "format": "prettier --write 'src/**/*.{ts,tsx,css,md,json}' --config ./.prettierrc", 18 | "test": "vitest", 19 | "test:run": "vitest run", 20 | "test:coverage": "vitest --coverage", 21 | "prepare": "husky install" 22 | }, 23 | "dependencies": { 24 | "axios": "^0.27.2", 25 | "react": "^18.2.0", 26 | "react-dom": "^18.2.0", 27 | "react-router-dom": "^6.4.0", 28 | "recoil": "^0.7.6", 29 | "unocss": "^0.47.6" 30 | }, 31 | "devDependencies": { 32 | "@testing-library/jest-dom": "^5.16.5", 33 | "@testing-library/react": "^13.4.0", 34 | "@types/react": "^18.0.20", 35 | "@types/react-dom": "^18.0.6", 36 | "@typescript-eslint/eslint-plugin": "^5.37.0", 37 | "@typescript-eslint/parser": "^5.37.0", 38 | "@unocss/reset": "^0.45.21", 39 | "@vitejs/plugin-react": "^2.1.0", 40 | "@vitest/coverage-c8": "^0.23.2", 41 | "eslint": "^8.23.1", 42 | "eslint-config-prettier": "^8.5.0", 43 | "eslint-import-resolver-typescript": "^3.5.1", 44 | "eslint-plugin-import": "^2.26.0", 45 | "eslint-plugin-prettier": "^4.2.1", 46 | "eslint-plugin-react": "^7.31.8", 47 | "eslint-plugin-react-hooks": "^4.6.0", 48 | "husky": "^8.0.1", 49 | "jsdom": "^20.0.0", 50 | "prettier": "^2.7.1", 51 | "typescript": "^4.8.3", 52 | "vite": "^4.0.1", 53 | "vitest": "^0.23.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement } from "react"; 2 | 3 | const Loading: FC<{ text?: string; fontSize?: string }> = ({ fontSize = "text-3xl", text = "Sedang Memuat..." }): ReactElement => { 4 | return ( 5 | 9 | 16 | 20 | 24 | 25 | {text} 26 | 27 | ); 28 | }; 29 | 30 | export default Loading; 31 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:import/recommended", 12 | "prettier" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaFeatures": { 17 | "jsx": true 18 | }, 19 | "ecmaVersion": "latest", 20 | "sourceType": "module" 21 | }, 22 | "plugins": ["react", "react-hooks", "@typescript-eslint", "prettier"], 23 | "settings": { 24 | "import/resolver": { 25 | "typescript": {}, 26 | "node": { 27 | "extensions": [".js", ".jsx", ".ts", ".tsx", ".css"], 28 | "moduleDirectory": ["node_modules", "src/"] 29 | } 30 | }, 31 | "ignorePatterns": ["*.css", "**/vendor/*.css", "uno.css"], 32 | "import/ignore": ["*.css", "node_modules/*"], 33 | "react": { 34 | "version": "detect" 35 | } 36 | }, 37 | "rules": { 38 | "indent": ["error", 2], 39 | "linebreak-style": ["error", "unix"], 40 | "quotes": ["error", "double"], 41 | "semi": ["error", "always"], 42 | "react/react-in-jsx-scope": "off", 43 | "react/jsx-filename-extension": ["warn", { "extensions": [".tsx"] }], 44 | "no-use-before-define": "off", 45 | "@typescript-eslint/no-use-before-define": ["error"], 46 | "import/extensions": [ 47 | "error", 48 | "ignorePackages", 49 | { 50 | "ts": "never", 51 | "tsx": "never" 52 | } 53 | ], 54 | "@typescript-eslint/explicit-function-return-type": [ 55 | "error", 56 | { 57 | "allowExpressions": true 58 | } 59 | ], 60 | "no-shadow": "off", 61 | "@typescript-eslint/no-shadow": ["error"], 62 | "react-hooks/rules-of-hooks": "error", 63 | "react-hooks/exhaustive-deps": "warn", 64 | "import/prefer-default-export": "off" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/service/Auth/index.ts: -------------------------------------------------------------------------------- 1 | import ApiService from "@service/Api"; 2 | import TokenService from "@service/Token"; 3 | import { AuthPayloadTypes } from "@util/types/Auth"; 4 | import { getErrorMessage, handleError } from "@util/helper/index"; 5 | 6 | const AuthService = { 7 | Login: async (payload: AuthPayloadTypes) => { 8 | const { email, password } = payload; 9 | const requestData = { 10 | method: "post", 11 | headers: { 12 | "Content-Type": "application/json; charset=utf-8", 13 | }, 14 | data: { 15 | email, 16 | password, 17 | }, 18 | url: "/auth/local/login", 19 | }; 20 | try { 21 | const res = await ApiService.customRequest(requestData); 22 | TokenService.saveToken(res.data.access_token); 23 | TokenService.saveRefreshToken(res.data.refresh_token); 24 | ApiService.setHeader(); 25 | } catch (error) { 26 | throw handleError(error); 27 | } 28 | }, 29 | 30 | Logout: () => { 31 | ApiService.removeHeader(); 32 | TokenService.removeToken(); 33 | TokenService.removeRefreshToken(); 34 | window.location.reload(); 35 | }, 36 | 37 | Register: async (payload: AuthPayloadTypes) => { 38 | const requestData = { 39 | method: "post", 40 | headers: { 41 | "Content-Type": "application/json; charset=utf-8", 42 | }, 43 | data: payload, 44 | url: "/auth/local/register", 45 | }; 46 | try { 47 | await ApiService.customRequest(requestData); 48 | } catch (error) { 49 | throw handleError(error); 50 | } 51 | }, 52 | 53 | RefreshToken: async () => { 54 | const requestData = { 55 | method: "post", 56 | headers: { 57 | "Content-Type": "application/json", 58 | }, 59 | data: { 60 | refresh_token: TokenService.getRefreshToken(), 61 | }, 62 | url: "/auth/refresh", 63 | }; 64 | try { 65 | const response = await ApiService.customRequest(requestData); 66 | ApiService.setHeader(); 67 | return response.data; 68 | } catch (error) { 69 | throw getErrorMessage(error); 70 | } 71 | }, 72 | }; 73 | export default AuthService; 74 | -------------------------------------------------------------------------------- /public/Logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/assets/logo/Logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/modules/Auth/Login/Content.tsx: -------------------------------------------------------------------------------- 1 | import { Login } from "@store/Auth/Login/selectors"; 2 | import { URL_PATH } from "@util/constant"; 3 | import { messageParser } from "@util/helper"; 4 | import { FC, FormEventHandler, lazy, ReactElement, Suspense, useState, useEffect } from "react"; 5 | import { useRecoilCallback } from "recoil"; 6 | 7 | const Form = lazy(() => import("@modules/Common/Form")); 8 | const AuthCheckBox = lazy(() => import("@modules/Auth/Common/AuthCheckBox")); 9 | const AuthFooter = lazy(() => import("@modules/Auth/Common/AuthFooter")); 10 | const LoginTextField = lazy(() => import("@modules/Auth/Login/LoginTextField")); 11 | const AuthLayout = lazy(() => import("@layouts/Auth")); 12 | 13 | const LoginContent: FC = (): ReactElement => { 14 | const [errorMessage, setErrorMessage] = useState(""); 15 | const [loading, setLoading] = useState(false); 16 | 17 | const LoginAction: FormEventHandler = useRecoilCallback( 18 | ({ snapshot }) => 19 | async (event) => { 20 | event.preventDefault(); 21 | try { 22 | setLoading(true); 23 | await snapshot.getPromise(Login); 24 | setLoading(false); 25 | } catch (e) { 26 | setLoading(false); 27 | setErrorMessage(messageParser(e)); 28 | } 29 | }, 30 | ); 31 | 32 | useEffect(() => { 33 | setTimeout(() => { 34 | setErrorMessage(""); 35 | }, 3000); 36 | }); 37 | 38 | return ( 39 | 40 | 41 | 42 | 43 | console.log(e.target.value)} 49 | /> 50 | 56 | 57 | 58 | 59 | ); 60 | }; 61 | 62 | export default LoginContent; 63 | -------------------------------------------------------------------------------- /src/modules/Auth/Register/Content.tsx: -------------------------------------------------------------------------------- 1 | import { Register } from "@store/Auth/Register/selectors"; 2 | import { URL_PATH } from "@util/constant"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { useRecoilCallback } from "recoil"; 5 | import { FC, ReactElement, useEffect, useState, lazy, Suspense, FormEventHandler } from "react"; 6 | import { messageParser } from "@util/helper"; 7 | 8 | const Form = lazy(() => import("@modules/Common/Form")); 9 | const AuthCheckBox = lazy(() => import("@modules/Auth/Common/AuthCheckBox")); 10 | const AuthFooter = lazy(() => import("@modules/Auth/Common/AuthFooter")); 11 | const RegisterTextField = lazy(() => import("@modules/Auth/Register/RegisterTextField")); 12 | const AuthLayout = lazy(() => import("@layouts/Auth")); 13 | 14 | const RegisterContent: FC = (): ReactElement => { 15 | const navigate = useNavigate(); 16 | const [errorMessage, setErrorMessage] = useState(""); 17 | const [loading, setLoading] = useState(false); 18 | 19 | const RegisterAction: FormEventHandler = useRecoilCallback( 20 | ({ snapshot }) => 21 | async (event) => { 22 | event.preventDefault(); 23 | try { 24 | setLoading(true); 25 | await snapshot.getPromise(Register); 26 | navigate(URL_PATH.LOGIN, { replace: true }); 27 | setLoading(false); 28 | } catch (e) { 29 | setLoading(false); 30 | setErrorMessage(messageParser(e)); 31 | } 32 | }, 33 | ); 34 | 35 | useEffect(() => { 36 | setTimeout(() => { 37 | setErrorMessage(""); 38 | }, 3000); 39 | }); 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | { 51 | console.log(e.target.value); 52 | }} 53 | /> 54 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | 66 | export default RegisterContent; 67 | -------------------------------------------------------------------------------- /src/modules/Auth/Register/RegisterTextField.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement, ChangeEvent } from "react"; 2 | import { useRecoilState } from "recoil"; 3 | import { AuthPayload } from "@store/Auth/Common/atoms"; 4 | import TextField from "@components/Common/TextField"; 5 | 6 | const RegisterTextField: FC = (): ReactElement => { 7 | const [payload, setPayload] = useRecoilState(AuthPayload); 8 | 9 | const className = 10 | "bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"; 11 | 12 | const InputFields = [ 13 | { 14 | type: "text", 15 | label: "Fullname", 16 | labelClassName: "block mb-2 text-sm font-medium text-gray-900 dark:text-white", 17 | name: "fullname", 18 | id: "fullname", 19 | className, 20 | placeholder: "Maulana Sodiqin", 21 | required: true, 22 | value: payload["fullname"], 23 | }, 24 | 25 | { 26 | type: "email", 27 | label: "Email", 28 | labelClassName: "block mb-2 text-sm font-medium text-gray-900 dark:text-white", 29 | name: "email", 30 | id: "email", 31 | className, 32 | placeholder: "maulana@psu.org", 33 | required: true, 34 | value: payload["email"], 35 | }, 36 | 37 | { 38 | type: "password", 39 | label: "Password", 40 | labelClassName: "block mb-2 text-sm font-medium text-gray-900 dark:text-white", 41 | name: "password", 42 | id: "password", 43 | className, 44 | placeholder: "*************", 45 | required: true, 46 | value: payload["password"], 47 | }, 48 | 49 | { 50 | type: "text", 51 | label: "Student ID", 52 | labelClassName: "block mb-2 text-sm font-medium text-gray-900 dark:text-white", 53 | name: "student_id", 54 | id: "student_id", 55 | className, 56 | placeholder: "4103700*****", 57 | required: true, 58 | value: payload["student_id"], 59 | }, 60 | 61 | { 62 | type: "text", 63 | label: "Grade", 64 | labelClassName: "block mb-2 text-sm font-medium text-gray-900 dark:text-white", 65 | name: "grade", 66 | id: "grade", 67 | className, 68 | placeholder: "A1", 69 | required: true, 70 | value: payload["grade"], 71 | }, 72 | 73 | { 74 | type: "text", 75 | label: "Departement", 76 | labelClassName: "block mb-2 text-sm font-medium text-gray-900 dark:text-white", 77 | name: "departement", 78 | id: "departement", 79 | className, 80 | placeholder: "Informatika", 81 | required: true, 82 | value: payload["departement"], 83 | }, 84 | ]; 85 | 86 | const onChange = (event: ChangeEvent): void => { 87 | setPayload({ ...payload, [event.target.name]: event.target.value }); 88 | }; 89 | 90 | return ( 91 | <> 92 | {InputFields.map((field, index) => ( 93 | 94 | ))} 95 | > 96 | ); 97 | }; 98 | 99 | export default RegisterTextField; 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React BOILERPLATE 2 | 3 | Ini adalah Boilerplate React dengan dependencies 4 | 5 | - React 6 | - React Router (Latest) 7 | - Racoil (State Management) 8 | - Vite (Web Bundler) 9 | - Vitest (Unit Testing based on Vite) 10 | - UnoCSS (Successor of TailwindCSS and WindiCSS) 11 | - ESLint 12 | - Prettier 13 | - Nix Flake (Development Dependencies Manager) 14 | - Nix Direnv (Automate Development Dependencies) 15 | - Docker (Containerizer) 16 | - Nginx (Web Server) 17 | - Axios 18 | - Husky 19 | 20 | ## Initial Setup 21 | 22 | - Clone Project ini ( Direkomendasikan menggunakan SSH ) 23 | 24 | > `git clone git@github.com:maulanasdqn/react-boilerplate` 25 | 26 | ## Install NodeJS dan Yarn 27 | 28 | - Anda perlu menginstall dulu NodeJS dan Yarn ( Direkomendasikan menggunakan NodeJS Versi 16 ) 29 | 30 | > `npm i -g yarn` 31 | 32 | ## Install Dependency 33 | 34 | - Pasang Dependency 35 | 36 | > `yarn install` 37 | 38 | ## Development With Nix 39 | 40 | Development dengan Nix membuat proses Develop menjadi lebih mudah dan ringkas dengan ada nya Flake.nix semua dependency akan terurus dengan sendirinya dan juga independent artinya tidak akan menggangu environment yang lain 41 | 42 | - Pasang Nixpkgs 43 | 44 | > `sh <(curl -L https://nixos.org/nix/install) --no-daemon` 45 | 46 | - Pasang nix-flakes 47 | 48 | > `nix-env -iA nixpkgs.nixFlakes` 49 | 50 | - Setup nix-flakes \ 51 | Edit file yang ada di `~/.config/nix/nix.conf` atau `/etc/nix/nix.conf` dan tambahkan: 52 | 53 | > `experimental-features = nix-command flakes` 54 | 55 | - Pasang nix-direnv 56 | 57 | > `nix-env -f '' -iA nix-direnv` 58 | 59 | - Setup nix-direnv 60 | 61 | > `source $HOME/.nix-profile/share/nix-direnv/direnvrc` 62 | 63 | - Masuk ke folder yang sudah di clone kemudian jalankan perintah berikut 64 | 65 | > `direnv allow` 66 | 67 | - Dan enjoy tinggal tunggu dependency terinstall dengan sendirinya 68 | 69 | ## Development with Docker 70 | 71 | Docker harus di pasang dulu jika belum ada 72 | 73 | - Pasang Docker bisa di unduh di https://docker.com 74 | 75 | - Setup Docker 76 | 77 | > `docker compose up` 78 | 79 | ## Setup Env 80 | 81 | ENV di sesuaikan seperti yang ada di contoh .env.example 82 | 83 | - Rename file .env.example menjadi .env.local 84 | - Isi ENV sesuai dengan yang ada di dalam file .env.local nya 85 | 86 | ## Setup Husky 87 | 88 | Untuk bisa menggunakan husky agar berjalan baik dan benar maka perlu di inisialisasi dulu 89 | 90 | - Jalankan perintah 91 | > `npx husky-init` 92 | 93 | ## Running Test, Formatter and Linter 94 | 95 | Jika anda mau menjalankan Unit test, format semua dokumen dan menjalankan linting 96 | 97 | - Jalnkan perintah ini untuk test sekali jalan 98 | > `yarn test:run` 99 | 100 | - Jalankan perintah ini untuk test dengan watch mode 101 | > `yarn test` 102 | 103 | - Jalankan perintah ini untuk test dengan coverage mode 104 | > `yarn test:coverage` 105 | 106 | - Jalankan perintah ini untuk format seluruh dokumen 107 | > `yarn format` 108 | 109 | - Jalankan perintah ini untuk linting dokumen tanpa fix 110 | > `yarn lint` 111 | 112 | - Jalankan perintah ini untuk lintind dokumen dengan fix 113 | > `yarn lint:fix` 114 | 115 | ## Deployment Guide 116 | 117 | - Netlify dan Vercel 118 | Jika anda bermaksud untuk mendeploy nya ke netlify atau vercel anda hanya perlu membinding repository ini ke branch main untuk production dan ke branch dev untuk development 119 | 120 | - Vps atau Heroku 121 | Jika anda bermaksud untuk mendeploy nya ke vps atau heroku anda bisa menggunakan docker container untuk deployment nya karena sudah terintegrasi baik dengan nginx 122 | -------------------------------------------------------------------------------- /src/server/dummy.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | /* eslint-disable @typescript-eslint/no-shadow */ 3 | /* eslint-disable @typescript-eslint/no-use-before-define */ 4 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 5 | let users = JSON.parse(localStorage.getItem("users")) || []; 6 | 7 | export function configureFakeBackend() { 8 | let realFetch = window.fetch; 9 | window.fetch = function (url, opts) { 10 | const { method, headers } = opts; 11 | const body = opts.body && JSON.parse(opts.body); 12 | 13 | return new Promise((resolve, reject) => { 14 | setTimeout(handleRoute, 500); 15 | 16 | function handleRoute() { 17 | switch (true) { 18 | case url.endsWith("/api/login") && method === "POST": 19 | return authenticate(); 20 | case url.endsWith("/api/register") && method === "POST": 21 | return register(); 22 | case url.endsWith("/api/users") && method === "GET": 23 | return getUsers(); 24 | case url.match(/\/users\/\d+$/) && method === "DELETE": 25 | return deleteUser(); 26 | default: 27 | // pass through any requests not handled above 28 | return realFetch(url, opts) 29 | .then((response) => resolve(response)) 30 | .catch((err) => reject(err)); 31 | } 32 | } 33 | 34 | // route functions 35 | 36 | function authenticate() { 37 | const { username, password } = body; 38 | const user = users.find((x) => x.username === username && x.password === password); 39 | if (!user) return error("Username or password is incorrect"); 40 | return ok({ 41 | id: user.id, 42 | username: user.username, 43 | firstName: user.firstName, 44 | lastName: user.lastName, 45 | token: "fake-jwt-token", 46 | }); 47 | } 48 | 49 | function register() { 50 | const user = body; 51 | 52 | if (users.find((x) => x.username === user.username)) { 53 | return error(`Username ${user.username} is already taken`); 54 | } 55 | 56 | // assign user id and a few other properties then save 57 | user.id = users.length ? Math.max(...users.map((x) => x.id)) + 1 : 1; 58 | users.push(user); 59 | localStorage.setItem("users", JSON.stringify(users)); 60 | 61 | return ok(); 62 | } 63 | 64 | function getUsers() { 65 | if (!isLoggedIn()) return unauthorized(); 66 | 67 | return ok(users); 68 | } 69 | 70 | function deleteUser() { 71 | if (!isLoggedIn()) return unauthorized(); 72 | 73 | users = users.filter((x) => x.id !== idFromUrl()); 74 | localStorage.setItem("users", JSON.stringify(users)); 75 | return ok(); 76 | } 77 | 78 | // helper functions 79 | 80 | function ok(body) { 81 | resolve({ ok: true, text: () => Promise.resolve(JSON.stringify(body)) }); 82 | } 83 | 84 | function unauthorized() { 85 | resolve({ 86 | status: 401, 87 | text: () => Promise.resolve(JSON.stringify({ message: "Unauthorized" })), 88 | }); 89 | } 90 | 91 | function error(message) { 92 | resolve({ status: 400, text: () => Promise.resolve(JSON.stringify({ message })) }); 93 | } 94 | 95 | function isLoggedIn() { 96 | return headers["Authorization"] === "Bearer fake-jwt-token"; 97 | } 98 | 99 | function idFromUrl() { 100 | const urlParts = url.split("/"); 101 | return parseInt(urlParts[urlParts.length - 1]); 102 | } 103 | }); 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------