diff --git a/.env b/.env index 46c68a82b2..6d2831321d 100644 --- a/.env +++ b/.env @@ -24,6 +24,7 @@ REACT_APP_FIREBASE_VAPID_KEY=BGO4YGVpmvxoMlqILCfVUVfyA0M5ohAKp_0-p1jqfGm15evDld0 REACT_APP_NOTIFICATION_API=https://notification.kyberswap.com/api REACT_APP_CAMPAIGN_BASE_URL=https://campaigns.kyberswap.com +REACT_APP_TYPE_AND_SWAP_URL=https://type-swap.dev.kyberengineering.io/api REACT_APP_TRANSAK_URL=https://global.transak.com REACT_APP_TRANSAK_API_KEY=48949c0b-2d20-4e3a-a311-51ca91ae8c0d diff --git a/.env.dev b/.env.dev index 5891ffc87a..3d52a13240 100644 --- a/.env.dev +++ b/.env.dev @@ -24,6 +24,7 @@ REACT_APP_FIREBASE_VAPID_KEY=BCH2laZcZj5fJyW2Od-iXyAy8OhJ2jpJDWJornW6JDSOi29IFeN REACT_APP_NOTIFICATION_API=https://notification.dev.kyberengineering.io/api REACT_APP_CAMPAIGN_BASE_URL=https://campaigns.dev.kyberengineering.io +REACT_APP_TYPE_AND_SWAP_URL=https://type-swap.dev.kyberengineering.io/api REACT_APP_TRANSAK_URL=https://staging-global.transak.com REACT_APP_TRANSAK_API_KEY=327b8b63-626b-4376-baf2-70a304c48488 diff --git a/.env.production b/.env.production index 34a0834b33..37e8e5c7c5 100644 --- a/.env.production +++ b/.env.production @@ -24,6 +24,7 @@ REACT_APP_FIREBASE_VAPID_KEY=BGO4YGVpmvxoMlqILCfVUVfyA0M5ohAKp_0-p1jqfGm15evDld0 REACT_APP_NOTIFICATION_API=https://notification.kyberswap.com/api REACT_APP_CAMPAIGN_BASE_URL=https://campaigns.kyberswap.com +REACT_APP_TYPE_AND_SWAP_URL=https://tns.kyberengineering.io/api REACT_APP_TRANSAK_URL=https://global.transak.com REACT_APP_TRANSAK_API_KEY=48949c0b-2d20-4e3a-a311-51ca91ae8c0d diff --git a/.env.stg b/.env.stg index d34cced0fc..4f2ee8e6e3 100644 --- a/.env.stg +++ b/.env.stg @@ -24,6 +24,7 @@ REACT_APP_FIREBASE_VAPID_KEY=BGO4YGVpmvxoMlqILCfVUVfyA0M5ohAKp_0-p1jqfGm15evDld0 REACT_APP_NOTIFICATION_API=https://notification.kyberswap.com/api REACT_APP_CAMPAIGN_BASE_URL=https://campaigns.stg.kyberengineering.io +REACT_APP_TYPE_AND_SWAP_URL=https://type-swap.stg.kyberengineering.io/api REACT_APP_TRANSAK_URL=https://staging-global.transak.com REACT_APP_TRANSAK_API_KEY=327b8b63-626b-4376-baf2-70a304c48488 diff --git a/package.json b/package.json index 38ddbc8d1b..382932b1ab 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,15 @@ "@types/react-virtualized-auto-sizer": "^1.0.0", "@types/react-window": "^1.8.2", "@types/rebass": "^4.0.5", - "@types/rebass__forms": "^4.0.4", "@types/styled-components": "^5.1.0", "@types/wcag-contrast": "^3.0.0", + "@types/big.js": "^6.0.0", + "@types/d3": "^7.1.0", + "@types/mixpanel-browser": "^2.38.0", + "@types/ms.macro": "^2.0.0", + "@types/react-copy-to-clipboard": "^5.0.2", + "@types/react-gtm-module": "^2.0.1", + "@types/recharts": "^1.8.23", "@typescript-eslint/eslint-plugin": "^2.31.0", "@typescript-eslint/parser": "^2.31.0", "@uniswap/token-lists": "^1.0.0-beta.21", @@ -133,16 +139,8 @@ "@kyberswap/ks-sdk-elastic": "^0.0.43", "@lingui/detect-locale": "^3.10.4", "@lingui/react": "^3.10.2", - "@rebass/forms": "^4.0.6", "@sentry/react": "^6.16.1", "@typeform/embed-react": "^1.2.4", - "@types/big.js": "^6.0.0", - "@types/d3": "^7.1.0", - "@types/mixpanel-browser": "^2.38.0", - "@types/ms.macro": "^2.0.0", - "@types/react-copy-to-clipboard": "^5.0.2", - "@types/react-gtm-module": "^2.0.1", - "@types/recharts": "^1.8.23", "@uniswap/default-token-list": "^2.0.0", "aos": "^2.3.4", "d3": "^7.3.0", @@ -162,7 +160,6 @@ "react-gtm-module": "^2.0.11", "react-helmet": "^6.1.0", "react-indiana-drag-scroll": "^2.0.1", - "react-player": "^2.9.0", "react-use": "^15.3.4", "recharts": "^2.1.6", "swiper": "^8.0.7", diff --git a/src/assets/svg/notification_icon_warning.svg b/src/assets/svg/notification_icon_warning.svg new file mode 100644 index 0000000000..09b6044c7c --- /dev/null +++ b/src/assets/svg/notification_icon_warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/LiveChart/AnimatingNumber.tsx b/src/components/LiveChart/AnimatingNumber.tsx index 68414acaf3..c5c019e657 100644 --- a/src/components/LiveChart/AnimatingNumber.tsx +++ b/src/components/LiveChart/AnimatingNumber.tsx @@ -1,5 +1,4 @@ -import React from 'react' -import { useEffect, useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import styled from 'styled-components' import { Text, Flex } from 'rebass' import useTheme from 'hooks/useTheme' diff --git a/src/components/Menu/FaucetModal.tsx b/src/components/Menu/FaucetModal.tsx index 138c9fdb59..6b3b1401cb 100644 --- a/src/components/Menu/FaucetModal.tsx +++ b/src/components/Menu/FaucetModal.tsx @@ -2,11 +2,16 @@ import { Trans, t } from '@lingui/macro' import React, { useContext, useEffect, useMemo, useState } from 'react' import { Flex, Text } from 'rebass' import { ApplicationModal } from 'state/application/actions' -import { useAddPopup, useModalOpen, useToggleModal, useWalletModalToggle } from 'state/application/hooks' -import { ThemeContext } from 'styled-components' +import { + NotificationType, + useModalOpen, + useNotify, + useToggleModal, + useWalletModalToggle, +} from 'state/application/hooks' +import styled, { ThemeContext } from 'styled-components' import { ButtonPrimary } from 'components/Button' import { getTokenLogoURL, isAddress, shortenAddress } from 'utils' -import styled from 'styled-components' import { CloseIcon } from 'theme' import { RowBetween } from 'components/Row' import { useActiveWeb3React } from 'hooks' @@ -14,7 +19,7 @@ import Modal from 'components/Modal' import { Fraction, ChainId } from '@kyberswap/ks-sdk-core' import { BigNumber } from 'ethers' import { useAllTokens } from 'hooks/Tokens' -import { filterTokens } from 'components/SearchModal/filtering' +import { filterTokens } from 'utils/filtering' import Logo from 'components/Logo' import useMixpanel, { MIXPANEL_TYPE } from 'hooks/useMixpanel' import JSBI from 'jsbi' @@ -50,7 +55,7 @@ function FaucetModal() { const toggle = useToggleModal(ApplicationModal.FAUCET_POPUP) const theme = useContext(ThemeContext) const [rewardData, setRewardData] = useState<{ amount: BigNumber; tokenAddress: string; program: number }>() - const addPopup = useAddPopup() + const notify = useNotify() const toggleWalletModal = useWalletModalToggle() const { mixpanelHandler } = useMixpanel() const allTokens = useAllTokens() @@ -84,14 +89,12 @@ function FaucetModal() { }) const content = await rawResponse.json() if (content) { - addPopup({ - simple: { - title: t`Request to Faucet - Submitted`, - success: true, - summary: t`You will receive ${ - rewardData?.amount ? getFullDisplayBalance(rewardData?.amount, token?.decimals) : 0 - } ${tokenSymbol} soon!`, - }, + notify({ + title: t`Request to Faucet - Submitted`, + type: NotificationType.SUCCESS, + summary: t`You will receive ${ + rewardData?.amount ? getFullDisplayBalance(rewardData?.amount, token?.decimals) : 0 + } ${tokenSymbol} soon!`, }) setRewardData(rw => { if (rw) { diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index 0716dd93fc..6569c9c768 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -80,7 +80,7 @@ export interface ModalProps { maxWidth?: number | string width?: string zindex?: number | string - initialFocusRef?: React.RefObject + enableInitialFocusInput?: boolean className?: string children?: React.ReactNode transition?: boolean @@ -93,7 +93,7 @@ export default function Modal({ maxHeight = 90, maxWidth = 420, width, - initialFocusRef, + enableInitialFocusInput = false, className, children, transition = true, @@ -140,7 +140,7 @@ export default function Modal({ className={className} > {/* prevents the automatic focusing of inputs on mobile by the reach dialog */} - {!initialFocusRef && isMobile ?
: null} + {!enableInitialFocusInput && isMobile ?
: null} {children} diff --git a/src/components/NetworkModal/index.tsx b/src/components/NetworkModal/index.tsx index e5d00fc554..83c7fd0529 100644 --- a/src/components/NetworkModal/index.tsx +++ b/src/components/NetworkModal/index.tsx @@ -2,7 +2,7 @@ import React from 'react' import styled from 'styled-components' import { Trans } from '@lingui/macro' -import { NETWORKS_INFO, MAINNET_NETWORKS } from '../../constants/networks' +import { NETWORKS_INFO, MAINNET_NETWORKS, SUPPORTED_NETWORKS } from '../../constants/networks' import { useModalOpen, useNetworkModalToggle } from 'state/application/hooks' import { ApplicationModal } from 'state/application/actions' @@ -76,7 +76,7 @@ export const SelectNetworkButton = styled(ButtonEmpty)` cursor: not-allowed; } ` - +const SHOW_NETWORKS = process.env.NODE_ENV === 'production' ? MAINNET_NETWORKS : SUPPORTED_NETWORKS export default function NetworkModal(): JSX.Element | null { const { chainId } = useActiveWeb3React() const networkModalOpen = useModalOpen(ApplicationModal.NETWORK) @@ -98,7 +98,7 @@ export default function NetworkModal(): JSX.Element | null { - {MAINNET_NETWORKS.map((key: ChainId, i: number) => { + {SHOW_NETWORKS.map((key: ChainId, i: number) => { if (chainId === key) { return ( diff --git a/src/components/Popups/PopupItem.tsx b/src/components/Popups/PopupItem.tsx index 1232a86d98..f0f306a63d 100644 --- a/src/components/Popups/PopupItem.tsx +++ b/src/components/Popups/PopupItem.tsx @@ -1,30 +1,28 @@ import React, { useCallback, useContext, useEffect } from 'react' import { X } from 'react-feather' import { useSpring } from 'react-spring/web' -import styled, { keyframes, ThemeContext } from 'styled-components' +import styled, { DefaultTheme, keyframes, ThemeContext } from 'styled-components' import { animated } from 'react-spring' -import { PopupContent } from 'state/application/actions' -import { useRemovePopup } from 'state/application/hooks' +import { PopupContentListUpdate, PopupContentSimple, PopupContentTxn, PopupType } from 'state/application/actions' +import { NotificationType, useRemovePopup } from 'state/application/hooks' import ListUpdatePopup from './ListUpdatePopup' import TransactionPopup from './TransactionPopup' import SimplePopup from './SimplePopup' +import { Flex } from 'rebass' export const StyledClose = styled(X)` - position: absolute; - right: 10px; - top: 10px; - + margin-left: 10px; :hover { cursor: pointer; } ` +const delta = window.innerWidth + 'px' const rtl = keyframes` from { opacity: 0; - transform: translateX(1000px); + transform: translateX(${delta}); } - to { opacity: 1; transform: translateX(0); @@ -36,25 +34,28 @@ const ltr = keyframes` opacity: 1; transform: translateX(0); } - to { opacity: 0; - transform: translateX(1000px); + transform: translateX(${delta}); } ` -export const Popup = styled.div<{ success?: boolean }>` +const getBackgroundColor = (theme: DefaultTheme, type: NotificationType = NotificationType.ERROR) => { + const mapColor = { + [NotificationType.SUCCESS]: theme.bg21, + [NotificationType.ERROR]: theme.bg22, + [NotificationType.WARNING]: theme.bg23, + } + return mapColor[type] +} + +export const Popup = styled.div<{ type?: NotificationType }>` display: inline-block; width: 100%; - background: ${({ theme, success }) => (success ? theme.bg21 : theme.bg22)}; + background: ${({ theme, type }) => getBackgroundColor(theme, type)}; position: relative; padding: 20px; - padding-right: 36px; - - ${({ theme }) => theme.mediaWidth.upToSmall` - padding: 12px; - padding-right: 24px; - `} + padding-right: 12px; ` const Fader = styled.div` @@ -68,19 +69,19 @@ const Fader = styled.div` const AnimatedFader = animated(Fader) -const PopupWrapper = styled.div` +const PopupWrapper = styled.div<{ removeAfterMs?: number | null }>` position: relative; isolation: isolate; border-radius: 10px; overflow: hidden; - animation: ${rtl} 1.5s ease-in-out, ${ltr} 1.5s ease-in-out 14.15s; - - ${({ theme }) => theme.mediaWidth.upToSmall` - width: min(calc(100vw - 32px), 425px); - - &:not(:first-of-type) { - margin-top: 10px; - } + width: min(calc(100vw - 32px), 425px); + animation: ${rtl} 0.7s ease-in-out, + ${ltr} 0.5s ease-in-out ${({ removeAfterMs }) => (removeAfterMs || 15000) / 1000 - 0.2}s; // animation out auto play after removeAfterMs - 0.2 seconds + &:not(:first-of-type) { + margin-top: 15px; + } + ${({ theme }) => theme.mediaWidth.upToMedium` + margin: auto; `} ` @@ -97,16 +98,17 @@ export default function PopupItem({ removeAfterMs, content, popKey, + popupType, }: { removeAfterMs: number | null - content: PopupContent + content: PopupContentTxn | PopupContentListUpdate | PopupContentSimple popKey: string + popupType: PopupType }) { const removePopup = useRemovePopup() const removeThisPopup = useCallback(() => removePopup(popKey), [popKey, removePopup]) useEffect(() => { - if (removeAfterMs === null) return undefined - + if (removeAfterMs === null) return const timeout = setTimeout(() => { removeThisPopup() }, removeAfterMs) @@ -118,29 +120,30 @@ export default function PopupItem({ const theme = useContext(ThemeContext) + let notiType: NotificationType let popupContent - if ('txn' in content) { - const { - txn: { hash, success, type, summary }, - } = content - popupContent = - } else if ('listUpdate' in content) { - const { - listUpdate: { listUrl, oldList, newList, auto }, - } = content - popupContent = - } else if ('simple' in content) { - const { - simple: { title, success, summary }, - } = content - popupContent = - } else if ('truesightNoti' in content) { - const { - truesightNoti: { title }, - } = content - popupContent = + switch (popupType) { + case PopupType.SIMPLE: { + const { title, summary, type = NotificationType.ERROR } = content as PopupContentSimple + notiType = type + popupContent = + break + } + case PopupType.TRANSACTION: { + const { hash, type, summary, notiType: _notiType = NotificationType.ERROR } = content as PopupContentTxn + notiType = _notiType + popupContent = + break + } + case PopupType.LIST_UPDATE: { + const { listUrl, oldList, newList, auto } = content as PopupContentListUpdate + notiType = NotificationType.SUCCESS + popupContent = ( + + ) + break + } } - const faderStyle = useSpring({ from: { width: '100%' }, to: { width: '0%' }, @@ -148,12 +151,14 @@ export default function PopupItem({ }) return ( - + - - - {popupContent} - {removeAfterMs !== null ? : null} + + + {popupContent} + + + {removeAfterMs && } ) diff --git a/src/components/Popups/SimplePopup.tsx b/src/components/Popups/SimplePopup.tsx index 1ed7632582..b477a99ca9 100644 --- a/src/components/Popups/SimplePopup.tsx +++ b/src/components/Popups/SimplePopup.tsx @@ -3,41 +3,49 @@ import { Box, Text } from 'rebass' import styled, { ThemeContext } from 'styled-components' import IconSuccess from 'assets/svg/notification_icon_success.svg' import IconFailure from 'assets/svg/notification_icon_failure.svg' +import IconWarning from 'assets/svg/notification_icon_warning.svg' import { AutoColumn } from 'components/Column' import { AutoRow } from 'components/Row' +import { NotificationType } from 'state/application/hooks' const RowNoFlex = styled(AutoRow)` flex-wrap: nowrap; ` - +const mapIcon = { + [NotificationType.SUCCESS]: IconSuccess, + [NotificationType.WARNING]: IconWarning, + [NotificationType.ERROR]: IconFailure, +} export default function SimplePopup({ title, - success = true, summary, + type = NotificationType.ERROR, }: { title: string - success?: boolean + type?: NotificationType summary?: string }) { const theme = useContext(ThemeContext) - + const mapColor = { + [NotificationType.SUCCESS]: theme.primary, + [NotificationType.WARNING]: theme.text, + [NotificationType.ERROR]: theme.red, + } return ( -
- {success ? ( - IconSuccess - ) : ( - IconFailure - )} +
+ Icon
- + {title} - - {summary} - + {summary && ( + + {summary} + + )} diff --git a/src/components/Popups/TransactionPopup.tsx b/src/components/Popups/TransactionPopup.tsx index c3a4cf2c56..ee887ecf9b 100644 --- a/src/components/Popups/TransactionPopup.tsx +++ b/src/components/Popups/TransactionPopup.tsx @@ -8,6 +8,7 @@ import { AutoColumn } from 'components/Column' import { AutoRow } from 'components/Row' import IconSuccess from 'assets/svg/notification_icon_success.svg' import IconFailure from 'assets/svg/notification_icon_failure.svg' +import { NotificationType } from 'state/application/hooks' const RowNoFlex = styled(AutoRow)` flex-wrap: nowrap; @@ -124,17 +125,17 @@ export const SUMMARY: { export default function TransactionPopup({ hash, - success, + notiType, type, summary, }: { hash: string - success?: boolean + notiType: NotificationType type?: string summary?: string }) { const { chainId } = useActiveWeb3React() - + const success = notiType === NotificationType.SUCCESS const theme = useContext(ThemeContext) return ( diff --git a/src/components/Popups/index.tsx b/src/components/Popups/index.tsx index f5649a2d5f..38454faf0c 100644 --- a/src/components/Popups/index.tsx +++ b/src/components/Popups/index.tsx @@ -1,73 +1,39 @@ import React from 'react' import styled from 'styled-components' import { useActivePopups } from 'state/application/hooks' -import { AutoColumn } from '../Column' import PopupItem from './PopupItem' -import { useURLWarningVisible, useRebrandingAnnouncement } from 'state/user/hooks' +import { Z_INDEXS } from 'constants/styles' -const MobilePopupWrapper = styled.div<{ height: string | number }>` - position: absolute; - z-index: 9999; - max-width: 100%; - height: ${({ height }) => height}; - margin: ${({ height }) => (height ? '20px auto;' : 0)}; - display: none; - - ${({ theme }) => theme.mediaWidth.upToSmall` - display: block; - `}; -` - -const MobilePopupInner = styled.div` - height: 99%; - overflow-x: auto; - overflow-y: hidden; - display: flex; - flex-direction: column; - -webkit-overflow-scrolling: touch; - ::-webkit-scrollbar { - display: none; - } -` - -const FixedPopupColumn = styled(AutoColumn)<{ extraPadding: string }>` +const FixedPopupColumn = styled.div` position: fixed; - top: ${({ extraPadding }) => extraPadding}; + top: 108px; right: 1rem; - max-width: 355px !important; width: 100%; - z-index: 9999; - - ${({ theme }) => theme.mediaWidth.upToSmall` - display: none; + z-index: ${Z_INDEXS.POPUP_NOTIFICATION}; + display: flex; + flex-direction: column; + align-items: flex-end; + ${({ theme }) => theme.mediaWidth.upToMedium` + left: 0; + right: 0; + top: 15px; + align-items: center; `}; ` export default function Popups() { - // get all popups const activePopups = useActivePopups() - - const urlWarningActive = useURLWarningVisible() - const rebrandingAnnouncement = useRebrandingAnnouncement() - return ( - <> - - {activePopups.map(item => ( - - ))} - - 0 ? 'auto' : 0}> - - {activePopups // reverse so new items up front - .map(item => ( - - ))} - - - + + {activePopups.map(item => ( + + ))} + ) } diff --git a/src/components/SearchModal/CurrencySearch.tsx b/src/components/SearchModal/CurrencySearch.tsx index 65fc04cb6c..3a69266122 100644 --- a/src/components/SearchModal/CurrencySearch.tsx +++ b/src/components/SearchModal/CurrencySearch.tsx @@ -30,7 +30,7 @@ import Row, { RowBetween, RowFixed } from '../Row' import Column from '../Column' import CommonBases from './CommonBases' import CurrencyList from './CurrencyList' -import { filterTokens } from './filtering' +import { filterTokens } from 'utils/filtering' import SortButton from './SortButton' import { useTokenComparator } from './sorting' import { PaddedColumn, SearchInput, Separator } from './styleds' diff --git a/src/components/SearchModal/ImportToken.tsx b/src/components/SearchModal/ImportToken.tsx index bcb2351654..a2d5f81bec 100644 --- a/src/components/SearchModal/ImportToken.tsx +++ b/src/components/SearchModal/ImportToken.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useCallback, useEffect } from 'react' import { Token, Currency } from '@kyberswap/ks-sdk-core' import styled from 'styled-components' import { t, Trans } from '@lingui/macro' @@ -7,8 +7,8 @@ import Card from 'components/Card' import { AutoColumn } from 'components/Column' import { RowBetween, RowFixed } from 'components/Row' import CurrencyLogo from 'components/CurrencyLogo' -import { ArrowLeft, AlertCircle } from 'react-feather' -import { transparentize } from 'polished' +import { ArrowLeft, AlertCircle, CornerDownLeft } from 'react-feather' +import { rgba, transparentize } from 'polished' import useTheme from 'hooks/useTheme' import { ButtonPrimary } from 'components/Button' import { SectionBreak } from 'components/swap/styleds' @@ -40,7 +40,16 @@ const AddressText = styled(TYPE.blue)` `} ` +const IconEnterWrapper = styled.div` + position: absolute; + background-color: ${({ theme }) => rgba(theme.background, 0.45)}; + border-radius: 20px; + padding: 6px 15px 4px 15px; + right: 13px; +` + interface ImportProps { + enterToImport?: boolean tokens: Token[] onBack?: () => void list?: TokenList @@ -48,12 +57,35 @@ interface ImportProps { handleCurrencySelect?: (currency: Currency) => void } -export function ImportToken({ tokens, onBack, onDismiss, handleCurrencySelect, list }: ImportProps) { +export function ImportToken({ + enterToImport = false, + tokens, + onBack, + onDismiss, + handleCurrencySelect, + list, +}: ImportProps) { const theme = useTheme() const { chainId } = useActiveWeb3React() const addToken = useAddUserToken() + const onClickImport = useCallback(() => { + tokens.forEach(addToken) + handleCurrencySelect?.(tokens[0]) + }, [tokens, addToken, handleCurrencySelect]) + useEffect(() => { + function onKeydown(e: KeyboardEvent) { + if (e.key === 'Enter' && enterToImport) { + e.preventDefault() + onClickImport() + } + } + window.addEventListener('keydown', onKeydown) + return () => { + window.removeEventListener('keydown', onKeydown) + } + }, [onClickImport, enterToImport]) return ( @@ -69,7 +101,17 @@ export function ImportToken({ tokens, onBack, onDismiss, handleCurrencySelect, l - {t`This token doesn't appear on the active token list(s). Make sure this is the token that you want to trade.`} + {tokens.length > 1 ? ( + + These tokens don't appear on the active token list(s). Make sure these are the tokens that you want to + trade. + + ) : ( + + This token doesn't appear on the active token list(s). Make sure this is the token that you want to + trade. + + )} {tokens.map(token => { @@ -123,13 +165,16 @@ export function ImportToken({ tokens, onBack, onDismiss, handleCurrencySelect, l borderRadius="20px" padding="10px 1rem" margin="16px 0 0" - onClick={() => { - tokens.map(token => addToken(token)) - handleCurrencySelect && handleCurrencySelect(tokens[0]) - }} + onClick={onClickImport} className=".token-dismiss-button" + style={{ position: 'relative' }} > Import + {enterToImport && ( + + + + )} diff --git a/src/components/Settings/index.tsx b/src/components/Settings/index.tsx index c3e5bcebfa..b445fe9403 100644 --- a/src/components/Settings/index.tsx +++ b/src/components/Settings/index.tsx @@ -77,7 +77,7 @@ const MenuFlyoutBrowserStyle = css` const StyledLabel = styled.div` font-size: ${isMobile ? '14px' : '12px'}; color: ${({ theme }) => theme.text}; - font-weigh: 400; + font-weight: 400; line-height: 20px; ` export default function SettingsTab() { diff --git a/src/components/TokenWarningModal/index.tsx b/src/components/TokenWarningModal/index.tsx index 784539aab1..6f67f02946 100644 --- a/src/components/TokenWarningModal/index.tsx +++ b/src/components/TokenWarningModal/index.tsx @@ -17,7 +17,7 @@ export default function TokenWarningModal({ }) { return ( - + ) } diff --git a/src/components/TradingViewChart/index.tsx b/src/components/TradingViewChart/index.tsx index eccdb6afb6..ce68c9afd6 100644 --- a/src/components/TradingViewChart/index.tsx +++ b/src/components/TradingViewChart/index.tsx @@ -10,7 +10,7 @@ import * as ReactDOMServer from 'react-dom/server' import { isMobile } from 'react-device-detect' import { useDatafeed } from './datafeed' import { Currency } from '@kyberswap/ks-sdk-core' -import { Z_INDEXS } from 'styles' +import { Z_INDEXS } from 'constants/styles' const ProLiveChartWrapper = styled.div<{ fullscreen: boolean }>` margin-top: 10px; @@ -172,7 +172,6 @@ function ProLiveChart({ auto_save_delay: 2, saved_data: localStorageState, } - const tvWidget = new window.TradingView.widget(widgetOptions) tvWidget.onChartReady(() => { diff --git a/src/components/swapv2/MobileTokenInfo.tsx b/src/components/swapv2/MobileTokenInfo.tsx index 1787e8aaf5..72dcfd3332 100644 --- a/src/components/swapv2/MobileTokenInfo.tsx +++ b/src/components/swapv2/MobileTokenInfo.tsx @@ -2,7 +2,7 @@ import React, { useContext } from 'react' import { MobileModalWrapper, StyledActionButtonSwapForm } from 'components/swapv2/styleds' import { Flex, Text } from 'rebass' import { ButtonText } from 'theme/components' -import { X } from 'react-feather' + import { ThemeContext } from 'styled-components' import { isMobile, MobileView } from 'react-device-detect' import { useModalOpen, useToggleModal } from 'state/application/hooks' @@ -12,7 +12,7 @@ import { Trans, t } from '@lingui/macro' import { Field } from 'state/swap/actions' import { Currency } from '@kyberswap/ks-sdk-core' import TokenInfo from 'components/swapv2/TokenInfo' -import { Info } from 'react-feather' +import { X, Info } from 'react-feather' import { MouseoverTooltip } from 'components/Tooltip' function MobileTradeRoutes({ diff --git a/src/components/swapv2/PairSuggestion/ListPair.tsx b/src/components/swapv2/PairSuggestion/ListPair.tsx new file mode 100644 index 0000000000..94cc4a41c0 --- /dev/null +++ b/src/components/swapv2/PairSuggestion/ListPair.tsx @@ -0,0 +1,158 @@ +import { Trans, t } from '@lingui/macro' +import { Z_INDEXS } from 'constants/styles' +import { useActiveWeb3React } from 'hooks' +import PairSuggestionItem from './PairSuggestionItem' +import useTheme from 'hooks/useTheme' +import React from 'react' +import { Star, AlertTriangle } from 'react-feather' +import { Flex, Text } from 'rebass' +import styled, { css } from 'styled-components' +import { Container, MAX_FAVORITE_PAIRS } from './index' +import { SuggestionPairData } from './request' +import { isFavoritePair } from './utils' + +const Break = styled.div` + border-top: 1px solid ${({ theme }) => theme.border}; +` + +const Title = styled.div` + font-size: 13px; + color: ${({ theme }) => theme.subText}; + margin: 1em 0 1em 0; +` + +const MenuFlyout = styled.div<{ showList: boolean; hasShadow?: boolean }>` + overflow: auto; + background-color: ${({ theme, showList }) => (showList ? theme.tabActive : theme.background)}; + border-radius: 20px; + padding: 0; + display: flex; + flex-direction: column; + font-size: 14px; + top: 55px; + left: 0; + right: 0; + outline: none; + z-index: ${Z_INDEXS.SUGGESTION_PAIR}; + ${({ hasShadow }) => + hasShadow + ? css` + box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.16); + position: absolute; + ` + : css` + box-shadow: unset; + position: unset; + `}; +` + +const TextWithIcon = ({ text, icon, color }: { text: string; icon?: JSX.Element; color: string }) => ( + + + {icon} + {text} + + +) + +export default function ListPair({ + isShowListPair, + isFullFavoritePair, + hasShadow, + suggestedAmount, + favoritePairs, + suggestedPairs, + isSearch, + selectedIndex, + onSelectPair, + onClickStar, +}: { + isFullFavoritePair: boolean + suggestedAmount: string + hasShadow?: boolean + selectedIndex: number + isShowListPair: boolean + favoritePairs: SuggestionPairData[] + suggestedPairs: SuggestionPairData[] + isSearch: boolean + onSelectPair: (item: SuggestionPairData) => void + onClickStar: (item: SuggestionPairData) => void +}) { + const theme = useTheme() + const { account } = useActiveWeb3React() + const isShowNotfound = isSearch && !suggestedPairs.length && !favoritePairs.length + const isShowNotfoundFavoritePair = !favoritePairs.length && !isSearch + + return isShowListPair ? ( + + {isShowNotfound && ( + } + /> + )} + {account && ( + <> + {!isSearch && ( + + + <Flex justifyContent="space-between"> + <Trans>Favourites</Trans> + <div> + {favoritePairs.length}/{MAX_FAVORITE_PAIRS} + </div> + </Flex> + + + )} + {isShowNotfoundFavoritePair && ( + } + text={t`Your favourite pairs will appear here`} + /> + )} + {favoritePairs.map((item, i) => ( + onSelectPair(item)} + onClickStar={() => onClickStar(item)} + amount={suggestedAmount} + isActive={selectedIndex === i} + data={item} + isFavorite + key={item.tokenIn + item.tokenOut} + isFullFavoritePair={isFullFavoritePair} + /> + ))} + {!isSearch && } + + )} + {suggestedPairs.length > 0 && ( + <> + {!isSearch && ( + + + <Trans>Top traded pairs</Trans> + + + )} + {suggestedPairs.map((item, i) => ( + onSelectPair(item)} + onClickStar={() => onClickStar(item)} + amount={suggestedAmount} + isActive={selectedIndex === favoritePairs.length + i} + data={item} + key={item.tokenIn + item.tokenOut} + isFavorite={isFavoritePair(favoritePairs, item)} + isFullFavoritePair={isFullFavoritePair} + /> + ))} + + )} + + + + ) : null +} diff --git a/src/components/swapv2/PairSuggestion/PairSuggestionItem.tsx b/src/components/swapv2/PairSuggestion/PairSuggestionItem.tsx new file mode 100644 index 0000000000..0ac4abf35c --- /dev/null +++ b/src/components/swapv2/PairSuggestion/PairSuggestionItem.tsx @@ -0,0 +1,103 @@ +import React from 'react' +import { useAllTokens } from 'hooks/Tokens' +import useTheme from 'hooks/useTheme' +import { Flex, Text } from 'rebass' +import styled from 'styled-components' +import { SuggestionPairData } from './request' +import { Star } from 'react-feather' +import { isMobile } from 'react-device-detect' +import Logo from 'components/Logo' +import { useActiveWeb3React } from 'hooks' +import { isActivePair } from './utils' +import { rgba } from 'polished' +import { MouseoverTooltip } from 'components/Tooltip' +import { t } from '@lingui/macro' + +const ItemWrapper = styled.div<{ isActive: boolean }>` + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + background-color: ${({ theme, isActive }) => (isActive ? rgba(theme.buttonBlack, 0.5) : 'transparent')}; + padding: 1em; + &:hover { + background-color: ${({ theme }) => rgba(theme.buttonBlack, 0.5)}; + } +` + +const StyledLogo = styled(Logo)` + width: 20px; + height: 20px; + border-radius: 100%; +` + +type PropsType = { + onClickStar: () => void + onSelectPair: () => void + data: SuggestionPairData + isActive: boolean + amount: string + isFavorite?: boolean + isFullFavoritePair?: boolean +} +export default function SuggestItem({ + data, + isFavorite, + isFullFavoritePair, + isActive, + amount, + onClickStar, + onSelectPair, +}: PropsType) { + const theme = useTheme() + const activeTokens = useAllTokens(true) + const { account } = useActiveWeb3React() + const { tokenInSymbol, tokenOutSymbol, tokenInImgUrl, tokenOutImgUrl, tokenInName, tokenOutName } = data + + const handleClickStar = (e: React.MouseEvent) => { + e.stopPropagation() + onClickStar() + } + + const isTokenNotImport = !isActivePair(activeTokens, data) + const star = ( + + ) + return ( + + + + + + +
+ + {amount} {tokenInSymbol} - {tokenOutSymbol} + + + {tokenInName} - {tokenOutName} + +
+
+ + {!isTokenNotImport && + account && + (isFullFavoritePair && !isMobile ? ( + {star} + ) : ( + star + ))} + +
+ ) +} diff --git a/src/components/swapv2/PairSuggestion/SearchInput.tsx b/src/components/swapv2/PairSuggestion/SearchInput.tsx new file mode 100644 index 0000000000..697096c89f --- /dev/null +++ b/src/components/swapv2/PairSuggestion/SearchInput.tsx @@ -0,0 +1,139 @@ +import { t } from '@lingui/macro' +import useMixpanel, { MIXPANEL_TYPE } from 'hooks/useMixpanel' +import React, { RefObject, forwardRef } from 'react' +import { BrowserView, isMobile, isMacOs } from 'react-device-detect' +import { Command, Search } from 'react-feather' +import { Flex } from 'rebass' +import styled, { css } from 'styled-components' +const SearchWrapper = styled.div<{ showList: boolean }>` + display: flex; + align-items: center; + gap: 5px; + position: relative; + width: 100%; + border-radius: 20px; + background-color: ${({ theme, showList }) => (showList ? theme.tabActive : theme.background)}; + height: 45px; +` +const SearchInput = styled.input<{ hasBorder?: boolean }>` + ::placeholder { + color: ${({ theme }) => theme.border}; + } + transition: border 100ms; + color: ${({ theme }) => theme.text}; + background: none; + border: none; + outline: none; + padding: 16px; + padding-left: 35px; + width: 100%; + font-size: 16px; + ${({ theme, hasBorder }) => + hasBorder + ? css` + border-radius: 20px; + border: 1px solid ${theme.primary}; + ` + : css` + border: none; + `}; +` + +const DisabledFrame = styled.div` + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; +` + +const SearchIcon = styled(Search)<{ showList: boolean }>` + position: absolute; + left: 10px; + color: ${({ theme, showList }) => (showList ? theme.subText : theme.border)}; + font-size: 14px; +` +const InputIcon = styled.div` + background: ${({ theme }) => theme.buttonBlack}; + padding: 3px 8px; + margin-right: 10px; + border-radius: 22px; + font-size: 12px; + color: ${({ theme }) => theme.subText}; + cursor: pointer; +` + +type Props = { + value: string + isShowListPair: boolean + disabled?: boolean + ref: RefObject + hasBorder?: boolean + showListView: () => void + hideListView: () => void + onChangeInput: (value: string) => void + onKeyPressInput: (e: React.KeyboardEvent) => void +} + +export default forwardRef(function SearchComponent( + { isShowListPair, hasBorder, disabled, value, onChangeInput, onKeyPressInput, showListView, hideListView }, + ref, +) { + const onChange = (event: React.FormEvent) => { + const { value } = event.currentTarget + onChangeInput(value) + } + + const onBlurInput = (e: React.FocusEvent) => { + if (isMobile) return + const relate = e.relatedTarget as HTMLDivElement + if (relate && relate.classList.contains('no-blur')) { + return // press star / import icon + } + hideListView() + } + + const { mixpanelHandler } = useMixpanel() + + const showListViewWithTracking = () => { + showListView() + mixpanelHandler(MIXPANEL_TYPE.TAS_PRESS_CTRL_K, 'mouse click') + } + + return ( + + + + {disabled && } + + {isShowListPair ? ( + + Esc + + ) : ( + + + {isMacOs ? ( + <> + K + + ) : ( + Ctrl+K + )} + + + )} + + + ) +}) diff --git a/src/components/swapv2/PairSuggestion/index.tsx b/src/components/swapv2/PairSuggestion/index.tsx new file mode 100644 index 0000000000..f081ca8594 --- /dev/null +++ b/src/components/swapv2/PairSuggestion/index.tsx @@ -0,0 +1,296 @@ +import React, { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react' +import styled from 'styled-components' +import { reqAddFavoritePair, reqGetSuggestionPair, reqRemoveFavoritePair, SuggestionPairData } from './request' +import { debounce } from 'lodash' +import ListPair from './ListPair' +import SearchInput from './SearchInput' +import { ChainId, NativeCurrency, Token } from '@kyberswap/ks-sdk-core' +import { BrowserView, isMobile, MobileView } from 'react-device-detect' +import Modal from 'components/Modal' +import { useActiveWeb3React } from 'hooks' +import { filterTokens } from 'utils/filtering' +import { ETHER_ADDRESS } from 'constants/index' +import { nativeOnChain } from 'constants/tokens' +import useParsedQueryString from 'hooks/useParsedQueryString' +import { useHistory } from 'react-router-dom' +import { stringify } from 'qs' +import { findLogoAndSortPair, getAddressParam, isActivePair, isFavoritePair } from './utils' +import { useAllTokens } from 'hooks/Tokens' +import { t } from '@lingui/macro' +import useMixpanel, { MIXPANEL_TYPE } from 'hooks/useMixpanel' +import { NotificationType, useNotify } from 'state/application/hooks' + +const Wrapper = styled.div` + position: relative; + width: 100%; +` + +const WrapperPopup = styled(Wrapper)` + height: 75vh; + background-color: ${({ theme }) => theme.tabActive}; +` + +export const Container = styled.div` + padding-left: 1em; + padding-right: 1em; + display: flex; + flex-direction: column; + row-gap: 1em; +` + +export const MAX_FAVORITE_PAIRS = 3 + +type Props = { + onSelectSuggestedPair: ( + fromToken: NativeCurrency | Token | undefined | null, + toToken: NativeCurrency | Token | undefined | null, + amount: string, + ) => void + setShowModalImportToken: (val: boolean) => void +} + +export type PairSuggestionHandle = { + onConfirmImportToken: () => void +} + +export default forwardRef(function PairSuggestionInput( + { onSelectSuggestedPair, setShowModalImportToken }, + ref, +) { + const [searchQuery, setSearchQuery] = useState('') + + const [selectedIndex, setSelectedIndex] = useState(0) // index selected when press up/down arrow + const [isShowListPair, setIsShowListPair] = useState(false) + + const [suggestedPairs, setSuggestions] = useState([]) + const [favoritePairs, setListFavorite] = useState([]) + + const [suggestedAmount, setSuggestedAmount] = useState('') + const [totalFavoritePair, setTotalFavoritePair] = useState(0) // to save actual total suggestedPairs because when searching, suggestedPairs being filter + + const { account, chainId } = useActiveWeb3React() + const qs = useParsedQueryString() + const history = useHistory() + const { mixpanelHandler } = useMixpanel() + + const refLoading = useRef(false) // prevent spam call api + const refInput = useRef(null) + + const activeTokens = useAllTokens(true) + + const findToken = (search: string): NativeCurrency | Token | undefined => { + if (search.toLowerCase() === ETHER_ADDRESS.toLowerCase()) { + return nativeOnChain(chainId as ChainId) + } + return filterTokens(Object.values(activeTokens), search)[0] + } + + const focusInput = () => { + const input = refInput.current + if (!input) return + input.focus() + input?.setSelectionRange(searchQuery.length, searchQuery.length) // fix focus input cursor at front (ios) + } + + const searchSuggestionPair = (keyword = '') => { + reqGetSuggestionPair(chainId, account, keyword) + .then(({ recommendedPairs = [], favoritePairs = [], amount }) => { + setSuggestions(findLogoAndSortPair(activeTokens, recommendedPairs, chainId)) + setListFavorite(findLogoAndSortPair(activeTokens, favoritePairs, chainId)) + setSuggestedAmount(amount || '') + if (!keyword) setTotalFavoritePair(favoritePairs.length) + }) + .catch(e => { + console.log(e) + setSuggestions([]) + setListFavorite([]) + }) + keyword && mixpanelHandler(MIXPANEL_TYPE.TAS_TYPING_KEYWORD, keyword) + } + + const searchDebounce = useCallback(debounce(searchSuggestionPair, 300), [chainId, account]) + const notify = useNotify() + const addToFavorite = (item: SuggestionPairData) => { + focusInput() + if (refLoading.current) return // prevent spam api + if (totalFavoritePair === MAX_FAVORITE_PAIRS && isMobile) { + // PC we already has tool tip + notify({ + title: t`You can only favorite up to three token pairs.`, + type: NotificationType.WARNING, + }) + return + } + refLoading.current = true + reqAddFavoritePair(item, account, chainId) + .then(() => { + searchSuggestionPair(searchQuery) + setTotalFavoritePair(prev => prev + 1) + }) + .catch(console.error) + .finally(() => { + refLoading.current = false + }) + mixpanelHandler(MIXPANEL_TYPE.TAS_LIKE_PAIR, { token_1: item.tokenIn, token_2: item.tokenOut }) + } + + const removeFavorite = (item: SuggestionPairData) => { + focusInput() + if (refLoading.current) return // prevent spam api + refLoading.current = true + reqRemoveFavoritePair(item, account, chainId) + .then(() => { + searchSuggestionPair(searchQuery) + setTotalFavoritePair(prev => prev - 1) + }) + .catch(console.error) + .finally(() => { + refLoading.current = false + }) + mixpanelHandler(MIXPANEL_TYPE.TAS_DISLIKE_PAIR, { token_1: item.tokenIn, token_2: item.tokenOut }) + } + + const onClickStar = (item: SuggestionPairData) => { + if (isFavoritePair(favoritePairs, item)) { + removeFavorite(item) + } else { + addToFavorite(item) + } + } + + const hideListView = () => { + setIsShowListPair(false) + setSelectedIndex(0) + refInput.current?.blur() + } + const showListView = () => { + setIsShowListPair(true) + focusInput() + } + + useEffect(() => { + function onKeydown(e: KeyboardEvent) { + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + // cmd+k or ctrl+k + e.preventDefault() + showListView() + mixpanelHandler(MIXPANEL_TYPE.TAS_PRESS_CTRL_K, 'keyboard hotkey') + } + } + window.addEventListener('keydown', onKeydown) + return () => { + window.removeEventListener('keydown', onKeydown) + } + }, [mixpanelHandler]) + + useEffect(() => { + if (isShowListPair) { + searchDebounce(searchQuery) + } + }, [isShowListPair, searchQuery, searchDebounce]) + + useEffect(() => { + setSearchQuery('') + }, [chainId]) + + const onChangeInput = (value: string) => { + setSearchQuery(value) + searchDebounce(value) + } + + const onSelectPair = (item: SuggestionPairData) => { + mixpanelHandler(MIXPANEL_TYPE.TAS_SELECT_PAIR, `${item.tokenIn} to ${item.tokenOut}`) + if (!isActivePair(activeTokens, item)) { + // show import modal + const newQs = { + ...qs, + inputCurrency: getAddressParam(item.tokenIn, chainId), + outputCurrency: getAddressParam(item.tokenOut, chainId), + } + history.push({ + search: stringify(newQs), + }) + setShowModalImportToken(true) + return + } + // select pair fill input swap form + const fromToken = findToken(item.tokenIn) + const toToken = findToken(item.tokenOut) + onSelectSuggestedPair(fromToken, toToken, suggestedAmount) + setIsShowListPair(false) + } + + useImperativeHandle(ref, () => ({ + onConfirmImportToken() { + setIsShowListPair(false) + if (suggestedAmount) { + onSelectSuggestedPair(null, null, suggestedAmount) // fill input amount + } + }, + })) + + const onKeyPressInput = (e: React.KeyboardEvent) => { + const lastIndex = suggestedPairs.length + favoritePairs.length - 1 + switch (e.key) { + case 'ArrowDown': + if (selectedIndex < lastIndex) { + setSelectedIndex(prev => prev + 1) + } else setSelectedIndex(0) + break + case 'ArrowUp': + if (selectedIndex > 0) { + setSelectedIndex(prev => prev - 1) + } else setSelectedIndex(lastIndex) + break + case 'Escape': + hideListView() + break + case 'Enter': + const selectedPair = favoritePairs.concat(suggestedPairs)[selectedIndex] + onSelectPair(selectedPair) + break + default: + break + } + } + + const propsListPair = { + suggestedAmount, + selectedIndex, + isSearch: !!searchQuery, + isShowListPair, + suggestedPairs, + favoritePairs, + isFullFavoritePair: totalFavoritePair === MAX_FAVORITE_PAIRS, + onClickStar, + onSelectPair, + } + + const propsSearch = { + isShowListPair, + value: searchQuery, + showListView, + hideListView, + onChangeInput, + onKeyPressInput, + } + + return ( + + + + + + + + + + + + + + + + + ) +}) diff --git a/src/components/swapv2/PairSuggestion/request.ts b/src/components/swapv2/PairSuggestion/request.ts new file mode 100644 index 0000000000..8d29ff0de1 --- /dev/null +++ b/src/components/swapv2/PairSuggestion/request.ts @@ -0,0 +1,56 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import Axios from 'axios' +const SUGGEST_PAIR_API = process.env.REACT_APP_TYPE_AND_SWAP_URL + +const formatData = (obj: any) => { + Object.keys(obj).forEach(key => { + if (obj[key] === undefined || obj[key] === '') { + delete obj[key] + } + }) + return obj +} + +export type SuggestionPairData = { + tokenIn: string + tokenInSymbol: string + tokenInImgUrl: string + tokenOut: string + tokenOutSymbol: string + tokenOutImgUrl: string + tokenInName: string + tokenOutName: string +} + +export function reqGetSuggestionPair( + chainId: ChainId | undefined, + wallet: string | null | undefined, + query: string, +): Promise<{ favoritePairs: SuggestionPairData[]; recommendedPairs: SuggestionPairData[]; amount: string }> { + return Axios.get(`${SUGGEST_PAIR_API}/v1/suggested-pairs`, { params: formatData({ chainId, query, wallet }) }).then( + ({ data }) => data.data, + ) +} + +export function reqRemoveFavoritePair( + item: SuggestionPairData, + wallet: string | null | undefined, + chainId: ChainId | undefined, +): Promise { + return Axios.delete(`${SUGGEST_PAIR_API}/v1/favorite-pairs`, { + data: { wallet, chainId: chainId + '', tokenIn: item.tokenIn, tokenOut: item.tokenOut }, + }) +} + +export function reqAddFavoritePair( + item: SuggestionPairData, + wallet: string | null | undefined, + chainId: ChainId | undefined, +): Promise { + return Axios.post(`${SUGGEST_PAIR_API}/v1/favorite-pairs`, { + wallet, + chainId: chainId + '', + tokenIn: item.tokenIn, + tokenOut: item.tokenOut, + }) +} diff --git a/src/components/swapv2/PairSuggestion/utils.ts b/src/components/swapv2/PairSuggestion/utils.ts new file mode 100644 index 0000000000..ceed07840e --- /dev/null +++ b/src/components/swapv2/PairSuggestion/utils.ts @@ -0,0 +1,48 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { ETHER_ADDRESS } from 'constants/index' +import { nativeOnChain } from 'constants/tokens' +import { AllTokenType } from 'hooks/Tokens' +import { getTokenLogoURL } from 'utils' +import { currencyId } from 'utils/currencyId' +import { SuggestionPairData } from './request' + +export const isFavoritePair = (favoritePairs: SuggestionPairData[], item: SuggestionPairData) => { + return favoritePairs.some(({ tokenIn, tokenOut }) => item.tokenIn === tokenIn && item.tokenOut === tokenOut) +} + +// address is lowercase +const isTokenInWhiteList = (activeTokens: AllTokenType, address: string) => + address.toLowerCase() === ETHER_ADDRESS.toLowerCase() ? true : !!activeTokens[address] + +// at least tokenIn or tokeOut not in whitelist +export const isActivePair = (activeTokens: AllTokenType, pair: SuggestionPairData) => + isTokenInWhiteList(activeTokens, pair.tokenIn) && isTokenInWhiteList(activeTokens, pair.tokenOut) + +export const findLogoAndSortPair = ( + activeTokens: AllTokenType, + list: SuggestionPairData[], + chainId: ChainId | undefined, +) => { + return list + .map(token => { + // find logo + if (!token.tokenInImgUrl) { + token.tokenInImgUrl = getTokenLogoURL(token.tokenIn, chainId) + } + if (!token.tokenOutImgUrl) { + token.tokenOutImgUrl = getTokenLogoURL(token.tokenOut, chainId) + } + return token + }) + .sort((a, b) => { + // sort token pair in whitelist appear first + const activeA = [isTokenInWhiteList(activeTokens, a.tokenIn), isTokenInWhiteList(activeTokens, a.tokenOut)] + const activeB = [isTokenInWhiteList(activeTokens, b.tokenIn), isTokenInWhiteList(activeTokens, b.tokenOut)] + return activeA.filter(Boolean).length > activeB.filter(Boolean).length ? -1 : 1 + }) +} + +export const getAddressParam = (address: string, chainId: ChainId | undefined) => + address.toLowerCase() === ETHER_ADDRESS.toLowerCase() && chainId + ? currencyId(nativeOnChain(chainId), chainId) + : address diff --git a/src/components/swapv2/TokenInfoV2/SingleTokenInfo.tsx b/src/components/swapv2/TokenInfoV2/SingleTokenInfo.tsx index 831f193c58..ae770f28d6 100644 --- a/src/components/swapv2/TokenInfoV2/SingleTokenInfo.tsx +++ b/src/components/swapv2/TokenInfoV2/SingleTokenInfo.tsx @@ -128,16 +128,9 @@ export function HowToSwap({ + How to swap {symbol1} to {symbol2}? - + } > diff --git a/src/components/swapv2/styleds.tsx b/src/components/swapv2/styleds.tsx index f25d9cfd73..5594677afd 100644 --- a/src/components/swapv2/styleds.tsx +++ b/src/components/swapv2/styleds.tsx @@ -8,7 +8,7 @@ import { AutoColumn } from '../Column' import { errorFriendly } from 'utils/dmm' import { ReactComponent as Alert } from '../../assets/images/alert.svg' import Modal, { ModalProps } from 'components/Modal' -import { Z_INDEXS } from 'styles' +import { Z_INDEXS } from 'constants/styles' export const PageWrapper = styled.div` display: flex; diff --git a/src/hooks/StakingRewards.json b/src/constants/abis/StakingRewards.json similarity index 100% rename from src/hooks/StakingRewards.json rename to src/constants/abis/StakingRewards.json diff --git a/src/styles.ts b/src/constants/styles.ts similarity index 57% rename from src/styles.ts rename to src/constants/styles.ts index 3eb2950185..557b36c880 100644 --- a/src/styles.ts +++ b/src/constants/styles.ts @@ -2,4 +2,9 @@ export const Z_INDEXS = { ICON_SUPPORT: 99, LIVE_CHART: 99999, MOBILE_MODAL: 999999, + + POPUP_NOTIFICATION: 9999, + + SWAP_FORM: 1, + SUGGESTION_PAIR: 2, } diff --git a/src/hooks/Tokens.ts b/src/hooks/Tokens.ts index 996e004aaa..2c09d7c17f 100644 --- a/src/hooks/Tokens.ts +++ b/src/hooks/Tokens.ts @@ -1,11 +1,11 @@ import { parseBytes32String } from '@ethersproject/strings' import { Currency, Token, ChainId, NativeCurrency } from '@kyberswap/ks-sdk-core' import { useMemo } from 'react' -import { TokenAddressMap, useAllLists, useCombinedActiveList, useInactiveListUrls } from 'state/lists/hooks' +import { ListType, TokenAddressMap, useAllLists, useCombinedActiveList, useInactiveListUrls } from 'state/lists/hooks' import { NEVER_RELOAD, useSingleCallResult, useMultipleContractSingleData } from 'state/multicall/hooks' import { useUserAddedTokens } from 'state/user/hooks' import { isAddress } from 'utils' -import { createTokenFilterFunction } from 'components/SearchModal/filtering' +import { createTokenFilterFunction } from 'utils/filtering' import { useActiveWeb3React } from 'hooks/index' import { useBytes32TokenContract, useTokenContract } from 'hooks/useContract' import { arrayify } from 'ethers/lib/utils' @@ -16,7 +16,11 @@ import { Interface } from '@ethersproject/abi' import { ZERO_ADDRESS } from 'constants/index' // reduce token map into standard address <-> Token mapping, optionally include user added tokens -function useTokensFromMap(tokenMap: TokenAddressMap, includeUserAdded: boolean): { [address: string]: Token } { +function useTokensFromMap( + tokenMap: TokenAddressMap, + includeUserAdded: boolean, + lowercaseAddress?: boolean, +): { [address: string]: Token } { const { chainId } = useActiveWeb3React() const userAddedTokens = useUserAddedTokens() @@ -25,7 +29,8 @@ function useTokensFromMap(tokenMap: TokenAddressMap, includeUserAdded: boolean): // reduce to just tokens const mapWithoutUrls = Object.keys(tokenMap[chainId]).reduce<{ [address: string]: Token }>((newMap, address) => { - newMap[address] = tokenMap[chainId][address].token + const key = lowercaseAddress ? address.toLowerCase() : address + newMap[key] = tokenMap[chainId][address].token return newMap }, {}) @@ -35,7 +40,8 @@ function useTokensFromMap(tokenMap: TokenAddressMap, includeUserAdded: boolean): // reduce into all ALL_TOKENS filtered by the current chain .reduce<{ [address: string]: Token }>( (tokenMap, token) => { - tokenMap[token.address] = token + const key = lowercaseAddress ? token.address.toLowerCase() : token.address + tokenMap[key] = token return tokenMap }, // must make a copy because reduce modifies the map, and we do not @@ -46,12 +52,13 @@ function useTokensFromMap(tokenMap: TokenAddressMap, includeUserAdded: boolean): } return mapWithoutUrls - }, [chainId, userAddedTokens, tokenMap, includeUserAdded]) + }, [chainId, userAddedTokens, tokenMap, includeUserAdded, lowercaseAddress]) } -export function useAllTokens(): { [address: string]: Token } { +export type AllTokenType = { [address: string]: Token } +export function useAllTokens(lowercaseAddress = false): AllTokenType { const allTokens = useCombinedActiveList() - return useTokensFromMap(allTokens, true) + return useTokensFromMap(allTokens, true, lowercaseAddress) } export function useIsTokenActive(token: Token | undefined | null): boolean { @@ -212,31 +219,49 @@ export function useCurrency(currencyId: string | undefined): Currency | null | u return isETH ? nativeOnChain(chainId as ChainId) : token } +export function searchInactiveTokenLists({ + search, + minResults = 10, + activeTokens, + chainId, + inactiveUrls, + activeList, +}: { + search: string | undefined + minResults: number + activeTokens: AllTokenType + chainId: ChainId | undefined + inactiveUrls: string[] + activeList: ListType +}): WrappedTokenInfo[] { + if (!search || search.trim().length === 0) return [] + const tokenFilter = createTokenFilterFunction(search) + const result: WrappedTokenInfo[] = [] + const addressSet: { [address: string]: true } = {} + for (const url of inactiveUrls) { + const list = activeList[url].current + if (!list) continue + for (const tokenInfo of list.tokens) { + if (tokenInfo.chainId === chainId && tokenFilter(tokenInfo)) { + const wrapped: WrappedTokenInfo = new WrappedTokenInfo(tokenInfo, list) + if (!activeTokens[wrapped.address] && !addressSet[wrapped.address]) { + addressSet[wrapped.address] = true + result.push(wrapped) + if (result.length >= minResults) return result + } + } + } + } + return result +} + export function useSearchInactiveTokenLists(search: string | undefined, minResults = 10): WrappedTokenInfo[] { - const lists = useAllLists() + const activeList = useAllLists() const inactiveUrls = useInactiveListUrls() const { chainId } = useActiveWeb3React() const activeTokens = useAllTokens() return useMemo(() => { - if (!search || search.trim().length === 0) return [] - const tokenFilter = createTokenFilterFunction(search) - const result: WrappedTokenInfo[] = [] - const addressSet: { [address: string]: true } = {} - for (const url of inactiveUrls) { - const list = lists[url].current - if (!list) continue - for (const tokenInfo of list.tokens) { - if (tokenInfo.chainId === chainId && tokenFilter(tokenInfo)) { - const wrapped: WrappedTokenInfo = new WrappedTokenInfo(tokenInfo, list) - if (!activeTokens[wrapped.address] && !addressSet[wrapped.address]) { - addressSet[wrapped.address] = true - result.push(wrapped) - if (result.length >= minResults) return result - } - } - } - } - return result - }, [activeTokens, chainId, inactiveUrls, lists, minResults, search]) + return searchInactiveTokenLists({ activeTokens, chainId, inactiveUrls, activeList, minResults, search }) + }, [activeTokens, chainId, inactiveUrls, activeList, minResults, search]) } diff --git a/src/hooks/useActiveNetwork.ts b/src/hooks/useActiveNetwork.ts index 316e4ab9b8..2012aa8173 100644 --- a/src/hooks/useActiveNetwork.ts +++ b/src/hooks/useActiveNetwork.ts @@ -9,7 +9,7 @@ import { ChainId } from '@kyberswap/ks-sdk-core' import { useAppDispatch } from 'state/hooks' import { updateChainIdWhenNotConnected } from 'state/application/actions' import { UnsupportedChainIdError } from '@web3-react/core' -import { useAddPopup } from 'state/application/hooks' +import { NotificationType, useNotify } from 'state/application/hooks' import { t } from '@lingui/macro' const getAddNetworkParams = (chainId: ChainId) => ({ @@ -38,7 +38,7 @@ export function useActiveNetwork() { const location = useLocation() const qs = useParsedQueryString() const dispatch = useAppDispatch() - const addPopup = useAddPopup() + const notify = useNotify() const locationWithoutNetworkId = useMemo(() => { // Delete networkId from qs object @@ -47,7 +47,6 @@ export function useActiveNetwork() { return { ...location, search: stringify({ ...qsWithoutNetworkId }) } }, [location, qs]) - const changeNetwork = useCallback( async (desiredChainId: ChainId, successCallback?: () => void, failureCallback?: () => void) => { const switchNetworkParams = { @@ -79,12 +78,10 @@ export function useActiveNetwork() { try { await activeProvider.request({ method: 'wallet_addEthereumChain', params: [addNetworkParams] }) if (chainId !== desiredChainId) { - addPopup({ - simple: { - title: t`Failed to switch network`, - success: false, - summary: t`In order to use KyberSwap on ${NETWORKS_INFO[desiredChainId].name}, you must change the network in your wallet.`, - }, + notify({ + title: t`Failed to switch network`, + type: NotificationType.ERROR, + summary: t`In order to use KyberSwap on ${NETWORKS_INFO[desiredChainId].name}, you must change the network in your wallet.`, }) } successCallback && successCallback() @@ -96,18 +93,16 @@ export function useActiveNetwork() { // handle other "switch" errors console.error(switchError) failureCallback && failureCallback() - addPopup({ - simple: { - title: t`Failed to switch network`, - success: false, - summary: t`In order to use KyberSwap on ${NETWORKS_INFO[desiredChainId].name}, you must change the network in your wallet.`, - }, + notify({ + title: t`Failed to switch network`, + type: NotificationType.ERROR, + summary: t`In order to use KyberSwap on ${NETWORKS_INFO[desiredChainId].name}, you must change the network in your wallet.`, }) } } } }, - [dispatch, history, library, locationWithoutNetworkId, error, addPopup, chainId], + [dispatch, history, library, locationWithoutNetworkId, error, notify, chainId], ) useEffect(() => { diff --git a/src/hooks/useLiveChartData.ts b/src/hooks/useLiveChartData.ts index 321dafa1dd..bd1241adc5 100644 --- a/src/hooks/useLiveChartData.ts +++ b/src/hooks/useLiveChartData.ts @@ -1,216 +1,217 @@ -import { useMemo } from 'react' -import { Token, ChainId, WETH } from '@kyberswap/ks-sdk-core' -import { useActiveWeb3React } from 'hooks' -import useSWR from 'swr' -import { getUnixTime, subHours } from 'date-fns' -import { NETWORKS_INFO } from 'constants/networks' - -export enum LiveDataTimeframeEnum { - HOUR = '1H', - FOUR_HOURS = '4H', - DAY = '1D', - WEEK = '1W', - MONTH = '1M', - SIX_MONTHS = '6M', -} - -const getTimeFrameHours = (timeFrame: LiveDataTimeframeEnum) => { - switch (timeFrame) { - case LiveDataTimeframeEnum.HOUR: - return 1 - case LiveDataTimeframeEnum.FOUR_HOURS: - return 4 - case LiveDataTimeframeEnum.DAY: - return 24 - case LiveDataTimeframeEnum.WEEK: - return 7 * 24 - case LiveDataTimeframeEnum.MONTH: - return 30 * 24 - case LiveDataTimeframeEnum.SIX_MONTHS: - return 180 * 24 - default: - return 7 * 24 - } -} -const generateCoingeckoUrl = ( - chainId: ChainId, - address: string | undefined, - timeFrame: LiveDataTimeframeEnum | 'live', -): string => { - const timeTo = getUnixTime(new Date()) - const timeFrom = - timeFrame === 'live' ? timeTo - 1000 : getUnixTime(subHours(new Date(), getTimeFrameHours(timeFrame))) - - return `https://api.coingecko.com/api/v3/coins/${ - NETWORKS_INFO[chainId || ChainId.MAINNET].coingeckoNetworkId - }/contract/${address}/market_chart/range?vs_currency=usd&from=${timeFrom}&to=${timeTo}` -} -const getClosestPrice = (prices: any[], time: number) => { - let closestIndex = 0 - prices.forEach((item, index) => { - if (Math.abs(item[0] - time) < Math.abs(prices[closestIndex][0] - time)) { - closestIndex = index - } - }) - return prices[closestIndex][0] - time > 10000000 ? 0 : prices[closestIndex][1] -} - -export interface ChartDataInfo { - readonly time: number - readonly value: number -} - -const liveDataApi: { [chainId in ChainId]?: string } = { - [ChainId.MAINNET]: `${process.env.REACT_APP_AGGREGATOR_API}/ethereum/tokens`, - [ChainId.BSCMAINNET]: `${process.env.REACT_APP_AGGREGATOR_API}/bsc/tokens`, - [ChainId.MATIC]: `${process.env.REACT_APP_AGGREGATOR_API}/polygon/tokens`, - [ChainId.AVAXMAINNET]: `${process.env.REACT_APP_AGGREGATOR_API}/avalanche/tokens`, - [ChainId.FANTOM]: `${process.env.REACT_APP_AGGREGATOR_API}/fantom/tokens`, - [ChainId.CRONOS]: `${process.env.REACT_APP_AGGREGATOR_API}/cronos/tokens`, - [ChainId.ARBITRUM]: `${process.env.REACT_APP_AGGREGATOR_API}/arbitrum/tokens`, -} -const fetchKyberDataSWR = async (url: string) => { - const res = await fetch(url) - if (!res.ok) throw new Error() - if (res.status === 204) { - throw new Error('No content') - } - return res.json() -} - -const fetchCoingeckoDataSWR = async (tokenAddresses: any, chainId: any, timeFrame: any): Promise => { - return await Promise.all( - [tokenAddresses[0], tokenAddresses[1]].map(address => - fetch(generateCoingeckoUrl(chainId, address, timeFrame)).then(res => { - if (!res.ok) throw new Error() - if (res.status === 204) { - throw new Error('No content') - } - return res.json() - }), - ), - ) -} - -export default function useLiveChartData(tokens: (Token | null | undefined)[], timeFrame: LiveDataTimeframeEnum) { - const { chainId } = useActiveWeb3React() - - const isReverse = useMemo(() => { - if (!tokens || !tokens[0] || !tokens[1] || tokens[0].equals(tokens[1])) return false - const [token0] = tokens[0].sortsBefore(tokens[1]) ? [tokens[0], tokens[1]] : [tokens[1], tokens[0]] - return token0 !== tokens[0] - }, [tokens]) - - const tokenAddresses = useMemo( - () => - tokens - .filter(Boolean) - .map(token => (token?.isNative ? WETH[chainId || ChainId.MAINNET].address : token?.address)?.toLowerCase()), - [tokens, chainId], - ) - const { data: kyberData, error: kyberError } = useSWR( - tokenAddresses[0] && tokenAddresses[1] - ? `https://price-chart.kyberswap.com/api/price-chart?chainId=${chainId}&timeWindow=${timeFrame.toLowerCase()}&tokenIn=${ - tokenAddresses[0] - }&tokenOut=${tokenAddresses[1]}` - : null, - fetchKyberDataSWR, - { - shouldRetryOnError: false, - revalidateOnFocus: false, - revalidateIfStale: false, - }, - ) - const isKyberDataNotValid = useMemo(() => { - if (kyberError || kyberData === null) return true - if (kyberData && kyberData.length === 0) return true - if ( - kyberData && - kyberData.length > 0 && - kyberData.every((item: any) => !item.token0Price || item.token0Price === '0') - ) - return true - return false - }, [kyberError, kyberData]) - - const { data: coingeckoData, error: coingeckoError } = useSWR( - isKyberDataNotValid ? [tokenAddresses, chainId, timeFrame] : null, - fetchCoingeckoDataSWR, - { - shouldRetryOnError: false, - revalidateOnFocus: false, - revalidateIfStale: false, - }, - ) - - const chartData = useMemo(() => { - if (!isKyberDataNotValid && kyberData && kyberData.length > 0) { - return kyberData - .sort((a: any, b: any) => parseInt(a.timestamp) - parseInt(b.timestamp)) - .map((item: any) => { - return { - time: parseInt(item.timestamp) * 1000, - value: !isReverse ? item.token0Price : item.token1Price || 0, - } - }) - } else if (coingeckoData && coingeckoData[0]?.prices?.length > 0 && coingeckoData[1]?.prices?.length > 0) { - const [data1, data2] = coingeckoData - return data1.prices.map((item: number[]) => { - const closestPrice = getClosestPrice(data2.prices, item[0]) - return { time: item[0], value: closestPrice > 0 ? parseFloat((item[1] / closestPrice).toPrecision(6)) : 0 } - }) - } else return [] - }, [kyberData, coingeckoData, isKyberDataNotValid, isReverse]) - - const error = (!!kyberError && !!coingeckoError) || chartData.length === 0 - - const { data: liveKyberData } = useSWR( - !isKyberDataNotValid && kyberData && chainId - ? liveDataApi[chainId] + `?ids=${tokenAddresses[0]},${tokenAddresses[1]}` - : null, - fetchKyberDataSWR, - { - refreshInterval: 60000, - shouldRetryOnError: false, - revalidateOnFocus: false, - revalidateIfStale: false, - }, - ) - const { data: liveCoingeckoData } = useSWR( - isKyberDataNotValid && coingeckoData ? [tokenAddresses, chainId, 'live'] : null, - fetchCoingeckoDataSWR, - { - refreshInterval: 60000, - shouldRetryOnError: false, - revalidateOnFocus: false, - revalidateIfStale: false, - }, - ) - const latestData = useMemo(() => { - if (isKyberDataNotValid) { - if (liveCoingeckoData) { - const [data1, data2] = liveCoingeckoData - if (data1.prices?.length > 0 && data2.prices?.length > 0) { - const newValue = parseFloat( - (data1.prices[data1.prices.length - 1][1] / data2.prices[data2.prices.length - 1][1]).toPrecision(6), - ) - return { time: new Date().getTime(), value: newValue } - } - } - } else { - if (liveKyberData) { - const value = - liveKyberData && tokenAddresses[0] && tokenAddresses[1] - ? liveKyberData[tokenAddresses[0]]?.price / liveKyberData[tokenAddresses[1]]?.price - : 0 - if (value) return { time: new Date().getTime(), value: value } - } - } - return null - }, [liveKyberData, liveCoingeckoData, isKyberDataNotValid, tokenAddresses]) - return { - data: useMemo(() => (latestData ? [...chartData, latestData] : chartData), [latestData, chartData]), - error: error, - loading: chartData.length === 0 && !error, - } -} +import { useMemo } from 'react' +import { Token, ChainId, WETH } from '@kyberswap/ks-sdk-core' +import { useActiveWeb3React } from 'hooks' +import useSWR from 'swr' +import { getUnixTime, subHours } from 'date-fns' +import { NETWORKS_INFO } from 'constants/networks' +import { COINGECKO_API_URL } from 'constants/index' + +export enum LiveDataTimeframeEnum { + HOUR = '1H', + FOUR_HOURS = '4H', + DAY = '1D', + WEEK = '1W', + MONTH = '1M', + SIX_MONTHS = '6M', +} + +const getTimeFrameHours = (timeFrame: LiveDataTimeframeEnum) => { + switch (timeFrame) { + case LiveDataTimeframeEnum.HOUR: + return 1 + case LiveDataTimeframeEnum.FOUR_HOURS: + return 4 + case LiveDataTimeframeEnum.DAY: + return 24 + case LiveDataTimeframeEnum.WEEK: + return 7 * 24 + case LiveDataTimeframeEnum.MONTH: + return 30 * 24 + case LiveDataTimeframeEnum.SIX_MONTHS: + return 180 * 24 + default: + return 7 * 24 + } +} +const generateCoingeckoUrl = ( + chainId: ChainId, + address: string | undefined, + timeFrame: LiveDataTimeframeEnum | 'live', +): string => { + const timeTo = getUnixTime(new Date()) + const timeFrom = + timeFrame === 'live' ? timeTo - 1000 : getUnixTime(subHours(new Date(), getTimeFrameHours(timeFrame))) + + return `${COINGECKO_API_URL}/coins/${ + NETWORKS_INFO[chainId || ChainId.MAINNET].coingeckoNetworkId + }/contract/${address}/market_chart/range?vs_currency=usd&from=${timeFrom}&to=${timeTo}` +} +const getClosestPrice = (prices: any[], time: number) => { + let closestIndex = 0 + prices.forEach((item, index) => { + if (Math.abs(item[0] - time) < Math.abs(prices[closestIndex][0] - time)) { + closestIndex = index + } + }) + return prices[closestIndex][0] - time > 10000000 ? 0 : prices[closestIndex][1] +} + +export interface ChartDataInfo { + readonly time: number + readonly value: number +} + +const liveDataApi: { [chainId in ChainId]?: string } = { + [ChainId.MAINNET]: `${process.env.REACT_APP_AGGREGATOR_API}/ethereum/tokens`, + [ChainId.BSCMAINNET]: `${process.env.REACT_APP_AGGREGATOR_API}/bsc/tokens`, + [ChainId.MATIC]: `${process.env.REACT_APP_AGGREGATOR_API}/polygon/tokens`, + [ChainId.AVAXMAINNET]: `${process.env.REACT_APP_AGGREGATOR_API}/avalanche/tokens`, + [ChainId.FANTOM]: `${process.env.REACT_APP_AGGREGATOR_API}/fantom/tokens`, + [ChainId.CRONOS]: `${process.env.REACT_APP_AGGREGATOR_API}/cronos/tokens`, + [ChainId.ARBITRUM]: `${process.env.REACT_APP_AGGREGATOR_API}/arbitrum/tokens`, +} +const fetchKyberDataSWR = async (url: string) => { + const res = await fetch(url) + if (!res.ok) throw new Error() + if (res.status === 204) { + throw new Error('No content') + } + return res.json() +} + +const fetchCoingeckoDataSWR = async (tokenAddresses: any, chainId: any, timeFrame: any): Promise => { + return await Promise.all( + [tokenAddresses[0], tokenAddresses[1]].map(address => + fetch(generateCoingeckoUrl(chainId, address, timeFrame)).then(res => { + if (!res.ok) throw new Error() + if (res.status === 204) { + throw new Error('No content') + } + return res.json() + }), + ), + ) +} + +export default function useLiveChartData(tokens: (Token | null | undefined)[], timeFrame: LiveDataTimeframeEnum) { + const { chainId } = useActiveWeb3React() + + const isReverse = useMemo(() => { + if (!tokens || !tokens[0] || !tokens[1] || tokens[0].equals(tokens[1])) return false + const [token0] = tokens[0].sortsBefore(tokens[1]) ? [tokens[0], tokens[1]] : [tokens[1], tokens[0]] + return token0 !== tokens[0] + }, [tokens]) + + const tokenAddresses = useMemo( + () => + tokens + .filter(Boolean) + .map(token => (token?.isNative ? WETH[chainId || ChainId.MAINNET].address : token?.address)?.toLowerCase()), + [tokens, chainId], + ) + const { data: kyberData, error: kyberError } = useSWR( + tokenAddresses[0] && tokenAddresses[1] + ? `https://price-chart.kyberswap.com/api/price-chart?chainId=${chainId}&timeWindow=${timeFrame.toLowerCase()}&tokenIn=${ + tokenAddresses[0] + }&tokenOut=${tokenAddresses[1]}` + : null, + fetchKyberDataSWR, + { + shouldRetryOnError: false, + revalidateOnFocus: false, + revalidateIfStale: false, + }, + ) + const isKyberDataNotValid = useMemo(() => { + if (kyberError || kyberData === null) return true + if (kyberData && kyberData.length === 0) return true + if ( + kyberData && + kyberData.length > 0 && + kyberData.every((item: any) => !item.token0Price || item.token0Price === '0') + ) + return true + return false + }, [kyberError, kyberData]) + + const { data: coingeckoData, error: coingeckoError } = useSWR( + isKyberDataNotValid ? [tokenAddresses, chainId, timeFrame] : null, + fetchCoingeckoDataSWR, + { + shouldRetryOnError: false, + revalidateOnFocus: false, + revalidateIfStale: false, + }, + ) + + const chartData = useMemo(() => { + if (!isKyberDataNotValid && kyberData && kyberData.length > 0) { + return kyberData + .sort((a: any, b: any) => parseInt(a.timestamp) - parseInt(b.timestamp)) + .map((item: any) => { + return { + time: parseInt(item.timestamp) * 1000, + value: !isReverse ? item.token0Price : item.token1Price || 0, + } + }) + } else if (coingeckoData && coingeckoData[0]?.prices?.length > 0 && coingeckoData[1]?.prices?.length > 0) { + const [data1, data2] = coingeckoData + return data1.prices.map((item: number[]) => { + const closestPrice = getClosestPrice(data2.prices, item[0]) + return { time: item[0], value: closestPrice > 0 ? parseFloat((item[1] / closestPrice).toPrecision(6)) : 0 } + }) + } else return [] + }, [kyberData, coingeckoData, isKyberDataNotValid, isReverse]) + + const error = (!!kyberError && !!coingeckoError) || chartData.length === 0 + + const { data: liveKyberData } = useSWR( + !isKyberDataNotValid && kyberData && chainId + ? liveDataApi[chainId] + `?ids=${tokenAddresses[0]},${tokenAddresses[1]}` + : null, + fetchKyberDataSWR, + { + refreshInterval: 60000, + shouldRetryOnError: false, + revalidateOnFocus: false, + revalidateIfStale: false, + }, + ) + const { data: liveCoingeckoData } = useSWR( + isKyberDataNotValid && coingeckoData ? [tokenAddresses, chainId, 'live'] : null, + fetchCoingeckoDataSWR, + { + refreshInterval: 60000, + shouldRetryOnError: false, + revalidateOnFocus: false, + revalidateIfStale: false, + }, + ) + const latestData = useMemo(() => { + if (isKyberDataNotValid) { + if (liveCoingeckoData) { + const [data1, data2] = liveCoingeckoData + if (data1.prices?.length > 0 && data2.prices?.length > 0) { + const newValue = parseFloat( + (data1.prices[data1.prices.length - 1][1] / data2.prices[data2.prices.length - 1][1]).toPrecision(6), + ) + return { time: new Date().getTime(), value: newValue } + } + } + } else { + if (liveKyberData) { + const value = + liveKyberData && tokenAddresses[0] && tokenAddresses[1] + ? liveKyberData[tokenAddresses[0]]?.price / liveKyberData[tokenAddresses[1]]?.price + : 0 + if (value) return { time: new Date().getTime(), value: value } + } + } + return null + }, [liveKyberData, liveCoingeckoData, isKyberDataNotValid, tokenAddresses]) + return { + data: useMemo(() => (latestData ? [...chartData, latestData] : chartData), [latestData, chartData]), + error: error, + loading: chartData.length === 0 && !error, + } +} diff --git a/src/hooks/useMixpanel.ts b/src/hooks/useMixpanel.ts index f474f65570..4f72f2bfaf 100644 --- a/src/hooks/useMixpanel.ts +++ b/src/hooks/useMixpanel.ts @@ -111,6 +111,13 @@ export enum MIXPANEL_TYPE { CAMPAIGN_WALLET_CONNECTED, TRANSAK_BUY_CRYPTO_CLICKED, TRANSAK_DOWNLOAD_WALLET_CLICKED, + + // type and swap + TAS_TYPING_KEYWORD, + TAS_SELECT_PAIR, + TAS_LIKE_PAIR, + TAS_DISLIKE_PAIR, + TAS_PRESS_CTRL_K, } export const NEED_CHECK_SUBGRAPH_TRANSACTION_TYPES = [ @@ -590,6 +597,30 @@ export default function useMixpanel(trade?: Aggregator | undefined, currencies?: mixpanel.track('Buy Crypto - To purchase crypto on Transak "Buy Now”') break } + + // type and swap + case MIXPANEL_TYPE.TAS_TYPING_KEYWORD: { + mixpanel.track('Type and Swap - Typed on the text box', { text: payload }) + break + } + case MIXPANEL_TYPE.TAS_SELECT_PAIR: { + mixpanel.track('Type and Swap - Selected an option', { option: payload }) + break + } + case MIXPANEL_TYPE.TAS_LIKE_PAIR: { + mixpanel.track('Type and Swap - Favorite a token pair', payload) + break + } + case MIXPANEL_TYPE.TAS_DISLIKE_PAIR: { + mixpanel.track('Type and Swap - Un-favorite a token pair', payload) + break + } + case MIXPANEL_TYPE.TAS_PRESS_CTRL_K: { + mixpanel.track('Type and Swap - User click Ctrl + K (or Cmd + K) or Clicked on the text box', { + navigation: payload, + }) + break + } } }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/pages/SwapV2/index.tsx b/src/pages/SwapV2/index.tsx index 042a7bd3c6..fd088c75cd 100644 --- a/src/pages/SwapV2/index.tsx +++ b/src/pages/SwapV2/index.tsx @@ -1,4 +1,4 @@ -import { ChainId, Currency, CurrencyAmount, Token } from '@kyberswap/ks-sdk-core' +import { ChainId, Currency, CurrencyAmount, NativeCurrency, Token } from '@kyberswap/ks-sdk-core' import JSBI from 'jsbi' import React, { useCallback, useContext, useEffect, useMemo, useState, useRef } from 'react' import { AlertTriangle } from 'react-feather' @@ -34,6 +34,7 @@ import { TabContainer, TabWrapper, Wrapper, + StyledActionButtonSwapForm, } from 'components/swapv2/styleds' import TokenWarningModal from 'components/TokenWarningModal' import ProgressSteps from 'components/ProgressSteps' @@ -76,6 +77,7 @@ import TokenInfoV2 from 'components/swapv2/TokenInfoV2' import MobileLiveChart from 'components/swapv2/MobileLiveChart' import MobileTradeRoutes from 'components/swapv2/MobileTradeRoutes' import MobileTokenInfo from 'components/swapv2/MobileTokenInfo' +import PairSuggestion, { PairSuggestionHandle } from 'components/swapv2/PairSuggestion' import useMixpanel, { MIXPANEL_TYPE } from 'hooks/useMixpanel' import { currencyId } from 'utils/currencyId' import Banner from 'components/Banner' @@ -85,12 +87,11 @@ import { NETWORKS_INFO, SUPPORTED_NETWORKS } from 'constants/networks' import { useActiveNetwork } from 'hooks/useActiveNetwork' import { convertToSlug, getNetworkSlug, getSymbolSlug } from 'utils/string' import { checkPairInWhiteList, convertSymbol } from 'utils/tokenInfo' -import { filterTokensWithExactKeyword } from 'components/SearchModal/filtering' +import { filterTokensWithExactKeyword } from 'utils/filtering' import { nativeOnChain } from 'constants/tokens' import usePrevious from 'hooks/usePrevious' import SettingsPanel from 'components/swapv2/SwapSettingsPanel' import TransactionSettingsIcon from 'components/Icons/TransactionSettingsIcon' -import { StyledActionButtonSwapForm } from 'components/swapv2/styleds' import GasPriceTrackerPanel from 'components/swapv2/GasPriceTrackerPanel' import LiquiditySourcesPanel from 'components/swapv2/LiquiditySourcesPanel' import useParsedQueryString from 'hooks/useParsedQueryString' @@ -98,6 +99,9 @@ import { ReactComponent as TutorialSvg } from 'assets/svg/play_circle_outline.sv import Tutorial, { TutorialType } from 'components/Tutorial' import { MouseoverTooltip } from 'components/Tooltip' import { reportException } from 'utils/sentry' +import { Z_INDEXS } from 'constants/styles' +import { stringify } from 'qs' +import { debounce } from 'lodash' const TutorialIcon = styled(TutorialSvg)` width: 22px; @@ -146,7 +150,7 @@ const highlight = (theme: DefaultTheme) => keyframes` export const AppBodyWrapped = styled(BodyWrapper)` box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.04); - z-index: 1; + z-index: ${Z_INDEXS.SWAP_FORM}; padding: 16px 16px 24px; margin-top: 0; @@ -179,7 +183,10 @@ export default function Swap({ history }: RouteComponentProps) { const isShowTokenInfoSetting = useShowTokenInfo() const qs = useParsedQueryString() - const shouldHighlightSwapBox = (qs.highlightBox as string) === 'true' + const refSuggestPair = useRef(null) + const [showingPairSuggestionImport, setShowingPairSuggestionImport] = useState(false) // show modal import when click pair suggestion + + const shouldHighlightSwapBox = qs.highlightBox === 'true' const [isSelectCurencyMannual, setIsSelectCurencyMannual] = useState(false) // true when: select token input, output mannualy or click rotate token. // else select via url @@ -291,8 +298,19 @@ export default function Swap({ history }: RouteComponentProps) { // reset if they close warning without tokens in params const handleDismissTokenWarning = useCallback(() => { - setDismissTokenWarning(true) - }, []) + if (showingPairSuggestionImport) { + setShowingPairSuggestionImport(false) + } else { + setDismissTokenWarning(true) + } + }, [showingPairSuggestionImport]) + + const handleConfirmTokenWarning = useCallback(() => { + handleDismissTokenWarning() + if (showingPairSuggestionImport) { + refSuggestPair.current?.onConfirmImportToken() // callback from children + } + }, [handleDismissTokenWarning, showingPairSuggestionImport]) // modal and loading const [{ showConfirm, tradeToConfirm, swapErrorMessage, attemptingTxn, txHash }, setSwapState] = useState<{ @@ -454,9 +472,17 @@ export default function Swap({ history }: RouteComponentProps) { return filterTokensWithExactKeyword(Object.values(defaultTokens), keyword)[0] } - const navigate = (url: string) => { - history.push(`${url}${window.location.search}`) // keep query params - } + const navigate = useCallback( + (url: string) => { + const newQs = { ...qs } + // /swap/net/symA-to-symB?inputCurrency= addressC/symC &outputCurrency= addressD/symD + delete newQs.outputCurrency + delete newQs.inputCurrency + delete newQs.networkId + history.push(`${url}?${stringify(newQs)}`) // keep query params + }, + [history, qs], + ) function findTokenPairFromUrl() { let { fromCurrency, toCurrency, network } = getUrlMatchParams() @@ -471,7 +497,7 @@ export default function Swap({ history }: RouteComponentProps) { const isSame = fromCurrency && fromCurrency === toCurrency if (!toCurrency || isSame) { - // net/xxx + // net/symbol const fromToken = findToken(fromCurrency) if (fromToken) { onCurrencySelection(Field.INPUT, fromToken) @@ -511,7 +537,7 @@ export default function Swap({ history }: RouteComponentProps) { } const checkAutoSelectTokenFromUrl = () => { - // check case: `/swap/net/x-to-y` or `/swap/net/x` is valid + // check case: `/swap/net/sym-to-sym` or `/swap/net/sym` is valid const { fromCurrency, network } = getUrlMatchParams() if (!fromCurrency || !network) return @@ -529,13 +555,29 @@ export default function Swap({ history }: RouteComponentProps) { } } - const syncUrl = () => { - const symbolIn = getSymbolSlug(currencyIn) - const symbolOut = getSymbolSlug(currencyOut) - if (symbolIn && symbolOut && chainId) { - navigate(`/swap/${getNetworkSlug(chainId)}/${symbolIn}-to-${symbolOut}`) - } - } + const syncUrl = useCallback( + (currencyIn: Currency | undefined, currencyOut: Currency | undefined) => { + const symbolIn = getSymbolSlug(currencyIn) + const symbolOut = getSymbolSlug(currencyOut) + if (symbolIn && symbolOut && chainId) { + navigate(`/swap/${getNetworkSlug(chainId)}/${symbolIn}-to-${symbolOut}`) + } + }, + [navigate, chainId], + ) + + const onSelectSuggestedPair = useCallback( + ( + fromToken: NativeCurrency | Token | undefined | null, + toToken: NativeCurrency | Token | undefined | null, + amount: string, + ) => { + if (fromToken) onCurrencySelection(Field.INPUT, fromToken) + if (toToken) onCurrencySelection(Field.OUTPUT, toToken) + if (amount) handleTypeInput(amount) + }, + [handleTypeInput, onCurrencySelection], + ) const tokenImports: Token[] = useUserAddedTokens() const prevTokenImports = usePrevious(tokenImports) || [] @@ -592,9 +634,35 @@ export default function Swap({ history }: RouteComponentProps) { }, []) useEffect(() => { - if (isSelectCurencyMannual) syncUrl() // when we select token mannual - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currencyIn, currencyOut]) + if (isSelectCurencyMannual) syncUrl(currencyIn, currencyOut) // when we select token manual + }, [currencyIn, currencyOut, isSelectCurencyMannual, syncUrl]) + + const refLoadedCurrency = useRef<{ + currencyIn: Currency | null | undefined + currencyOut: Currency | null | undefined + }>({ currencyIn: null, currencyOut: null }) + + useEffect(() => { + refLoadedCurrency.current = { currencyIn: loadedInputCurrency, currencyOut: loadedOutputCurrency } + }, [loadedInputCurrency, loadedOutputCurrency]) + + const checkParamWrong = useCallback(() => { + const { currencyIn, currencyOut } = refLoadedCurrency.current + if (!currencyIn || !currencyOut) { + const newQuery = { ...qs } + if (!currencyIn) delete newQuery.inputCurrency + if (!currencyOut) delete newQuery.outputCurrency + history.replace({ + search: stringify(newQuery), + }) + } + }, [qs, history]) + + // swap?inputCurrency=xxx&outputCurrency=yyy. xxx yyy not exist in chain => remove params => select default pair + const checkParamWrongDebounce = useCallback(debounce(checkParamWrong, 300), []) + useEffect(() => { + checkParamWrongDebounce() + }, [chainId, checkParamWrongDebounce]) useEffect(() => { if (isExpertMode) { @@ -611,15 +679,16 @@ export default function Swap({ history }: RouteComponentProps) { }, [allowedSlippage]) const shareUrl = useMemo(() => { - return currencies && currencyIn && currencyOut - ? window.location.origin + - `/swap?inputCurrency=${currencyId(currencyIn as Currency, chainId)}&outputCurrency=${currencyId( - currencyOut as Currency, - chainId, - )}&networkId=${chainId}` - : window.location.origin + `/swap?networkId=${chainId}` + return `${window.location.origin}/swap?networkId=${chainId}${ + currencyIn && currencyOut + ? `&${stringify({ + inputCurrency: currencyId(currencyIn, chainId), + outputCurrency: currencyId(currencyOut, chainId), + })}` + : '' + }` // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currencies, currencyIn, currencyOut, chainId, currencyId, window.location.origin]) + }, [currencyIn, currencyOut, chainId, currencyId, window.location.origin]) const { isInWhiteList: isPairInWhiteList, canonicalUrl } = checkPairInWhiteList( chainId, @@ -629,6 +698,9 @@ export default function Swap({ history }: RouteComponentProps) { const shouldRenderTokenInfo = isShowTokenInfoSetting && currencyIn && currencyOut && isPairInWhiteList + const isShowModalImportToken = + importTokensNotInDefault.length > 0 && (!dismissTokenWarning || showingPairSuggestionImport) + return ( <> {/** @@ -638,9 +710,9 @@ export default function Swap({ history }: RouteComponentProps) { 0 && !dismissTokenWarning} + isOpen={isShowModalImportToken} tokens={importTokensNotInDefault} - onConfirm={handleDismissTokenWarning} + onConfirm={handleConfirmTokenWarning} onDismiss={handleDismissTokenWarning} /> @@ -695,6 +767,14 @@ export default function Swap({ history }: RouteComponentProps) { + + + + {activeTab === TAB.SWAP && ( <> diff --git a/src/pages/TrueSight/hooks/useGetCoinGeckoChartData.ts b/src/pages/TrueSight/hooks/useGetCoinGeckoChartData.ts index b67b929993..b3d88c7833 100644 --- a/src/pages/TrueSight/hooks/useGetCoinGeckoChartData.ts +++ b/src/pages/TrueSight/hooks/useGetCoinGeckoChartData.ts @@ -2,6 +2,7 @@ import { useMemo, useRef } from 'react' import { TrueSightTimeframe } from 'pages/TrueSight/index' import { NETWORKS_INFO, TRUESIGHT_NETWORK_TO_CHAINID } from 'constants/networks' +import { COINGECKO_API_URL } from 'constants/index' import useSWRImmutable from 'swr/immutable' export interface CoinGeckoChartData { @@ -48,15 +49,15 @@ export default function useGetCoinGeckoChartData( const from = to - (timeframe === TrueSightTimeframe.ONE_DAY ? 24 * 3600 : 24 * 3600 * 7) const chainId = TRUESIGHT_NETWORK_TO_CHAINID[tokenNetwork] const coinGeckoNetworkId = NETWORKS_INFO[chainId].coingeckoNetworkId - let url = `https://api.coingecko.com/api/v3/coins/${coinGeckoNetworkId}/contract/${tokenAddress.toLowerCase()}/market_chart/range?vs_currency=usd&from=${from}&to=${to}` + let url = `${COINGECKO_API_URL}/coins/${coinGeckoNetworkId}/contract/${tokenAddress.toLowerCase()}/market_chart/range?vs_currency=usd&from=${from}&to=${to}` if (tokenAddress === 'bnb') { - url = `https://api.coingecko.com/api/v3/coins/binancecoin/market_chart/range?vs_currency=usd&from=${from}&to=${to}` + url = `${COINGECKO_API_URL}/coins/binancecoin/market_chart/range?vs_currency=usd&from=${from}&to=${to}` } else if (tokenNetwork === 'bsc' && tokenAddress === '0x1Fa4a73a3F0133f0025378af00236f3aBDEE5D63') { // NEAR - url = `https://api.coingecko.com/api/v3/coins/near/market_chart/range?vs_currency=usd&from=${from}&to=${to}` + url = `${COINGECKO_API_URL}/coins/near/market_chart/range?vs_currency=usd&from=${from}&to=${to}` } else if (tokenNetwork === 'eth' && tokenAddress === '0x7c8161545717a334f3196e765d9713f8042EF338') { // CAKE - url = `https://api.coingecko.com/api/v3/coins/binance-smart-chain/contract/0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82/market_chart/range?vs_currency=usd&from=${from}&to=${to}` + url = `${COINGECKO_API_URL}/coins/binance-smart-chain/contract/0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82/market_chart/range?vs_currency=usd&from=${from}&to=${to}` } if (Date.now() - latestRequestingTime.current < FETCHING_COINGECKO_CHART_DATA_OFFSET) { // Too Many Request diff --git a/src/pages/TrueSight/hooks/useGetTrendingSoonTokenId.ts b/src/pages/TrueSight/hooks/useGetTrendingSoonTokenId.ts index 0b61543449..9c24ba0110 100644 --- a/src/pages/TrueSight/hooks/useGetTrendingSoonTokenId.ts +++ b/src/pages/TrueSight/hooks/useGetTrendingSoonTokenId.ts @@ -9,25 +9,26 @@ export default function useGetTrendingSoonTokenId(token?: Token): number | undef useEffect(() => { const asyncF = async () => { - setTokenId(undefined) - if (token) { - const { address } = token - const url24h = `${process.env.REACT_APP_TRUESIGHT_API}/api/v1/trending-soon?timeframe=24h&page_number=0&page_size=${TRENDING_SOON_MAX_ITEMS}&search_token_address=${address}` - // const url7d = `${process.env.REACT_APP_TRUESIGHT_API}/api/v1/trending-soon?timeframe=7d&page_number=0&page_size=${TRENDING_SOON_MAX_ITEMS}&search_token_address=${address}` - const responses = await Promise.all([fetch(url24h)]) - for (let i = 0; i < responses.length; i++) { - const response = responses[i] - if (response.ok) { - const { data }: { data: TrueSightTokenResponse } = await response.json() - if (data.tokens.length) { - setTokenId(data.tokens[0].token_id) - return + try { + setTokenId(undefined) + if (token) { + const { address } = token + const url24h = `${process.env.REACT_APP_TRUESIGHT_API}/api/v1/trending-soon?timeframe=24h&page_number=0&page_size=${TRENDING_SOON_MAX_ITEMS}&search_token_address=${address}` + // const url7d = `${process.env.REACT_APP_TRUESIGHT_API}/api/v1/trending-soon?timeframe=7d&page_number=0&page_size=${TRENDING_SOON_MAX_ITEMS}&search_token_address=${address}` + const responses = await Promise.all([fetch(url24h)]) + for (let i = 0; i < responses.length; i++) { + const response = responses[i] + if (response.ok) { + const { data }: { data: TrueSightTokenResponse } = await response.json() + if (data.tokens.length) { + setTokenId(data.tokens[0].token_id) + return + } } } } - } + } catch (error) {} } - asyncF() }, [token]) diff --git a/src/state/application/actions.ts b/src/state/application/actions.ts index d8d313de91..68688f30ad 100644 --- a/src/state/application/actions.ts +++ b/src/state/application/actions.ts @@ -2,37 +2,32 @@ import { createAction } from '@reduxjs/toolkit' import { TokenList } from '@uniswap/token-lists' import { ChainId } from '@kyberswap/ks-sdk-core' import { GasPrice } from './reducer' +import { NotificationType } from './hooks' +export type PopupContentTxn = { + hash: string + notiType: NotificationType + type?: string + summary?: string +} +export type PopupContentListUpdate = { + listUrl: string + oldList: TokenList + newList: TokenList + auto: boolean +} +export type PopupContentSimple = { + title: string + summary?: string + type: NotificationType +} + +export enum PopupType { + TRANSACTION, + LIST_UPDATE, + SIMPLE, +} -export type PopupContent = - | { - txn: { - hash: string - success: boolean - type?: string - summary?: string - } - } - | { - listUpdate: { - listUrl: string - oldList: TokenList - newList: TokenList - auto: boolean - } - } - | { - simple: { - title: string - success: boolean - summary: string - } - } - | { - truesightNoti: { - title: string - body: string - } - } +export type PopupContent = PopupContentTxn | PopupContentListUpdate | PopupContentSimple export enum ApplicationModal { NETWORK, @@ -67,9 +62,12 @@ export enum ApplicationModal { export const updateBlockNumber = createAction<{ chainId: number; blockNumber: number }>('application/updateBlockNumber') export const setOpenModal = createAction('application/setOpenModal') -export const addPopup = createAction<{ key?: string; removeAfterMs?: number | null; content: PopupContent }>( - 'application/addPopup', -) +export const addPopup = createAction<{ + key?: string + removeAfterMs?: number | null + content: PopupContent + popupType: PopupType +}>('application/addPopup') export const removePopup = createAction<{ key: string }>('application/removePopup') export const updatePrommETHPrice = createAction<{ currentPrice: string diff --git a/src/state/application/hooks.ts b/src/state/application/hooks.ts index b20d44528f..5d65d10084 100644 --- a/src/state/application/hooks.ts +++ b/src/state/application/hooks.ts @@ -16,6 +16,9 @@ import { updateETHPrice, updatePrommETHPrice, updateKNCPrice, + PopupType, + PopupContentTxn, + PopupContentSimple, } from './actions' import { getPercentChange, getBlockFromTimestamp } from 'utils' import { useDeepCompareEffect } from 'react-use' @@ -115,17 +118,49 @@ export function useTrueSightUnsubscribeModalToggle(): () => void { } // returns a function that allows adding a popup -export function useAddPopup(): (content: PopupContent, key?: string) => void { +function useAddPopup(): ( + content: PopupContent, + popupType: PopupType, + key?: string, + removeAfterMs?: number | null, +) => void { const dispatch = useDispatch() return useCallback( - (content: PopupContent, key?: string) => { - dispatch(addPopup({ content, key })) + (content: PopupContent, popupType: PopupType, key?: string, removeAfterMs?: number | null) => { + dispatch(addPopup({ content, key, popupType, removeAfterMs })) }, [dispatch], ) } +export enum NotificationType { + SUCCESS, + ERROR, + WARNING, +} +// simple notify with text and description +export const useNotify = () => { + const addPopup = useAddPopup() + return useCallback( + (data: PopupContentSimple, removeAfterMs = 3000) => { + addPopup(data, PopupType.SIMPLE, data.title, removeAfterMs) + }, + [addPopup], + ) +} + +// popup notify transaction +export const useTransactionNotify = () => { + const addPopup = useAddPopup() + return useCallback( + (data: PopupContentTxn) => { + addPopup(data, PopupType.TRANSACTION, data.hash) + }, + [addPopup], + ) +} + // returns a function that allows removing a popup via its key export function useRemovePopup(): (key: string) => void { const dispatch = useDispatch() diff --git a/src/state/application/reducer.ts b/src/state/application/reducer.ts index e50aef7524..65ea9ab058 100644 --- a/src/state/application/reducer.ts +++ b/src/state/application/reducer.ts @@ -12,9 +12,16 @@ import { updateChainIdWhenNotConnected, setGasPrice, updatePrommETHPrice, + PopupType, } from './actions' -type PopupList = Array<{ key: string; show: boolean; content: PopupContent; removeAfterMs: number | null }> +type PopupList = Array<{ + key: string + show: boolean + content: PopupContent + removeAfterMs: number | null + popupType: PopupType +}> type ETHPrice = { currentPrice?: string @@ -63,13 +70,14 @@ export default createReducer(initialState, builder => .addCase(setOpenModal, (state, action) => { state.openModal = action.payload }) - .addCase(addPopup, (state, { payload: { content, key, removeAfterMs = 15000 } }) => { + .addCase(addPopup, (state, { payload: { content, key, removeAfterMs = 15000, popupType } }) => { state.popupList = (key ? state.popupList.filter(popup => popup.key !== key) : state.popupList).concat([ { key: key || nanoid(), show: true, content, removeAfterMs, + popupType, }, ]) }) diff --git a/src/state/farms/promm/hooks.ts b/src/state/farms/promm/hooks.ts index 6e88bc9f5b..bf48ba121a 100644 --- a/src/state/farms/promm/hooks.ts +++ b/src/state/farms/promm/hooks.ts @@ -33,25 +33,6 @@ export const useProMMFarms = () => { return useSelector((state: AppState) => state.prommFarms) } -export const useProMMFarmsFetchOnlyOne = () => { - const { data: farms } = useProMMFarms() - const getProMMFarm = useGetProMMFarms() - - const firstRender = useRef(true) - - const { chainId } = useActiveWeb3React() - const previousChainId = usePrevious(chainId) - - useEffect(() => { - if ((!Object.keys(farms).length && firstRender.current) || chainId !== previousChainId) { - getProMMFarm() - firstRender.current = false - } - }, [previousChainId, farms, getProMMFarm, chainId]) - - return farms -} - export const useGetProMMFarms = () => { const dispatch = useAppDispatch() const { chainId, account } = useActiveWeb3React() @@ -185,6 +166,25 @@ export const useGetProMMFarms = () => { return getProMMFarms } +export const useProMMFarmsFetchOnlyOne = () => { + const { data: farms } = useProMMFarms() + const getProMMFarm = useGetProMMFarms() + + const firstRender = useRef(true) + + const { chainId } = useActiveWeb3React() + const previousChainId = usePrevious(chainId) + + useEffect(() => { + if ((!Object.keys(farms).length && firstRender.current) || chainId !== previousChainId) { + getProMMFarm() + firstRender.current = false + } + }, [previousChainId, farms, getProMMFarm, chainId]) + + return farms +} + export const useFarmAction = (address: string) => { const addTransactionWithType = useTransactionAdder() const contract = useProMMFarmContract(address) diff --git a/src/state/lists/hooks.ts b/src/state/lists/hooks.ts index b0e4b50f20..c977a122c6 100644 --- a/src/state/lists/hooks.ts +++ b/src/state/lists/hooks.ts @@ -67,14 +67,15 @@ function listToTokenMap(list: TokenList): TokenAddressMap { const TRANSFORMED_DEFAULT_TOKEN_LIST = listToTokenMap(DEFAULT_TOKEN_LIST) // returns all downloaded current lists -export function useAllLists(): { +export type ListType = { readonly [url: string]: { readonly current: TokenList | null readonly pendingUpdate: TokenList | null readonly loadingRequestId: string | null readonly error: string | null } -} { +} +export function useAllLists(): ListType { return useSelector(state => state.lists.byUrl) } diff --git a/src/state/transactions/updater.tsx b/src/state/transactions/updater.tsx index 4459c6861f..a5db7f70e3 100644 --- a/src/state/transactions/updater.tsx +++ b/src/state/transactions/updater.tsx @@ -4,7 +4,7 @@ import { TransactionReceipt } from '@ethersproject/abstract-provider' import { ethers } from 'ethers' import { BigNumber } from '@ethersproject/bignumber' import { useActiveWeb3React } from '../../hooks' -import { useAddPopup, useBlockNumber } from '../application/hooks' +import { NotificationType, useBlockNumber, useTransactionNotify } from '../application/hooks' import { AppDispatch, AppState } from '../index' import { checkedTransaction, finalizeTransaction } from './actions' import { AGGREGATOR_ROUTER_SWAPPED_EVENT_TOPIC } from 'constants/index' @@ -42,7 +42,6 @@ export default function Updater(): null { const transactions = useMemo(() => (chainId ? state[chainId] ?? {} : {}), [chainId, state]) // show popup on confirm - const addPopup = useAddPopup() const parseTransactionType = useCallback( (receipt: TransactionReceipt): string | undefined => { @@ -97,6 +96,7 @@ export default function Updater(): null { [transactions], ) const { mixpanelHandler, subgraphMixpanelHandler } = useMixpanel() + const transactionNotify = useTransactionNotify() useEffect(() => { if (!chainId || !library || !lastBlockNumber) return @@ -129,17 +129,12 @@ export default function Updater(): null { }), ) - addPopup( - { - txn: { - hash, - success: receipt.status === 1, - type: parseTransactionType(receipt), - summary: parseTransactionSummary(receipt), - }, - }, + transactionNotify({ hash, - ) + notiType: receipt.status === 1 ? NotificationType.SUCCESS : NotificationType.ERROR, + type: parseTransactionType(receipt), + summary: parseTransactionSummary(receipt), + }) if (receipt.status === 1 && transaction && transaction.arbitrary) { switch (transaction.type) { case 'Swap': { @@ -185,16 +180,7 @@ export default function Updater(): null { }) // eslint-disable-next-line - }, [ - chainId, - library, - transactions, - lastBlockNumber, - dispatch, - addPopup, - parseTransactionSummary, - parseTransactionType, - ]) + }, [chainId, library, transactions, lastBlockNumber, dispatch, parseTransactionSummary, parseTransactionType]) return null } diff --git a/src/theme/index.tsx b/src/theme/index.tsx index 880f38904b..4054d19b68 100644 --- a/src/theme/index.tsx +++ b/src/theme/index.tsx @@ -8,7 +8,7 @@ import styled, { import { useIsDarkMode } from 'state/user/hooks' import { Text, TextProps } from 'rebass' import { Colors } from './styled' -import { Z_INDEXS } from 'styles' +import { Z_INDEXS } from 'constants/styles' export * from './components' @@ -92,10 +92,13 @@ export function colors(darkMode: boolean): Colors { bg20: darkMode ? '#243036' : '#F5F5F5', bg21: darkMode ? 'linear-gradient(90deg, rgba(29, 122, 95, 0.5) 0%, rgba(29, 122, 95, 0) 100%)' - : 'linear-gradient(90deg, rgba(49, 203, 158, 0.15) 0%, rgba(49, 203, 158, 0) 100%)', + : 'linear-gradient(90deg, rgba(49, 203, 158, 0.15) 0%, rgba(49, 203, 158, 0) 100%)', // success bg22: darkMode ? 'linear-gradient(90deg, rgba(255, 83, 123, 0.4) 0%, rgba(255, 83, 123, 0) 100%)' - : 'linear-gradient(90deg, rgba(255, 83, 123, 0.15) 0%, rgba(255, 83, 123, 0) 100%)', + : 'linear-gradient(90deg, rgba(255, 83, 123, 0.15) 0%, rgba(255, 83, 123, 0) 100%)', // error + bg23: darkMode + ? 'linear-gradient(90deg, rgba(255, 153, 1, 0.5) 0%, rgba(255, 153, 1, 0) 100%)' + : 'linear-gradient(90deg, rgba(255, 153, 1, 0.5) 0%, rgba(255, 153, 1, 0) 100%)', // warning //specialty colors modalBG: darkMode ? 'rgba(0,0,0,.425)' : 'rgba(0,0,0,0.3)', diff --git a/src/theme/styled.d.ts b/src/theme/styled.d.ts index 54bbfc7ac8..0f6bca0a24 100644 --- a/src/theme/styled.d.ts +++ b/src/theme/styled.d.ts @@ -51,6 +51,7 @@ export interface Colors { bg20: Color bg21: Color bg22: Color + bg23: Color buttonBlack: Color buttonGray: Color diff --git a/src/components/SearchModal/filtering.ts b/src/utils/filtering.ts similarity index 63% rename from src/components/SearchModal/filtering.ts rename to src/utils/filtering.ts index d01da6b09f..fa7aa1e8c9 100644 --- a/src/components/SearchModal/filtering.ts +++ b/src/utils/filtering.ts @@ -1,5 +1,4 @@ -import { useMemo } from 'react' -import { isAddress } from '../../utils' +import { isAddress } from 'utils' import { Token } from '@kyberswap/ks-sdk-core' import { TokenInfo } from '@uniswap/token-lists' @@ -46,37 +45,3 @@ export function filterTokensWithExactKeyword(tokens const filterExact = result.filter(e => (e.symbol ? e.symbol.toLowerCase() === search.toLowerCase() : true)) // Exact Keyword return filterExact.length ? filterExact : result } - -export function useSortedTokensByQuery(tokens: Token[] | undefined, searchQuery: string): Token[] { - return useMemo(() => { - if (!tokens) { - return [] - } - - const symbolMatch = searchQuery - .toLowerCase() - .split(/\s+/) - .filter(s => s.length > 0) - - if (symbolMatch.length > 1) { - return tokens - } - - const exactMatches: Token[] = [] - const symbolSubtrings: Token[] = [] - const rest: Token[] = [] - - // sort tokens by exact match -> subtring on symbol match -> rest - tokens.map(token => { - if (token.symbol?.toLowerCase() === symbolMatch[0]) { - return exactMatches.push(token) - } else if (token.symbol?.toLowerCase().startsWith(searchQuery.toLowerCase().trim())) { - return symbolSubtrings.push(token) - } else { - return rest.push(token) - } - }) - - return [...exactMatches, ...symbolSubtrings, ...rest] - }, [tokens, searchQuery]) -} diff --git a/src/utils/mumbaiTokenMapping.ts b/src/utils/mumbaiTokenMapping.ts index 539fe50230..a0c06d14e9 100644 --- a/src/utils/mumbaiTokenMapping.ts +++ b/src/utils/mumbaiTokenMapping.ts @@ -1,27 +1,16 @@ +const MapAddress: { [key: string]: string } = { + '0x9c3c9283d3e44854697cd22d3faa240cfb032889': '0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0', + '0xfd1f9381cb641dc76fe8087dbcf8ea84a2c77cbe': '0xdeFA4e8a7bcBA345F687a2f1456F5Edd9CE97202', + '0x19395624c030a11f58e820c3aefb1f5960d9742a': '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + '0x2cec76b26a8d96bf3072d34a01bb3a4ede7c06be': '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0x064b91bda6d178dfe03835de9450bfe78201c43f': '0xdAC17F958D2ee523a2206206994597C13D831ec7', + '0x5e2de02472ac02736b43054f095837725a5870ef': '0x6B175474E89094C44Da98b954EedeAC495271d0F', + '0x326c977e6efc84e512bb9c30f76e30c160ed06fb': '0x514910771AF9Ca656af840dff83E8264EcF986CA', +} export const getMumbaiTokenLogoURL = (address: string) => { let uri - if (address?.toLowerCase() === '0x9c3c9283d3e44854697cd22d3faa240cfb032889') { - address = '0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0' - } - if (address?.toLowerCase() === '0xfd1f9381cb641dc76fe8087dbcf8ea84a2c77cbe') { - address = '0xdeFA4e8a7bcBA345F687a2f1456F5Edd9CE97202' - } - if (address?.toLowerCase() === '0x19395624c030a11f58e820c3aefb1f5960d9742a') { - address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' - } - if (address?.toLowerCase() === '0x2cec76b26a8d96bf3072d34a01bb3a4ede7c06be') { - address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' - } - if (address?.toLowerCase() === '0x064b91bda6d178dfe03835de9450bfe78201c43f') { - address = '0xdAC17F958D2ee523a2206206994597C13D831ec7' - } - if (address?.toLowerCase() === '0x5e2de02472ac02736b43054f095837725a5870ef') { - address = '0x6B175474E89094C44Da98b954EedeAC495271d0F' - } - if (address?.toLowerCase() === '0x326c977e6efc84e512bb9c30f76e30c160ed06fb') { - address = '0x514910771AF9Ca656af840dff83E8264EcF986CA' - } + if (address) address = MapAddress[address.toLowerCase()] || address if (!uri) { uri = `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/${address}/logo.png`