diff --git a/package.json b/package.json index 0659923..6a27870 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,14 @@ "bignumber.js": "^9.1.2", "dayjs": "^1.11.13", "framer-motion": "^12.3.0", + "html5-qrcode": "^2.3.8", "lodash": "^4.17.21", "match-sorter": "^6.3.1", "mixpanel-browser": "^2.48.1", "multicoin-address-validator": "^0.5.24", "qrcode": "^1.5.4", "react": "^18.2.0", + "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-hook-form": "^7.54.2", "react-icons": "^4.12.0", diff --git a/src/components/QRCodeIcon.tsx b/src/components/QRCodeIcon.tsx new file mode 100644 index 0000000..10eba08 --- /dev/null +++ b/src/components/QRCodeIcon.tsx @@ -0,0 +1,7 @@ +import { createIcon } from '@chakra-ui/icons' + +export const QRCodeIcon = createIcon({ + displayName: 'QRCodeIcon', + viewBox: '0 0 24 24', + d: 'M3 11V3H11V11H3ZM5 9H9V5H5V9ZM3 21V13H11V21H3ZM5 19H9V15H5V19ZM13 11V3H21V11H13ZM15 9H19V5H15V9ZM21 21H19V19H21V21ZM13 17V13H15V17H13ZM17 17V13H21V15H19V17H17ZM17 21V19H19V21H17ZM13 21V19H15V21H13Z', +}) diff --git a/src/components/QrCodeReader.tsx b/src/components/QrCodeReader.tsx new file mode 100644 index 0000000..60b2d1a --- /dev/null +++ b/src/components/QrCodeReader.tsx @@ -0,0 +1,79 @@ +import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode' +import type { + QrcodeErrorCallback, + QrcodeSuccessCallback, + QrDimensions, +} from 'html5-qrcode/esm/core' +import React, { useEffect, useState } from 'react' +import { isMobile } from 'react-device-detect' + +export type DOMExceptionCallback = (errorMessage: string) => void + +const qrcodeRegionId = 'reader' + +type QrCodeReaderProps = { + fps: number + qrbox: number | QrDimensions + qrCodeSuccessCallback: QrcodeSuccessCallback + qrCodeErrorCallback: QrcodeErrorCallback | DOMExceptionCallback +} + +export const QrCodeReader: React.FC = ({ + fps, + qrbox, + qrCodeSuccessCallback, + qrCodeErrorCallback, +}) => { + const [cameraId, setCameraId] = useState(null) + + useEffect(() => { + const qrScanner = new Html5Qrcode(qrcodeRegionId, { + formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE], + verbose: false, + }) + + ;(async () => { + if (!cameraId) { + try { + const devices = await Html5Qrcode.getCameras() + + if (devices?.length) { + setCameraId(devices[0].id) + } + } catch (e) { + // Errors on getCameras() are caused by browser native APIs exception e.g not supported, permission not granted etc + const error = e as DOMException['message'] + ;(qrCodeErrorCallback as DOMExceptionCallback)(error) + } + + return + } + + await qrScanner.start( + isMobile ? { facingMode: 'environment' } : cameraId, + { + fps, + qrbox, + }, + (decodedText, result) => { + qrCodeSuccessCallback(decodedText, result) + }, + qrCodeErrorCallback as QrcodeErrorCallback, + ) + })() + + return () => { + ;(async () => { + const scannerState = qrScanner.getState() + + if (scannerState === Html5QrcodeScannerState.SCANNING) { + await qrScanner.stop() + qrScanner.clear() + } + })() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cameraId]) + + return
+} diff --git a/src/components/QrCodeScanner.tsx b/src/components/QrCodeScanner.tsx new file mode 100644 index 0000000..9c54f92 --- /dev/null +++ b/src/components/QrCodeScanner.tsx @@ -0,0 +1,100 @@ +import { + Alert, + AlertIcon, + Box, + Button, + Flex, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, +} from '@chakra-ui/react' +import type { Html5QrcodeError, Html5QrcodeResult } from 'html5-qrcode/esm/core' +import { Html5QrcodeErrorTypes } from 'html5-qrcode/esm/core' +import { useCallback, useState } from 'react' + +import { QrCodeReader } from './QrCodeReader' + +const PERMISSION_ERROR = 'NotAllowedError : Permission denied' +const isPermissionError = ( + error: DOMException['message'] | Html5QrcodeError, +): error is DOMException['message'] => + typeof (error as DOMException['message']) === 'string' && error === PERMISSION_ERROR + +const boxStyle = { + width: '100%', + minHeight: '298px', + overflow: 'hidden', + borderRadius: '1rem', +} +const qrBoxStyle = { width: 250, height: 250 } + +export type QrCodeScannerProps = { + isOpen: boolean + onClose: () => void + onSuccess: (address: string) => void +} + +export const QrCodeScanner = ({ isOpen, onClose, onSuccess }: QrCodeScannerProps) => { + const [scanError, setScanError] = useState(null) + + const handleScanSuccess = useCallback( + (decodedText: string, _result: Html5QrcodeResult) => { + onSuccess(decodedText) + onClose() + }, + [onSuccess, onClose], + ) + + const handleScanError = useCallback((_errorMessage: string, error?: Html5QrcodeError) => { + if (error?.type === Html5QrcodeErrorTypes.UNKWOWN_ERROR) { + // https://github.com/mebjas/html5-qrcode/issues/320 + // 'NotFoundException: No MultiFormat Readers were able to detect the code' errors are thrown on every frame until a valid QR is detected, don't handle these + return + } + + setScanError(_errorMessage) + }, []) + + const handlePermissionsButtonClick = useCallback(() => setScanError(null), []) + + return ( + + + + + Scan QR Code + + + + {scanError ? ( + + + + {isPermissionError(scanError) + ? 'Camera permission denied. Please allow camera access to scan QR codes.' + : 'An error occurred while trying to scan the QR code.'} + + {isPermissionError(scanError) && ( + + )} + + ) : ( + + + + )} + + + + ) +} diff --git a/src/components/TradeInput.tsx b/src/components/TradeInput.tsx index 2254dd6..5c03af5 100644 --- a/src/components/TradeInput.tsx +++ b/src/components/TradeInput.tsx @@ -9,6 +9,8 @@ import { HStack, IconButton, Input, + InputGroup, + InputRightElement, Link, Skeleton, StackDivider, @@ -41,10 +43,13 @@ import { AssetIcon } from './AssetIcon' import { AssetSelectModal } from './AssetSelectModal/AssetSelectModal' import { AssetType } from './AssetSelectModal/types' import { CountdownSpinner } from './CountdownSpinner/CountdownSpinner' +import { QRCodeIcon } from './QRCodeIcon' +import { QrCodeScanner } from './QrCodeScanner' const QUOTE_REFETCH_INTERVAL = 15_000 const divider = +const qrCodeIcon = const skeletonInputSx = { bg: 'background.surface.raised.base', @@ -77,6 +82,10 @@ export const TradeInput = () => { const [sellAmountFiatInput, setSellAmountFiatInput] = useState('') const debouncedSellAmount = useDebounce(sellAmountInput, 500) const debouncedSellAmountFiat = useDebounce(sellAmountFiatInput, 500) + const [isQrScannerOpen, setIsQrScannerOpen] = useState(false) + const [activeAddressField, setActiveAddressField] = useState< + 'destinationAddress' | 'refundAddress' | null + >(null) useEffect(() => { trigger(['destinationAddress', 'refundAddress']) @@ -446,6 +455,34 @@ export const TradeInput = () => { return buyAmountCryptoPrecision }, [buyAmountCryptoPrecision, debouncedSellAmount, debouncedSellAmountFiat, isFiat, quoteError]) + const handleQrButtonClick = useCallback((field: 'destinationAddress' | 'refundAddress') => { + setActiveAddressField(field) + setIsQrScannerOpen(true) + }, []) + + const handleDestinationAddressQrClick = useCallback( + () => handleQrButtonClick('destinationAddress'), + [handleQrButtonClick], + ) + const handleRefundAddressQrClick = useCallback( + () => handleQrButtonClick('refundAddress'), + [handleQrButtonClick], + ) + + const handleQrScannerClose = useCallback(() => { + setIsQrScannerOpen(false) + setActiveAddressField(null) + }, []) + + const handleQrScannerSuccess = useCallback( + (address: string) => { + if (activeAddressField) { + setValue(activeAddressField, address, { shouldValidate: true }) + } + }, + [activeAddressField, setValue], + ) + if (!(sellAsset && buyAsset)) return null return ( @@ -654,13 +691,24 @@ export const TradeInput = () => { Destination Address - + + + + + + {errors.destinationAddress && ( {errors.destinationAddress.message} @@ -671,13 +719,24 @@ export const TradeInput = () => { Refund Address - + + + + + + {errors.refundAddress && ( {errors.refundAddress.message} @@ -700,6 +759,11 @@ export const TradeInput = () => { + ) } diff --git a/yarn.lock b/yarn.lock index 82bf53b..8306122 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6021,6 +6021,13 @@ __metadata: languageName: node linkType: hard +"html5-qrcode@npm:^2.3.8": + version: 2.3.8 + resolution: "html5-qrcode@npm:2.3.8" + checksum: 10c0/3d7d0b3687e41a6fc0a06345f67e89ad3c7c00a3d0d8846d6fd31985e1ed2ac1c310e625f0b650dbc689f6b83469e3378417e7431ae5a9194178f1172bf6a93a + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -7676,6 +7683,18 @@ __metadata: languageName: node linkType: hard +"react-device-detect@npm:^2.2.3": + version: 2.2.3 + resolution: "react-device-detect@npm:2.2.3" + dependencies: + ua-parser-js: "npm:^1.0.33" + peerDependencies: + react: ">= 0.14.0" + react-dom: ">= 0.14.0" + checksum: 10c0/396bbeeab0cb21da084c67434d204c9cf502fad6c683903313084d3f6487950a36a34f9bf67ccf5c1772a1bb5b79a2a4403fcfe6b51d93877db4c2d9f3a3a925 + languageName: node + linkType: hard + "react-dom@npm:^18.2.0": version: 18.2.0 resolution: "react-dom@npm:18.2.0" @@ -8333,6 +8352,7 @@ __metadata: eslint-plugin-simple-import-sort: "npm:^10.0.0" framer-motion: "npm:^12.3.0" happy-dom: "npm:^12.10.3" + html5-qrcode: "npm:^2.3.8" inquirer: "npm:^9.2.12" lodash: "npm:^4.17.21" match-sorter: "npm:^6.3.1" @@ -8342,6 +8362,7 @@ __metadata: prettier: "npm:^3.1.1" qrcode: "npm:^1.5.4" react: "npm:^18.2.0" + react-device-detect: "npm:^2.2.3" react-dom: "npm:^18.2.0" react-hook-form: "npm:^7.54.2" react-icons: "npm:^4.12.0" @@ -8972,6 +8993,15 @@ __metadata: languageName: node linkType: hard +"ua-parser-js@npm:^1.0.33": + version: 1.0.40 + resolution: "ua-parser-js@npm:1.0.40" + bin: + ua-parser-js: script/cli.js + checksum: 10c0/2b6ac642c74323957dae142c31f72287f2420c12dced9603d989b96c132b80232779c429b296d7de4012ef8b64e0d8fadc53c639ef06633ce13d785a78b5be6c + languageName: node + linkType: hard + "ufo@npm:^1.3.0": version: 1.3.2 resolution: "ufo@npm:1.3.2"