diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index 087307420c5..51d4279c7bd 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -161,6 +161,7 @@ export * from './tan-query/wallets/useTokenBalance' export * from './tan-query/jupiter/useSwapTokens' export * from './tan-query/jupiter/useTokenExchangeRate' export * from './tan-query/jupiter/utils' +export * from './tan-query/jupiter/constants' // Saga fetch utils, remove when migration is complete export * from './tan-query/saga-utils' diff --git a/packages/common/src/api/tan-query/jupiter/constants.ts b/packages/common/src/api/tan-query/jupiter/constants.ts index 420dc8ac19c..c555124eb46 100644 --- a/packages/common/src/api/tan-query/jupiter/constants.ts +++ b/packages/common/src/api/tan-query/jupiter/constants.ts @@ -1,5 +1,6 @@ import { PublicKey } from '@solana/web3.js' +import { MintName } from '~/services/audius-backend/solana' import { Env } from '~/services/env' import { getOrInitializeRegistry, type TokenConfig } from '~/services/tokens' @@ -12,6 +13,13 @@ const isClaimableTokenMint = (symbol: string): symbol is ClaimableTokenMint => { return CLAIMABLE_TOKEN_MINTS.includes(symbol as ClaimableTokenMint) } +// Convert token symbol to proper mint name for useTokenBalance +// AUDIO -> wAUDIO, others remain the same +export const getTokenMintName = (symbol: string): MintName => { + const mintName = symbol === 'AUDIO' ? 'wAUDIO' : symbol + return mintName as MintName +} + export const JUPITER_PROGRAM_ID = new PublicKey( 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4' ) diff --git a/packages/common/src/api/tan-query/wallets/useTokenBalance.ts b/packages/common/src/api/tan-query/wallets/useTokenBalance.ts index 8dbaf2ac28e..de68442a3f4 100644 --- a/packages/common/src/api/tan-query/wallets/useTokenBalance.ts +++ b/packages/common/src/api/tan-query/wallets/useTokenBalance.ts @@ -5,9 +5,8 @@ import { TokenAccountNotFoundError } from '@solana/spl-token' import { Commitment } from '@solana/web3.js' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useCurrentUserId, useUser } from '~/api' +import { useCurrentAccountUser } from '~/api' import { useQueryContext } from '~/api/tan-query/utils' -import { ID } from '~/models' import { Status } from '~/models/Status' import { MintName } from '~/services/audius-backend/solana' import { getUserbankAccountInfo, getTokenBySymbol } from '~/services/index' @@ -47,22 +46,18 @@ export const getTokenBalanceQueryKey = ( */ export const useTokenBalance = ({ token, - userId, isPolling, pollingInterval = 1000, commitment = 'processed', ...queryOptions }: { token: MintName - userId?: ID isPolling?: boolean pollingInterval?: number commitment?: Commitment } & QueryOptions) => { const { audiusSdk, env } = useQueryContext() - const { data: currentUserId } = useCurrentUserId() - const { data: user } = useUser(userId ?? currentUserId) - const isCurrentUser = !userId || userId === currentUserId + const { data: user } = useCurrentAccountUser() const ethAddress = user?.wallet ?? null const queryClient = useQueryClient() @@ -104,16 +99,10 @@ export const useTokenBalance = ({ ) return null } - // TODO: temporarily fake balances for testing - if (isCurrentUser) { - return createTokenBalance(100000000, tokenConfig.decimals) - } - return createTokenBalance( - Math.floor(Math.random() * 500000000) + 100000000, - tokenConfig.decimals - ) + return createTokenBalance(BigInt(0), tokenConfig.decimals) } console.error(`Error fetching ${token} balance:`, e) + // Return null instead of throwing to prevent infinite loading return null } }, diff --git a/packages/common/src/hooks/useBuySellAnalytics.ts b/packages/common/src/hooks/useBuySellAnalytics.ts index 8071913144f..14f0bebebed 100644 --- a/packages/common/src/hooks/useBuySellAnalytics.ts +++ b/packages/common/src/hooks/useBuySellAnalytics.ts @@ -5,7 +5,7 @@ import { Name } from '~/models/Analytics' import { useAnalytics } from './useAnalytics' export type SwapDetails = { - activeTab: 'buy' | 'sell' + activeTab: 'buy' | 'sell' | 'convert' inputToken: string outputToken: string inputAmount?: number diff --git a/packages/common/src/messages/buySell.ts b/packages/common/src/messages/buySell.ts index 0775ca739c6..17b09550672 100644 --- a/packages/common/src/messages/buySell.ts +++ b/packages/common/src/messages/buySell.ts @@ -6,6 +6,7 @@ export const buySellMessages = { title: 'BUY / SELL', buy: 'Buy', sell: 'Sell', + convert: 'Convert', youPay: 'You Pay', youPaid: 'You Paid', youReceive: 'You Receive', @@ -61,5 +62,13 @@ export const buySellMessages = { stackedBalance: (formattedAvailableBalance: string) => `${formattedAvailableBalance} Available`, tokenTicker: (symbol: string, isStablecoin: boolean) => - isStablecoin ? symbol : `$${symbol}` + isStablecoin ? symbol : `$${symbol}`, + exchangeRate: (inputSymbol: string, outputSymbol: string, rate: number) => + `Rate 1 $${inputSymbol} ≈ ${rate} $${outputSymbol}`, + exchangeRateLabel: 'Rate', + exchangeRateValue: ( + inputSymbol: string, + outputSymbol: string, + rate: number + ) => `1 $${inputSymbol} ≈ ${rate} $${outputSymbol}` } diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index 77aaa28b12f..cd1b53d7599 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -2111,7 +2111,7 @@ type BuyUSDCAddFundsManually = { } export type BuySellSwapEventFields = { - activeTab: 'buy' | 'sell' + activeTab: 'buy' | 'sell' | 'convert' inputToken: string outputToken: string inputAmount?: number diff --git a/packages/common/src/store/ui/buy-sell/types.ts b/packages/common/src/store/ui/buy-sell/types.ts index 28b31f256cd..ad599915c8f 100644 --- a/packages/common/src/store/ui/buy-sell/types.ts +++ b/packages/common/src/store/ui/buy-sell/types.ts @@ -2,7 +2,7 @@ import type { ComponentType } from 'react' import { TooltipPlacement } from 'antd/lib/tooltip' -export type BuySellTab = 'buy' | 'sell' +export type BuySellTab = 'buy' | 'sell' | 'convert' export type Screen = 'input' | 'confirm' | 'success' @@ -83,6 +83,7 @@ export type TransactionData = { * Utility function to get input and output tokens based on active tab and token pair * For 'buy': user pays with quote token (e.g., USDC) to get base token (e.g., AUDIO) * For 'sell': user pays with base token (e.g., AUDIO) to get quote token (e.g., USDC) + * For 'convert': user pays with base token (e.g., AUDIO) to get quote token (e.g., BONK) */ export const getSwapTokens = (activeTab: BuySellTab, tokenPair: TokenPair) => { return { diff --git a/packages/mobile/src/components/user-badges/UserBadges.tsx b/packages/mobile/src/components/user-badges/UserBadges.tsx index 8889a5001b2..2915cb48372 100644 --- a/packages/mobile/src/components/user-badges/UserBadges.tsx +++ b/packages/mobile/src/components/user-badges/UserBadges.tsx @@ -25,7 +25,6 @@ export const UserBadges = (props: UserBadgesProps) => { const { tier } = useTierAndVerifiedForUser(userId) const { data: coinBalance } = useTokenBalance({ - userId, token: 'BONK' }) diff --git a/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx b/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx index febee650baf..c788f0730c9 100644 --- a/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx +++ b/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx @@ -67,7 +67,8 @@ export const BuySellFlow = ({ Record >({ buy: '', - sell: '' + sell: '', + convert: '' }) // Update input value for buy tab diff --git a/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx b/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx index 5a68969be44..64af65ba097 100644 --- a/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx +++ b/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useContext, useMemo } from 'react' import { useBuySellAnalytics } from '@audius/common/hooks' import { buySellMessages as messages } from '@audius/common/messages' +import { FeatureFlags } from '@audius/common/services' import { useBuySellScreen, useBuySellSwap, @@ -17,9 +18,11 @@ import { Button, Flex, Hint, SegmentedControl, TextLink } from '@audius/harmony' import { ExternalTextLink } from 'components/link' import { ModalLoading } from 'components/modal-loading' import { ToastContext } from 'components/toast/ToastContext' +import { useFlag } from 'hooks/useRemoteConfig' import { BuyTab } from './BuyTab' import { ConfirmSwapScreen } from './ConfirmSwapScreen' +import { ConvertTab } from './ConvertTab' import { SellTab } from './SellTab' import { TransactionSuccessScreen } from './TransactionSuccessScreen' import { SUPPORTED_TOKEN_PAIRS, TOKENS } from './constants' @@ -37,6 +40,7 @@ export const BuySellFlow = (props: BuySellFlowProps) => { const { onClose, openAddCashModal, onScreenChange, onLoadingStateChange } = props const { toast } = useContext(ToastContext) + const { isEnabled: isArtistCoinsEnabled } = useFlag(FeatureFlags.ARTIST_COINS) const { trackSwapRequested, trackSwapSuccess, @@ -65,7 +69,8 @@ export const BuySellFlow = (props: BuySellFlowProps) => { Record >({ buy: '', - sell: '' + sell: '', + convert: '' }) // Update input value for current tab @@ -76,14 +81,57 @@ export const BuySellFlow = (props: BuySellFlowProps) => { })) } + // Track if user has attempted to submit the form + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false) + + const selectedPair = SUPPORTED_TOKEN_PAIRS[0] + + // State for dynamic token selection - separate states for each tab + const [buyTabTokens, setBuyTabTokens] = useState<{ + baseToken: string + quoteToken: string + }>({ + baseToken: selectedPair.baseToken.symbol, // AUDIO by default + quoteToken: selectedPair.quoteToken.symbol // USDC by default + }) + + const [sellTabTokens, setSellTabTokens] = useState<{ + baseToken: string + quoteToken: string + }>({ + baseToken: selectedPair.baseToken.symbol, // AUDIO by default + quoteToken: selectedPair.quoteToken.symbol // USDC by default + }) + + const [convertTabTokens, setConvertTabTokens] = useState<{ + baseToken: string + quoteToken: string + }>({ + baseToken: selectedPair.baseToken.symbol, // AUDIO by default + quoteToken: 'BONK' // BONK by default for convert tab (excluding USDC) + }) + + // Get current tab's token symbols + const currentTabTokens = + activeTab === 'buy' + ? buyTabTokens + : activeTab === 'sell' + ? sellTabTokens + : convertTabTokens + const baseTokenSymbol = currentTabTokens.baseToken + const quoteTokenSymbol = currentTabTokens.quoteToken + // Handle token changes const handleInputTokenChange = (symbol: string) => { if (activeTab === 'sell') { // On sell tab, input token change means base token change - setBaseTokenSymbol(symbol) - } else { + setSellTabTokens((prev) => ({ ...prev, baseToken: symbol })) + } else if (activeTab === 'buy') { // On buy tab, input token change means quote token change - setQuoteTokenSymbol(symbol) + setBuyTabTokens((prev) => ({ ...prev, quoteToken: symbol })) + } else { + // On convert tab, input token change means base token change + setConvertTabTokens((prev) => ({ ...prev, baseToken: symbol })) } // Reset transaction data when tokens change resetTransactionData() @@ -92,28 +140,18 @@ export const BuySellFlow = (props: BuySellFlowProps) => { const handleOutputTokenChange = (symbol: string) => { if (activeTab === 'buy') { // On buy tab, output token change means base token change - setBaseTokenSymbol(symbol) - } else { + setBuyTabTokens((prev) => ({ ...prev, baseToken: symbol })) + } else if (activeTab === 'sell') { // On sell tab, output token change means quote token change - setQuoteTokenSymbol(symbol) + setSellTabTokens((prev) => ({ ...prev, quoteToken: symbol })) + } else { + // On convert tab, output token change means quote token change + setConvertTabTokens((prev) => ({ ...prev, quoteToken: symbol })) } // Reset transaction data when tokens change resetTransactionData() } - // Track if user has attempted to submit the form - const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false) - - const selectedPair = SUPPORTED_TOKEN_PAIRS[0] - - // State for dynamic token selection - using base/quote terminology - const [baseTokenSymbol, setBaseTokenSymbol] = useState( - selectedPair.baseToken.symbol // AUDIO by default - ) - const [quoteTokenSymbol, setQuoteTokenSymbol] = useState( - selectedPair.quoteToken.symbol // USDC by default - ) - // Get all available tokens const availableTokens = useMemo(() => { const tokensSet = new Set() @@ -233,10 +271,18 @@ export const BuySellFlow = (props: BuySellFlowProps) => { trackSwapFailure ]) - const tabs = [ - { key: 'buy' as BuySellTab, text: messages.buy }, - { key: 'sell' as BuySellTab, text: messages.sell } - ] + const tabs = useMemo(() => { + const baseTabs = [ + { key: 'buy' as BuySellTab, text: messages.buy }, + { key: 'sell' as BuySellTab, text: messages.sell } + ] + + if (isArtistCoinsEnabled) { + baseTabs.push({ key: 'convert' as BuySellTab, text: messages.convert }) + } + + return baseTabs + }, [isArtistCoinsEnabled]) const { successDisplayData, @@ -352,16 +398,8 @@ export const BuySellFlow = (props: BuySellFlowProps) => { errorMessage={displayErrorMessage} initialInputValue={tabInputValues.buy} onInputValueChange={handleTabInputValueChange} - availableInputTokens={availableTokens.filter( - (t) => t.symbol !== baseTokenSymbol - )} - availableOutputTokens={availableTokens.filter( - (t) => t.symbol !== quoteTokenSymbol - )} - onInputTokenChange={handleInputTokenChange} - onOutputTokenChange={handleOutputTokenChange} /> - ) : ( + ) : activeTab === 'sell' ? ( { initialInputValue={tabInputValues.sell} onInputValueChange={handleTabInputValueChange} availableInputTokens={availableTokens.filter( - (t) => t.symbol !== quoteTokenSymbol + (t) => t.symbol !== baseTokenSymbol && t.symbol !== 'USDC' )} availableOutputTokens={availableTokens.filter( - (t) => t.symbol !== baseTokenSymbol + (t) => t.symbol !== quoteTokenSymbol && t.symbol !== 'USDC' )} onInputTokenChange={handleInputTokenChange} onOutputTokenChange={handleOutputTokenChange} /> - )} + ) : isArtistCoinsEnabled ? ( + + ) : null} {activeTab === 'buy' && !hasSufficientBalance ? ( @@ -398,7 +448,8 @@ export const BuySellFlow = (props: BuySellFlowProps) => { ) : null} - {hasSufficientBalance ? ( + {hasSufficientBalance && + (activeTab !== 'convert' || !isArtistCoinsEnabled) ? ( {messages.helpCenter}{' '} @@ -447,7 +498,7 @@ export const BuySellFlow = (props: BuySellFlowProps) => { resetSuccessDisplayData() setCurrentScreen('input') // Clear all tab input values on completion - setTabInputValues({ buy: '', sell: '' }) + setTabInputValues({ buy: '', sell: '', convert: '' }) }} /> ) : null} diff --git a/packages/web/src/components/buy-sell-modal/BuyTab.tsx b/packages/web/src/components/buy-sell-modal/BuyTab.tsx index 0f038a97bb5..fa26f7bf3e0 100644 --- a/packages/web/src/components/buy-sell-modal/BuyTab.tsx +++ b/packages/web/src/components/buy-sell-modal/BuyTab.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { useTokenBalance, useTokenPrice } from '@audius/common/api' import { Status } from '@audius/common/models' -import { TokenInfo, TokenPair } from '@audius/common/store' +import { TokenPair } from '@audius/common/store' import { getCurrencyDecimalPlaces } from '@audius/common/utils' import { FixedDecimal } from '@audius/fixed-decimal' @@ -21,10 +21,6 @@ type BuyTabProps = { errorMessage?: string initialInputValue?: string onInputValueChange?: (value: string) => void - availableInputTokens?: TokenInfo[] - availableOutputTokens?: TokenInfo[] - onInputTokenChange?: (symbol: string) => void - onOutputTokenChange?: (symbol: string) => void } export const BuyTab = ({ @@ -33,11 +29,7 @@ export const BuyTab = ({ error, errorMessage, initialInputValue, - onInputValueChange, - availableInputTokens, - availableOutputTokens, - onInputTokenChange, - onOutputTokenChange + onInputValueChange }: BuyTabProps) => { const { baseToken, quoteToken } = tokenPair @@ -81,8 +73,6 @@ export const BuyTab = ({ tokenPriceDecimalPlaces={decimalPlaces} initialInputValue={initialInputValue} onInputValueChange={onInputValueChange} - availableOutputTokens={availableOutputTokens} - onOutputTokenChange={onOutputTokenChange} /> ) } diff --git a/packages/web/src/components/buy-sell-modal/ConfirmSwapScreen.tsx b/packages/web/src/components/buy-sell-modal/ConfirmSwapScreen.tsx index 04c2ffef8a2..eb225c32e5a 100644 --- a/packages/web/src/components/buy-sell-modal/ConfirmSwapScreen.tsx +++ b/packages/web/src/components/buy-sell-modal/ConfirmSwapScreen.tsx @@ -28,7 +28,7 @@ type ConfirmSwapScreenProps = { onBack: () => void onConfirm: () => void isConfirming: boolean - activeTab: 'buy' | 'sell' + activeTab: 'buy' | 'sell' | 'convert' selectedPair: TokenPair } diff --git a/packages/web/src/components/buy-sell-modal/ConvertTab.tsx b/packages/web/src/components/buy-sell-modal/ConvertTab.tsx new file mode 100644 index 00000000000..51ef7d4eeff --- /dev/null +++ b/packages/web/src/components/buy-sell-modal/ConvertTab.tsx @@ -0,0 +1,120 @@ +import { useMemo, useCallback } from 'react' + +import { TokenInfo, TokenPair } from '@audius/common/store' + +import { SwapTab } from './SwapTab' +import { useTokenBalanceManager } from './hooks/useTokenBalanceManager' + +type ConvertTabProps = { + tokenPair: TokenPair + onTransactionDataChange?: (data: { + inputAmount: number + outputAmount: number + isValid: boolean + error: string | null + isInsufficientBalance: boolean + }) => void + error?: boolean + errorMessage?: string + initialInputValue?: string + onInputValueChange?: (value: string) => void + availableTokens?: TokenInfo[] + onInputTokenChange?: (symbol: string) => void + onOutputTokenChange?: (symbol: string) => void +} + +export const ConvertTab = ({ + tokenPair, + onTransactionDataChange, + error, + errorMessage, + initialInputValue, + onInputValueChange, + availableTokens, + onInputTokenChange, + onOutputTokenChange +}: ConvertTabProps) => { + // Extract the tokens from the pair + const { baseToken, quoteToken } = tokenPair + + // Use shared token balance manager + const { inputBalance, outputBalance } = useTokenBalanceManager( + baseToken, + quoteToken + ) + + // Filter available tokens to prevent same token selection and exclude USDC + const availableInputTokens = useMemo(() => { + return availableTokens?.filter( + (token) => token.symbol !== baseToken.symbol && token.symbol !== 'USDC' + ) + }, [availableTokens, baseToken.symbol]) + + const availableOutputTokens = useMemo(() => { + return availableTokens?.filter( + (token) => token.symbol !== quoteToken.symbol && token.symbol !== 'USDC' + ) + }, [availableTokens, quoteToken.symbol]) + + // Enhanced token change handlers for automatic swapping when only 2 tokens available + const handleInputTokenChange = useCallback( + (symbol: string) => { + onInputTokenChange?.(symbol) + + // If there are only 2 available tokens (excluding USDC), automatically set the other as output + const nonUSDCTokens = availableTokens?.filter( + (token) => token.symbol !== 'USDC' + ) + if (nonUSDCTokens?.length === 2) { + const otherToken = nonUSDCTokens.find( + (token) => token.symbol !== symbol + ) + if (otherToken) { + onOutputTokenChange?.(otherToken.symbol) + } + } + }, + [onInputTokenChange, onOutputTokenChange, availableTokens] + ) + + const handleOutputTokenChange = useCallback( + (symbol: string) => { + onOutputTokenChange?.(symbol) + + // If there are only 2 available tokens (excluding USDC), automatically set the other as input + const nonUSDCTokens = availableTokens?.filter( + (token) => token.symbol !== 'USDC' + ) + if (nonUSDCTokens?.length === 2) { + const otherToken = nonUSDCTokens.find( + (token) => token.symbol !== symbol + ) + if (otherToken) { + onInputTokenChange?.(otherToken.symbol) + } + } + }, + [onInputTokenChange, onOutputTokenChange, availableTokens] + ) + + return ( + + ) +} diff --git a/packages/web/src/components/buy-sell-modal/SellTab.tsx b/packages/web/src/components/buy-sell-modal/SellTab.tsx index 025ae66eb97..c4350266fa8 100644 --- a/packages/web/src/components/buy-sell-modal/SellTab.tsx +++ b/packages/web/src/components/buy-sell-modal/SellTab.tsx @@ -1,12 +1,7 @@ -import { useMemo } from 'react' - -import { useAudioBalance, useTokenBalance } from '@audius/common/api' -import { Status } from '@audius/common/models' import { TokenInfo, TokenPair } from '@audius/common/store' -import { isNullOrUndefined } from '@audius/common/utils' -import { AUDIO, FixedDecimal } from '@audius/fixed-decimal' import { SwapTab } from './SwapTab' +import { useTokenBalanceManager } from './hooks/useTokenBalanceManager' type SellTabProps = { tokenPair: TokenPair @@ -42,49 +37,14 @@ export const SellTab = ({ // Extract the tokens from the pair const { baseToken, quoteToken } = tokenPair - // For AUDIO, use the specialized hook for compatibility - const { accountBalance } = useAudioBalance({ includeConnectedWallets: false }) - const { data: tokenBalanceData, status: tokenBalanceStatus } = - useTokenBalance({ - token: 'wAUDIO' - }) - - const isBalanceLoading = - baseToken.symbol === 'AUDIO' - ? isNullOrUndefined(accountBalance) - : tokenBalanceStatus === Status.LOADING - - // Get balance in UI format - const getBalance = useMemo(() => { - return () => { - if (baseToken.symbol === 'AUDIO') { - if (!isBalanceLoading && accountBalance) { - return Number(AUDIO(accountBalance).toString()) - } - } else { - if (tokenBalanceStatus === Status.SUCCESS && tokenBalanceData) { - return Number(new FixedDecimal(tokenBalanceData.toString())) - } - } - return undefined - } - }, [ - accountBalance, - isBalanceLoading, - baseToken.symbol, - tokenBalanceData, - tokenBalanceStatus - ]) + // Use shared token balance manager + const { inputBalance } = useTokenBalanceManager(baseToken, quoteToken) return ( 'Insufficient balance' - }} + balance={inputBalance} onTransactionDataChange={onTransactionDataChange} isDefault={false} error={error} diff --git a/packages/web/src/components/buy-sell-modal/SwapTab.tsx b/packages/web/src/components/buy-sell-modal/SwapTab.tsx index de16a2b6ca0..f14aaa21215 100644 --- a/packages/web/src/components/buy-sell-modal/SwapTab.tsx +++ b/packages/web/src/components/buy-sell-modal/SwapTab.tsx @@ -1,15 +1,22 @@ import { useRef } from 'react' +import { buySellMessages } from '@audius/common/messages' import { TokenInfo, useTokenSwapForm } from '@audius/common/store' -import { Flex, Skeleton } from '@audius/harmony' -import { TooltipPlacement } from 'antd/lib/tooltip' +import { Flex, Skeleton, Text } from '@audius/harmony' import { Form, FormikProvider } from 'formik' import { TokenAmountSection } from './TokenAmountSection' +import type { + BalanceConfig, + TokenPricing, + UIConfiguration, + InputConfiguration, + TokenSelection, + SwapCallbacks +} from './types' const messages = { - youPay: 'You Pay', - youReceive: 'You Receive', + ...buySellMessages, placeholder: '0.00' } @@ -30,35 +37,13 @@ const SwapFormSkeleton = () => ( export type SwapTabProps = { inputToken: TokenInfo outputToken: TokenInfo - min?: number - max?: number - balance: { - get: () => number | undefined - loading: boolean - formatError: () => string - } - - onTransactionDataChange?: (data: { - inputAmount: number - outputAmount: number - isValid: boolean - error: string | null - isInsufficientBalance: boolean - }) => void - isDefault?: boolean - error?: boolean - errorMessage?: string - tokenPrice?: string | null - isTokenPriceLoading?: boolean - tokenPriceDecimalPlaces?: number - tooltipPlacement?: TooltipPlacement - initialInputValue?: string - onInputValueChange?: (value: string) => void - availableInputTokens?: TokenInfo[] - availableOutputTokens?: TokenInfo[] - onInputTokenChange?: (symbol: string) => void - onOutputTokenChange?: (symbol: string) => void -} + balance: BalanceConfig + outputBalance?: BalanceConfig +} & TokenPricing & + UIConfiguration & + InputConfiguration & + TokenSelection & + SwapCallbacks export const SwapTab = ({ inputToken, @@ -66,6 +51,7 @@ export const SwapTab = ({ min, max, balance, + outputBalance, onTransactionDataChange, isDefault = true, error, @@ -79,7 +65,8 @@ export const SwapTab = ({ availableInputTokens, availableOutputTokens, onInputTokenChange, - onOutputTokenChange + onOutputTokenChange, + showExchangeRate = false }: SwapTabProps) => { const { formik, @@ -135,6 +122,8 @@ export const SwapTab = ({ error={error} errorMessage={errorMessage} tooltipPlacement={tooltipPlacement} + availableTokens={!isDefault ? availableInputTokens : undefined} + onTokenChange={!isDefault ? onInputTokenChange : undefined} /> + + {/* Show exchange rate for convert flow */} + {showExchangeRate && currentExchangeRate && ( + + + {messages.exchangeRateLabel}  + + + {messages.exchangeRateValue( + inputToken.symbol, + outputToken.symbol, + currentExchangeRate + )} + + + )} )} diff --git a/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx b/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx index 62a5e8417bf..606e3e5ff3c 100644 --- a/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx +++ b/packages/web/src/components/buy-sell-modal/TokenAmountSection.tsx @@ -1,11 +1,25 @@ -import { useMemo } from 'react' +import { useMemo, useCallback, useState, useRef } from 'react' import { buySellMessages as messages } from '@audius/common/messages' +import { FeatureFlags } from '@audius/common/services' import { TokenAmountSectionProps, TokenInfo } from '@audius/common/store' -import { Button, Divider, Flex, Text, TextInput } from '@audius/harmony' +import { + Button, + Divider, + Flex, + IconCaretDown, + IconTransaction, + Text, + TextInput, + TokenAmountInput, + TokenAmountInputChangeHandler, + Popup +} from '@audius/harmony' import { useTheme } from '@emotion/react' import { TooltipPlacement } from 'antd/lib/tooltip' +import { useFlag } from '../../hooks/useRemoteConfig' + import { TooltipInfoIcon } from './TooltipInfoIcon' import { useTokenAmountFormatting } from './hooks' @@ -14,6 +28,9 @@ type BalanceSectionProps = { formattedAvailableBalance: string | null tokenInfo: TokenInfo tooltipPlacement?: TooltipPlacement + availableTokens?: TokenInfo[] + onTokenChange?: (symbol: string) => void + isConvertFlow?: boolean } const DefaultBalanceSection = ({ @@ -56,7 +73,166 @@ const DefaultBalanceSection = ({ ) } +const TokenSelectionPopup = ({ + availableTokens, + onTokenSelect, + onClose +}: { + availableTokens: TokenInfo[] + onTokenSelect: (symbol: string) => void + onClose: () => void +}) => { + const { spacing, color } = useTheme() + + const handleTokenClick = useCallback( + (symbol: string) => { + onTokenSelect(symbol) + onClose() + }, + [onTokenSelect, onClose] + ) + + return ( + + {availableTokens.map((token) => { + const { icon: TokenIcon, symbol, name } = token + + if (!TokenIcon) return null + + return ( + + ) + })} + + ) +} + const StackedBalanceSection = ({ + formattedAvailableBalance, + tokenInfo, + isStablecoin, + availableTokens, + onTokenChange, + isConvertFlow = false +}: BalanceSectionProps) => { + const { icon: TokenIcon, symbol } = tokenInfo + const [isPopupVisible, setIsPopupVisible] = useState(false) + const anchorRef = useRef(null) + + const handleClick = useCallback(() => { + if (availableTokens && availableTokens.length > 0) { + setIsPopupVisible(true) + } + }, [availableTokens]) + + const handleTokenSelect = useCallback( + (selectedSymbol: string) => { + onTokenChange?.(selectedSymbol) + setIsPopupVisible(false) + }, + [onTokenChange] + ) + + const handlePopupClose = useCallback(() => { + setIsPopupVisible(false) + }, []) + + if (!formattedAvailableBalance || !TokenIcon) { + return null + } + + const isClickable = availableTokens && availableTokens.length > 0 + + return ( + <> + + + + + + + + {messages.tokenTicker(symbol, !!isStablecoin)} + + + + {messages.stackedBalance(formattedAvailableBalance)} + + + + + + + + {isClickable && ( + + + + )} + + ) +} + +const OldStackedBalanceSection = ({ formattedAvailableBalance, tokenInfo, isStablecoin @@ -87,7 +263,6 @@ const StackedBalanceSection = ({ {messages.stackedBalance(formattedAvailableBalance)} - {/* We need the border radius to be circle here because the AUDIO icon is a square image */} @@ -98,12 +273,16 @@ const CryptoAmountSection = ({ formattedAmount, tokenInfo, isStablecoin, - priceDisplay + priceDisplay, + noPadding = false, + verticalLayout = false }: { formattedAmount: string tokenInfo: TokenInfo isStablecoin: boolean priceDisplay?: string + noPadding?: boolean + verticalLayout?: boolean }) => { const { spacing } = useTheme() const { icon: TokenIcon, symbol } = tokenInfo @@ -113,8 +292,29 @@ const CryptoAmountSection = ({ return null } + if (verticalLayout) { + return ( + + + + + {formattedAmount} + + + {tokenTicker} + + {priceDisplay && ( + + {priceDisplay} + + )} + + + ) + } + return ( - + @@ -151,9 +351,15 @@ export const TokenAmountSection = ({ tokenPrice, isTokenPriceLoading, tokenPriceDecimalPlaces = 2, - tooltipPlacement -}: TokenAmountSectionProps) => { + tooltipPlacement, + availableTokens, + onTokenChange +}: TokenAmountSectionProps & { + availableTokens?: TokenInfo[] + onTokenChange?: (symbol: string) => void +}) => { const { spacing } = useTheme() + const { isEnabled: isArtistCoinsEnabled } = useFlag(FeatureFlags.ARTIST_COINS) const { icon: TokenIcon, symbol, isStablecoin } = tokenInfo @@ -171,18 +377,37 @@ export const TokenAmountSection = ({ ? messages.tokenPrice(tokenPrice, tokenPriceDecimalPlaces) : undefined + const handleTokenAmountChange: TokenAmountInputChangeHandler = useCallback( + (value) => { + onAmountChange?.(value) + }, + [onAmountChange] + ) + const youPaySection = useMemo(() => { return ( - onAmountChange?.(e.target.value)} - error={error} - /> + {isStablecoin ? ( + onAmountChange?.(e.target.value)} + error={error} + /> + ) : ( + + )}