diff --git a/.env.develop b/.env.develop index fec09cb3922..af8a2bb3196 100644 --- a/.env.develop +++ b/.env.develop @@ -1,4 +1,5 @@ # feature flags +REACT_APP_FEATURE_LIMIT_ORDERS=true # mixpanel REACT_APP_MIXPANEL_TOKEN=1c1369f6ea23a6404bac41b42817cc4b diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 453573f5fc3..175c202f4dd 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -987,6 +987,9 @@ "slippage": "Slippage: %{slippageFormatted}" } }, + "limitOrder": { + "heading": "Limit Order" + }, "modals": { "assetSearch": { "myAssets": "My Assets", diff --git a/src/components/Acknowledgement/Acknowledgement.tsx b/src/components/Acknowledgement/Acknowledgement.tsx index 6a27d22fb3e..7c841f46c0d 100644 --- a/src/components/Acknowledgement/Acknowledgement.tsx +++ b/src/components/Acknowledgement/Acknowledgement.tsx @@ -1,6 +1,5 @@ -import type { ComponentWithAs, IconProps, ResponsiveValue, ThemeTypings } from '@chakra-ui/react' +import type { BoxProps, ComponentWithAs, IconProps, ThemeTypings } from '@chakra-ui/react' import { Box, Button, Checkbox, Link, useColorModeValue } from '@chakra-ui/react' -import type * as CSS from 'csstype' import type { AnimationDefinition, MotionStyle } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion' import type { InterpolationOptions } from 'node-polyglot' @@ -92,7 +91,7 @@ type AcknowledgementProps = { buttonTranslation?: string | [string, InterpolationOptions] icon?: ComponentWithAs<'svg', IconProps> disableButton?: boolean - position?: ResponsiveValue + boxProps?: BoxProps } type StreamingAcknowledgementProps = Omit & { @@ -115,7 +114,7 @@ export const Acknowledgement = ({ buttonTranslation, disableButton, icon: CustomIcon, - position = 'relative', + boxProps, }: AcknowledgementProps) => { const translate = useTranslate() const [isShowing, setIsShowing] = useState(false) @@ -152,10 +151,11 @@ export const Acknowledgement = ({ return ( {shouldShowAcknowledgement && ( diff --git a/src/components/MultiHopTrade/MultiHopTrade.tsx b/src/components/MultiHopTrade/MultiHopTrade.tsx index 5d84586edd9..68016a67d1f 100644 --- a/src/components/MultiHopTrade/MultiHopTrade.tsx +++ b/src/components/MultiHopTrade/MultiHopTrade.tsx @@ -1,20 +1,22 @@ import type { AssetId } from '@shapeshiftoss/caip' +import { assertUnreachable } from '@shapeshiftoss/utils' import { AnimatePresence } from 'framer-motion' -import { memo, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' -import { MemoryRouter, Route, Switch, useLocation, useParams } from 'react-router-dom' +import { MemoryRouter, Route, Switch, useHistory, useLocation, useParams } from 'react-router-dom' import { selectAssetById } from 'state/slices/assetsSlice/selectors' import { tradeInput } from 'state/slices/tradeInputSlice/tradeInputSlice' import { tradeQuoteSlice } from 'state/slices/tradeQuoteSlice/tradeQuoteSlice' import { useAppDispatch, useAppSelector } from 'state/store' +import { LimitOrder } from './components/LimitOrder/LimitOrder' import { MultiHopTradeConfirm } from './components/MultiHopTradeConfirm/MultiHopTradeConfirm' import { QuoteListRoute } from './components/QuoteList/QuoteListRoute' import { Claim } from './components/TradeInput/components/Claim/Claim' import { TradeInput } from './components/TradeInput/TradeInput' import { VerifyAddresses } from './components/VerifyAddresses/VerifyAddresses' import { useGetTradeQuotes } from './hooks/useGetTradeQuotes/useGetTradeQuotes' -import { TradeRoutePaths } from './types' +import { TradeInputTab, TradeRoutePaths } from './types' const TradeRouteEntries = [ TradeRoutePaths.Input, @@ -22,6 +24,7 @@ const TradeRouteEntries = [ TradeRoutePaths.VerifyAddresses, TradeRoutePaths.QuoteList, TradeRoutePaths.Claim, + TradeRoutePaths.LimitOrder, ] export type TradeCardProps = { @@ -80,6 +83,7 @@ type TradeRoutesProps = { } const TradeRoutes = memo(({ isCompact }: TradeRoutesProps) => { + const history = useHistory() const location = useLocation() const dispatch = useAppDispatch() @@ -102,12 +106,35 @@ const TradeRoutes = memo(({ isCompact }: TradeRoutesProps) => { ) }, [location.pathname]) + const handleChangeTab = useCallback( + (newTab: TradeInputTab) => { + switch (newTab) { + case TradeInputTab.Trade: + history.push(TradeRoutePaths.Input) + break + case TradeInputTab.LimitOrder: + history.push(TradeRoutePaths.LimitOrder) + break + case TradeInputTab.Claim: + history.push(TradeRoutePaths.Claim) + break + default: + assertUnreachable(newTab) + } + }, + [history], + ) + return ( <> - + @@ -122,7 +149,14 @@ const TradeRoutes = memo(({ isCompact }: TradeRoutesProps) => { /> - + + + + diff --git a/src/components/MultiHopTrade/components/LimitOrder/LimitOrder.tsx b/src/components/MultiHopTrade/components/LimitOrder/LimitOrder.tsx new file mode 100644 index 00000000000..d378f512cb9 --- /dev/null +++ b/src/components/MultiHopTrade/components/LimitOrder/LimitOrder.tsx @@ -0,0 +1,194 @@ +import { isLedger } from '@shapeshiftoss/hdwallet-ledger' +import type { Asset } from '@shapeshiftoss/types' +import type { FormEvent } from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useFormContext } from 'react-hook-form' +import { ethereum, fox } from 'test/mocks/assets' +import { WarningAcknowledgement } from 'components/Acknowledgement/Acknowledgement' +import { useReceiveAddress } from 'components/MultiHopTrade/hooks/useReceiveAddress' +import { TradeInputTab } from 'components/MultiHopTrade/types' +import { WalletActions } from 'context/WalletProvider/actions' +import { useErrorHandler } from 'hooks/useErrorToast/useErrorToast' +import { useWallet } from 'hooks/useWallet/useWallet' +import type { ParameterModel } from 'lib/fees/parameters/types' +import { selectIsSnapshotApiQueriesPending, selectVotingPower } from 'state/apis/snapshot/selectors' +import { + selectHasUserEnteredAmount, + selectIsAnyAccountMetadataLoadedForChainId, +} from 'state/slices/selectors' +import { + selectActiveQuote, + selectBuyAmountAfterFeesCryptoPrecision, + selectBuyAmountAfterFeesUserCurrency, + selectIsTradeQuoteRequestAborted, + selectShouldShowTradeQuoteOrAwaitInput, +} from 'state/slices/tradeQuoteSlice/selectors' +import { useAppSelector } from 'state/store' + +import { useAccountIds } from '../../hooks/useAccountIds' +import { SharedTradeInput } from '../SharedTradeInput/SharedTradeInput' + +const votingPowerParams: { feeModel: ParameterModel } = { feeModel: 'SWAPPER' } +const acknowledgementBoxProps = { + display: 'flex', + justifyContent: 'center', +} + +type LimitOrderProps = { + tradeInputRef: React.MutableRefObject + isCompact?: boolean + onChangeTab: (newTab: TradeInputTab) => void +} + +// TODO: Implement me +const CollapsibleLimitOrderList = () => <> + +export const LimitOrder = ({ isCompact, tradeInputRef, onChangeTab }: LimitOrderProps) => { + const { + dispatch: walletDispatch, + state: { isConnected, isDemoWallet, wallet }, + } = useWallet() + + const { handleSubmit } = useFormContext() + const { showErrorToast } = useErrorHandler() + const { manualReceiveAddress, walletReceiveAddress } = useReceiveAddress({ + fetchUnchainedAddress: Boolean(wallet && isLedger(wallet)), + }) + const { sellAssetAccountId, buyAssetAccountId, setSellAssetAccountId, setBuyAssetAccountId } = + useAccountIds() + + const [isConfirmationLoading, setIsConfirmationLoading] = useState(false) + const [shouldShowWarningAcknowledgement, setShouldShowWarningAcknowledgement] = useState(false) + + const buyAmountAfterFeesCryptoPrecision = useAppSelector(selectBuyAmountAfterFeesCryptoPrecision) + const buyAmountAfterFeesUserCurrency = useAppSelector(selectBuyAmountAfterFeesUserCurrency) + const shouldShowTradeQuoteOrAwaitInput = useAppSelector(selectShouldShowTradeQuoteOrAwaitInput) + const isSnapshotApiQueriesPending = useAppSelector(selectIsSnapshotApiQueriesPending) + const isTradeQuoteRequestAborted = useAppSelector(selectIsTradeQuoteRequestAborted) + const hasUserEnteredAmount = useAppSelector(selectHasUserEnteredAmount) + const votingPower = useAppSelector(state => selectVotingPower(state, votingPowerParams)) + const sellAsset = ethereum // TODO: Implement me + const buyAsset = fox // TODO: Implement me + const activeQuote = useAppSelector(selectActiveQuote) + const isAnyAccountMetadataLoadedForChainIdFilter = useMemo( + () => ({ chainId: sellAsset.chainId }), + [sellAsset.chainId], + ) + const isAnyAccountMetadataLoadedForChainId = useAppSelector(state => + selectIsAnyAccountMetadataLoadedForChainId(state, isAnyAccountMetadataLoadedForChainIdFilter), + ) + + const isVotingPowerLoading = useMemo( + () => isSnapshotApiQueriesPending && votingPower === undefined, + [isSnapshotApiQueriesPending, votingPower], + ) + + const isLoading = useMemo( + () => + // No account meta loaded for that chain + !isAnyAccountMetadataLoadedForChainId || + (!shouldShowTradeQuoteOrAwaitInput && !isTradeQuoteRequestAborted) || + isConfirmationLoading || + // Only consider snapshot API queries as pending if we don't have voting power yet + // if we do, it means we have persisted or cached (both stale) data, which is enough to let the user continue + // as we are optimistic and don't want to be waiting for a potentially very long time for the snapshot API to respond + isVotingPowerLoading, + [ + isAnyAccountMetadataLoadedForChainId, + shouldShowTradeQuoteOrAwaitInput, + isTradeQuoteRequestAborted, + isConfirmationLoading, + isVotingPowerLoading, + ], + ) + + const warningAcknowledgementMessage = useMemo(() => { + // TODO: Implement me + return '' + }, []) + + const headerRightContent = useMemo(() => { + // TODO: Implement me + return <> + }, []) + + const setBuyAsset = useCallback((_asset: Asset) => { + // TODO: Implement me + }, []) + const setSellAsset = useCallback((_asset: Asset) => { + // TODO: Implement me + }, []) + const handleSwitchAssets = useCallback(() => { + // TODO: Implement me + }, []) + + const handleConnect = useCallback(() => { + walletDispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }) + }, [walletDispatch]) + + const onSubmit = useCallback(() => { + // No preview happening if wallet isn't connected i.e is using the demo wallet + if (!isConnected || isDemoWallet) { + return handleConnect() + } + + setIsConfirmationLoading(true) + try { + // TODO: Implement me + } catch (e) { + showErrorToast(e) + } + + setIsConfirmationLoading(false) + }, [handleConnect, isConnected, isDemoWallet, showErrorToast]) + + const handleFormSubmit = useMemo(() => handleSubmit(onSubmit), [handleSubmit, onSubmit]) + + const handleWarningAcknowledgementSubmit = useCallback(() => { + handleFormSubmit() + }, [handleFormSubmit]) + + const handleTradeQuoteConfirm = useCallback( + (e: FormEvent) => { + e.preventDefault() + handleFormSubmit() + }, + [handleFormSubmit], + ) + + return ( + + + + ) +} diff --git a/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInput.tsx b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInput.tsx new file mode 100644 index 00000000000..2a08e62a3d1 --- /dev/null +++ b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInput.tsx @@ -0,0 +1,124 @@ +import { Card, Center, Flex, useMediaQuery } from '@chakra-ui/react' +import type { AccountId } from '@shapeshiftoss/caip' +import type { TradeQuote } from '@shapeshiftoss/swapper' +import type { Asset } from '@shapeshiftoss/types' +import type { FormEvent } from 'react' +import type { TradeInputTab } from 'components/MultiHopTrade/types' +import { breakpoints } from 'theme/theme' + +import { SharedTradeInputBody } from '../SharedTradeInput/SharedTradeInputBody' +import { SharedTradeInputHeader } from '../SharedTradeInput/SharedTradeInputHeader' +import { ConfirmSummary } from '../TradeInput/components/ConfirmSummary' +import { WithLazyMount } from '../TradeInput/components/WithLazyMount' +import { useSharedHeight } from '../TradeInput/hooks/useSharedHeight' + +type SharedTradeInputProps = { + activeQuote: TradeQuote | undefined + buyAmountAfterFeesCryptoPrecision: string | undefined + buyAmountAfterFeesUserCurrency: string | undefined + buyAsset: Asset + hasUserEnteredAmount: boolean + headerRightContent: JSX.Element + buyAssetAccountId: AccountId | undefined + sellAssetAccountId: AccountId | undefined + isCompact: boolean | undefined + isLoading: boolean + manualReceiveAddress: string | undefined + sellAsset: Asset + sideComponent: React.ComponentType + tradeInputRef: React.RefObject + tradeInputTab: TradeInputTab + walletReceiveAddress: string | undefined + handleSwitchAssets: () => void + onChangeTab: (newTab: TradeInputTab) => void + onSubmit: (e: FormEvent) => void + setBuyAsset: (asset: Asset) => void + setBuyAssetAccountId: (accountId: string) => void + setSellAsset: (asset: Asset) => void + setSellAssetAccountId: (accountId: string) => void +} + +export const SharedTradeInput: React.FC = ({ + activeQuote, + buyAmountAfterFeesCryptoPrecision, + buyAmountAfterFeesUserCurrency, + buyAsset, + hasUserEnteredAmount, + headerRightContent, + buyAssetAccountId, + sellAssetAccountId, + isCompact, + isLoading, + manualReceiveAddress, + sellAsset, + sideComponent, + tradeInputTab, + tradeInputRef, + walletReceiveAddress, + handleSwitchAssets, + onChangeTab, + onSubmit, + setBuyAsset, + setBuyAssetAccountId, + setSellAsset, + setSellAssetAccountId, +}) => { + const totalHeight = useSharedHeight(tradeInputRef) + const [isSmallerThanXl] = useMediaQuery(`(max-width: ${breakpoints.xl})`, { ssr: false }) + + return ( + +
+ + + + + + +
+
+ ) +} diff --git a/src/components/MultiHopTrade/components/TradeInput/components/TradeInputBody.tsx b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputBody.tsx similarity index 81% rename from src/components/MultiHopTrade/components/TradeInput/components/TradeInputBody.tsx rename to src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputBody.tsx index 87b823e4a58..1a27daecaaa 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/TradeInputBody.tsx +++ b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputBody.tsx @@ -8,6 +8,7 @@ import { Stack, } from '@chakra-ui/react' import type { AccountId } from '@shapeshiftoss/caip' +import type { TradeQuote } from '@shapeshiftoss/swapper' import type { Asset } from '@shapeshiftoss/types' import { positiveOrZero } from '@shapeshiftoss/utils' import { useCallback, useEffect, useMemo } from 'react' @@ -21,22 +22,14 @@ import { isToken } from 'lib/utils' import { selectHasUserEnteredAmount, selectHighestMarketCapFeeAsset, - selectInputBuyAsset, - selectInputSellAsset, selectIsAccountMetadataLoadingByAccountId, selectIsAccountsMetadataLoading, selectWalletConnectedChainIds, } from 'state/slices/selectors' -import { tradeInput } from 'state/slices/tradeInputSlice/tradeInputSlice' -import { - selectActiveQuote, - selectBuyAmountAfterFeesCryptoPrecision, - selectBuyAmountAfterFeesUserCurrency, -} from 'state/slices/tradeQuoteSlice/selectors' -import { useAppDispatch, useAppSelector } from 'state/store' +import { useAppSelector } from 'state/store' -import { TradeAssetInput } from '../../TradeAssetInput' -import { SellAssetInput } from './SellAssetInput' +import { TradeAssetInput } from '../TradeAssetInput' +import { SellAssetInput } from '../TradeInput/components/SellAssetInput' const formControlProps = { borderRadius: 0, @@ -46,65 +39,63 @@ const formControlProps = { const arrowDownIcon = const emptyPercentOptions: number[] = [] -type TradeInputBodyProps = { +type SharedTradeInputBodyProps = { + activeQuote: TradeQuote | undefined isLoading: boolean | undefined manualReceiveAddress: string | undefined - initialSellAssetAccountId: AccountId | undefined - initialBuyAssetAccountId: AccountId | undefined + sellAssetAccountId: AccountId | undefined + buyAssetAccountId: AccountId | undefined setSellAssetAccountId: (accountId: AccountId) => void setBuyAssetAccountId: (accountId: AccountId) => void + buyAmountAfterFeesCryptoPrecision: string | undefined + buyAmountAfterFeesUserCurrency: string | undefined + buyAsset: Asset + sellAsset: Asset + setBuyAsset: (asset: Asset) => void + setSellAsset: (asset: Asset) => void + handleSwitchAssets: () => void } -export const TradeInputBody = ({ +export const SharedTradeInputBody = ({ + buyAmountAfterFeesCryptoPrecision, + buyAmountAfterFeesUserCurrency, + buyAsset, + sellAsset, isLoading, manualReceiveAddress, - initialSellAssetAccountId, - initialBuyAssetAccountId, + sellAssetAccountId, + buyAssetAccountId, setSellAssetAccountId, setBuyAssetAccountId, -}: TradeInputBodyProps) => { + setBuyAsset, + setSellAsset, + handleSwitchAssets, + activeQuote, +}: SharedTradeInputBodyProps) => { const translate = useTranslate() - const dispatch = useAppDispatch() const { state: { wallet }, } = useWallet() - const isAccountMetadataLoadingByAccountId = useAppSelector( - selectIsAccountMetadataLoadingByAccountId, - ) - const isAccountsMetadataLoading = useAppSelector(selectIsAccountsMetadataLoading) - const buyAmountAfterFeesCryptoPrecision = useAppSelector(selectBuyAmountAfterFeesCryptoPrecision) - const buyAmountAfterFeesUserCurrency = useAppSelector(selectBuyAmountAfterFeesUserCurrency) const walletConnectedChainIds = useAppSelector(selectWalletConnectedChainIds) const defaultSellAsset = useAppSelector(selectHighestMarketCapFeeAsset) const hasUserEnteredAmount = useAppSelector(selectHasUserEnteredAmount) - const buyAsset = useAppSelector(selectInputBuyAsset) - const sellAsset = useAppSelector(selectInputSellAsset) + const isAccountsMetadataLoading = useAppSelector(selectIsAccountsMetadataLoading) + const isAccountMetadataLoadingByAccountId = useAppSelector( + selectIsAccountMetadataLoadingByAccountId, + ) const walletSupportsBuyAssetChain = useWalletSupportsChain(buyAsset.chainId, wallet) const buyAssetSearch = useModal('buyTradeAssetSearch') const sellAssetSearch = useModal('sellTradeAssetSearch') - const setBuyAsset = useCallback( - (asset: Asset) => dispatch(tradeInput.actions.setBuyAsset(asset)), - [dispatch], - ) - const setSellAsset = useCallback( - (asset: Asset) => dispatch(tradeInput.actions.setSellAsset(asset)), - [dispatch], - ) - const handleSwitchAssets = useCallback( - () => dispatch(tradeInput.actions.switchAssets()), - [dispatch], - ) - const percentOptions = useMemo(() => { if (!sellAsset?.assetId) return [] if (!isToken(sellAsset.assetId)) return [] return [1] }, [sellAsset.assetId]) - const activeQuote = useAppSelector(selectActiveQuote) + const inputOutputDifferenceDecimalPercentage = useInputOutputDifferenceDecimalPercentage(activeQuote) @@ -174,7 +165,7 @@ export const TradeInputBody = ({ return ( void +} + +export const SharedTradeInputHeader = ({ + initialTab, + rightContent, + onChangeTab, +}: SharedTradeInputHeaderProps) => { + const translate = useTranslate() + const [selectedTab, setSelectedTab] = useState(initialTab) + + const enableBridgeClaims = useFeatureFlag('ArbitrumBridgeClaims') + const enableLimitOrders = useFeatureFlag('LimitOrders') + + const handleChangeTab = useCallback( + (newTab: TradeInputTab) => { + setSelectedTab(newTab) + onChangeTab(newTab) + }, + [onChangeTab], + ) + + const handleClickTrade = useCallback(() => { + handleChangeTab(TradeInputTab.Trade) + }, [handleChangeTab]) + + const handleClickLimitOrder = useCallback(() => { + handleChangeTab(TradeInputTab.LimitOrder) + }, [handleChangeTab]) + + const handleClickClaim = useCallback(() => { + handleChangeTab(TradeInputTab.Claim) + }, [handleChangeTab]) + + return ( + + + + + {translate('navBar.trade')} + + {enableLimitOrders && ( + + {translate('limitOrder.heading')} + + )} + {enableBridgeClaims && ( + + {translate('bridge.claim')} + + )} + + + {rightContent} + + + + ) +} diff --git a/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx b/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx index 0f3907bb17b..8f0735ecceb 100644 --- a/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx @@ -1,9 +1,9 @@ -import { Box, Card, Center, Flex, Stack, useMediaQuery } from '@chakra-ui/react' import { isLedger } from '@shapeshiftoss/hdwallet-ledger' import { isArbitrumBridgeTradeQuote } from '@shapeshiftoss/swapper/dist/swappers/ArbitrumBridgeSwapper/getTradeQuote/getTradeQuote' import type { ThorTradeQuote } from '@shapeshiftoss/swapper/dist/swappers/ThorchainSwapper/getThorTradeQuote/getTradeQuote' +import type { Asset } from '@shapeshiftoss/types' import type { FormEvent } from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslate } from 'react-polyglot' import { useHistory } from 'react-router' @@ -16,7 +16,6 @@ import { MessageOverlay } from 'components/MessageOverlay/MessageOverlay' import { getMixpanelEventData } from 'components/MultiHopTrade/helpers' import { useReceiveAddress } from 'components/MultiHopTrade/hooks/useReceiveAddress' import { TradeInputTab, TradeRoutePaths } from 'components/MultiHopTrade/types' -import { SlideTransition } from 'components/SlideTransition' import { WalletActions } from 'context/WalletProvider/actions' import { useErrorHandler } from 'hooks/useErrorToast/useErrorToast' import { useWallet } from 'hooks/useWallet/useWallet' @@ -28,11 +27,15 @@ import { isKeplrHDWallet } from 'lib/utils' import { selectIsSnapshotApiQueriesPending, selectVotingPower } from 'state/apis/snapshot/selectors' import { selectHasUserEnteredAmount, + selectInputBuyAsset, selectInputSellAsset, selectIsAnyAccountMetadataLoadedForChainId, } from 'state/slices/selectors' +import { tradeInput } from 'state/slices/tradeInputSlice/tradeInputSlice' import { selectActiveQuote, + selectBuyAmountAfterFeesCryptoPrecision, + selectBuyAmountAfterFeesUserCurrency, selectFirstHop, selectIsTradeQuoteRequestAborted, selectIsUnsafeActiveQuote, @@ -40,49 +43,63 @@ import { } from 'state/slices/tradeQuoteSlice/selectors' import { tradeQuoteSlice } from 'state/slices/tradeQuoteSlice/tradeQuoteSlice' import { useAppDispatch, useAppSelector } from 'state/store' -import { breakpoints } from 'theme/theme' import { useAccountIds } from '../../hooks/useAccountIds' +import { SharedTradeInput } from '../SharedTradeInput/SharedTradeInput' import { CollapsibleQuoteList } from './components/CollapsibleQuoteList' -import { ConfirmSummary } from './components/ConfirmSummary' -import { TradeInputBody } from './components/TradeInputBody' -import { TradeInputHeader } from './components/TradeInputHeader' -import { WithLazyMount } from './components/WithLazyMount' -import { useSharedHeight } from './hooks/useSharedHeight' +import { TradeSettingsMenu } from './components/TradeSettingsMenu' const votingPowerParams: { feeModel: ParameterModel } = { feeModel: 'SWAPPER' } +const acknowledgementBoxProps = { + display: 'flex', + justifyContent: 'center', +} const STREAM_ACKNOWLEDGEMENT_MINIMUM_TIME_THRESHOLD = 1_000 * 60 * 5 type TradeInputProps = { tradeInputRef: React.MutableRefObject isCompact?: boolean + onChangeTab: (newTab: TradeInputTab) => void } -export const TradeInput = ({ isCompact, tradeInputRef }: TradeInputProps) => { +export const TradeInput = ({ isCompact, tradeInputRef, onChangeTab }: TradeInputProps) => { const { dispatch: walletDispatch, state: { isConnected, isDemoWallet, wallet }, } = useWallet() - const bodyRef = useRef(null) - const totalHeight = useSharedHeight(tradeInputRef) - const [isSmallerThanXl] = useMediaQuery(`(max-width: ${breakpoints.xl})`, { ssr: false }) + const { handleSubmit } = useFormContext() const dispatch = useAppDispatch() + const translate = useTranslate() const mixpanel = getMixPanel() const history = useHistory() const { showErrorToast } = useErrorHandler() + const { manualReceiveAddress, walletReceiveAddress } = useReceiveAddress({ + fetchUnchainedAddress: Boolean(wallet && isLedger(wallet)), + }) + const { sellAssetAccountId, buyAssetAccountId, setSellAssetAccountId, setBuyAssetAccountId } = + useAccountIds() + const [isConfirmationLoading, setIsConfirmationLoading] = useState(false) const [shouldShowWarningAcknowledgement, setShouldShowWarningAcknowledgement] = useState(false) const [shouldShowStreamingAcknowledgement, setShouldShowStreamingAcknowledgement] = useState(false) const [shouldShowArbitrumBridgeAcknowledgement, setShouldShowArbitrumBridgeAcknowledgement] = useState(false) - const isKeplr = useMemo(() => !!wallet && isKeplrHDWallet(wallet), [wallet]) - const sellAsset = useAppSelector(selectInputSellAsset) + + const buyAmountAfterFeesCryptoPrecision = useAppSelector(selectBuyAmountAfterFeesCryptoPrecision) + const buyAmountAfterFeesUserCurrency = useAppSelector(selectBuyAmountAfterFeesUserCurrency) + const shouldShowTradeQuoteOrAwaitInput = useAppSelector(selectShouldShowTradeQuoteOrAwaitInput) + const isSnapshotApiQueriesPending = useAppSelector(selectIsSnapshotApiQueriesPending) + const isTradeQuoteRequestAborted = useAppSelector(selectIsTradeQuoteRequestAborted) + const hasUserEnteredAmount = useAppSelector(selectHasUserEnteredAmount) const tradeQuoteStep = useAppSelector(selectFirstHop) const isUnsafeQuote = useAppSelector(selectIsUnsafeActiveQuote) - + const votingPower = useAppSelector(state => selectVotingPower(state, votingPowerParams)) + const sellAsset = useAppSelector(selectInputSellAsset) + const buyAsset = useAppSelector(selectInputBuyAsset) + const activeQuote = useAppSelector(selectActiveQuote) const isAnyAccountMetadataLoadedForChainIdFilter = useMemo( () => ({ chainId: sellAsset.chainId }), [sellAsset.chainId], @@ -91,35 +108,13 @@ export const TradeInput = ({ isCompact, tradeInputRef }: TradeInputProps) => { selectIsAnyAccountMetadataLoadedForChainId(state, isAnyAccountMetadataLoadedForChainIdFilter), ) - const shouldShowTradeQuoteOrAwaitInput = useAppSelector(selectShouldShowTradeQuoteOrAwaitInput) - const isTradeQuoteRequestAborted = useAppSelector(selectIsTradeQuoteRequestAborted) - const hasUserEnteredAmount = useAppSelector(selectHasUserEnteredAmount) - const activeQuote = useAppSelector(selectActiveQuote) - - const isSnapshotApiQueriesPending = useAppSelector(selectIsSnapshotApiQueriesPending) - const votingPower = useAppSelector(state => selectVotingPower(state, votingPowerParams)) + const isKeplr = useMemo(() => !!wallet && isKeplrHDWallet(wallet), [wallet]) const isVotingPowerLoading = useMemo( () => isSnapshotApiQueriesPending && votingPower === undefined, [isSnapshotApiQueriesPending, votingPower], ) - const { - sellAssetAccountId: initialSellAssetAccountId, - buyAssetAccountId: initialBuyAssetAccountId, - setSellAssetAccountId, - setBuyAssetAccountId, - } = useAccountIds() - - const useReceiveAddressArgs = useMemo( - () => ({ - fetchUnchainedAddress: Boolean(wallet && isLedger(wallet)), - }), - [wallet], - ) - - const { manualReceiveAddress, walletReceiveAddress } = useReceiveAddress(useReceiveAddressArgs) - const isLoading = useMemo( () => // No account meta loaded for that chain @@ -139,12 +134,63 @@ export const TradeInput = ({ isCompact, tradeInputRef }: TradeInputProps) => { ], ) - const translate = useTranslate() const overlayTitle = useMemo( () => translate('trade.swappingComingSoonForWallet', { walletName: 'Keplr' }), [translate], ) + useEffect(() => { + // Reset the trade warning if the active quote has changed, i.e. a better quote has come in and the + // user has not yet confirmed the previous one + if (shouldShowWarningAcknowledgement) setShouldShowWarningAcknowledgement(false) + // We also need to reset the streaming acknowledgement if the active quote has changed + if (shouldShowStreamingAcknowledgement) setShouldShowStreamingAcknowledgement(false) + if (shouldShowArbitrumBridgeAcknowledgement) setShouldShowArbitrumBridgeAcknowledgement(false) + // We need to ignore changes to shouldShowWarningAcknowledgement or this effect will react to itself + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeQuote]) + + const isEstimatedExecutionTimeOverThreshold = useMemo(() => { + if (!tradeQuoteStep?.estimatedExecutionTimeMs) return false + + if (tradeQuoteStep?.estimatedExecutionTimeMs >= STREAM_ACKNOWLEDGEMENT_MINIMUM_TIME_THRESHOLD) + return true + + return false + }, [tradeQuoteStep?.estimatedExecutionTimeMs]) + + const warningAcknowledgementMessage = useMemo(() => { + const recommendedMinimumCryptoBaseUnit = (activeQuote as ThorTradeQuote) + ?.recommendedMinimumCryptoBaseUnit + if (!recommendedMinimumCryptoBaseUnit) return translate('warningAcknowledgement.unsafeTrade') + const recommendedMinimumCryptoPrecision = fromBaseUnit( + recommendedMinimumCryptoBaseUnit, + sellAsset.precision, + ) + const message = translate('trade.errors.unsafeQuote', { + symbol: sellAsset.symbol, + recommendedMin: recommendedMinimumCryptoPrecision, + }) + return message + }, [activeQuote, sellAsset.precision, sellAsset.symbol, translate]) + + const headerRightContent = useMemo(() => { + return + }, [isCompact, isLoading]) + + const setBuyAsset = useCallback( + (asset: Asset) => dispatch(tradeInput.actions.setBuyAsset(asset)), + [dispatch], + ) + const setSellAsset = useCallback( + (asset: Asset) => dispatch(tradeInput.actions.setSellAsset(asset)), + [dispatch], + ) + const handleSwitchAssets = useCallback( + () => dispatch(tradeInput.actions.switchAssets()), + [dispatch], + ) + const handleConnect = useCallback(() => { walletDispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }) }, [walletDispatch]) @@ -193,37 +239,8 @@ export const TradeInput = ({ isCompact, tradeInputRef }: TradeInputProps) => { wallet, ]) - useEffect(() => { - // Reset the trade warning if the active quote has changed, i.e. a better quote has come in and the - // user has not yet confirmed the previous one - if (shouldShowWarningAcknowledgement) setShouldShowWarningAcknowledgement(false) - // We also need to reset the streaming acknowledgement if the active quote has changed - if (shouldShowStreamingAcknowledgement) setShouldShowStreamingAcknowledgement(false) - if (shouldShowArbitrumBridgeAcknowledgement) setShouldShowArbitrumBridgeAcknowledgement(false) - // We need to ignore changes to shouldShowWarningAcknowledgement or this effect will react to itself - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeQuote]) - - const isEstimatedExecutionTimeOverThreshold = useMemo(() => { - if (!tradeQuoteStep?.estimatedExecutionTimeMs) return false - - if (tradeQuoteStep?.estimatedExecutionTimeMs >= STREAM_ACKNOWLEDGEMENT_MINIMUM_TIME_THRESHOLD) - return true - - return false - }, [tradeQuoteStep?.estimatedExecutionTimeMs]) - const handleFormSubmit = useMemo(() => handleSubmit(onSubmit), [handleSubmit, onSubmit]) - const handleChangeTab = useCallback( - (newTab: TradeInputTab) => { - if (newTab === TradeInputTab.Claim) { - history.push(TradeRoutePaths.Claim) - } - }, - [history], - ) - // If the warning acknowledgement is shown, we need to handle the submit differently because we might want to show the streaming acknowledgement const handleWarningAcknowledgementSubmit = useCallback(() => { if (activeQuote?.isStreaming && isEstimatedExecutionTimeOverThreshold) @@ -247,92 +264,58 @@ export const TradeInput = ({ isCompact, tradeInputRef }: TradeInputProps) => { [isUnsafeQuote, activeQuote, isEstimatedExecutionTimeOverThreshold, handleFormSubmit], ) - const warningAcknowledgementMessage = (() => { - const recommendedMinimumCryptoBaseUnit = (activeQuote as ThorTradeQuote) - ?.recommendedMinimumCryptoBaseUnit - if (!recommendedMinimumCryptoBaseUnit) return translate('warningAcknowledgement.unsafeTrade') - const recommendedMinimumCryptoPrecision = fromBaseUnit( - recommendedMinimumCryptoBaseUnit, - sellAsset.precision, - ) - const message = translate('trade.errors.unsafeQuote', { - symbol: sellAsset.symbol, - recommendedMin: recommendedMinimumCryptoPrecision, - }) - return message - })() - return ( - -
- - - - - - - - - - - - - - - - - - - -
-
+ + + + + +
) } diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/Claim.tsx b/src/components/MultiHopTrade/components/TradeInput/components/Claim/Claim.tsx index a7ceca2a8f8..adc392c698d 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/Claim.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/Claim/Claim.tsx @@ -1,10 +1,10 @@ import { Card } from '@chakra-ui/react' import type { TxStatus } from '@shapeshiftoss/unchained-client' import { useCallback, useState } from 'react' -import { MemoryRouter, Route, Switch, useHistory, useLocation } from 'react-router' -import { TradeInputTab, TradeRoutePaths } from 'components/MultiHopTrade/types' +import { MemoryRouter, Route, Switch, useLocation } from 'react-router' +import { TradeInputTab } from 'components/MultiHopTrade/types' -import { TradeInputHeader } from '../TradeInputHeader' +import { SharedTradeInputHeader } from '../../../SharedTradeInput/SharedTradeInputHeader' import { ClaimConfirm } from './ClaimConfirm' import { ClaimSelect } from './ClaimSelect' import { ClaimStatus } from './ClaimStatus' @@ -13,23 +13,13 @@ import { ClaimRoutePaths } from './types' const ClaimRouteEntries = [ClaimRoutePaths.Select, ClaimRoutePaths.Confirm, ClaimRoutePaths.Status] -export const Claim = ({ isCompact }: { isCompact?: boolean }) => { +export const Claim = ({ onChangeTab }: { onChangeTab: (newTab: TradeInputTab) => void }) => { const location = useLocation() - const history = useHistory() const [activeClaim, setActiveClaim] = useState() const [claimTxHash, setClaimTxHash] = useState() const [claimTxStatus, setClaimTxStatus] = useState() - const handleChangeTab = useCallback( - (newTab: TradeInputTab) => { - if (newTab === TradeInputTab.Trade) { - history.push(TradeRoutePaths.Input) - } - }, - [history], - ) - const renderClaimSelect = useCallback(() => { return }, []) @@ -64,12 +54,7 @@ export const Claim = ({ isCompact }: { isCompact?: boolean }) => { - + void -} - -const TradeInputHeaderRightComponent = ({ - isCompact, - isLoading, -}: TradeInputHeaderRightComponentProps) => { - const [isSmallerThanXl] = useMediaQuery(`(max-width: ${breakpoints.xl})`, { ssr: false }) - const activeQuote = useAppSelector(selectActiveQuote) - const activeSwapperName = useAppSelector(selectActiveSwapperName) - const isTradeQuoteApiQueryPending = useAppSelector(selectIsTradeQuoteApiQueryPending) - - const pollingInterval = useMemo(() => { - if (!activeSwapperName) return DEFAULT_GET_TRADE_QUOTE_POLLING_INTERVAL - return swappers[activeSwapperName]?.pollingInterval ?? DEFAULT_GET_TRADE_QUOTE_POLLING_INTERVAL - }, [activeSwapperName]) - - const isRefetching = useMemo( - () => Boolean(activeSwapperName && isTradeQuoteApiQueryPending[activeSwapperName] === true), - [activeSwapperName, isTradeQuoteApiQueryPending], - ) - - return ( - <> - {activeQuote && (isCompact || isSmallerThanXl) && ( - - )} - - - ) -} - -export const TradeInputHeader = ({ - initialTab, - isCompact, - isLoading, - onChangeTab, -}: FakeTabHeaderProps) => { - const translate = useTranslate() - const [selectedTab, setSelectedTab] = useState(initialTab) - - const enableBridgeClaims = useFeatureFlag('ArbitrumBridgeClaims') - - const handleClickTrade = useCallback(() => { - setSelectedTab(TradeInputTab.Trade) - onChangeTab(TradeInputTab.Trade) - }, [onChangeTab]) - - const handleClickClaim = useCallback(() => { - setSelectedTab(TradeInputTab.Claim) - onChangeTab(TradeInputTab.Claim) - }, [onChangeTab]) - - const rightComponent = useMemo(() => { - return (() => { - switch (selectedTab) { - case TradeInputTab.Trade: - return - case TradeInputTab.Claim: - return null - default: - assertUnreachable(selectedTab) - } - })() - }, [selectedTab, isLoading, isCompact]) - - return ( - - - - - {translate('navBar.trade')} - - {enableBridgeClaims && ( - - {translate('bridge.claim')} - - )} - - - {rightComponent} - - - - ) -} diff --git a/src/components/MultiHopTrade/components/TradeInput/components/TradeSettingsMenu.tsx b/src/components/MultiHopTrade/components/TradeInput/components/TradeSettingsMenu.tsx new file mode 100644 index 00000000000..94629868090 --- /dev/null +++ b/src/components/MultiHopTrade/components/TradeInput/components/TradeSettingsMenu.tsx @@ -0,0 +1,41 @@ +import { useMediaQuery } from '@chakra-ui/react' +import { DEFAULT_GET_TRADE_QUOTE_POLLING_INTERVAL, swappers } from '@shapeshiftoss/swapper' +import { useMemo } from 'react' +import { selectIsTradeQuoteApiQueryPending } from 'state/apis/swapper/selectors' +import { selectActiveQuote, selectActiveSwapperName } from 'state/slices/tradeQuoteSlice/selectors' +import { useAppSelector } from 'state/store' +import { breakpoints } from 'theme/theme' + +import { SlippagePopover } from '../../SlippagePopover' +import { CountdownSpinner } from './TradeQuotes/components/CountdownSpinner' + +type TradeSettingsMenuProps = { + isCompact: boolean | undefined + isLoading: boolean +} + +export const TradeSettingsMenu = ({ isCompact, isLoading }: TradeSettingsMenuProps) => { + const [isSmallerThanXl] = useMediaQuery(`(max-width: ${breakpoints.xl})`, { ssr: false }) + const activeQuote = useAppSelector(selectActiveQuote) + const activeSwapperName = useAppSelector(selectActiveSwapperName) + const isTradeQuoteApiQueryPending = useAppSelector(selectIsTradeQuoteApiQueryPending) + + const pollingInterval = useMemo(() => { + if (!activeSwapperName) return DEFAULT_GET_TRADE_QUOTE_POLLING_INTERVAL + return swappers[activeSwapperName]?.pollingInterval ?? DEFAULT_GET_TRADE_QUOTE_POLLING_INTERVAL + }, [activeSwapperName]) + + const isRefetching = useMemo( + () => Boolean(activeSwapperName && isTradeQuoteApiQueryPending[activeSwapperName] === true), + [activeSwapperName, isTradeQuoteApiQueryPending], + ) + + return ( + <> + {activeQuote && (isCompact || isSmallerThanXl) && ( + + )} + + + ) +} diff --git a/src/components/MultiHopTrade/types.ts b/src/components/MultiHopTrade/types.ts index 6cbe72bb8c2..90fdb3ccddd 100644 --- a/src/components/MultiHopTrade/types.ts +++ b/src/components/MultiHopTrade/types.ts @@ -9,6 +9,7 @@ export enum TradeRoutePaths { VerifyAddresses = '/trade/verify-addresses', QuoteList = '/trade/quote-list', Claim = '/trade/claim', + LimitOrder = '/trade/limit-order', } export type GetReceiveAddressArgs = { @@ -35,4 +36,5 @@ export type TradeQuoteInputCommonArgs = Pick< export enum TradeInputTab { Trade = 'trade', Claim = 'claim', + LimitOrder = 'limitOrder', } diff --git a/src/config.ts b/src/config.ts index f6eca47acca..f7fbb98421d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -177,6 +177,7 @@ const validators = { REACT_APP_FEATURE_FOX_PAGE: bool({ default: false }), REACT_APP_FEATURE_FOX_PAGE_RFOX: bool({ default: false }), REACT_APP_FEATURE_FOX_PAGE_FOX_SECTION: bool({ default: true }), + REACT_APP_FEATURE_LIMIT_ORDERS: bool({ default: false }), } function reporter({ errors }: envalid.ReporterOptions) { diff --git a/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Deposit/components/Deposit.tsx b/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Deposit/components/Deposit.tsx index bb53ff68ad9..842e8403d77 100644 --- a/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Deposit/components/Deposit.tsx +++ b/src/features/defi/providers/thorchain-savers/components/ThorchainSaversManager/Deposit/components/Deposit.tsx @@ -1,9 +1,11 @@ +import type { ResponsiveValue } from '@chakra-ui/react' import { Skeleton, useToast } from '@chakra-ui/react' import type { AccountId } from '@shapeshiftoss/caip' import { fromAccountId, fromAssetId, thorchainAssetId, toAssetId } from '@shapeshiftoss/caip' import { ContractType, getOrCreateContractByType } from '@shapeshiftoss/contracts' import type { Asset } from '@shapeshiftoss/types' import { useQueryClient } from '@tanstack/react-query' +import type * as CSS from 'csstype' import type { DepositValues } from 'features/defi/components/Deposit/Deposit' import { Deposit as ReusableDeposit } from 'features/defi/components/Deposit/Deposit' import type { @@ -76,6 +78,9 @@ type DepositProps = StepComponentProps & { } const percentOptions = [0.25, 0.5, 0.75, 1] +const infoAcknowledgementBoxProps = { + position: 'static' as ResponsiveValue, +} export const Deposit: React.FC = ({ accountId, @@ -861,7 +866,7 @@ export const Deposit: React.FC = ({ onAcknowledge={handleAcknowledge} shouldShowAcknowledgement={shouldShowInfoAcknowledgement} setShouldShowAcknowledgement={setShouldShowInfoAcknowledgement} - position='static' + boxProps={infoAcknowledgementBoxProps} >