diff --git a/src/app/sjekk/page.tsx b/src/app/sjekk/page.tsx new file mode 100644 index 0000000..8201119 --- /dev/null +++ b/src/app/sjekk/page.tsx @@ -0,0 +1,26 @@ +import { Card, Container, Stack, Typography } from "@mui/material"; +import { Box } from "@mui/system"; +import { Metadata } from "next"; +import React from "react"; + +import PublicBlidSearch from "@/components/search/PublicBlidSearch"; + +export const metadata: Metadata = { + title: "Boksøk", + description: "Sjekk hvem bøker utdelt fra Boklisten tilhører", +}; + +export default function PublicBlidSearchPage() { + return ( + + + + Boksøk + + + + + + + ); +} diff --git a/src/components/RapidHandoutDetails.tsx b/src/components/RapidHandoutDetails.tsx index 6fc0403..d57ef5b 100644 --- a/src/components/RapidHandoutDetails.tsx +++ b/src/components/RapidHandoutDetails.tsx @@ -7,7 +7,8 @@ import useSWR from "swr"; import BlFetcher from "@/api/blFetcher"; import { ItemStatus } from "@/components/matches/matches-helper"; import MatchItemTable from "@/components/matches/MatchItemTable"; -import ScannerModal from "@/components/matches/Scanner/ScannerModal"; +import MatchScannerContent from "@/components/matches/MatchScannerContent"; +import ScannerModal from "@/components/scanner/ScannerModal"; import BL_CONFIG from "@/utils/bl-config"; function calculateUnfulfilledOrderItems(orders: Order[]): OrderItem[] { @@ -101,12 +102,19 @@ export default function RapidHandoutDetails({ handleClose={() => { setScanModalOpen(false); }} - itemStatuses={itemStatuses} - expectedItems={itemStatuses.map((itemStatus) => itemStatus.id)} - fulfilledItems={itemStatuses - .filter((itemStatus) => itemStatus.fulfilled) - .map((itemStatus) => itemStatus.id)} - /> + > + { + setScanModalOpen(false); + }} + itemStatuses={itemStatuses} + expectedItems={itemStatuses.map((itemStatus) => itemStatus.id)} + fulfilledItems={itemStatuses + .filter((itemStatus) => itemStatus.fulfilled) + .map((itemStatus) => itemStatus.id)} + /> + ); } diff --git a/src/components/matches/MatchScannerContent.tsx b/src/components/matches/MatchScannerContent.tsx new file mode 100644 index 0000000..a4be9f4 --- /dev/null +++ b/src/components/matches/MatchScannerContent.tsx @@ -0,0 +1,50 @@ +import { Box } from "@mui/material"; +import Typography from "@mui/material/Typography"; +import React, { useEffect } from "react"; + +import { ItemStatus } from "@/components/matches/matches-helper"; +import ProgressBar from "@/components/matches/matchesList/ProgressBar"; +import MatchItemTable from "@/components/matches/MatchItemTable"; + +export default function MatchScannerContent({ + expectedItems, + fulfilledItems, + itemStatuses, + scannerOpen, + handleClose, +}: { + itemStatuses: ItemStatus[]; + expectedItems: string[]; + fulfilledItems: string[]; + scannerOpen: boolean; + handleClose: () => void; +}) { + useEffect(() => { + if (scannerOpen && expectedItems.length === fulfilledItems.length) { + handleClose(); + } + }, [expectedItems.length, fulfilledItems.length, handleClose, scannerOpen]); + return ( + <> + + + {fulfilledItems.length} av {expectedItems.length} bøker mottatt + + } + /> + + + + + + ); +} diff --git a/src/components/matches/UserMatchDetail.tsx b/src/components/matches/UserMatchDetail.tsx index 5065648..3943e19 100644 --- a/src/components/matches/UserMatchDetail.tsx +++ b/src/components/matches/UserMatchDetail.tsx @@ -13,10 +13,11 @@ import { import { UserMatchTitle } from "@/components/matches/matchesList/helper"; import ProgressBar from "@/components/matches/matchesList/ProgressBar"; import MatchItemTable from "@/components/matches/MatchItemTable"; +import MatchScannerContent from "@/components/matches/MatchScannerContent"; import MeetingInfo from "@/components/matches/MeetingInfo"; import OtherPersonContact from "@/components/matches/OtherPersonContact"; -import ScannerModal from "@/components/matches/Scanner/ScannerModal"; -import ScannerTutorial from "@/components/matches/Scanner/ScannerTutorial"; +import ScannerModal from "@/components/scanner/ScannerModal"; +import ScannerTutorial from "@/components/scanner/ScannerTutorial"; import BL_CONFIG from "@/utils/bl-config"; import { UserMatchWithDetails } from "@/utils/types"; @@ -160,10 +161,18 @@ const UserMatchDetail = ({ setScanModalOpen(false); setRedirectCountdownStarted(isFulfilled); }} - itemStatuses={itemStatuses} - expectedItems={match.expectedItems} - fulfilledItems={fulfilledItems} - /> + > + { + setScanModalOpen(false); + setRedirectCountdownStarted(isFulfilled); + }} + scannerOpen={scanModalOpen} + itemStatuses={itemStatuses} + expectedItems={match.expectedItems} + fulfilledItems={fulfilledItems} + /> + ); }; diff --git a/src/components/scanner/BlidScanner.tsx b/src/components/scanner/BlidScanner.tsx new file mode 100644 index 0000000..ce43bdb --- /dev/null +++ b/src/components/scanner/BlidScanner.tsx @@ -0,0 +1,53 @@ +import { IDetectedBarcode, Scanner } from "@yudiel/react-qr-scanner"; + +import { ScannedTextType, TextType } from "@/utils/types"; + +export function determineScannedTextType(scannedText: string): ScannedTextType { + if (/^[\dA-Za-z]{12}$|^\d{8}$/.test(scannedText)) { + return TextType.BLID; + } else if (/^\d{13}$/.test(scannedText)) { + return TextType.ISBN; + } + + return TextType.UNKNOWN; +} + +export default function BlidScanner({ + onResult, +}: { + onResult: (scannedText: string) => Promise; +}) { + const handleCodeDetection = async ( + detectedCodes: IDetectedBarcode[], + ): Promise => { + const didFindBlid = detectedCodes.some( + (code) => determineScannedTextType(code.rawValue) === TextType.BLID, + ); + const codesToProcess = didFindBlid + ? detectedCodes.filter( + (code) => determineScannedTextType(code.rawValue) === TextType.BLID, + ) + : detectedCodes; + + for (const code of codesToProcess) { + try { + await onResult(code.rawValue); + } catch (error) { + console.error("Failed to handle scan", error); + } + // Arbitrary delay to somewhat avoid races the backend isn't smart enough to handle + await new Promise((resolve) => { + window.setTimeout(resolve, 250); + }); + } + }; + + return ( + + ); +} diff --git a/src/components/matches/Scanner/ManualRegistrationModal.tsx b/src/components/scanner/ManualBlidSearchModal.tsx similarity index 95% rename from src/components/matches/Scanner/ManualRegistrationModal.tsx rename to src/components/scanner/ManualBlidSearchModal.tsx index 7c7cc7a..c741d6e 100644 --- a/src/components/matches/Scanner/ManualRegistrationModal.tsx +++ b/src/components/scanner/ManualBlidSearchModal.tsx @@ -12,7 +12,7 @@ import { } from "@mui/material"; import React, { useState } from "react"; -const ManualRegistrationModal = ({ +const ManualBlidSearchModal = ({ open, handleClose, handleSubmit, @@ -69,4 +69,4 @@ const ManualRegistrationModal = ({ ); }; -export default ManualRegistrationModal; +export default ManualBlidSearchModal; diff --git a/src/components/matches/Scanner/ScannerFeedback.tsx b/src/components/scanner/ScannerFeedback.tsx similarity index 100% rename from src/components/matches/Scanner/ScannerFeedback.tsx rename to src/components/scanner/ScannerFeedback.tsx diff --git a/src/components/matches/Scanner/ScannerModal.tsx b/src/components/scanner/ScannerModal.tsx similarity index 51% rename from src/components/matches/Scanner/ScannerModal.tsx rename to src/components/scanner/ScannerModal.tsx index 0a4500f..77c9b67 100644 --- a/src/components/matches/Scanner/ScannerModal.tsx +++ b/src/components/scanner/ScannerModal.tsx @@ -1,25 +1,13 @@ import { Close, InputRounded } from "@mui/icons-material"; import { AlertColor, Box, Button, Card, Modal, Stack } from "@mui/material"; -import Typography from "@mui/material/Typography"; -import { Scanner, IDetectedBarcode } from "@yudiel/react-qr-scanner"; -import React, { useEffect, useState } from "react"; +import React, { ReactNode, useState } from "react"; -import { ItemStatus } from "@/components/matches/matches-helper"; -import ProgressBar from "@/components/matches/matchesList/ProgressBar"; -import MatchItemTable from "@/components/matches/MatchItemTable"; -import ManualRegistrationModal from "@/components/matches/Scanner/ManualRegistrationModal"; -import ScannerFeedback from "@/components/matches/Scanner/ScannerFeedback"; -import { assertBlApiError, ScannedTextType, TextType } from "@/utils/types"; - -function determineScannedTextType(scannedText: string): ScannedTextType { - if (/^[\dA-Za-z]{12}$|^\d{8}$/.test(scannedText)) { - return TextType.BLID; - } else if (/^\d{13}$/.test(scannedText)) { - return TextType.ISBN; - } - - return TextType.UNKNOWN; -} +import BlidScanner, { + determineScannedTextType, +} from "@/components/scanner/BlidScanner"; +import ManualBlidSearchModal from "@/components/scanner/ManualBlidSearchModal"; +import ScannerFeedback from "@/components/scanner/ScannerFeedback"; +import { assertBlApiError, TextType } from "@/utils/types"; type Feedback = { text: string; @@ -32,17 +20,15 @@ const ScannerModal = ({ open, handleClose, handleSuccessfulScan, - itemStatuses, - expectedItems, - fulfilledItems, + allowManualRegistration, + children, }: { onScan: (blid: string) => Promise<[{ feedback: string }]>; open: boolean; handleClose: () => void; handleSuccessfulScan?: (() => void) | undefined; - itemStatuses: ItemStatus[]; - expectedItems: string[]; - fulfilledItems: string[]; + allowManualRegistration?: boolean; + children?: ReactNode; }) => { const [manualRegistrationModalOpen, setManualRegistrationModalOpen] = useState(false); @@ -96,46 +82,6 @@ const ScannerModal = ({ } }; - useEffect(() => { - if ( - open && - expectedItems.length === fulfilledItems.length && - !(feedback.visible && feedback.severity === "info") - ) { - handleClose(); - } - }, [ - expectedItems.length, - fulfilledItems.length, - handleClose, - open, - feedback.visible, - feedback.severity, - ]); - - const handleCodeDetection = async ( - detectedCodes: IDetectedBarcode[], - ): Promise => { - const didFindBlid = detectedCodes.some( - (code) => determineScannedTextType(code.rawValue) === TextType.BLID, - ); - const codesToProcess = didFindBlid - ? detectedCodes.filter( - (code) => determineScannedTextType(code.rawValue) === TextType.BLID, - ) - : detectedCodes; - - for (const code of codesToProcess) { - await handleRegistration(code.rawValue).catch((error) => - console.error("Failed to handle scan", error), - ); - // Arbitrary delay to somewhat avoid races the backend isn't smart enough to handle - await new Promise((resolve) => { - window.setTimeout(resolve, 250); - }); - } - }; - return ( - - - - - {fulfilledItems.length} av {expectedItems.length} bøker mottatt - - } - /> - - - + + {children} - + {allowManualRegistration && ( + + )} - { setManualRegistrationModalOpen(false); diff --git a/src/components/matches/Scanner/ScannerTutorial.tsx b/src/components/scanner/ScannerTutorial.tsx similarity index 100% rename from src/components/matches/Scanner/ScannerTutorial.tsx rename to src/components/scanner/ScannerTutorial.tsx diff --git a/src/components/search/PublicBlidSearch.tsx b/src/components/search/PublicBlidSearch.tsx new file mode 100644 index 0000000..ca3afff --- /dev/null +++ b/src/components/search/PublicBlidSearch.tsx @@ -0,0 +1,228 @@ +"use client"; +import { Phone } from "@mui/icons-material"; +import EmailIcon from "@mui/icons-material/Email"; +import QrCodeScannerIcon from "@mui/icons-material/QrCodeScanner"; +import { + Alert, + Table, + TableBody, + TableCell, + TableRow, + Typography, +} from "@mui/material"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import { Box } from "@mui/system"; +import moment from "moment"; +import { useState } from "react"; + +import { isLoggedIn } from "@/api/auth"; +import BlFetcher from "@/api/blFetcher"; +import DynamicLink from "@/components/DynamicLink"; +import ScannerModal from "@/components/scanner/ScannerModal"; +import BL_CONFIG from "@/utils/bl-config"; +import useIsHydrated from "@/utils/useIsHydrated"; + +interface PublicBlidLookupResult { + handoutBranch: string; + handoutTime: string; + deadline: string; + title: string; + isbn: number; + name: string; + email: string; + phone: string; +} + +export default function PublicBlidSearch() { + const hydrated = useIsHydrated(); + const [scannerModalOpen, setScannerModalOpen] = useState(false); + const [blidSearch, setBlidSearch] = useState(""); + const [searchResult, setSearchResult] = useState< + PublicBlidLookupResult | "inactive" | null + >(null); + + async function onBlidSearch(blid: string) { + setSearchResult(null); + setScannerModalOpen(false); + setBlidSearch(blid); + if (blid.length !== 8 && blid.length !== 12) { + // Only lookup for valid blids + return; + } + try { + const result = await BlFetcher.post<[PublicBlidLookupResult] | []>( + BL_CONFIG.collection.customerItem + "/public-blid-lookup", + { + blid, + }, + ); + if (result.length === 1) { + setSearchResult(result[0]); + } else { + setSearchResult("inactive"); + } + } catch (error) { + console.error(error); + setSearchResult("inactive"); + } + } + + return ( + hydrated && + (isLoggedIn() ? ( + + + Skriv inn en bok sin unike ID (8 eller 12 siffer) for å se hvem den + tilhører. Du kan også trykke på{" "} + for å + scanne bokas unike ID med kamera. + + onBlidSearch(event.target.value)} + InputProps={{ + endAdornment: ( + + ), + }} + /> + + {searchResult === "inactive" && + "Denne boken er ikke registrert som utdelt."} + {((searchResult === null && + blidSearch.length !== 8 && + blidSearch.length !== 12) || + blidSearch.length === 0) && + "Venter på unik ID"} + + + {searchResult !== "inactive" && searchResult !== null && ( + <> + + Denne boken tilhører + + + + {searchResult.name} + + + + + + {searchResult.phone} + + + + {searchResult.email} + + + + + + + + Tittel + + {searchResult.title} + + + + ISBN + + {searchResult.isbn} + + + + Utdelt hos + + {searchResult.handoutBranch} + + + + Utdelt den + + + {moment(searchResult.handoutTime).format("DD/MM/YYYY")} + + + + + Frist + + + {moment(searchResult.deadline).format("DD/MM/YYYY")} + + + +
+
+ + )} + + { + onBlidSearch(blid); + return [{ feedback: "" }] as [{ feedback: string }]; + }} + open={scannerModalOpen} + handleClose={() => setScannerModalOpen(false)} + /> +
+ ) : ( + <> + Du må logge inn for å kunne bruke boksøk + + + + + )) + ); +} diff --git a/src/utils/types.ts b/src/utils/types.ts index 21bc256..2b25c1c 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -76,11 +76,8 @@ export interface GroupedMatches { } export enum TextType { - // eslint-disable-next-line no-unused-vars BLID, - // eslint-disable-next-line no-unused-vars ISBN, - // eslint-disable-next-line no-unused-vars UNKNOWN, }