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
+
+
+
+
+
+ {`${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 = () => {
- setOpenResult(false)}
- primaryButton={{
- text: 'Continue',
- size: 'large',
- onClick: () => {
- setOpenResult(false)
- },
- fullWidth: !desktop,
- sx: {
- color: result.success ? theme.palette.success.dark : theme.palette.error.dark,
- },
- }}
- iconButtonSX={{
- color: 'text.primary',
- }}
- PaperProps={{
- elevation: 2,
- sx: {
- backgroundColor: result.success
- ? theme.palette.success.dark
- : theme.palette.error.dark,
- m: '1rem',
- maxHeight: 'calc(100% - 2rem)',
- width: 'calc(100% - 2rem)',
- },
- }}
- {...(!desktop && {
- fullScreen: true,
- PaperProps: {
- elevation: 2,
-
- sx: {
- backgroundColor: result.success
- ? theme.palette.success.dark
- : theme.palette.error.dark,
- m: '0',
- maxHeight: '100%',
- width: '100%',
- },
- },
- })}
- dialogContentProps={{
- sx: {
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
- flexDirection: 'column',
- },
- }}
- >
- {result.success ? (
-
-
-
- ) : (
-
-
-
- )}
-
- {result.message}
-
-
+ setOpen={setOpenResult}
+ onConfirmCheckIn={handleCheckIn}
+ modalContext={modalContext}
+ isLoading={qrCheckInLoading || qrUserLoading}
+ />
>
)}
diff --git a/pages/schedule/index.tsx b/pages/schedule/index.tsx
index 436f09b..3026464 100644
--- a/pages/schedule/index.tsx
+++ b/pages/schedule/index.tsx
@@ -14,6 +14,7 @@ import ScheduleGrid from '@/components/Shared/ScheduleGrid'
import { useToast } from '@/contexts/Toast'
import { useEventList } from '@/hooks/Event/useEventList'
import Error500Page from '@/pages/500'
+import theme from '@/styles/theme'
import { ParsedEventData } from '@/types/Event'
type Props = {
@@ -44,6 +45,11 @@ const Schedule = (props: Props) => {
return 0
})
+ const handleSetTabIndex = (index: number) => {
+ window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
+ setTabIndex(index)
+ }
+
const oneWeekFromNow = new Date(now.getTime() + 6 * 24 * 60 * 60 * 1000)
const transitionDuration = {
@@ -53,11 +59,21 @@ const Schedule = (props: Props) => {
return (
<>
-
+
setTabIndex(newIndex)}
+ onChange={(_, newIndex) => handleSetTabIndex(newIndex)}
sx={{ borderBottom: 1, borderColor: 'divider' }}
>
{days.map((day) => {
diff --git a/types/QRCode/index.ts b/types/QRCode/index.ts
index 9f09a93..b034598 100644
--- a/types/QRCode/index.ts
+++ b/types/QRCode/index.ts
@@ -1,3 +1,7 @@
+export type QRUserGetParams = {
+ qrId: string
+}
+
export type QRCheckInReq = {
qrId: string
context: QRCheckInContext