Skip to content

Commit

Permalink
Merge pull request #27 from shapeshift/asset-selection-modal
Browse files Browse the repository at this point in the history
feat: add asset select modal
  • Loading branch information
reallybeard authored Jan 10, 2024
2 parents 006787d + 8ef712e commit 00a80a8
Show file tree
Hide file tree
Showing 16 changed files with 121,263 additions and 31 deletions.
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,18 @@
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@shapeshiftoss/caip": "^8.15.0",
"@shapeshiftoss/types": "^8.6.0",
"axios": "^1.6.5",
"framer-motion": "^10.17.9",
"match-sorter": "^6.3.1",
"mixpanel-browser": "^2.48.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-router-dom": "^6.21.1"
"react-router-dom": "^6.21.1",
"react-virtuoso": "^4.6.2",
"viem": "^2.0.3"
},
"devDependencies": {
"@types/inquirer": "^9.0.7",
Expand Down
34 changes: 34 additions & 0 deletions src/components/AssetSelectModal/AssetList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ListProps } from '@chakra-ui/react'
import type { FC } from 'react'
import { useCallback } from 'react'
import { Virtuoso } from 'react-virtuoso'
import type { Asset } from 'types/Asset'

import { AssetRow } from './AssetRow'

const style = { height: '400px' }

export type AssetData = {
assets: Asset[]
handleClick: (asset: Asset) => void
}

type AssetListProps = AssetData & ListProps

export const AssetList: FC<AssetListProps> = ({ assets, handleClick }) => {
const renderItemContent = useCallback(
(index: number, asset: Asset) => {
return <AssetRow {...asset} key={index} onClick={handleClick} />
},
[handleClick],
)

return (
<Virtuoso
style={style}
data={assets}
totalCount={assets.length}
itemContent={renderItemContent}
/>
)
}
69 changes: 69 additions & 0 deletions src/components/AssetSelectModal/AssetRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Avatar, Box, Button, Flex, SkeletonCircle, Text } from '@chakra-ui/react'
import type { FC } from 'react'
import { memo, useCallback, useState } from 'react'
import { middleEllipsis } from 'lib/utils'
import type { Asset } from 'types/Asset'

const focus = {
shadow: 'outline-inset',
}

type AssetRowProps = {
onClick: (asset: Asset) => void
} & Asset

export const AssetRow: FC<AssetRowProps> = memo(({ onClick, ...asset }) => {
const [imgLoaded, setImgLoaded] = useState(false)
const { name, icon, symbol, id } = asset
const handleClick = useCallback(() => {
onClick(asset)
}, [asset, onClick])

const handleImageLoad = useCallback(() => {
setImgLoaded(true)
}, [])

if (!asset) return null

return (
<Button
width='full'
variant='ghost'
onClick={handleClick}
justifyContent='space-between'
_focus={focus}
height='auto'
py={4}
>
<Flex gap={4} alignItems='center'>
<SkeletonCircle isLoaded={imgLoaded}>
<Avatar src={icon} size='sm' onLoad={handleImageLoad} />
</SkeletonCircle>
<Box textAlign='left'>
<Text
lineHeight={1}
textOverflow='ellipsis'
whiteSpace='nowrap'
maxWidth='200px'
overflow='hidden'
color='text.base'
fontSize='sm'
mb={1}
>
{name}
</Text>
<Flex alignItems='center' gap={2}>
<Text fontWeight='normal' fontSize='xs' color='text.subtle'>
{symbol}
</Text>
{id && (
<Text fontWeight='normal' fontSize='xs' color='text.subtle'>
{middleEllipsis(id)}
</Text>
)}
</Flex>
</Box>
</Flex>
</Button>
)
})
159 changes: 159 additions & 0 deletions src/components/AssetSelectModal/AssetSelectModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {
Button,
CloseButton,
Flex,
Input,
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalOverlay,
Text,
} from '@chakra-ui/react'
import type { ChainId } from '@shapeshiftoss/caip'
import type { ChangeEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import AssetData from 'lib/generatedAssetData.json'
import { isNft } from 'lib/utils'
import type { Asset } from 'types/Asset'

import { AssetList } from './AssetList'
import type { ChainRow } from './ChainButton'
import { ChainButton } from './ChainButton'
import { filterAssetsBySearchTerm } from './helpers/filterAssetsBySearchTerm'

type AssetSelectModalProps = {
isOpen: boolean
onClose: () => void
onClick: (asset: Asset) => void
}

export const AssetSelectModal: React.FC<AssetSelectModalProps> = ({ isOpen, onClose, onClick }) => {
const [searchQuery, setSearchQuery] = useState('')
const assets = useMemo(() => Object.values(AssetData), [])
const [activeChain, setActiveChain] = useState<ChainId | 'All'>('All')
const [searchTermAssets, setSearchTermAssets] = useState<Asset[]>([])
const iniitalRef = useRef(null)

const filteredAssets = useMemo(
() =>
activeChain === 'All'
? assets.filter(a => !isNft(a.assetId))
: assets.filter(a => a.chainId === activeChain && !isNft(a.assetId)),
[activeChain, assets],
)

const searching = useMemo(() => searchQuery.length > 0, [searchQuery])

const handleSearchQuery = useCallback((value: ChangeEvent<HTMLInputElement>) => {
setSearchQuery(value.target.value)
}, [])

const handleChainClick = useCallback((chainId: ChainId | 'All') => {
setActiveChain(chainId)
}, [])

const handleAllClick = useCallback(() => {
setActiveChain('All')
}, [])

const handleClose = useCallback(() => {
// Reset state on close
setActiveChain('All')
setSearchQuery('')
onClose()
}, [onClose])

const handleClick = useCallback(
(asset: Asset) => {
onClick(asset)
handleClose()
},
[handleClose, onClick],
)

useEffect(() => {
if (filteredAssets && searching) {
setSearchTermAssets(
searching ? filterAssetsBySearchTerm(searchQuery, filteredAssets) : filteredAssets,
)
}
}, [searchQuery, searching, filteredAssets])

const listAssets = searching ? searchTermAssets : filteredAssets

const uniqueChainIds: ChainRow[] = assets.reduce((accumulator, currentAsset: Asset) => {
const existingEntry = accumulator.find(
(entry: ChainRow) => entry.chainId === currentAsset.chainId,
)

if (!existingEntry) {
accumulator.push({
chainId: currentAsset.chainId,
icon: currentAsset.networkIcon ?? currentAsset.icon,
name: currentAsset.networkName ?? currentAsset.name,
})
}

return accumulator
}, [])

const renderRows = useMemo(() => {
return <AssetList assets={listAssets} handleClick={handleClick} />
}, [handleClick, listAssets])

const renderChains = useMemo(() => {
return uniqueChainIds.map(chain => (
<ChainButton
key={chain.chainId}
isActive={chain.chainId === activeChain}
onClick={handleChainClick}
{...chain}
/>
))
}, [activeChain, handleChainClick, uniqueChainIds])

return (
<Modal isOpen={isOpen} onClose={handleClose} isCentered initialFocusRef={iniitalRef}>
<ModalOverlay />
<ModalContent>
<ModalHeader
display='flex'
flexDir='column'
gap={2}
borderBottomWidth={1}
borderColor='border.base'
>
<Flex alignItems='center' justifyContent='space-between'>
<Text fontWeight='bold' fontSize='md'>
Select asset
</Text>
<CloseButton position='relative' />
</Flex>
<Input
size='lg'
placeholder='Search name or paste address'
ref={iniitalRef}
onChange={handleSearchQuery}
/>
<Flex mt={4} flexWrap='wrap' gap={2} justifyContent='space-between'>
<Button
size='lg'
isActive={activeChain === 'All'}
variant='outline'
fontSize='sm'
px={2}
onClick={handleAllClick}
>
All
</Button>
{renderChains}
</Flex>
</ModalHeader>
<ModalBody px={2} py={0}>
{renderRows}
</ModalBody>
</ModalContent>
</Modal>
)
}
40 changes: 40 additions & 0 deletions src/components/AssetSelectModal/ChainButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Avatar, IconButton } from '@chakra-ui/react'
import type { ChainId } from '@shapeshiftoss/caip'
import { useCallback, useMemo } from 'react'
export type ChainRow = {
chainId: ChainId
icon: string
name: string
}

