From 7b4810dc75e5e76d34c87401c2f9478df88301ff Mon Sep 17 00:00:00 2001 From: Hana Dowe <113059487+hana-dowe@users.noreply.github.com> Date: Wed, 14 Feb 2024 01:44:24 -0500 Subject: [PATCH] MCSS-121: Change QR Check-in Workflow (#122) --- api/schema.ts | 6 +- components/Dashboard/ModalScanner/index.tsx | 238 ++++++++++++++++++++ hooks/QRCode/useQRUserGet.ts | 17 ++ hooks/User/useUserGet.ts | 2 +- pages/dashboard/scanner/index.tsx | 165 ++++++-------- pages/schedule/index.tsx | 20 +- types/QRCode/index.ts | 4 + 7 files changed, 351 insertions(+), 101 deletions(-) create mode 100644 components/Dashboard/ModalScanner/index.tsx create mode 100644 hooks/QRCode/useQRUserGet.ts diff --git a/api/schema.ts b/api/schema.ts index 5fded1b..1bc53f1 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -9,7 +9,7 @@ import { import { EmailVerifyReq, EmailVerifyResp } from '@/types/Email' import { EventListResp } from '@/types/Event' import { PhotoListResp } from '@/types/Photo' -import { QRCheckInReq, QRCheckInResp } from '@/types/QRCode' +import { QRCheckInReq, QRCheckInResp, QRUserGetParams } from '@/types/QRCode' import { UserGetResp, UserListParams, @@ -90,6 +90,10 @@ const qrCodes = (customFetch: CustomFetch) => const res = await customFetch('POST', 'DH_BE', '/qr-check-in', args) return res.data as QRCheckInResp }, + qrUserInfo: async (args: QRUserGetParams) => { + const res = await customFetch('GET', 'DH_BE', `/admin-user-get?qrId=${args.qrId}`) + return res.data as UserGetResp + }, } as const) const users = (customFetch: CustomFetch) => diff --git a/components/Dashboard/ModalScanner/index.tsx b/components/Dashboard/ModalScanner/index.tsx new file mode 100644 index 0000000..a8fbdd4 --- /dev/null +++ b/components/Dashboard/ModalScanner/index.tsx @@ -0,0 +1,238 @@ +import Image from 'next/image' + +import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded' +import InfoIcon from '@mui/icons-material/Info' +import TaskAltRoundedIcon from '@mui/icons-material/TaskAltRounded' +import Chip from '@mui/material/Chip' +import Grid from '@mui/material/Grid' +import Typography from '@mui/material/Typography' +import useMediaQuery from '@mui/material/useMediaQuery' + +import Modal from '@/components/Dashboard/Modal' +import FullPageSpinner from '@/components/Shared/FullPageSpinner' +import theme from '@/styles/theme' +import { User } from '@/types/User' + +export type ScannerModalContext = + | { + message: string + success: boolean + qrId?: never + user?: never + } + | { + message?: never + success?: never + qrId: string + user: User + } + +type Props = { + open: boolean + setOpen: (open: boolean) => void + onConfirmCheckIn: (qrId: string) => void + modalContext: ScannerModalContext + isLoading: boolean +} + +const ModalScanner = (props: Props) => { + const { open, setOpen, onConfirmCheckIn, modalContext, isLoading } = props + + const desktop = useMediaQuery(theme.breakpoints.up('sm')) + + const userContext = getUserContext(modalContext.user) + const isUserModal = !!modalContext.user && !!userContext + const isValidateName = isUserModal && userContext.success === undefined + + const getModalTitle = () => { + if (isLoading) return '' + if (isUserModal) { + if (userContext.success === undefined) return 'Registration' + return userContext.success ? 'Success' : 'Error' + } + return modalContext.success ? 'Success' : 'Error' + } + + const modalTitle = getModalTitle() + + const getModalColor = () => { + if (isLoading) return '' + if (isUserModal) { + if (userContext.success === undefined) return '' + return userContext.success ? theme.palette.success.dark : theme.palette.error.dark + } + return modalContext.success ? theme.palette.success.dark : theme.palette.error.dark + } + + const modalColor = getModalColor() + + return ( + setOpen(false)} + primaryButton={{ + text: 'Continue', + size: 'large', + onClick: () => { + isValidateName ? onConfirmCheckIn(modalContext.qrId) : setOpen(false) + }, + fullWidth: !desktop, + loading: isLoading, + sx: { + color: modalColor, + transition: 'all 0.2s ease', + }, + }} + {...(isValidateName && { + secondaryButton: { + text: 'Cancel', + size: 'large', + onClick: () => { + setOpen(false) + }, + disabled: isLoading, + fullWidth: !desktop, + }, + })} + iconButtonSX={{ + color: 'text.primary', + }} + PaperProps={{ + elevation: 2, + sx: { + transition: 'all 0.2s ease', + backgroundColor: modalColor, + m: '1rem', + maxHeight: 'calc(100% - 2rem)', + width: 'calc(100% - 2rem)', + }, + }} + {...(!desktop && { + fullScreen: true, + PaperProps: { + elevation: 2, + sx: { + transition: 'all 0.2s ease', + backgroundColor: modalColor, + m: '0', + maxHeight: '100%', + width: '100%', + }, + }, + })} + dialogContentProps={{ + sx: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'column', + whiteSpace: 'pre-line', + }, + }} + > + {isLoading ? ( + + ) : isUserModal ? ( + // Registration validate name before actual check-in + + + User Avatar + + + {`${modalContext.user.first_name} ${modalContext.user.last_name}`} + + + {`@${modalContext.user.username}`} + + + } + color="secondary" + label={`Status: ${modalContext.user.status.title()}`} + /> + + + {userContext.success === false && {`Error: `}} + {userContext.message} + + + ) : ( + // Error or Success from check-in OR api error from user-get + <> + {modalContext.success ? ( + + + + ) : ( + + + + )} + + {modalContext.message} + + + )} + + ) +} + +const getUserContext = (user?: User) => { + if (!user) return + + switch (user.status) { + case 'accepted': + return { + message: `Confirm registration for this hacker?`, + } + + case 'attended': + return { + message: 'Hacker has already checked in.', + success: false, + } + case 'admin': + case 'moderator': + case 'guest': + case 'volunteer': + return { + message: `${user.status.capitalize()}s don't need to check-in.`, + success: true, + } + + default: + // pending, registering, applied, selected, rejected + return { + message: `${user.status.capitalize()} users cannot check-in.`, + success: false, + } + } +} + +export default ModalScanner diff --git a/hooks/QRCode/useQRUserGet.ts b/hooks/QRCode/useQRUserGet.ts new file mode 100644 index 0000000..2f6ed27 --- /dev/null +++ b/hooks/QRCode/useQRUserGet.ts @@ -0,0 +1,17 @@ +import { useAPI } from '@/contexts/API' +import { getAvatar } from '@/hooks/User/useUserGet' + +type Props = { + onSuccess?: () => void + onError?: () => void +} + +export const useQRUserGet = (props?: Props) => { + return useAPI().useMutation('qrUserInfo', { + onSuccess: (data) => { + data.user.avatar = getAvatar(data.user) + props?.onSuccess?.() + }, + onError: props?.onError, + }) +} diff --git a/hooks/User/useUserGet.ts b/hooks/User/useUserGet.ts index ca4bc31..4b54f6f 100644 --- a/hooks/User/useUserGet.ts +++ b/hooks/User/useUserGet.ts @@ -26,7 +26,7 @@ export const useUserGet = (props?: Props) => { /** * https://discord.com/developers/docs/reference#image-formatting-image-base-url */ -const getAvatar = (user: User) => { +export const getAvatar = (user: User) => { const avatar = user.avatar if (avatar) return `https://cdn.discordapp.com/avatars/${user.discord_id}/${avatar}.${ diff --git a/pages/dashboard/scanner/index.tsx b/pages/dashboard/scanner/index.tsx index d5d1e18..85eb78d 100644 --- a/pages/dashboard/scanner/index.tsx +++ b/pages/dashboard/scanner/index.tsx @@ -1,8 +1,6 @@ import Head from 'next/head' import { Suspense, useState } from 'react' -import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded' -import TaskAltRoundedIcon from '@mui/icons-material/TaskAltRounded' import CircularProgress from '@mui/material/CircularProgress' import Container from '@mui/material/Container' import Fade from '@mui/material/Fade' @@ -11,42 +9,87 @@ import InputLabel from '@mui/material/InputLabel' import MenuItem from '@mui/material/MenuItem' import Select, { SelectChangeEvent } from '@mui/material/Select' import Typography from '@mui/material/Typography' -import useMediaQuery from '@mui/material/useMediaQuery' import { APIError } from '@/api/types' -import Modal from '@/components/Dashboard/Modal' +import ModalScanner, { ScannerModalContext } from '@/components/Dashboard/ModalScanner' import BackButton from '@/components/Shared/BackButton' import FullPageSpinner from '@/components/Shared/FullPageSpinner' import { useAuth } from '@/contexts/Auth' import { useFeatureToggle } from '@/contexts/FeatureToggle' import { useToast } from '@/contexts/Toast' import { useQRCheckIn } from '@/hooks/QRCode/useQRCheckIn' +import { useQRUserGet } from '@/hooks/QRCode/useQRUserGet' import Error401Page from '@/pages/401' import Error404Page from '@/pages/404' -import theme from '@/styles/theme' import { QrScanner } from '@yudiel/react-qr-scanner' -import { QRCheckInContext, QRCheckInResp, qrContextLabels, qrContextOptions } from 'types/QRCode' +import { QRCheckInContext, qrContextLabels, qrContextOptions } from 'types/QRCode' const QRCodeScanner = () => { const [context, setContext] = useState('') - const [result, setResult] = useState({ message: '', success: false }) + const [modalContext, setModalContext] = useState({ + message: '', + success: false, + }) const [openResult, setOpenResult] = useState(false) const { user, loading, authenticated } = useAuth() const { toggles } = useFeatureToggle() const { setToast } = useToast() - const desktop = useMediaQuery(theme.breakpoints.up('sm')) + const { mutate: qrCheckIn, isLoading: qrCheckInLoading } = useQRCheckIn() + const { mutate: qrUserGet, isLoading: qrUserLoading } = useQRUserGet() - const { mutate: qrCheckIn, isLoading } = useQRCheckIn() - - const enableScanner = !openResult && !isLoading + const enableScanner = !openResult && !qrCheckInLoading && !qrUserLoading const allowedStatuses = ['admin', 'moderator', 'volunteer'] - const handleChange = (event: SelectChangeEvent) => { + const handleChangeContext = (event: SelectChangeEvent) => { setContext(event.target.value as QRCheckInContext) } + const handleRegistration = (qrId: string) => { + qrUserGet( + { qrId }, + { + onSuccess: (resp) => { + setModalContext({ + user: resp.user, + qrId, + }) + setOpenResult(true) + }, + onError: (err) => { + const apiError = (err as APIError).apiError.err + setModalContext({ + message: apiError.message ?? apiError.error, + success: false, + }) + setOpenResult(true) + }, + } + ) + } + + const handleCheckIn = (qrId: string) => { + if (!context) return + qrCheckIn( + { qrId, context }, + { + onSuccess: (resp) => { + setModalContext(resp) + setOpenResult(true) + }, + onError: (err) => { + const apiError = (err as APIError).apiError.err + setModalContext({ + message: apiError.message ?? apiError.error, + success: false, + }) + setOpenResult(true) + }, + } + ) + } + if (!toggles.dashboard || (user?.status && !allowedStatuses.includes(user.status))) { return } @@ -72,25 +115,12 @@ const QRCodeScanner = () => { { - if (!context) return - qrCheckIn( - { qrId: result, context }, - { - onSuccess: (resp) => { - setResult(resp) - setOpenResult(true) - }, - onError: (err) => { - const apiError = (err as APIError).apiError.err - setResult({ - message: apiError.message ?? apiError.error, - success: false, - }) - setOpenResult(true) - }, - } - ) + onDecode={(qrId) => { + if (context === 'registration') { + handleRegistration(qrId) + } else { + handleCheckIn(qrId) + } }} onError={(error) => setToast({ @@ -109,7 +139,7 @@ const QRCodeScanner = () => {