Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required for mobile detection for QR scanning

"react-dom": "^18.2.0",
"react-hook-form": "^7.54.2",
"react-icons": "^4.12.0",
Expand Down
7 changes: 7 additions & 0 deletions src/components/QRCodeIcon.tsx
Original file line number Diff line number Diff line change
@@ -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',
})
79 changes: 79 additions & 0 deletions src/components/QrCodeReader.tsx
Original file line number Diff line number Diff line change
@@ -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<QrCodeReaderProps> = ({
fps,
qrbox,
qrCodeSuccessCallback,
qrCodeErrorCallback,
}) => {
const [cameraId, setCameraId] = useState<string | null>(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 <div id={qrcodeRegionId} />
}
100 changes: 100 additions & 0 deletions src/components/QrCodeScanner.tsx
Original file line number Diff line number Diff line change
@@ -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<DOMException['message'] | null>(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 (
<Modal isOpen={isOpen} onClose={onClose} size='md'>
<ModalOverlay />
<ModalContent>
<ModalHeader>
Scan QR Code
<ModalCloseButton />
</ModalHeader>
<ModalBody pb={6}>
{scanError ? (
<Flex justifyContent='center' alignItems='center' flexDirection='column' pb={4}>
<Alert status='error' borderRadius='xl'>
<AlertIcon />
{isPermissionError(scanError)
? 'Camera permission denied. Please allow camera access to scan QR codes.'
: 'An error occurred while trying to scan the QR code.'}
</Alert>
{isPermissionError(scanError) && (
<Button colorScheme='blue' mt='5' onClick={handlePermissionsButtonClick}>
Try Again
</Button>
)}
</Flex>
) : (
<Box style={boxStyle}>
<QrCodeReader
qrbox={qrBoxStyle}
fps={10}
qrCodeSuccessCallback={handleScanSuccess}
qrCodeErrorCallback={handleScanError}
/>
</Box>
)}
</ModalBody>
</ModalContent>
</Modal>
)
}
92 changes: 78 additions & 14 deletions src/components/TradeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
HStack,
IconButton,
Input,
InputGroup,
InputRightElement,
Link,
Skeleton,
StackDivider,
Expand Down Expand Up @@ -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 = <StackDivider borderColor='border.base' />
const qrCodeIcon = <QRCodeIcon />

const skeletonInputSx = {
bg: 'background.surface.raised.base',
Expand Down Expand Up @@ -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'])
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -654,13 +691,24 @@ export const TradeInput = () => {
<Text fontSize='sm' color='text.subtle'>
Destination Address
</Text>
<Input
{...register('destinationAddress', destinationAddressRules)}
placeholder={`Enter ${buyAsset.symbol || ''} address`}
isInvalid={!!errors.destinationAddress}
required
title='Please enter a valid destination address'
/>
<InputGroup>
<Input
{...register('destinationAddress', destinationAddressRules)}
placeholder={`Enter ${buyAsset.symbol || ''} address`}
isInvalid={!!errors.destinationAddress}
required
title='Please enter a valid destination address'
/>
<InputRightElement>
<IconButton
aria-label='Scan QR Code'
icon={qrCodeIcon}
onClick={handleDestinationAddressQrClick}
size='sm'
variant='ghost'
/>
</InputRightElement>
</InputGroup>
{errors.destinationAddress && (
<Text fontSize='sm' color='red.500'>
{errors.destinationAddress.message}
Expand All @@ -671,13 +719,24 @@ export const TradeInput = () => {
<Text fontSize='sm' color='text.subtle'>
Refund Address
</Text>
<Input
{...register('refundAddress', refundAddressRules)}
placeholder={`Enter ${sellAsset.symbol || ''} address`}
isInvalid={!!errors.refundAddress}
required
title='Please enter a valid refund address'
/>
<InputGroup>
<Input
{...register('refundAddress', refundAddressRules)}
placeholder={`Enter ${sellAsset.symbol || ''} address`}
isInvalid={!!errors.refundAddress}
required
title='Please enter a valid refund address'
/>
<InputRightElement>
<IconButton
aria-label='Scan QR Code'
icon={qrCodeIcon}
onClick={handleRefundAddressQrClick}
size='sm'
variant='ghost'
/>
</InputRightElement>
</InputGroup>
{errors.refundAddress && (
<Text fontSize='sm' color='red.500'>
{errors.refundAddress.message}
Expand All @@ -700,6 +759,11 @@ export const TradeInput = () => {
</CardFooter>
</Card>
<AssetSelectModal isOpen={isOpen} onClose={onClose} onClick={handleAssetSelect} />
<QrCodeScanner
isOpen={isQrScannerOpen}
onClose={handleQrScannerClose}
onSuccess={handleQrScannerSuccess}
/>
</>
)
}
Loading