diff --git a/.barrelsby.json b/.barrelsby.json index 2bed204..1fc8490 100644 --- a/.barrelsby.json +++ b/.barrelsby.json @@ -1,6 +1,10 @@ { "directory": [ - "./src/components", + "./src/components/common", + "./src/components/home", + "./src/components/order/common", + "./src/components/order/mobile", + "./src/components/order/desktop", "./src/constants", "./src/hooks", "./src/interfaces", diff --git a/.env.example b/.env.example index 408a3f1..8bd58e8 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ +# This file is not allowed to be deleted, because it represents which environment variables to use when the project is open-source. VITE_BACKEND_URL= \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index fd84151..6d2b95c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,10 +3,12 @@ "extends": [ "prettier", "plugin:@typescript-eslint/recommended", - "plugin:react-hooks/recommended" + "plugin:react-hooks/recommended", + "plugin:@tanstack/eslint-plugin-query/recommended" ], "plugins": [ - "@typescript-eslint" + "@typescript-eslint", + "@tanstack/query" ], "parserOptions": { "ecmaVersion": 2018, @@ -21,6 +23,10 @@ "@typescript-eslint/no-inferrable-types": 0, "@typescript-eslint/no-unused-vars": 2, "@typescript-eslint/no-var-requires": 0, - "eqeqeq": "error" + "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/no-rest-destructuring": "warn", + "@tanstack/query/stable-query-client": "error", + "eqeqeq": "error", + "no-console": "error" } } \ No newline at end of file diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 2be9f15..fcb83d1 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,24 +1,39 @@ -name: CD +name: Staging on: push: branches: ['main'] paths-ignore: - '*.md' + env: REGISTRY: ghcr.io IMAGE_NAME: ticklabvn/ssps-fe ORG_USERNAME: ${{ github.actor }} - + permissions: contents: write pull-requests: write packages: write jobs: + release: + runs-on: ubuntu-latest + outputs: + build: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: google-github-actions/release-please-action@v3 + id: release + with: + release-type: node + pull-request-header: 'Bot (:robot:) requested to create a new release on ${{ github.ref_name }}' + build: name: Build + needs: [release] runs-on: ubuntu-latest + if: ${{ needs.release.outputs.build == 'true' }} steps: - name: Checkout uses: actions/checkout@v3 @@ -46,19 +61,26 @@ jobs: deploy: name: Deploy - runs-on: self-hosted + runs-on: ubuntu-latest needs: [build] steps: - - uses: actions/checkout@v3 + - name: Deploy + uses: appleboy/ssh-action@master with: - fetch-depth: 0 - - name: start nginx - run: | - export CR_PAT=${{ secrets.GITHUB_TOKEN }} - echo $CR_PAT | docker login ${{ env.REGISTRY }} -u ${{ env.ORG_USERNAME }} --password-stdin - docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + host: ${{ secrets.VPS_HOST }} + port: ${{ secrets.VPS_PORT }} + username: ${{ secrets.VPS_USERNAME }} + password: ${{ secrets.VPS_PASSWORD }} + + script: | + export CR_PAT=${{ secrets.GITHUB_TOKEN }} + echo $CR_PAT | docker login ${{ env.REGISTRY }} -u ${{ env.ORG_USERNAME }} --password-stdin + docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - docker compose stop ssps-fe - docker compose rm -f ssps-fe - docker compose up -d ssps-fe - docker logout ${{ env.REGISTRY }} \ No newline at end of file + cd ssps-fe + export TOKEN=${{ secrets.CURL_TOKEN }} + curl -s https://$TOKEN@raw.githubusercontent.com/ticklabvn/ssps-fe/main/docker-compose.yml -o docker-compose.yml + docker compose stop ssps-fe + docker compose rm -f ssps-fe + docker compose up -d ssps-fe + docker logout ${{ env.REGISTRY }} \ No newline at end of file diff --git a/index.html b/index.html index e4b78ea..c9cfe06 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - Vite + React + TS + Smart Service Printing System
diff --git a/package.json b/package.json index fb30ec3..17b8878 100644 --- a/package.json +++ b/package.json @@ -10,25 +10,32 @@ "barrels": "barrelsby --config .barrelsby.json -q", "lint": "eslint '**/*.{tsx,ts,js}'", "format": "prettier '**/*.{tsx,ts,js,json,md,yml,yaml}' --write", - "prepare": "husky install" + "prepare": "is-ci || husky install && yarn api:pull", + "api:pull": "openapi-typescript https://ssps-server.tickflow.net/docs/json -o src/openapi-spec.ts" }, "dependencies": { + "@cyntler/react-doc-viewer": "^1.14.0", + "@emotion/is-prop-valid": "^1.2.1", "@heroicons/react": "^2.0.18", "@hookform/resolvers": "^3.2.0", "@material-tailwind/react": "^2.1.0", - "axios": "^1.4.0", + "@tanstack/react-query": "^5.8.4", "moment": "^2.29.4", + "openapi-fetch": "^0.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.45.4", "react-router-dom": "^6.15.0", "react-toastify": "^9.1.3", + "styled-components": "^6.0.8", "yup": "^1.2.0", "zustand": "^4.4.1" }, "devDependencies": { "@commitlint/cli": "^17.7.1", "@commitlint/config-conventional": "^17.7.0", + "@tanstack/eslint-plugin-query": "^5.8.4", + "@tanstack/react-query-devtools": "^5.8.4", "@types/node": "^20.5.0", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", @@ -42,11 +49,14 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-react-hooks": "^4.6.0", "husky": "^8.0.3", + "is-ci": "^3.0.1", "lint-staged": "^14.0.0", + "openapi-typescript": "^7.0.0-next.2", "postcss": "^8.4.27", "prettier": "3.0.1", "tailwindcss": "^3.3.3", - "typescript": "^5.0.2", - "vite": "^4.4.5" + "typescript": "^5.2.2", + "vite": "^4.4.5", + "vite-tsconfig-paths": "^4.2.1" } } diff --git a/public/ssps-logo.jpg b/public/ssps-logo.jpg new file mode 100644 index 0000000..6518357 Binary files /dev/null and b/public/ssps-logo.jpg differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 541013e..ab78a1b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,94 @@ -import { AuthLayout } from '@layouts'; -import { AuthPage } from '@pages'; +import { useEffect } from 'react'; +import { toast } from 'react-toastify'; +import { NavigateFunction, useNavigate, useLocation } from 'react-router-dom'; +import { AppSkeleton } from '@components/common'; +import { MAIN_MENU, SUB_MENU } from '@constants'; +import { useUserQuery } from '@hooks'; +import { AppLayout, AuthLayout } from '@layouts'; +import { AuthPage, HomePage } from '@pages'; export default function App() { - return ( - - - - ); + const navigate: NavigateFunction = useNavigate(); + const { + info: { isFetching, isError, isSuccess, refetch } + } = useUserQuery(); + const { pathname } = useLocation(); + + useEffect(() => { + refetch({ throwOnError: true }).catch((err) => { + if (err.statusCode !== 401) toast.error(err.message); + }); + }, [refetch]); + + useEffect(() => { + if (pathname === '/' && isSuccess) { + navigate('/home'); + } + }, [pathname, isSuccess, navigate]); + + if (isFetching) return ; + + if (isError) + return ( + + + + ); + + if (isSuccess) { + return ( + + }, + { + type: 'main-item', + path: '/home', + name: MAIN_MENU.home, + element: + }, + { + type: 'main-item', + path: '/order', + name: MAIN_MENU.order, + element: <> + }, + { + type: 'main-item', + path: '/payment', + name: MAIN_MENU.payment, + element: <> + }, + { + type: 'main-item', + path: '/location', + name: MAIN_MENU.location, + element: <> + }, + { + type: 'sub-item', + path: '/help', + name: SUB_MENU.help, + element: <> + }, + { + type: 'sub-item', + path: '/settings', + name: SUB_MENU.settings, + element: <> + }, + { + type: 'logout-btn', + name: SUB_MENU.logout, + onClick() {} + //onClick: () => authService.logout().then(() => getData()) + } + ]} + /> + ); + } } diff --git a/src/assets/coin.png b/src/assets/coin.png new file mode 100644 index 0000000..913e686 Binary files /dev/null and b/src/assets/coin.png differ diff --git a/src/assets/corner.jpg b/src/assets/corner.jpg new file mode 100644 index 0000000..069718f Binary files /dev/null and b/src/assets/corner.jpg differ diff --git a/src/assets/corner.png b/src/assets/corner.png new file mode 100644 index 0000000..d8911ca Binary files /dev/null and b/src/assets/corner.png differ diff --git a/src/assets/header.png b/src/assets/header.png new file mode 100644 index 0000000..951cfde Binary files /dev/null and b/src/assets/header.png differ diff --git a/src/assets/landscape-bottom.jpg b/src/assets/landscape-bottom.jpg new file mode 100644 index 0000000..0a88420 Binary files /dev/null and b/src/assets/landscape-bottom.jpg differ diff --git a/src/assets/landscape-left.png b/src/assets/landscape-left.png new file mode 100644 index 0000000..4cd976a Binary files /dev/null and b/src/assets/landscape-left.png differ diff --git a/src/assets/landscape-right.jpg b/src/assets/landscape-right.jpg new file mode 100644 index 0000000..12f5b40 Binary files /dev/null and b/src/assets/landscape-right.jpg differ diff --git a/src/assets/landscape-top.jpg b/src/assets/landscape-top.jpg new file mode 100644 index 0000000..26b26c9 Binary files /dev/null and b/src/assets/landscape-top.jpg differ diff --git a/src/assets/login.svg b/src/assets/login.svg deleted file mode 100644 index 6554a6a..0000000 --- a/src/assets/login.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/assets/logobk.png b/src/assets/logobk.png new file mode 100644 index 0000000..524b386 Binary files /dev/null and b/src/assets/logobk.png differ diff --git a/src/assets/portrait-bottom.jpg b/src/assets/portrait-bottom.jpg new file mode 100644 index 0000000..f70d550 Binary files /dev/null and b/src/assets/portrait-bottom.jpg differ diff --git a/src/assets/portrait-left.jpg b/src/assets/portrait-left.jpg new file mode 100644 index 0000000..671c2af Binary files /dev/null and b/src/assets/portrait-left.jpg differ diff --git a/src/assets/portrait-right.jpg b/src/assets/portrait-right.jpg new file mode 100644 index 0000000..ddff5bf Binary files /dev/null and b/src/assets/portrait-right.jpg differ diff --git a/src/assets/portrait-top.jpg b/src/assets/portrait-top.jpg new file mode 100644 index 0000000..ee986d2 Binary files /dev/null and b/src/assets/portrait-top.jpg differ diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/ticklab.png b/src/assets/ticklab.png new file mode 100644 index 0000000..4969c0e Binary files /dev/null and b/src/assets/ticklab.png differ diff --git a/src/components/ToggleSidebarBtn.tsx b/src/components/ToggleSidebarBtn.tsx deleted file mode 100644 index a66687c..0000000 --- a/src/components/ToggleSidebarBtn.tsx +++ /dev/null @@ -1,24 +0,0 @@ -export const ToggleSidebarBtn: Component<{ open: boolean }> = ({ open }) => { - return open ? ( - - - - ) : ( - - - - ); -}; diff --git a/src/components/common/AppDrawer.tsx b/src/components/common/AppDrawer.tsx new file mode 100644 index 0000000..5f481b5 --- /dev/null +++ b/src/components/common/AppDrawer.tsx @@ -0,0 +1,16 @@ +import React, { ReactNode } from 'react'; +import { Drawer } from '@material-tailwind/react'; + +export const AppDrawer: Component<{ + open: boolean; + children: ReactNode; + onClose: () => void; +}> = ({ open, children, onClose }) => { + return ( + + + {children} + + + ); +}; diff --git a/src/components/common/AppNavigation.tsx b/src/components/common/AppNavigation.tsx new file mode 100644 index 0000000..161e0ab --- /dev/null +++ b/src/components/common/AppNavigation.tsx @@ -0,0 +1,50 @@ +import { UserCircleIcon } from '@heroicons/react/24/solid'; +import coin from '@assets/coin.png'; +import { AppDrawer, DesktopNavbar, ToggleSidebarBtn, useSidebarMenu } from '@components/common'; +import { ScreenSize } from '@constants'; +import { useScreenSize, useUserQuery } from '@hooks'; +import { useMenuBarStore } from '@states'; + +export const AppNavigation: Component<{ mainMenu: RouteMenu; subMenu: RouteMenu }> = ({ + mainMenu, + subMenu +}) => { + const { screenSize } = useScreenSize(); + const { openSidebar, handleOpenSidebar, SidebarMenu } = useSidebarMenu(); + const { selectedMenu } = useMenuBarStore(); + const { + remainCoins: { data } + } = useUserQuery(); + + return ( +
+
+
+
+ +
+
{selectedMenu}
+
+ {screenSize < ScreenSize.LG ? ( + + + + ) : ( + + )} +
+ {data && ( +
+ {data} +
+ )} + + +
+
+
+ ); +}; diff --git a/src/components/AppSkeleton.tsx b/src/components/common/AppSkeleton.tsx similarity index 100% rename from src/components/AppSkeleton.tsx rename to src/components/common/AppSkeleton.tsx diff --git a/src/components/common/DesktopNavbar.tsx b/src/components/common/DesktopNavbar.tsx new file mode 100644 index 0000000..61c85a5 --- /dev/null +++ b/src/components/common/DesktopNavbar.tsx @@ -0,0 +1,40 @@ +import { Link } from 'react-router-dom'; +import { List, ListItem } from '@material-tailwind/react'; +import logo from '@assets/logobk.png'; +import ticklab from '@assets/ticklab.png'; +import { useMenuBarStore } from '@states'; + +export const DesktopNavbar: Component<{ mainMenu: RouteMenu }> = ({ mainMenu }) => { + const { selectedMenu, setSelectedMenu } = useMenuBarStore(); + + return ( +
+
+ +
+
+ +
+ + {mainMenu.map((menuItem, idx) => { + if (menuItem.type === 'logout-btn') return; + return ( + + setSelectedMenu(menuItem.name)} + > + {menuItem.name} + + + ); + })} + +
+ ); +}; diff --git a/src/components/common/SidebarMenu.tsx b/src/components/common/SidebarMenu.tsx new file mode 100644 index 0000000..1a8d900 --- /dev/null +++ b/src/components/common/SidebarMenu.tsx @@ -0,0 +1,94 @@ +import { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { List, ListItem } from '@material-tailwind/react'; +import logo from '@assets/logobk.png'; +import ticklab from '@assets/ticklab.png'; +import { ToggleSidebarBtn } from '@components/common'; +import { useMenuBarStore } from '@states'; + +export function useSidebarMenu() { + const { selectedMenu, setSelectedMenu } = useMenuBarStore(); + + const ITEM_CLASSNAME = + 'hover:bg-gray/1 focus:bg-blue-100 active:bg-blue-100 focus:text-blue/1 active:text-blue/1 focus:font-bold active:font-bold h-14 px-6 py-4 rounded-none text-gray/4 font-medium'; + const [openSidebar, setOpenSidebar] = useState(false); + + const SidebarMenu: Component<{ mainMenu: RouteMenu; subMenu: RouteMenu }> = useMemo( + () => + ({ mainMenu, subMenu }) => { + return ( + <> +
+
setOpenSidebar(false)} + > + +
+
+ + +
+
+
+ + {mainMenu.map((menuItem, idx) => { + if (menuItem.type === 'main-item') { + return ( + + { + setSelectedMenu(menuItem.name); + setOpenSidebar(false); + }} + > + {menuItem.name} + + + ); + } + })} + + + {subMenu.map((menuItem, idx) => { + if (menuItem.type === 'sub-item') { + return ( + + { + setSelectedMenu(menuItem.name); + setOpenSidebar(false); + }} + > + {menuItem.name} + + + ); + } + })} + +
+ + ); + }, + [openSidebar, selectedMenu, setSelectedMenu] + ); + + return { + openSidebar: openSidebar, + handleOpenSidebar: () => setOpenSidebar(!openSidebar), + SidebarMenu: SidebarMenu + }; +} diff --git a/src/components/common/ToggleSidebarBtn.tsx b/src/components/common/ToggleSidebarBtn.tsx new file mode 100644 index 0000000..f60db31 --- /dev/null +++ b/src/components/common/ToggleSidebarBtn.tsx @@ -0,0 +1,32 @@ +export const ToggleSidebarBtn: Component<{ open: boolean }> = ({ open }) => { + return open ? ( + + + + + + + + + ) : ( + + + + + ); +}; diff --git a/src/components/common/index.ts b/src/components/common/index.ts new file mode 100644 index 0000000..db0483a --- /dev/null +++ b/src/components/common/index.ts @@ -0,0 +1,10 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './AppDrawer'; +export * from './AppNavigation'; +export * from './AppSkeleton'; +export * from './DesktopNavbar'; +export * from './SidebarMenu'; +export * from './ToggleSidebarBtn'; diff --git a/src/components/home/ChooseFileBox.tsx b/src/components/home/ChooseFileBox.tsx new file mode 100644 index 0000000..b384e26 --- /dev/null +++ b/src/components/home/ChooseFileBox.tsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Dialog, DialogBody } from '@material-tailwind/react'; +import { ArrowUpTrayIcon } from '@heroicons/react/24/outline'; +import { useUploadAndPreviewDocBox } from '@components/order/desktop'; +import { useOrderWorkflowBox } from '@components/order/mobile'; +import { ScreenSize } from '@constants'; +import { useScreenSize, usePrintingRequestMutation } from '@hooks'; +import { useOrderPrintStore, useOrderWorkflowStore } from '@states'; + +export function useChooseFileBox() { + const queryClient = useQueryClient(); + const [openBox, setOpenBox] = useState(false); + + const { openUploadAndPreviewDocBox, closeUploadAndPreviewDocBox, UploadAndPreviewDocBox } = + useUploadAndPreviewDocBox(); + const { openOrderWorkflowBox, closeOrderWorkflowBox, OrderWorkflowBox } = useOrderWorkflowBox(); + + const ChooseFileBox = () => { + const printingRequestId = queryClient.getQueryData(['printingRequestId']); + const { screenSize } = useScreenSize(); + const { desktopOrderStep, mobileOrderStep, setMobileOrderStep } = useOrderWorkflowStore(); + const { isFileUploadSuccess, setIsFileUploadSuccess } = useOrderPrintStore(); + const { uploadFile, cancelPrintingRequest } = usePrintingRequestMutation(); + + useEffect(() => { + if (isFileUploadSuccess) { + if (screenSize <= ScreenSize.MD) { + openOrderWorkflowBox(); + } else { + if (desktopOrderStep === 0) { + openUploadAndPreviewDocBox(); + } + } + } else { + closeOrderWorkflowBox(); + closeUploadAndPreviewDocBox(); + } + }, [screenSize, uploadFile, desktopOrderStep, isFileUploadSuccess]); + + const handleUploadDocument = useCallback( + async (event: React.ChangeEvent) => { + if (event.target.files) { + if (!event.target.files[0] || !printingRequestId) return; + await uploadFile.mutateAsync({ + printingRequestId: printingRequestId.id, + file: event.target.files[0] + }); + setIsFileUploadSuccess(true); + setOpenBox(false); + if (screenSize <= ScreenSize.MD) { + openOrderWorkflowBox(); + } else { + openUploadAndPreviewDocBox(); + } + setMobileOrderStep({ + current: 0, + prev: mobileOrderStep.current + }); + } + }, + [ + screenSize, + uploadFile, + mobileOrderStep, + printingRequestId, + setMobileOrderStep, + setIsFileUploadSuccess + ] + ); + + const handleCloseDialog = async () => { + if (!printingRequestId) return; + await cancelPrintingRequest.mutateAsync(printingRequestId.id); + setOpenBox(false); + }; + + return ( + <> + + + + + + {screenSize <= ScreenSize.MD ? : } + + ); + }; + + return { + openChooseFileBox: () => setOpenBox(true), + ChooseFileBox: ChooseFileBox + }; +} diff --git a/src/components/home/Orders.tsx b/src/components/home/Orders.tsx new file mode 100644 index 0000000..26f0497 --- /dev/null +++ b/src/components/home/Orders.tsx @@ -0,0 +1,92 @@ +import { useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Card, CardBody } from '@material-tailwind/react'; +import { ArrowRightIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; +import { MapPinIcon } from '@heroicons/react/24/solid'; +import coin from '@assets/coin.png'; +import { ORDER_STATUS_COLOR } from '@constants'; +import { printingRequestService } from '@services'; +import { retryQueryFn } from '@utils'; + +export function Orders() { + const sliderRef = useRef(null); + + const scrollLeft = () => { + if (sliderRef.current) { + sliderRef.current.scrollLeft -= 500; + } + }; + const scrollRight = () => { + if (sliderRef.current) { + sliderRef.current.scrollLeft += 500; + } + }; + + const { data: orders } = useQuery({ + queryKey: ['/api/printRequest'], + queryFn: () => printingRequestService.getPrintingRequest(), + retry: retryQueryFn + }); + + return ( +
+
+

Order in progress

+
+ See more + +
+
+
+ {orders && + orders.map((order, index) => ( + + +
+ + {order.status} + + {order.id.slice(8, order.id.length)} +
+
+ {order.filesName[0]} + +{order.numFiles} +
+
+ + {order.location} +
+
+
+

+ {order.numPages} + pages +

+
+ coin + {order.coins} + ({order.paid}) +
+
+
+
+ ))} +
+ +
+
+ +
+
+
+ ); +} diff --git a/src/components/home/Slides.tsx b/src/components/home/Slides.tsx new file mode 100644 index 0000000..0a639d3 --- /dev/null +++ b/src/components/home/Slides.tsx @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query'; +import { Carousel, Spinner } from '@material-tailwind/react'; +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; +import { homeService } from '@services'; +import { retryQueryFn } from '@utils'; + +export function Slides() { + const { data: slides } = useQuery({ + queryKey: ['/api/home/slides'], + queryFn: () => homeService.getSlides(), + retry: retryQueryFn + }); + + return ( + ( +
+ +
+ )} + nextArrow={({ handleNext }) => ( +
+ +
+ )} + loop={true} + autoplay={true} + autoplayDelay={3000} + > + {slides ? ( + slides.map((slide, index) => ( + {slide.alt} + )) + ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/home/index.ts b/src/components/home/index.ts new file mode 100644 index 0000000..88e38b8 --- /dev/null +++ b/src/components/home/index.ts @@ -0,0 +1,7 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './ChooseFileBox'; +export * from './Orders'; +export * from './Slides'; diff --git a/src/components/index.ts b/src/components/index.ts deleted file mode 100644 index 7d1d2fb..0000000 --- a/src/components/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './AppSkeleton'; -export * from './ToggleSidebarBtn'; diff --git a/src/components/order/common/CloseForm.tsx b/src/components/order/common/CloseForm.tsx new file mode 100644 index 0000000..0ef0356 --- /dev/null +++ b/src/components/order/common/CloseForm.tsx @@ -0,0 +1,59 @@ +import { useMemo, useState } from 'react'; +import { + Button, + Dialog, + DialogBody, + DialogHeader, + IconButton, + Typography +} from '@material-tailwind/react'; + +export function useCloseForm() { + const [openDialog, setOpenDialog] = useState(false); + + const CloseForm: Component<{ + handleSave: () => Promise | void; + handleExist: () => Promise | void; + }> = ({ handleSave, handleExist }) => + useMemo(() => { + const handleOpenDialog = () => { + setOpenDialog(!openDialog); + }; + + return ( + + + + + + + + + + + Do you want to save your changes? + + Your changes will be lost if you don't save them +
+ + +
+
+
+ ); + }, [handleSave, handleExist]); + + return { + openCloseForm: () => setOpenDialog(true), + CloseForm: CloseForm + }; +} diff --git a/src/components/order/common/FileBox.tsx b/src/components/order/common/FileBox.tsx new file mode 100644 index 0000000..d1503b4 --- /dev/null +++ b/src/components/order/common/FileBox.tsx @@ -0,0 +1,74 @@ +import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { ArrowUpTrayIcon } from '@heroicons/react/24/solid'; +import { ScreenSize } from '@constants'; +import { useScreenSize, usePrintingRequestMutation } from '@hooks'; +import { useOrderPrintStore, useOrderWorkflowStore } from '@states'; + +export function FileBox() { + const queryClient = useQueryClient(); + const { screenSize } = useScreenSize(); + const { setIsFileUploadSuccess } = useOrderPrintStore(); + const { mobileOrderStep, setMobileOrderStep, setDesktopOrderStep } = useOrderWorkflowStore(); + const { uploadFile } = usePrintingRequestMutation(); + + const handleUploadDocument = useCallback( + async (event: React.ChangeEvent) => { + if (event.target.files) { + const printingRequestId = queryClient.getQueryData([ + 'printingRequestId' + ]); + if (!event.target.files[0] || !printingRequestId) return; + await uploadFile.mutateAsync({ + printingRequestId: printingRequestId.id, + file: event.target.files[0] + }); + setIsFileUploadSuccess(true); + if (screenSize <= ScreenSize.MD) { + setMobileOrderStep({ + current: 0, + prev: mobileOrderStep.current + }); + } else { + setDesktopOrderStep(0); + } + } + }, + [ + screenSize, + mobileOrderStep, + uploadFile, + queryClient, + setMobileOrderStep, + setDesktopOrderStep, + setIsFileUploadSuccess + ] + ); + + return ( + + ); +} diff --git a/src/components/order/common/FormFooter.tsx b/src/components/order/common/FormFooter.tsx new file mode 100644 index 0000000..06bd2f3 --- /dev/null +++ b/src/components/order/common/FormFooter.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import coin from '@assets/coin.png'; + +export const FormFooter: Component<{ children: ReactNode; totalCost: number }> = ({ + children, + totalCost +}) => { + const queryClient = useQueryClient(); + const remainCoins = queryClient.getQueryData(['/api/user/remain-coins']); + + return ( +
+
+
+

Balance:

+
+
+ Ballance Coin +
+ {remainCoins} +
+
+
+

Total Cost

+
+
+ Total Cost +
+ {totalCost} +
+
+
+ {children} +
+ ); +}; diff --git a/src/components/order/common/LayoutSide.tsx b/src/components/order/common/LayoutSide.tsx new file mode 100644 index 0000000..15fb2c4 --- /dev/null +++ b/src/components/order/common/LayoutSide.tsx @@ -0,0 +1,97 @@ +import { useState, useMemo } from 'react'; +import { + Dialog, + DialogHeader, + DialogBody, + IconButton, + List, + ListItem, + ListItemPrefix +} from '@material-tailwind/react'; +import { LAYOUT_INFO, LAYOUT_SIDE } from '@constants'; + +export const useLayoutSide = () => { + const [openDialog, setOpenDialog] = useState(false); + + const LayoutSide: Component<{ + layout: string; + pageSide: string; + handlePageBothSide: (event: string) => void; + }> = useMemo( + () => + ({ layout, pageSide, handlePageBothSide }) => { + const handleOpen = () => { + setOpenDialog(!openDialog); + }; + + return ( + + +
+ Both side + - + {layout} +
+ + + + + +
+ + + {LAYOUT_INFO.map((item, index) => ( + { + handlePageBothSide( + (layout === LAYOUT_SIDE.portrait ? item.portraitSize : item.landscapeSize) + + ' (' + + item.pos + + ')' + ); + handleOpen(); + }} + > + 1 && layout === LAYOUT_SIDE.portrait ? 'px-2 bg-gray-200' : '' + } + > + + +
+

+ {layout === LAYOUT_SIDE.portrait ? item.portraitSize : item.landscapeSize} +

+

{item.pos}

+
+
+ ))} +
+
+
+ ); + }, + [openDialog] + ); + + return { + openLayoutSide: () => setOpenDialog(true), + LayoutSide: LayoutSide + }; +}; diff --git a/src/components/order/common/index.ts b/src/components/order/common/index.ts new file mode 100644 index 0000000..33c6c8f --- /dev/null +++ b/src/components/order/common/index.ts @@ -0,0 +1,8 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './CloseForm'; +export * from './FileBox'; +export * from './FormFooter'; +export * from './LayoutSide'; diff --git a/src/components/order/desktop/UploadAndPreviewDocBox.tsx b/src/components/order/desktop/UploadAndPreviewDocBox.tsx new file mode 100644 index 0000000..ee75871 --- /dev/null +++ b/src/components/order/desktop/UploadAndPreviewDocBox.tsx @@ -0,0 +1,359 @@ +import { ChangeEvent, useCallback, useEffect, useState, useRef } from 'react'; +import DocViewer, { DocViewerRenderers } from '@cyntler/react-doc-viewer'; +import { useQueryClient } from '@tanstack/react-query'; +import { + Button, + Dialog, + DialogBody, + DialogHeader, + IconButton, + Input, + Option, + Radio, + Select +} from '@material-tailwind/react'; +import { MinusIcon, PlusIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { ExclamationCircleIcon } from '@heroicons/react/24/solid'; +import coinImage from '@assets/coin.png'; +import { useLayoutSide, FormFooter, useCloseForm } from '@components/order/common'; +import { LAYOUT_SIDE, FILE_CONFIG, PAGES_SPECIFIC, PAGES_PER_SHEET, PAGE_SIDE } from '@constants'; +import { usePrintingRequestMutation } from '@hooks'; +import { useOrderPrintStore } from '@states'; +import { formatFileSize } from '@utils'; + +export function useUploadAndPreviewDocBox() { + const [openDialog, setOpenDialog] = useState(false); + const queryClient = useQueryClient(); + const fileIdCurrent = queryClient.getQueryData(['fileIdCurrent']); + const fileMetadata = queryClient.getQueryData(['fileMetadata', fileIdCurrent]); + + const PreviewDocument = () => { + const PreviewBody = () => { + if (fileMetadata?.fileURL) { + return ( + + ); + } else return null; + }; + return ; + }; + + const UploadAndPreviewDocBox = () => { + const { uploadFileConfig } = usePrintingRequestMutation(); + const { + fileConfig, + totalCost, + setFileConfig, + setTotalCost, + setIsFileUploadSuccess, + clearFileConfig + } = useOrderPrintStore(); + const { openLayoutSide, LayoutSide } = useLayoutSide(); + const { openCloseForm, CloseForm } = useCloseForm(); + + const initialTotalCost = useRef(totalCost); + const [specificPage, setSpecificPage] = useState(''); + const [pageBothSide, setPageBothSide] = useState( + fileConfig.layout === LAYOUT_SIDE.portrait + ? PAGE_SIDE.both.portrait[0]! + : PAGE_SIDE.both.landscape[0]! + ); + + useEffect(() => { + if (fileMetadata?.fileCoin) { + setTotalCost(initialTotalCost.current + fileMetadata?.fileCoin); + } + }, [setTotalCost]); + + const handleOpenDialog = useCallback(() => setOpenDialog(!openDialog), []); + + const handlePageBothSide = useCallback( + (event: string) => { + setPageBothSide(event); + setFileConfig(FILE_CONFIG.pageSide, event); + }, + [setFileConfig] + ); + + const handleSaveFileConfig = useCallback(async () => { + if (fileMetadata) { + await uploadFileConfig.mutateAsync({ + fileId: fileMetadata.fileId, + fileConfig: fileConfig + }); + clearFileConfig(); + } + }, [fileConfig, uploadFileConfig, clearFileConfig]); + + const handleExistCloseForm = useCallback(() => { + clearFileConfig(); + setTotalCost(0); + setIsFileUploadSuccess(false); + handleOpenDialog(); + }, [handleOpenDialog, clearFileConfig, setTotalCost, setIsFileUploadSuccess]); + + const handleDecreaseCopies = () => { + if (fileMetadata && fileConfig.numOfCopies > 1) { + setFileConfig(FILE_CONFIG.numOfCopies, fileConfig.numOfCopies - 1); + setTotalCost(totalCost - fileMetadata.fileCoin); + } + }; + const handleIncreaseCopies = () => { + if (fileMetadata) { + setFileConfig(FILE_CONFIG.numOfCopies, fileConfig.numOfCopies + 1); + setTotalCost(totalCost + fileMetadata.fileCoin); + } + }; + const handleLayoutChange = (e: ChangeEvent) => { + setPageBothSide( + e.target.value === LAYOUT_SIDE.portrait + ? PAGE_SIDE.both.portrait[0]! + : PAGE_SIDE.both.landscape[0]! + ); + setFileConfig( + FILE_CONFIG.pageSide, + e.target.value === LAYOUT_SIDE.portrait + ? PAGE_SIDE.both.portrait[0]! + : PAGE_SIDE.both.landscape[0]! + ); + setFileConfig(FILE_CONFIG.layout, e.target.value); + }; + const handlePagesChange = (e: ChangeEvent) => { + setFileConfig(FILE_CONFIG.pages, e.target.value); + }; + const handlePageSideChange = (e: ChangeEvent) => { + setFileConfig(FILE_CONFIG.pageSide, e.target.value); + }; + + if (!fileMetadata) return null; + + return ( + + + + + + + + +
+
+
+
+ Upload document +
+

{fileMetadata.fileName}

+

{`(${formatFileSize(fileMetadata.fileSize)})`}

+
+

+ + + {fileMetadata.fileCoin} x {fileConfig.numOfCopies} copies ={' '} + + + + {fileMetadata.fileCoin * fileConfig.numOfCopies} + +

+
+
+
+ + + + {fileConfig.numOfCopies > 0 && ( + {fileConfig.numOfCopies} + )} + + + +
+ +
+
+
+
+ Layout +
+ {[LAYOUT_SIDE.portrait, LAYOUT_SIDE.landscape].map((item, index) => ( + + ))} +
+
+
+ Pages +
+ {[PAGES_SPECIFIC.all, PAGES_SPECIFIC.odd, PAGES_SPECIFIC.even].map( + (item, index) => ( + + ) + )} + ) => { + setSpecificPage(event.target.value); + setFileConfig(FILE_CONFIG.pages, event.target.value); + }} + crossOrigin='' + /> + } + value={specificPage} + onChange={(event: ChangeEvent) => + setFileConfig(FILE_CONFIG.pages, event.target.value) + } + checked={fileConfig.pages === specificPage} + crossOrigin='' + /> +
+
+
+ Pages per sheet + +
+
+ Page Side +
+ +
+ { + if (event) { + handlePageBothSide(event); + } + }} + > + {PAGE_SIDE.both.portrait.map((item, index) => ( + + ))} + + ) : ( + + ) + } + value={pageBothSide} + onChange={(event: ChangeEvent) => + setFileConfig(FILE_CONFIG.pageSide, event.target.value) + } + checked={fileConfig.pageSide === pageBothSide} + crossOrigin='' + /> + + +
+
+
+
+
+ + + +
+
{}
+
+
+ ); + }; + + return { + openUploadAndPreviewDocBox: () => setOpenDialog(true), + closeUploadAndPreviewDocBox: () => setOpenDialog(false), + UploadAndPreviewDocBox: UploadAndPreviewDocBox + }; +} diff --git a/src/components/order/desktop/index.ts b/src/components/order/desktop/index.ts new file mode 100644 index 0000000..50ae361 --- /dev/null +++ b/src/components/order/desktop/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './UploadAndPreviewDocBox'; diff --git a/src/components/order/mobile/ConfirmOrderForm.tsx b/src/components/order/mobile/ConfirmOrderForm.tsx new file mode 100644 index 0000000..318e930 --- /dev/null +++ b/src/components/order/mobile/ConfirmOrderForm.tsx @@ -0,0 +1,146 @@ +import { useOrderPrintStore } from '@states'; +import { + PrinterIcon, + ChevronLeftIcon, + XMarkIcon, + MapPinIcon, + QuestionMarkCircleIcon, + WalletIcon, + ChevronRightIcon, + ClipboardDocumentListIcon +} from '@heroicons/react/24/solid'; +import { Typography, Select, Option } from '@material-tailwind/react'; +import { FormFooter } from '@components/order/common'; +//import { ConfirmOrderItem } from '@components/order/mobile'; +import coin from '@assets/coin.png'; + +// Tan's third-task in here. +export function ConfirmOrderForm() { + const { totalCost } = useOrderPrintStore(); + //const { setMobileOrderStep } = useOrderWorkflowStore(); + //const { userRemainCoins } = useHomeStore(); + //const { extraFeeData } = useOrderExtraStore(); + // function IconSolid() { + // return ( + // + // + // + // ); + // } + return ( +
+
+
+ setMobileOrderStep(2)} + className='cursor-pointer' + /> + Confirm order +
+ +
+
+
+ + Document +
+
+ {/* {orderPrintList.map((orderItem, index) => ( + + ))} */} +
+
+
+ + Pick-up location +
+
+ + +
+
+
+
+ + Payment method +
+
+ Print wallet + +
+
+ Current balance: +
+ {/* {userRemainCoins} */} + +
+
+ {/* {userRemainCoins < totalCost && ( + }> + Amout exceed balance + Top up your account to proceed + + )} */} +
+
+
+ + Charge Details +
+
    +
  • + Print fee +
    + + {totalCost} +
    +
  • +
  • + Service fee +
    + + {/* {extraFeeData.extraFee} */} +
    +
  • +
  • + Total Cost +
    + + {/* {totalCost + extraFeeData.extraFee} */} +
    +
  • +
