diff --git a/.env b/.env index e96408c6644..b5fe4545e9b 100644 --- a/.env +++ b/.env @@ -309,5 +309,7 @@ VITE_FEATURE_KATANA=true # Yield.xyz Feature Flag VITE_FEATURE_YIELD_XYZ=false +VITE_FEATURE_YIELDS_PAGE=false VITE_YIELD_XYZ_API_KEY=06903960-e442-4870-81eb-03ff3ad4c035 VITE_FEATURE_YIELD_MULTI_ACCOUNT=false +VITE_FEATURE_EARN_TAB=false diff --git a/.env.development b/.env.development index 2c0d8104231..7ee5b06ee99 100644 --- a/.env.development +++ b/.env.development @@ -97,3 +97,5 @@ VITE_FEATURE_AVNU_SWAP=true VITE_FEATURE_NEAR=true VITE_FEATURE_KATANA=true VITE_FEATURE_YIELD_XYZ=true +VITE_FEATURE_YIELDS_PAGE=true +VITE_FEATURE_EARN_TAB=true diff --git a/src/Routes/RoutesCommon.tsx b/src/Routes/RoutesCommon.tsx index a3af4334438..ea0ff606aed 100644 --- a/src/Routes/RoutesCommon.tsx +++ b/src/Routes/RoutesCommon.tsx @@ -28,6 +28,7 @@ import { History } from '@/pages/History/History' import { RFOX } from '@/pages/RFOX/RFOX' import { TCYNavIndicator } from '@/pages/TCY/components/TCYNavIndicator' import { TCY } from '@/pages/TCY/tcy' +import { EarnTab } from '@/pages/Trade/tabs/EarnTab' import { LimitTab } from '@/pages/Trade/tabs/LimitTab' import { RampTab } from '@/pages/Trade/tabs/RampTab' import { TradeTab } from '@/pages/Trade/tabs/TradeTab' @@ -37,6 +38,8 @@ export const TRADE_ROUTE_ASSET_SPECIFIC = '/trade/:chainId/:assetSubId/:sellChainId/:sellAssetSubId/:sellAmountCryptoBaseUnit' export const LIMIT_ORDER_ROUTE_ASSET_SPECIFIC = '/limit/:chainId/:assetSubId/:sellChainId/:sellAssetSubId/:sellAmountCryptoBaseUnit/:limitPriceMode/:limitPriceDirection/:limitPrice' +export const EARN_ROUTE_ASSET_SPECIFIC = + '/earn/:sellChainId/:sellAssetSubId/:yieldId/:sellAmountCryptoBaseUnit' const Dashboard = makeSuspenseful( lazy(() => @@ -246,7 +249,7 @@ export const routes: Route[] = [ category: RouteCategory.Featured, priority: 3, mobileNav: false, - disable: !getConfig().VITE_FEATURE_YIELD_XYZ, + disable: !getConfig().VITE_FEATURE_YIELD_XYZ || !getConfig().VITE_FEATURE_YIELDS_PAGE, }, { path: '/ramp/*', @@ -426,4 +429,18 @@ export const routes: Route[] = [ }, ], }, + { + path: '/earn/*', + label: '', + hideDesktop: true, + main: EarnTab, + disable: !getConfig().VITE_FEATURE_EARN_TAB, + routes: [ + { + path: EARN_ROUTE_ASSET_SPECIFIC, + main: EarnTab, + hide: true, + }, + ], + }, ] diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 94c411e4918..a926e3a4742 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -48,10 +48,12 @@ "insufficientAmountForGas": "Not enough %{assetSymbol}.%{chainSymbol} to cover gas", "invalidAddress": "Invalid Address", "deposit": "Deposit", + "supply": "Supply", "withdraw": "Withdraw", "withdrawal": "Withdrawal", "claim": "Claim", "claiming": "Claiming...", + "confirming": "Confirming...", "withdrawAndClaim": "Withdraw & Claim", "overview": "Overview", "connectWallet": "Connect Wallet", @@ -519,7 +521,8 @@ "markets": "Markets", "tokens": "Tokens", "swap": "Swap", - "yields": "Yields" + "yields": "Yields", + "earn": "Earn" }, "shapeShiftMenu": { "products": "Products", @@ -2670,6 +2673,7 @@ "pageSubtitle": "Discover and manage yield opportunities across multiple chains", "enter": "Enter", "exit": "Exit", + "enterAsset": "Enter %{asset}", "yield": "Yield", "apy": "APY", "apr": "APR", @@ -2681,49 +2685,27 @@ "noYields": "No yield opportunities available", "connectWallet": "Connect a wallet to view yields", "stats": "Stats", - "minDeposit": "Min Deposit", - "mechanics": "Mechanics", + "minEnter": "Min Enter", "rewardSchedule": "Reward Schedule", "gasToken": "Gas Token", - "transactionSteps": "Transaction Steps", - "stepApprove": "Approve", - "stepApproveDesc": "Approve the token for deposit", - "stepDeposit": "Deposit", - "stepDepositDesc": "Deposit your assets into the strategy", - "stepComplete": "Complete", - "stepCompleteDesc": "Your deposit is complete and earning yield", - "gasFeeNote": "Gas fees are paid in the native token of the network", - "yourInfo": "Your Position", - "activeBalance": "Active Balance", - "entering": "Entering", + "entering": "Entering...", + "exiting": "Exiting...", + "unstaking": "Unstaking", + "availableDate": "available %{date}", "withdrawable": "Withdrawable", - "locked": "Locked", - "enterDisabled": "Enter is currently disabled for this yield opportunity", - "exitDisabled": "Exit is currently disabled for this yield opportunity", "type": "Type", - "inputToken": "Input Token", - "netApy": "Net APY", - "grossApy": "Gross APY", "totalValue": "Total Value", "myPosition": "My Position", + "myPositions": "My Positions", "myValidatorPosition": "My %{validator} Position", - "vault": "Vault", - "lending": "Lending", - "yourDeposits": "Your Deposits", - "positions": "Positions", - "opportunities": "Opportunities", - "yields": "Yields", - "earnUpTo": "You could earn up to %{apy}% on your balance", + "earnUpToOnBalance": "You could earn up to %{apy} on your balance", "startEarning": "Start earning", "maxApy": "Max APY", + "nativeStaking": "Native Staking", "validator": "Validator", - "validatorBreakdown": "Validator Breakdown", - "staked": "Staked", - "exiting": "Exiting", + "entered": "Entered", "claimable": "Claimable", "loadingQuote": "Loading Quote...", - "depositing": "Depositing...", - "withdrawing": "Withdrawing...", "selectValidator": "Select Validator", "allValidators": "All Validators", "myValidators": "My Validators", @@ -2739,20 +2721,25 @@ "nameZA": "Name (Z-A)", "allNetworks": "All Networks", "allProviders": "All Providers", + "allTypes": "All Types", "showAll": "Show All", "searchValidator": "Search for validator", - "depositYourToken": "Deposit your %{symbol} to start earning yield securely.", + "enterYourToken": "Enter your %{symbol} to start earning yield securely.", "noActiveValidators": "You don't have any active validators yet.", - "confirming": "Confirming...", "success": "Success!", "transactions": "Transactions", "currentApy": "Current APY", "estYearlyEarnings": "Est. Yearly Earnings", "allPositions": "All Positions", "switch": "Switch", - "supplySymbol": "Supply %{symbol}", - "withdrawSymbol": "Withdraw %{symbol}", + "enterSymbol": "Enter %{symbol}", + "exitSymbol": "Exit %{symbol}", "claimSymbol": "Claim %{symbol}", + "stakeSymbol": "Stake %{symbol}", + "unstakeSymbol": "Unstake %{symbol}", + "depositSymbol": "Deposit %{symbol}", + "withdrawSymbol": "Withdraw %{symbol}", + "claimableRewards": "Claimable rewards", "noActivePositions": "You do not have any active yield positions.", "connectWalletPositions": "Connect a wallet to view your active yield positions.", "view": "View", @@ -2769,31 +2756,36 @@ "assetYields": "%{asset} Yields", "opportunitiesAvailable": "%{count} opportunities available", "noYieldsMatchingFilters": "No yields found matching filters.", - "activeDeposits": "Active Deposits", + "activePositions": "Active Positions", "acrossPositions": "Across %{count} positions", "availableToEarn": "Available to Earn", "idleAssetsEarning": "Idle assets that could be earning up to %{apy}% APY", "potentialEarnings": "Potential Earnings", + "potentialEarningsTooltip": "Estimated yearly earnings based on your idle balance × best available APY for each asset.", + "availableToEarnTooltip": "Total idle balance across assets that have yield opportunities available.", + "apyTooltip": "Annual percentage yield shown by the provider. Rates may fluctuate over time.", "perYear": "/yr", + "earningsPerYear": "%{amount} %{symbol} /yr", + "recommendedForYou": "Recommended for you", "earn": "Earn", "myBalance": "My Balance", "providers": "Providers", - "deposit": "Deposit", - "withdraw": "Withdraw", - "successDeposit": "You successfully deposited %{amount} %{symbol}", - "successWithdraw": "You successfully withdrew %{amount} %{symbol}", + "successEnter": "You successfully entered %{amount} %{symbol}", + "successExit": "You successfully exited %{amount} %{symbol}", "successClaim": "You successfully claimed %{amount} %{symbol}", + "viewPosition": "View Position", + "via": "via", + "resetAllowance": "Reset Allowance", + "transactionNumber": "Transaction %{number}", "loading": { "signInWallet": "Sign in Wallet", - "signNow": "Sign now...", "waiting": "Waiting", "done": "Done", - "preparing": "Preparing...", + "failed": "Failed", "preparingTransaction": "Preparing transaction..." }, "errors": { "walletNotConnected": "Wallet not connected", - "unsupportedYieldNetwork": "Unsupported yield network", "broadcastFailed": "Failed to broadcast transaction", "transactionFailedTitle": "Transaction failed", "transactionFailedDescription": "Please try again.", @@ -2808,5 +2800,40 @@ "quoteFailedTitle": "Quote failed", "quoteFailedDescription": "Unable to get a quote for this transaction. Please try again." } + }, + "earn": { + "enterFrom": "Enter from", + "stakeAmount": "Stake Amount", + "selectYieldOpportunity": "Select yield opportunity", + "selectYieldFor": "Select yield for %{asset}", + "noYieldsAvailable": "No yield opportunities available for %{asset}", + "estimatedYearlyEarnings": "Est. Yearly Earnings", + "yieldType": "Yield Type", + "yieldTypes": { + "native-staking": "Native Staking", + "pooled-staking": "Pooled Staking", + "liquid-staking": "Liquid Staking", + "staking": "Staking", + "lending": "Lending", + "vault": "Vault", + "restaking": "Restaking" + }, + "confirmEarn": "Confirm Stake", + "earnWith": "Earn with", + "belowMinimum": "Below minimum deposit", + "minimumDeposit": "Minimum Deposit", + "explainers": { + "liquidStakingReceive": "You'll receive %{symbol} which you can trade at any time", + "liquidStakingTrade": "You can trade your liquid staking token at any time", + "liquidStakingWithdraw": "When withdrawing, your assets will be available immediately", + "rewardsSchedule": "Rewards are distributed every %{schedule} and accrue automatically", + "stakingUnbonding": "When unstaking, there is a %{days} day unbonding period before tokens are available", + "restakingYield": "Restaking rewards accrue to your position automatically", + "restakingWithdraw": "When withdrawing, your assets may have an unbonding period", + "vaultYield": "Yield accrues to your position automatically", + "vaultWithdraw": "When withdrawing, your assets will be available immediately", + "lendingYield": "Interest accrues to your position automatically", + "lendingWithdraw": "When withdrawing, your assets will be available immediately" + } } -} \ No newline at end of file +} diff --git a/src/components/Layout/Header/Header.tsx b/src/components/Layout/Header/Header.tsx index 712933d3154..c0f10f7a42a 100644 --- a/src/components/Layout/Header/Header.tsx +++ b/src/components/Layout/Header/Header.tsx @@ -56,7 +56,7 @@ const rightHStackSpacingSx = { base: 2, lg: 4 } const searchBoxMaxWSx = { base: 'auto', lg: '400px' } const searchBoxMinWSx = { base: 'auto', xl: '300px' } -const tradeSubMenuItems = [ +const baseTradeSubMenuItems = [ { label: 'navBar.swap', path: '/trade', icon: TbRefresh }, { label: 'limitOrder.heading', path: '/limit', icon: TbLayersSelected }, { label: 'fiatRamps.buy', path: '/ramp/buy', icon: TbCreditCard }, @@ -110,6 +110,15 @@ export const Header = memo(() => { const isActionCenterEnabled = useFeatureFlag('ActionCenter') const isNewWalletManagerEnabled = useFeatureFlag('NewWalletManager') const isRfoxFoxEcosystemPageEnabled = useFeatureFlag('RfoxFoxEcosystemPage') + const isEarnTabEnabled = useFeatureFlag('EarnTab') + + const tradeSubMenuItems = useMemo( + () => + isEarnTabEnabled + ? [...baseTradeSubMenuItems, { label: 'navBar.earn', path: '/earn', icon: TbTrendingUp }] + : baseTradeSubMenuItems, + [isEarnTabEnabled], + ) const { degradedChainIds } = useDiscoverAccounts() const hasWallet = Boolean(walletInfo?.deviceId) diff --git a/src/components/Layout/Header/NavBar/NavigationDropdown.tsx b/src/components/Layout/Header/NavBar/NavigationDropdown.tsx index 1a080746a93..00a393cc2a5 100644 --- a/src/components/Layout/Header/NavBar/NavigationDropdown.tsx +++ b/src/components/Layout/Header/NavBar/NavigationDropdown.tsx @@ -70,7 +70,8 @@ export const NavigationDropdown = ({ label, items, defaultPath }: NavigationDrop return ( currentPath.startsWith('/trade') || currentPath.startsWith('/limit') || - currentPath.startsWith('/ramp') + currentPath.startsWith('/ramp') || + currentPath.startsWith('/earn') ) } diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx new file mode 100644 index 00000000000..65151b1b2ad --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -0,0 +1,345 @@ +import { Avatar, Box, Button, Flex, HStack, Skeleton, Text, VStack } from '@chakra-ui/react' +import { memo, useCallback, useEffect, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import { SharedConfirm } from '../SharedConfirm/SharedConfirm' +import { EarnRoutePaths } from './types' + +import { Amount } from '@/components/Amount/Amount' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' +import { getTransactionButtonText } from '@/lib/yieldxyz/utils' +import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' +import { YieldAssetFlow } from '@/pages/Yields/components/YieldAssetFlow' +import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' +import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { useYields } from '@/react-queries/queries/yieldxyz/useYields' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { + selectAccountIdByAccountNumberAndChainId, + selectAssetById, + selectMarketDataByFilter, +} from '@/state/slices/selectors' +import { + selectInputSellAmountCryptoPrecision, + selectInputSellAsset, + selectSelectedYieldId, + selectSellAccountId, +} from '@/state/slices/tradeEarnInputSlice/selectors' +import { useAppSelector } from '@/state/store' + +export const EarnConfirm = memo(() => { + const translate = useTranslate() + const navigate = useNavigate() + + const sellAsset = useAppSelector(selectInputSellAsset) + const sellAmountCryptoPrecision = useAppSelector(selectInputSellAmountCryptoPrecision) + const selectedYieldId = useAppSelector(selectSelectedYieldId) + const sellAccountId = useAppSelector(selectSellAccountId) + + const { data: yieldsData, isLoading: isLoadingYields } = useYields() + + const selectedYield = useMemo(() => { + if (!selectedYieldId || !yieldsData?.byId) return undefined + return yieldsData.byId[selectedYieldId] + }, [selectedYieldId, yieldsData?.byId]) + + const hasValidState = Boolean( + selectedYieldId && sellAmountCryptoPrecision && bnOrZero(sellAmountCryptoPrecision).gt(0), + ) + + useEffect(() => { + if (!isLoadingYields && !hasValidState) { + navigate(EarnRoutePaths.Input, { replace: true }) + } + }, [isLoadingYields, hasValidState, navigate]) + + // Fallback to account 0 if no account selected + const yieldChainId = selectedYield?.chainId + const fallbackAccountId = useAppSelector(state => { + if (sellAccountId) return undefined + if (!yieldChainId) return undefined + return selectAccountIdByAccountNumberAndChainId(state)[0]?.[yieldChainId] + }) + const accountIdToUse = sellAccountId ?? fallbackAccountId + + const requiresValidatorSelection = selectedYield?.mechanics.requiresValidatorSelection ?? false + + const { data: validators } = useYieldValidators(selectedYieldId ?? '', requiresValidatorSelection) + const { data: providers } = useYieldProviders() + + const selectedValidatorAddress = useMemo(() => { + if (!requiresValidatorSelection || !validators?.length) return undefined + const chainId = selectedYield?.chainId + const defaultAddress = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined + if (defaultAddress) { + const defaultValidator = validators.find(v => v.address === defaultAddress) + if (defaultValidator) return defaultValidator.address + } + const preferred = validators.find(v => v.preferred) + return preferred?.address ?? validators[0]?.address + }, [requiresValidatorSelection, validators, selectedYield?.chainId]) + + const selectedValidator = useMemo(() => { + if (!selectedValidatorAddress || !validators?.length) return undefined + return validators.find(v => v.address === selectedValidatorAddress) + }, [selectedValidatorAddress, validators]) + + const sellAssetFromState = useAppSelector(state => + selectAssetById(state, sellAsset?.assetId ?? ''), + ) + + const { price: sellAssetUserCurrencyRate } = + useAppSelector(state => selectMarketDataByFilter(state, { assetId: sellAsset?.assetId })) || {} + + const sellAmountUserCurrency = useMemo(() => { + if (!sellAmountCryptoPrecision || !sellAssetUserCurrencyRate) return undefined + return bnOrZero(sellAmountCryptoPrecision).times(sellAssetUserCurrencyRate).toString() + }, [sellAmountCryptoPrecision, sellAssetUserCurrencyRate]) + + const handleBack = useCallback(() => navigate(EarnRoutePaths.Input), [navigate]) + + const apy = useMemo( + () => (selectedYield ? (selectedYield.rewardRate?.total ?? 0) * 100 : 0), + [selectedYield], + ) + + const estimatedYearlyEarnings = useMemo(() => { + if (!selectedYield || !sellAmountCryptoPrecision) return undefined + const apyDecimal = selectedYield.rewardRate?.total ?? 0 + const amount = bnOrZero(sellAmountCryptoPrecision) + if (amount.isZero()) return undefined + return amount.times(apyDecimal).decimalPlaces(6).toString() + }, [selectedYield, sellAmountCryptoPrecision]) + + const { + step, + transactionSteps, + displaySteps, + isSubmitting, + activeStepIndex, + canSubmit, + handleConfirm, + isQuoteLoading, + quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, + } = useYieldTransactionFlow({ + yieldItem: selectedYield, + action: 'enter', + amount: sellAmountCryptoPrecision, + assetSymbol: sellAsset?.symbol ?? '', + onClose: handleBack, + isOpen: Boolean(selectedYield), + validatorAddress: selectedValidatorAddress, + accountId: accountIdToUse, + }) + + // Align loading states with YieldEnterModal + const isQuoteActive = isQuoteLoading || isAllowanceCheckPending + const isLoading = isLoadingYields || isQuoteActive + + // Use stepsToShow pattern from YieldEnterModal - show transactionSteps once execution starts + const stepsToShow = activeStepIndex >= 0 ? transactionSteps : displaySteps + + const confirmButtonText = useMemo(() => { + // Use the current step's type/title for a clean button label (e.g., "Enter", "Approve") + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { + const currentStep = transactionSteps[activeStepIndex] + return getTransactionButtonText(currentStep.type, currentStep.originalTitle) + } + // USDT reset required before other transactions + if (isUsdtResetRequired) { + return translate('yieldXYZ.resetAllowance') + } + // Before execution starts, use the first CREATED transaction from quoteData + const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED') + if (firstCreatedTx) { + return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) + } + // Fallback states + if (isLoading) return translate('common.loadingText') + return translate('yieldXYZ.enter') + }, [activeStepIndex, transactionSteps, isUsdtResetRequired, quoteData, isLoading, translate]) + + const providerInfo = useMemo(() => { + if (selectedValidator) { + return { name: selectedValidator.name, logoURI: selectedValidator.logoURI } + } + if (selectedYield) { + const provider = providers?.[selectedYield.providerId] + if (provider) { + return { name: provider.name, logoURI: provider.logoURI } + } + return { name: selectedYield.metadata.name, logoURI: selectedYield.metadata.logoURI } + } + return null + }, [selectedValidator, selectedYield, providers]) + + if (!selectedYield) { + return ( + + {translate('earn.selectYieldOpportunity')} + + + } + footerContent={null} + onBack={handleBack} + headerTranslation='earn.confirmEarn' + /> + ) + } + + if (step === ModalStep.Success) { + return ( + + + + } + footerContent={null} + onBack={handleBack} + headerTranslation='yieldXYZ.success' + /> + ) + } + + const bodyContent = ( + + + + + + + + {translate('common.amount')} + + {isLoading ? ( + + ) : ( + + )} + + + + + {translate('common.apy')} + + {isLoading ? ( + + ) : ( + + {apy.toFixed(2)}% + + )} + + + {estimatedYearlyEarnings && ( + + + {translate('yieldXYZ.estEarnings')} + + {isLoading ? ( + + ) : ( + + + {translate('yieldXYZ.earningsPerYear', { + amount: estimatedYearlyEarnings, + symbol: sellAsset?.symbol ?? '', + })} + + {sellAmountUserCurrency && ( + + + + )} + + )} + + )} + + {providerInfo && ( + + + {selectedValidator + ? translate('yieldXYZ.validator') + : translate('yieldXYZ.provider')} + + + + + {providerInfo.name} + + + + )} + + + {stepsToShow.length > 0 && ( + + + + )} + + + ) + + const footerContent = ( + + + + ) + + return ( + + ) +}) diff --git a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx new file mode 100644 index 00000000000..3fcda2ca603 --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx @@ -0,0 +1,536 @@ +import { Box, Flex, Stack, useMediaQuery } from '@chakra-ui/react' +import type { AccountId, AssetId } from '@shapeshiftoss/caip' +import { cosmosChainId, ethAssetId, fromAccountId } from '@shapeshiftoss/caip' +import type { Asset } from '@shapeshiftoss/types' +import { isToken } from '@shapeshiftoss/utils' +import { useQuery } from '@tanstack/react-query' +import type { FormEvent } from 'react' +import { memo, useCallback, useEffect, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import type { SideComponentProps } from '../SharedTradeInput/SharedTradeInput' +import { SharedTradeInput } from '../SharedTradeInput/SharedTradeInput' +import { SellAssetInput } from '../TradeInput/components/SellAssetInput' +import { EarnFooter } from './components/EarnFooter' +import { YieldSelector } from './components/YieldSelector' +import { EarnRoutePaths } from './types' + +import { TradeAssetSelect } from '@/components/AssetSelection/AssetSelection' +import { FormDivider } from '@/components/FormDivider' +import { TradeInputTab } from '@/components/MultiHopTrade/types' +import { useDebounce } from '@/hooks/useDebounce/useDebounce' +import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' +import { useModal } from '@/hooks/useModal/useModal' +import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero, positiveOrZero } from '@/lib/bignumber/bignumber' +import { fromBaseUnit } from '@/lib/math' +import { enterYield } from '@/lib/yieldxyz/api' +import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' +import { useYields } from '@/react-queries/queries/yieldxyz/useYields' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { + selectAccountIdByAccountNumberAndChainId, + selectAssetById, + selectFeeAssetByChainId, + selectMarketDataByAssetIdUserCurrency, + selectMarketDataByFilter, + selectPortfolioCryptoPrecisionBalanceByFilter, +} from '@/state/slices/selectors' +import { + selectHasUserEnteredAmount, + selectInputSellAmountCryptoBaseUnit, + selectInputSellAmountCryptoPrecision, + selectInputSellAmountUserCurrency, + selectInputSellAsset, + selectIsInputtingFiatSellAmount, + selectSelectedYieldId, + selectSellAccountId, +} from '@/state/slices/tradeEarnInputSlice/selectors' +import { tradeEarnInput } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice' +import { useAppDispatch, useAppSelector } from '@/state/store' +import { breakpoints } from '@/theme/theme' + +export type EarnInputProps = { + onChangeTab: (newTab: TradeInputTab) => void + tradeInputRef: React.RefObject + defaultSellAssetId?: string + defaultYieldId?: string + defaultSellAmountCryptoBaseUnit?: string +} + +const SELL_AMOUNT_DEBOUNCE_MS = 500 +const EmptySideComponent: React.FC = () => null + +export const EarnInput = memo( + ({ + onChangeTab, + tradeInputRef, + defaultSellAssetId, + defaultYieldId, + defaultSellAmountCryptoBaseUnit, + }: EarnInputProps) => { + const translate = useTranslate() + const navigate = useNavigate() + const dispatch = useAppDispatch() + const [isSmallerThanMd] = useMediaQuery(`(max-width: ${breakpoints.md})`, { ssr: false }) + const { + number: { toFiat }, + } = useLocaleFormatter() + + const { + state: { isConnected, wallet }, + } = useWallet() + + const sellAsset = useAppSelector(selectInputSellAsset) + const sellAccountId = useAppSelector(selectSellAccountId) + const sellAmountCryptoPrecision = useAppSelector(selectInputSellAmountCryptoPrecision) + const sellAmountUserCurrency = useAppSelector(selectInputSellAmountUserCurrency) + const isInputtingFiatSellAmount = useAppSelector(selectIsInputtingFiatSellAmount) + const hasUserEnteredAmount = useAppSelector(selectHasUserEnteredAmount) + const selectedYieldId = useAppSelector(selectSelectedYieldId) + + const { data: yieldsData, isLoading: isLoadingYields } = useYields() + + const ethAsset = useAppSelector(state => selectAssetById(state, ethAssetId)) + const defaultSellAsset = useAppSelector(state => + defaultSellAssetId ? selectAssetById(state, defaultSellAssetId as AssetId) : undefined, + ) + + useEffect(() => { + if (defaultSellAsset && !sellAsset.assetId) { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(defaultSellAsset)) + } else if (!sellAsset.assetId && ethAsset) { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(ethAsset)) + } + }, [sellAsset.assetId, ethAsset, defaultSellAsset, dispatch]) + + // Set default accountId (account 0) on mount if not already set + const sellAssetChainId = sellAsset?.chainId + const defaultAccountId = useAppSelector(state => { + if (!sellAssetChainId) return undefined + return selectAccountIdByAccountNumberAndChainId(state)[0]?.[sellAssetChainId] + }) + + useEffect(() => { + if (!sellAccountId && defaultAccountId) { + dispatch(tradeEarnInput.actions.setSellAccountId(defaultAccountId)) + } + }, [sellAccountId, defaultAccountId, dispatch]) + + useEffect(() => { + if (defaultYieldId && !selectedYieldId && yieldsData?.byId?.[defaultYieldId]) { + dispatch(tradeEarnInput.actions.setSelectedYieldId(defaultYieldId)) + } + }, [defaultYieldId, selectedYieldId, yieldsData?.byId, dispatch]) + + useEffect(() => { + if (defaultSellAmountCryptoBaseUnit && defaultSellAsset && !sellAmountCryptoPrecision) { + const precision = defaultSellAsset.precision ?? 18 + const amountCryptoPrecision = fromBaseUnit(defaultSellAmountCryptoBaseUnit, precision) + dispatch(tradeEarnInput.actions.setSellAmountCryptoPrecision(amountCryptoPrecision)) + } + }, [defaultSellAmountCryptoBaseUnit, defaultSellAsset, sellAmountCryptoPrecision, dispatch]) + + const sellAmountCryptoBaseUnit = useAppSelector(selectInputSellAmountCryptoBaseUnit) + + useEffect(() => { + if (!sellAsset.assetId || !selectedYieldId) return + + const encodedAssetId = encodeURIComponent(sellAsset.assetId) + const encodedYieldId = encodeURIComponent(selectedYieldId) + const baseUnit = sellAmountCryptoBaseUnit ?? '0' + + navigate(`/earn/${encodedAssetId}/${encodedYieldId}/${baseUnit}`, { replace: true }) + }, [sellAsset.assetId, selectedYieldId, sellAmountCryptoBaseUnit, navigate]) + + const selectedYield = useMemo(() => { + if (!selectedYieldId || !yieldsData?.byId) return undefined + return yieldsData.byId[selectedYieldId] + }, [selectedYieldId, yieldsData?.byId]) + + const requiresValidatorSelection = useMemo(() => { + return selectedYield?.mechanics.requiresValidatorSelection ?? false + }, [selectedYield?.mechanics.requiresValidatorSelection]) + + const { data: validators } = useYieldValidators( + selectedYieldId ?? '', + requiresValidatorSelection, + ) + + const selectedValidator = useMemo(() => { + if (!requiresValidatorSelection || !validators?.length) return undefined + const chainId = selectedYield?.chainId + const defaultAddress = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined + if (defaultAddress) { + return ( + validators.find(v => v.address === defaultAddress) ?? + validators.find(v => v.preferred) ?? + validators[0] + ) + } + return validators.find(v => v.preferred) ?? validators[0] + }, [requiresValidatorSelection, validators, selectedYield?.chainId]) + + const selectedValidatorAddress = selectedValidator?.address + + const yieldChainId = selectedYield?.chainId + + const userAddress = useMemo( + () => (sellAccountId ? fromAccountId(sellAccountId).account : ''), + [sellAccountId], + ) + + const feeAsset = useAppSelector(state => + yieldChainId ? selectFeeAssetByChainId(state, yieldChainId) : undefined, + ) + + const feeAssetMarketData = useAppSelector(state => + feeAsset?.assetId + ? selectMarketDataByAssetIdUserCurrency(state, feeAsset.assetId) + : undefined, + ) + + const debouncedAmount = useDebounce(sellAmountCryptoPrecision, SELL_AMOUNT_DEBOUNCE_MS) + + const txArguments = useMemo(() => { + if (!selectedYield || !userAddress || !yieldChainId || !debouncedAmount) return null + if (!bnOrZero(debouncedAmount).gt(0)) return null + + const fields = selectedYield.mechanics.arguments.enter.fields + const fieldNames = new Set(fields.map(field => field.name)) + const args: Record = { amount: debouncedAmount } + + if (fieldNames.has('receiverAddress')) { + args.receiverAddress = userAddress + } + + if (fieldNames.has('validatorAddress') && yieldChainId) { + const validatorAddress = + selectedValidatorAddress ?? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldChainId] + if (validatorAddress) { + args.validatorAddress = validatorAddress + } + } + + if (fieldNames.has('cosmosPubKey') && yieldChainId === cosmosChainId) { + args.cosmosPubKey = userAddress + } + + return args + }, [selectedYield, userAddress, yieldChainId, debouncedAmount, selectedValidatorAddress]) + + const { data: quoteData, isLoading: isQuoteLoading } = useQuery({ + queryKey: ['yieldxyz', 'quote', 'enter', selectedYield?.id, userAddress, txArguments], + queryFn: () => { + if (!txArguments || !userAddress || !selectedYield?.id) throw new Error('Missing arguments') + return enterYield({ + yieldId: selectedYield.id, + address: userAddress, + arguments: txArguments, + }) + }, + enabled: + !!txArguments && + !!wallet && + !!sellAccountId && + !!selectedYield && + isConnected && + bnOrZero(debouncedAmount).gt(0), + staleTime: 30_000, + gcTime: 60_000, + retry: false, + }) + + const networkFeeFiatUserCurrency = useMemo(() => { + if (!quoteData?.transactions?.length || !feeAssetMarketData?.price) { + return undefined + } + + const totalGasCryptoPrecision = quoteData.transactions.reduce((acc, tx) => { + if (!tx.gasEstimate) return acc + try { + const gasData = JSON.parse(tx.gasEstimate) + return acc.plus(bnOrZero(gasData.amount)) + } catch { + return acc + } + }, bnOrZero(0)) + + if (totalGasCryptoPrecision.isZero()) return undefined + return totalGasCryptoPrecision.times(feeAssetMarketData.price).toFixed(2) + }, [quoteData?.transactions, feeAssetMarketData?.price]) + + const { price: sellAssetUserCurrencyRate } = + useAppSelector(state => selectMarketDataByFilter(state, { assetId: sellAsset?.assetId })) || + {} + + const balanceFilter = useMemo( + () => ({ accountId: sellAccountId ?? '', assetId: sellAsset?.assetId ?? '' }), + [sellAccountId, sellAsset?.assetId], + ) + const sellAssetBalanceCryptoPrecision = useAppSelector(state => + isConnected ? selectPortfolioCryptoPrecisionBalanceByFilter(state, balanceFilter) : '0', + ) + + const minDeposit = useMemo( + () => selectedYield?.mechanics.entryLimits.minimum, + [selectedYield?.mechanics.entryLimits.minimum], + ) + + const isBelowMinimum = useMemo(() => { + if (!sellAmountCryptoPrecision || !minDeposit) return false + return ( + bnOrZero(sellAmountCryptoPrecision).gt(0) && + bnOrZero(sellAmountCryptoPrecision).lt(minDeposit) + ) + }, [sellAmountCryptoPrecision, minDeposit]) + + const isInsufficientBalance = useMemo(() => { + if (!sellAmountCryptoPrecision || !sellAssetBalanceCryptoPrecision) return false + return bnOrZero(sellAmountCryptoPrecision).gt(sellAssetBalanceCryptoPrecision) + }, [sellAmountCryptoPrecision, sellAssetBalanceCryptoPrecision]) + + const sellAssetSearch = useModal('sellTradeAssetSearch') + + const availableAssetIds = useMemo(() => { + if (!yieldsData?.byInputAssetId) return new Set() + return new Set(Object.keys(yieldsData.byInputAssetId)) + }, [yieldsData?.byInputAssetId]) + + const assetFilterPredicate = useCallback( + (assetId: AssetId) => availableAssetIds.has(assetId), + [availableAssetIds], + ) + + const handleSellAssetClick = useCallback(() => { + sellAssetSearch.open({ + onAssetClick: (asset: Asset) => { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(asset)) + }, + title: 'earn.enterFrom', + assetFilterPredicate, + chainIdFilterPredicate: () => true, + }) + }, [assetFilterPredicate, dispatch, sellAssetSearch]) + + const setSellAsset = useCallback( + (asset: Asset) => { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(asset)) + }, + [dispatch], + ) + + const setSellAccountId = useCallback( + (accountId: AccountId) => { + dispatch(tradeEarnInput.actions.setSellAccountId(accountId)) + }, + [dispatch], + ) + + const handleSellAmountChange = useCallback( + (value: string) => { + dispatch( + tradeEarnInput.actions.setSellAmountCryptoPrecision(positiveOrZero(value).toString()), + ) + }, + [dispatch], + ) + + const handleIsInputtingFiatSellAmountChange = useCallback( + (isInputtingFiat: boolean) => { + dispatch(tradeEarnInput.actions.setIsInputtingFiatSellAmount(isInputtingFiat)) + }, + [dispatch], + ) + + const handleYieldSelect = useCallback( + (yieldId: string) => { + dispatch(tradeEarnInput.actions.setSelectedYieldId(yieldId)) + }, + [dispatch], + ) + + const percentOptions = useMemo(() => { + if (!sellAsset?.assetId) return [] + if (!isToken(sellAsset.assetId)) return [] + return [1] + }, [sellAsset?.assetId]) + + const assetSelectButtonProps = useMemo( + () => ({ + maxWidth: isSmallerThanMd ? '100%' : undefined, + }), + [isSmallerThanMd], + ) + + const sellTradeAssetSelect = useMemo( + () => ( + + ), + [ + sellAsset?.assetId, + handleSellAssetClick, + setSellAsset, + assetFilterPredicate, + isSmallerThanMd, + assetSelectButtonProps, + ], + ) + + const yieldsForAsset = useMemo(() => { + if (!sellAsset?.assetId || !yieldsData?.byInputAssetId) return [] + return yieldsData.byInputAssetId[sellAsset.assetId] ?? [] + }, [sellAsset?.assetId, yieldsData?.byInputAssetId]) + + useEffect(() => { + if (yieldsForAsset.length > 0 && !selectedYieldId) { + const sortedByApy = [...yieldsForAsset].sort( + (a, b) => (b.rewardRate?.total ?? 0) - (a.rewardRate?.total ?? 0), + ) + if (sortedByApy[0]) { + dispatch(tradeEarnInput.actions.setSelectedYieldId(sortedByApy[0].id)) + } + } + }, [yieldsForAsset, selectedYieldId, dispatch]) + + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault() + if (!selectedYield || !hasUserEnteredAmount || !isConnected) return + navigate(EarnRoutePaths.Confirm) + }, + [selectedYield, hasUserEnteredAmount, isConnected, navigate], + ) + + const estimatedYearlyEarnings = useMemo(() => { + if (!selectedYield || !sellAmountCryptoPrecision) return undefined + const apy = selectedYield.rewardRate?.total ?? 0 + const amount = bnOrZero(sellAmountCryptoPrecision) + if (amount.isZero()) return undefined + return amount.times(apy).decimalPlaces(6).toString() + }, [selectedYield, sellAmountCryptoPrecision]) + + const estimatedYearlyEarningsUserCurrency = useMemo(() => { + if (!estimatedYearlyEarnings || !sellAssetUserCurrencyRate) return undefined + return bnOrZero(estimatedYearlyEarnings) + .times(sellAssetUserCurrencyRate) + .decimalPlaces(2) + .toString() + }, [estimatedYearlyEarnings, sellAssetUserCurrencyRate]) + + const placeholder = useMemo(() => { + return toFiat(0, { omitDecimalTrailingZeros: true }) + }, [toFiat]) + + const bodyContent = useMemo( + () => ( + + + + + + + + + + ), + [ + sellAccountId, + sellAsset, + isInputtingFiatSellAmount, + isLoadingYields, + placeholder, + translate, + sellTradeAssetSelect, + percentOptions, + sellAmountCryptoPrecision, + sellAmountUserCurrency, + setSellAccountId, + handleIsInputtingFiatSellAmountChange, + handleSellAmountChange, + selectedYieldId, + yieldsForAsset, + handleYieldSelect, + selectedValidator, + ], + ) + + const footerContent = useMemo( + () => ( + + ), + [ + selectedYield, + hasUserEnteredAmount, + isLoadingYields, + sellAsset, + estimatedYearlyEarnings, + estimatedYearlyEarningsUserCurrency, + isConnected, + isBelowMinimum, + isInsufficientBalance, + networkFeeFiatUserCurrency, + isQuoteLoading, + ], + ) + + return ( + } + isCompact={false} + isLoading={isLoadingYields} + SideComponent={EmptySideComponent} + shouldOpenSideComponent={false} + tradeInputTab={TradeInputTab.Earn} + tradeInputRef={tradeInputRef} + onSubmit={handleSubmit} + /> + ) + }, +) diff --git a/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx b/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx new file mode 100644 index 00000000000..77f994e6e5a --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx @@ -0,0 +1,312 @@ +import { InfoIcon } from '@chakra-ui/icons' +import type { CardFooterProps, FlexProps } from '@chakra-ui/react' +import { Box, CardFooter, Flex, HStack, Icon, Skeleton, Text, VStack } from '@chakra-ui/react' +import type { Asset } from '@shapeshiftoss/types' +import { memo, useMemo } from 'react' +import { FaGift } from 'react-icons/fa' +import { MdSwapHoriz } from 'react-icons/md' +import { useTranslate } from 'react-polyglot' + +import { Amount } from '@/components/Amount/Amount' +import { ButtonWalletPredicate } from '@/components/ButtonWalletPredicate/ButtonWalletPredicate' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { GradientApy } from '@/pages/Yields/components/GradientApy' + +type EarnFooterProps = { + selectedYield: AugmentedYieldDto | undefined + hasUserEnteredAmount: boolean + isLoading: boolean + sellAsset: Asset + estimatedYearlyEarnings: string | undefined + estimatedYearlyEarningsUserCurrency: string | undefined + isConnected: boolean + isBelowMinimum: boolean + isInsufficientBalance: boolean + networkFeeFiatUserCurrency: string | undefined + isQuoteLoading: boolean +} + +const footerBgProp = { base: 'background.surface.base', md: 'transparent' } +const footerPosition: CardFooterProps['position'] = { base: 'sticky', md: 'static' } + +const statsBoxSx: FlexProps = { + bg: 'background.surface.raised.base', + borderRadius: 'lg', + p: 3, + borderWidth: '1px', + borderColor: 'border.base', +} + +const getActionTextKey = (yieldType: string | undefined): string => { + switch (yieldType) { + case 'native-staking': + case 'pooled-staking': + case 'liquid-staking': + case 'staking': + return 'defi.stake' + case 'vault': + return 'common.deposit' + case 'lending': + return 'common.supply' + default: + return 'common.deposit' + } +} + +type ExplainerItem = { + icon: React.ReactNode + textKey: string +} + +const getYieldExplainers = (selectedYield: AugmentedYieldDto): ExplainerItem[] => { + const yieldType = selectedYield.mechanics.type + const outputTokenSymbol = selectedYield.outputToken?.symbol + + switch (yieldType) { + case 'liquid-staking': + return [ + { + icon: , + textKey: outputTokenSymbol + ? 'earn.explainers.liquidStakingReceive' + : 'earn.explainers.liquidStakingTrade', + }, + { + icon: , + textKey: 'earn.explainers.rewardsSchedule', + }, + { + icon: , + textKey: 'earn.explainers.liquidStakingWithdraw', + }, + ] + case 'native-staking': + case 'pooled-staking': + case 'staking': + return [ + { + icon: , + textKey: 'earn.explainers.rewardsSchedule', + }, + { icon: , textKey: 'earn.explainers.stakingUnbonding' }, + ] + case 'restaking': + return [ + { + icon: , + textKey: 'earn.explainers.restakingYield', + }, + { icon: , textKey: 'earn.explainers.restakingWithdraw' }, + ] + case 'vault': + return [ + { icon: , textKey: 'earn.explainers.vaultYield' }, + { icon: , textKey: 'earn.explainers.vaultWithdraw' }, + ] + case 'lending': + return [ + { icon: , textKey: 'earn.explainers.lendingYield' }, + { icon: , textKey: 'earn.explainers.lendingWithdraw' }, + ] + default: + return [] + } +} + +export const EarnFooter = memo( + ({ + selectedYield, + hasUserEnteredAmount, + isLoading, + sellAsset, + estimatedYearlyEarnings, + estimatedYearlyEarningsUserCurrency, + isConnected, + isBelowMinimum, + isInsufficientBalance, + networkFeeFiatUserCurrency, + isQuoteLoading, + }: EarnFooterProps) => { + const translate = useTranslate() + + const apy = useMemo( + () => (selectedYield ? (selectedYield.rewardRate?.total ?? 0) * 100 : 0), + [selectedYield], + ) + + const apyDisplay = useMemo(() => `${apy.toFixed(2)}%`, [apy]) + + const minDeposit = selectedYield?.mechanics.entryLimits.minimum + const hasMinDeposit = minDeposit && bnOrZero(minDeposit).gt(0) + + const hasValidationError = isBelowMinimum || isInsufficientBalance + const isDisabled = + !hasUserEnteredAmount || !selectedYield || isLoading || !isConnected || hasValidationError + + const buttonText = useMemo(() => { + if (!isConnected) return translate('common.connectWallet') + if (!selectedYield) return translate('earn.selectYieldOpportunity') + if (!hasUserEnteredAmount) return translate('common.enterAmount') + if (isInsufficientBalance) return translate('common.insufficientFunds') + if (isBelowMinimum) return translate('earn.belowMinimum') + return translate(getActionTextKey(selectedYield.mechanics.type)) + }, [ + isConnected, + selectedYield, + hasUserEnteredAmount, + isInsufficientBalance, + isBelowMinimum, + translate, + ]) + + const explainers = useMemo( + () => (selectedYield ? getYieldExplainers(selectedYield) : []), + [selectedYield], + ) + + const rewardSchedule = selectedYield?.mechanics.rewardSchedule + const cooldownDays = useMemo(() => { + const seconds = selectedYield?.mechanics.cooldownPeriod?.seconds + if (!seconds) return undefined + return Math.ceil(seconds / 86400) + }, [selectedYield?.mechanics.cooldownPeriod?.seconds]) + + return ( + + + {selectedYield && hasUserEnteredAmount && ( + + + + {translate('common.apy')} + + {isLoading ? ( + + ) : ( + {apyDisplay} + )} + + + {estimatedYearlyEarnings && ( + + + {translate('earn.estimatedYearlyEarnings')} + + {isLoading ? ( + + ) : ( + + + + + {estimatedYearlyEarningsUserCurrency && ( + + + + )} + + )} + + )} + + {hasMinDeposit && ( + + + {translate('earn.minimumDeposit')} + + + + )} + + {selectedYield.mechanics.type && ( + + + {translate('earn.yieldType')} + + + {selectedYield.mechanics.type.replace(/-/g, ' ')} + + + )} + + + + {translate('trade.networkFee')} + + {isQuoteLoading ? ( + + ) : networkFeeFiatUserCurrency ? ( + + ) : ( + + — + + )} + + + )} + + {selectedYield && explainers.length > 0 && ( + + {explainers.map((explainer, index) => ( + + {explainer.icon} + + {translate(explainer.textKey, { + symbol: selectedYield.outputToken?.symbol ?? sellAsset?.symbol ?? '', + schedule: rewardSchedule ?? '', + days: cooldownDays ?? '', + })} + + + ))} + + )} + + + {buttonText} + + + + ) + }, +) diff --git a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx new file mode 100644 index 00000000000..f2f9445e730 --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx @@ -0,0 +1,331 @@ +import { ChevronDownIcon, SearchIcon } from '@chakra-ui/icons' +import { + Avatar, + Box, + Button, + HStack, + Input, + InputGroup, + InputLeftElement, + Skeleton, + Text, + useColorModeValue, + VStack, +} from '@chakra-ui/react' +import { cosmosChainId } from '@shapeshiftoss/caip' +import type { Asset } from '@shapeshiftoss/types' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslate } from 'react-polyglot' + +import { Dialog } from '@/components/Modal/components/Dialog' +import { DialogBody } from '@/components/Modal/components/DialogBody' +import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' +import { DialogHeader } from '@/components/Modal/components/DialogHeader' +import { DialogTitle } from '@/components/Modal/components/DialogTitle' +import { + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, +} from '@/lib/yieldxyz/constants' +import type { AugmentedYieldDto, ProviderDto, ValidatorDto } from '@/lib/yieldxyz/types' +import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' + +const chevronDownIcon = +const searchIcon = + +type YieldSelectorProps = { + selectedYieldId: string | undefined + yields: AugmentedYieldDto[] + onYieldSelect: (yieldId: string) => void + isLoading: boolean + sellAsset: Asset + selectedValidator?: ValidatorDto +} + +const isNativeStaking = (type: string | undefined): boolean => + ['native-staking', 'staking'].includes(type ?? '') + +const getDisplayInfo = ( + yieldItem: AugmentedYieldDto, + providers: Record | undefined, +): { name: string; logoURI: string | undefined } => { + // For Cosmos native staking, always show ShapeShift DAO + if (isNativeStaking(yieldItem.mechanics.type) && yieldItem.chainId === cosmosChainId) { + return { name: SHAPESHIFT_VALIDATOR_NAME, logoURI: SHAPESHIFT_VALIDATOR_LOGO } + } + // For other yields, show provider info + const provider = providers?.[yieldItem.providerId] + if (provider) { + return { name: provider.name, logoURI: provider.logoURI } + } + return { name: yieldItem.metadata.name, logoURI: yieldItem.metadata.logoURI } +} + +const hoverBg = { bg: 'background.surface.raised.hover' } + +const getYieldTypeName = (type: string, translate: (key: string) => string): string => { + const translationKey = `earn.yieldTypes.${type}` + const translated = translate(translationKey) + if (translated !== translationKey) return translated + return type + .split('-') + .filter((word): word is string => Boolean(word)) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +const YieldItem = memo( + ({ + yieldItem, + isSelected, + onClick, + providers, + }: { + yieldItem: AugmentedYieldDto + isSelected: boolean + onClick: () => void + providers: Record | undefined + }) => { + const selectedBg = useColorModeValue('blue.50', 'whiteAlpha.100') + + const requiresValidator = yieldItem.mechanics.requiresValidatorSelection + const { data: validators } = useYieldValidators(yieldItem.id, requiresValidator) + + const apyDisplay = useMemo(() => { + const apy = (yieldItem.rewardRate?.total ?? 0) * 100 + return `${apy.toFixed(2)}%` + }, [yieldItem.rewardRate?.total]) + + const displayInfo = useMemo(() => { + // For staking yields with validators, show the default validator + if (requiresValidator && validators?.length) { + const chainId = yieldItem.chainId + const defaultAddress = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined + const defaultValidator = defaultAddress + ? validators.find(v => v.address === defaultAddress) + : undefined + const preferredValidator = validators.find(v => v.preferred) ?? validators[0] + const validator = defaultValidator ?? preferredValidator + if (validator) { + return { name: validator.name, logoURI: validator.logoURI } + } + } + // Fall back to the static display info (includes Cosmos ShapeShift DAO fallback) + return getDisplayInfo(yieldItem, providers) + }, [requiresValidator, validators, yieldItem, providers]) + + return ( + + + + + + {displayInfo.name} + + + + {apyDisplay} + + + + ) + }, +) + +const isStakingType = (type: string | undefined): boolean => + ['native-staking', 'pooled-staking', 'staking'].includes(type ?? '') + +export const YieldSelector = memo( + ({ + selectedYieldId, + yields, + onYieldSelect, + isLoading, + sellAsset, + selectedValidator, + }: YieldSelectorProps) => { + const translate = useTranslate() + const [isOpen, setIsOpen] = useState(false) + const borderColor = useColorModeValue('gray.200', 'gray.700') + const [searchQuery, setSearchQuery] = useState('') + const { data: providers } = useYieldProviders() + + const selectedYield = useMemo(() => { + if (!selectedYieldId) return undefined + return yields.find(y => y.id === selectedYieldId) + }, [selectedYieldId, yields]) + + const filteredYields = useMemo(() => { + if (!searchQuery.trim()) return yields + const query = searchQuery.toLowerCase() + return yields.filter( + y => + y.metadata.name.toLowerCase().includes(query) || + y.mechanics.type.toLowerCase().includes(query), + ) + }, [yields, searchQuery]) + + const groupedYields = useMemo(() => { + const groups: Record = {} + for (const yieldItem of filteredYields) { + const type = yieldItem.mechanics.type + if (!groups[type]) groups[type] = [] + groups[type].push(yieldItem) + } + for (const type of Object.keys(groups)) { + groups[type].sort((a, b) => (b.rewardRate?.total ?? 0) - (a.rewardRate?.total ?? 0)) + } + return groups + }, [filteredYields]) + + const handleOpen = useCallback(() => setIsOpen(true), []) + + const handleClose = useCallback(() => { + setSearchQuery('') + setIsOpen(false) + }, []) + + const handleYieldClick = useCallback( + (yieldId: string) => { + onYieldSelect(yieldId) + setSearchQuery('') + setIsOpen(false) + }, + [onYieldSelect], + ) + + const selectedApyDisplay = useMemo(() => { + if (!selectedYield) return '0.00%' + const apy = (selectedYield.rewardRate?.total ?? 0) * 100 + return `${apy.toFixed(2)}%` + }, [selectedYield]) + + const selectedDisplayInfo = useMemo(() => { + if (!selectedYield) return null + // For staking yields with a validator, show validator info + if (isStakingType(selectedYield.mechanics.type) && selectedValidator) { + return { + name: selectedValidator.name, + logoURI: selectedValidator.logoURI, + } + } + // Otherwise show provider info + return getDisplayInfo(selectedYield, providers) + }, [selectedYield, selectedValidator, providers]) + + if (isLoading) { + return + } + + if (yields.length === 0) { + return ( + + + {translate('earn.noYieldsAvailable', { asset: sellAsset?.symbol ?? 'asset' })} + + + ) + } + + return ( + <> + + + + + {null} + + + {translate('earn.selectYieldFor', { asset: sellAsset?.symbol ?? '' })} + + + + + + + + + + {searchIcon} + setSearchQuery(e.target.value)} + variant='filled' + /> + + + {Object.entries(groupedYields).length === 0 ? ( + + {translate('common.noResultsFound')} + + ) : ( + Object.entries(groupedYields).map(([type, typeYields]) => ( + + + {getYieldTypeName(type, translate).toUpperCase()} + + + {typeYields.map(yieldItem => ( + handleYieldClick(yieldItem.id)} + providers={providers} + /> + ))} + + + )) + )} + + + + + ) + }, +) diff --git a/src/components/MultiHopTrade/components/Earn/types.ts b/src/components/MultiHopTrade/components/Earn/types.ts new file mode 100644 index 00000000000..14e7ecfb6c5 --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/types.ts @@ -0,0 +1,4 @@ +export enum EarnRoutePaths { + Input = '/earn', + Confirm = 'confirm', +} diff --git a/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx b/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx index aadcedd75a3..01d678d0397 100644 --- a/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx +++ b/src/components/MultiHopTrade/components/SharedConfirm/SharedConfirm.tsx @@ -12,9 +12,9 @@ import type { TextPropTypes } from '@/components/Text/Text' type SharedConfirmProps = { bodyContent: JSX.Element footerContent: JSX.Element | null - isLoading: boolean onBack: () => void headerTranslation: TextPropTypes['translation'] + isLoading?: boolean } const cardMinHeight = { base: 'calc(100vh - var(--mobile-nav-offset))', md: 'initial' } diff --git a/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx index 3aa5e0b2065..694f1b299da 100644 --- a/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx +++ b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx @@ -45,6 +45,7 @@ export const SharedTradeInputHeader = ({ const enableLimitOrders = useFeatureFlag('LimitOrders') const enableSwapperFiatRamps = useFeatureFlag('SwapperFiatRamps') + const enableEarnTab = useFeatureFlag('EarnTab') const handleChangeTab = useCallback( (newTab: TradeInputTab) => { @@ -70,6 +71,10 @@ export const SharedTradeInputHeader = ({ handleChangeTab(TradeInputTab.SellFiat) }, [handleChangeTab]) + const handleClickEarn = useCallback(() => { + handleChangeTab(TradeInputTab.Earn) + }, [handleChangeTab]) + return ( )} + {enableEarnTab && !isStandalone && ( + + {translate('navBar.earn')} + + )} {rightContent} @@ -210,6 +226,23 @@ export const SharedTradeInputHeader = ({ {translate('fiatRamps.sell')} )} + {enableEarnTab && !isStandalone && ( + + {translate('navBar.earn')} + + )} diff --git a/src/components/MultiHopTrade/types.ts b/src/components/MultiHopTrade/types.ts index e273e264f5d..cb35d40571d 100644 --- a/src/components/MultiHopTrade/types.ts +++ b/src/components/MultiHopTrade/types.ts @@ -20,4 +20,5 @@ export enum TradeInputTab { LimitOrder = 'limitOrder', BuyFiat = 'buy', SellFiat = 'sell', + Earn = 'earn', } diff --git a/src/components/ReactTable/ReactTable.tsx b/src/components/ReactTable/ReactTable.tsx index 77bfc3ac418..9a2bcd65868 100644 --- a/src/components/ReactTable/ReactTable.tsx +++ b/src/components/ReactTable/ReactTable.tsx @@ -49,10 +49,14 @@ const arrowBackIcon = const arrowForwardIcon = const CellWrap = ({ cell }: { cell: Cell }) => { - const cellProps = useMemo(() => cell.getCellProps(), [cell]) - const dataLabel = useMemo(() => { - return typeof cell.column.Header === 'string' ? cell.column.Header : undefined - }, [cell.column.Header]) + const cellProps = useMemo(() => { + const { key: _key, ...rest } = cell.getCellProps() + return rest + }, [cell]) + const dataLabel = useMemo( + () => (typeof cell.column.Header === 'string' ? cell.column.Header : undefined), + [cell.column.Header], + ) return ( ({ cell }: { cell: Cell }) => { data-label={dataLabel} display={cell.column.display} textAlign={cell.column.textAlign} - key={cell.column.id} > {cell.render('Cell')} @@ -94,7 +97,10 @@ const RowWrap = ({ onRowLongPress?.(row as Row) }, defaultLongPressConfig) - const rowProps = useMemo(() => row.getRowProps(), [row]) + const rowProps = useMemo(() => { + const { key: _key, ...rest } = row.getRowProps() + return rest + }, [row]) const dataTest = useMemo(() => { if (!rowDataTestKey) return undefined @@ -229,33 +235,48 @@ export const ReactTable = ({ {displayHeaders && ( - {headerGroups.map(headerGroup => ( - - {headerGroup.headers.map(column => ( - - ))} - - ))} + {headerGroups.map(headerGroup => { + const { key: headerGroupKey, ...headerGroupProps } = headerGroup.getHeaderGroupProps() + return ( + + {headerGroup.headers.map(column => { + const { key: columnKey, ...columnProps } = column.getHeaderProps( + column.getSortByToggleProps(), + ) + return ( + + ) + })} + + ) + })} )} {renderedRows} diff --git a/src/config.ts b/src/config.ts index 6e55bf894bf..e7557edefff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -235,6 +235,8 @@ const validators = { VITE_FEATURE_ADDRESS_BOOK: bool({ default: false }), VITE_FEATURE_APP_RATING: bool({ default: false }), VITE_FEATURE_YIELD_XYZ: bool({ default: false }), + VITE_FEATURE_YIELDS_PAGE: bool({ default: false }), + VITE_FEATURE_EARN_TAB: bool({ default: false }), VITE_YIELD_XYZ_API_KEY: str({ default: '' }), VITE_YIELD_XYZ_BASE_URL: url({ default: 'https://api.yield.xyz/v1' }), VITE_FEATURE_YIELD_MULTI_ACCOUNT: bool({ default: false }), diff --git a/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx index d9440ce96bb..15c41be6836 100644 --- a/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useGenericTransactionSubscriber.tsx @@ -158,6 +158,12 @@ export const useGenericTransactionSubscriber = () => { }) } + // Invalidate yield balances when yield transactions complete in background + if (action.transactionMetadata.displayType === GenericTransactionDisplayType.Yield) { + queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'yields'] }) + } + // No double-toasty if (toast.isActive(action.transactionMetadata.txHash)) return diff --git a/src/lib/yieldxyz/constants.ts b/src/lib/yieldxyz/constants.ts index 44b4773b531..a7690c61975 100644 --- a/src/lib/yieldxyz/constants.ts +++ b/src/lib/yieldxyz/constants.ts @@ -20,6 +20,7 @@ import { } from '@shapeshiftoss/caip' import invert from 'lodash/invert' +import type { ValidatorDto } from './types' import { YieldNetwork } from './types' export const CHAIN_ID_TO_YIELD_NETWORK: Partial> = { @@ -54,6 +55,10 @@ export const isSupportedYieldNetwork = (network: string): network is YieldNetwor export const YIELD_POLL_INTERVAL_MS = 5000 export const YIELD_MAX_POLL_ATTEMPTS = 120 +// Compute unit safety margin for Solana yield transactions +// Same as Jupiter swapper (1.6) to handle network volatility +export const SOLANA_YIELD_COMPUTE_UNIT_MARGIN_MULTIPLIER = 1.6 + export const SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS = 'cosmosvaloper199mlc7fr6ll5t54w7tts7f4s0cvnqgc59nmuxf' @@ -62,10 +67,38 @@ export const SHAPESHIFT_VALIDATOR_LOGO = export const COSMOS_SHAPESHIFT_FALLBACK_APR = '0.1425' +export const COSMOS_NETWORK_FALLBACK_APR = 0.15 + +export const SHAPESHIFT_VALIDATOR_NAME = 'ShapeShift DAO' + +export const SHAPESHIFT_VALIDATOR: ValidatorDto = { + address: SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + name: SHAPESHIFT_VALIDATOR_NAME, + logoURI: SHAPESHIFT_VALIDATOR_LOGO, + preferred: true, + votingPower: 0, + commission: 0.1, + status: 'active', + tvl: '0', + tvlRaw: '0', + rewardRate: { + total: parseFloat(COSMOS_SHAPESHIFT_FALLBACK_APR), + rateType: 'APR', + components: [], + }, +} + export const COSMOS_DECIMALS = 6 export const COSMOS_ATOM_NATIVE_STAKING_YIELD_ID = 'cosmos-atom-native-staking' +export const SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID = + 'solana-sol-native-multivalidator-staking' + +export const FIGMENT_SOLANA_VALIDATOR_ADDRESS = 'CcaHc2L43ZWjwCHART3oZoJvHLAe9hzT2DJNUpBzoTN1' +export const FIGMENT_VALIDATOR_NAME = 'Figment' +export const FIGMENT_VALIDATOR_LOGO = 'https://assets.stakek.it/validators/figment.png' export const DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID: Partial> = { [cosmosChainId]: SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + [solanaChainId]: FIGMENT_SOLANA_VALIDATOR_ADDRESS, } diff --git a/src/lib/yieldxyz/executeTransaction.ts b/src/lib/yieldxyz/executeTransaction.ts index e511321f63a..172ea3983ff 100644 --- a/src/lib/yieldxyz/executeTransaction.ts +++ b/src/lib/yieldxyz/executeTransaction.ts @@ -16,8 +16,10 @@ import { import type { Hex } from 'viem' import { isHex, toHex } from 'viem' +import { SOLANA_YIELD_COMPUTE_UNIT_MARGIN_MULTIPLIER } from './constants' import type { TransactionDto } from './types' +import { bnOrZero } from '@/lib/bignumber/bignumber' import { toBaseUnit } from '@/lib/math' import { assertGetCosmosSdkChainAdapter } from '@/lib/utils/cosmosSdk' import { assertGetEvmChainAdapter, signAndBroadcast as evmSignAndBroadcast } from '@/lib/utils/evm' @@ -343,13 +345,14 @@ const executeSolanaTransaction = async ({ }: ExecuteSolanaTransactionInput): Promise => { const adapter = assertGetSolanaChainAdapter(chainId) const accountNumber = bip44Params?.accountNumber ?? 0 - const txData = unsignedTransaction.startsWith('0x') - ? unsignedTransaction.slice(2) - : unsignedTransaction - const versionedTransaction = VersionedTransaction.deserialize( - new Uint8Array(Buffer.from(txData, 'hex')), - ) + // Yield.xyz returns base64 for Solana transactions (Solana convention) + // Use isHex from viem to detect hex-encoded transactions + const txBytes = isHex(unsignedTransaction) + ? new Uint8Array(Buffer.from(unsignedTransaction.slice(2), 'hex')) + : new Uint8Array(Buffer.from(unsignedTransaction, 'base64')) + + const versionedTransaction = VersionedTransaction.deserialize(txBytes) const addressLookupTableAccountKeys = versionedTransaction.message.addressTableLookups.map( lookup => lookup.accountKey.toString(), @@ -392,11 +395,10 @@ const executeSolanaTransaction = async ({ adapter.convertInstruction(instruction), ) - const STAKE_COMPUTE_UNIT_BUFFER = 50000 - const estimatedComputeUnits = Math.max( - Number(fast.chainSpecific.computeUnits), - STAKE_COMPUTE_UNIT_BUFFER, - ) + // Apply safety margin to estimated compute units (same approach as Jupiter swapper) + const estimatedComputeUnits = bnOrZero(fast.chainSpecific.computeUnits) + .times(SOLANA_YIELD_COMPUTE_UNIT_MARGIN_MULTIPLIER) + .toFixed(0) const txToSign = await adapter.buildSendApiTransaction({ from, diff --git a/src/lib/yieldxyz/types.ts b/src/lib/yieldxyz/types.ts index df7c880d734..be584174067 100644 --- a/src/lib/yieldxyz/types.ts +++ b/src/lib/yieldxyz/types.ts @@ -238,6 +238,7 @@ export type YieldMechanics = { entryLimits: YieldEntryLimits arguments: YieldArguments supportsLedgerWalletApi?: boolean + cooldownPeriod?: { seconds: number } possibleFeeTakingMechanisms?: { depositFee: boolean managementFee: boolean diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts index 3fe6b72688f..e0feda81908 100644 --- a/src/lib/yieldxyz/utils.ts +++ b/src/lib/yieldxyz/utils.ts @@ -1,6 +1,7 @@ import type { ChainId } from '@shapeshiftoss/caip' import { + COSMOS_NETWORK_FALLBACK_APR, isSupportedYieldNetwork, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, YIELD_NETWORK_TO_CHAIN_ID, @@ -16,13 +17,63 @@ export const yieldNetworkToChainId = (network: string): ChainId | undefined => { const TX_TITLE_PATTERNS: [RegExp, string][] = [ [/approv/i, 'Approve'], - [/supply|deposit|enter/i, 'Deposit'], - [/withdraw|exit/i, 'Withdraw'], + [/supply|deposit|enter/i, 'Enter'], + [/withdraw|exit|unstake|undelegate/i, 'Exit'], [/claim/i, 'Claim'], - [/unstake/i, 'Unstake'], - [/stake/i, 'Stake'], + [/stake|delegate/i, 'Enter'], + [/bridge/i, 'Bridge'], + [/swap/i, 'Swap'], ] +// Map of transaction types to user-friendly button labels +// These should match the action verbs shown in the step row (without the asset symbol) +// Yield.xyz uses Enter/Exit terminology consistently +const TX_TYPE_TO_LABEL: Record = { + APPROVE: 'Approve', + APPROVAL: 'Approve', + DELEGATE: 'Enter', // Monad uses DELEGATE for staking + UNDELEGATE: 'Exit', // Monad uses UNDELEGATE for unstaking + STAKE: 'Enter', + UNSTAKE: 'Exit', + DEPOSIT: 'Enter', + WITHDRAW: 'Exit', + SUPPLY: 'Enter', + EXIT: 'Exit', + ENTER: 'Enter', + BRIDGE: 'Bridge', + SWAP: 'Swap', + CLAIM: 'Claim', + CLAIM_REWARDS: 'Claim', + TRANSFER: 'Transfer', +} + +/** + * Gets a clean button label from a transaction type or title. + * Used for the main CTA button in the yield action modal. + */ +export const getTransactionButtonText = ( + type: string | undefined, + title: string | undefined, +): string => { + // First try to use the transaction type directly + if (type) { + const normalized = type.toUpperCase().replace(/[_-]/g, '_') + if (TX_TYPE_TO_LABEL[normalized]) { + return TX_TYPE_TO_LABEL[normalized] + } + // Fallback: capitalize the type + return type.charAt(0).toUpperCase() + type.slice(1).toLowerCase() + } + + // Fall back to parsing the title + if (title) { + const match = TX_TITLE_PATTERNS.find(([pattern]) => pattern.test(title)) + if (match) return match[1] + } + + return 'Confirm' +} + export const formatYieldTxTitle = (title: string, assetSymbol: string): string => { const normalized = title.replace(/ transaction$/i, '').toLowerCase() const match = TX_TITLE_PATTERNS.find(([pattern]) => pattern.test(normalized)) @@ -97,3 +148,15 @@ export const sortValidators = ( export const toUserCurrency = (usdAmount: string | number, rate: string | number): string => bnOrZero(usdAmount).times(rate).toFixed() + +export const ensureValidatorApr = (validator: ValidatorDto): ValidatorDto => + validator.rewardRate?.total + ? validator + : { + ...validator, + rewardRate: { + total: COSMOS_NETWORK_FALLBACK_APR, + rateType: 'APR' as const, + components: validator.rewardRate?.components ?? [], + }, + } diff --git a/src/pages/Trade/tabs/EarnTab.tsx b/src/pages/Trade/tabs/EarnTab.tsx new file mode 100644 index 00000000000..8f69caf54a5 --- /dev/null +++ b/src/pages/Trade/tabs/EarnTab.tsx @@ -0,0 +1,143 @@ +import { Box, Flex } from '@chakra-ui/react' +import { memo, useCallback, useMemo, useRef } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { useTranslate } from 'react-polyglot' +import { matchPath, Route, Routes, useLocation, useNavigate } from 'react-router-dom' + +import { TradingErrorBoundary } from '@/components/ErrorBoundary' +import { Main } from '@/components/Layout/Main' +import { SEO } from '@/components/Layout/Seo' +import { EarnConfirm } from '@/components/MultiHopTrade/components/Earn/EarnConfirm' +import { EarnInput } from '@/components/MultiHopTrade/components/Earn/EarnInput' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' +import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' +import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' +import { TradeInputTab, TradeRoutePaths } from '@/components/MultiHopTrade/types' +import { blurBackgroundSx, gridOverlaySx } from '@/pages/Trade/constants' +import { EARN_ROUTE_ASSET_SPECIFIC } from '@/Routes/RoutesCommon' + +const padding = { base: 0, md: 8 } +const mainPaddingTop = { base: 0, md: '4.5rem' } +const mainMarginTop = { base: 0, md: '-4.5rem' } + +const containerPaddingTop = { base: 0, md: 12 } +const containerPaddingBottom = { base: 0, md: 12 } + +export const EarnTab = memo(function EarnTab() { + const translate = useTranslate() + const methods = useForm({ mode: 'onChange' }) + const navigate = useNavigate() + const location = useLocation() + const tradeInputRef = useRef(null) + + const earnMatch = useMemo( + () => matchPath({ path: EARN_ROUTE_ASSET_SPECIFIC, end: true }, location.pathname), + [location.pathname], + ) + + const params = earnMatch?.params + + const defaultSellAssetId = useMemo( + () => + params?.sellChainId && params.sellAssetSubId + ? `${params.sellChainId}/${params.sellAssetSubId}` + : undefined, + [params?.sellChainId, params?.sellAssetSubId], + ) + + const defaultYieldId = useMemo( + () => (params?.yieldId ? decodeURIComponent(params.yieldId) : undefined), + [params?.yieldId], + ) + + const defaultSellAmountCryptoBaseUnit = useMemo( + () => params?.sellAmountCryptoBaseUnit, + [params?.sellAmountCryptoBaseUnit], + ) + + const handleChangeTab = useCallback( + (newTab: TradeInputTab) => { + switch (newTab) { + case TradeInputTab.Trade: + navigate(TradeRoutePaths.Input) + break + case TradeInputTab.LimitOrder: + navigate(LimitOrderRoutePaths.Input) + break + case TradeInputTab.BuyFiat: + navigate(FiatRampRoutePaths.Buy) + break + case TradeInputTab.SellFiat: + navigate(FiatRampRoutePaths.Sell) + break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break + default: + break + } + }, + [navigate], + ) + + const title = useMemo(() => { + return translate('navBar.earn') + }, [translate]) + + const earnInputElement = useMemo( + () => ( + + + + ), + [handleChangeTab, defaultSellAssetId, defaultYieldId, defaultSellAmountCryptoBaseUnit], + ) + + const earnConfirmElement = useMemo( + () => ( + + + + ), + [], + ) + + return ( +
+ + + + + + + + + + + +
+ ) +}) diff --git a/src/pages/Trade/tabs/LimitTab.tsx b/src/pages/Trade/tabs/LimitTab.tsx index ba413bff5f9..1a9c2710b10 100644 --- a/src/pages/Trade/tabs/LimitTab.tsx +++ b/src/pages/Trade/tabs/LimitTab.tsx @@ -6,6 +6,7 @@ import { matchPath, Route, Routes, useLocation, useNavigate } from 'react-router import { Main } from '@/components/Layout/Main' import { SEO } from '@/components/Layout/Seo' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' import { LimitOrder } from '@/components/MultiHopTrade/components/LimitOrder/LimitOrder' import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' @@ -68,6 +69,9 @@ export const LimitTab = memo(() => { case TradeInputTab.SellFiat: navigate(FiatRampRoutePaths.Sell) break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break default: break } diff --git a/src/pages/Trade/tabs/RampTab.tsx b/src/pages/Trade/tabs/RampTab.tsx index e6eef70057d..484fae0cc50 100644 --- a/src/pages/Trade/tabs/RampTab.tsx +++ b/src/pages/Trade/tabs/RampTab.tsx @@ -8,6 +8,7 @@ import { RampErrorBoundary } from '@/components/ErrorBoundary/RampErrorBoundary' import { Main } from '@/components/Layout/Main' import { SEO } from '@/components/Layout/Seo' import { FiatRampAction } from '@/components/Modals/FiatRamps/FiatRampsCommon' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' import { FiatRampTrade } from '@/components/MultiHopTrade/components/FiatRamps/FiatRampTrade' import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' @@ -48,6 +49,9 @@ export const RampTab = () => { case TradeInputTab.SellFiat: navigate(FiatRampRoutePaths.Sell) break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break default: break } diff --git a/src/pages/Trade/tabs/TradeTab.tsx b/src/pages/Trade/tabs/TradeTab.tsx index a1e4ea6d111..9e6d0f537cd 100644 --- a/src/pages/Trade/tabs/TradeTab.tsx +++ b/src/pages/Trade/tabs/TradeTab.tsx @@ -7,6 +7,7 @@ import { matchPath, Route, Routes, useLocation, useNavigate } from 'react-router import { TradingErrorBoundary } from '@/components/ErrorBoundary' import { Main } from '@/components/Layout/Main' import { SEO } from '@/components/Layout/Seo' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' import { TopAssetsCarousel } from '@/components/MultiHopTrade/components/TradeInput/components/TopAssetsCarousel' @@ -78,6 +79,9 @@ export const TradeTab = memo(() => { case TradeInputTab.SellFiat: navigate(FiatRampRoutePaths.Sell) break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break default: break } diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx index 644a8fae98a..6cf98a882c3 100644 --- a/src/pages/Yields/YieldAssetDetails.tsx +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -10,6 +10,7 @@ import { SimpleGrid, Stat, Text, + useMediaQuery, } from '@chakra-ui/react' import type { ColumnDef, Row } from '@tanstack/react-table' import { getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table' @@ -21,7 +22,15 @@ import { Amount } from '@/components/Amount/Amount' import { AssetIcon } from '@/components/AssetIcon' import { ChainIcon } from '@/components/ChainMenu' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { YIELD_NETWORK_TO_CHAIN_ID } from '@/lib/yieldxyz/constants' +import { + COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, + FIGMENT_VALIDATOR_LOGO, + FIGMENT_VALIDATOR_NAME, + SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, + SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, + YIELD_NETWORK_TO_CHAIN_ID, +} from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto, YieldNetwork } from '@/lib/yieldxyz/types' import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' @@ -41,6 +50,7 @@ export const YieldAssetDetails = memo(() => { const decodedSymbol = useMemo(() => decodeURIComponent(assetSymbol || ''), [assetSymbol]) const navigate = useNavigate() const translate = useTranslate() + const [isMobile] = useMediaQuery('(max-width: 768px)') const [searchParams, setSearchParams] = useSearchParams() const viewParam = useMemo(() => searchParams.get('view'), [searchParams]) @@ -51,9 +61,10 @@ export const YieldAssetDetails = memo(() => { const setViewMode = useCallback( (mode: 'grid' | 'list') => { setSearchParams(prev => { - if (mode === 'grid') prev.delete('view') - else prev.set('view', mode) - return prev + const next = new URLSearchParams(prev) + if (mode === 'grid') next.delete('view') + else next.set('view', mode) + return next }) }, [setSearchParams], @@ -62,10 +73,12 @@ export const YieldAssetDetails = memo(() => { selectedNetwork, selectedProvider, sortOption, + selectedType, sorting, setSorting, handleNetworkChange, handleProviderChange, + handleTypeChange, handleSortChange, } = useYieldFilters() @@ -80,11 +93,48 @@ export const YieldAssetDetails = memo(() => { [yieldProviders], ) + const getYieldDisplayInfo = useCallback( + (yieldItem: AugmentedYieldDto) => { + const isNativeStaking = + yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection + + if (yieldItem.id === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID) { + return { + name: SHAPESHIFT_VALIDATOR_NAME, + logoURI: SHAPESHIFT_VALIDATOR_LOGO, + title: translate('yieldXYZ.nativeStaking'), + } + } + if ( + yieldItem.id === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || + (yieldItem.id.includes('solana') && yieldItem.id.includes('native')) + ) { + return { + name: FIGMENT_VALIDATOR_NAME, + logoURI: FIGMENT_VALIDATOR_LOGO, + title: translate('yieldXYZ.nativeStaking'), + } + } + if (isNativeStaking) { + return { + name: yieldItem.metadata.name, + logoURI: yieldItem.metadata.logoURI, + title: translate('yieldXYZ.nativeStaking'), + } + } + const provider = yieldProviders?.[yieldItem.providerId] + return { name: provider?.name, logoURI: provider?.logoURI } + }, + [translate, yieldProviders], + ) + const assetYields = useMemo( () => (yields?.byAssetSymbol && decodedSymbol ? yields.byAssetSymbol[decodedSymbol] || [] : []), [yields, decodedSymbol], ) + // Networks available for THIS asset - since we're on an asset-specific page, + // we show only networks that have yields for this particular asset (not all global networks) const networks = useMemo( () => Array.from(new Set(assetYields.map(y => y.network))).map(net => ({ @@ -95,6 +145,7 @@ export const YieldAssetDetails = memo(() => { [assetYields], ) + // Providers available for THIS asset - shows only providers that offer yields for this asset const providers = useMemo( () => Array.from(new Set(assetYields.map(y => y.providerId))).map(pId => ({ @@ -105,14 +156,25 @@ export const YieldAssetDetails = memo(() => { [assetYields, getProviderLogo], ) + // Types available for THIS asset + const types = useMemo( + () => + Array.from(new Set(assetYields.map(y => y.mechanics.type))).map(type => ({ + id: type, + name: type.charAt(0).toUpperCase() + type.slice(1).replace(/-/g, ' '), + })), + [assetYields], + ) + const filteredYields = useMemo( () => assetYields.filter(y => { if (selectedNetwork && y.network !== selectedNetwork) return false if (selectedProvider && y.providerId !== selectedProvider) return false + if (selectedType && y.mechanics.type !== selectedType) return false return true }), - [assetYields, selectedNetwork, selectedProvider], + [assetYields, selectedNetwork, selectedProvider, selectedType], ) const assetInfo = useMemo(() => { @@ -287,6 +349,8 @@ export const YieldAssetDetails = memo(() => { onSortingChange: setSorting, }) + const sortedRows = table.getSortedRowModel().rows + const handleYieldClick = useCallback( (yieldId: string) => { const balances = allBalances?.[yieldId] @@ -320,23 +384,20 @@ export const YieldAssetDetails = memo(() => { {translate('yieldXYZ.assetYields', { asset: assetInfo.assetName })} - - {translate('yieldXYZ.opportunitiesAvailable', { count: assetYields.length })} - ) - }, [assetInfo, assetYields.length, translate]) + }, [assetInfo, translate]) const loadingGridElement = useMemo( () => ( - + {Array.from({ length: 6 }).map((_, i) => ( - + ))} ), - [], + [isMobile], ) const loadingListElement = useMemo( @@ -352,30 +413,35 @@ export const YieldAssetDetails = memo(() => { const gridViewElement = useMemo( () => ( - - {table.getSortedRowModel().rows.map(row => ( - handleYieldClick(row.original.id)} - userBalanceUsd={ - allBalances?.[row.original.id] - ? allBalances[row.original.id].reduce( - (sum, b) => sum.plus(bnOrZero(b.amountUsd)), - bnOrZero(0), - ) - : undefined - } - /> - ))} + + {sortedRows.map(row => { + const displayInfo = getYieldDisplayInfo(row.original) + return ( + handleYieldClick(row.original.id)} + titleOverride={displayInfo.title} + userBalanceUsd={ + allBalances?.[row.original.id] + ? allBalances[row.original.id].reduce( + (sum, b) => sum.plus(bnOrZero(b.amountUsd)), + bnOrZero(0), + ) + : undefined + } + /> + ) + })} ), - [allBalances, getProviderLogo, handleYieldClick, table], + [allBalances, getYieldDisplayInfo, handleYieldClick, isMobile, sortedRows], ) const listViewElement = useMemo( @@ -384,14 +450,19 @@ export const YieldAssetDetails = memo(() => { ), - [handleRowClick, table], + // sortedRows needed to trigger re-memoization when filtered data changes (table ref is stable) + // eslint-disable-next-line react-hooks/exhaustive-deps + [handleRowClick, sortedRows, table], ) + // Force grid view (which renders mobile cards) on mobile + const effectiveViewMode = isMobile ? 'grid' : viewMode + const contentElement = useMemo(() => { - if (isLoading) return viewMode === 'grid' ? loadingGridElement : loadingListElement + if (isLoading) return effectiveViewMode === 'grid' ? loadingGridElement : loadingListElement if (filteredYields.length === 0) return {translate('yieldXYZ.noYieldsMatchingFilters')} - return viewMode === 'grid' ? gridViewElement : listViewElement + return effectiveViewMode === 'grid' ? gridViewElement : listViewElement }, [ filteredYields.length, gridViewElement, @@ -400,11 +471,11 @@ export const YieldAssetDetails = memo(() => { loadingGridElement, loadingListElement, translate, - viewMode, + effectiveViewMode, ]) return ( - + {assetHeaderElement} - - - - - + + {contentElement} diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 43c2e759690..6495750ec93 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -1,74 +1,139 @@ -import { - Avatar, - AvatarGroup, - Box, - Button, - Container, - Flex, - Heading, - HStack, - Text, - useColorModeValue, -} from '@chakra-ui/react' +import { Box, Button, Container, Flex, Heading, Text } from '@chakra-ui/react' import { memo, useEffect, useMemo } from 'react' -import { FaChevronLeft } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' -import { useNavigate, useParams } from 'react-router-dom' +import { useNavigate, useParams, useSearchParams } from 'react-router-dom' -import { AssetIcon } from '@/components/AssetIcon' -import { ChainIcon } from '@/components/ChainMenu' -import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' -import { ValidatorBreakdown } from '@/pages/Yields/components/ValidatorBreakdown' -import { YieldEnterExit } from '@/pages/Yields/components/YieldEnterExit' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { + COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + FIGMENT_SOLANA_VALIDATOR_ADDRESS, + FIGMENT_VALIDATOR_LOGO, + FIGMENT_VALIDATOR_NAME, + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, + SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, +} from '@/lib/yieldxyz/constants' +import { YieldBalanceType } from '@/lib/yieldxyz/types' +import { YieldHero } from '@/pages/Yields/components/YieldHero' +import { YieldManager } from '@/pages/Yields/components/YieldManager' import { YieldPositionCard } from '@/pages/Yields/components/YieldPositionCard' import { YieldStats } from '@/pages/Yields/components/YieldStats' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYield } from '@/react-queries/queries/yieldxyz/useYield' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { selectUserCurrencyToUsdRate } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' export const YieldDetail = memo(() => { const { yieldId } = useParams<{ yieldId: string }>() + const [searchParams] = useSearchParams() const navigate = useNavigate() const translate = useTranslate() + const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) - const { data: yieldItem, isLoading, isFetching, error } = useYield(yieldId ?? '') - const { data: yieldProviders } = useYieldProviders() + const { data: yieldItem, isLoading, error } = useYield(yieldId ?? '') + const { data: allBalancesData, isFetching: isBalancesFetching } = useAllYieldBalances() + const balances = yieldItem?.id ? allBalancesData?.normalized[yieldItem.id] : undefined + const isBalancesLoading = !allBalancesData && isBalancesFetching - const shouldFetchValidators = useMemo( + const validatorParam = useMemo(() => searchParams.get('validator'), [searchParams]) + const defaultValidator = useMemo( () => - yieldItem?.mechanics.type === 'staking' && yieldItem?.mechanics.requiresValidatorSelection, - [yieldItem?.mechanics.type, yieldItem?.mechanics.requiresValidatorSelection], + yieldItem?.chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] : undefined, + [yieldItem?.chainId], ) - const { data: validators } = useYieldValidators(yieldId ?? '', shouldFetchValidators) + const selectedValidatorAddress = useMemo(() => { + // For native staking with hardcoded defaults, always use the default validator (ignore URL param) + if ( + yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID || + yieldId === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || + (yieldId?.includes('solana') && yieldId?.includes('native')) + ) { + return defaultValidator + } + return validatorParam || defaultValidator + }, [yieldId, validatorParam, defaultValidator]) - const providerLogo = useMemo( - () => - yieldItem?.providerId && yieldProviders - ? yieldProviders[yieldItem.providerId]?.logoURI - : undefined, - [yieldItem?.providerId, yieldProviders], + const isStaking = yieldItem?.mechanics.type === 'staking' + const shouldFetchValidators = useMemo( + () => isStaking && yieldItem?.mechanics.requiresValidatorSelection, + [isStaking, yieldItem?.mechanics.requiresValidatorSelection], ) + const { data: validators } = useYieldValidators(yieldItem?.id ?? '', shouldFetchValidators) + const { data: yieldProviders } = useYieldProviders() - const bgColor = useColorModeValue('gray.50', 'gray.900') - const borderColor = useColorModeValue('gray.200', 'gray.800') - const heroBg = useColorModeValue('gray.100', 'gray.900') - const heroTextColor = useColorModeValue('gray.900', 'white') - const heroSubtleColor = useColorModeValue('gray.600', 'gray.400') - const heroIconBorderColor = useColorModeValue('gray.200', 'gray.800') + const validatorOrProvider = useMemo(() => { + if (isStaking && selectedValidatorAddress) { + const found = validators?.find(v => v.address === selectedValidatorAddress) + if (found) return { name: found.name, logoURI: found.logoURI } + if (selectedValidatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { + return { name: SHAPESHIFT_VALIDATOR_NAME, logoURI: SHAPESHIFT_VALIDATOR_LOGO } + } + if (selectedValidatorAddress === FIGMENT_SOLANA_VALIDATOR_ADDRESS) { + return { name: FIGMENT_VALIDATOR_NAME, logoURI: FIGMENT_VALIDATOR_LOGO } + } + } + if (!isStaking && yieldItem) { + const provider = yieldProviders?.[yieldItem.providerId] + if (provider) return { name: provider.name, logoURI: provider.logoURI } + } + return null + }, [isStaking, selectedValidatorAddress, validators, yieldItem, yieldProviders]) - const { data: allBalancesData, isFetching: isBalancesFetching } = useAllYieldBalances() - const balances = yieldItem?.id ? allBalancesData?.normalized[yieldItem.id] : undefined - const isBalancesLoading = !allBalancesData && isBalancesFetching - const uniqueValidatorCount = balances?.validatorAddresses.length ?? 0 + const titleOverride = useMemo(() => { + if (!yieldItem) return undefined + const isNativeStaking = + yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection + if (isNativeStaking) return translate('yieldXYZ.nativeStaking') + // For non-native staking, use token symbol (consistent with cards) + return yieldItem.token.symbol + }, [yieldItem, translate]) + + const userBalances = useMemo(() => { + if (!balances) return { userCurrency: '0', crypto: '0' } + + const balancesByType = selectedValidatorAddress + ? balances.byValidatorAddress[selectedValidatorAddress] ?? balances.byType + : balances.byType + + const activeBalance = balancesByType[YieldBalanceType.Active] + const enteringBalance = balancesByType[YieldBalanceType.Entering] + const exitingBalance = balancesByType[YieldBalanceType.Exiting] + const withdrawableBalance = balancesByType[YieldBalanceType.Withdrawable] + + const totalCrypto = [ + activeBalance, + enteringBalance, + exitingBalance, + withdrawableBalance, + ].reduce((sum, b) => sum.plus(bnOrZero(b?.aggregatedAmount)), bnOrZero(0)) + + const totalUsd = [activeBalance, enteringBalance, exitingBalance, withdrawableBalance].reduce( + (sum, b) => sum.plus(bnOrZero(b?.aggregatedAmountUsd)), + bnOrZero(0), + ) + + return { + userCurrency: totalUsd.times(userCurrencyToUsdRate).toFixed(), + crypto: totalCrypto.toFixed(), + } + }, [balances, selectedValidatorAddress, userCurrencyToUsdRate]) useEffect(() => { if (!yieldId) navigate('/yields') }, [yieldId, navigate]) + const isModalOpen = useMemo(() => { + const modal = searchParams.get('modal') + return modal === 'yield' + }, [searchParams]) + const loadingElement = useMemo( () => ( - + {translate('common.loadingText')} @@ -81,8 +146,8 @@ export const YieldDetail = memo(() => { const errorElement = useMemo( () => ( - - + + {translate('common.error')} @@ -95,145 +160,37 @@ export const YieldDetail = memo(() => { ), - [error, heroBg, navigate, translate], + [error, navigate, translate], ) - const heroIcon = useMemo(() => { - if (!yieldItem) return null - const iconSource = resolveYieldInputAssetIcon(yieldItem) - if (iconSource.assetId) - return ( - - ) - return ( - - ) - }, [heroIconBorderColor, yieldItem]) - - const providerOrValidatorsElement = useMemo(() => { - if (!yieldItem) return null - if (shouldFetchValidators && validators && validators.length > 0 && uniqueValidatorCount > 1) - return ( - - - {validators.map(v => ( - - ))} - - - - {validators.length > 3 ? `${validators.length} Validators` : 'Validators'} - - - - ) - return ( - - - - - {yieldItem.providerId} - - - - ) - }, [ - heroTextColor, - providerLogo, - shouldFetchValidators, - uniqueValidatorCount, - validators, - yieldItem, - ]) - - const chainElement = useMemo(() => { - if (!yieldItem?.chainId) return null - return ( - - - - {yieldItem.network} - - - ) - }, [heroTextColor, yieldItem?.chainId, yieldItem?.network]) - if (isLoading) return loadingElement if (error || !yieldItem) return errorElement return ( - - - - - - {heroIcon} - - - {yieldItem.metadata.name} - - - {providerOrValidatorsElement} - - {chainElement} - - {yieldItem.metadata.description} - - - - - - - - - - - - - - - - - + + + + + + + + + {isModalOpen && } ) }) diff --git a/src/pages/Yields/components/TransactionStepsList.tsx b/src/pages/Yields/components/TransactionStepsList.tsx new file mode 100644 index 00000000000..3fc74468f2c --- /dev/null +++ b/src/pages/Yields/components/TransactionStepsList.tsx @@ -0,0 +1,113 @@ +import { Box, Flex, Icon, Link, Spinner, Text } from '@chakra-ui/react' +import { memo, useMemo } from 'react' +import { FaCheck, FaExternalLinkAlt, FaTimes } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' + +import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' +import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' + +type TransactionStepStatus = TransactionStep['status'] + +type TransactionStepsListProps = { + steps: TransactionStep[] +} + +const StepIcon = memo(({ status }: { status: TransactionStepStatus }) => { + if (status === 'success') { + return + } + if (status === 'failed') { + return + } + if (status === 'loading') { + return + } + return +}) + +const getStatusText = ( + step: TransactionStep, + translate: ReturnType, +): string | null => { + if (step.txHash) return null + if (step.loadingMessage) return step.loadingMessage + if (step.status === 'pending') return translate('yieldXYZ.loading.waiting') + if (step.status === 'success') return translate('yieldXYZ.loading.done') + if (step.status === 'failed') return translate('yieldXYZ.loading.failed') + return null +} + +export const TransactionStepsList = memo(({ steps }: TransactionStepsListProps) => { + const translate = useTranslate() + + const stepElements = useMemo(() => { + return steps.map((step, idx) => { + const statusText = getStatusText(step, translate) + return ( + + + + + {step.title} + + + {step.txHash && step.txUrl ? ( + + + + + ) : statusText ? ( + + {statusText} + + ) : null} + + ) + }) + }, [steps, translate]) + + if (!steps.length) return null + + return ( + + {stepElements} + + ) +}) diff --git a/src/pages/Yields/components/ValidatorBreakdown.tsx b/src/pages/Yields/components/ValidatorBreakdown.tsx deleted file mode 100644 index 067df5509a1..00000000000 --- a/src/pages/Yields/components/ValidatorBreakdown.tsx +++ /dev/null @@ -1,478 +0,0 @@ -import { - Avatar, - Box, - Button, - Card, - CardBody, - Collapse, - Divider, - Flex, - Heading, - HStack, - Skeleton, - Text, - useColorModeValue, - useDisclosure, - VStack, -} from '@chakra-ui/react' -import { fromAccountId } from '@shapeshiftoss/caip' -import type { FC } from 'react' -import { memo, useCallback, useMemo, useState } from 'react' -import { FaChevronDown, FaChevronUp } from 'react-icons/fa' -import { useTranslate } from 'react-polyglot' -import { useSearchParams } from 'react-router-dom' - -import { GradientApy } from './GradientApy' -import { YieldActionModal } from './YieldActionModal' - -import { Amount } from '@/components/Amount/Amount' -import { bnOrZero } from '@/lib/bignumber/bignumber' -import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' -import { YieldBalanceType } from '@/lib/yieldxyz/types' -import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' -import type { - AggregatedBalance, - NormalizedYieldBalances, - ValidatorSummary, -} from '@/react-queries/queries/yieldxyz/useAllYieldBalances' -import { - selectAccountIdByAccountNumberAndChainId, - selectUserCurrencyToUsdRate, -} from '@/state/slices/selectors' -import { useAppSelector } from '@/state/store' - -type ValidatorBreakdownProps = { - yieldItem: AugmentedYieldDto - balances: NormalizedYieldBalances | undefined - isBalancesLoading: boolean -} - -type ClaimModalData = { - validatorAddress: string - validatorName: string - validatorLogoURI: string | undefined - amount: string - assetSymbol: string - assetLogoURI: string | undefined - passthrough: string - manageActionType: string -} - -type ValidatorCardProps = { - validatorSummary: ValidatorSummary - isSelected: boolean - userCurrencyToUsdRate: string - hoverBg: string - onValidatorSwitch: (e: React.MouseEvent) => void - onClaimClick: (e: React.MouseEvent) => void - formatUnlockDate: (dateString: string | undefined) => string | null -} - -type BalanceRowProps = { - balance: AggregatedBalance | undefined - hasBalance: boolean - label: string - bg?: string - textColor?: string - dateColor?: string - valueColor?: string - showDate?: boolean - claimButton?: React.ReactNode -} - -const BalanceRow: FC = memo( - ({ balance, hasBalance, label, bg, textColor, valueColor, dateColor, showDate, claimButton }) => { - const translate = useTranslate() - const formatUnlockDate = useCallback((dateString: string | undefined) => { - if (!dateString) return null - const date = new Date(dateString) - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) - }, []) - - if (!balance || !hasBalance) return null - - const isStyledRow = !!bg - - if (isStyledRow) { - return ( - - {claimButton ? ( - <> - - - {translate(label)} - - - - - - {claimButton} - - ) : ( - <> - - - {translate(label)} - - {showDate && balance.date && ( - - ({formatUnlockDate(balance.date)}) - - )} - - - - - - )} - - ) - } - - return ( - - - {translate(label)} - - - - - - ) - }, -) - -const ValidatorCard: FC = memo( - ({ - validatorSummary, - isSelected, - userCurrencyToUsdRate, - hoverBg, - onValidatorSwitch, - onClaimClick, - }) => { - const translate = useTranslate() - const enteringBg = useColorModeValue('blue.50', 'blue.900') - const enteringTextColor = useColorModeValue('blue.700', 'blue.300') - const enteringDateColor = useColorModeValue('blue.600', 'blue.400') - const enteringValueColor = useColorModeValue('blue.800', 'blue.200') - const exitingBg = useColorModeValue('orange.50', 'orange.900') - const exitingTextColor = useColorModeValue('orange.700', 'orange.300') - const exitingDateColor = useColorModeValue('orange.600', 'orange.400') - const exitingValueColor = useColorModeValue('orange.800', 'orange.200') - const claimableBg = useColorModeValue('purple.50', 'purple.900') - const claimableTextColor = useColorModeValue('purple.700', 'purple.300') - const claimableValueColor = useColorModeValue('purple.800', 'purple.200') - - const { - validator, - byType, - totalUsd, - hasActive, - hasEntering, - hasExiting, - hasClaimable, - claimAction, - } = validatorSummary - - const activeBalance = byType[YieldBalanceType.Active] - const enteringBalance = byType[YieldBalanceType.Entering] - const exitingBalance = byType[YieldBalanceType.Exiting] - const claimableBalance = byType[YieldBalanceType.Claimable] - - const claimButton = useMemo( - () => - claimAction ? ( - - ) : null, - [claimAction, onClaimClick, translate], - ) - - return ( - - {!isSelected && ( - - )} - - - - - - {validator.name} - - {validator.apr !== undefined && bnOrZero(validator.apr).gt(0) && ( - - {bnOrZero(validator.apr).times(100).toFixed(2)}% APR - - )} - - - - - - - - - - - - - - ) - }, -) - -export const ValidatorBreakdown = memo( - ({ yieldItem, balances, isBalancesLoading }: ValidatorBreakdownProps) => { - const translate = useTranslate() - const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true }) - const [claimModalData, setClaimModalData] = useState(null) - const handleClaimClose = useCallback(() => setClaimModalData(null), []) - - const cardBg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const hoverBg = useColorModeValue('gray.50', 'gray.750') - - const { chainId } = yieldItem - const { accountNumber } = useYieldAccount() - const accountId = useAppSelector(state => { - if (!chainId) return undefined - const accountIdsByNumberAndChain = selectAccountIdByAccountNumberAndChainId(state) - return accountIdsByNumberAndChain[accountNumber]?.[chainId] - }) - const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) - const address = useMemo( - () => (accountId ? fromAccountId(accountId).account : undefined), - [accountId], - ) - - const [searchParams, setSearchParams] = useSearchParams() - const selectedValidator = useMemo(() => searchParams.get('validator'), [searchParams]) - - const requiresValidatorSelection = useMemo( - () => yieldItem.mechanics.requiresValidatorSelection, - [yieldItem.mechanics.requiresValidatorSelection], - ) - - const validators = useMemo( - () => (requiresValidatorSelection ? balances?.validators ?? [] : []), - [balances?.validators, requiresValidatorSelection], - ) - - const hasValidatorPositions = useMemo( - () => (requiresValidatorSelection ? balances?.hasValidatorPositions ?? false : false), - [balances?.hasValidatorPositions, requiresValidatorSelection], - ) - - const allPositionsTotalUserCurrency = useMemo( - () => - bnOrZero(balances?.totalUsd) - .times(userCurrencyToUsdRate) - .toFixed(), - [balances?.totalUsd, userCurrencyToUsdRate], - ) - - const formatUnlockDate = useCallback((dateString: string | undefined) => { - if (!dateString) return null - const date = new Date(dateString) - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) - }, []) - - const handleValidatorSwitch = useCallback( - (validatorAddress: string) => (e: React.MouseEvent) => { - e.stopPropagation() - setSearchParams(prev => { - prev.set('validator', validatorAddress) - return prev - }) - }, - [setSearchParams], - ) - - const handleClaimClick = useCallback( - (validatorSummary: ValidatorSummary, passthrough: string, manageActionType: string) => - (e: React.MouseEvent) => { - e.stopPropagation() - const claimableBalance = validatorSummary.byType[YieldBalanceType.Claimable] - setClaimModalData({ - validatorAddress: validatorSummary.validator.address, - validatorName: validatorSummary.validator.name, - validatorLogoURI: validatorSummary.validator.logoURI, - amount: claimableBalance?.aggregatedAmount ?? '0', - assetSymbol: claimableBalance?.token.symbol ?? '', - assetLogoURI: claimableBalance?.token.logoURI, - passthrough, - manageActionType, - }) - }, - [], - ) - - const loadingElement = useMemo( - () => ( - - - - - - - - - - ), - [borderColor, cardBg], - ) - - const claimModalElement = useMemo(() => { - if (!claimModalData) return null - return ( - - ) - }, [claimModalData, handleClaimClose, yieldItem]) - - if (!requiresValidatorSelection || !address) return null - if (isBalancesLoading) return loadingElement - if (!hasValidatorPositions) return null - - return ( - - - - - - {translate('yieldXYZ.allPositions')} - - - - - - - {isOpen ? : } - - - - - {validators.map((validatorSummary, index) => { - const isSelected = validatorSummary.validator.address === selectedValidator - - return ( - - {index > 0 && } - - - ) - })} - - - - {claimModalElement} - - ) - }, -) diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 45924cb3f17..719933082fe 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -1,34 +1,26 @@ -import { - Avatar, - Box, - Button, - Flex, - Heading, - Icon, - Link, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalOverlay, - Spinner, - Text, - useColorModeValue, - VStack, -} from '@chakra-ui/react' -import { keyframes } from '@emotion/react' -import type { Options } from 'canvas-confetti' -import { memo, useCallback, useEffect, useMemo, useRef } from 'react' -import ReactCanvasConfetti from 'react-canvas-confetti' -import type { TCanvasConfettiInstance } from 'react-canvas-confetti/dist/types' -import { FaCheck, FaExternalLinkAlt, FaWallet } from 'react-icons/fa' +import { Avatar, Box, Button, Flex, Text } from '@chakra-ui/react' +import { memo, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { Amount } from '@/components/Amount/Amount' -import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' +import { Dialog } from '@/components/Modal/components/Dialog' +import { DialogBody } from '@/components/Modal/components/DialogBody' +import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' +import { DialogFooter } from '@/components/Modal/components/DialogFooter' +import { DialogHeader } from '@/components/Modal/components/DialogHeader' +import { DialogTitle } from '@/components/Modal/components/DialogTitle' import { bnOrZero } from '@/lib/bignumber/bignumber' +import { + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, +} from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' +import { YieldAssetFlow } from '@/pages/Yields/components/YieldAssetFlow' +import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' @@ -38,13 +30,6 @@ import { } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -const walletIcon = -const checkIconBox = ( - - - -) - type YieldActionModalProps = { isOpen: boolean onClose: () => void @@ -58,6 +43,7 @@ type YieldActionModalProps = { validatorLogoURI?: string passthrough?: string manageActionType?: string + accountId?: string } export const YieldActionModal = memo(function YieldActionModal({ @@ -75,21 +61,20 @@ export const YieldActionModal = memo(function YieldActionModal({ ...props }: YieldActionModalProps) { const translate = useTranslate() - const modalBg = useColorModeValue('white', 'gray.900') - const modalBorderColor = useColorModeValue('gray.200', 'gray.700') - const cardBg = useColorModeValue('gray.50', 'gray.800') - const cardBorderColor = useColorModeValue('gray.200', 'whiteAlpha.100') - const subtleTextColor = useColorModeValue('gray.600', 'gray.400') - const avatarBg = useColorModeValue('gray.100', 'gray.900') const { step, transactionSteps, + displaySteps, isSubmitting, + activeStepIndex, canSubmit, handleConfirm, handleClose, isQuoteLoading, + quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, } = useYieldTransactionFlow({ yieldItem, action, @@ -100,6 +85,7 @@ export const YieldActionModal = memo(function YieldActionModal({ validatorAddress, passthrough, manageActionType: props.manageActionType, + accountId: props.accountId, }) const shouldFetchValidators = useMemo( @@ -122,6 +108,9 @@ export const YieldActionModal = memo(function YieldActionModal({ if (yieldItem.mechanics.type === 'staking' && validatorAddress) { const validator = validators?.find(v => v.address === validatorAddress) if (validator) return { name: validator.name, logoURI: validator.logoURI } + if (validatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { + return { name: SHAPESHIFT_VALIDATOR_NAME, logoURI: SHAPESHIFT_VALIDATOR_LOGO } + } if (validatorName) return { name: validatorName, logoURI: validatorLogoURI } } const provider = providers?.[yieldItem.providerId] @@ -132,19 +121,6 @@ export const YieldActionModal = memo(function YieldActionModal({ const chainId = useMemo(() => yieldItem.chainId ?? '', [yieldItem.chainId]) const feeAsset = useAppSelector(state => selectFeeAssetByChainId(state, chainId)) - const horizontalScroll = useMemo( - () => keyframes` - 0% { background-position: 0 0; } - 100% { background-position: 28px 0; } - `, - [], - ) - - const flexDirection = useMemo( - () => (action === 'enter' ? 'row' : 'row-reverse') as 'row' | 'row-reverse', - [action], - ) - const assetAvatarSrc = useMemo( () => assetLogoURI ?? yieldItem.token.logoURI, [assetLogoURI, yieldItem.token.logoURI], @@ -159,11 +135,11 @@ export const YieldActionModal = memo(function YieldActionModal({ const estimatedEarningsAmount = useMemo( () => - `${bnOrZero(amount) - .times(yieldItem.rewardRate.total) - .decimalPlaces(4) - .toString()} ${assetSymbol}/yr`, - [amount, yieldItem.rewardRate.total, assetSymbol], + translate('yieldXYZ.earningsPerYear', { + amount: bnOrZero(amount).times(yieldItem.rewardRate.total).decimalPlaces(4).toString(), + symbol: assetSymbol, + }), + [amount, yieldItem.rewardRate.total, assetSymbol, translate], ) const estimatedEarningsFiat = useMemo( @@ -191,318 +167,151 @@ export const YieldActionModal = memo(function YieldActionModal({ ) const isButtonLoading = useMemo( - () => isSubmitting || isQuoteLoading, - [isSubmitting, isQuoteLoading], + () => isSubmitting || isQuoteLoading || isAllowanceCheckPending, + [isSubmitting, isQuoteLoading, isAllowanceCheckPending], ) const loadingText = useMemo(() => { if (isQuoteLoading) return translate('yieldXYZ.loadingQuote') - if (action === 'enter') return translate('yieldXYZ.depositing') - if (action === 'exit') return translate('yieldXYZ.withdrawing') + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]?.loadingMessage) { + return transactionSteps[activeStepIndex].loadingMessage + } + if (action === 'enter') return translate('yieldXYZ.entering') + if (action === 'exit') return translate('yieldXYZ.exiting') return translate('common.claiming') - }, [isQuoteLoading, action, translate]) + }, [isQuoteLoading, action, translate, activeStepIndex, transactionSteps]) const buttonText = useMemo(() => { - if (action === 'enter') return translate('yieldXYZ.deposit') - if (action === 'exit') return translate('yieldXYZ.withdraw') + // Use the current step's type/title for a clean button label (e.g., "Enter", "Exit", "Approve") + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { + const step = transactionSteps[activeStepIndex] + return getTransactionButtonText(step.type, step.originalTitle) + } + // USDT reset required before other transactions + if (isUsdtResetRequired) { + return translate('yieldXYZ.resetAllowance') + } + // Before execution starts, use the first CREATED transaction from quoteData + const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED') + if (firstCreatedTx) { + return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) + } + // Fallback to action-based text + if (action === 'enter') return translate('yieldXYZ.enter') + if (action === 'exit') return translate('yieldXYZ.exit') return translate('common.claim') - }, [action, translate]) + }, [action, translate, activeStepIndex, transactionSteps, quoteData, isUsdtResetRequired]) const modalHeading = useMemo(() => { - if (action === 'enter') return translate('yieldXYZ.supplySymbol', { symbol: assetSymbol }) - if (action === 'exit') return translate('yieldXYZ.withdrawSymbol', { symbol: assetSymbol }) + if (action === 'enter') return translate('yieldXYZ.enterSymbol', { symbol: assetSymbol }) + if (action === 'exit') return translate('yieldXYZ.exitSymbol', { symbol: assetSymbol }) return translate('yieldXYZ.claimSymbol', { symbol: assetSymbol }) }, [action, assetSymbol, translate]) - const successMessage = useMemo(() => { - if (action === 'enter') - return translate('yieldXYZ.successDeposit', { symbol: assetSymbol, amount }) - if (action === 'exit') - return translate('yieldXYZ.successWithdraw', { symbol: assetSymbol, amount }) - return translate('yieldXYZ.successClaim', { symbol: assetSymbol, amount }) - }, [action, assetSymbol, amount, translate]) - const networkAvatarSrc = useMemo( () => feeAsset?.networkIcon ?? feeAsset?.icon, [feeAsset?.networkIcon, feeAsset?.icon], ) - const statusCard = useMemo( + const assetFlowDirection = action === 'exit' ? 'exit' : 'enter' + + const animatedAvatarRow = useMemo( + () => ( + + ), + [assetSymbol, assetAvatarSrc, vaultMetadata.name, vaultMetadata.logoURI, assetFlowDirection], + ) + + const statsContent = useMemo( () => ( - - - - - - - - - - - {assetSymbol} - - - - - - - - - - - - {vaultMetadata.name} - - + + + {translate('common.amount')} + + - - {action === 'enter' && ( - <> - - - {translate('yieldXYZ.apr')} + {action === 'enter' && ( + <> + + + {translate('yieldXYZ.apr')} + + + {aprFormatted} + + + {showEstimatedEarnings && ( + + + {translate('yieldXYZ.estEarnings')} - - {aprFormatted} - - - {showEstimatedEarnings && ( - - - {translate('yieldXYZ.estEarnings')} + + + {estimatedEarningsAmount} + + + - - - - {estimatedEarningsAmount} - - - - - - - )} - - )} - {showValidatorRow && ( - - - {translate('yieldXYZ.validator')} - - - - - {vaultMetadata.name} - - - )} - {!isStaking && ( - - - {translate('yieldXYZ.provider')} + )} + + )} + {showValidatorRow && ( + + + {translate('yieldXYZ.validator')} + + + + + {vaultMetadata.name} - - - - {vaultMetadata.name} - - - )} - - - {translate('yieldXYZ.network')} + + )} + {!isStaking && ( + + + {translate('yieldXYZ.provider')} - {feeAsset && } - - {yieldItem.network} + + + {vaultMetadata.name} - - - {transactionSteps.map((s, idx) => ( - - - {s.status === 'success' ? ( - - ) : s.status === 'loading' ? ( - - ) : ( - - )} - - {s.title} - - - {s.txHash ? ( - - - - ) : ( - - {s.status === 'success' - ? translate('yieldXYZ.loading.done') - : s.status === 'loading' - ? '' - : translate('yieldXYZ.loading.waiting')} - - )} - - ))} - + )} + + + {translate('yieldXYZ.network')} + + + {feeAsset && } + + {yieldItem.network} + + + ), [ - cardBg, - cardBorderColor, amount, assetSymbol, - flexDirection, - avatarBg, - assetAvatarSrc, - subtleTextColor, - horizontalScroll, - vaultMetadata.logoURI, - vaultMetadata.name, action, translate, aprFormatted, @@ -511,217 +320,117 @@ export const YieldActionModal = memo(function YieldActionModal({ estimatedEarningsFiat, showValidatorRow, isStaking, + vaultMetadata.logoURI, + vaultMetadata.name, feeAsset, networkAvatarSrc, yieldItem.network, - transactionSteps, ], ) const actionContent = useMemo( () => ( - - {statusCard} - - + + {animatedAvatarRow} + {statsContent} + + ), - [statusCard, handleConfirm, isButtonDisabled, isButtonLoading, loadingText, buttonText], + [animatedAvatarRow, statsContent, displaySteps], ) - const refAnimationInstance = useRef(null) - const getInstance = useCallback(({ confetti }: { confetti: TCanvasConfettiInstance }) => { - refAnimationInstance.current = confetti - }, []) + const successMessageKey = useMemo(() => { + if (action === 'enter') return 'successEnter' as const + if (action === 'exit') return 'successExit' as const + return 'successClaim' as const + }, [action]) - const makeShot = useCallback((particleRatio: number, opts: Partial) => { - if (refAnimationInstance.current) { - refAnimationInstance.current({ - ...opts, - origin: { y: 0.7 }, - particleCount: Math.floor(200 * particleRatio), - }) - } - }, []) - - const fireConfetti = useCallback(() => { - makeShot(0.25, { - spread: 26, - startVelocity: 55, - }) - makeShot(0.2, { - spread: 60, - }) - makeShot(0.35, { - spread: 100, - decay: 0.91, - scalar: 0.8, - }) - makeShot(0.1, { - spread: 120, - startVelocity: 25, - decay: 0.92, - scalar: 1.2, - }) - makeShot(0.1, { - spread: 120, - startVelocity: 45, - }) - }, [makeShot]) - - useEffect(() => { - if (step === ModalStep.Success) fireConfetti() - }, [step, fireConfetti]) + const successProviderInfo = useMemo( + () => (vaultMetadata ? { name: vaultMetadata.name, logoURI: vaultMetadata.logoURI } : null), + [vaultMetadata], + ) const successContent = useMemo( () => ( - - - - - - - {translate('yieldXYZ.success')} - - - {successMessage} - - - - - - {translate('yieldXYZ.transactions')} - - {transactionSteps.map((s, idx) => ( - - - - - {s.title} - - - {s.txHash && ( - - {translate('yieldXYZ.view')} - - )} - - ))} - - - - + ), - [translate, successMessage, transactionSteps, handleClose], - ) - - const confettiStyle = useMemo( - () => ({ - position: 'fixed' as const, - pointerEvents: 'none' as const, - width: '100%', - height: '100%', - top: 0, - left: 0, - zIndex: 9999, - }), - [], + [ + amount, + assetSymbol, + successProviderInfo, + transactionSteps, + yieldItem.id, + handleClose, + successMessageKey, + ], ) - const isNotSuccess = useMemo(() => step !== ModalStep.Success, [step]) - const isInProgress = useMemo(() => step === ModalStep.InProgress, [step]) - const isSuccess = useMemo(() => step === ModalStep.Success, [step]) - - const headerContent = useMemo(() => { - if (!isNotSuccess) return null - return ( - - - {modalHeading} - - - ) - }, [isNotSuccess, modalHeading]) + const isInProgress = step === ModalStep.InProgress + const isSuccess = step === ModalStep.Success return ( - <> - - - - - - {headerContent} - {isInProgress && actionContent} - {isSuccess && successContent} - - - - - + + + {null} + + {isSuccess ? translate('common.success') : modalHeading} + + + + + + + {isInProgress && actionContent} + {isSuccess && successContent} + + {isInProgress && ( + + + + )} + {isSuccess && ( + + + + )} + ) }) diff --git a/src/pages/Yields/components/YieldActivePositions.tsx b/src/pages/Yields/components/YieldActivePositions.tsx index f4859f227c4..eeb29dac83f 100644 --- a/src/pages/Yields/components/YieldActivePositions.tsx +++ b/src/pages/Yields/components/YieldActivePositions.tsx @@ -10,7 +10,6 @@ import { Th, Thead, Tr, - useColorModeValue, } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { memo, useCallback, useMemo } from 'react' @@ -18,10 +17,9 @@ import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' import { Amount } from '@/components/Amount/Amount' -import { AssetIcon } from '@/components/AssetIcon' import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' -import { resolveYieldInputAssetIcon, toUserCurrency } from '@/lib/yieldxyz/utils' +import { toUserCurrency } from '@/lib/yieldxyz/utils' import type { YieldBalanceAggregate } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { selectAssetById, selectUserCurrencyToUsdRate } from '@/state/slices/selectors' @@ -39,8 +37,6 @@ export const YieldActivePositions = memo( const navigate = useNavigate() const asset = useAppSelector(state => selectAssetById(state, assetId)) const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) - const hoverBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const borderColor = useColorModeValue('gray.100', 'whiteAlpha.100') const { data: providers } = useYieldProviders() @@ -69,8 +65,6 @@ export const YieldActivePositions = memo( [activeYields, aggregated], ) - const assetColumnHeader = useMemo(() => translate('yieldXYZ.asset') ?? 'Asset', [translate]) - const providerColumnHeader = useMemo( () => hasValidators @@ -88,14 +82,6 @@ export const YieldActivePositions = memo( [translate], ) - const yourBalanceLabel = useMemo(() => translate('defi.yourBalance'), [translate]) - - const renderAssetIcon = useCallback((yieldItem: AugmentedYieldDto) => { - const iconSource = resolveYieldInputAssetIcon(yieldItem) - if (iconSource.assetId) return - return - }, []) - const tableRows = useMemo(() => { if (!asset) return null @@ -114,17 +100,9 @@ export const YieldActivePositions = memo( return (
handleRowClick(yieldItem.id, validator.address)} > - handleRowClick(yieldItem.id)} > - ) }), - [rows, handleRowClick, hoverBg], + [rows, handleRowClick], ) const tbodyContent = useMemo( @@ -108,7 +95,7 @@ export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProp textAlign={meta?.textAlign} cursor={canSort ? 'pointer' : undefined} onClick={canSort ? sortingHandler : undefined} - _hover={canSort ? { color: hoverColor } : undefined} + _hover={canSort ? { color: 'text.base' } : undefined} > - -type YieldValidatorSelectModalProps = { - isOpen: boolean - onClose: () => void - validators: ValidatorDto[] - onSelect: (address: string) => void - balances?: AugmentedYieldBalanceWithAccountId[] -} - -export const YieldValidatorSelectModal = memo( - ({ isOpen, onClose, validators, onSelect, balances }: YieldValidatorSelectModalProps) => { - const translate = useTranslate() - const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) - const [searchQuery, setSearchQuery] = useState('') - const bgColor = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const hoverBg = useColorModeValue('gray.50', 'whiteAlpha.50') - - const balanceMap = useMemo(() => { - if (!balances) return new Map() - const map = new Map() - for (const balance of balances) { - if (!balance.validator || bnOrZero(balance.amount).lte(0)) continue - const addr = balance.validator.address - map.set( - addr, - bnOrZero(map.get(addr) || '0') - .plus(balance.amountUsd) - .toFixed(), - ) - } - return map - }, [balances]) - - const myValidators = useMemo(() => { - if (!balances) return [] - const seen = new Set() - const result: ValidatorDto[] = [] - for (const balance of balances) { - if ( - !balance.validator || - bnOrZero(balance.amount).lte(0) || - seen.has(balance.validator.address) - ) - continue - seen.add(balance.validator.address) - const full = validators.find(v => v.address === balance.validator?.address) - result.push( - full ?? { - address: balance.validator.address, - name: balance.validator.name, - logoURI: balance.validator.logoURI, - preferred: false, - votingPower: 0, - commission: balance.validator.commission ?? 0, - status: balance.validator.status ?? 'active', - tvl: '0', - tvlRaw: '0', - rewardRate: { - total: balance.validator.apr ?? 0, - rateType: 'APR' as const, - components: [], - }, - }, - ) - } - return result - }, [balances, validators]) - - const allValidators = validators - - const filteredAll = useMemo( - () => sortValidators(searchValidators(allValidators, searchQuery)), - [allValidators, searchQuery], - ) - - const filteredMy = useMemo( - () => searchValidators(myValidators, searchQuery), - [myValidators, searchQuery], - ) - - const handleSelect = useCallback( - (address: string) => { - onSelect(address) - onClose() - }, - [onSelect, onClose], - ) - - const handleSearchChange = useCallback( - (e: ChangeEvent) => setSearchQuery(e.target.value), - [], - ) - - const renderValidatorRow = useCallback( - (v: ValidatorDto) => { - const apr = v.rewardRate?.total ? `${(v.rewardRate.total * 100).toFixed(2)}%` : null - const usd = balanceMap.get(v.address) || '0' - const hasBalance = bnOrZero(usd).gt(0) - - return ( - handleSelect(v.address)} - > - - - - - {v.name} - {v.address === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS && ( - - {translate('yieldXYZ.preferred')} - - )} - - {hasBalance && ( - - - - )} - - - - {apr && ( - - {apr} {translate('yieldXYZ.apr')} - - )} - - - ) - }, - [balanceMap, userCurrencyToUsdRate, hoverBg, handleSelect, translate], - ) - - return ( - - - - {translate('yieldXYZ.selectValidator')} - - - - - {searchIcon} - - - - - - {`${translate('yieldXYZ.allValidators')} (${filteredAll.length})`} - {`${translate('yieldXYZ.myValidators')} (${filteredMy.length})`} - - - - - {filteredAll.length === 0 ? ( - - {translate('yieldXYZ.noValidatorsFound')} - - ) : ( - filteredAll.map(renderValidatorRow) - )} - - - - - {filteredMy.length === 0 ? ( - - {translate('yieldXYZ.noActiveValidators')} - - ) : ( - filteredMy.map(renderValidatorRow) - )} - - - - - - - - ) - }, -) diff --git a/src/pages/Yields/components/YieldViewHelpers.tsx b/src/pages/Yields/components/YieldViewHelpers.tsx index d8a068e1060..98fbad04f78 100644 --- a/src/pages/Yields/components/YieldViewHelpers.tsx +++ b/src/pages/Yields/components/YieldViewHelpers.tsx @@ -19,22 +19,22 @@ export const ViewToggle = memo(({ viewMode, setViewMode }: ViewToggleProps) => { const handleSetListView = useCallback(() => setViewMode('list'), [setViewMode]) return ( - - - - - - + + + + ) }) diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 9df01b97dba..2b4cb68b625 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -18,7 +18,7 @@ import { TabPanels, Tabs, Text, - useColorModeValue, + useMediaQuery, } from '@chakra-ui/react' import type { ColumnDef, Row } from '@tanstack/react-table' import { getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table' @@ -32,7 +32,18 @@ import { ChainIcon } from '@/components/ChainMenu' import { ResultsEmptyNoWallet } from '@/components/ResultsEmptyNoWallet' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { YIELD_NETWORK_TO_CHAIN_ID } from '@/lib/yieldxyz/constants' +import { fromBaseUnit } from '@/lib/math' +import { + COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, + FIGMENT_SOLANA_VALIDATOR_ADDRESS, + FIGMENT_VALIDATOR_LOGO, + FIGMENT_VALIDATOR_NAME, + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, + SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, + YIELD_NETWORK_TO_CHAIN_ID, +} from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto, YieldNetwork } from '@/lib/yieldxyz/types' import { resolveYieldInputAssetIcon, searchYields } from '@/lib/yieldxyz/utils' import { YieldFilters } from '@/pages/Yields/components/YieldFilters' @@ -45,18 +56,21 @@ import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYiel import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYields } from '@/react-queries/queries/yieldxyz/useYields' import { + selectAssets, + selectPortfolioAssetBalancesBaseUnit, selectPortfolioUserCurrencyBalances, selectUserCurrencyToUsdRate, } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' +const tabSelectedSx = { color: 'white', bg: 'blue.500' } + export const YieldsList = memo(() => { const translate = useTranslate() const navigate = useNavigate() const { state: walletState } = useWallet() const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) - const headerBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const searchInputBg = useColorModeValue('white', 'gray.800') + const [isMobile] = useMediaQuery('(max-width: 768px)') const [searchParams, setSearchParams] = useSearchParams() const tabParam = useMemo(() => searchParams.get('tab'), [searchParams]) const tabIndex = useMemo(() => (tabParam === 'my-positions' ? 1 : 0), [tabParam]) @@ -70,27 +84,33 @@ export const YieldsList = memo(() => { const setViewMode = useCallback( (mode: 'grid' | 'list') => { setSearchParams(prev => { - if (mode === 'grid') prev.delete('view') - else prev.set('view', mode) - return prev + const next = new URLSearchParams(prev) + if (mode === 'grid') next.delete('view') + else next.set('view', mode) + return next }) }, [setSearchParams], ) const [searchQuery, setSearchQuery] = useState('') + const filterSearchString = useMemo(() => searchParams.toString(), [searchParams]) const { selectedNetwork, selectedProvider, + selectedType, sortOption, sorting: positionsSorting, setSorting: setPositionsSorting, handleNetworkChange, handleProviderChange, + handleTypeChange, handleSortChange, } = useYieldFilters() const userCurrencyBalances = useAppSelector(selectPortfolioUserCurrencyBalances) + const assetBalancesBaseUnit = useAppSelector(selectPortfolioAssetBalancesBaseUnit) + const assets = useAppSelector(selectAssets) const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) const { @@ -110,9 +130,10 @@ export const YieldsList = memo(() => { const handleTabChange = useCallback( (index: number) => { setSearchParams(prev => { - if (index === 0) prev.delete('tab') - else prev.set('tab', 'my-positions') - return prev + const next = new URLSearchParams(prev) + if (index === 0) next.delete('tab') + else next.set('tab', 'my-positions') + return next }) }, [setSearchParams], @@ -120,9 +141,10 @@ export const YieldsList = memo(() => { const handleToggleMyOpportunities = useCallback(() => { setSearchParams(prev => { - if (isMyOpportunities) prev.delete('filter') - else prev.set('filter', 'my-assets') - return prev + const next = new URLSearchParams(prev) + if (isMyOpportunities) next.delete('filter') + else next.set('filter', 'my-assets') + return next }) }, [isMyOpportunities, setSearchParams]) @@ -131,6 +153,70 @@ export const YieldsList = memo(() => { [yieldProviders], ) + const getYieldDisplayInfo = useCallback( + (yieldItem: AugmentedYieldDto) => { + const isNativeStaking = + yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection + + if (yieldItem.id === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID) { + return { + name: SHAPESHIFT_VALIDATOR_NAME, + logoURI: SHAPESHIFT_VALIDATOR_LOGO, + title: translate('yieldXYZ.nativeStaking'), + } + } + if ( + yieldItem.id === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || + (yieldItem.id.includes('solana') && yieldItem.id.includes('native')) + ) { + return { + name: FIGMENT_VALIDATOR_NAME, + logoURI: FIGMENT_VALIDATOR_LOGO, + title: translate('yieldXYZ.nativeStaking'), + } + } + if (isNativeStaking) { + return { + name: yieldItem.metadata.name, + logoURI: yieldItem.metadata.logoURI, + title: translate('yieldXYZ.nativeStaking'), + } + } + const provider = yieldProviders?.[yieldItem.providerId] + return { name: provider?.name, logoURI: provider?.logoURI } + }, + [translate, yieldProviders], + ) + + const getYieldPositionBalanceUsd = useCallback( + (yieldId: string) => { + const yieldBalances = allBalances?.[yieldId] + if (!yieldBalances) return undefined + + // For Cosmos native staking, only show ShapeShift DAO validator balance + if (yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID) { + const filteredBalances = yieldBalances.filter( + b => b.validator?.address === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + ) + if (filteredBalances.length === 0) return undefined + return filteredBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) + } + + // For Solana native multivalidator staking, only show Figment validator balance + if (yieldId === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID) { + const filteredBalances = yieldBalances.filter( + b => b.validator?.address === FIGMENT_SOLANA_VALIDATOR_ADDRESS, + ) + if (filteredBalances.length === 0) return undefined + return filteredBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) + } + + // For other yields, sum all balances + return yieldBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) + }, + [allBalances], + ) + const handleSearchChange = useCallback( (e: React.ChangeEvent) => setSearchQuery(e.target.value), [], @@ -160,6 +246,15 @@ export const YieldsList = memo(() => { [yields, getProviderLogo], ) + const types = useMemo(() => { + if (!yields?.all) return [] + const uniqueTypes = [...new Set(yields.all.map(y => y.mechanics.type))] + return uniqueTypes.map(type => ({ + id: type, + name: type.charAt(0).toUpperCase() + type.slice(1).replace(/-/g, ' '), + })) + }, [yields]) + const yieldsByAsset = useMemo(() => { if (!yields?.assetGroups) return [] @@ -177,14 +272,16 @@ export const YieldsList = memo(() => { filteredYields = filteredYields.filter(y => y.network === selectedNetwork) if (selectedProvider) filteredYields = filteredYields.filter(y => y.providerId === selectedProvider) + if (selectedType) + filteredYields = filteredYields.filter(y => y.mechanics.type === selectedType) if (searchQuery) filteredYields = searchYields(filteredYields, searchQuery) if (filteredYields.length === 0) return null const userGroupBalanceUsd = filteredYields.reduce((acc, y) => { - const balances = allBalances?.[y.id] - if (!balances) return acc - return balances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), acc) + const balance = getYieldPositionBalanceUsd(y.id) + if (!balance) return acc + return acc.plus(balance) }, bnOrZero(0)) return { @@ -235,12 +332,113 @@ export const YieldsList = memo(() => { isMyOpportunities, selectedNetwork, selectedProvider, + selectedType, searchQuery, - allBalances, + getYieldPositionBalanceUsd, sortOption, userCurrencyBalances, ]) + const recommendedYields = useMemo(() => { + if (!isConnected || !yields?.byInputAssetId || !userCurrencyBalances || !assetBalancesBaseUnit) + return [] + + const recommendations: { + yield: AugmentedYieldDto + balanceFiat: ReturnType + potentialEarnings: ReturnType + }[] = [] + + for (const [assetId, balanceFiat] of Object.entries(userCurrencyBalances)) { + const yieldsForAsset = yields.byInputAssetId[assetId] + if (!yieldsForAsset?.length) continue + + const balance = bnOrZero(balanceFiat) + if (balance.lte(0)) continue + + // Filter to only include yields where user meets the minimum requirement + const eligibleYields = yieldsForAsset.filter(y => { + const minDeposit = bnOrZero(y.mechanics?.entryLimits?.minimum) + if (minDeposit.lte(0)) return true + + // minDeposit is human readable (e.g. 32), so convert base balance to human + const asset = assets[assetId] + if (!asset) return false + const baseBalance = bnOrZero(assetBalancesBaseUnit[assetId]) + const balanceHuman = bnOrZero(fromBaseUnit(baseBalance, asset.precision)) + return balanceHuman.gte(minDeposit) + }) + + if (!eligibleYields.length) continue + + const bestYield = eligibleYields.reduce((best, current) => + current.rewardRate.total > best.rewardRate.total ? current : best, + ) + + recommendations.push({ + yield: bestYield, + balanceFiat: balance, + potentialEarnings: balance.times(bestYield.rewardRate.total), + }) + } + + return recommendations + .sort((a, b) => b.potentialEarnings.minus(a.potentialEarnings).toNumber()) + .slice(0, 3) + }, [isConnected, yields?.byInputAssetId, userCurrencyBalances, assetBalancesBaseUnit, assets]) + + const availableYields = useMemo(() => { + if (!isConnected || !yields?.byInputAssetId || !userCurrencyBalances || !assetBalancesBaseUnit) + return [] + + const available: { + yield: AugmentedYieldDto + balanceFiat: ReturnType + }[] = [] + + for (const [assetId, balanceFiat] of Object.entries(userCurrencyBalances)) { + const yieldsForAsset = yields.byInputAssetId[assetId] + if (!yieldsForAsset?.length) continue + + const balance = bnOrZero(balanceFiat) + if (balance.lte(0)) continue + + const eligibleYields = yieldsForAsset.filter(y => { + const minDeposit = bnOrZero(y.mechanics?.entryLimits?.minimum) + if (minDeposit.gt(0)) { + const asset = assets[assetId] + if (!asset) return false + const baseBalance = bnOrZero(assetBalancesBaseUnit[assetId]) + const balanceHuman = bnOrZero(fromBaseUnit(baseBalance, asset.precision)) + if (balanceHuman.lt(minDeposit)) return false + } + if (selectedNetwork && y.network !== selectedNetwork) return false + if (selectedProvider && y.providerId !== selectedProvider) return false + if (selectedType && y.mechanics.type !== selectedType) return false + if (searchQuery && !searchYields([y], searchQuery).length) return false + return true + }) + + for (const yieldItem of eligibleYields) { + available.push({ yield: yieldItem, balanceFiat: balance }) + } + } + + return available.sort((a, b) => + bnOrZero(b.yield.rewardRate.total).minus(a.yield.rewardRate.total).toNumber(), + ) + }, [ + isConnected, + yields?.byInputAssetId, + userCurrencyBalances, + assetBalancesBaseUnit, + assets, + selectedNetwork, + selectedProvider, + selectedType, + searchQuery, + ]) + const myPositions = useMemo(() => { if (!yields?.all || !allBalances) return [] const positions = yields.all.filter(yieldItem => { @@ -324,18 +522,17 @@ export const YieldsList = memo(() => { accessorFn: row => row.providerId, enableSorting: true, sortingFn: 'alphanumeric', - cell: ({ row }) => ( - - - - {row.original.providerId} - - - ), + cell: ({ row }) => { + const tableDisplayInfo = getYieldDisplayInfo(row.original) + return ( + + + + {tableDisplayInfo.name ?? row.original.providerId} + + + ) + }, meta: { display: { base: 'none', md: 'table-cell' } }, }, { @@ -394,29 +591,17 @@ export const YieldsList = memo(() => { header: translate('yieldXYZ.yourBalance'), id: 'balance', accessorFn: row => { - const balances = allBalances?.[row.id] - if (!balances) return 0 - return balances - .reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) - .toNumber() + const balance = getYieldPositionBalanceUsd(row.id) + return balance?.toNumber() ?? 0 }, enableSorting: true, sortingFn: (rowA, rowB) => { - const balancesA = allBalances?.[rowA.original.id] - const balancesB = allBalances?.[rowB.original.id] - const a = balancesA - ? balancesA.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)).toNumber() - : 0 - const b = balancesB - ? balancesB.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)).toNumber() - : 0 + const a = getYieldPositionBalanceUsd(rowA.original.id)?.toNumber() ?? 0 + const b = getYieldPositionBalanceUsd(rowB.original.id)?.toNumber() ?? 0 return a === b ? 0 : a > b ? 1 : -1 }, cell: ({ row }) => { - const balances = allBalances?.[row.original.id] - const totalUsd = balances - ? balances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) - : bnOrZero(0) + const totalUsd = getYieldPositionBalanceUsd(row.original.id) ?? bnOrZero(0) if (totalUsd.lte(0)) return null const totalUserCurrency = totalUsd.times(userCurrencyToUsdRate).toFixed() return ( @@ -428,7 +613,7 @@ export const YieldsList = memo(() => { meta: { display: { base: 'none', lg: 'table-cell' } }, }, ], - [translate, getProviderLogo, allBalances, userCurrencyToUsdRate], + [translate, getYieldDisplayInfo, getYieldPositionBalanceUsd, userCurrencyToUsdRate], ) const positionsTable = useReactTable({ @@ -455,11 +640,11 @@ export const YieldsList = memo(() => { () => ( {Array.from({ length: 6 }).map((_, i) => ( - + ))} ), - [], + [isMobile], ) const allYieldsLoadingListElement = useMemo( @@ -482,93 +667,272 @@ export const YieldsList = memo(() => { [translate], ) - const allYieldsGridElement = useMemo( - () => ( - - {yieldsByAsset.map(group => ( - - ))} + const allYieldsGridElement = useMemo(() => { + if (isMyOpportunities) { + return ( + + {availableYields.map(item => { + const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) + const displayInfo = getYieldDisplayInfo(item.yield) + const inputSymbol = item.yield.inputTokens?.[0]?.symbol ?? item.yield.token.symbol + return ( + handleYieldClick(item.yield.id)} + /> + ) + })} + + ) + } + + return ( + + {yieldsByAsset.map(group => { + if (group.yields.length === 1) { + const singleYield = group.yields[0] + const inputAssetId = singleYield.inputTokens?.[0]?.assetId + const availableUsd = inputAssetId + ? bnOrZero(userCurrencyBalances[inputAssetId]) + : undefined + const singleDisplayInfo = getYieldDisplayInfo(singleYield) + return ( + handleYieldClick(singleYield.id)} + /> + ) + } + + return ( + + ) + })} - ), - [yieldsByAsset], - ) + ) + }, [ + isMyOpportunities, + availableYields, + getYieldPositionBalanceUsd, + filterSearchString, + yieldsByAsset, + isMobile, + getYieldDisplayInfo, + handleYieldClick, + userCurrencyBalances, + ]) - const allYieldsListElement = useMemo( - () => ( - - - + const allYieldsListElement = useMemo(() => { + const listHeader = ( + + + + {translate('yieldXYZ.asset')} + + + + - {translate('yieldXYZ.asset')} + {translate('yieldXYZ.maxApy')} - - - - - {translate('yieldXYZ.maxApy')} - - - - - {translate('yieldXYZ.tvl')} - - - - - {translate('yieldXYZ.providers')} - - - - - {translate('yieldXYZ.myBalance')} - - - + + + + {translate('yieldXYZ.tvl')} + + + + + {translate('yieldXYZ.providers')} + + + + + {translate('yieldXYZ.myBalance')} + + - {yieldsByAsset.map(group => ( - - ))} + + ) + + if (isMyOpportunities) { + return ( + + {listHeader} + {availableYields.map(item => { + const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) + const rowDisplayInfo = getYieldDisplayInfo(item.yield) + const rowInputSymbol = item.yield.inputTokens?.[0]?.symbol ?? item.yield.token.symbol + return ( + handleYieldClick(item.yield.id)} + /> + ) + })} + + ) + } + + return ( + + {listHeader} + {yieldsByAsset.map(group => { + if (group.yields.length === 1) { + const singleYield = group.yields[0] + const inputAssetId = singleYield.inputTokens?.[0]?.assetId + const availableUsd = inputAssetId + ? bnOrZero(userCurrencyBalances[inputAssetId]) + : undefined + const rowSingleDisplayInfo = getYieldDisplayInfo(singleYield) + return ( + handleYieldClick(singleYield.id)} + /> + ) + } + + return ( + + ) + })} - ), - [headerBg, translate, yieldsByAsset], - ) + ) + }, [ + isMyOpportunities, + availableYields, + getYieldPositionBalanceUsd, + filterSearchString, + translate, + yieldsByAsset, + getYieldDisplayInfo, + handleYieldClick, + userCurrencyBalances, + ]) + + const recommendedStripElement = useMemo(() => { + if (!isConnected || recommendedYields.length === 0 || isMyOpportunities) return null + + return ( + + + {translate('yieldXYZ.recommendedForYou')} + + + {recommendedYields.map(rec => { + const recDisplayInfo = getYieldDisplayInfo(rec.yield) + const inputSymbol = rec.yield.inputTokens?.[0]?.symbol ?? rec.yield.token.symbol + return ( + handleYieldClick(rec.yield.id)} + /> + ) + })} + + + ) + }, [ + isConnected, + recommendedYields, + isMyOpportunities, + translate, + getYieldDisplayInfo, + handleYieldClick, + isMobile, + ]) const allYieldsContentElement = useMemo(() => { if (isLoading) - return viewMode === 'grid' ? allYieldsLoadingGridElement : allYieldsLoadingListElement - if (yieldsByAsset.length === 0) return allYieldsEmptyElement - return viewMode === 'grid' ? allYieldsGridElement : allYieldsListElement + return viewMode === 'grid' || isMobile + ? allYieldsLoadingGridElement + : allYieldsLoadingListElement + const isEmpty = isMyOpportunities ? availableYields.length === 0 : yieldsByAsset.length === 0 + if (isEmpty) return allYieldsEmptyElement + return viewMode === 'grid' || isMobile ? allYieldsGridElement : allYieldsListElement }, [ allYieldsEmptyElement, allYieldsGridElement, @@ -576,8 +940,11 @@ export const YieldsList = memo(() => { allYieldsLoadingGridElement, allYieldsLoadingListElement, isLoading, + isMyOpportunities, + availableYields.length, viewMode, yieldsByAsset.length, + isMobile, ]) const positionsLoadingElement = useMemo( @@ -593,7 +960,7 @@ export const YieldsList = memo(() => { const positionsEmptyElement = useMemo( () => ( - + {translate('yieldXYZ.noYields')} @@ -607,30 +974,34 @@ export const YieldsList = memo(() => { const positionsGridElement = useMemo( () => ( - - {positionsTable.getRowModel().rows.map(row => ( - handleYieldClick(row.original.id)} - userBalanceUsd={ - allBalances?.[row.original.id] - ? allBalances[row.original.id].reduce( - (sum, b) => sum.plus(bnOrZero(b.amountUsd)), - bnOrZero(0), - ) - : undefined - } - /> - ))} + + {positionsTable.getRowModel().rows.map(row => { + const posDisplayInfo = getYieldDisplayInfo(row.original) + return ( + handleYieldClick(row.original.id)} + userBalanceUsd={ + allBalances?.[row.original.id] + ? allBalances[row.original.id].reduce( + (sum, b) => sum.plus(bnOrZero(b.amountUsd)), + bnOrZero(0), + ) + : undefined + } + /> + ) + })} ), - [allBalances, getProviderLogo, handleYieldClick, positionsTable], + [allBalances, getYieldDisplayInfo, handleYieldClick, positionsTable, isMobile], ) const positionsListElement = useMemo( @@ -657,7 +1028,7 @@ export const YieldsList = memo(() => { ) if (isLoading || isLoadingBalances) return positionsLoadingElement if (myPositions.length > 0) - return viewMode === 'grid' ? positionsGridElement : positionsListElement + return viewMode === 'grid' || isMobile ? positionsGridElement : positionsListElement return positionsEmptyElement }, [ isConnected, @@ -669,24 +1040,30 @@ export const YieldsList = memo(() => { positionsListElement, positionsLoadingElement, viewMode, + isMobile, ]) return ( - + {translate('yieldXYZ.pageTitle')} - {translate('yieldXYZ.pageSubtitle')} + {!isMobile && {translate('yieldXYZ.pageSubtitle')}} {errorElement} - + {isConnected && ( + + )} + {recommendedStripElement} { onChange={handleTabChange} > - {translate('common.all')} - - {translate('yieldXYZ.myPosition')} ({myPositions.length}) + {translate('common.all')} + + {translate('yieldXYZ.myPositions')} ({myPositions.length}) - + - - - - + {!isMobile && ( + <> + + + + )} + {allYieldsContentElement} diff --git a/src/pages/Yields/hooks/useConfetti.ts b/src/pages/Yields/hooks/useConfetti.ts new file mode 100644 index 00000000000..d19f9a3721f --- /dev/null +++ b/src/pages/Yields/hooks/useConfetti.ts @@ -0,0 +1,44 @@ +import type { Options } from 'canvas-confetti' +import { useCallback, useMemo, useRef } from 'react' +import type { TCanvasConfettiInstance } from 'react-canvas-confetti/dist/types' + +export const useConfetti = () => { + const refAnimationInstance = useRef(null) + + const getInstance = useCallback(({ confetti }: { confetti: TCanvasConfettiInstance }) => { + refAnimationInstance.current = confetti + }, []) + + const makeShot = useCallback((particleRatio: number, opts: Partial) => { + if (refAnimationInstance.current) { + refAnimationInstance.current({ + ...opts, + origin: { y: 0.7 }, + particleCount: Math.floor(200 * particleRatio), + }) + } + }, []) + + const fireConfetti = useCallback(() => { + makeShot(0.25, { spread: 26, startVelocity: 55 }) + makeShot(0.2, { spread: 60 }) + makeShot(0.35, { spread: 100, decay: 0.91, scalar: 0.8 }) + makeShot(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 }) + makeShot(0.1, { spread: 120, startVelocity: 45 }) + }, [makeShot]) + + const confettiStyle = useMemo( + () => ({ + position: 'fixed' as const, + pointerEvents: 'none' as const, + width: '100%', + height: '100%', + top: 0, + left: 0, + zIndex: 9999, + }), + [], + ) + + return { getInstance, fireConfetti, confettiStyle } +} diff --git a/src/pages/Yields/hooks/useYieldFilters.ts b/src/pages/Yields/hooks/useYieldFilters.ts index e9bb091ba37..2d9354d1140 100644 --- a/src/pages/Yields/hooks/useYieldFilters.ts +++ b/src/pages/Yields/hooks/useYieldFilters.ts @@ -1,26 +1,66 @@ -import type { SortingState } from '@tanstack/react-table' -import { useCallback, useEffect, useMemo, useState } from 'react' +import type { OnChangeFn, SortingState, Updater } from '@tanstack/react-table' +import { useCallback, useMemo } from 'react' import { useSearchParams } from 'react-router-dom' import type { SortOption } from '@/pages/Yields/components/YieldFilters' +const getSortingFromOption = (sortOption: SortOption): SortingState => { + switch (sortOption) { + case 'apy-desc': + return [{ id: 'apy', desc: true }] + case 'apy-asc': + return [{ id: 'apy', desc: false }] + case 'tvl-desc': + return [{ id: 'tvl', desc: true }] + case 'tvl-asc': + return [{ id: 'tvl', desc: false }] + case 'name-asc': + return [{ id: 'pool', desc: false }] + case 'name-desc': + return [{ id: 'pool', desc: true }] + default: + return [{ id: 'apy', desc: true }] + } +} + export const useYieldFilters = () => { const [searchParams, setSearchParams] = useSearchParams() - const [sorting, setSorting] = useState([{ id: 'apy', desc: true }]) const selectedNetwork = useMemo(() => searchParams.get('network'), [searchParams]) const selectedProvider = useMemo(() => searchParams.get('provider'), [searchParams]) + const selectedType = useMemo(() => searchParams.get('type'), [searchParams]) const sortOption = useMemo( () => (searchParams.get('sort') as SortOption) || 'apy-desc', [searchParams], ) + const sorting = useMemo(() => getSortingFromOption(sortOption), [sortOption]) + + const setSorting: OnChangeFn = useCallback( + (updaterOrValue: Updater) => { + const newSorting = + typeof updaterOrValue === 'function' ? updaterOrValue(sorting) : updaterOrValue + const sort = newSorting[0] + if (!sort) return + const option = `${sort.id === 'pool' ? 'name' : sort.id}-${ + sort.desc ? 'desc' : 'asc' + }` as SortOption + setSearchParams(prev => { + const next = new URLSearchParams(prev) + next.set('sort', option) + return next + }) + }, + [setSearchParams, sorting], + ) + const handleNetworkChange = useCallback( (network: string | null) => { setSearchParams(prev => { - if (!network) prev.delete('network') - else prev.set('network', network) - return prev + const next = new URLSearchParams(prev) + if (!network) next.delete('network') + else next.set('network', network) + return next }) }, [setSearchParams], @@ -29,9 +69,22 @@ export const useYieldFilters = () => { const handleProviderChange = useCallback( (provider: string | null) => { setSearchParams(prev => { - if (!provider) prev.delete('provider') - else prev.set('provider', provider) - return prev + const next = new URLSearchParams(prev) + if (!provider) next.delete('provider') + else next.set('provider', provider) + return next + }) + }, + [setSearchParams], + ) + + const handleTypeChange = useCallback( + (type: string | null) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev) + if (!type) next.delete('type') + else next.set('type', type) + return next }) }, [setSearchParams], @@ -40,46 +93,24 @@ export const useYieldFilters = () => { const handleSortChange = useCallback( (option: SortOption) => { setSearchParams(prev => { - prev.set('sort', option) - return prev + const next = new URLSearchParams(prev) + next.set('sort', option) + return next }) }, [setSearchParams], ) - useEffect(() => { - switch (sortOption) { - case 'apy-desc': - setSorting([{ id: 'apy', desc: true }]) - break - case 'apy-asc': - setSorting([{ id: 'apy', desc: false }]) - break - case 'tvl-desc': - setSorting([{ id: 'tvl', desc: true }]) - break - case 'tvl-asc': - setSorting([{ id: 'tvl', desc: false }]) - break - case 'name-asc': - setSorting([{ id: 'pool', desc: false }]) - break - case 'name-desc': - setSorting([{ id: 'pool', desc: true }]) - break - default: - break - } - }, [sortOption]) - return { selectedNetwork, selectedProvider, + selectedType, sortOption, sorting, setSorting, handleNetworkChange, handleProviderChange, + handleTypeChange, handleSortChange, } } diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 8349bc90348..81da1f75a1f 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -1,13 +1,19 @@ import { useToast } from '@chakra-ui/react' -import type { AssetId } from '@shapeshiftoss/caip' -import { cosmosChainId, fromAccountId } from '@shapeshiftoss/caip' +import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import { cosmosChainId, ethChainId, fromAccountId, usdtAssetId } from '@shapeshiftoss/caip' +import { assertGetViemClient } from '@shapeshiftoss/contracts' +import type { KnownChainIds } from '@shapeshiftoss/types' import { useQuery, useQueryClient } from '@tanstack/react-query' import { uuidv4 } from '@walletconnect/utils' import { useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' +import type { Hash } from 'viem' +import { SECOND_CLASS_CHAINS } from '@/constants/chains' +import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' +import { toBaseUnit } from '@/lib/math' import { enterYield, exitYield, fetchAction, manageYield } from '@/lib/yieldxyz/api' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, @@ -20,6 +26,8 @@ import type { ActionDto, AugmentedYieldDto, TransactionDto } from '@/lib/yieldxy import { ActionStatus as YieldActionStatus, TransactionStatus } from '@/lib/yieldxyz/types' import { formatYieldTxTitle } from '@/lib/yieldxyz/utils' import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' +import { reactQueries } from '@/react-queries' +import { useAllowance } from '@/react-queries/hooks/useAllowance' import { useSubmitYieldTransactionHash } from '@/react-queries/queries/yieldxyz/useSubmitYieldTransactionHash' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import { @@ -27,6 +35,7 @@ import { ActionType, GenericTransactionDisplayType, } from '@/state/slices/actionSlice/types' +import { portfolioApi } from '@/state/slices/portfolioSlice/portfolioSlice' import { selectPortfolioAccountMetadataByAccountId } from '@/state/slices/portfolioSlice/selectors' import { selectAccountIdByAccountNumberAndChainId, @@ -41,8 +50,9 @@ export enum ModalStep { export type TransactionStep = { title: string - status: 'pending' | 'success' | 'loading' + status: 'pending' | 'success' | 'loading' | 'failed' originalTitle: string + type?: string txHash?: string txUrl?: string loadingMessage?: string @@ -63,8 +73,8 @@ const poll = async ( throw new Error('Polling timed out') } -const waitForActionCompletion = (actionId: string): Promise => { - return poll( +export const waitForActionCompletion = (actionId: string): Promise => + poll( () => fetchAction(actionId), action => action.status === YieldActionStatus.Success, action => { @@ -73,9 +83,28 @@ const waitForActionCompletion = (actionId: string): Promise => { return undefined }, ) -} -const filterExecutableTransactions = (transactions: TransactionDto[]): TransactionDto[] => { +export const waitForTransactionConfirmation = ( + actionId: string, + transactionId: string, +): Promise => + poll( + () => fetchAction(actionId), + action => { + const tx = action.transactions.find(t => t.id === transactionId) + if (!tx) return false + return tx.status !== TransactionStatus.Created + }, + action => { + if (action.status === YieldActionStatus.Failed) return new Error('Action failed') + if (action.status === YieldActionStatus.Canceled) return new Error('Action was canceled') + const tx = action.transactions.find(t => t.id === transactionId) + if (tx?.status === TransactionStatus.Failed) return new Error('Transaction failed') + return undefined + }, + ) + +export const filterExecutableTransactions = (transactions: TransactionDto[]): TransactionDto[] => { const seen = new Set() return transactions.filter(tx => { if (tx.status !== TransactionStatus.Created) return false @@ -85,8 +114,31 @@ const filterExecutableTransactions = (transactions: TransactionDto[]): Transacti }) } +export const getSpenderFromApprovalTx = (tx: TransactionDto): string | null => { + try { + const parsed = JSON.parse(tx.unsignedTransaction) + const data = parsed.data as string | undefined + if (!data || !data.toLowerCase().startsWith('0x095ea7b3')) return null + return ('0x' + data.slice(10, 74).slice(-40)).toLowerCase() + } catch { + return null + } +} + +export const isApprovalTransaction = (tx: TransactionDto): boolean => { + const type = tx.type?.toUpperCase() + return type === 'APPROVE' || type === 'APPROVAL' +} + +export const isUsdtOnEthereumMainnet = ( + assetId: string | undefined, + chainId: ChainId | undefined, +): boolean => { + return assetId === usdtAssetId && chainId === ethChainId +} + type UseYieldTransactionFlowProps = { - yieldItem: AugmentedYieldDto + yieldItem: AugmentedYieldDto | undefined action: 'enter' | 'exit' | 'manage' amount: string assetSymbol: string @@ -95,6 +147,7 @@ type UseYieldTransactionFlowProps = { validatorAddress?: string passthrough?: string manageActionType?: string + accountId?: string } export const useYieldTransactionFlow = ({ @@ -107,6 +160,7 @@ export const useYieldTransactionFlow = ({ validatorAddress, passthrough, manageActionType, + accountId: accountIdProp, }: UseYieldTransactionFlowProps) => { const dispatch = useAppDispatch() const queryClient = useQueryClient() @@ -122,17 +176,24 @@ export const useYieldTransactionFlow = ({ const [isSubmitting, setIsSubmitting] = useState(false) const [activeStepIndex, setActiveStepIndex] = useState(-1) const [currentActionId, setCurrentActionId] = useState(null) + const [resetTxHash, setResetTxHash] = useState(null) + const isUsdtApprovalResetEnabled = useFeatureFlag('UsdtApprovalReset') const submitHashMutation = useSubmitYieldTransactionHash() - const { chainId: yieldChainId } = yieldItem - const { accountNumber } = useYieldAccount() + const inputTokenAssetId = useMemo(() => yieldItem?.inputTokens?.[0]?.assetId, [yieldItem]) + + const yieldChainId = yieldItem?.chainId + const { accountNumber: contextAccountNumber } = useYieldAccount() - const accountId = useAppSelector(state => { + const derivedAccountId = useAppSelector(state => { + if (accountIdProp) return undefined if (!yieldChainId) return undefined - return selectAccountIdByAccountNumberAndChainId(state)[accountNumber]?.[yieldChainId] + return selectAccountIdByAccountNumberAndChainId(state)[contextAccountNumber]?.[yieldChainId] }) + const accountId = accountIdProp ?? derivedAccountId + const feeAsset = useAppSelector(state => yieldChainId ? selectFeeAssetByChainId(state, yieldChainId) : undefined, ) @@ -192,9 +253,9 @@ export const useYieldTransactionFlow = ({ isLoading: isQuoteLoading, error: quoteError, } = useQuery({ - queryKey: ['yieldxyz', 'quote', action, yieldItem.id, userAddress, txArguments], + queryKey: ['yieldxyz', 'quote', action, yieldItem?.id, userAddress, txArguments], queryFn: () => { - if (!txArguments || !userAddress || !yieldItem.id) throw new Error('Missing arguments') + if (!txArguments || !userAddress || !yieldItem?.id) throw new Error('Missing arguments') if (action === 'manage') { if (!passthrough) throw new Error('Missing passthrough for manage action') @@ -210,11 +271,107 @@ export const useYieldTransactionFlow = ({ const fn = action === 'enter' ? enterYield : exitYield return fn({ yieldId: yieldItem.id, address: userAddress, arguments: txArguments }) }, - enabled: !!txArguments && !!wallet && !!accountId && canSubmit && isOpen, - staleTime: 60_000, + enabled: !!txArguments && !!wallet && !!accountId && !!yieldItem && canSubmit && isOpen, + staleTime: 0, + gcTime: 0, retry: false, }) + // USDT reset logic - only for enter action on USDT/ETH + const approvalSpender = useMemo(() => { + if (action !== 'enter') return null + if (!quoteData?.transactions) return null + const createdTransactions = quoteData.transactions.filter( + tx => tx.status === TransactionStatus.Created, + ) + const approvalTx = createdTransactions.find(isApprovalTransaction) + if (!approvalTx) return null + return getSpenderFromApprovalTx(approvalTx) + }, [action, quoteData?.transactions]) + + const allowanceQuery = useAllowance({ + assetId: inputTokenAssetId, + spender: approvalSpender ?? undefined, + from: userAddress || undefined, + isDisabled: + !approvalSpender || + !isUsdtApprovalResetEnabled || + action !== 'enter' || + !isUsdtOnEthereumMainnet(inputTokenAssetId, yieldChainId), + isRefetchEnabled: true, + }) + + const isUsdtResetRequired = useMemo(() => { + if (action !== 'enter') return false + if (!isUsdtApprovalResetEnabled) return false + if (!isUsdtOnEthereumMainnet(inputTokenAssetId, yieldChainId)) return false + if (!approvalSpender) return false + if (!allowanceQuery.data) return false + return bnOrZero(allowanceQuery.data).gt(0) + }, [ + action, + isUsdtApprovalResetEnabled, + inputTokenAssetId, + yieldChainId, + approvalSpender, + allowanceQuery.data, + ]) + + // Check if we're waiting for USDT allowance check before we can determine reset requirement + const isAllowanceCheckPending = useMemo(() => { + if (action !== 'enter') return false + if (!isUsdtApprovalResetEnabled) return false + if (!isUsdtOnEthereumMainnet(inputTokenAssetId, yieldChainId)) return false + if (!approvalSpender) return false + // If we have an approval spender for USDT but allowance data hasn't loaded yet + return allowanceQuery.data === undefined && !allowanceQuery.isError + }, [ + action, + isUsdtApprovalResetEnabled, + inputTokenAssetId, + yieldChainId, + approvalSpender, + allowanceQuery.data, + allowanceQuery.isError, + ]) + + const displaySteps = useMemo((): TransactionStep[] => { + if (transactionSteps.length > 0) { + return transactionSteps + } + if (isAllowanceCheckPending) return [] + if (quoteData?.transactions?.length) { + const steps: TransactionStep[] = [] + if (isUsdtResetRequired) { + steps.push({ + title: translate('yieldXYZ.resetAllowance'), + originalTitle: 'Reset Allowance', + type: 'RESET', + status: 'pending' as const, + }) + } + steps.push( + ...quoteData.transactions + .filter(tx => tx.status === TransactionStatus.Created) + .map((tx, i) => ({ + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), + originalTitle: tx.title || '', + type: tx.type, + status: 'pending' as const, + })), + ) + return steps + } + return [] + }, [ + transactionSteps, + quoteData, + assetSymbol, + isAllowanceCheckPending, + isUsdtResetRequired, + translate, + ]) + const updateStepStatus = useCallback((index: number, updates: Partial) => { setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...updates } : s))) }, []) @@ -234,7 +391,7 @@ export const useYieldTransactionFlow = ({ const dispatchNotification = useCallback( (tx: TransactionDto, txHash: string) => { - if (!yieldChainId || !accountId) return + if (!yieldChainId || !accountId || !yieldItem) return if (!yieldItem.token.assetId) { console.warn('[useYieldTransactionFlow] Cannot dispatch notification: missing assetId') return @@ -259,11 +416,17 @@ export const useYieldTransactionFlow = ({ // For now, KISS and simply don't handle claims in action center. if (action === 'manage') return + const typeMessagesMap: Partial> = { + [ActionType.Deposit]: 'actionCenter.deposit.complete', + [ActionType.Withdraw]: 'actionCenter.withdrawal.complete', + [ActionType.Approve]: 'actionCenter.approve.approvalTxComplete', + } + dispatch( actionSlice.actions.upsertAction({ id: uuidv4(), type: actionType, - status: ActionStatus.Pending, + status: ActionStatus.Complete, createdAt: Date.now(), updatedAt: Date.now(), transactionMetadata: { @@ -272,41 +435,104 @@ export const useYieldTransactionFlow = ({ chainId: yieldChainId, assetId: yieldItem.token.assetId as AssetId, accountId, - message: formatYieldTxTitle(tx.title || 'Transaction', assetSymbol), + message: + typeMessagesMap[actionType] ?? + formatYieldTxTitle(tx.title || 'Transaction', assetSymbol), amountCryptoPrecision: amount, + contractName: yieldItem.metadata.name, + chainName: yieldItem.network, }, }), ) }, - [dispatch, yieldChainId, accountId, action, yieldItem.token.assetId, assetSymbol, amount], + [dispatch, yieldChainId, accountId, action, yieldItem, assetSymbol, amount], ) const buildCosmosStakeArgs = useCallback((): CosmosStakeArgs | undefined => { - if (yieldChainId !== cosmosChainId) return undefined + if (yieldChainId !== cosmosChainId || !yieldItem) return undefined const validator = validatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[cosmosChainId] if (!validator) return undefined - const inputTokenDecimals = yieldItem.inputTokens[0]?.decimals ?? yieldItem.token.decimals + const inputTokenDecimals = + yieldItem.inputTokens?.[0]?.decimals ?? yieldItem.token?.decimals ?? 18 return { validator, - amountCryptoBaseUnit: bnOrZero(amount).times(bnOrZero(10).pow(inputTokenDecimals)).toFixed(0), + amountCryptoBaseUnit: toBaseUnit(amount, inputTokenDecimals), action: action === 'enter' ? 'stake' : action === 'exit' ? 'unstake' : 'claim', } + }, [yieldChainId, validatorAddress, amount, yieldItem, action]) + + const executeResetAllowance = useCallback(async () => { + if (!wallet || !accountId || !inputTokenAssetId || !approvalSpender) { + throw new Error(translate('yieldXYZ.errors.walletNotConnected')) + } + + setIsSubmitting(true) + updateStepStatus(0, { + status: 'loading', + }) + + try { + const txHash = await reactQueries.mutations + .approve({ + assetId: inputTokenAssetId, + spender: approvalSpender, + amountCryptoBaseUnit: '0', + accountNumber: accountMetadata?.bip44Params?.accountNumber ?? 0, + wallet, + from: userAddress, + }) + .mutationFn() + + if (!txHash) throw new Error(translate('yieldXYZ.errors.broadcastFailed')) + + setResetTxHash(txHash) + const txUrl = feeAsset?.explorerTxLink ? `${feeAsset.explorerTxLink}${txHash}` : '' + updateStepStatus(0, { txHash, txUrl, loadingMessage: translate('common.confirming') }) + + const publicClient = assertGetViemClient(ethChainId) + await publicClient.waitForTransactionReceipt({ hash: txHash as Hash }) + + await allowanceQuery.refetch() + updateStepStatus(0, { status: 'success', loadingMessage: undefined }) + setActiveStepIndex(1) + } catch (error) { + console.error('Reset allowance failed:', error) + toast({ + title: translate('yieldXYZ.errors.transactionFailedTitle'), + description: + error instanceof Error + ? error.message + : translate('yieldXYZ.errors.transactionFailedDescription'), + status: 'error', + duration: 5000, + isClosable: true, + }) + updateStepStatus(0, { status: 'failed', loadingMessage: undefined }) + } finally { + setIsSubmitting(false) + } }, [ - yieldChainId, - validatorAddress, - amount, - yieldItem.inputTokens, - yieldItem.token.decimals, - action, + wallet, + accountId, + inputTokenAssetId, + approvalSpender, + accountMetadata?.bip44Params?.accountNumber, + userAddress, + feeAsset?.explorerTxLink, + translate, + updateStepStatus, + toast, + allowanceQuery, ]) const executeSingleTransaction = useCallback( async ( tx: TransactionDto, - index: number, + yieldTxIndex: number, + uiStepIndex: number, allTransactions: TransactionDto[], actionId: string, ) => { @@ -314,9 +540,8 @@ export const useYieldTransactionFlow = ({ throw new Error(translate('yieldXYZ.errors.walletNotConnected')) } - updateStepStatus(index, { + updateStepStatus(uiStepIndex, { status: 'loading', - loadingMessage: translate('yieldXYZ.loading.signInWallet'), }) setIsSubmitting(true) @@ -335,8 +560,13 @@ export const useYieldTransactionFlow = ({ const txUrl = feeAsset ? `${feeAsset.explorerTxLink}${txHash}` : '' - updateStepStatus(index, { txHash, txUrl, loadingMessage: translate('common.confirming') }) + updateStepStatus(uiStepIndex, { + txHash, + txUrl, + loadingMessage: translate('common.confirming'), + }) + if (!yieldItem?.id) throw new Error('Missing yield item') await submitHashMutation.mutateAsync({ transactionId: tx.id, hash: txHash, @@ -344,31 +574,57 @@ export const useYieldTransactionFlow = ({ address: userAddress, }) - const isLastTransaction = index + 1 >= allTransactions.length + const isLastTransaction = yieldTxIndex + 1 >= allTransactions.length if (isLastTransaction) { await waitForActionCompletion(actionId) - queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'allBalances'] }) - queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'yields'] }) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) + if ( + yieldChainId && + accountId && + SECOND_CLASS_CHAINS.includes(yieldChainId as KnownChainIds) + ) { + dispatch( + portfolioApi.endpoints.getAccount.initiate( + { accountId, upsertOnFetch: true }, + { forceRefetch: true }, + ), + ) + } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote'] }) setStep(ModalStep.Success) } else { - const freshAction = await fetchAction(actionId) - const nextTx = freshAction.transactions.find( - t => t.status === TransactionStatus.Created && t.stepIndex === index + 1, + const confirmedAction = await waitForTransactionConfirmation(actionId, tx.id) + const nextTx = confirmedAction.transactions.find( + t => t.status === TransactionStatus.Created && t.stepIndex === yieldTxIndex + 1, ) if (nextTx) { - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) - setRawTransactions(prev => prev.map((t, i) => (i === index + 1 ? nextTx : t))) - setActiveStepIndex(index + 1) + updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) + setRawTransactions(prev => prev.map((t, i) => (i === yieldTxIndex + 1 ? nextTx : t))) + setActiveStepIndex(uiStepIndex + 1) } else { await waitForActionCompletion(actionId) - queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'allBalances'] }) - queryClient.invalidateQueries({ queryKey: ['yieldxyz', 'yields'] }) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) + await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) + if ( + yieldChainId && + accountId && + SECOND_CLASS_CHAINS.includes(yieldChainId as KnownChainIds) + ) { + dispatch( + portfolioApi.endpoints.getAccount.initiate( + { accountId, upsertOnFetch: true }, + { forceRefetch: true }, + ), + ) + } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote'] }) setStep(ModalStep.Success) } } @@ -378,7 +634,7 @@ export const useYieldTransactionFlow = ({ 'yieldXYZ.errors.transactionFailedTitle', 'yieldXYZ.errors.transactionFailedDescription', ) - updateStepStatus(index, { status: 'pending', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'failed', loadingMessage: undefined }) } finally { setIsSubmitting(false) } @@ -390,7 +646,7 @@ export const useYieldTransactionFlow = ({ userAddress, accountMetadata?.bip44Params, feeAsset, - yieldItem.id, + yieldItem, translate, updateStepStatus, buildCosmosStakeArgs, @@ -398,23 +654,41 @@ export const useYieldTransactionFlow = ({ queryClient, dispatchNotification, showErrorToast, + dispatch, ], ) const handleClose = useCallback(() => { if (isSubmitting) return + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote'] }) setStep(ModalStep.InProgress) setTransactionSteps([]) setRawTransactions([]) setActiveStepIndex(-1) setCurrentActionId(null) + setResetTxHash(null) onClose() - }, [isSubmitting, onClose]) + }, [isSubmitting, onClose, queryClient]) const handleConfirm = useCallback(async () => { - if (activeStepIndex >= 0 && rawTransactions[activeStepIndex] && currentActionId) { + // Handle USDT reset step if required and not yet done + const shouldExecuteReset = isUsdtResetRequired && activeStepIndex === 0 && !resetTxHash + + if (shouldExecuteReset) { + await executeResetAllowance() + return + } + + // Calculate the yield transaction index (offset by 1 if we had a reset step) + // Use resetTxHash as indicator, not isUsdtResetRequired (which changes to false after reset) + const hadResetStep = Boolean(resetTxHash) + const yieldStepIndex = hadResetStep ? activeStepIndex - 1 : activeStepIndex + + // If we're in the middle of a multi-step flow, execute the next step + if (yieldStepIndex >= 0 && rawTransactions[yieldStepIndex] && currentActionId) { await executeSingleTransaction( - rawTransactions[activeStepIndex], + rawTransactions[yieldStepIndex], + yieldStepIndex, activeStepIndex, rawTransactions, currentActionId, @@ -470,16 +744,35 @@ export const useYieldTransactionFlow = ({ setCurrentActionId(quoteData.id) setRawTransactions(transactions) - setTransactionSteps( - transactions.map((tx, i) => ({ + + // Build transaction steps with reset step if needed + const steps: TransactionStep[] = [] + if (isUsdtResetRequired) { + steps.push({ + title: translate('yieldXYZ.resetAllowance'), + originalTitle: 'Reset Allowance', + type: 'RESET', + status: 'pending', + }) + } + steps.push( + ...transactions.map((tx, i) => ({ title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), originalTitle: tx.title || '', + type: tx.type, status: 'pending' as const, })), ) + + setTransactionSteps(steps) setActiveStepIndex(0) - await executeSingleTransaction(transactions[0], 0, transactions, quoteData.id) + // Execute first step (reset if required, otherwise first yield tx) + if (isUsdtResetRequired) { + await executeResetAllowance() + } else { + await executeSingleTransaction(transactions[0], 0, 0, transactions, quoteData.id) + } } catch (error) { console.error('Failed to initiate action:', error) showErrorToast( @@ -490,9 +783,13 @@ export const useYieldTransactionFlow = ({ setTransactionSteps([]) } }, [ + isUsdtResetRequired, activeStepIndex, - rawTransactions, + resetTxHash, currentActionId, + rawTransactions, + executeResetAllowance, + executeSingleTransaction, yieldChainId, wallet, accountId, @@ -503,29 +800,36 @@ export const useYieldTransactionFlow = ({ assetSymbol, translate, showErrorToast, - executeSingleTransaction, ]) return useMemo( () => ({ step, transactionSteps, + displaySteps, isSubmitting, activeStepIndex, canSubmit, handleConfirm, handleClose, isQuoteLoading, + quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, }), [ step, transactionSteps, + displaySteps, isSubmitting, activeStepIndex, canSubmit, handleConfirm, handleClose, isQuoteLoading, + quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, ], ) } diff --git a/src/react-queries/queries/yieldxyz/useYieldValidators.ts b/src/react-queries/queries/yieldxyz/useYieldValidators.ts index 0962b595ddf..0c0b5842ec4 100644 --- a/src/react-queries/queries/yieldxyz/useYieldValidators.ts +++ b/src/react-queries/queries/yieldxyz/useYieldValidators.ts @@ -1,7 +1,27 @@ import { skipToken, useQuery } from '@tanstack/react-query' import { fetchYieldValidators } from '@/lib/yieldxyz/api' +import { + COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + SHAPESHIFT_VALIDATOR, +} from '@/lib/yieldxyz/constants' import type { ValidatorDto } from '@/lib/yieldxyz/types' +import { ensureValidatorApr } from '@/lib/yieldxyz/utils' + +const normalizeCosmosValidators = (validators: ValidatorDto[]): ValidatorDto[] => { + const existingShapeshift = validators.find(v => v.address === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) + + const shapeshiftValidator: ValidatorDto = existingShapeshift?.rewardRate?.total + ? { ...existingShapeshift, preferred: true } + : { ...SHAPESHIFT_VALIDATOR, ...existingShapeshift, preferred: true } + + const otherValidators = validators + .filter(v => v.address !== SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) + .map(v => ensureValidatorApr({ ...v, preferred: false })) + + return [shapeshiftValidator, ...otherValidators] +} export const useYieldValidators = (yieldId: string, enabled: boolean = true) => { return useQuery({ @@ -10,7 +30,10 @@ export const useYieldValidators = (yieldId: string, enabled: boolean = true) => yieldId && enabled ? async () => { const data = await fetchYieldValidators(yieldId) - return data.items + const validators = data.items + if (yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID) + return normalizeCosmosValidators(validators) + return validators } : skipToken, staleTime: 1000 * 60 * 60, diff --git a/src/state/reducer.ts b/src/state/reducer.ts index ffeaf6cc684..363a1080d99 100644 --- a/src/state/reducer.ts +++ b/src/state/reducer.ts @@ -49,6 +49,7 @@ import { txHistory, txHistoryApi } from './slices/txHistorySlice/txHistorySlice' import { gridplusSlice } from '@/state/slices/gridplusSlice/gridplusSlice' import type { GridPlusState } from '@/state/slices/gridplusSlice/types' +import { tradeEarnInput } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice' import { tradeQuoteSlice } from '@/state/slices/tradeQuoteSlice/tradeQuoteSlice' import { tradeRampInput } from '@/state/slices/tradeRampInputSlice/tradeRampInputSlice' @@ -62,6 +63,7 @@ export const slices = { tradeInput, limitOrderInput, tradeRampInput, + tradeEarnInput, tradeQuote: tradeQuoteSlice, limitOrder: limitOrderSlice, snapshot, @@ -166,6 +168,7 @@ export const sliceReducers = { tradeInput: tradeInput.reducer, limitOrderInput: limitOrderInput.reducer, tradeRampInput: tradeRampInput.reducer, + tradeEarnInput: tradeEarnInput.reducer, opportunities: persistReducer( opportunitiesPersistConfig, opportunities.reducer, diff --git a/src/state/slices/actionSlice/types.ts b/src/state/slices/actionSlice/types.ts index 08fb078224b..e63a15d63d0 100644 --- a/src/state/slices/actionSlice/types.ts +++ b/src/state/slices/actionSlice/types.ts @@ -114,6 +114,7 @@ type ActionGenericTransactionMetadata = { amountCryptoPrecision: string | undefined newAddress?: string contractName?: string + chainName?: string cooldownPeriod?: string cooldownPeriodSeconds?: number thorMemo?: string | null diff --git a/src/state/slices/preferencesSlice/preferencesSlice.ts b/src/state/slices/preferencesSlice/preferencesSlice.ts index 63c56c1aed2..41d2c8f3c8b 100644 --- a/src/state/slices/preferencesSlice/preferencesSlice.ts +++ b/src/state/slices/preferencesSlice/preferencesSlice.ts @@ -111,7 +111,9 @@ export type FeatureFlags = { AddressBook: boolean AppRating: boolean YieldXyz: boolean + YieldsPage: boolean YieldMultiAccount: boolean + EarnTab: boolean } export type Flag = keyof FeatureFlags @@ -257,7 +259,9 @@ const initialState: Preferences = { AddressBook: getConfig().VITE_FEATURE_ADDRESS_BOOK, AppRating: getConfig().VITE_FEATURE_APP_RATING, YieldXyz: getConfig().VITE_FEATURE_YIELD_XYZ, + YieldsPage: getConfig().VITE_FEATURE_YIELDS_PAGE, YieldMultiAccount: getConfig().VITE_FEATURE_YIELD_MULTI_ACCOUNT, + EarnTab: getConfig().VITE_FEATURE_EARN_TAB, }, selectedLocale: simpleLocale(), hasWalletSeenTcyClaimAlert: {}, diff --git a/src/state/slices/tradeEarnInputSlice/selectors.ts b/src/state/slices/tradeEarnInputSlice/selectors.ts new file mode 100644 index 00000000000..4b53de57683 --- /dev/null +++ b/src/state/slices/tradeEarnInputSlice/selectors.ts @@ -0,0 +1,35 @@ +import { createSelector } from '@reduxjs/toolkit' + +import { createTradeInputBaseSelectors } from '../common/tradeInputBase/createTradeInputBaseSelectors' + +import type { TradeEarnInputState } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice' + +export const { + selectInputBuyAsset, + selectInputSellAsset, + selectInputBuyAssetUserCurrencyRate, + selectInputSellAssetUserCurrencyRate, + selectSellAccountId, + selectBuyAccountId, + selectInputSellAmountCryptoBaseUnit, + selectManualReceiveAddress, + selectIsManualReceiveAddressValidating, + selectIsManualReceiveAddressEditing, + selectIsManualReceiveAddressValid, + selectInputSellAmountUsd, + selectInputSellAmountUserCurrency, + selectSellAssetBalanceCryptoBaseUnit, + selectIsInputtingFiatSellAmount, + selectInputSellAmountCryptoPrecision, + selectSelectedSellAssetChainId, + selectSelectedBuyAssetChainId, + selectHasUserEnteredAmount, + ...privateSelectors +} = createTradeInputBaseSelectors('tradeEarnInput') + +const { selectBaseSlice } = privateSelectors + +export const selectSelectedYieldId = createSelector( + selectBaseSlice, + tradeEarnInput => tradeEarnInput.selectedYieldId, +) diff --git a/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts b/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts new file mode 100644 index 00000000000..ca6666bcb53 --- /dev/null +++ b/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts @@ -0,0 +1,57 @@ +import type { PayloadAction } from '@reduxjs/toolkit' +import type { AccountId, ChainId } from '@shapeshiftoss/caip' +import type { Asset } from '@shapeshiftoss/types' + +import { defaultAsset } from '../assetsSlice/assetsSlice' +import type { BaseReducers } from '../common/tradeInputBase/createTradeInputBaseSlice' +import { createTradeInputBaseSlice } from '../common/tradeInputBase/createTradeInputBaseSlice' + +export type TradeEarnInputState = { + buyAsset: Asset + sellAsset: Asset + sellAccountId: AccountId | undefined + buyAccountId: AccountId | undefined + sellAmountCryptoPrecision: string + isInputtingFiatSellAmount: boolean + manualReceiveAddress: string | undefined + isManualReceiveAddressValidating: boolean + isManualReceiveAddressEditing: boolean + isManualReceiveAddressValid: boolean | undefined + selectedSellAssetChainId: ChainId | 'All' + selectedBuyAssetChainId: ChainId | 'All' + selectedYieldId: string | undefined +} + +const initialState: TradeEarnInputState = { + buyAsset: defaultAsset, + sellAsset: defaultAsset, + sellAccountId: undefined, + buyAccountId: undefined, + sellAmountCryptoPrecision: '0', + isInputtingFiatSellAmount: false, + manualReceiveAddress: undefined, + isManualReceiveAddressValidating: false, + isManualReceiveAddressValid: undefined, + isManualReceiveAddressEditing: false, + selectedSellAssetChainId: 'All', + selectedBuyAssetChainId: 'All', + selectedYieldId: undefined, +} + +export const tradeEarnInput = createTradeInputBaseSlice({ + name: 'tradeEarnInput', + initialState, + extraReducers: (baseReducers: BaseReducers) => ({ + setSelectedYieldId: (state: TradeEarnInputState, action: PayloadAction) => { + state.selectedYieldId = action.payload + }, + setSellAssetWithYieldReset: (state: TradeEarnInputState, action: PayloadAction) => { + baseReducers.setSellAsset(state, action) + state.selectedYieldId = undefined + state.isInputtingFiatSellAmount = false + }, + }), + selectors: { + selectSelectedYieldId: state => state.selectedYieldId, + }, +}) diff --git a/src/state/store.ts b/src/state/store.ts index 7b17f864839..b3e7cf0615d 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -53,6 +53,7 @@ export const clearState = () => { store.dispatch(slices.tradeInput.actions.clear()) store.dispatch(slices.localWallet.actions.clear()) store.dispatch(slices.limitOrderInput.actions.clear()) + store.dispatch(slices.tradeEarnInput.actions.clear()) store.dispatch(slices.limitOrder.actions.clear()) store.dispatch(slices.gridplus.actions.clear()) store.dispatch(slices.addressBook.actions.clear()) diff --git a/src/test/mocks/store.ts b/src/test/mocks/store.ts index 1b8a947958a..3d06288f098 100644 --- a/src/test/mocks/store.ts +++ b/src/test/mocks/store.ts @@ -184,7 +184,9 @@ export const mockStore: ReduxState = { AddressBook: false, AppRating: false, YieldXyz: false, + YieldsPage: false, YieldMultiAccount: false, + EarnTab: false, }, showTopAssetsCarousel: true, quickBuyAmounts: [10, 50, 100], @@ -347,6 +349,21 @@ export const mockStore: ReduxState = { selectedBuyFiatRampQuote: null, selectedSellFiatRampQuote: null, }, + tradeEarnInput: { + buyAsset: defaultAsset, + sellAsset: defaultAsset, + sellAccountId: undefined, + buyAccountId: undefined, + sellAmountCryptoPrecision: '0', + isInputtingFiatSellAmount: false, + manualReceiveAddress: undefined, + isManualReceiveAddressValidating: false, + isManualReceiveAddressEditing: false, + isManualReceiveAddressValid: undefined, + selectedBuyAssetChainId: 'All', + selectedSellAssetChainId: 'All', + selectedYieldId: undefined, + }, tradeQuote: { activeQuoteMeta: undefined, confirmedQuote: undefined, diff --git a/vite.config.mts b/vite.config.mts index 79406fea2a4..4234e54ca2b 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -168,6 +168,7 @@ export default defineConfig(({ mode }) => { port: 3000, headers, host: '0.0.0.0', + allowedHosts: true, proxy: { '/user-api': { target: 'http://localhost:3002',
- - {column.render('Header')} - - {column.isSorted ? ( - column.isSortedDesc ? ( - - ) : ( - - ) - ) : null} - - -
+ + {column.render('Header')} + + {column.isSorted ? ( + column.isSortedDesc ? ( + + ) : ( + + ) + ) : null} + + +
- - {renderAssetIcon(yieldItem)} - - {yieldItem.metadata.name} - - - {validator.logoURI ? ( @@ -180,17 +158,9 @@ export const YieldActivePositions = memo( return (
- - {renderAssetIcon(yieldItem)} - - {yieldItem.metadata.name} - - - ) }) - }, [ - activeYields, - aggregated, - asset, - getProviderLogo, - handleRowClick, - hoverBg, - renderAssetIcon, - userCurrencyToUsdRate, - ]) + }, [activeYields, aggregated, asset, getProviderLogo, handleRowClick, userCurrencyToUsdRate]) if (!asset) return null if (activeYields.length === 0) return null return ( - - - {yourBalanceLabel} - - - - - - - - - - - - - {tableRows} -
{assetColumnHeader}{providerColumnHeader}{apyColumnHeader}{tvlColumnHeader}{balanceColumnHeader}
-
-
+ + + + + + + + + + + {tableRows} +
{providerColumnHeader}{apyColumnHeader}{tvlColumnHeader}{balanceColumnHeader}
+
) }, ) diff --git a/src/pages/Yields/components/YieldAssetFlow.tsx b/src/pages/Yields/components/YieldAssetFlow.tsx new file mode 100644 index 00000000000..9e384e0284a --- /dev/null +++ b/src/pages/Yields/components/YieldAssetFlow.tsx @@ -0,0 +1,76 @@ +import { Avatar, Box, Flex, Text, VStack } from '@chakra-ui/react' +import { keyframes } from '@emotion/react' +import { memo, useMemo } from 'react' + +type YieldAssetFlowProps = { + assetSymbol: string + assetLogoURI: string + providerName: string + providerLogoURI: string | undefined + direction?: 'enter' | 'exit' +} + +export const YieldAssetFlow = memo( + ({ + assetSymbol, + assetLogoURI, + providerName, + providerLogoURI, + direction = 'enter', + }: YieldAssetFlowProps) => { + const horizontalScroll = useMemo( + () => keyframes` + 0% { background-position: 0 0; } + 100% { background-position: 28px 0; } + `, + [], + ) + + const flexDirection = useMemo( + () => (direction === 'enter' ? 'row' : 'row-reverse') as 'row' | 'row-reverse', + [direction], + ) + + return ( + + + + + + + {assetSymbol} + + + + + + + + + + + + {providerName} + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldAssetSection.tsx b/src/pages/Yields/components/YieldAssetSection.tsx index 636e039abf5..fdeb1c864c0 100644 --- a/src/pages/Yields/components/YieldAssetSection.tsx +++ b/src/pages/Yields/components/YieldAssetSection.tsx @@ -1,22 +1,28 @@ -import { Box, Heading, Stack, VStack } from '@chakra-ui/react' +import { Card, CardBody, CardHeader, Heading, Stack, VStack } from '@chakra-ui/react' import type { AccountId, AssetId } from '@shapeshiftoss/caip' import { fromAccountId } from '@shapeshiftoss/caip' -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' -import { useNavigate } from 'react-router-dom' import { YieldActivePositions } from './YieldActivePositions' +import { YieldEnterModal } from './YieldEnterModal' import { YieldItemSkeleton } from './YieldItem' import { YieldOpportunityCard } from './YieldOpportunityCard' import { getConfig } from '@/config' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' +import { useWallet } from '@/hooks/useWallet/useWallet' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import type { YieldBalanceAggregate } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYields } from '@/react-queries/queries/yieldxyz/useYields' -import { selectAssetById } from '@/state/slices/selectors' -import { useAppSelector } from '@/state/store' + +const LoadingContent = ( + + + + +) type YieldAssetSectionProps = { assetId: AssetId @@ -25,23 +31,27 @@ type YieldAssetSectionProps = { export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSectionProps) => { const translate = useTranslate() - const navigate = useNavigate() const isYieldXyzEnabled = useFeatureFlag('YieldXyz') - const asset = useAppSelector(state => selectAssetById(state, assetId)) + const { + state: { isConnected }, + } = useWallet() const { data: yieldsData, isLoading: isYieldsLoading } = useYields() const balanceOptions = useMemo(() => (accountId ? { accountIds: [accountId] } : {}), [accountId]) const { data: allBalancesData, isLoading: isBalancesLoading } = useAllYieldBalances(balanceOptions) const isLoading = isYieldsLoading || isBalancesLoading + const [isEnterModalOpen, setIsEnterModalOpen] = useState(false) + const [selectedYield, setSelectedYield] = useState(null) + const yields = useMemo(() => { - if (!yieldsData?.all || !asset) return [] + if (!yieldsData?.all) return [] return yieldsData.all.filter(yieldItem => { const matchesToken = yieldItem.token.assetId === assetId const matchesInput = yieldItem.inputTokens.some(t => t.assetId === assetId) return matchesToken || matchesInput }) - }, [yieldsData, asset, assetId]) + }, [yieldsData, assetId]) const aggregated = useMemo(() => { const multiAccountEnabled = getConfig().VITE_FEATURE_YIELD_MULTI_ACCOUNT @@ -78,48 +88,45 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection const hasActivePositions = Object.keys(aggregated).length > 0 - const handleOpportunityClick = useCallback( - (yieldItem: AugmentedYieldDto) => { - navigate(`/yields/${yieldItem.id}`) - }, - [navigate], - ) - - const yieldHeading = translate('yieldXYZ.yield') ?? 'Yield' - - const loadingContent = useMemo( - () => ( - - - - - ), - [], - ) - - const activePositionsContent = useMemo( - () => , - [aggregated, yields, assetId], - ) + const handleOpportunityClick = useCallback((yieldItem: AugmentedYieldDto) => { + setSelectedYield(yieldItem) + setIsEnterModalOpen(true) + }, []) - const opportunityCardContent = useMemo(() => { - if (!bestYield) return null - return - }, [bestYield, handleOpportunityClick]) + const handleEnterModalClose = useCallback(() => { + setIsEnterModalOpen(false) + setSelectedYield(null) + }, []) if (!isYieldXyzEnabled) return null + if (!isConnected) return null if (!isLoading && yields.length === 0) return null return ( - - - {yieldHeading} - - - {hasActivePositions && activePositionsContent} - {isLoading && loadingContent} - {!isLoading && !hasActivePositions && opportunityCardContent} - - + <> + + + {translate('yieldXYZ.yield')} + + + + {hasActivePositions && ( + + )} + {isLoading && LoadingContent} + {!isLoading && !hasActivePositions && bestYield && ( + + )} + + + + {selectedYield && ( + + )} + ) }) diff --git a/src/pages/Yields/components/YieldEnterExit.tsx b/src/pages/Yields/components/YieldEnterExit.tsx deleted file mode 100644 index 14578245e42..00000000000 --- a/src/pages/Yields/components/YieldEnterExit.tsx +++ /dev/null @@ -1,666 +0,0 @@ -import { ChevronDownIcon } from '@chakra-ui/icons' -import { - Avatar, - Box, - Button, - Flex, - Icon, - Skeleton, - Tab, - TabList, - TabPanel, - TabPanels, - Tabs, - Text, - useColorModeValue, -} from '@chakra-ui/react' -import { memo, useCallback, useEffect, useMemo, useState } from 'react' -import { FaMoneyBillWave } from 'react-icons/fa' -import { useTranslate } from 'react-polyglot' -import { useLocation, useSearchParams } from 'react-router-dom' - -import { Amount } from '@/components/Amount/Amount' -import { AssetInput } from '@/components/DeFi/components/AssetInput' -import { WalletActions } from '@/context/WalletProvider/actions' -import { useWallet } from '@/hooks/useWallet/useWallet' -import { bnOrZero } from '@/lib/bignumber/bignumber' -import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' -import type { AugmentedYieldDto, ValidatorDto } from '@/lib/yieldxyz/types' -import { YieldBalanceType } from '@/lib/yieldxyz/types' -import { GradientApy } from '@/pages/Yields/components/GradientApy' -import { YieldActionModal } from '@/pages/Yields/components/YieldActionModal' -import { YieldValidatorSelectModal } from '@/pages/Yields/components/YieldValidatorSelectModal' -import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' -import type { - AugmentedYieldBalanceWithAccountId, - NormalizedYieldBalances, -} from '@/react-queries/queries/yieldxyz/useAllYieldBalances' -import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' -import { - selectAccountIdByAccountNumberAndChainId, - selectMarketDataByAssetIdUserCurrency, - selectPortfolioCryptoPrecisionBalanceByFilter, -} from '@/state/slices/selectors' -import { useAppSelector } from '@/state/store' - -type YieldEnterExitProps = { - yieldItem: AugmentedYieldDto - isQuoteLoading?: boolean - balances: NormalizedYieldBalances | undefined - isBalancesLoading: boolean -} - -const percentOptions = [0.25, 0.5, 0.75, 1] - -const YieldEnterExitSkeleton = memo(() => ( - - - - -)) - -const moneyBillWaveIcon = -const chevronDownIcon = - -export const YieldEnterExit = memo( - ({ yieldItem, isQuoteLoading, balances, isBalancesLoading }: YieldEnterExitProps) => { - const translate = useTranslate() - const location = useLocation() - const { accountNumber } = useYieldAccount() - const { state: walletState, dispatch } = useWallet() - const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) - const cardBg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const validatorPickerBg = useColorModeValue('gray.50', 'blackAlpha.50') - const validatorPickerHoverBg = useColorModeValue('gray.100', 'whiteAlpha.100') - const tabListBg = useColorModeValue('gray.50', 'blackAlpha.200') - const estimatedEarningsBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const estimatedEarningsBorderColor = useColorModeValue('gray.100', 'whiteAlpha.100') - - const initialTab = useMemo(() => { - if (location.pathname.endsWith('/exit')) return 1 - if (location.pathname.endsWith('/enter')) return 0 - return 0 - }, [location.pathname]) - - const [tabIndex, setTabIndex] = useState(initialTab) - const [cryptoAmount, setCryptoAmount] = useState('') - const [isModalOpen, setIsModalOpen] = useState(false) - const [modalAction, setModalAction] = useState<'enter' | 'exit'>('enter') - const [isValidatorModalOpen, setIsValidatorModalOpen] = useState(false) - - const { chainId } = yieldItem - - const [searchParams, setSearchParams] = useSearchParams() - const validatorParam = useMemo(() => searchParams.get('validator'), [searchParams]) - - const shouldFetchValidators = useMemo( - () => - yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection, - [yieldItem.mechanics.type, yieldItem.mechanics.requiresValidatorSelection], - ) - const { data: validators } = useYieldValidators(yieldItem.id, shouldFetchValidators) - - const defaultValidator = useMemo(() => { - if (chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId]) - return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] - return validators?.[0]?.address - }, [chainId, validators]) - - const selectedValidatorAddress = useMemo( - () => validatorParam || defaultValidator, - [validatorParam, defaultValidator], - ) - - const handleValidatorChange = useCallback( - (newAddress: string) => { - setSearchParams(params => { - params.set('validator', newAddress) - return params - }) - }, - [setSearchParams], - ) - - useEffect(() => { - if (!validatorParam && defaultValidator) { - setSearchParams( - params => { - params.set('validator', defaultValidator) - return params - }, - { replace: true }, - ) - } - }, [defaultValidator, validatorParam, setSearchParams]) - - const accountId = useAppSelector(state => { - if (!chainId) return undefined - const accountIdsByNumberAndChain = selectAccountIdByAccountNumberAndChainId(state) - return accountIdsByNumberAndChain[accountNumber]?.[chainId] - }) - - const validatorMetadata = useMemo(() => { - if (!selectedValidatorAddress) return undefined - - const foundInList = validators?.find(v => v.address === selectedValidatorAddress) - if (foundInList) return foundInList - - const foundInBalances = balances?.raw.find( - (b: AugmentedYieldBalanceWithAccountId) => - b.validator?.address === selectedValidatorAddress, - )?.validator - if (foundInBalances) - return { - ...foundInBalances, - apr: undefined, - commission: undefined, - } - - return { - name: `${selectedValidatorAddress.slice(0, 6)}...${selectedValidatorAddress.slice(-4)}`, - logoURI: '', - address: selectedValidatorAddress, - apr: '0', - commission: '0', - } - }, [validators, selectedValidatorAddress, balances]) - - const inputToken = yieldItem.inputTokens[0] - const inputTokenAssetId = inputToken?.assetId - - const inputTokenBalance = useAppSelector(state => - inputTokenAssetId && accountId - ? selectPortfolioCryptoPrecisionBalanceByFilter(state, { - assetId: inputTokenAssetId, - accountId, - }) - : '0', - ) - - const minDeposit = yieldItem.mechanics?.entryLimits?.minimum - - const isBelowMinimum = useMemo(() => { - if (!cryptoAmount || !minDeposit) return false - return bnOrZero(cryptoAmount).lt(minDeposit) - }, [cryptoAmount, minDeposit]) - - const isLoading = isBalancesLoading || isQuoteLoading - - const activeBalance = useMemo( - () => - balances?.raw.find((b: AugmentedYieldBalanceWithAccountId) => { - if (b.type !== YieldBalanceType.Active) return false - if (selectedValidatorAddress && b.validator) - return b.validator.address === selectedValidatorAddress - return true - }), - [balances?.raw, selectedValidatorAddress], - ) - - const withdrawableBalance = useMemo( - () => - balances?.raw.find((b: AugmentedYieldBalanceWithAccountId) => { - if (b.type !== YieldBalanceType.Withdrawable) return false - if (selectedValidatorAddress && b.validator) - return b.validator.address === selectedValidatorAddress - return true - }), - [balances?.raw, selectedValidatorAddress], - ) - - const exitBalance = useMemo( - () => activeBalance?.amount ?? withdrawableBalance?.amount ?? '0', - [activeBalance?.amount, withdrawableBalance?.amount], - ) - - const handlePercentClick = useCallback( - (percent: number) => { - const balance = tabIndex === 0 ? inputTokenBalance : exitBalance - const percentAmount = bnOrZero(balance).times(percent).toFixed() - setCryptoAmount(percentAmount) - }, - [inputTokenBalance, exitBalance, tabIndex], - ) - - const handleMaxClick = useCallback(async () => { - await Promise.resolve() - const balance = tabIndex === 0 ? inputTokenBalance : exitBalance - setCryptoAmount(balance) - }, [inputTokenBalance, exitBalance, tabIndex]) - - const handleEnterClick = useCallback(() => { - setModalAction('enter') - setIsModalOpen(true) - }, []) - - const handleExitClick = useCallback(() => { - setModalAction('exit') - setIsModalOpen(true) - }, []) - - const handleOpenValidatorModal = useCallback(() => setIsValidatorModalOpen(true), []) - const handleCloseValidatorModal = useCallback(() => setIsValidatorModalOpen(false), []) - const handleCloseModal = useCallback(() => setIsModalOpen(false), []) - - const handleConnectWallet = useCallback( - () => dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), - [dispatch], - ) - - const marketData = useAppSelector(state => - selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId ?? ''), - ) - - const apy = useMemo(() => bnOrZero(yieldItem.rewardRate.total), [yieldItem.rewardRate.total]) - - const estimatedYearlyEarnings = useMemo( - () => bnOrZero(cryptoAmount).times(apy), - [cryptoAmount, apy], - ) - - const estimatedYearlyEarningsFiat = useMemo( - () => estimatedYearlyEarnings.times(marketData?.price ?? 0), - [estimatedYearlyEarnings, marketData?.price], - ) - - const fiatAmount = useMemo( - () => - bnOrZero(cryptoAmount) - .times(marketData?.price ?? 0) - .toFixed(2), - [cryptoAmount, marketData?.price], - ) - - const hasAmount = bnOrZero(cryptoAmount).gt(0) - const inputSymbol = inputToken?.symbol ?? '' - - const uniqueValidatorCount = balances ? balances.validatorAddresses.length : 0 - const shouldShowValidatorPicker = uniqueValidatorCount > 1 - - const enterTabSelectedStyle = useMemo( - () => ({ - color: 'blue.400', - bg: cardBg, - borderBottomColor: cardBg, - borderTopColor: 'blue.400', - borderTopWidth: 2, - }), - [cardBg], - ) - - const tabFocusStyle = useMemo(() => ({ boxShadow: 'none' }), []) - const buttonHoverStyle = useMemo(() => ({ transform: 'translateY(-1px)', boxShadow: 'lg' }), []) - - const enterButtonDisabled = useMemo( - () => - isConnected && - (isLoading || - !yieldItem.status.enter || - !cryptoAmount || - isBelowMinimum || - !!isQuoteLoading), - [ - isConnected, - isLoading, - yieldItem.status.enter, - cryptoAmount, - isBelowMinimum, - isQuoteLoading, - ], - ) - - const exitButtonDisabled = useMemo( - () => isConnected && (isLoading || !yieldItem.status.exit || !cryptoAmount), - [isConnected, isLoading, yieldItem.status.exit, cryptoAmount], - ) - - const enterButtonText = useMemo(() => { - if (isQuoteLoading) return translate('common.loading') - if (isConnected) return translate('yieldXYZ.enter') - return translate('common.connectWallet') - }, [isQuoteLoading, isConnected, translate]) - - const exitButtonText = useMemo(() => { - if (isConnected) return translate('yieldXYZ.exit') - return translate('common.connectWallet') - }, [isConnected, translate]) - - const handleEnterButtonClick = useMemo( - () => (isConnected ? handleEnterClick : handleConnectWallet), - [isConnected, handleEnterClick, handleConnectWallet], - ) - - const handleExitButtonClick = useMemo( - () => (isConnected ? handleExitClick : handleConnectWallet), - [isConnected, handleExitClick, handleConnectWallet], - ) - - const modalAssetSymbol = useMemo( - () => (modalAction === 'enter' ? inputToken?.symbol ?? '' : yieldItem.token.symbol), - [modalAction, inputToken?.symbol, yieldItem.token.symbol], - ) - - const enterTabDisabled = !yieldItem.status.enter - const exitTabDisabled = !yieldItem.status.exit - const enterTabOpacity = enterTabDisabled ? 0.5 : 1 - const exitTabOpacity = exitTabDisabled ? 0.5 : 1 - - const isPreferredValidator = useMemo( - () => (validatorMetadata as ValidatorDto | undefined)?.preferred === true, - [validatorMetadata], - ) - - const validatorRewardRate = useMemo(() => { - if (!validatorMetadata) return null - if (!('rewardRate' in validatorMetadata)) return null - const rate = (validatorMetadata as ValidatorDto).rewardRate?.total - if (!rate) return null - return (rate * 100).toFixed(2) - }, [validatorMetadata]) - - const apyDisplay = useMemo(() => `${apy.times(100).toFixed(2)}%`, [apy]) - - const estimatedYearlyEarningsDisplay = useMemo( - () => `${estimatedYearlyEarnings.decimalPlaces(4).toString()} ${inputSymbol}`, - [estimatedYearlyEarnings, inputSymbol], - ) - - const estimatedEarningsMarginBottom = hasAmount ? 2 : 0 - - const validatorPickerContent = useMemo(() => { - if (!shouldShowValidatorPicker) return null - - return ( - <> - - - - {validatorMetadata ? ( - <> - - - - {validatorMetadata.name} - - - {isPreferredValidator && ( - - {translate('yieldXYZ.preferred')} - - )} - {validatorRewardRate && ( - - {validatorRewardRate}% {translate('yieldXYZ.apr')} - - )} - - - - ) : ( - - {translate('yieldXYZ.selectValidator')} - - )} - - {chevronDownIcon} - - - - - ) - }, [ - shouldShowValidatorPicker, - borderColor, - validatorPickerBg, - validatorPickerHoverBg, - handleOpenValidatorModal, - validatorMetadata, - isPreferredValidator, - translate, - validatorRewardRate, - isValidatorModalOpen, - handleCloseValidatorModal, - validators, - handleValidatorChange, - balances?.raw, - ]) - - const minDepositContent = useMemo(() => { - if (!minDeposit || isLoading) return null - - return ( - - - {moneyBillWaveIcon} - - {translate('yieldXYZ.minDeposit')} - - - - {minDeposit} {inputToken?.symbol} - - - ) - }, [minDeposit, isLoading, translate, isBelowMinimum, inputToken?.symbol]) - - const estimatedYearlyEarningsContent = useMemo(() => { - if (!hasAmount) return null - - return ( - - - {translate('yieldXYZ.estYearlyEarnings')} - - - - {estimatedYearlyEarningsDisplay} - - - - - - - ) - }, [hasAmount, translate, estimatedYearlyEarningsDisplay, estimatedYearlyEarningsFiat]) - - const enterTabPanelContent = useMemo(() => { - if (isBalancesLoading) return - - return ( - - ) - }, [ - isBalancesLoading, - accountId, - inputTokenAssetId, - inputToken?.symbol, - yieldItem.metadata.logoURI, - cryptoAmount, - inputTokenBalance, - handlePercentClick, - handleMaxClick, - fiatAmount, - ]) - - const exitTabPanelContent = useMemo(() => { - if (isBalancesLoading) return - - return ( - - ) - }, [ - isBalancesLoading, - accountId, - inputTokenAssetId, - yieldItem.token.symbol, - yieldItem.metadata.logoURI, - cryptoAmount, - exitBalance, - handlePercentClick, - handleMaxClick, - fiatAmount, - ]) - - return ( - <> - - {validatorPickerContent} - - - - {translate('yieldXYZ.enter')} - - - {translate('yieldXYZ.exit')} - - - - - - {enterTabPanelContent} - {minDepositContent} - - - - {translate('yieldXYZ.currentApy')} - - - {apyDisplay} - - - {estimatedYearlyEarningsContent} - - - - - - - {exitTabPanelContent} - - - - - - - - - ) - }, -) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx new file mode 100644 index 00000000000..64ee396aafb --- /dev/null +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -0,0 +1,647 @@ +import { Avatar, Box, Button, Flex, HStack, Icon, Input, Skeleton, Text } from '@chakra-ui/react' +import { useQueryClient } from '@tanstack/react-query' +import type { ChangeEvent } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' +import { TbSwitchVertical } from 'react-icons/tb' +import type { NumberFormatValues } from 'react-number-format' +import { NumericFormat } from 'react-number-format' +import { useTranslate } from 'react-polyglot' + +import { AccountSelector } from '@/components/AccountSelector/AccountSelector' +import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' +import { Dialog } from '@/components/Modal/components/Dialog' +import { DialogBody } from '@/components/Modal/components/DialogBody' +import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' +import { DialogFooter } from '@/components/Modal/components/DialogFooter' +import { DialogHeader } from '@/components/Modal/components/DialogHeader' +import { DialogTitle } from '@/components/Modal/components/DialogTitle' +import { WalletActions } from '@/context/WalletProvider/actions' +import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' +import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' +import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, +} from '@/lib/yieldxyz/constants' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { getTransactionButtonText } from '@/lib/yieldxyz/utils' +import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' +import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' +import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' +import { + selectAccountIdByAccountNumberAndChainId, + selectAssetById, + selectMarketDataByAssetIdUserCurrency, + selectPortfolioAccountIdsByAssetIdFilter, + selectPortfolioCryptoPrecisionBalanceByFilter, +} from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type YieldEnterModalProps = { + isOpen: boolean + onClose: () => void + yieldItem: AugmentedYieldDto + accountNumber?: number +} + +const PRESET_PERCENTAGES = [0.25, 0.5, 0.75, 1] as const + +const INPUT_LENGTH_BREAKPOINTS = { + FOR_XS_FONT: 22, + FOR_SM_FONT: 14, + FOR_MD_FONT: 10, +} as const + +const getInputFontSize = (length: number): string => { + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_XS_FONT) return '24px' + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_SM_FONT) return '30px' + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_MD_FONT) return '38px' + return '48px' +} + +const selectedHoverSx = { bg: 'blue.600' } +const unselectedHoverSx = { bg: 'background.surface.raised.hover' } + +type CryptoAmountInputProps = { + value?: string + onChange?: (e: ChangeEvent) => void + placeholder?: string + [key: string]: unknown +} + +const CryptoAmountInput = (props: CryptoAmountInputProps) => { + const valueLength = useMemo(() => (props.value ? String(props.value).length : 0), [props.value]) + const fontSize = useMemo(() => getInputFontSize(valueLength), [valueLength]) + + return ( + + ) +} + +const YieldEnterModalSkeleton = memo(() => ( + + + + +)) + +export const YieldEnterModal = memo( + ({ isOpen, onClose, yieldItem, accountNumber = 0 }: YieldEnterModalProps) => { + const queryClient = useQueryClient() + const translate = useTranslate() + const { state: walletState, dispatch: walletDispatch } = useWallet() + const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) + const isYieldMultiAccountEnabled = useFeatureFlag('YieldMultiAccount') + const { + number: { localeParts }, + } = useLocaleFormatter() + + const [cryptoAmount, setCryptoAmount] = useState('') + const [isFiat, setIsFiat] = useState(false) + const [selectedAccountId, setSelectedAccountId] = useState() + const [selectedPercent, setSelectedPercent] = useState(null) + + const { chainId } = yieldItem + const inputToken = yieldItem.inputTokens[0] + const inputTokenAssetId = inputToken?.assetId + + const accountIdFilter = useMemo( + () => ({ assetId: inputTokenAssetId ?? '' }), + [inputTokenAssetId], + ) + const accountIds = useAppSelector(state => + selectPortfolioAccountIdsByAssetIdFilter(state, accountIdFilter), + ) + + const defaultAccountId = useAppSelector(state => { + if (!chainId) return undefined + const accountIdsByNumberAndChain = selectAccountIdByAccountNumberAndChainId(state) + return accountIdsByNumberAndChain[accountNumber]?.[chainId] + }) + + const accountId = selectedAccountId ?? defaultAccountId + const hasMultipleAccounts = accountIds.length > 1 + const isAccountSelectorDisabled = !isYieldMultiAccountEnabled || !hasMultipleAccounts + + const shouldFetchValidators = useMemo( + () => + yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection, + [yieldItem.mechanics.type, yieldItem.mechanics.requiresValidatorSelection], + ) + const { data: validators, isLoading: isValidatorsLoading } = useYieldValidators( + yieldItem.id, + shouldFetchValidators, + ) + + const selectedValidatorAddress = useMemo(() => { + if (chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId]) { + return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] + } + return validators?.[0]?.address + }, [chainId, validators]) + + const { data: providers } = useYieldProviders() + + const isStaking = yieldItem.mechanics.type === 'staking' + + const selectedValidatorMetadata = useMemo(() => { + if (!isStaking || !selectedValidatorAddress) return null + const found = validators?.find(v => v.address === selectedValidatorAddress) + if (found) return found + if (selectedValidatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { + return { + name: SHAPESHIFT_VALIDATOR_NAME, + logoURI: SHAPESHIFT_VALIDATOR_LOGO, + address: selectedValidatorAddress, + } + } + return null + }, [isStaking, selectedValidatorAddress, validators]) + + const providerMetadata = useMemo(() => { + if (!providers) return null + return providers[yieldItem.providerId] + }, [providers, yieldItem.providerId]) + + const inputTokenAsset = useAppSelector(state => selectAssetById(state, inputTokenAssetId ?? '')) + + const inputTokenBalance = useAppSelector(state => + inputTokenAssetId && accountId + ? selectPortfolioCryptoPrecisionBalanceByFilter(state, { + assetId: inputTokenAssetId, + accountId, + }) + : '0', + ) + + const marketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId ?? ''), + ) + + const minDeposit = yieldItem.mechanics?.entryLimits?.minimum + + const isBelowMinimum = useMemo(() => { + if (!cryptoAmount || !minDeposit) return false + return bnOrZero(cryptoAmount).lt(minDeposit) + }, [cryptoAmount, minDeposit]) + + const isLoading = isValidatorsLoading || !inputTokenAsset + + const fiatAmount = useMemo( + () => bnOrZero(cryptoAmount).times(marketData?.price ?? 0), + [cryptoAmount, marketData?.price], + ) + + const apy = useMemo(() => bnOrZero(yieldItem.rewardRate.total), [yieldItem.rewardRate.total]) + const apyDisplay = useMemo(() => `${apy.times(100).toFixed(2)}%`, [apy]) + + const estimatedYearlyEarnings = useMemo( + () => bnOrZero(cryptoAmount).times(apy), + [cryptoAmount, apy], + ) + + const estimatedYearlyEarningsFiat = useMemo( + () => estimatedYearlyEarnings.times(marketData?.price ?? 0), + [estimatedYearlyEarnings, marketData?.price], + ) + + const hasAmount = bnOrZero(cryptoAmount).gt(0) + + const displayPlaceholder = useMemo( + () => (isFiat ? `${localeParts.prefix}0` : '0'), + [isFiat, localeParts.prefix], + ) + + const handleInputChange = useCallback( + (values: NumberFormatValues) => { + setSelectedPercent(null) + if (isFiat) { + const crypto = bnOrZero(values.value) + .div(marketData?.price || 1) + .toFixed() + setCryptoAmount(crypto) + } else { + setCryptoAmount(values.value) + } + }, + [isFiat, marketData?.price], + ) + + const displayValue = useMemo(() => { + if (isFiat) { + return fiatAmount.toFixed(2) + } + return cryptoAmount + }, [isFiat, fiatAmount, cryptoAmount]) + + const toggleIsFiat = useCallback(() => setIsFiat(prev => !prev), []) + + const handlePercentClick = useCallback( + (percent: number) => { + const percentAmount = bnOrZero(inputTokenBalance).times(percent).toFixed() + setCryptoAmount(percentAmount) + setSelectedPercent(percent) + }, + [inputTokenBalance], + ) + + const handleConnectWallet = useCallback( + () => walletDispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), + [walletDispatch], + ) + + const handleModalClose = useCallback(() => { + setCryptoAmount('') + setSelectedPercent(null) + setIsFiat(false) + setSelectedAccountId(undefined) + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote', 'enter', yieldItem.id] }) + onClose() + }, [onClose, queryClient, yieldItem.id]) + + const handleAccountChange = useCallback((newAccountId: string) => { + setSelectedAccountId(newAccountId) + setCryptoAmount('') + setSelectedPercent(null) + }, []) + + const { + step, + transactionSteps, + displaySteps, + isSubmitting, + activeStepIndex, + handleConfirm, + handleClose: hookHandleClose, + isQuoteLoading, + quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, + } = useYieldTransactionFlow({ + yieldItem, + action: 'enter', + amount: cryptoAmount, + assetSymbol: inputTokenAsset?.symbol ?? '', + onClose: handleModalClose, + isOpen, + validatorAddress: selectedValidatorAddress, + accountId, + }) + + const isQuoteActive = isQuoteLoading || isAllowanceCheckPending + + const successProviderInfo = useMemo(() => { + if (isStaking && selectedValidatorMetadata) { + return { + name: selectedValidatorMetadata.name, + logoURI: selectedValidatorMetadata.logoURI, + } + } + if (providerMetadata) { + return { + name: providerMetadata.name, + logoURI: providerMetadata.logoURI, + } + } + return null + }, [isStaking, selectedValidatorMetadata, providerMetadata]) + + const enterButtonDisabled = useMemo( + () => + isConnected && + (isLoading || !yieldItem.status.enter || !cryptoAmount || isBelowMinimum || !quoteData), + [isConnected, isLoading, yieldItem.status.enter, cryptoAmount, isBelowMinimum, quoteData], + ) + + const enterButtonText = useMemo(() => { + if (!isConnected) return translate('common.connectWallet') + if (isQuoteActive) return translate('yieldXYZ.loadingQuote') + + if (isSubmitting && transactionSteps.length > 0) { + const activeStep = transactionSteps.find(s => s.status !== 'success') + if (activeStep) return getTransactionButtonText(activeStep.type, activeStep.originalTitle) + } + + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { + const currentStep = transactionSteps[activeStepIndex] + return getTransactionButtonText(currentStep.type, currentStep.originalTitle) + } + + if (isUsdtResetRequired) { + return translate('yieldXYZ.resetAllowance') + } + + const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED') + if (firstCreatedTx) return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) + + return translate('yieldXYZ.enterAsset', { asset: inputTokenAsset?.symbol }) + }, [ + isConnected, + isQuoteActive, + isSubmitting, + transactionSteps, + activeStepIndex, + isUsdtResetRequired, + quoteData, + translate, + inputTokenAsset?.symbol, + ]) + + const modalTitle = useMemo(() => { + if (step === ModalStep.Success) return translate('common.success') + return translate('yieldXYZ.enterAsset', { asset: inputTokenAsset?.symbol }) + }, [translate, inputTokenAsset?.symbol, step]) + + const percentButtons = useMemo( + () => ( + + {PRESET_PERCENTAGES.map(percent => { + const isSelected = selectedPercent === percent + return ( + + ) + })} + + ), + [selectedPercent, handlePercentClick, translate], + ) + + const statsContent = useMemo( + () => ( + + + + {translate('yieldXYZ.currentApy')} + + + {apyDisplay} + + + {hasAmount && ( + + + {translate('yieldXYZ.estYearlyEarnings')} + + + + {estimatedYearlyEarnings.decimalPlaces(4).toString()} {inputTokenAsset?.symbol} + + + + + + + )} + {isStaking && selectedValidatorMetadata && ( + + + {translate('yieldXYZ.validator')} + + + + + {selectedValidatorMetadata.name} + + + + )} + {!isStaking && providerMetadata && ( + + + {translate('yieldXYZ.provider')} + + + + + {providerMetadata.name} + + + + )} + {minDeposit && bnOrZero(minDeposit).gt(0) && ( + + + {translate('yieldXYZ.minEnter')} + + + {minDeposit} {inputTokenAsset?.symbol} + + + )} + + ), + [ + translate, + apyDisplay, + hasAmount, + estimatedYearlyEarnings, + inputTokenAsset?.symbol, + estimatedYearlyEarningsFiat, + isStaking, + selectedValidatorMetadata, + providerMetadata, + minDeposit, + isBelowMinimum, + ], + ) + + const inputContent = useMemo(() => { + if (isLoading) return + + return ( + + {inputTokenAssetId && } + + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleIsFiat() + } + }} + > + + {isFiat ? ( + + ) : ( + + )} + + + + + ) + }, [ + isLoading, + inputTokenAssetId, + isFiat, + inputTokenAsset?.precision, + localeParts, + displayValue, + displayPlaceholder, + inputTokenAsset?.symbol, + handleInputChange, + toggleIsFiat, + cryptoAmount, + fiatAmount, + translate, + ]) + + const isInProgress = step === ModalStep.InProgress + const isSuccess = step === ModalStep.Success + + const successContent = useMemo( + () => ( + + ), + [ + cryptoAmount, + inputTokenAsset?.symbol, + successProviderInfo, + transactionSteps, + yieldItem.id, + hookHandleClose, + ], + ) + + const stepsToShow = activeStepIndex >= 0 ? transactionSteps : displaySteps + + const dialogOnClose = useMemo( + () => (isSubmitting ? () => {} : hookHandleClose), + [isSubmitting, hookHandleClose], + ) + + return ( + + + {null} + + {modalTitle} + + + + + + + {isInProgress && ( + + {inputContent} + {percentButtons} + {inputTokenAssetId && accountId && ( + + + + )} + {statsContent} + {stepsToShow.length > 0 && } + + )} + {isSuccess && successContent} + + {isInProgress && ( + + + + )} + + ) + }, +) diff --git a/src/pages/Yields/components/YieldFilters.tsx b/src/pages/Yields/components/YieldFilters.tsx index aea9951210b..1acf96a12a1 100644 --- a/src/pages/Yields/components/YieldFilters.tsx +++ b/src/pages/Yields/components/YieldFilters.tsx @@ -6,12 +6,12 @@ import { IconButton, Menu, MenuButton, - MenuItem, + MenuItemOption, MenuList, + MenuOptionGroup, Stack, Text, Tooltip, - useColorModeValue, } from '@chakra-ui/react' import type { ChainId } from '@shapeshiftoss/caip' import React, { memo, useCallback, useMemo } from 'react' @@ -36,6 +36,11 @@ export type ProviderOption = { icon?: string } +export type TypeOption = { + id: string + name: string +} + type FilterMenuProps = { label: string value: string | null @@ -51,73 +56,44 @@ type FilterMenuProps = { const chevronDownIcon = +const ALL_OPTION_VALUE = '__all__' + const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: FilterMenuProps) => { const selectedOption = useMemo(() => options.find(o => o.id === value), [options, value]) const displayLabel = useMemo( () => (selectedOption ? selectedOption.name : label), [selectedOption, label], ) - const bg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.200', 'gray.700') - const selectedBg = useColorModeValue('blue.50', 'blue.900') - const selectedColor = useColorModeValue('blue.600', 'blue.200') - const hoverBg = useColorModeValue('gray.50', 'gray.750') - const activeBg = useColorModeValue('gray.100', 'gray.700') - - const handleSelectAll = useCallback(() => onSelect(null), [onSelect]) - - const hoverStyle = useMemo(() => ({ bg: hoverBg }), [hoverBg]) - const activeStyle = useMemo(() => ({ bg: activeBg }), [activeBg]) const selectedIcon = useMemo( () => (selectedOption && renderIcon ? renderIcon(selectedOption) : null), [selectedOption, renderIcon], ) - const allItemBg = useMemo(() => (value === null ? selectedBg : undefined), [value, selectedBg]) - const allItemColor = useMemo( - () => (value === null ? selectedColor : undefined), - [value, selectedColor], + const handleChange = useCallback( + (newValue: string | string[]) => { + const selectedValue = Array.isArray(newValue) ? newValue[0] : newValue + onSelect(selectedValue === ALL_OPTION_VALUE ? null : selectedValue) + }, + [onSelect], ) - const allItemFontWeight = useMemo(() => (value === null ? 'semibold' : undefined), [value]) const menuItems = useMemo( () => - options.map(opt => { - const isSelected = value === opt.id - return ( - onSelect(opt.id)} - bg={isSelected ? selectedBg : undefined} - color={isSelected ? selectedColor : undefined} - fontWeight={isSelected ? 'semibold' : undefined} - > - - {renderIcon && renderIcon(opt)} - {opt.name} - - - ) - }), - [options, value, selectedBg, selectedColor, renderIcon, onSelect], + options.map(opt => ( + + + {renderIcon && renderIcon(opt)} + {opt.name} + + + )), + [options, renderIcon], ) return ( - + {selectedIcon} @@ -125,16 +101,11 @@ const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: Filter - - - {label} - - {menuItems} + + + {label} + {menuItems} + ) @@ -147,6 +118,9 @@ type YieldFiltersProps = { providers: ProviderOption[] selectedProvider: string | null onSelectProvider: (id: string | null) => void + types: TypeOption[] + selectedType: string | null + onSelectType: (id: string | null) => void sortOption: SortOption onSortChange: (option: SortOption) => void } & StackProps @@ -159,15 +133,14 @@ export const YieldFilters = memo( providers, selectedProvider, onSelectProvider, + types, + selectedType, + onSelectType, sortOption, onSortChange, ...props }: YieldFiltersProps) => { const translate = useTranslate() - const bg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.200', 'gray.700') - const hoverBg = useColorModeValue('gray.50', 'gray.750') - const activeBg = useColorModeValue('gray.100', 'gray.700') const sortOptions = useMemo( () => [ @@ -183,6 +156,7 @@ export const YieldFilters = memo( const allNetworksLabel = useMemo(() => translate('yieldXYZ.allNetworks'), [translate]) const allProvidersLabel = useMemo(() => translate('yieldXYZ.allProviders'), [translate]) + const allTypesLabel = useMemo(() => translate('yieldXYZ.allTypes'), [translate]) const renderNetworkIcon = useCallback( (opt: { id: string; name: string; icon?: string; chainId?: ChainId }) => { @@ -204,26 +178,31 @@ export const YieldFilters = memo( return }, [sortOption]) - const hoverStyle = useMemo(() => ({ bg: hoverBg }), [hoverBg]) - const activeStyle = useMemo(() => ({ bg: activeBg }), [activeBg]) + const handleSortChange = useCallback( + (newValue: string | string[]) => { + const selectedValue = Array.isArray(newValue) ? newValue[0] : newValue + onSortChange(selectedValue as SortOption) + }, + [onSortChange], + ) const sortMenuItems = useMemo( () => sortOptions.map(opt => ( - onSortChange(opt.value)} - color={sortOption === opt.value ? 'blue.500' : 'inherit'} - fontWeight={sortOption === opt.value ? 'bold' : 'normal'} - > + {opt.label} - + )), - [sortOptions, sortOption, onSortChange], + [sortOptions], ) return ( - + + - - {sortMenuItems} + + + {sortMenuItems} + diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx new file mode 100644 index 00000000000..01e725c24f3 --- /dev/null +++ b/src/pages/Yields/components/YieldForm.tsx @@ -0,0 +1,764 @@ +import { Avatar, Box, Button, Flex, HStack, Icon, Input, Skeleton, Text } from '@chakra-ui/react' +import { useQueryClient } from '@tanstack/react-query' +import type { ChangeEvent } from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { TbSwitchVertical } from 'react-icons/tb' +import type { NumberFormatValues } from 'react-number-format' +import { NumericFormat } from 'react-number-format' +import { useTranslate } from 'react-polyglot' + +import { AccountSelector } from '@/components/AccountSelector/AccountSelector' +import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' +import { WalletActions } from '@/context/WalletProvider/actions' +import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' +import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' +import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + SHAPESHIFT_VALIDATOR_LOGO, + SHAPESHIFT_VALIDATOR_NAME, +} from '@/lib/yieldxyz/constants' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { YieldBalanceType } from '@/lib/yieldxyz/types' +import { getTransactionButtonText } from '@/lib/yieldxyz/utils' +import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' +import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' +import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' +import type { NormalizedYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' +import { + selectAccountIdByAccountNumberAndChainId, + selectAssetById, + selectMarketDataByAssetIdUserCurrency, + selectPortfolioAccountIdsByAssetIdFilter, + selectPortfolioCryptoPrecisionBalanceByFilter, +} from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type YieldFormProps = { + yieldItem: AugmentedYieldDto + balances?: NormalizedYieldBalances + action: 'enter' | 'exit' | 'claim' + validatorAddress?: string + accountNumber?: number + onClose: () => void + onDone?: () => void + isSubmitting?: boolean // Optional, if handled externally or to override +} + +const PRESET_PERCENTAGES = [0.25, 0.5, 0.75, 1] as const + +const getEnterActionTextKey = (yieldType: string | undefined): string => { + switch (yieldType) { + case 'native-staking': + case 'pooled-staking': + case 'liquid-staking': + case 'staking': + return 'defi.stake' + case 'vault': + return 'common.deposit' + case 'lending': + return 'common.supply' + default: + return 'common.deposit' + } +} + +const getExitActionTextKey = (yieldType: string | undefined): string => { + switch (yieldType) { + case 'native-staking': + case 'pooled-staking': + case 'liquid-staking': + case 'staking': + return 'defi.unstake' + case 'vault': + case 'lending': + default: + return 'common.withdraw' + } +} + +const INPUT_LENGTH_BREAKPOINTS = { + FOR_XS_FONT: 22, + FOR_SM_FONT: 14, + FOR_MD_FONT: 10, +} as const + +const getInputFontSize = (length: number): string => { + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_XS_FONT) return '24px' + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_SM_FONT) return '30px' + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_MD_FONT) return '38px' + return '48px' +} + +type CryptoAmountInputProps = { + value?: string + onChange?: (e: ChangeEvent) => void + placeholder?: string + [key: string]: unknown +} + +const CryptoAmountInput = (props: CryptoAmountInputProps) => { + const valueLength = useMemo(() => (props.value ? String(props.value).length : 0), [props.value]) + const fontSize = useMemo(() => getInputFontSize(valueLength), [valueLength]) + + return ( + + ) +} + +const YieldFormSkeleton = memo(() => ( + + + + +)) + +const selectedHoverSx = { bg: 'blue.600' } +const unselectedHoverSx = { bg: 'background.surface.raised.hover' } + +export const YieldForm = memo( + ({ + yieldItem, + balances, + action, + validatorAddress, + accountNumber = 0, + onClose, + onDone, + }: YieldFormProps) => { + const queryClient = useQueryClient() + const translate = useTranslate() + const { state: walletState, dispatch: walletDispatch } = useWallet() + const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) + const isYieldMultiAccountEnabled = useFeatureFlag('YieldMultiAccount') + const { + number: { localeParts }, + } = useLocaleFormatter() + + const [cryptoAmount, setCryptoAmount] = useState('') + const [isFiat, setIsFiat] = useState(false) + const [selectedAccountId, setSelectedAccountId] = useState() + const [selectedPercent, setSelectedPercent] = useState(null) + + const { chainId } = yieldItem + const inputToken = yieldItem.inputTokens[0] + const inputTokenAssetId = inputToken?.assetId + + const claimableBalance = useMemo(() => balances?.byType[YieldBalanceType.Claimable], [balances]) + const claimableToken = claimableBalance?.token + const claimableAmount = claimableBalance?.aggregatedAmount ?? '0' + const isClaimAction = action === 'claim' + + const claimAction = useMemo( + () => claimableBalance?.pendingActions?.find(a => a.type.toUpperCase().includes('CLAIM')), + [claimableBalance], + ) + + const accountIdFilter = useMemo( + () => ({ assetId: inputTokenAssetId ?? '' }), + [inputTokenAssetId], + ) + const accountIds = useAppSelector(state => + selectPortfolioAccountIdsByAssetIdFilter(state, accountIdFilter), + ) + + const defaultAccountId = useAppSelector(state => { + if (!chainId) return undefined + const accountIdsByNumberAndChain = selectAccountIdByAccountNumberAndChainId(state) + return accountIdsByNumberAndChain[accountNumber]?.[chainId] + }) + + const accountId = selectedAccountId ?? defaultAccountId + const hasMultipleAccounts = accountIds.length > 1 + const isAccountSelectorDisabled = !isYieldMultiAccountEnabled || !hasMultipleAccounts + + const shouldFetchValidators = useMemo( + () => + yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection, + [yieldItem.mechanics.type, yieldItem.mechanics.requiresValidatorSelection], + ) + const { data: validators, isLoading: isValidatorsLoading } = useYieldValidators( + yieldItem.id, + shouldFetchValidators, + ) + + const selectedValidatorAddress = useMemo(() => { + if (validatorAddress) return validatorAddress + if (chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId]) { + return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] + } + return validators?.[0]?.address + }, [chainId, validators, validatorAddress]) + + const { data: providers } = useYieldProviders() + + const isStaking = yieldItem.mechanics.type === 'staking' + + const selectedValidatorMetadata = useMemo(() => { + if (!isStaking || !selectedValidatorAddress) return null + const found = validators?.find(v => v.address === selectedValidatorAddress) + if (found) return found + if (selectedValidatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { + return { + name: SHAPESHIFT_VALIDATOR_NAME, + logoURI: SHAPESHIFT_VALIDATOR_LOGO, + address: selectedValidatorAddress, + } + } + return null + }, [isStaking, selectedValidatorAddress, validators]) + + const providerMetadata = useMemo(() => { + if (!providers) return null + return providers[yieldItem.providerId] + }, [providers, yieldItem.providerId]) + + const inputTokenAsset = useAppSelector(state => selectAssetById(state, inputTokenAssetId ?? '')) + + const inputTokenBalance = useAppSelector(state => + inputTokenAssetId && accountId + ? selectPortfolioCryptoPrecisionBalanceByFilter(state, { + assetId: inputTokenAssetId, + accountId, + }) + : '0', + ) + + // Calculate maximum available balance for the action + // If Enter: Wallet Balance + // If Exit: Staked Balance (filtered by validator if applicable) + // If Claim: Not really relevant for amount input usually, but if so, claimable amount + const availableBalance = useMemo(() => { + if (action === 'enter') return inputTokenBalance + + if (action === 'exit' && balances) { + if (isStaking && selectedValidatorAddress) { + // Find specific validator entered balance + const validatorBalance = balances.raw.find( + b => + b.validator?.address === selectedValidatorAddress && + b.type === YieldBalanceType.Active, + ) + return validatorBalance?.amount ?? '0' + } + // Otherwise use total active balance + return balances.byType[YieldBalanceType.Active]?.aggregatedAmount ?? '0' + } + + return '0' // default fallback + }, [action, inputTokenBalance, balances, isStaking, selectedValidatorAddress]) + + // Proceeding with inputTokenBalance for now to match YieldEnterModal logic structure, but aware this is a nuance for Exit. + + const marketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId ?? ''), + ) + + const minDeposit = yieldItem.mechanics?.entryLimits?.minimum + + const isBelowMinimum = useMemo(() => { + if (!cryptoAmount) return false + if (action === 'enter' && minDeposit) { + return bnOrZero(cryptoAmount).lt(minDeposit) + } + if (action === 'exit') { + // For exit, maybe ensure they don't exit more than they have? + // Though the transaction flow usually simulates and fails. + // But UI check is nice. + return bnOrZero(cryptoAmount).gt(availableBalance) + } + return false + }, [cryptoAmount, minDeposit, action, availableBalance]) + + const isLoading = isValidatorsLoading || !inputTokenAsset + + const fiatAmount = useMemo( + () => bnOrZero(cryptoAmount).times(marketData?.price ?? 0), + [cryptoAmount, marketData?.price], + ) + + const apy = useMemo(() => bnOrZero(yieldItem.rewardRate.total), [yieldItem.rewardRate.total]) + const apyDisplay = useMemo(() => `${apy.times(100).toFixed(2)}%`, [apy]) + + const estimatedYearlyEarnings = useMemo( + () => bnOrZero(cryptoAmount).times(apy), + [cryptoAmount, apy], + ) + + const estimatedYearlyEarningsFiat = useMemo( + () => estimatedYearlyEarnings.times(marketData?.price ?? 0), + [estimatedYearlyEarnings, marketData?.price], + ) + + const hasAmount = bnOrZero(cryptoAmount).gt(0) + + const displayPlaceholder = useMemo( + () => (isFiat ? `${localeParts.prefix}0` : '0'), + [isFiat, localeParts.prefix], + ) + + const handleInputChange = useCallback( + (values: NumberFormatValues) => { + setSelectedPercent(null) + if (isFiat) { + const crypto = bnOrZero(values.value) + .div(marketData?.price || 1) + .toFixed() + setCryptoAmount(crypto) + } else { + setCryptoAmount(values.value) + } + }, + [isFiat, marketData?.price], + ) + + const displayValue = useMemo(() => { + if (isFiat) { + return fiatAmount.toFixed(2) + } + return cryptoAmount + }, [isFiat, fiatAmount, cryptoAmount]) + + const toggleIsFiat = useCallback(() => setIsFiat(prev => !prev), []) + + const handlePercentClick = useCallback( + (percent: number) => { + const percentAmount = bnOrZero(availableBalance).times(percent).toFixed() + setCryptoAmount(percentAmount) + setSelectedPercent(percent) + }, + [availableBalance], + ) + + const handleConnectWallet = useCallback( + () => walletDispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), + [walletDispatch], + ) + + const handleFormDone = useCallback(() => { + setCryptoAmount('') + setSelectedPercent(null) + setIsFiat(false) + setSelectedAccountId(undefined) + // Clear queries? + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote', action, yieldItem.id] }) + if (onDone) onDone() + else onClose() + }, [onClose, onDone, queryClient, yieldItem.id, action]) + + const handleAccountChange = useCallback((newAccountId: string) => { + setSelectedAccountId(newAccountId) + setCryptoAmount('') + setSelectedPercent(null) + }, []) + + // Map 'claim' -> 'manage' for useYieldTransactionFlow + const flowAction = action === 'claim' ? 'manage' : action + + const { + step, + transactionSteps, + displaySteps, + isSubmitting, + activeStepIndex, + handleConfirm, + isQuoteLoading, + quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, + } = useYieldTransactionFlow({ + yieldItem, + action: flowAction, + amount: isClaimAction ? claimableAmount : cryptoAmount, + assetSymbol: isClaimAction ? claimableToken?.symbol ?? '' : inputTokenAsset?.symbol ?? '', + onClose: handleFormDone, + isOpen: true, + validatorAddress: selectedValidatorAddress, + accountId, + passthrough: claimAction?.passthrough, + manageActionType: claimAction?.type, + }) + + const isQuoteActive = isQuoteLoading || isAllowanceCheckPending + + useEffect(() => { + if (step === ModalStep.Success) { + // Here we could auto-close or let YieldSuccess handle it + } + }, [step]) + + const successProviderInfo = useMemo(() => { + if (isStaking && selectedValidatorMetadata) { + return { + name: selectedValidatorMetadata.name, + logoURI: selectedValidatorMetadata.logoURI, + } + } + if (providerMetadata) { + return { + name: providerMetadata.name, + logoURI: providerMetadata.logoURI, + } + } + return null + }, [isStaking, selectedValidatorMetadata, providerMetadata]) + + const buttonDisabled = useMemo(() => { + if (!isConnected) return false + if (isLoading) return true + if (isClaimAction) { + return !claimAction || !claimableAmount || bnOrZero(claimableAmount).lte(0) + } + return !cryptoAmount || isBelowMinimum || !quoteData + }, [ + isConnected, + isLoading, + isClaimAction, + claimAction, + claimableAmount, + cryptoAmount, + isBelowMinimum, + quoteData, + ]) + + const buttonText = useMemo(() => { + if (!isConnected) return translate('common.connectWallet') + if (isQuoteActive) return translate('yieldXYZ.loadingQuote') + + if (isSubmitting && transactionSteps.length > 0) { + const activeStep = transactionSteps.find(s => s.status !== 'success') + if (activeStep) return getTransactionButtonText(activeStep.type, activeStep.originalTitle) + } + + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { + const currentStep = transactionSteps[activeStepIndex] + return getTransactionButtonText(currentStep.type, currentStep.originalTitle) + } + + if (isUsdtResetRequired) { + return translate('yieldXYZ.resetAllowance') + } + + const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED') + if (firstCreatedTx) return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) + + const yieldType = yieldItem.mechanics.type + if (action === 'enter') { + const actionKey = getEnterActionTextKey(yieldType) + return `${translate(actionKey)} ${inputTokenAsset?.symbol ?? ''}` + } + if (action === 'exit') { + const actionKey = getExitActionTextKey(yieldType) + return `${translate(actionKey)} ${inputTokenAsset?.symbol ?? ''}` + } + if (action === 'claim') { + return `${translate('common.claim')} ${claimableToken?.symbol ?? ''}` + } + return translate('common.continue') + }, [ + isConnected, + isQuoteActive, + isSubmitting, + transactionSteps, + activeStepIndex, + isUsdtResetRequired, + quoteData, + translate, + inputTokenAsset?.symbol, + action, + yieldItem.mechanics.type, + claimableToken?.symbol, + ]) + + const percentButtons = useMemo( + () => ( + + {PRESET_PERCENTAGES.map(percent => { + const isSelected = selectedPercent === percent + return ( + + ) + })} + + ), + [selectedPercent, handlePercentClick, translate], + ) + + const statsContent = useMemo( + () => ( + + + + {translate('yieldXYZ.currentApy')} + + + {apyDisplay} + + + {hasAmount && ( + + + {translate('yieldXYZ.estYearlyEarnings')} + + + + {estimatedYearlyEarnings.decimalPlaces(4).toString()} {inputTokenAsset?.symbol} + + + + + + + )} + {isStaking && selectedValidatorMetadata && ( + + + {translate('yieldXYZ.validator')} + + + + + {selectedValidatorMetadata.name} + + + + )} + {!isStaking && providerMetadata && ( + + + {translate('yieldXYZ.provider')} + + + + + {providerMetadata.name} + + + + )} + {minDeposit && bnOrZero(minDeposit).gt(0) && action === 'enter' && ( + + + {translate('yieldXYZ.minEnter')} + + + {minDeposit} {inputTokenAsset?.symbol} + + + )} + + ), + [ + translate, + apyDisplay, + hasAmount, + estimatedYearlyEarnings, + inputTokenAsset?.symbol, + estimatedYearlyEarningsFiat, + isStaking, + selectedValidatorMetadata, + providerMetadata, + minDeposit, + isBelowMinimum, + action, + ], + ) + + const inputContent = useMemo(() => { + if (isLoading) return + + if (isClaimAction && claimableToken) { + return ( + + + + + + + {translate('yieldXYZ.claimableRewards')} + + + ) + } + + return ( + + {inputTokenAssetId && } + + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleIsFiat() + } + }} + > + + {isFiat ? ( + + ) : ( + + )} + + + + + ) + }, [ + isLoading, + isClaimAction, + claimableToken, + claimableAmount, + translate, + inputTokenAssetId, + isFiat, + inputTokenAsset?.precision, + localeParts, + displayValue, + displayPlaceholder, + inputTokenAsset?.symbol, + handleInputChange, + toggleIsFiat, + cryptoAmount, + fiatAmount, + ]) + + const isSuccess = step === ModalStep.Success + + const stepsToShow = activeStepIndex >= 0 ? transactionSteps : displaySteps + + // If Success, render YieldSuccess + if (isSuccess) { + const successAmount = isClaimAction ? claimableAmount : cryptoAmount + const successSymbol = isClaimAction + ? claimableToken?.symbol ?? '' + : inputTokenAsset?.symbol ?? '' + const successMessageKey = isClaimAction + ? 'successClaim' + : action === 'exit' + ? 'successExit' + : 'successEnter' + + return ( + + ) + } + + return ( + + + {inputContent} + {!isClaimAction && percentButtons} + {!isClaimAction && inputTokenAssetId && accountId && ( + + + + )} + {!isClaimAction && statsContent} + {stepsToShow.length > 0 && } + + + + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx new file mode 100644 index 00000000000..6d1552c1f96 --- /dev/null +++ b/src/pages/Yields/components/YieldHero.tsx @@ -0,0 +1,252 @@ +import { ArrowBackIcon, ArrowDownIcon, ArrowUpIcon } from '@chakra-ui/icons' +import { + Avatar, + Badge, + Box, + Button, + Flex, + HStack, + IconButton, + Text, + VStack, +} from '@chakra-ui/react' +import qs from 'qs' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate, useSearchParams } from 'react-router-dom' + +import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' +import { ChainIcon } from '@/components/ChainMenu' +import { useBrowserRouter } from '@/hooks/useBrowserRouter/useBrowserRouter' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' + +const backIcon = +const enterIcon = +const exitIcon = + +type ValidatorOrProviderInfo = { + name: string + logoURI?: string +} | null + +type YieldHeroProps = { + yieldItem: AugmentedYieldDto + userBalanceUsd: string + userBalanceCrypto: string + validatorOrProvider: ValidatorOrProviderInfo + titleOverride?: string +} + +export const YieldHero = memo( + ({ + yieldItem, + userBalanceUsd, + userBalanceCrypto, + validatorOrProvider, + titleOverride, + }: YieldHeroProps) => { + const navigate = useNavigate() + const translate = useTranslate() + const { location } = useBrowserRouter() + + const iconSource = useMemo(() => resolveYieldInputAssetIcon(yieldItem), [yieldItem]) + const apy = useMemo( + () => bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2), + [yieldItem.rewardRate.total], + ) + const hasExitBalance = useMemo(() => bnOrZero(userBalanceCrypto).gt(0), [userBalanceCrypto]) + + const [searchParams] = useSearchParams() + const validator = useMemo(() => searchParams.get('validator'), [searchParams]) + + const handleBack = useCallback(() => navigate('/yields'), [navigate]) + + const handleAction = useCallback( + (action: 'enter' | 'exit') => { + navigate({ + pathname: location.pathname, + search: qs.stringify({ + action, + modal: 'yield', + ...(validator ? { validator } : {}), + }), + }) + }, + [navigate, location.pathname, validator], + ) + + const handleEnter = useCallback(() => handleAction('enter'), [handleAction]) + const handleExit = useCallback(() => handleAction('exit'), [handleAction]) + + const enterLabel = useMemo( + () => + yieldItem.mechanics.type === 'staking' + ? translate('defi.stake') + : translate('common.deposit'), + [yieldItem.mechanics.type, translate], + ) + + const exitLabel = useMemo( + () => + yieldItem.mechanics.type === 'staking' + ? translate('defi.unstake') + : translate('common.withdraw'), + [yieldItem.mechanics.type, translate], + ) + + const yieldTitle = titleOverride ?? yieldItem.metadata.name ?? yieldItem.token.symbol + + const stackedIconElement = useMemo(() => { + const assetIcon = iconSource.assetId ? ( + + ) : ( + + ) + + const hasOverlay = validatorOrProvider?.logoURI || yieldItem.chainId + + if (!hasOverlay) return assetIcon + + return ( + + {assetIcon} + {validatorOrProvider?.logoURI ? ( + + ) : yieldItem.chainId ? ( + + + + ) : null} + + ) + }, [iconSource, validatorOrProvider, yieldItem.chainId]) + + return ( + + + + + {yieldTitle} + + + + + + {stackedIconElement} + + {validatorOrProvider?.name ?? yieldItem.token.symbol} + + {yieldItem.chainId && ( + + + + {yieldItem.network} + + + )} + + + + {apy}% {translate('common.apy')} + + + {yieldItem.metadata.description && ( + + {yieldItem.metadata.description} + + )} + + + + + + + {yieldItem.token.symbol} + + + + + + + + + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx index cbf6e465903..22049d633c6 100644 --- a/src/pages/Yields/components/YieldItem.tsx +++ b/src/pages/Yields/components/YieldItem.tsx @@ -6,13 +6,13 @@ import { CardBody, Flex, HStack, + SimpleGrid, Skeleton, SkeletonCircle, Stat, StatLabel, StatNumber, Text, - useColorModeValue, } from '@chakra-ui/react' import type BigNumber from 'bignumber.js' import { memo, useCallback, useMemo } from 'react' @@ -21,10 +21,10 @@ import { useNavigate } from 'react-router-dom' import { Amount } from '@/components/Amount/Amount' import { AssetIcon } from '@/components/AssetIcon' -import { ChainIcon } from '@/components/ChainMenu' import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' +import { GradientApy } from '@/pages/Yields/components/GradientApy' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { selectUserCurrencyToUsdRate } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -33,6 +33,7 @@ type SingleYieldData = { type: 'single' yieldItem: AugmentedYieldDto providerIcon?: string + providerName?: string } type GroupYieldData = { @@ -46,356 +47,488 @@ type GroupYieldData = { type YieldItemProps = { data: SingleYieldData | GroupYieldData - variant: 'card' | 'row' + variant: 'card' | 'row' | 'mobile' userBalanceUsd?: BigNumber + availableBalanceUserCurrency?: BigNumber onEnter?: (yieldItem: AugmentedYieldDto) => void + searchString?: string + titleOverride?: string } -export const YieldItem = memo(({ data, variant, userBalanceUsd, onEnter }: YieldItemProps) => { - const navigate = useNavigate() - const translate = useTranslate() - const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) - const { data: yieldProviders } = useYieldProviders() +export const YieldItem = memo( + ({ + data, + variant, + userBalanceUsd, + availableBalanceUserCurrency, + onEnter, + searchString, + titleOverride, + }: YieldItemProps) => { + const navigate = useNavigate() + const translate = useTranslate() + const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) + const { data: yieldProviders } = useYieldProviders() - const borderColor = useColorModeValue('gray.100', 'gray.750') - const cardBg = useColorModeValue('white', 'gray.800') - const hoverBorderColor = useColorModeValue('blue.500', 'blue.400') - const hoverBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const cardShadow = useColorModeValue('sm', 'none') - const cardHoverShadow = useColorModeValue('lg', 'lg') + const isSingle = data.type === 'single' + const isGroup = data.type === 'group' - const isSingle = data.type === 'single' - const isGroup = data.type === 'group' + const stats = useMemo(() => { + if (isSingle) { + const y = data.yieldItem + return { + apy: y.rewardRate.total, + apyLabel: y.rewardRate.rateType, + tvlUsd: y.statistics?.tvlUsd ?? '0', + providers: [{ id: y.providerId, logo: data.providerIcon }], + chainIds: y.chainId ? [y.chainId] : [], + count: 1, + name: y.metadata.name, + canEnter: y.status.enter, + } + } + const yields = data.yields + const maxApy = Math.max(0, ...yields.map(y => y.rewardRate.total)) + const totalTvlUsd = yields + .reduce((acc, y) => acc.plus(bnOrZero(y.statistics?.tvlUsd)), bnOrZero(0)) + .toFixed() + const providerIds = [...new Set(yields.map(y => y.providerId))] + const chainIds = [...new Set(yields.map(y => y.chainId).filter(Boolean))] as string[] - const stats = useMemo(() => { - if (isSingle) { - const y = data.yieldItem return { - apy: y.rewardRate.total, - apyLabel: y.rewardRate.rateType, - tvlUsd: y.statistics?.tvlUsd ?? '0', - providers: [{ id: y.providerId, logo: data.providerIcon }], - chainIds: y.chainId ? [y.chainId] : [], - count: 1, - name: y.metadata.name, - canEnter: y.status.enter, + apy: maxApy, + apyLabel: 'APY', + tvlUsd: totalTvlUsd, + providers: providerIds.map(id => ({ id, logo: yieldProviders?.[id]?.logoURI })), + chainIds, + count: yields.length, + name: data.assetName, + canEnter: true, } - } - const yields = data.yields - const maxApy = Math.max(0, ...yields.map(y => y.rewardRate.total)) - const totalTvlUsd = yields - .reduce((acc, y) => acc.plus(bnOrZero(y.statistics?.tvlUsd)), bnOrZero(0)) - .toFixed() - const providerIds = [...new Set(yields.map(y => y.providerId))] - const chainIds = [...new Set(yields.map(y => y.chainId).filter(Boolean))] as string[] + }, [data, isSingle, yieldProviders]) - return { - apy: maxApy, - apyLabel: 'APY', - tvlUsd: totalTvlUsd, - providers: providerIds.map(id => ({ id, logo: yieldProviders?.[id]?.logoURI })), - chainIds, - count: yields.length, - name: data.assetName, - canEnter: true, - } - }, [data, isSingle, yieldProviders]) + const apyFormatted = useMemo(() => `${(stats.apy * 100).toFixed(2)}%`, [stats.apy]) - const apyFormatted = useMemo(() => `${(stats.apy * 100).toFixed(2)}%`, [stats.apy]) - - const tvlUserCurrency = useMemo( - () => bnOrZero(stats.tvlUsd).times(userCurrencyToUsdRate).toFixed(), - [stats.tvlUsd, userCurrencyToUsdRate], - ) + const tvlUserCurrency = useMemo( + () => bnOrZero(stats.tvlUsd).times(userCurrencyToUsdRate).toFixed(), + [stats.tvlUsd, userCurrencyToUsdRate], + ) - const userBalanceUserCurrency = useMemo( - () => (userBalanceUsd ? userBalanceUsd.times(userCurrencyToUsdRate).toFixed() : undefined), - [userBalanceUsd, userCurrencyToUsdRate], - ) + const userBalanceUserCurrency = useMemo( + () => (userBalanceUsd ? userBalanceUsd.times(userCurrencyToUsdRate).toFixed() : undefined), + [userBalanceUsd, userCurrencyToUsdRate], + ) - const hasBalance = userBalanceUsd && userBalanceUsd.gt(0) + const hasBalance = userBalanceUsd && userBalanceUsd.gt(0) + const hasAvailable = availableBalanceUserCurrency && availableBalanceUserCurrency.gt(0) - const handleClick = useCallback(() => { - if (isSingle) { - if (stats.canEnter && onEnter) { - onEnter(data.yieldItem) + const handleClick = useCallback(() => { + if (isSingle) { + if (stats.canEnter && onEnter) { + onEnter(data.yieldItem) + } else { + navigate(`/yields/${data.yieldItem.id}`) + } } else { - navigate(`/yields/${data.yieldItem.id}`) + const suffix = searchString ? `?${searchString}` : '' + navigate(`/yields/asset/${encodeURIComponent(data.assetSymbol)}${suffix}`) } - } else { - navigate(`/yields/asset/${encodeURIComponent(data.assetSymbol)}`) - } - }, [data, isSingle, navigate, onEnter, stats.canEnter]) + }, [data, isSingle, navigate, onEnter, searchString, stats.canEnter]) - const iconElement = useMemo(() => { - if (isSingle) { - const iconSource = resolveYieldInputAssetIcon(data.yieldItem) + const iconElement = useMemo(() => { + if (isSingle) { + const iconSource = resolveYieldInputAssetIcon(data.yieldItem) + const size = variant === 'card' ? 'md' : 'sm' + if (iconSource.assetId) { + return + } + return + } const size = variant === 'card' ? 'md' : 'sm' - if (iconSource.assetId) { + if (data.assetId) { + return + } + return + }, [data, isSingle, variant]) + + const subtitle = useMemo(() => { + if (isSingle) { + return data.providerName ?? data.yieldItem.providerId + } + return `${stats.count} ${ + stats.count === 1 ? translate('yieldXYZ.market') : translate('yieldXYZ.markets') + }` + }, [data, isSingle, stats.count, translate]) + + const title = useMemo(() => { + if (titleOverride) return titleOverride + if (isSingle) return data.yieldItem.metadata.name + return data.assetSymbol + }, [data, isSingle, titleOverride]) + + const showAvailable = isSingle && hasAvailable && !hasBalance + + const cardStatElement = useMemo(() => { + if (hasBalance) { return ( - + <> + + {translate('yieldXYZ.balance')} + + + + + + ) + } + if (showAvailable) { + return ( + <> + + {translate('common.available')} + + + + + ) } return ( - - ) - } - const size = variant === 'card' ? 'md' : 'sm' - if (data.assetId) { - return ( - + <> + + {translate('yieldXYZ.tvl')} + + + + + ) - } - return ( - - ) - }, [data, isSingle, variant, borderColor]) + }, [ + hasBalance, + showAvailable, + userBalanceUserCurrency, + availableBalanceUserCurrency, + tvlUserCurrency, + translate, + ]) - const subtitle = useMemo(() => { - if (isSingle) { - return data.yieldItem.providerId - } - return `${stats.count} ${ - stats.count === 1 ? translate('yieldXYZ.market') : translate('yieldXYZ.markets') - }` - }, [data, isSingle, stats.count, translate]) + const showAvailableInRow = isSingle && hasAvailable - const title = useMemo(() => { - if (isSingle) return data.yieldItem.metadata.name - return data.assetSymbol - }, [data, isSingle]) + const mobileBalanceLabelKey = useMemo(() => { + if (hasBalance) return 'yieldXYZ.balance' + if (showAvailable) return 'common.available' + return 'yieldXYZ.balance' + }, [hasBalance, showAvailable]) - if (variant === 'row') { - return ( - - - - {iconElement} - - - {title} - - - {subtitle} + const mobileBalanceElement = useMemo(() => { + if (hasBalance) { + return ( + + + + ) + } + if (showAvailable) { + return ( + + + + ) + } + return ( + + — + + ) + }, [hasBalance, showAvailable, userBalanceUserCurrency, availableBalanceUserCurrency]) + + const rowBalanceElement = useMemo(() => { + if (hasBalance && showAvailableInRow) { + return ( + + + + - - - - - {isGroup ? translate('yieldXYZ.maxApy') : translate('yieldXYZ.apy')} + {translate('yieldXYZ.balance').toLowerCase()} - - {apyFormatted} + + + + - - - {translate('yieldXYZ.tvl')} + {translate('common.available').toLowerCase()} - - + + + ) + } + if (hasBalance) { + return ( + + + + ) + } + if (showAvailableInRow) { + return ( + <> + + {translate('common.available')} + + + + + + ) + } + return ( + + — + + ) + }, [ + hasBalance, + showAvailableInRow, + userBalanceUserCurrency, + availableBalanceUserCurrency, + translate, + ]) + + if (variant === 'mobile') { + return ( + + + + {iconElement} + + {title} - - - {isGroup ? ( - - {stats.providers.map(p => ( - - ))} - - ) : ( - - {stats.providers.slice(0, 1).map(p => ( - - ))} - - )} - - - {hasBalance ? ( - - + + + + + + {translate(mobileBalanceLabelKey)} - ) : ( - - — + {mobileBalanceElement} + + + + {translate('yieldXYZ.tvl')} - )} - - - - - ) - } + + + + + + + {isGroup ? translate('yieldXYZ.maxApy') : translate('yieldXYZ.apy')} + + + {apyFormatted} + + + + + + ) + } - return ( - - - - - {iconElement} - - - {title} - - - {isSingle && data.providerIcon && ( - - )} + if (variant === 'row') { + return ( + + + + {iconElement} + + + {title} + {subtitle} - - - - - - - - - {isGroup - ? translate('yieldXYZ.maxApy') - : `${translate('yieldXYZ.apy')} (${stats.apyLabel})`} - - - {apyFormatted} - - - - {hasBalance ? ( - - - - ) : ( - <> - + + + + + + {isGroup ? translate('yieldXYZ.maxApy') : translate('yieldXYZ.apy')} + + + {apyFormatted} + + + + {translate('yieldXYZ.tvl')} - - + + - - - )} - -
- - {isGroup && ( - - - - - {stats.providers.length}{' '} - {stats.providers.length === 1 - ? translate('yieldXYZ.protocol') - : translate('yieldXYZ.protocols')} - - {stats.providers.map(p => ( - - ))} - - - - {stats.chainIds.length}{' '} - {stats.chainIds.length === 1 - ? translate('yieldXYZ.chain') - : translate('yieldXYZ.chains')} + + {isGroup ? ( + + {stats.providers.map(p => ( + + ))} + + ) : ( + + {stats.providers.slice(0, 1).map(p => ( + + ))} + + )} + + + {rowBalanceElement} + + + + + ) + } + + return ( + + + + + {iconElement} + + + {title} - - {stats.chainIds.slice(0, 5).map(chainId => ( - - ))} - + + {isSingle && data.providerIcon && ( + + )} + + {subtitle} + + - - )} - - - ) -}) + + + + + + {isGroup + ? translate('yieldXYZ.maxApy') + : `${translate('yieldXYZ.apy')} (${stats.apyLabel})`} + + + {apyFormatted} + + + + {cardStatElement} + + + + + ) + }, +) -export const YieldItemSkeleton = memo(({ variant }: { variant: 'card' | 'row' }) => { - const borderColor = useColorModeValue('gray.100', 'gray.750') - const cardBg = useColorModeValue('white', 'gray.800') +export const YieldItemSkeleton = memo(({ variant }: { variant: 'card' | 'row' | 'mobile' }) => { + if (variant === 'mobile') { + return ( + + + + + + + + + + + + + + + ) + } if (variant === 'row') { return ( + diff --git a/src/pages/Yields/components/YieldManager.tsx b/src/pages/Yields/components/YieldManager.tsx new file mode 100644 index 00000000000..70dd74070bd --- /dev/null +++ b/src/pages/Yields/components/YieldManager.tsx @@ -0,0 +1,97 @@ +import { useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate, useParams, useSearchParams } from 'react-router-dom' + +import { YieldForm } from './YieldForm' + +import { Dialog } from '@/components/Modal/components/Dialog' +import { DialogBody } from '@/components/Modal/components/DialogBody' +import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' +import { DialogHeader } from '@/components/Modal/components/DialogHeader' +import { DialogTitle } from '@/components/Modal/components/DialogTitle' +import { + COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, +} from '@/lib/yieldxyz/constants' +import { YieldBalanceType } from '@/lib/yieldxyz/types' +import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' +import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' +import { useYield } from '@/react-queries/queries/yieldxyz/useYield' + +export const YieldManager = () => { + const translate = useTranslate() + const navigate = useNavigate() + const { yieldId } = useParams<{ yieldId: string }>() + const [searchParams] = useSearchParams() + + const action = searchParams.get('action') as 'enter' | 'exit' | 'claim' | undefined + const validatorParam = searchParams.get('validator') ?? undefined + + const { data: yieldItem } = useYield(yieldId ?? '') + + const validatorAddress = useMemo(() => { + // For native staking with hardcoded defaults, always use the default validator (ignore URL param) + if ( + yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID || + yieldId === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || + (yieldId?.includes('solana') && yieldId?.includes('native')) + ) { + return yieldItem?.chainId + ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] + : undefined + } + return validatorParam + }, [yieldId, yieldItem?.chainId, validatorParam]) + const { accountNumber } = useYieldAccount() + const { data: allBalancesData } = useAllYieldBalances() + const balances = yieldItem?.id ? allBalancesData?.normalized[yieldItem.id] : undefined + + const inputTokenSymbol = yieldItem?.inputTokens[0]?.symbol + const claimableTokenSymbol = balances?.byType[YieldBalanceType.Claimable]?.token?.symbol + const isStaking = yieldItem?.mechanics.type === 'staking' + + const title = useMemo(() => { + if (action === 'enter') { + return isStaking + ? translate('yieldXYZ.stakeSymbol', { symbol: inputTokenSymbol }) + : translate('yieldXYZ.depositSymbol', { symbol: inputTokenSymbol }) + } + if (action === 'exit') { + return isStaking + ? translate('yieldXYZ.unstakeSymbol', { symbol: inputTokenSymbol }) + : translate('yieldXYZ.withdrawSymbol', { symbol: inputTokenSymbol }) + } + if (action === 'claim') { + return translate('yieldXYZ.claimSymbol', { symbol: claimableTokenSymbol ?? '' }) + } + return translate('yieldXYZ.manage') + }, [action, isStaking, translate, inputTokenSymbol, claimableTokenSymbol]) + + if (!yieldItem) return null + + return ( + navigate(-1)} isFullScreen> + + {null} + + {title} + + + navigate(-1)} /> + + + + navigate(-1)} + onDone={() => navigate(-1)} + /> + + + ) +} diff --git a/src/pages/Yields/components/YieldOpportunityCard.tsx b/src/pages/Yields/components/YieldOpportunityCard.tsx index 5dc0e10a6ae..142ba8afb2a 100644 --- a/src/pages/Yields/components/YieldOpportunityCard.tsx +++ b/src/pages/Yields/components/YieldOpportunityCard.tsx @@ -1,9 +1,11 @@ -import { Box, Button, Flex, Heading, Text, useColorModeValue } from '@chakra-ui/react' +import { Box, Button, Flex } from '@chakra-ui/react' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { Text } from '@/components/Text/Text' import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { GradientApy } from '@/pages/Yields/components/GradientApy' type YieldOpportunityCardProps = { maxApyYield: AugmentedYieldDto @@ -14,51 +16,50 @@ const hoverStyle = { bgGradient: 'linear(to-r, blue.600, purple.700)' } export const YieldOpportunityCard = memo(({ maxApyYield, onClick }: YieldOpportunityCardProps) => { const translate = useTranslate() - const bg = useColorModeValue('gray.50', 'whiteAlpha.100') - const borderColor = useColorModeValue('gray.100', 'whiteAlpha.100') const apy = useMemo( () => bnOrZero(maxApyYield.rewardRate.total).times(100).toFixed(2), [maxApyYield.rewardRate.total], ) - const earnUpToText = useMemo(() => translate('yieldXYZ.earnUpTo', { apy }), [translate, apy]) - const startEarningText = useMemo(() => translate('yieldXYZ.startEarning'), [translate]) const handleClick = useCallback(() => { onClick(maxApyYield) }, [onClick, maxApyYield]) + const apyComponent = useMemo( + () => ( + + {apy}% + + ), + [apy], + ) + return ( - - - - {earnUpToText} - - - {apy}% APY - + + + - )} - - + + + + + {translate('yieldXYZ.claimable')} + + + {formatBalance(claimableBalance)} + + + + + {translate('yieldXYZ.reward')} + + {claimAction && ( + + )} + + + ) }, [ hasClaimable, - claimableBg, - claimableBorderColor, - claimableTextColor, translate, formatBalance, claimableBalance, @@ -443,28 +329,20 @@ export const YieldPositionCard = memo( const addressBadge = useMemo(() => { if (!address) return null return ( - + {addressBadgeText} ) - }, [address, badgeBg, badgeColor, addressBadgeText]) + }, [address, addressBadgeText]) const pendingActionsSection = useMemo(() => { if (!showPendingActions) return null return ( <> - + {enteringSection} - {exitingSection} + {unstakingSection} {withdrawableSection} {claimableSection} @@ -472,24 +350,19 @@ export const YieldPositionCard = memo( ) }, [ showPendingActions, - borderColor, enteringSection, - exitingSection, + unstakingSection, withdrawableSection, claimableSection, ]) + if (!accountId) return null + if (isBalancesLoading) { return ( - - - + + + - {headingText} + {translate('yieldXYZ.myPosition')} - {addressBadge} {loadingState} @@ -507,10 +379,12 @@ export const YieldPositionCard = memo( ) } + if (!hasAnyPosition && !showPendingActions) return null + return ( - - - + + + {addressBadge} - + {translate('yieldXYZ.totalValue')} @@ -538,24 +412,7 @@ export const YieldPositionCard = memo( /> - {!hasAnyPosition && emptyStateAlert} {pendingActionsSection} - {claimModalData && ( - - )} diff --git a/src/pages/Yields/components/YieldStats.tsx b/src/pages/Yields/components/YieldStats.tsx index 251e5a9211a..981e3ca7375 100644 --- a/src/pages/Yields/components/YieldStats.tsx +++ b/src/pages/Yields/components/YieldStats.tsx @@ -1,32 +1,17 @@ -import { - Avatar, - Box, - Card, - CardBody, - Divider, - Flex, - Heading, - Icon, - Stat, - StatLabel, - StatNumber, - Text, - Tooltip, - useColorModeValue, -} from '@chakra-ui/react' +import { Avatar, Box, Flex, SimpleGrid, Text } from '@chakra-ui/react' import { memo, useMemo } from 'react' -import { FaClock, FaGasPump, FaLayerGroup, FaMoneyBillWave, FaUserShield } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' import { useSearchParams } from 'react-router-dom' import { Amount } from '@/components/Amount/Amount' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' +import { + COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, + DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, + SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, +} from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' -import type { - AugmentedYieldBalanceWithAccountId, - NormalizedYieldBalances, -} from '@/react-queries/queries/yieldxyz/useAllYieldBalances' +import type { NormalizedYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' import { selectMarketDataByAssetIdUserCurrency, @@ -34,12 +19,6 @@ import { } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -const layerGroupIcon = -const userShieldIcon = -const clockIcon = -const gasPumpIcon = -const moneyBillWaveIcon = - type YieldStatsProps = { yieldItem: AugmentedYieldDto balances?: NormalizedYieldBalances @@ -52,10 +31,6 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { const inputTokenMarketData = useAppSelector(state => selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId), ) - const cardBg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const rewardBreakdownBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const dividerColor = useColorModeValue('gray.200', 'whiteAlpha.100') const [searchParams] = useSearchParams() const validatorParam = useMemo(() => searchParams.get('validator'), [searchParams]) @@ -72,18 +47,24 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { return validators?.[0]?.address }, [yieldItem.chainId, validators]) - const selectedValidatorAddress = useMemo( - () => validatorParam || defaultValidator, - [validatorParam, defaultValidator], - ) + const selectedValidatorAddress = useMemo(() => { + // For native staking with hardcoded defaults, always use the default validator (ignore URL param) + if ( + yieldItem.id === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID || + yieldItem.id === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || + (yieldItem.id.includes('solana') && yieldItem.id.includes('native')) + ) { + return defaultValidator + } + return validatorParam || defaultValidator + }, [yieldItem.id, validatorParam, defaultValidator]) const selectedValidator = useMemo(() => { if (!selectedValidatorAddress) return undefined const inList = validators?.find(v => v.address === selectedValidatorAddress) if (inList) return inList - const inBalances = balances?.raw.find( - (b: AugmentedYieldBalanceWithAccountId) => b.validator?.address === selectedValidatorAddress, - )?.validator + const inBalances = balances?.raw.find(b => b.validator?.address === selectedValidatorAddress) + ?.validator if (inBalances) return inBalances return undefined }, [validators, selectedValidatorAddress, balances]) @@ -103,18 +84,6 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { .toFixed() }, [yieldItem.statistics?.tvlUsd, userCurrencyToUsdRate, tvl, inputTokenMarketData?.price]) - const apy = useMemo( - () => - bnOrZero( - selectedValidator && 'rewardRate' in selectedValidator && selectedValidator.rewardRate - ? selectedValidator.rewardRate.total - : yieldItem.rewardRate.total, - ) - .times(100) - .toNumber(), - [selectedValidator, yieldItem.rewardRate.total], - ) - const validatorMetadata = useMemo(() => { if (yieldItem.mechanics.type !== 'staking') return null if (selectedValidator) @@ -122,150 +91,48 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { return null }, [yieldItem.mechanics.type, selectedValidator]) - const apyFormatted = useMemo(() => apy.toFixed(2), [apy]) - const tvlFormatted = useMemo(() => tvl.toFixed(), [tvl]) - - const rewardBreakdownContent = useMemo(() => { - if (yieldItem.rewardRate.components.length === 0) return null - return ( - - {yieldItem.rewardRate.components.map((component, idx) => ( - - - - - {component.yieldSource} - - - - {bnOrZero(component.rate).times(100).toFixed(2)}% - - - ))} - - ) - }, [yieldItem.rewardRate.components, rewardBreakdownBg]) - - const validatorRowContent = useMemo(() => { - if (!validatorMetadata) return null - return ( - - - {userShieldIcon} - {translate('yieldXYZ.validator')} - - - {validatorMetadata.logoURI && ( - - )} - - {validatorMetadata.name} - - - - ) - }, [validatorMetadata, translate]) - - const minDepositRowContent = useMemo(() => { - if (!yieldItem.mechanics.entryLimits.minimum) return null - return ( - - - {moneyBillWaveIcon} - {translate('yieldXYZ.minDeposit')} - - - - ) - }, [yieldItem.mechanics.entryLimits.minimum, yieldItem.token.symbol, translate]) - return ( - - - - {translate('yieldXYZ.stats')} - - - - - - - {translate('common.apy')} - - - {apyFormatted}% - - {yieldItem.rewardRate.rateType} - - - - - {rewardBreakdownContent} - - - - - {translate('yieldXYZ.tvl')} - - - - - - + + + + {translate('yieldXYZ.tvl')} + + + + + + + {translate('yieldXYZ.rewardSchedule')} + + + {yieldItem.mechanics.rewardSchedule} + + + + + + {translate('yieldXYZ.type')} + + + {yieldItem.mechanics.type} + + + + {validatorMetadata && ( + + + {translate('yieldXYZ.validator')} + + + {validatorMetadata.logoURI && ( + + )} + + {validatorMetadata.name} - - - - - - {layerGroupIcon} - {translate('yieldXYZ.type')} - - - {yieldItem.mechanics.type} - - - {validatorRowContent} - - - {clockIcon} - {translate('yieldXYZ.rewardSchedule')} - - - {yieldItem.mechanics.rewardSchedule} - - - - - {gasPumpIcon} - {translate('yieldXYZ.gasToken')} - - - {yieldItem.mechanics.gasFeeToken.symbol} - - - {minDepositRowContent} - - - - - + + + )} + ) }) diff --git a/src/pages/Yields/components/YieldSuccess.tsx b/src/pages/Yields/components/YieldSuccess.tsx new file mode 100644 index 00000000000..588d1469551 --- /dev/null +++ b/src/pages/Yields/components/YieldSuccess.tsx @@ -0,0 +1,139 @@ +import { Avatar, Box, Button, Flex, Heading, Icon, Text, VStack } from '@chakra-ui/react' +import { memo, useCallback, useEffect, useMemo } from 'react' +import ReactCanvasConfetti from 'react-canvas-confetti' +import { FaCheck } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import { useConfetti } from '../hooks/useConfetti' +import type { TransactionStep } from '../hooks/useYieldTransactionFlow' +import { TransactionStepsList } from './TransactionStepsList' + +type ProviderInfo = { + name: string + logoURI: string | undefined +} + +type YieldSuccessProps = { + amount: string + symbol: string + providerInfo: ProviderInfo | null + transactionSteps: TransactionStep[] + yieldId?: string + onDone: () => void + showConfetti?: boolean + successMessageKey?: 'successEnter' | 'successExit' | 'successClaim' +} + +export const YieldSuccess = memo( + ({ + amount, + symbol, + providerInfo, + transactionSteps, + yieldId, + onDone, + showConfetti = true, + successMessageKey = 'successEnter', + }: YieldSuccessProps) => { + const translate = useTranslate() + const navigate = useNavigate() + const { getInstance, fireConfetti, confettiStyle } = useConfetti() + + useEffect(() => { + if (showConfetti) fireConfetti() + }, [showConfetti, fireConfetti]) + + const handleViewPosition = useCallback(() => { + if (!yieldId) return + navigate(`/yields/${yieldId}`) + }, [yieldId, navigate]) + + const providerPillProps = useMemo( + () => + yieldId + ? { + cursor: 'pointer' as const, + onClick: handleViewPosition, + _hover: { bg: 'background.surface.raised.hover' }, + transition: 'background 0.2s', + } + : {}, + [yieldId, handleViewPosition], + ) + + return ( + <> + + + + + + + + + {translate('yieldXYZ.success')} + + + {translate(`yieldXYZ.${successMessageKey}`, { amount, symbol })} + + + + {providerInfo && ( + + + {translate('yieldXYZ.via')} + + + + {providerInfo.name} + + + )} + + {transactionSteps.length > 0 && ( + + + + )} + + + {yieldId && ( + + )} + + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldTable.tsx b/src/pages/Yields/components/YieldTable.tsx index 239576bbf49..ed00865b474 100644 --- a/src/pages/Yields/components/YieldTable.tsx +++ b/src/pages/Yields/components/YieldTable.tsx @@ -1,15 +1,5 @@ import { ArrowDownIcon, ArrowUpIcon } from '@chakra-ui/icons' -import { - Flex, - Skeleton, - Table, - Tbody, - Td, - Th, - Thead, - Tr, - useColorModeValue, -} from '@chakra-ui/react' +import { Flex, Skeleton, Table, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react' import type { Row, Table as TanstackTable } from '@tanstack/react-table' import { flexRender } from '@tanstack/react-table' import { memo, useCallback, useMemo } from 'react' @@ -32,9 +22,6 @@ const tableSize = { base: 'sm', md: 'md' } const SKELETON_ROWS = 6 export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProps) => { - const hoverBg = useColorModeValue('gray.50', 'gray.750') - const hoverColor = useColorModeValue('black', 'white') - const columns = useMemo(() => table.getAllColumns(), [table]) const headerGroups = useMemo(() => table.getHeaderGroups(), [table]) const rows = useMemo(() => table.getRowModel().rows, [table]) @@ -70,7 +57,7 @@ export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProp key={row.id} cursor={isClickable ? 'pointer' : undefined} onClick={() => handleRowClick(row)} - _hover={isClickable ? { bg: hoverBg } : undefined} + _hover={isClickable ? { bg: 'background.surface.raised.base' } : undefined} > {row.getVisibleCells().map(cell => { const meta = cell.column.columnDef.meta as YieldColumnMeta | undefined @@ -83,7 +70,7 @@ export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProp