diff --git a/CLAUDE.md b/CLAUDE.md index 4beb4e217f3..d680613311a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,7 @@ - When creating commits, follow the Git Safety Protocol (see session notes) - Main branch is `develop` - use this for PRs - Branch naming: Use descriptive names (e.g., `feat_gridplus`, `fix_wallet_connect`) +- When opening PRs (via `gh`, Aviator `av`, or any CLI tool), ALWAYS use the `.github/PULL_REQUEST_TEMPLATE.md` template as the base for the PR body ### UI/UX Standards - Account for light/dark mode using `useColorModeValue` hook diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index cbe48ce44ba..f49cb5cb90b 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -452,6 +452,7 @@ "balance": "Balance", "netWorth": "Net Worth", "loadingAccounts": "Loaded %{portfolioAccountsLoaded} accounts", + "loadingMorePositions": "More DeFi positions are still loading", "walletBalanceChange24Hr": "24-hour change", "earnBody": "Earn passive income by staking your assets or depositing them into a DeFi strategy.", "noAccountsOpportunities": "You have no accounts for this asset, so staking opportunities are currently unavailable.", diff --git a/src/components/CryptoAmountInput/CryptoAmountInput.tsx b/src/components/CryptoAmountInput/CryptoAmountInput.tsx new file mode 100644 index 00000000000..8e5ce23fe2c --- /dev/null +++ b/src/components/CryptoAmountInput/CryptoAmountInput.tsx @@ -0,0 +1,44 @@ +import { Input } from '@chakra-ui/react' +import type { ChangeEvent } from 'react' +import { memo, useMemo } from 'react' + +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' +} + +export type CryptoAmountInputProps = { + value?: string + onChange?: (e: ChangeEvent) => void + placeholder?: string + [key: string]: unknown +} + +export const CryptoAmountInput = memo((props: CryptoAmountInputProps) => { + const valueLength = useMemo(() => (props.value ? String(props.value).length : 0), [props.value]) + const fontSize = useMemo(() => getInputFontSize(valueLength), [valueLength]) + + return ( + + ) +}) diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx index 74fc903b872..e3328675b08 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -1,5 +1,5 @@ import { Avatar, Box, Button, Flex, HStack, Skeleton, Text, VStack } from '@chakra-ui/react' -import { memo, useCallback, useEffect, useMemo } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' @@ -33,11 +33,13 @@ import { selectSelectedYieldId, selectSellAccountId, } from '@/state/slices/tradeEarnInputSlice/selectors' -import { useAppSelector } from '@/state/store' +import { tradeEarnInput } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice' +import { useAppDispatch, useAppSelector } from '@/state/store' export const EarnConfirm = memo(() => { const translate = useTranslate() const navigate = useNavigate() + const dispatch = useAppDispatch() const sellAsset = useAppSelector(selectInputSellAsset) const sellAmountCryptoPrecision = useAppSelector(selectInputSellAmountCryptoPrecision) @@ -141,6 +143,19 @@ export const EarnConfirm = memo(() => { accountId: accountIdToUse, }) + // Track step in ref for cleanup + const stepRef = useRef(step) + stepRef.current = step + + // Clear Redux when unmounting from success state to prevent re-access + useEffect(() => { + return () => { + if (stepRef.current === ModalStep.Success) { + dispatch(tradeEarnInput.actions.clear()) + } + } + }, [dispatch]) + // Align loading states with YieldEnterModal const isQuoteActive = isQuoteLoading || isAllowanceCheckPending const isLoading = isLoadingYields || isQuoteActive @@ -193,21 +208,13 @@ export const EarnConfirm = memo(() => { return null }, [selectedValidator, selectedYield, providers]) - if (!selectedYield) { - return ( - - {translate('earn.selectYieldOpportunity')} - - - } - footerContent={null} - onBack={handleBack} - headerTranslation='earn.confirmEarn' - /> - ) - } + const handleViewPosition = useCallback(() => { + if (!selectedYieldId) return + const params = new URLSearchParams() + if (accountIdToUse) params.set('accountId', accountIdToUse) + const queryString = params.toString() + navigate(queryString ? `/yield/${selectedYieldId}?${queryString}` : `/yield/${selectedYieldId}`) + }, [selectedYieldId, accountIdToUse, navigate]) if (step === ModalStep.Success) { return ( @@ -221,16 +228,52 @@ export const EarnConfirm = memo(() => { transactionSteps={transactionSteps} yieldId={selectedYieldId} onDone={handleBack} + showButtons={false} /> } - footerContent={null} + footerContent={ + + + {selectedYieldId && ( + + )} + + + + } onBack={handleBack} headerTranslation='yieldXYZ.success' /> ) } + if (!selectedYield) { + return ( + + {translate('earn.selectYieldOpportunity')} + + + } + footerContent={null} + onBack={handleBack} + headerTranslation='earn.confirmEarn' + /> + ) + } + const bodyContent = ( diff --git a/src/components/StakingVaults/DeFiEarn.tsx b/src/components/StakingVaults/DeFiEarn.tsx index 21235f932f0..e6920881c6b 100644 --- a/src/components/StakingVaults/DeFiEarn.tsx +++ b/src/components/StakingVaults/DeFiEarn.tsx @@ -1,10 +1,11 @@ import type { FlexProps, ResponsiveValue } from '@chakra-ui/react' -import { Box, Flex } from '@chakra-ui/react' +import { Box, Flex, Tooltip } from '@chakra-ui/react' import type { ChainId } from '@shapeshiftoss/caip' import { fromAssetId } from '@shapeshiftoss/caip' import type { Property } from 'csstype' import type { JSX } from 'react' -import { useMemo, useState } from 'react' +import { memo, useMemo, useState } from 'react' +import { useTranslate } from 'react-polyglot' import { GlobalFilter } from './GlobalFilter' import { useFetchOpportunities } from './hooks/useFetchOpportunities' @@ -13,6 +14,7 @@ import type { PositionTableProps, UnifiedOpportunity } from './PositionTable' import { PositionTable } from './PositionTable' import { ChainDropdown } from '@/components/ChainDropdown/ChainDropdown' +import { CircularProgress } from '@/components/CircularProgress/CircularProgress' import { knownChainIds } from '@/constants/chains' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useQuery } from '@/hooks/useQuery/useQuery' @@ -35,115 +37,124 @@ const flexPaddingX = { base: 2, xl: 0 } const globalFilterFlexMaxWidth = { base: '100%', md: '300px' } const tablePx = { base: 0, md: 0 } -export const DeFiEarn: React.FC = ({ - positionTableProps, - header, - forceCompactView, - ...rest -}) => { - const { - state: { isConnected }, - } = useWallet() - const { q } = useQuery<{ q?: string }>() - const [searchQuery, setSearchQuery] = useState(q ?? '') - const [selectedChainId, setSelectedChainId] = useState() - - const isYieldXyzEnabled = useFeatureFlag('YieldXyz') - - const chainIdsFromWallet = useAppSelector(state => - isConnected ? selectWalletConnectedChainIdsSorted(state) : knownChainIds, - ) - - const { isLoading: isOpportunitiesLoading } = useFetchOpportunities() - const legacyPositions = useAppSelector(state => - selectAggregatedEarnOpportunitiesByAssetId(state, { chainId: undefined }), - ) - - const { data: yieldOpportunities, isLoading: isYieldLoading } = - useYieldAsOpportunities(isYieldXyzEnabled) - - const mergedData: UnifiedOpportunity[] = useMemo(() => { - const map = new Map() - - if (isYieldXyzEnabled && yieldOpportunities) { - yieldOpportunities.forEach(item => { - map.set(item.assetId, item) - }) - } - - legacyPositions.forEach(item => { - const existing = map.get(item.assetId) - if (existing) { - const mergedFiatAmount = bnOrZero(existing.fiatAmount) - .plus(bnOrZero(item.fiatAmount)) - .toFixed(2) - const mergedApy = bnOrZero(existing.apy).gt(bnOrZero(item.apy)) ? existing.apy : item.apy - map.set(item.assetId, { - ...existing, - fiatAmount: mergedFiatAmount, - apy: mergedApy, - opportunities: { - staking: [...existing.opportunities.staking, ...item.opportunities.staking], - lp: [...existing.opportunities.lp, ...item.opportunities.lp], - }, +export const DeFiEarn = memo( + ({ positionTableProps, header, forceCompactView, ...rest }: DefiEarnProps) => { + const translate = useTranslate() + const { + state: { isConnected }, + } = useWallet() + const { q } = useQuery<{ q?: string }>() + const [searchQuery, setSearchQuery] = useState(q ?? '') + const [selectedChainId, setSelectedChainId] = useState() + + const isYieldXyzEnabled = useFeatureFlag('YieldXyz') + + const chainIdsFromWallet = useAppSelector(state => + isConnected ? selectWalletConnectedChainIdsSorted(state) : knownChainIds, + ) + + const { isLoading: isOpportunitiesLoading } = useFetchOpportunities() + const legacyPositions = useAppSelector(state => + selectAggregatedEarnOpportunitiesByAssetId(state, { chainId: undefined }), + ) + + const { data: yieldOpportunities, isLoading: isYieldLoading } = + useYieldAsOpportunities(isYieldXyzEnabled) + + const mergedData: UnifiedOpportunity[] = useMemo(() => { + const map = new Map() + + if (isYieldXyzEnabled && yieldOpportunities) { + yieldOpportunities.forEach(item => { + map.set(item.assetId, item) }) - } else { - map.set(item.assetId, item as UnifiedOpportunity) } - }) - - return Array.from(map.values()).sort((a, b) => { - const balanceDiff = bnOrZero(b.fiatAmount).minus(bnOrZero(a.fiatAmount)).toNumber() - if (balanceDiff !== 0) return balanceDiff - return bnOrZero(b.apy).minus(bnOrZero(a.apy)).toNumber() - }) - }, [isYieldXyzEnabled, legacyPositions, yieldOpportunities]) - - const chainIds = useMemo(() => { - if (!isYieldXyzEnabled || !yieldOpportunities?.length) return chainIdsFromWallet - const yieldChainIds = yieldOpportunities - .map(item => fromAssetId(item.assetId).chainId) - .filter(Boolean) - return Array.from(new Set([...chainIdsFromWallet, ...yieldChainIds])) - }, [chainIdsFromWallet, isYieldXyzEnabled, yieldOpportunities]) - - const isLoading = isOpportunitiesLoading || (isYieldXyzEnabled && isYieldLoading) - - return ( - - - {header && header} - - - - + + legacyPositions.forEach(item => { + const existing = map.get(item.assetId) + if (existing) { + const mergedFiatAmount = bnOrZero(existing.fiatAmount) + .plus(bnOrZero(item.fiatAmount)) + .toFixed(2) + const mergedApy = bnOrZero(existing.apy).gt(bnOrZero(item.apy)) ? existing.apy : item.apy + map.set(item.assetId, { + ...existing, + fiatAmount: mergedFiatAmount, + apy: mergedApy, + opportunities: { + staking: [...existing.opportunities.staking, ...item.opportunities.staking], + lp: [...existing.opportunities.lp, ...item.opportunities.lp], + }, + }) + } else { + map.set(item.assetId, item as UnifiedOpportunity) + } + }) + + return Array.from(map.values()).sort((a, b) => { + const balanceDiff = bnOrZero(b.fiatAmount).minus(bnOrZero(a.fiatAmount)).toNumber() + if (balanceDiff !== 0) return balanceDiff + return bnOrZero(b.apy).minus(bnOrZero(a.apy)).toNumber() + }) + }, [isYieldXyzEnabled, legacyPositions, yieldOpportunities]) + + const chainIds = useMemo(() => { + if (!isYieldXyzEnabled || !yieldOpportunities?.length) return chainIdsFromWallet + const yieldChainIds = yieldOpportunities + .map(item => fromAssetId(item.assetId).chainId) + .filter(Boolean) + return Array.from(new Set([...chainIdsFromWallet, ...yieldChainIds])) + }, [chainIdsFromWallet, isYieldXyzEnabled, yieldOpportunities]) + + const isTableLoading = isYieldXyzEnabled ? isYieldLoading : isOpportunitiesLoading + + return ( + + + {header && header} + + + + + {isOpportunitiesLoading && ( + + + + )} + + + + - - - - - ) -} + ) + }, +) diff --git a/src/lib/yieldxyz/utils.test.ts b/src/lib/yieldxyz/utils.test.ts index 3dac14a8313..a43d87a5262 100644 --- a/src/lib/yieldxyz/utils.test.ts +++ b/src/lib/yieldxyz/utils.test.ts @@ -5,6 +5,7 @@ import type { AugmentedYieldDto, ValidatorDto } from './types' import { ensureValidatorApr, formatYieldTxTitle, + getBestActionableYield, getDefaultValidatorForYield, getTransactionButtonText, getYieldActionLabelKeys, @@ -384,3 +385,81 @@ describe('getDefaultValidatorForYield', () => { expect(getDefaultValidatorForYield('some-random-yield')).toBeUndefined() }) }) + +describe('getBestActionableYield', () => { + const createMockYield = ( + id: string, + apy: number, + options: { enterDisabled?: boolean; underMaintenance?: boolean; deprecated?: boolean } = {}, + ): AugmentedYieldDto => + ({ + id, + rewardRate: { total: apy, rateType: 'APY', components: [] }, + status: { enter: !options.enterDisabled, exit: true }, + metadata: { + name: `Yield ${id}`, + underMaintenance: options.underMaintenance ?? false, + deprecated: options.deprecated ?? false, + }, + }) as unknown as AugmentedYieldDto + + it('should return undefined for empty array', () => { + expect(getBestActionableYield([])).toBeUndefined() + }) + + it('should return undefined when all yields are disabled', () => { + const yields = [ + createMockYield('a', 0.1, { enterDisabled: true }), + createMockYield('b', 0.2, { underMaintenance: true }), + createMockYield('c', 0.3, { deprecated: true }), + ] + expect(getBestActionableYield(yields)).toBeUndefined() + }) + + it('should return highest APY yield when multiple are actionable', () => { + const yields = [ + createMockYield('low', 0.05), + createMockYield('high', 0.15), + createMockYield('mid', 0.1), + ] + const result = getBestActionableYield(yields) + expect(result?.id).toBe('high') + }) + + it('should filter out yields with enter disabled', () => { + const yields = [ + createMockYield('disabled-high', 0.2, { enterDisabled: true }), + createMockYield('enabled-low', 0.05), + ] + const result = getBestActionableYield(yields) + expect(result?.id).toBe('enabled-low') + }) + + it('should filter out yields under maintenance', () => { + const yields = [ + createMockYield('maintenance-high', 0.2, { underMaintenance: true }), + createMockYield('active-low', 0.05), + ] + const result = getBestActionableYield(yields) + expect(result?.id).toBe('active-low') + }) + + it('should filter out deprecated yields', () => { + const yields = [ + createMockYield('deprecated-high', 0.2, { deprecated: true }), + createMockYield('active-low', 0.05), + ] + const result = getBestActionableYield(yields) + expect(result?.id).toBe('active-low') + }) + + it('should return the only actionable yield', () => { + const yields = [ + createMockYield('disabled', 0.3, { enterDisabled: true }), + createMockYield('only-active', 0.1), + createMockYield('maintenance', 0.25, { underMaintenance: true }), + ] + const result = getBestActionableYield(yields) + expect(result?.id).toBe('only-active') + }) +}) diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts index 7b20aee6e15..00c25690152 100644 --- a/src/lib/yieldxyz/utils.ts +++ b/src/lib/yieldxyz/utils.ts @@ -9,6 +9,8 @@ import { } from './constants' import type { AugmentedYieldDto, ValidatorDto, YieldIconSource, YieldType } from './types' +import { bnOrZero } from '@/lib/bignumber/bignumber' + export const yieldNetworkToChainId = (network: string): ChainId | undefined => { if (!isSupportedYieldNetwork(network)) return undefined return YIELD_NETWORK_TO_CHAIN_ID[network] @@ -64,7 +66,7 @@ const TX_TYPE_TO_LABELS: Record = { type TerminologyKey = 'staking' | 'vault' -const isStakingType = (yieldType: YieldType): boolean => { +export const isStakingYieldType = (yieldType: YieldType): boolean => { switch (yieldType) { case 'staking': case 'native-staking': @@ -76,7 +78,6 @@ const isStakingType = (yieldType: YieldType): boolean => { case 'lending': return false default: - // This shouldn't happen but satisfies exhaustiveness check assertNever(yieldType) return false } @@ -91,7 +92,7 @@ export const getTransactionButtonText = ( title: string | undefined, yieldType?: YieldType, ): string => { - const labelKey: TerminologyKey = yieldType && isStakingType(yieldType) ? 'staking' : 'vault' + const labelKey: TerminologyKey = yieldType && isStakingYieldType(yieldType) ? 'staking' : 'vault' if (type) { const normalized = type.toUpperCase().replace(/[_-]/g, '_') @@ -113,7 +114,7 @@ export const formatYieldTxTitle = ( assetSymbol: string, yieldType?: YieldType, ): string => { - const labelKey: TerminologyKey = yieldType && isStakingType(yieldType) ? 'staking' : 'vault' + const labelKey: TerminologyKey = yieldType && isStakingYieldType(yieldType) ? 'staking' : 'vault' const normalized = title.replace(/ transaction$/i, '').toLowerCase() const match = TX_TITLE_PATTERNS.find(p => p.pattern.test(normalized)) @@ -314,10 +315,6 @@ export const getYieldMinAmountKey = (yieldType: YieldType): string => { } } -export const isStakingYieldType = (yieldType: YieldType): boolean => { - return isStakingType(yieldType) -} - export type YieldSuccessMessageKey = | 'successStaked' | 'successUnstaked' @@ -350,3 +347,13 @@ export const isYieldDisabled = ( yieldItem: Pick, ): boolean => !yieldItem.status.enter || yieldItem.metadata.underMaintenance || yieldItem.metadata.deprecated + +export const getBestActionableYield = ( + yields: AugmentedYieldDto[], +): AugmentedYieldDto | undefined => { + const actionable = yields.filter(y => !isYieldDisabled(y)) + if (actionable.length === 0) return undefined + return actionable.reduce((best, current) => + bnOrZero(current.rewardRate.total).gt(best.rewardRate.total) ? current : best, + ) +} diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx index ce6f6c63a61..a796c5ff854 100644 --- a/src/pages/Yields/YieldAssetDetails.tsx +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -391,7 +391,7 @@ export const YieldAssetDetails = memo(() => { const handleYieldClick = useCallback( (yieldId: string) => { const validator = getDefaultValidatorForYield(yieldId) - const url = validator ? `/yields/${yieldId}?validator=${validator}` : `/yields/${yieldId}` + const url = validator ? `/yield/${yieldId}?validator=${validator}` : `/yield/${yieldId}` navigate(url) }, [navigate], diff --git a/src/pages/Yields/components/YieldAssetSection.tsx b/src/pages/Yields/components/YieldAssetSection.tsx index b8f1a10ee1d..52c799957c5 100644 --- a/src/pages/Yields/components/YieldAssetSection.tsx +++ b/src/pages/Yields/components/YieldAssetSection.tsx @@ -12,6 +12,7 @@ import { YieldOpportunityCard } from './YieldOpportunityCard' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useWallet } from '@/hooks/useWallet/useWallet' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { getBestActionableYield } from '@/lib/yieldxyz/utils' import type { AugmentedYieldBalanceWithAccountId } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYields } from '@/react-queries/queries/yieldxyz/useYields' @@ -81,12 +82,7 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection return Object.keys(result).length > 0 ? result : undefined }, [allBalancesData, yields, accountId]) - const sortedYields = useMemo( - () => [...yields].sort((a, b) => b.rewardRate.total - a.rewardRate.total), - [yields], - ) - - const bestYield = sortedYields[0] + const bestYield = useMemo(() => getBestActionableYield(yields), [yields]) const hasActivePositions = Boolean(filteredBalancesByYieldId) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 7f3f0774577..11b492c8acd 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -273,7 +273,7 @@ export const YieldEnterModal = memo( const displayValue = useMemo(() => { if (isFiat) { - return fiatAmount.toFixed(2) + return fiatAmount.isZero() ? '' : fiatAmount.toFixed(2) } return cryptoAmount }, [isFiat, fiatAmount, cryptoAmount]) @@ -489,7 +489,7 @@ export const YieldEnterModal = memo( )} - {!isStaking && providerMetadata && ( + {(!isStaking || !selectedValidatorMetadata) && providerMetadata && ( {translate('yieldXYZ.provider')} diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index a1755511a65..41e5c61fab6 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -8,14 +8,12 @@ import { Flex, HStack, Icon, - Input, Skeleton, Text, } from '@chakra-ui/react' import type { AccountId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' -import type { ChangeEvent } from 'react' -import { memo, useCallback, useEffect, useMemo, useState } 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' @@ -24,6 +22,7 @@ import { useTranslate } from 'react-polyglot' import { AccountSelector } from '@/components/AccountSelector/AccountSelector' import { Amount } from '@/components/Amount/Amount' import { AssetIcon } from '@/components/AssetIcon' +import { CryptoAmountInput } from '@/components/CryptoAmountInput/CryptoAmountInput' import { WalletActions } from '@/context/WalletProvider/actions' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' @@ -77,47 +76,6 @@ type YieldFormProps = { 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' -} - -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(() => ( @@ -335,7 +293,7 @@ export const YieldForm = memo( const displayValue = useMemo(() => { if (isFiat) { - return fiatAmount.toFixed(2) + return fiatAmount.isZero() ? '' : fiatAmount.toFixed(2) } return cryptoAmount }, [isFiat, fiatAmount, cryptoAmount]) @@ -402,12 +360,6 @@ export const YieldForm = memo( const isQuoteActive = isQuoteLoading || isAllowanceCheckPending - useEffect(() => { - if (step === ModalStep.Success) { - // Here we could auto-close or let YieldSuccess handle it - } - }, [step]) - const maybeSuccessProviderInfo = useMemo(() => { if (isStaking && maybeSelectedValidatorMetadata) { return { @@ -587,7 +539,7 @@ export const YieldForm = memo( )} - {!isStaking && maybeProviderMetadata && ( + {(!isStaking || !maybeSelectedValidatorMetadata) && maybeProviderMetadata && ( {translate('yieldXYZ.provider')} diff --git a/src/pages/Yields/components/YieldRelatedMarkets.tsx b/src/pages/Yields/components/YieldRelatedMarkets.tsx index fc536021662..ac58cf40b30 100644 --- a/src/pages/Yields/components/YieldRelatedMarkets.tsx +++ b/src/pages/Yields/components/YieldRelatedMarkets.tsx @@ -37,7 +37,7 @@ export const YieldRelatedMarkets = memo( const handleYieldClick = useCallback( (yieldId: string) => { - navigate(`/yields/${yieldId}`) + navigate(`/yield/${yieldId}`) }, [navigate], ) diff --git a/src/pages/Yields/components/YieldSuccess.tsx b/src/pages/Yields/components/YieldSuccess.tsx index d65cc662777..b4f0119bf2e 100644 --- a/src/pages/Yields/components/YieldSuccess.tsx +++ b/src/pages/Yields/components/YieldSuccess.tsx @@ -27,6 +27,7 @@ type YieldSuccessProps = { onDone: () => void showConfetti?: boolean successMessageKey?: YieldSuccessMessageKey + showButtons?: boolean } export const YieldSuccess = memo( @@ -40,6 +41,7 @@ export const YieldSuccess = memo( onDone, showConfetti = true, successMessageKey = 'successStaked', + showButtons = true, }: YieldSuccessProps) => { const translate = useTranslate() const navigate = useNavigate() @@ -124,22 +126,24 @@ export const YieldSuccess = memo( )} - - {yieldId && ( - + )} + - )} - - + + )} ) diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 93124222c02..6ca88253e6c 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -33,21 +33,10 @@ import { ResultsEmptyNoWallet } from '@/components/ResultsEmptyNoWallet' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' 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 { YIELD_NETWORK_TO_CHAIN_ID } from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto, YieldNetwork } from '@/lib/yieldxyz/types' import { getDefaultValidatorForYield, - isStakingYieldType, isYieldDisabled, resolveYieldInputAssetIcon, searchYields, @@ -57,6 +46,7 @@ import { YieldItem, YieldItemSkeleton } from '@/pages/Yields/components/YieldIte import { YieldOpportunityStats } from '@/pages/Yields/components/YieldOpportunityStats' import { YieldTable } from '@/pages/Yields/components/YieldTable' import { ViewToggle } from '@/pages/Yields/components/YieldViewHelpers' +import { useYieldDisplayInfo } from '@/pages/Yields/hooks/useYieldDisplayInfo' import { useYieldFilters } from '@/pages/Yields/hooks/useYieldFilters' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' @@ -71,6 +61,7 @@ import { import { useAppSelector } from '@/state/store' const tabSelectedSx = { color: 'white', bg: 'blue.500' } +const searchIcon = const TAB_PARAMS = ['all', 'available', 'my-positions'] as const type YieldTab = (typeof TAB_PARAMS)[number] @@ -140,6 +131,7 @@ export const YieldsList = memo(() => { }) const allBalances = allBalancesData?.byYieldId const { data: yieldProviders } = useYieldProviders() + const getYieldDisplayInfo = useYieldDisplayInfo(yieldProviders) const handleTabChange = useCallback( (index: number) => { @@ -173,66 +165,10 @@ export const YieldsList = memo(() => { [yieldProviders], ) - const getYieldDisplayInfo = useCallback( - (yieldItem: AugmentedYieldDto) => { - const isNativeStaking = - isStakingYieldType(yieldItem.mechanics.type) && - 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 + if (!yieldBalances || yieldBalances.length === 0) return undefined return yieldBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) }, [allBalances], @@ -558,7 +494,7 @@ export const YieldsList = memo(() => { const handleYieldClick = useCallback( (yieldId: string) => { const validator = getDefaultValidatorForYield(yieldId) - const url = validator ? `/yields/${yieldId}?validator=${validator}` : `/yields/${yieldId}` + const url = validator ? `/yield/${yieldId}?validator=${validator}` : `/yield/${yieldId}` navigate(url) }, [navigate], @@ -1212,9 +1148,7 @@ export const YieldsList = memo(() => { direction={{ base: 'column', md: 'row' }} > - - - + {searchIcon} | undefined) => { + const translate = useTranslate() + + return useCallback( + (yieldItem: AugmentedYieldDto): YieldDisplayInfo => { + const isNativeStaking = + isStakingYieldType(yieldItem.mechanics.type) && + 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 = providers?.[yieldItem.providerId] + return { name: provider?.name, logoURI: provider?.logoURI } + }, + [translate, providers], + ) +} diff --git a/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts b/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts index b98920d9a8b..c6bd9965e9e 100644 --- a/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts +++ b/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts @@ -5,10 +5,13 @@ import { useMemo } from 'react' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { isSome } from '@/lib/utils' import { fetchAggregateBalances } from '@/lib/yieldxyz/api' import { augmentYieldBalances } from '@/lib/yieldxyz/augment' -import { CHAIN_ID_TO_YIELD_NETWORK, SUPPORTED_YIELD_NETWORKS } from '@/lib/yieldxyz/constants' +import { + CHAIN_ID_TO_YIELD_NETWORK, + DEFAULT_VALIDATOR_BY_YIELD_ID, + SUPPORTED_YIELD_NETWORKS, +} from '@/lib/yieldxyz/constants' import type { AugmentedYieldBalance, YieldBalanceType, @@ -17,10 +20,7 @@ import type { } from '@/lib/yieldxyz/types' import { YieldBalanceType as YieldBalanceTypeEnum } from '@/lib/yieldxyz/types' import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' -import { - selectAccountIdsByAccountNumberAndChainId, - selectEnabledWalletAccountIds, -} from '@/state/slices/selectors' +import { selectEnabledWalletAccountIds } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' type UseAllYieldBalancesOptions = { @@ -224,25 +224,14 @@ export const useAllYieldBalances = (options: UseAllYieldBalancesOptions = {}) => const isEnabled = enabled ?? true const { state: walletState } = useWallet() const isConnected = Boolean(walletState.walletInfo) - const { accountId: contextAccountId, accountNumber } = useYieldAccount() - const accountIdsByAccountNumberAndChainId = useAppSelector( - selectAccountIdsByAccountNumberAndChainId, - ) + const { accountId: contextAccountId } = useYieldAccount() const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) - const accountIdsForAccountNumber = useMemo((): AccountId[] => { - if (accountNumber === undefined) return [] - const byChainId = accountIdsByAccountNumberAndChainId[accountNumber] - if (!byChainId) return [] - return Object.values(byChainId).flat().filter(isSome) - }, [accountIdsByAccountNumberAndChainId, accountNumber]) - const targetAccountIds: AccountId[] = useMemo(() => { if (filterAccountIds?.length) return filterAccountIds if (contextAccountId) return [contextAccountId] - if (accountIdsForAccountNumber.length) return accountIdsForAccountNumber return enabledWalletAccountIds - }, [filterAccountIds, contextAccountId, accountIdsForAccountNumber, enabledWalletAccountIds]) + }, [filterAccountIds, contextAccountId, enabledWalletAccountIds]) const queryPayloads = useMemo(() => { if (!isConnected || targetAccountIds.length === 0) return [] @@ -296,10 +285,15 @@ export const useAllYieldBalances = (options: UseAllYieldBalancesOptions = {}) => const augmentedBalances = augmentYieldBalances(item.balances, chainId) + const defaultValidator = DEFAULT_VALIDATOR_BY_YIELD_ID[item.yieldId] + const filteredBalances = augmentedBalances.filter( + balance => !defaultValidator || balance.validator?.address === defaultValidator, + ) + let highestAmountUsd = bnOrZero(0) let highestAmountUsdValidator: string | undefined - for (const balance of augmentedBalances) { + for (const balance of filteredBalances) { const usd = bnOrZero(balance.amountUsd) if (balance.validator?.address && usd.gt(highestAmountUsd)) { highestAmountUsd = usd @@ -311,7 +305,7 @@ export const useAllYieldBalances = (options: UseAllYieldBalancesOptions = {}) => balanceMap[item.yieldId] = [] } - for (const balance of augmentedBalances) { + for (const balance of filteredBalances) { const network = item.yieldId.split('-')[0] const lookupKey = `${balance.address.toLowerCase()}:${network}` let accountId = addressToAccountId[lookupKey]