+
+
+ + {/* */} + +
+ ); +} diff --git a/src/components/order/mobile/ConfirmOrderItem.tsx b/src/components/order/mobile/ConfirmOrderItem.tsx new file mode 100644 index 0000000..ded2164 --- /dev/null +++ b/src/components/order/mobile/ConfirmOrderItem.tsx @@ -0,0 +1,41 @@ +import { EyeIcon } from '@heroicons/react/24/solid'; +import coin from '@assets/coin.png'; +import { Typography } from '@material-tailwind/react'; +export const ConfirmOrderItem: Component<{ + fileName: string; + coins: number; + size: number; + number: number; +}> = ({ fileName, coins, size, number }) => { + return ( +
+
+ +
Preview
+
+
+
+
+ {fileName} + {` (${size} MB)`} +
+
+
+ + {coins} + x + {`${number}`} +
+
+
+
+ Charge price +
+ + {number * coins} +
+
+
+
+ ); +}; diff --git a/src/components/order/mobile/FileInfo.tsx b/src/components/order/mobile/FileInfo.tsx new file mode 100644 index 0000000..19117b3 --- /dev/null +++ b/src/components/order/mobile/FileInfo.tsx @@ -0,0 +1,227 @@ +import { MutableRefObject, useCallback, useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { + Button, + Dialog, + DialogBody, + DialogHeader, + IconButton, + Typography +} from '@material-tailwind/react'; +import { TrashIcon } from '@heroicons/react/24/outline'; +import { EyeIcon, MinusIcon, PlusIcon } from '@heroicons/react/24/solid'; +import coinImage from '@assets/coin.png'; +import { FILE_CONFIG } from '@constants'; +import { usePrintingRequestMutation, emitEvent } from '@hooks'; +import { useOrderPrintStore, useOrderWorkflowStore } from '@states'; +import { formatFileSize } from '@utils'; + +export const FileInfo: Component<{ + fileExtraMetadata: FileMetadata & { numOfCopies: number }; + isConfigStep: boolean; + fileIndex?: number; + initialTotalCost?: MutableRefObject; + updateAmountFiles?: (listFileAmount: FileAmount[]) => Promise; +}> = ({ fileExtraMetadata, isConfigStep, fileIndex, initialTotalCost, updateAmountFiles }) => { + const queryClient = useQueryClient(); + const { deleteFile } = usePrintingRequestMutation(); + + const { + totalCost, + listFileAmount, + isOrderUpdate, + setFileConfig, + setTotalCost, + setListFileAmount, + clearFileConfig + } = useOrderPrintStore(); + const { mobileOrderStep, setMobileOrderStep } = useOrderWorkflowStore(); + const [openDialog, setOpenDialog] = useState(false); + + useEffect(() => { + if (fileIndex !== undefined && listFileAmount[fileIndex] === undefined && isOrderUpdate) { + setListFileAmount({ + fileId: fileExtraMetadata.fileId, + numOfCopies: fileExtraMetadata.numOfCopies + }); + } + }, [ + fileExtraMetadata.fileId, + fileExtraMetadata.numOfCopies, + fileIndex, + isOrderUpdate, + listFileAmount, + setListFileAmount + ]); + + const handleOpenDialog = useCallback(() => setOpenDialog(!openDialog), [openDialog]); + const handleDecreaseCopies = () => { + if (isConfigStep) { + if (fileExtraMetadata.numOfCopies > 1) { + setFileConfig(FILE_CONFIG.numOfCopies, fileExtraMetadata.numOfCopies - 1); + setTotalCost(totalCost - fileExtraMetadata.fileCoin); + } + } else if (fileIndex !== undefined) { + if ((listFileAmount[fileIndex]?.numOfCopies ?? 0) > 1) { + setListFileAmount({ + fileId: fileExtraMetadata.fileId, + numOfCopies: (listFileAmount[fileIndex]?.numOfCopies ?? 0) - 1 + }); + setTotalCost(totalCost - fileExtraMetadata.fileCoin); + if (initialTotalCost) { + initialTotalCost.current -= fileExtraMetadata.fileCoin; + } + } + } + }; + const handleIncreaseCopies = () => { + if (isConfigStep) { + setFileConfig(FILE_CONFIG.numOfCopies, fileExtraMetadata.numOfCopies + 1); + } else if (fileIndex !== undefined) { + setListFileAmount({ + fileId: fileExtraMetadata.fileId, + numOfCopies: (listFileAmount[fileIndex]?.numOfCopies ?? 0) + 1 + }); + } + setTotalCost(totalCost + fileExtraMetadata.fileCoin); + if (initialTotalCost) { + initialTotalCost.current += fileExtraMetadata.fileCoin; + } + }; + + const handleDeleteFile = useCallback(async () => { + if ( + !isConfigStep && + fileIndex !== undefined && + listFileAmount[fileIndex] !== undefined && + initialTotalCost && + updateAmountFiles + ) { + await updateAmountFiles(listFileAmount); + await deleteFile.mutateAsync(fileExtraMetadata.fileId); + setTotalCost( + totalCost - fileExtraMetadata.fileCoin * (listFileAmount[fileIndex]?.numOfCopies ?? 0) + ); + initialTotalCost.current -= + fileExtraMetadata.fileCoin * (listFileAmount[fileIndex]?.numOfCopies ?? 0); + emitEvent('listFiles:refetch'); + } else { + await deleteFile.mutateAsync(fileExtraMetadata.fileId); + setTotalCost(totalCost - fileExtraMetadata.fileCoin * fileExtraMetadata.numOfCopies); + clearFileConfig(); + } + handleOpenDialog(); + }, [ + fileExtraMetadata.fileCoin, + fileExtraMetadata.fileId, + fileExtraMetadata.numOfCopies, + fileIndex, + initialTotalCost, + isConfigStep, + listFileAmount, + totalCost, + deleteFile, + setTotalCost, + handleOpenDialog, + clearFileConfig, + updateAmountFiles + ]); + + return ( + <> +
+
{ + if (!isConfigStep) { + queryClient.setQueryData(['fileURL'], fileExtraMetadata.fileURL); + } + setMobileOrderStep({ + current: 1, + prev: mobileOrderStep.current + }); + }} + > + + Preview +
+
+
+
+

{fileExtraMetadata.fileName}

+

{`(${formatFileSize(fileExtraMetadata.fileSize)})`}

+
+

+ + + {fileExtraMetadata.fileCoin + + ' x ' + + (fileIndex === undefined + ? fileExtraMetadata.numOfCopies + : listFileAmount[fileIndex]?.numOfCopies)} + + + + {fileExtraMetadata.fileCoin * + (fileIndex === undefined + ? fileExtraMetadata.numOfCopies + : listFileAmount[fileIndex]?.numOfCopies ?? 0)} + +

+
+
+
+ + + + + {fileIndex === undefined + ? fileExtraMetadata.numOfCopies + : listFileAmount[fileIndex]?.numOfCopies} + + + + +
+ +
+
+
+ + + + + + + + + + Bạn có đồng ý xóa file này ra khỏi đơn đặt hàng ? +
+ + +
+
+
+ + ); +}; diff --git a/src/components/order/mobile/OrderListForm.tsx b/src/components/order/mobile/OrderListForm.tsx new file mode 100644 index 0000000..9b14820 --- /dev/null +++ b/src/components/order/mobile/OrderListForm.tsx @@ -0,0 +1,137 @@ +import { MutableRefObject, useCallback, useMemo } from 'react'; +import { Button, Spinner, Typography } from '@material-tailwind/react'; +import { ChevronLeftIcon } from '@heroicons/react/24/solid'; +import { useCloseForm, FileBox, FormFooter } from '@components/order/common'; +import { usePrintingRequestMutation, usePrintingRequestQuery, useListenEvent } from '@hooks'; +import { useOrderPrintStore, useOrderWorkflowStore } from '@states'; +import { formatFileSize } from '@utils'; +import { FileInfo } from './FileInfo'; + +export const OrderListForm: Component<{ + handleExistOrderForm: () => Promise; + initialTotalCost: MutableRefObject; +}> = ({ handleExistOrderForm, initialTotalCost }) => { + const { updateAmountFiles } = usePrintingRequestMutation(); + const { + listFiles: { data: listFiles, isFetching, isError, refetch: refetchListFiles } + } = usePrintingRequestQuery(); + + const { + totalCost, + listFileAmount, + clearListFileAmount, + setTotalCost, + setIsFileUploadSuccess, + setIsOrderUpdate + } = useOrderPrintStore(); + const { setMobileOrderStep } = useOrderWorkflowStore(); + const { openCloseForm, CloseForm } = useCloseForm(); + + const totalSize = useMemo( + () => listFiles?.reduce((totalSize, file) => totalSize + file.fileSize, 0), + [listFiles] + ); + + useListenEvent('listFiles:refetch', clearListFileAmount); + useListenEvent('listFiles:refetch', refetchListFiles); + + const handleExistCloseForm = useCallback(async () => { + initialTotalCost.current = 0; + setTotalCost(0); + setIsFileUploadSuccess(false); + setIsOrderUpdate(false); + clearListFileAmount(); + await handleExistOrderForm(); + }, [ + initialTotalCost, + setIsFileUploadSuccess, + setIsOrderUpdate, + setTotalCost, + clearListFileAmount, + handleExistOrderForm + ]); + + const handleSaveOrderUpdate = useCallback(async () => { + await updateAmountFiles.mutateAsync(listFileAmount); + initialTotalCost.current = totalCost; + setIsOrderUpdate(false); + setMobileOrderStep({ + current: 3, + prev: 2 + }); + }, [ + totalCost, + listFileAmount, + initialTotalCost, + updateAmountFiles, + setMobileOrderStep, + setIsOrderUpdate + ]); + + return ( +
+
+
+ + Order list +
+ +
+ Size limit: +
+ {`${totalSize && formatFileSize(totalSize)} / `} + 1GB +
+
+
+ {listFiles && listFiles.length > 0 ? ( +
+
+ +
+
+ {isFetching ? ( +
+ +
+ ) : isError ? ( +
+ + Không thể tải danh sách các files trong đơn hàng. + +
+ ) : ( + listFiles.map((file, index) => ( +
+ + updateAmountFiles.mutateAsync(listFileAmount) + } + /> +
+ )) + )} +
+
+ ) : ( +
+ +
+ )} + + + +
+ ); +}; diff --git a/src/components/order/mobile/OrderSuccessForm.tsx b/src/components/order/mobile/OrderSuccessForm.tsx new file mode 100644 index 0000000..ed84316 --- /dev/null +++ b/src/components/order/mobile/OrderSuccessForm.tsx @@ -0,0 +1,12 @@ +// Tue's second-task in here. +export function OrderSuccessForm() { + //const { setMobileOrderStep } = useOrderWorkflowStore(); + + return ( + <> +
OrderSuccessForm
+ {/* + */} + + ); +} diff --git a/src/components/order/mobile/OrderWorkflowBox.tsx b/src/components/order/mobile/OrderWorkflowBox.tsx new file mode 100644 index 0000000..7dc9f3a --- /dev/null +++ b/src/components/order/mobile/OrderWorkflowBox.tsx @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useState, useRef } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Dialog, DialogBody } from '@material-tailwind/react'; +import { + UploadDocumentForm, + OrderListForm, + ConfirmOrderForm, + TopupWalletForm, + OrderSuccessForm, + PreviewDocument +} from '@components/order/mobile'; +import { usePrintingRequestMutation } from '@hooks'; +import { useOrderWorkflowStore, useOrderPrintStore } from '@states'; + +export function useOrderWorkflowBox() { + const [openDialog, setOpenDialog] = useState(false); + + const DialogBodyWorkflow = () => { + const queryClient = useQueryClient(); + const printingRequestId = queryClient.getQueryData(['printingRequestId']); + const fileIdCurrent = queryClient.getQueryData(['fileIdCurrent']); + + const { data: fileMetadata } = useQuery({ + queryKey: ['fileMetadata', fileIdCurrent], + queryFn: () => queryClient.getQueryData(['fileMetadata', fileIdCurrent]) + }); + const { cancelPrintingRequest } = usePrintingRequestMutation(); + + const { totalCost, setTotalCost } = useOrderPrintStore(); + const { mobileOrderStep } = useOrderWorkflowStore(); + const initialTotalCost = useRef(totalCost); + + useEffect(() => { + if (fileMetadata?.fileId) { + setTotalCost(initialTotalCost.current + fileMetadata?.fileCoin); + } + }, [fileMetadata?.fileId, fileMetadata?.fileCoin, setTotalCost]); + + const handleExistOrderForm = useCallback(async () => { + if (!printingRequestId) return; + await cancelPrintingRequest.mutateAsync(printingRequestId.id); + setOpenDialog(false); + }, [printingRequestId, cancelPrintingRequest]); + + if (mobileOrderStep.current === 0) { + return ( + + ); + } else if (mobileOrderStep.current === 1) { + return ; + } else if (mobileOrderStep.current === 2) { + return ( + + ); + } else if (mobileOrderStep.current === 3) { + return ; + } else if (mobileOrderStep.current === 4) { + return ; + } else if (mobileOrderStep.current === 5) { + return ; + } + }; + + const OrderWorkflowBox = () => { + return ( + setOpenDialog(false)} + dismiss={{ + escapeKey: false, + outsidePress: false + }} + > + + + + + ); + }; + + return { + openOrderWorkflowBox: () => setOpenDialog(true), + closeOrderWorkflowBox: () => setOpenDialog(false), + OrderWorkflowBox: OrderWorkflowBox + }; +} diff --git a/src/components/order/mobile/PreviewDocument.tsx b/src/components/order/mobile/PreviewDocument.tsx new file mode 100644 index 0000000..a7eb7d8 --- /dev/null +++ b/src/components/order/mobile/PreviewDocument.tsx @@ -0,0 +1,48 @@ +import { useQueryClient } from '@tanstack/react-query'; +import DocViewer, { DocViewerRenderers, IHeaderOverride } from '@cyntler/react-doc-viewer'; +import { IconButton } from '@material-tailwind/react'; +import { XMarkIcon } from '@heroicons/react/24/solid'; +import { useOrderWorkflowStore } from '@states'; + +export function PreviewDocument() { + const queryClient = useQueryClient(); + const fileURL = queryClient.getQueryData(['fileURL']); + const { mobileOrderStep, setMobileOrderStep } = useOrderWorkflowStore(); + + const MyHeader: IHeaderOverride = (state) => { + return ( +
+ Preview document + setMobileOrderStep({ current: mobileOrderStep.prev, prev: 1 })} + > + + +
+ ); + }; + + return ( +
+ {fileURL && ( + + )} +
+ ); +} diff --git a/src/components/order/mobile/TopupWalletForm.tsx b/src/components/order/mobile/TopupWalletForm.tsx new file mode 100644 index 0000000..c47ecab --- /dev/null +++ b/src/components/order/mobile/TopupWalletForm.tsx @@ -0,0 +1,11 @@ +// Tue's first-task in here. +export function TopupWalletForm() { + //const { setMobileOrderStep } = useOrderWorkflowStore(); + + return ( + <> +
TopupWalletForm
+ {/* */} + + ); +} diff --git a/src/components/order/mobile/UploadDocumentForm.tsx b/src/components/order/mobile/UploadDocumentForm.tsx new file mode 100644 index 0000000..b02700f --- /dev/null +++ b/src/components/order/mobile/UploadDocumentForm.tsx @@ -0,0 +1,308 @@ +import { ChangeEvent, MutableRefObject, useCallback, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Button, + IconButton, + Input, + Option, + Radio, + Select, + Typography +} from '@material-tailwind/react'; +import { ExclamationCircleIcon, XMarkIcon } from '@heroicons/react/24/solid'; +import { useCloseForm, useLayoutSide, FileBox, FormFooter } from '@components/order/common'; +import { LAYOUT_SIDE, FILE_CONFIG, PAGES_SPECIFIC, PAGES_PER_SHEET, PAGE_SIDE } from '@constants'; +import { usePrintingRequestMutation, emitEvent } from '@hooks'; +import { useOrderWorkflowStore, useOrderPrintStore } from '@states'; +import { FileInfo } from './FileInfo'; + +export const UploadDocumentForm: Component<{ + handleExistOrderForm: () => Promise; + initialTotalCost: MutableRefObject; +}> = ({ handleExistOrderForm, initialTotalCost }) => { + const queryClient = useQueryClient(); + const fileIdCurrent = queryClient.getQueryData(['fileIdCurrent']); + const { data: fileMetadata } = useQuery({ + queryKey: ['fileMetadata', fileIdCurrent], + queryFn: () => queryClient.getQueryData(['fileMetadata', fileIdCurrent]) + }); + const { uploadFileConfig, deleteFile } = usePrintingRequestMutation(); + const { setMobileOrderStep } = useOrderWorkflowStore(); + const { + isOrderUpdate, + totalCost, + fileConfig, + setFileConfig, + setTotalCost, + setIsFileUploadSuccess, + clearFileConfig, + setIsOrderUpdate + } = useOrderPrintStore(); + const { openLayoutSide, LayoutSide } = useLayoutSide(); + const { openCloseForm, CloseForm } = useCloseForm(); + + const [specificPage, setSpecificPage] = useState(''); + const [pageBothSide, setPageBothSide] = useState( + fileConfig.layout === LAYOUT_SIDE.portrait + ? PAGE_SIDE.both.portrait[0]! + : PAGE_SIDE.both.landscape[0]! + ); + + const handlePageBothSide = useCallback( + (event: string) => { + setPageBothSide(event); + setFileConfig(FILE_CONFIG.pageSide, event); + }, + [setFileConfig] + ); + + const handleSaveFileConfig = useCallback(async () => { + if (fileMetadata?.fileId) { + await uploadFileConfig.mutateAsync({ + fileId: fileMetadata.fileId, + fileConfig: fileConfig + }); + initialTotalCost.current = totalCost; + clearFileConfig(); + setIsOrderUpdate(true); + setMobileOrderStep({ + current: 2, + prev: 0 + }); + } + }, [ + fileMetadata?.fileId, + fileConfig, + totalCost, + uploadFileConfig, + initialTotalCost, + setMobileOrderStep, + clearFileConfig, + setIsOrderUpdate + ]); + + const handleExistCloseForm = useCallback(async () => { + if (isOrderUpdate) { + if (fileMetadata) { + await deleteFile.mutateAsync(fileMetadata.fileId); + setTotalCost(totalCost - fileMetadata.fileCoin * fileConfig.numOfCopies); + emitEvent('listFiles:refetch'); + } + setMobileOrderStep({ + current: 2, + prev: 0 + }); + } else { + initialTotalCost.current = 0; + setTotalCost(0); + setIsFileUploadSuccess(false); + await handleExistOrderForm(); + } + clearFileConfig(); + }, [ + fileConfig.numOfCopies, + fileMetadata, + totalCost, + isOrderUpdate, + deleteFile, + initialTotalCost, + handleExistOrderForm, + clearFileConfig, + setTotalCost, + setIsFileUploadSuccess, + setMobileOrderStep + ]); + + const handleLayoutChange = (e: ChangeEvent) => { + setPageBothSide( + e.target.value === LAYOUT_SIDE.portrait + ? PAGE_SIDE.both.portrait[0]! + : PAGE_SIDE.both.landscape[0]! + ); + setFileConfig( + FILE_CONFIG.pageSide, + e.target.value === LAYOUT_SIDE.portrait + ? PAGE_SIDE.both.portrait[0]! + : PAGE_SIDE.both.landscape[0]! + ); + setFileConfig(FILE_CONFIG.layout, e.target.value); + }; + const handlePagesChange = (e: ChangeEvent) => { + setFileConfig(FILE_CONFIG.pages, e.target.value); + }; + const handlePageSideChange = (e: ChangeEvent) => { + setFileConfig(FILE_CONFIG.pageSide, e.target.value); + }; + + return ( + <> +
+ Upload document + + + + +
+ {fileMetadata ? ( + + ) : ( + + )} +
+
+ Layout +
+ {[LAYOUT_SIDE.portrait, LAYOUT_SIDE.landscape].map((item, index) => ( + + ))} +
+
+
+ Pages +
+ {[PAGES_SPECIFIC.all, PAGES_SPECIFIC.odd, PAGES_SPECIFIC.even].map((item, index) => ( + + ))} + ) => { + setSpecificPage(event.target.value); + setFileConfig(FILE_CONFIG.pages, event.target.value); + }} + crossOrigin='' + /> + } + value={specificPage} + onChange={(event: ChangeEvent) => + setFileConfig(FILE_CONFIG.pages, event.target.value) + } + checked={fileConfig.pages === specificPage} + crossOrigin='' + /> +
+
+
+ Pages per sheet + +
+
+ Page Side +
+ +
+ { + if (event) { + handlePageBothSide(event); + } + }} + > + {PAGE_SIDE.both.portrait.map((item, index) => ( + + ))} + + ) : ( + + ) + } + value={pageBothSide} + onChange={(event: ChangeEvent) => + setFileConfig(FILE_CONFIG.pageSide, event.target.value) + } + checked={fileConfig.pageSide === pageBothSide} + crossOrigin='' + /> + + +
+
+
+
+ + + + + ); +}; diff --git a/src/components/order/mobile/index.ts b/src/components/order/mobile/index.ts new file mode 100644 index 0000000..d112637 --- /dev/null +++ b/src/components/order/mobile/index.ts @@ -0,0 +1,13 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './ConfirmOrderForm'; +export * from './ConfirmOrderItem'; +export * from './FileInfo'; +export * from './OrderListForm'; +export * from './OrderSuccessForm'; +export * from './OrderWorkflowBox'; +export * from './PreviewDocument'; +export * from './TopupWalletForm'; +export * from './UploadDocumentForm'; diff --git a/src/constants/home.ts b/src/constants/home.ts new file mode 100644 index 0000000..d94bef7 --- /dev/null +++ b/src/constants/home.ts @@ -0,0 +1,15 @@ +type OrderStatus = 'progressing' | 'ready' | 'done' | 'canceled'; +export const ORDER_STATUS: Readonly> = Object.freeze({ + progressing: 'progressing', + ready: 'ready', + done: 'done', + canceled: 'canceled' +}); + +type OrderStatusColor = 'amber' | 'green' | 'indigo' | 'red'; +export const ORDER_STATUS_COLOR: Readonly> = Object.freeze({ + progressing: 'amber', + ready: 'green', + done: 'indigo', + canceled: 'red' +}); diff --git a/src/constants/index.ts b/src/constants/index.ts index b7e4a51..76a8280 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -2,5 +2,7 @@ * @file Automatically generated by barrelsby. */ -export * from './project'; +export * from './home'; +export * from './menuBar'; +export * from './orderPrint'; export * from './screen'; diff --git a/src/constants/menuBar.ts b/src/constants/menuBar.ts new file mode 100644 index 0000000..6d2e90e --- /dev/null +++ b/src/constants/menuBar.ts @@ -0,0 +1,11 @@ +export const MAIN_MENU = { + home: 'Home', + order: 'My order', + payment: 'Payment', + location: 'Location' +}; +export const SUB_MENU = { + help: 'Help Center', + settings: 'Settings', + logout: 'Log out' +}; diff --git a/src/constants/orderPrint.ts b/src/constants/orderPrint.ts new file mode 100644 index 0000000..dad9204 --- /dev/null +++ b/src/constants/orderPrint.ts @@ -0,0 +1,68 @@ +import portraitBottom from '@assets/portrait-bottom.jpg'; +import portraitTop from '@assets/portrait-top.jpg'; +import portraitLeft from '@assets/portrait-left.jpg'; +import portraitRight from '@assets/portrait-right.jpg'; +import landscapeBottom from '@assets/landscape-bottom.jpg'; +import landscapeTop from '@assets/landscape-top.jpg'; +import landscapeLeft from '@assets/landscape-left.png'; +import landscapeRight from '@assets/landscape-right.jpg'; + +export const LAYOUT_SIDE = { + portrait: 'Portrait', + landscape: 'Landscape' +}; + +export const LAYOUT_INFO = [ + { + pos: 'Left', + portraitImage: portraitLeft, + landscapeImage: landscapeLeft, + portraitSize: 'Long edge', + landscapeSize: 'Short edge' + }, + { + pos: 'Right', + portraitImage: portraitRight, + landscapeImage: landscapeRight, + portraitSize: 'Long edge', + landscapeSize: 'Short edge' + }, + { + pos: 'Top', + portraitImage: portraitTop, + landscapeImage: landscapeTop, + portraitSize: 'Short edge', + landscapeSize: 'Long edge' + }, + { + pos: 'Bottom', + portraitImage: portraitBottom, + landscapeImage: landscapeBottom, + portraitSize: 'Short edge', + landscapeSize: 'Long edge' + } +]; + +export const FILE_CONFIG = { + numOfCopies: 'numOfCopies', + layout: 'layout', + pages: 'pages', + pagesPerSheet: 'pagesPerSheet', + pageSide: 'pageSide' +}; + +export const PAGES_SPECIFIC = { + all: 'All', + odd: 'Odd pages only', + even: 'Even pages only' +}; + +export const PAGES_PER_SHEET = ['1', '2', '4', '8', '16']; + +export const PAGE_SIDE = { + one: 'One side', + both: { + portrait: ['Long edge (Left)', 'Long edge (Right)', 'Short edge (Top)', 'Short edge (Bottom)'], + landscape: ['Short edge (Left)', 'Short edge (Right)', 'Long edge (Top)', 'Long edge (Bottom)'] + } +}; diff --git a/src/constants/project.ts b/src/constants/project.ts deleted file mode 100644 index 8f42a0e..0000000 --- a/src/constants/project.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const PROJECT_HEADER = [ - 'TÊN DỰ ÁN', - 'LOẠI SẢN PHẨM', - 'MÔ TẢ DỰ ÁN', - 'TRẠNG THÁI', - 'THÀNH VIÊN', - 'THỜI GIAN' -]; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 27652a7..478fa76 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,4 +2,8 @@ * @file Automatically generated by barrelsby. */ +export * from './useEvent'; +export * from './usePrintingRequestMutation.hook'; +export * from './usePrintingRequestQuery.hook'; export * from './useScreenSize'; +export * from './useUserQuery.hook'; diff --git a/src/hooks/useEvent.ts b/src/hooks/useEvent.ts new file mode 100644 index 0000000..387da50 --- /dev/null +++ b/src/hooks/useEvent.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react'; + +type EventKey = 'listFiles:refetch' | 'amountFiles:update'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useListenEvent(event: EventKey, listener: (...args: any[]) => void) { + useEffect(() => { + window.addEventListener(event, listener); + return () => { + window.removeEventListener(event, listener); + }; + }, [event, listener]); +} + +export function emitEvent(event: EventKey, ...args: unknown[]) { + return window.dispatchEvent(new CustomEvent(event, { detail: args })); +} diff --git a/src/hooks/usePrintingRequestMutation.hook.ts b/src/hooks/usePrintingRequestMutation.hook.ts new file mode 100644 index 0000000..8499504 --- /dev/null +++ b/src/hooks/usePrintingRequestMutation.hook.ts @@ -0,0 +1,59 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { printingRequestService } from '@services'; + +export function usePrintingRequestMutation() { + const queryClient = useQueryClient(); + + const createPrintingRequest = useMutation({ + mutationKey: ['createPrintingRequest'], + mutationFn: () => printingRequestService.createPrintingRequest(), + onSuccess: (data) => { + queryClient.setQueryData(['printingRequestId'], data); + } + }); + + const uploadFile = useMutation({ + mutationKey: ['uploadFile'], + mutationFn: ({ printingRequestId, file }: { printingRequestId: string; file: File }) => + printingRequestService.uploadFile(printingRequestId, file), + onSuccess: (data) => { + queryClient.setQueryData(['fileIdCurrent'], data.fileId); + queryClient.setQueryData(['fileURL'], data.fileURL); + queryClient.setQueryData(['fileMetadata', data.fileId], data); + } + }); + + const uploadFileConfig = useMutation({ + mutationKey: ['uploadFileConfig'], + mutationFn: ({ fileId, fileConfig }: { fileId: string; fileConfig: FileConfig }) => + printingRequestService.uploadFileConfig(fileId, fileConfig) + }); + + const deleteFile = useMutation({ + mutationKey: ['deleteFile'], + mutationFn: (fileId: string) => printingRequestService.deleteFile(fileId), + onSuccess: (data) => { + queryClient.setQueryData(['fileMetadata', data.fileId], null); + } + }); + + const updateAmountFiles = useMutation({ + mutationKey: ['updateAmountFile'], + mutationFn: (payload: FileAmount[]) => printingRequestService.updateAmountFiles(payload) + }); + + const cancelPrintingRequest = useMutation({ + mutationKey: ['cancelPrintingRequest'], + mutationFn: (printingRequestId: string) => + printingRequestService.cancelPrintingRequest(printingRequestId) + }); + + return { + createPrintingRequest: createPrintingRequest, + uploadFile: uploadFile, + uploadFileConfig: uploadFileConfig, + deleteFile: deleteFile, + updateAmountFiles: updateAmountFiles, + cancelPrintingRequest: cancelPrintingRequest + }; +} diff --git a/src/hooks/usePrintingRequestQuery.hook.ts b/src/hooks/usePrintingRequestQuery.hook.ts new file mode 100644 index 0000000..85cc356 --- /dev/null +++ b/src/hooks/usePrintingRequestQuery.hook.ts @@ -0,0 +1,25 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { printingRequestService } from '@services'; +import { retryQueryFn } from '@utils'; + +export function usePrintingRequestQuery() { + const queryClient = useQueryClient(); + const printingRequestId = queryClient.getQueryData(['printingRequestId']); + + const listFiles = useQuery({ + queryKey: [ + '/api/printRequest/{printingRequestId}/files', + printingRequestId, + printingRequestId && printingRequestId.id + ], + queryFn: () => + printingRequestId + ? printingRequestService.getListFilesByPrintingRequest(printingRequestId.id) + : undefined, + retry: retryQueryFn + }); + + return { + listFiles: listFiles + }; +} diff --git a/src/hooks/useUserQuery.hook.ts b/src/hooks/useUserQuery.hook.ts new file mode 100644 index 0000000..72438a7 --- /dev/null +++ b/src/hooks/useUserQuery.hook.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; +import { userService } from '@services'; +import { retryQueryFn } from '@utils'; + +export function useUserQuery() { + const remainCoins = useQuery({ + queryKey: ['/api/user/remain-coins'], + queryFn: () => userService.getRemainCoins(), + retry: retryQueryFn + }); + + const info = useQuery({ + queryKey: ['/api/user'], + queryFn: () => userService.getInfo(), + retry(failureCount, error: ResponseError) { + if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) return false; + return failureCount < 0; + }, + enabled: false + }); + + return { + remainCoins: remainCoins, + info: info + }; +} diff --git a/src/index.css b/src/index.css index 0884c20..2022c45 100644 --- a/src/index.css +++ b/src/index.css @@ -2,4 +2,35 @@ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +.slider-container { + display: flex; + overflow-x: auto; + padding: 10px 0; + scrollbar-width: none; + -ms-overflow-style: none; + scroll-behavior: smooth; +} + +.slider-container::-webkit-scrollbar { + width: 0; +} + +.prevButton, +.nextButton { + cursor: pointer; + padding: 15px; + border: 1px solid #ccc; + border-radius: 50%; + position: absolute; + top: 50%; + z-index: 1; +} + +.grayscale { filter: grayscale(100%); } + +#react-doc-viewer #pdf-controls { + z-index: 5; + padding-top: 0; +} diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index e8a1549..92b48f2 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -1,3 +1,55 @@ -export function AppLayout() { - return <>; -} +import { useMemo } from 'react'; +import { Routes, Route } from 'react-router-dom'; +import { AppNavigation } from '@components/common'; + +export const AppLayout: Component<{ menu: RouteMenu }> = ({ menu }) => { + const routeItems = useMemo(() => { + const mainItem: RouteMenu = []; + const subItem: RouteMenu = []; + const items: { path: string; element: React.ReactElement }[] = []; + + for (const menuItem of menu) { + if (menuItem.type === 'logout-btn') { + subItem.push({ type: menuItem.type, name: menuItem.name, onClick: menuItem.onClick }); + continue; + } + + items.push({ path: menuItem.path, element: menuItem.element }); + if (menuItem.type === 'main-item') { + mainItem.push({ + type: menuItem.type, + name: menuItem.name, + path: menuItem.path, + element: menuItem.element + }); + } + if (menuItem.type === 'sub-item') { + subItem.push({ + type: menuItem.type, + name: menuItem.name, + path: menuItem.path, + element: menuItem.element + }); + } + } + + return { + items, + mainItem, + subItem + }; + }, [menu]); + + return ( +
+ +
+ + {routeItems.items.map((item) => ( + + ))} + +
+
+ ); +}; diff --git a/src/layouts/AuthLayout.tsx b/src/layouts/AuthLayout.tsx index 6a6d0d1..ffe49cc 100644 --- a/src/layouts/AuthLayout.tsx +++ b/src/layouts/AuthLayout.tsx @@ -1,13 +1,29 @@ -import loginImage from '@assets/login.svg'; +import logo from '@assets/logobk.png'; +import corner from '@assets/corner.png'; import { ILayout } from '@interfaces'; -export const AuthLayout: ILayout = function ({ children }) { +export const AuthLayout: ILayout = ({ children }) => { return ( -
-
- TickLab Banner +
+
+
+
+ +
+
+

+ đại học quốc gia thành phố hồ chí minh +

+

+ trường đại học bách khoa +

+
+
{children}
+
+ +
); }; diff --git a/src/main.tsx b/src/main.tsx index be9838f..3fd8cb0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,16 +1,43 @@ -import React from 'react'; import ReactDOM from 'react-dom/client'; -import App from './App'; -import './index.css'; -import 'react-toastify/dist/ReactToastify.css'; +import { BrowserRouter } from 'react-router-dom'; import { ToastContainer } from 'react-toastify'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ThemeProvider } from '@material-tailwind/react'; +import { StyleSheetManager } from 'styled-components'; +import isPropValid from '@emotion/is-prop-valid'; +import 'react-toastify/dist/ReactToastify.css'; +import 'react-pdf/dist/esm/Page/TextLayer.css'; +import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; +import './index.css'; +import App from './App'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + gcTime: 1800000 + } + } +}); -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + { + if (typeof target === 'string') { + return isPropValid(propName); + } + return true; + }} + > + + + + + + + + + ); diff --git a/src/openapi-spec.ts b/src/openapi-spec.ts new file mode 100644 index 0000000..08f9429 --- /dev/null +++ b/src/openapi-spec.ts @@ -0,0 +1,920 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + '/auth/google': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Redirect URL of google auth */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/login': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': { + /** Format: email */ + email: string; + password: string; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + id: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/signup': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': { + /** Format: email */ + userName: string; + password: string; + role: number[]; + name: string; + email: string; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + id: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/coin/paypal/completing': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Complete PayPal Order to buy more coin */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** @example { + * "intent": "CAPTURE", + * "orderId": "Order_id_is_in_result_of_create_order_api" + * } */ + 'application/json': { + intent: string; + orderId: string; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + id: string; + numCoin: number; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/coin/paypal/creating': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create PayPal Order to buy more coin */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** @example { + * "intent": "CAPTURE", + * "amount": 1 + * } */ + 'application/json': { + intent: string; + amount: number; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + id: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/configuration/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get list configurations + * @description Get all current name and value of configurations + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + name: string; + value: string; + }[]; + }; + }; + }; + }; + /** + * Update list accepted extensions + * @description Update accepted extensions of printing file + */ + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** @example { + * "acceptedExtensions": [ + * "pdf", + * "png" + * ] + * } */ + 'application/json': { + acceptedExtensions: string[]; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': string[]; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/configuration/acceptedExtension': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get list accepted extensions + * @description Get all current accepted extensions of printing file + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': string[]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/home/slides': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get slide images for home page */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** Format: uri */ + src: string; + alt: string; + }[]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/printRequest': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all printing request of current user */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + id: string; + /** Format: status */ + status: string; + /** Format: location */ + location: string; + /** Format: number */ + numFiles: number; + filesName: string[]; + /** Format: pageNumber */ + numPages: number; + /** Format: coins */ + coins: number; + /** Format: paid */ + paid: string; + }[]; + }; + }; + }; + }; + put?: never; + /** Create printing request */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + id: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/printRequest/{printingRequestId}/file/{fileId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the specific files of printing request + * @deprecated + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + fileId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + fileId: string; + fileName: string; + numPage: number; + fileURL: string; + fileSize: number; + fileCoin: number; + numOfCopies: number; + }[]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/printRequest/{printingRequestId}/files': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all files of printing request */ + get: { + parameters: { + query?: never; + header?: never; + path: { + printingRequestId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + fileId: string; + fileName: string; + numPage: number; + fileURL: string; + fileSize: number; + fileCoin: number; + numOfCopies: number; + }[]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/printRequest/execute': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Execute printing request */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': { + printingRequestId: string; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + status: string; + message: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/printRequest/uploadConfig/{fileId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upload config to specific file */ + post: { + parameters: { + query?: never; + header?: never; + path: { + fileId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': { + numOfCopies: number; + layout: string; + pages: string; + pagesPerSheet: string; + pageSide: string; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + status: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/printRequest/{printingRequestId}/uploadFile': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upload file to printing request */ + post: { + parameters: { + query?: never; + header?: never; + path: { + printingRequestId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'multipart/form-data': { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + fileId: string; + fileName: string; + numPage: number; + fileURL: string; + fileSize: number; + fileCoin: number; + fileNum: number; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/printRequest/printAmount': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Change the amount of prints for multiple files */ + patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + fileId: string; + numOfCopies: number; + }[]; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + status: string; + message?: string; + }; + }; + }; + }; + }; + trace?: never; + }; + '/api/printRequest/{printingRequestId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Cancel printing request */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + printingRequestId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + printingStatus: 'progressing' | 'ready' | 'done' | 'canceled'; + }; + }; + }; + }; + }; + trace?: never; + }; + '/api/printRequest/file/{fileId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete the specific file of printing request */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + fileId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + status: string; + fileId: string; + fileName: string; + message: string; + }; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/user': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + id: string; + /** Format: email */ + email: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/user/remainCoins': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description get remain coin of current student */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': number; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: never; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/src/pages/AuthPage.tsx b/src/pages/AuthPage.tsx index b235cef..0571f26 100644 --- a/src/pages/AuthPage.tsx +++ b/src/pages/AuthPage.tsx @@ -1,60 +1,112 @@ -import { useForm } from 'react-hook-form'; +import { useState } from 'react'; +import { NavigateFunction, useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import { useMutation } from '@tanstack/react-query'; +import * as Yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; import { Card, Input, Button, Typography } from '@material-tailwind/react'; -import { EnvelopeIcon, KeyIcon } from '@heroicons/react/24/outline'; +import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'; +import { useUserQuery } from '@hooks'; import { authService } from '@services'; -import { useUserStore } from '@states'; -export function AuthPage() { - const { register, handleSubmit } = useForm(); +export const AuthPage = () => { + const navigate: NavigateFunction = useNavigate(); + const [showPassword, setShowPassword] = useState('password'); + const { + info: { refetch } + } = useUserQuery(); - const { getUserData } = useUserStore(); + const validateSchema = Yup.object().shape({ + email: Yup.string() + .required('Username is required!') + .min(5, 'Username must be at least 5 characters'), + password: Yup.string() + .required('Password is required!') + .min(8, 'Password must be at least 8 characters') + }); - const submit = (data: LoginFormData) => { - authService - .login(data) - .then(() => { - getUserData(); - }) - .catch((err) => { - toast.error(err.message); - }); + const { + register, + handleSubmit, + formState: { errors } + } = useForm({ + resolver: yupResolver(validateSchema) + }); + + const { mutateAsync } = useMutation({ + mutationKey: ['login'], + mutationFn: (data: LoginFormData) => authService.login(data) + }); + + const submit: SubmitHandler = async (data) => { + try { + await mutateAsync(data); + await refetch(); + toast.success('Login successfully!'); + navigate('/home'); + } catch (err) { + const errorMessage = (err as ResponseError).message; + toast.error(errorMessage); + } }; return ( - - Login + + Welcome! - - Enter your email and password. + + Student Smart Printing Service (SSPS) -
-
+ +
} {...register('email', { required: true, minLength: 5 })} - type='email' - crossOrigin='' - /> - } - label='Password' - {...register('password', { - required: true, - minLength: 8 - })} + type='text' crossOrigin='' + className={ + errors.email + ? 'focus:text-red-200 focus:font-bold focus:bg-[#fdf2f2] !text-red-200 !font-bold !bg-[#fdf2f2]' + : 'bg-white' + } /> +

{errors.email?.message}

+
+ + { + showPassword === 'password' ? setShowPassword('text') : setShowPassword('password'); + }} + > + {showPassword === 'text' ? : } + +
+

{errors.password?.message}