type ChainButtonProps = {
onClick: (chainId: ChainId) => void
isActive?: boolean
} & ChainRow

export const ChainButton: React.FC<ChainButtonProps> = ({
name,
chainId,
icon,
onClick,
isActive,
}) => {
const chainIcon = useMemo(() => {
return <Avatar size='xs' src={icon} />
}, [icon])

const handleClick = useCallback(() => {
onClick(chainId)
}, [chainId, onClick])

return (
<IconButton
isActive={isActive}
variant='outline'
size='lg'
aria-label={name}
icon={chainIcon}
onClick={handleClick}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { fromAssetId } from '@shapeshiftoss/caip'
import { matchSorter } from 'match-sorter'
import { isEthAddress } from 'lib/utils'
import type { Asset } from 'types/Asset'

export const filterAssetsBySearchTerm = (search: string, assets: Asset[]) => {
if (!assets) return []

const searchLower = search.toLowerCase()

if (isEthAddress(search)) {
return assets.filter(
asset => fromAssetId(asset?.assetId).assetReference.toLowerCase() === searchLower,
)
}

return matchSorter(assets, search, {
keys: ['name', 'symbol'],
threshold: matchSorter.rankings.CONTAINS,
})
}
4 changes: 4 additions & 0 deletions src/components/AssetSelectModal/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum AssetType {
BUY = 'buy',
SELL = 'sell',
}
17 changes: 7 additions & 10 deletions src/components/AssetSelection.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
import { Avatar, Button, Text } from '@chakra-ui/react'
import type { Asset } from 'types/Asset'

type AssetSelectionProps = {
onClick: () => void
label: string
assetIcon: string
assetName: string
asset?: Asset
}

export const AssetSelection: React.FC<AssetSelectionProps> = ({
label,
onClick,
assetIcon,
assetName,
}) => {
export const AssetSelection: React.FC<AssetSelectionProps> = ({ label, onClick, asset }) => {
return (
<Button flexDir='column' height='auto' py={4} gap={4} flex={1} onClick={onClick}>
<Text color='text.subtle'>{label}</Text>
<Avatar src={assetIcon} />
<Text>{assetName}</Text>
<Avatar src={asset ? asset.icon : ''} />
<Text textOverflow='ellipsis' overflow='hidden' width='full'>
{asset ? asset.name : 'Select Asset'}
</Text>
</Button>
)
}
6 changes: 5 additions & 1 deletion src/components/Chatwoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,9 @@ export const ChatwootButton: React.FC = () => {
if (window.$chatwoot) window.$chatwoot.toggle()
}, [])

return chatWootEnabled ? <Button onClick={handleChatWoot}>Get support</Button> : null
return chatWootEnabled ? (
<Button position='absolute' right='1rem' bottom='1rem' onClick={handleChatWoot}>
Get support
</Button>
) : null
}
Loading

0 comments on commit 00a80a8

Please sign in to comment.