/ src / components / transactions / Switch / SwitchAssetInput.tsx
SwitchAssetInput.tsx
  1  import { isAddress } from '@ethersproject/address';
  2  import { formatUnits } from '@ethersproject/units';
  3  import { ExclamationIcon } from '@heroicons/react/outline';
  4  import { XCircleIcon } from '@heroicons/react/solid';
  5  import { Trans } from '@lingui/macro';
  6  import { ExpandLess, ExpandMore } from '@mui/icons-material';
  7  import {
  8    Box,
  9    Button,
 10    CircularProgress,
 11    IconButton,
 12    InputBase,
 13    ListItemText,
 14    Menu,
 15    MenuItem,
 16    SvgIcon,
 17    Typography,
 18    useTheme,
 19  } from '@mui/material';
 20  import React, { useRef, useState } from 'react';
 21  import NumberFormat, { NumberFormatProps } from 'react-number-format';
 22  import { TokenInfoWithBalance } from 'src/hooks/generic/useTokensBalance';
 23  import { useRootStore } from 'src/store/root';
 24  import { useSharedDependencies } from 'src/ui-config/SharedDependenciesProvider';
 25  
 26  import { COMMON_SWAPS } from '../../../ui-config/TokenList';
 27  import { FormattedNumber } from '../../primitives/FormattedNumber';
 28  import { ExternalTokenIcon } from '../../primitives/TokenIcon';
 29  import { SearchInput } from '../../SearchInput';
 30  
 31  interface CustomProps {
 32    onChange: (event: { target: { name: string; value: string } }) => void;
 33    name: string;
 34    value: string;
 35  }
 36  
 37  export const NumberFormatCustom = React.forwardRef<NumberFormatProps, CustomProps>(
 38    function NumberFormatCustom(props, ref) {
 39      const { onChange, ...other } = props;
 40  
 41      return (
 42        <NumberFormat
 43          {...other}
 44          getInputRef={ref}
 45          onValueChange={(values) => {
 46            if (values.value !== props.value)
 47              onChange({
 48                target: {
 49                  name: props.name,
 50                  value: values.value || '',
 51                },
 52              });
 53          }}
 54          thousandSeparator
 55          isNumericString
 56          allowNegative={false}
 57        />
 58      );
 59    }
 60  );
 61  
 62  export interface AssetInputProps {
 63    value: string;
 64    usdValue: string;
 65    chainId: number;
 66    onChange?: (value: string) => void;
 67    disabled?: boolean;
 68    disableInput?: boolean;
 69    onSelect?: (asset: TokenInfoWithBalance) => void;
 70    assets: TokenInfoWithBalance[];
 71    maxValue?: string;
 72    isMaxSelected?: boolean;
 73    loading?: boolean;
 74    selectedAsset: TokenInfoWithBalance;
 75  }
 76  
 77  export const SwitchAssetInput = ({
 78    value,
 79    usdValue,
 80    onChange,
 81    disabled,
 82    disableInput,
 83    onSelect,
 84    assets,
 85    maxValue,
 86    isMaxSelected,
 87    loading = false,
 88    chainId,
 89    selectedAsset,
 90  }: AssetInputProps) => {
 91    const theme = useTheme();
 92    const handleSelect = (asset: TokenInfoWithBalance) => {
 93      onSelect && onSelect(asset);
 94      onChange && onChange('');
 95      handleClose();
 96    };
 97  
 98    const { erc20Service } = useSharedDependencies();
 99  
100    const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
101    const inputRef = useRef<HTMLDivElement>(null);
102    const open = Boolean(anchorEl);
103  
104    const handleClick = () => {
105      setAnchorEl(inputRef.current);
106    };
107    const handleClose = () => {
108      setAnchorEl(null);
109      handleCleanSearch();
110    };
111  
112    const [filteredAssets, setFilteredAssets] = useState(assets);
113    const [loadingNewAsset, setLoadingNewAsset] = useState(false);
114    const user = useRootStore((store) => store.account);
115  
116    const popularAssets = assets.filter((asset) => COMMON_SWAPS.includes(asset.symbol));
117    const handleSearchAssetChange = (value: string) => {
118      const searchQuery = value.trim().toLowerCase();
119      const matchingAssets = assets.filter(
120        (asset) =>
121          asset.symbol.toLowerCase().includes(searchQuery) ||
122          asset.name.toLowerCase().includes(searchQuery) ||
123          asset.address.toLowerCase() === searchQuery
124      );
125      if (matchingAssets.length === 0 && isAddress(value)) {
126        setLoadingNewAsset(true);
127        Promise.all([
128          erc20Service.getTokenInfo(value, chainId),
129          erc20Service.getBalance(value, user, chainId),
130        ])
131          .then(([tokenMetadata, userBalance]) => {
132            const tokenInfo = {
133              chainId: chainId,
134              balance: formatUnits(userBalance, tokenMetadata.decimals),
135              extensions: {
136                isUserCustom: true,
137              },
138              ...tokenMetadata,
139            };
140            setFilteredAssets([tokenInfo]);
141          })
142          .catch(() => setFilteredAssets([]))
143          .finally(() => setLoadingNewAsset(false));
144      } else {
145        setFilteredAssets(matchingAssets);
146      }
147    };
148  
149    const handleCleanSearch = () => {
150      setFilteredAssets(assets);
151      setLoadingNewAsset(false);
152    };
153  
154    return (
155      <Box
156        ref={inputRef}
157        sx={(theme) => ({
158          p: '8px 12px',
159          border: `1px solid ${theme.palette.divider}`,
160          borderRadius: '6px',
161          width: '100%',
162          mb: 1,
163        })}
164      >
165        <Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
166          {loading ? (
167            <Box sx={{ flex: 1 }}>
168              <CircularProgress color="inherit" size="16px" />
169            </Box>
170          ) : (
171            <InputBase
172              sx={{ flex: 1 }}
173              placeholder="0.00"
174              disabled={disabled || disableInput}
175              value={value}
176              autoFocus
177              onChange={(e) => {
178                if (!onChange) return;
179                if (Number(e.target.value) > Number(maxValue)) {
180                  onChange('-1');
181                } else {
182                  onChange(e.target.value);
183                }
184              }}
185              inputProps={{
186                'aria-label': 'amount input',
187                style: {
188                  fontSize: '21px',
189                  lineHeight: '28,01px',
190                  padding: 0,
191                  height: '28px',
192                  textOverflow: 'ellipsis',
193                  whiteSpace: 'nowrap',
194                  overflow: 'hidden',
195                },
196              }}
197              // eslint-disable-next-line
198              inputComponent={NumberFormatCustom as any}
199            />
200          )}
201          {value !== '' && !disableInput && (
202            <IconButton
203              sx={{
204                minWidth: 0,
205                p: 0,
206                left: 8,
207                zIndex: 1,
208                color: 'text.muted',
209                '&:hover': {
210                  color: 'text.secondary',
211                },
212              }}
213              onClick={() => {
214                onChange && onChange('');
215              }}
216              disabled={disabled}
217            >
218              <XCircleIcon height={16} />
219            </IconButton>
220          )}
221          <Button
222            disableRipple
223            onClick={handleClick}
224            data-cy={`assetSelect`}
225            sx={{ p: 0, '&:hover': { backgroundColor: 'transparent' } }}
226            endIcon={open ? <ExpandLess /> : <ExpandMore />}
227          >
228            <ExternalTokenIcon
229              symbol={selectedAsset.symbol}
230              logoURI={selectedAsset.logoURI}
231              sx={{ mr: 2, ml: 3 }}
232            />
233            <Typography
234              data-cy={`assetsSelectedOption_${selectedAsset.symbol.toUpperCase()}`}
235              variant="main16"
236              color="text.primary"
237            >
238              {selectedAsset.symbol}
239            </Typography>
240            {selectedAsset.extensions?.isUserCustom && (
241              <SvgIcon sx={{ fontSize: 14, ml: 1 }} color="warning">
242                <ExclamationIcon />
243              </SvgIcon>
244            )}
245          </Button>
246          <Menu
247            anchorEl={anchorEl}
248            open={open}
249            onClose={handleClose}
250            PaperProps={{
251              sx: {
252                width: inputRef.current?.offsetWidth,
253                border: theme.palette.mode === 'dark' ? '1px solid #EBEBED1F' : 'unset',
254                boxShadow: '0px 2px 10px 0px #0000001A',
255                overflow: 'hidden',
256              },
257            }}
258            anchorOrigin={{
259              vertical: 'bottom',
260              horizontal: 'right',
261            }}
262            transformOrigin={{
263              vertical: 'top',
264              horizontal: 'right',
265            }}
266          >
267            <Box
268              sx={{
269                p: 2,
270                px: 3,
271                borderBottom: `1px solid ${theme.palette.divider}`,
272                top: 0,
273                zIndex: 2,
274              }}
275            >
276              <SearchInput
277                onSearchTermChange={handleSearchAssetChange}
278                placeholder="Search name or paste address"
279                disableFocus={true}
280              />
281              <Box
282                sx={{
283                  display: 'flex',
284                  justifyContent: 'flex-start',
285                  overfloyY: 'auto',
286                  alignItems: 'flex-start',
287                  flexWrap: 'wrap',
288                  mt: 2,
289                  gap: 2,
290                }}
291              >
292                {popularAssets.map((asset) => (
293                  <Box
294                    key={asset.symbol}
295                    sx={{
296                      display: 'flex',
297                      flexDirection: 'row',
298                      alignItems: 'center',
299                      p: 1,
300                      borderRadius: '16px',
301                      border: '1px solid',
302                      borderColor: theme.palette.divider,
303                      cursor: 'pointer',
304                      '&:hover': {
305                        backgroundColor: theme.palette.divider,
306                      },
307                    }}
308                    onClick={() => handleSelect(asset)}
309                  >
310                    <ExternalTokenIcon
311                      logoURI={asset.logoURI}
312                      symbol={asset.symbol}
313                      sx={{ width: 24, height: 24, mr: 1 }}
314                    />
315                    <Typography variant="main14" color="text.primary" sx={{ mr: 1 }}>
316                      {asset.symbol}
317                    </Typography>
318                  </Box>
319                ))}
320              </Box>
321            </Box>
322            <Box sx={{ overflow: 'auto', maxHeight: '200px' }}>
323              {loadingNewAsset ? (
324                <Box
325                  sx={{
326                    maxHeight: '178px',
327                    overflowY: 'auto',
328                    display: 'flex',
329                    flexDirection: 'column',
330                    minHeight: '60px',
331                  }}
332                >
333                  <CircularProgress sx={{ mx: 'auto', my: 'auto' }} />
334                </Box>
335              ) : filteredAssets.length > 0 ? (
336                filteredAssets.map((asset) => (
337                  <MenuItem
338                    key={asset.symbol}
339                    value={asset.symbol}
340                    data-cy={`assetsSelectOption_${asset.symbol.toUpperCase()}`}
341                    sx={{
342                      backgroundColor: theme.palette.background.paper,
343                    }}
344                    onClick={() => handleSelect(asset)}
345                  >
346                    <ExternalTokenIcon symbol={asset.symbol} logoURI={asset.logoURI} sx={{ mr: 2 }} />
347                    <ListItemText sx={{ flexGrow: 0 }}>{asset.symbol}</ListItemText>
348                    {asset.extensions?.isUserCustom && (
349                      <SvgIcon sx={{ fontSize: 14, ml: 1 }} color="warning">
350                        <ExclamationIcon />
351                      </SvgIcon>
352                    )}
353                    {asset.balance && (
354                      <FormattedNumber sx={{ ml: 'auto' }} value={asset.balance} compact />
355                    )}
356                  </MenuItem>
357                ))
358              ) : (
359                <Typography
360                  variant="main14"
361                  color="text.primary"
362                  sx={{ width: 'auto', textAlign: 'center', m: 4 }}
363                >
364                  <Trans>
365                    No results found. You can import a custom token with a contract address
366                  </Trans>
367                </Typography>
368              )}
369            </Box>
370          </Menu>
371        </Box>
372  
373        <Box sx={{ display: 'flex', alignItems: 'center', height: '16px' }}>
374          {loading ? (
375            <Box sx={{ flex: 1 }} />
376          ) : (
377            <FormattedNumber
378              value={isNaN(Number(usdValue)) ? 0 : Number(usdValue)}
379              compact
380              symbol="USD"
381              variant="secondary12"
382              color="text.muted"
383              symbolsColor="text.muted"
384              flexGrow={1}
385            />
386          )}
387  
388          {selectedAsset.balance && onChange && (
389            <>
390              <Typography component="div" variant="secondary12" color="text.secondary">
391                <Trans>Balance</Trans>
392                <FormattedNumber
393                  value={selectedAsset.balance}
394                  compact
395                  variant="secondary12"
396                  color="text.secondary"
397                  symbolsColor="text.disabled"
398                  sx={{ ml: 1 }}
399                />
400              </Typography>
401              {!disableInput && (
402                <Button
403                  size="small"
404                  sx={{ minWidth: 0, ml: '7px', p: 0 }}
405                  onClick={() => {
406                    onChange('-1');
407                  }}
408                  disabled={disabled || isMaxSelected}
409                >
410                  <Trans>Max</Trans>
411                </Button>
412              )}
413            </>
414          )}
415        </Box>
416      </Box>
417    );
418  };