diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index 371799760c9..b12a3bd9c38 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -174,6 +174,7 @@ export * from './tan-query/wallets/useWalletCollectibles' export * from './tan-query/wallets/useWalletOwner' export * from './tan-query/wallets/useUSDCBalance' export * from './tan-query/wallets/useTokenBalance' +export * from './tan-query/wallets/useSendTokens' export * from './tan-query/jupiter/useSwapTokens' export * from './tan-query/jupiter/useTokenExchangeRate' export * from './tan-query/jupiter/utils' diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index dfa2ce981e2..8bd344c014b 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -98,6 +98,7 @@ export const QUERY_KEYS = { tokenPrice: 'tokenPrice', usdcBalance: 'usdcBalance', fileSizes: 'fileSizes', + sendTokens: 'sendTokens', managedAccounts: 'managedAccounts', userManagers: 'userManagers', reactions: 'reactions', diff --git a/packages/common/src/api/tan-query/wallets/useSendTokens.ts b/packages/common/src/api/tan-query/wallets/useSendTokens.ts new file mode 100644 index 00000000000..d5f032e58d6 --- /dev/null +++ b/packages/common/src/api/tan-query/wallets/useSendTokens.ts @@ -0,0 +1,163 @@ +import { AudioWei } from '@audius/fixed-decimal' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { useQueryContext } from '~/api/tan-query/utils' +import { Name, SolanaWalletAddress } from '~/models' +import { getErrorMessage } from '~/utils' + +import { getUserCoinQueryKey } from '../coins/useUserCoin' +import { useWalletAddresses } from '../users/account/useWalletAddresses' + +import { useTokenBalance } from './useTokenBalance' + +export type SendTokensParams = { + recipientWallet: SolanaWalletAddress + amount: AudioWei +} + +export type SendTokensResult = { + signature: string + success: boolean +} + +/** + * Hook for sending tokens on Solana blockchain. + * This hook handles only Solana transfers, not ETH transfers. + * + * @returns Mutation object with sendTokens function and status + */ +export const useSendTokens = ({ mint }: { mint: string }) => { + const queryClient = useQueryClient() + const { audiusBackend, audiusSdk, reportToSentry, analytics, env } = + useQueryContext() + const { data: walletAddresses } = useWalletAddresses() + + const { data: tokenBalance } = useTokenBalance({ mint }) + + return useMutation({ + mutationFn: async ({ + recipientWallet, + amount + }: SendTokensParams): Promise => { + try { + // For now, we only support wAUDIO transfers + // This can be extended to support other tokens in the future + if (mint !== env.WAUDIO_MINT_ADDRESS) { + throw new Error(`Token mint ${mint} is not supported for sending`) + } + + const currentUser = walletAddresses?.currentUser + if (!currentUser) { + throw new Error('Failed to retrieve current user wallet address') + } + + const sdk = await audiusSdk() + + if (!tokenBalance?.balance || tokenBalance.balance.value < amount) { + throw new Error('Insufficient balance to send tokens') + } + + await audiusBackend.sendWAudioTokens({ + address: recipientWallet, + amount, + ethAddress: currentUser, + sdk + }) + + return { + signature: 'success', // The backend doesn't return a signature, so we use a placeholder + success: true + } + } catch (error) { + console.error('Error sending tokens:', error) + + const errorMessage = getErrorMessage(error) + + if (errorMessage === 'Missing social proof') { + throw new Error('Missing social proof') + } + if ( + errorMessage === + 'Recipient has no $AUDIO token account. Please install Phantom-Wallet to create one.' + ) { + throw new Error(errorMessage) + } + + throw new Error('Something has gone wrong, please try again.') + } + }, + onMutate: async ({ amount }) => { + const queryKey = getUserCoinQueryKey(mint) + await queryClient.cancelQueries({ queryKey }) + + const previousBalance = queryClient.getQueryData(queryKey) + + if (previousBalance) { + queryClient.setQueryData(queryKey, (old: any) => { + if (!old) return old + + return { + ...old, + balance: old.balance - amount, + accounts: old.accounts?.map((account: any) => + account.isInAppWallet + ? { ...account, balance: account.balance - amount } + : account + ) + } + }) + } + + return { previousBalance } + }, + onSuccess: (_, { recipientWallet }) => { + if (analytics) { + const currentUser = walletAddresses?.currentUser + if (currentUser) { + analytics.track( + analytics.make({ + eventName: Name.SEND_AUDIO_SUCCESS, + from: currentUser, + recipient: recipientWallet + }) + ) + } + } + }, + onError: (error, { amount, recipientWallet }, context) => { + if (context?.previousBalance) { + const queryKey = getUserCoinQueryKey(mint) + queryClient.setQueryData(queryKey, context.previousBalance) + } + + if (analytics) { + const currentUser = walletAddresses?.currentUser + if (currentUser) { + analytics.track( + analytics.make({ + eventName: Name.SEND_AUDIO_FAILURE, + from: currentUser, + recipient: recipientWallet, + error: error instanceof Error ? error.message : 'Unknown error' + }) + ) + } + } + + if (reportToSentry) { + reportToSentry({ + error: error instanceof Error ? error : new Error(error as string), + name: 'Send Tokens', + additionalInfo: { + amount: amount.toString(), + mint + } + }) + } + }, + onSettled: () => { + const queryKey = getUserCoinQueryKey(mint) + queryClient.invalidateQueries({ queryKey }) + } + }) +} diff --git a/packages/common/src/utils/route.ts b/packages/common/src/utils/route.ts index cc9a21aa445..02d19c5e66f 100644 --- a/packages/common/src/utils/route.ts +++ b/packages/common/src/utils/route.ts @@ -450,3 +450,7 @@ export const searchPage = (searchOptions: SearchOptions) => { query: searchParams }) } + +export const solanaExplorerAddress = (address: string) => { + return `https://explorer.solana.com/address/${address}` +} diff --git a/packages/harmony/src/components/hint/Hint.tsx b/packages/harmony/src/components/hint/Hint.tsx index 30e213f79d4..b2771fb8ded 100644 --- a/packages/harmony/src/components/hint/Hint.tsx +++ b/packages/harmony/src/components/hint/Hint.tsx @@ -8,6 +8,7 @@ import { IconQuestionCircle } from '~harmony/icons' type HintProps = { icon?: IconComponent + noIcon?: boolean actions?: ReactNode } & PaperProps @@ -15,7 +16,13 @@ type HintProps = { * A way of informing the user of important details in line in a prominent way. */ export const Hint = (props: HintProps) => { - const { icon: Icon = IconQuestionCircle, children, actions, ...other } = props + const { + icon: Icon = IconQuestionCircle, + children, + actions, + noIcon, + ...other + } = props return ( { {...other} > - + {noIcon ? null : } {children} diff --git a/packages/web/src/components/modal/ResponsiveModal.tsx b/packages/web/src/components/modal/ResponsiveModal.tsx index f78fa7ee409..050c0689c3e 100644 --- a/packages/web/src/components/modal/ResponsiveModal.tsx +++ b/packages/web/src/components/modal/ResponsiveModal.tsx @@ -13,6 +13,7 @@ import Drawer from 'components/drawer/Drawer' import { useIsMobile } from 'hooks/useIsMobile' export type ResponsiveModalProps = { + className?: string // Core props isOpen: boolean onClose: () => void @@ -68,7 +69,8 @@ const ResponsiveModal = ({ showDismissButton, zIndex, renderAsDrawer, - renderAsModal + renderAsModal, + className }: ResponsiveModalProps) => { const isMobile = useIsMobile() const shouldRenderAsDrawer = renderAsDrawer ?? (isMobile && !renderAsModal) @@ -123,6 +125,7 @@ const ResponsiveModal = ({ size={getModalSize(size)} zIndex={zIndex} dismissOnClickOutside={dismissOnClickOutside} + className={className} > {(title || Icon || subtitle) && ( diff --git a/packages/web/src/components/send-tokens-modal/SendTokensConfirmation.tsx b/packages/web/src/components/send-tokens-modal/SendTokensConfirmation.tsx new file mode 100644 index 00000000000..d1ae99b6cea --- /dev/null +++ b/packages/web/src/components/send-tokens-modal/SendTokensConfirmation.tsx @@ -0,0 +1,184 @@ +import React, { ChangeEvent, useState } from 'react' + +import { + useArtistCoin, + useTokenBalance, + transformArtistCoinToTokenInfo +} from '@audius/common/api' +import { FixedDecimal } from '@audius/fixed-decimal' +import { + Button, + Text, + Flex, + Divider, + Hint, + Checkbox, + useMedia +} from '@audius/harmony' + +import { CryptoBalanceSection } from 'components/buy-sell-modal/CryptoBalanceSection' + +interface SendTokensConfirmationProps { + mint: string + amount: bigint + destinationAddress: string + onConfirm: () => void + onBack: () => void + onClose: () => void +} + +const messages = { + sendTitle: 'SEND', + amountToSend: 'Amount to Send', + destinationAddress: 'Destination Address', + reviewDetails: 'Review Details Carefully', + reviewDescription: + 'By proceeding, you accept full responsibility for any errors, including the risk of irreversible loss of funds. Transfers are final and cannot be reversed.', + confirmationText: + 'I have reviewed the information and understand that transfers are final.', + back: 'Back', + confirm: 'Confirm', + loadingTokenInformation: 'Loading token information...' +} + +const SendTokensConfirmation = ({ + mint, + amount, + destinationAddress, + onConfirm, + onBack, + onClose +}: SendTokensConfirmationProps) => { + const [isConfirmed, setIsConfirmed] = useState(false) + const { isMobile } = useMedia() + + // Get token data and balance using the same hooks as ReceiveTokensModal + const { data: coin } = useArtistCoin({ mint }) + const { data: tokenBalance } = useTokenBalance({ mint }) + const tokenInfo = coin ? transformArtistCoinToTokenInfo(coin) : undefined + const currentBalance = tokenBalance?.balance + ? tokenBalance.balance.value + : BigInt(0) + + const formatAmount = (amount: bigint) => { + return new FixedDecimal(amount, tokenInfo?.decimals).toLocaleString( + 'en-US', + { + minimumFractionDigits: 0, + maximumFractionDigits: 0 + } + ) + } + + const formatBalance = (balance: bigint) => { + return new FixedDecimal(balance, tokenInfo?.decimals).toLocaleString( + 'en-US', + { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + } + ) + } + + const handleCheckboxChange = (event: ChangeEvent) => { + setIsConfirmed(event.target.checked) + } + + // Show loading state if we don't have tokenInfo yet + if (!tokenInfo) { + return ( + + + {messages.loadingTokenInformation} + + + ) + } + + return ( + + {/* Token Balance Section */} + + + + + {/* Amount Info */} + + + {messages.amountToSend} + + + -{formatAmount(amount)} {tokenInfo.symbol} + + + + + + {/* Transfer Info */} + + + {messages.destinationAddress} + + + {destinationAddress} + + + + {/* Review Details Hint */} + + + + {messages.reviewDetails} + + + {messages.reviewDescription} + + + + ({ color: theme.color.neutral.n600 })} + > + {messages.confirmationText} + + + + + + {/* Action Buttons */} + + + + + + ) +} + +export default SendTokensConfirmation diff --git a/packages/web/src/components/send-tokens-modal/SendTokensFailure.tsx b/packages/web/src/components/send-tokens-modal/SendTokensFailure.tsx new file mode 100644 index 00000000000..a10b69a105e --- /dev/null +++ b/packages/web/src/components/send-tokens-modal/SendTokensFailure.tsx @@ -0,0 +1,170 @@ +import { + useArtistCoin, + useTokenBalance, + transformArtistCoinToTokenInfo +} from '@audius/common/api' +import { FixedDecimal } from '@audius/fixed-decimal' +import { + Button, + Text, + Flex, + Divider, + CompletionCheck, + IconExternalLink, + PlainButton, + useMedia +} from '@audius/harmony' + +import { CryptoBalanceSection } from 'components/buy-sell-modal/CryptoBalanceSection' + +interface SendTokensFailureProps { + mint: string + amount: bigint + destinationAddress: string + error: string + onTryAgain: () => void + onClose: () => void +} + +const messages = { + failed: 'Failed', + destinationAddress: 'Destination Address', + viewOnSolana: 'View On Solana Block Explorer', + transactionFailed: 'Your transaction failed to complete.', + tryAgain: 'Try Again', + close: 'Close' +} + +const SendTokensFailure = ({ + mint, + amount, + destinationAddress, + error, + onTryAgain, + onClose +}: SendTokensFailureProps) => { + const { isMobile } = useMedia() + // Get token data and balance using the same hooks as ReceiveTokensModal + const { data: coin } = useArtistCoin({ mint }) + const { data: tokenBalance } = useTokenBalance({ mint }) + const tokenInfo = coin ? transformArtistCoinToTokenInfo(coin) : undefined + const currentBalance = tokenBalance?.balance + ? tokenBalance.balance.value + : BigInt(0) + + const formatAmount = (amount: bigint) => { + return new FixedDecimal(amount, tokenInfo?.decimals).toLocaleString( + 'en-US', + { + minimumFractionDigits: 0, + maximumFractionDigits: 0 + } + ) + } + + const formatBalance = (balance: bigint) => { + return new FixedDecimal(balance, tokenInfo?.decimals).toLocaleString( + 'en-US', + { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + } + ) + } + + // Show loading state if we don't have tokenInfo yet + if (!tokenInfo) { + return ( + + + Loading token information... + + + ) + } + + return ( + + {/* Token Balance Section */} + + + + + {/* Amount Info */} + + + {messages.failed} + + + -{formatAmount(amount)} {tokenInfo.symbol} + + + + + + {/* Address Container */} + + + {messages.destinationAddress} + + + {destinationAddress} + + { + window.open( + `https://explorer.solana.com/address/${destinationAddress}`, + '_blank' + ) + }} + iconRight={IconExternalLink} + > + {messages.viewOnSolana} + + + + {/* Error Message */} + + + + {messages.transactionFailed} + + + + {/* Error Details */} + {error && ( + + + {error} + + + )} + + {/* Action Buttons */} + + + + + + ) +} + +export default SendTokensFailure diff --git a/packages/web/src/components/send-tokens-modal/SendTokensInput.tsx b/packages/web/src/components/send-tokens-modal/SendTokensInput.tsx new file mode 100644 index 00000000000..5b4197a3636 --- /dev/null +++ b/packages/web/src/components/send-tokens-modal/SendTokensInput.tsx @@ -0,0 +1,240 @@ +import { ChangeEvent, useCallback, useState } from 'react' + +import { + useArtistCoin, + useTokenBalance, + transformArtistCoinToTokenInfo +} from '@audius/common/api' +import { isValidSolAddress } from '@audius/common/store' +import { FixedDecimal } from '@audius/fixed-decimal' +import { + Button, + IconValidationX, + TokenAmountInput, + Text, + Flex, + Divider +} from '@audius/harmony' + +import { CryptoBalanceSection } from 'components/buy-sell-modal/CryptoBalanceSection' + +import WalletInput from './WalletInput' + +interface SendTokensInputProps { + mint: string + onContinue: (amount: bigint, destinationAddress: string) => void + onClose: () => void + initialAmount?: string + initialDestinationAddress?: string +} + +const messages = { + amount: 'Amount', + amountToSend: 'Amount to Send', + amountDescription: 'How much {symbol} would you like to send?', + destinationAddress: 'Destination Address', + destinationDescription: 'The Solana wallet address to receive funds.', + continue: 'Continue', + insufficientBalance: 'Insufficient balance', + validWalletAddressRequired: 'A valid wallet address is required.', + amountRequired: 'Amount is required', + amountTooLow: 'Amount is too low to send', + walletAddress: 'Wallet Address' +} + +type ValidationError = + | 'INSUFFICIENT_BALANCE' + | 'INVALID_ADDRESS' + | 'AMOUNT_REQUIRED' + | 'AMOUNT_TOO_LOW' + +const SendTokensInput = ({ + mint, + onContinue, + onClose, + initialAmount = '', + initialDestinationAddress = '' +}: SendTokensInputProps) => { + const [amount, setAmount] = useState(initialAmount) + const [destinationAddress, setDestinationAddress] = useState( + initialDestinationAddress + ) + const [amountError, setAmountError] = useState(null) + const [addressError, setAddressError] = useState(null) + + // Get the coin data and balance using the same hooks as ReceiveTokensModal + const { data: coin } = useArtistCoin({ mint }) + const { data: tokenBalance } = useTokenBalance({ mint }) + const tokenInfo = coin ? transformArtistCoinToTokenInfo(coin) : undefined + const currentBalance = tokenBalance?.balance + ? tokenBalance.balance.value + : BigInt(0) + + const handleAmountChange = useCallback((value: string, weiAmount: bigint) => { + setAmount(value) + setAmountError(null) + }, []) + + const handleAddressChange = useCallback( + (e: ChangeEvent) => { + setDestinationAddress(e.target.value) + setAddressError(null) + }, + [] + ) + + const validateInputs = (): boolean => { + let isValid = true + + // Validate amount + if (!amount || parseFloat(amount) <= 0) { + setAmountError('AMOUNT_REQUIRED') + isValid = false + } else { + const amountWei = new FixedDecimal(amount, tokenInfo?.decimals).value + if (amountWei > currentBalance) { + setAmountError('INSUFFICIENT_BALANCE') + isValid = false + } else if (amountWei < BigInt(1000)) { + // Minimum amount + setAmountError('AMOUNT_TOO_LOW') + isValid = false + } + } + + // Validate address + if (!destinationAddress) { + setAddressError('INVALID_ADDRESS') + isValid = false + } else if (!isValidSolAddress(destinationAddress as any)) { + setAddressError('INVALID_ADDRESS') + isValid = false + } + + return isValid + } + + const handleContinue = () => { + if (validateInputs()) { + const amountWei = new FixedDecimal(amount, tokenInfo?.decimals).value + onContinue(amountWei, destinationAddress) + } + } + + const getAmountDescription = () => { + return messages.amountDescription.replace( + '{symbol}', + tokenInfo?.symbol ?? 'tokens' + ) + } + + const getErrorText = (error: ValidationError | null) => { + switch (error) { + case 'INSUFFICIENT_BALANCE': + return messages.insufficientBalance + case 'INVALID_ADDRESS': + return messages.validWalletAddressRequired + case 'AMOUNT_REQUIRED': + return messages.amountRequired + case 'AMOUNT_TOO_LOW': + return messages.amountTooLow + default: + return '' + } + } + + const hasErrors = amountError || addressError + + // Show loading state if we don't have tokenInfo yet + if (!tokenInfo) { + return ( + + + Loading token information... + + + ) + } + + // Format balance for display + const formattedBalance = new FixedDecimal( + currentBalance, + tokenInfo.decimals + ).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }) + + return ( + + {/* Token Balance Section */} + + + + + {/* Amount Section */} + + + + {messages.amountToSend} + + + {getAmountDescription()} + + + + + + {amountError && ( + + + + {getErrorText(amountError)} + + + )} + + + + + {/* Destination Address Section */} + + + + {messages.destinationAddress} + + + {messages.destinationDescription} + + + + + + + {/* Continue Button */} + + + ) +} + +export default SendTokensInput diff --git a/packages/web/src/components/send-tokens-modal/SendTokensModal.tsx b/packages/web/src/components/send-tokens-modal/SendTokensModal.tsx new file mode 100644 index 00000000000..3e3d5f2a83d --- /dev/null +++ b/packages/web/src/components/send-tokens-modal/SendTokensModal.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react' + +import { + useArtistCoin, + transformArtistCoinToTokenInfo, + useSendTokens +} from '@audius/common/api' +import { AUDIO, FixedDecimal } from '@audius/fixed-decimal' + +import ResponsiveModal from 'components/modal/ResponsiveModal' + +import SendTokensConfirmation from './SendTokensConfirmation' +import SendTokensFailure from './SendTokensFailure' +import SendTokensInput from './SendTokensInput' +import SendTokensProgress from './SendTokensProgress' +import SendTokensSuccess from './SendTokensSuccess' + +interface SendTokensModalProps { + mint: string + onClose: () => void + walletAddress: string + isOpen: boolean +} + +type SendTokensState = { + step: 'input' | 'confirm' | 'progress' | 'success' | 'failure' + amount: bigint + destinationAddress: string +} + +const SendTokensModal = ({ + mint, + onClose, + walletAddress, + isOpen +}: SendTokensModalProps) => { + const [state, setState] = useState({ + step: 'input', + amount: BigInt(0), + destinationAddress: '' + }) + const [error, setError] = useState('') + + // Get token data and balance using the same hooks as ReceiveTokensModal + const { data: coin } = useArtistCoin({ mint }) + const tokenInfo = coin ? transformArtistCoinToTokenInfo(coin) : undefined + + // Use the new tan-query hook for sending tokens + const sendTokensMutation = useSendTokens({ mint }) + + const handleInputContinue = (amount: bigint, destinationAddress: string) => { + setState({ + step: 'confirm', + amount, + destinationAddress + }) + } + + const handleConfirm = async () => { + setState((prev) => ({ ...prev, step: 'progress' })) + setError('') // Clear any previous errors + + try { + // Use the new hook to send tokens + await sendTokensMutation.mutateAsync({ + recipientWallet: state.destinationAddress as any, // Type assertion for now + amount: AUDIO(state.amount).value // Convert bigint to AudioWei + }) + + // If successful, move to success step + setState((prev) => ({ ...prev, step: 'success' })) + } catch (error) { + // If there's an error, move to failure step + const errorMessage = + error instanceof Error ? error.message : 'An unknown error occurred' + setError(errorMessage) + setState((prev) => ({ ...prev, step: 'failure' })) + } + } + + const handleBack = () => { + setState((prev) => ({ ...prev, step: 'input' })) + } + + const handleTryAgain = () => { + setState((prev) => ({ ...prev, step: 'confirm' })) + setError('') + } + + const handleDone = () => { + onClose() + setState({ + step: 'input', + amount: BigInt(0), + destinationAddress: '' + }) + setError('') + } + + const handleClose = () => { + if (state.step === 'input') { + onClose() + setState({ + step: 'input', + amount: BigInt(0), + destinationAddress: '' + }) + setError('') + } + } + + const getModalTitle = () => { + if (!tokenInfo) return 'Send Tokens' + + switch (state.step) { + case 'input': + return `Send ${tokenInfo.symbol}` + case 'confirm': + return 'Confirm Send' + case 'progress': + return 'Sending...' + case 'success': + return 'Sent Successfully' + case 'failure': + return 'Send Failed' + default: + return `Send ${tokenInfo.symbol}` + } + } + + if (!isOpen) return null + + return ( + + {state.step === 'input' ? ( + 0 + ? new FixedDecimal(state.amount, tokenInfo?.decimals).toString() + : '' + } + initialDestinationAddress={state.destinationAddress} + /> + ) : null} + + {state.step === 'confirm' ? ( + + ) : null} + + {state.step === 'progress' ? : null} + + {state.step === 'success' ? ( + + ) : null} + + {state.step === 'failure' ? ( + + ) : null} + + ) +} + +export default SendTokensModal diff --git a/packages/web/src/components/send-tokens-modal/SendTokensProgress.tsx b/packages/web/src/components/send-tokens-modal/SendTokensProgress.tsx new file mode 100644 index 00000000000..3407c46cffa --- /dev/null +++ b/packages/web/src/components/send-tokens-modal/SendTokensProgress.tsx @@ -0,0 +1,34 @@ +import { Text, Flex, LoadingSpinner } from '@audius/harmony' + +const messages = { + transactionInProgress: 'Transaction in Progress', + description: 'This may take a moment.' +} + +const SendTokensProgress = () => { + return ( + + {/* Loading Spinner */} + + + {/* Status Text */} + + + {messages.transactionInProgress} + + + {messages.description} + + + + ) +} + +export default SendTokensProgress diff --git a/packages/web/src/components/send-tokens-modal/SendTokensSuccess.tsx b/packages/web/src/components/send-tokens-modal/SendTokensSuccess.tsx new file mode 100644 index 00000000000..e84bbbed6b2 --- /dev/null +++ b/packages/web/src/components/send-tokens-modal/SendTokensSuccess.tsx @@ -0,0 +1,154 @@ +import { + useArtistCoin, + useTokenBalance, + transformArtistCoinToTokenInfo +} from '@audius/common/api' +import { route } from '@audius/common/utils' +import { FixedDecimal } from '@audius/fixed-decimal' +import { + Button, + Text, + Flex, + Divider, + CompletionCheck, + IconExternalLink, + PlainButton, + useMedia +} from '@audius/harmony' + +import { CryptoBalanceSection } from 'components/buy-sell-modal/CryptoBalanceSection' + +interface SendTokensSuccessProps { + mint: string + amount: bigint + destinationAddress: string + onDone: () => void + onClose: () => void +} + +const messages = { + sent: 'Sent', + destinationAddress: 'Destination Address', + viewOnSolana: 'View On Solana Block Explorer', + transactionComplete: 'Your transaction is complete!', + done: 'Done' +} + +const SendTokensSuccess = ({ + mint, + amount, + destinationAddress, + onDone, + onClose +}: SendTokensSuccessProps) => { + const { isMobile } = useMedia() + // Get token data and balance using the same hooks as ReceiveTokensModal + const { data: coin } = useArtistCoin({ mint }) + const { data: tokenBalance } = useTokenBalance({ mint }) + const tokenInfo = coin ? transformArtistCoinToTokenInfo(coin) : undefined + const currentBalance = tokenBalance?.balance + ? tokenBalance.balance.value + : BigInt(0) + + const formatAmount = (amount: bigint) => { + return new FixedDecimal(amount, tokenInfo?.decimals).toLocaleString( + 'en-US', + { + minimumFractionDigits: 0, + maximumFractionDigits: 0 + } + ) + } + + const formatBalance = (balance: bigint) => { + return new FixedDecimal(balance, tokenInfo?.decimals).toLocaleString( + 'en-US', + { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + } + ) + } + + // Show loading state if we don't have tokenInfo yet + if (!tokenInfo) { + return ( + + + Loading token information... + + + ) + } + + return ( + + {/* Token Balance Section */} + + + + + {/* Amount Info */} + + + {messages.sent} + + + -{formatAmount(amount)} {tokenInfo.symbol} + + + + + + {/* Address Container */} + + + {messages.destinationAddress} + + + {destinationAddress} + + { + window.open( + route.solanaExplorerAddress(destinationAddress), + '_blank' + ) + }} + iconRight={IconExternalLink} + > + {messages.viewOnSolana} + + + + {/* Success Message */} + + + + {messages.transactionComplete} + + + + {/* Action Button */} + + + ) +} + +export default SendTokensSuccess diff --git a/packages/web/src/components/send-tokens-modal/WalletInput.tsx b/packages/web/src/components/send-tokens-modal/WalletInput.tsx new file mode 100644 index 00000000000..6b09ffb3373 --- /dev/null +++ b/packages/web/src/components/send-tokens-modal/WalletInput.tsx @@ -0,0 +1,31 @@ +import { ChangeEvent, forwardRef, Ref } from 'react' + +import { TextInput, TextInputProps } from '@audius/harmony' + +type WalletInputProps = TextInputProps + +export const WalletInput = forwardRef( + (props: WalletInputProps, ref: Ref) => { + const { value, onChange, error, helperText, ...other } = props + + const handleChange = (e: ChangeEvent) => { + if (onChange) { + onChange(e) + } + } + + return ( + + ) + } +) + +export default WalletInput diff --git a/packages/web/src/components/send-tokens-modal/index.ts b/packages/web/src/components/send-tokens-modal/index.ts new file mode 100644 index 00000000000..a72a9fc4c30 --- /dev/null +++ b/packages/web/src/components/send-tokens-modal/index.ts @@ -0,0 +1 @@ +export { default as SendTokensModal } from './SendTokensModal' diff --git a/packages/web/src/pages/asset-detail-page/AssetDetailContent.tsx b/packages/web/src/pages/asset-detail-page/AssetDetailContent.tsx index c3801a14b86..4e7454b0412 100644 --- a/packages/web/src/pages/asset-detail-page/AssetDetailContent.tsx +++ b/packages/web/src/pages/asset-detail-page/AssetDetailContent.tsx @@ -5,7 +5,6 @@ import { AssetInsights } from './components/AssetInsights' import { AssetLeaderboardCard } from './components/AssetLeaderboardCard' import { BalanceSection } from './components/BalanceSection' import { ExternalWallets } from './components/ExternalWallets' -import { AssetDetailProps } from './types' const LEFT_SECTION_WIDTH = '704px' const RIGHT_SECTION_WIDTH = '360px' @@ -73,7 +72,11 @@ const useStyles = makeResponsiveStyles(({ media, theme }) => ({ } })) -export const AssetDetailContent = ({ mint }: AssetDetailProps) => { +type AssetDetailContentProps = { + mint: string +} + +export const AssetDetailContent = ({ mint }: AssetDetailContentProps) => { const styles = useStyles() return ( diff --git a/packages/web/src/pages/asset-detail-page/components/AssetInfoSection.tsx b/packages/web/src/pages/asset-detail-page/components/AssetInfoSection.tsx index 2f4e4b85eae..8c577dc5d60 100644 --- a/packages/web/src/pages/asset-detail-page/components/AssetInfoSection.tsx +++ b/packages/web/src/pages/asset-detail-page/components/AssetInfoSection.tsx @@ -31,8 +31,6 @@ import { useCoverPhoto } from 'hooks/useCoverPhoto' import Tiers from 'pages/rewards-page/Tiers' import { env } from 'services/env' -import { AssetDetailProps } from '../types' - const messages = { loading: 'Loading...', createdBy: 'Created By', @@ -138,7 +136,11 @@ const TokenIcon = ({ logoURI }: { logoURI?: string }) => { return } -const BannerSection = ({ mint }: AssetDetailProps) => { +type BannerSectionProps = { + mint: string +} + +const BannerSection = ({ mint }: BannerSectionProps) => { const { data: coin, isLoading } = useArtistCoin({ mint }) const userId = coin?.ownerId @@ -232,7 +234,11 @@ const BannerSection = ({ mint }: AssetDetailProps) => { ) } -export const AssetInfoSection = ({ mint }: AssetDetailProps) => { +type AssetInfoSectionProps = { + mint: string +} + +export const AssetInfoSection = ({ mint }: AssetInfoSectionProps) => { const [isTiersModalOpen, setIsTiersModalOpen] = useState(false) const { data: coin, isLoading } = useArtistCoin({ mint }) diff --git a/packages/web/src/pages/asset-detail-page/components/AssetInsights.tsx b/packages/web/src/pages/asset-detail-page/components/AssetInsights.tsx index 3d7d0ef6a52..ae34f3de216 100644 --- a/packages/web/src/pages/asset-detail-page/components/AssetInsights.tsx +++ b/packages/web/src/pages/asset-detail-page/components/AssetInsights.tsx @@ -4,7 +4,6 @@ import { Flex, IconCaretDown, IconCaretUp, Paper, Text } from '@audius/harmony' import { componentWithErrorBoundary } from '../../../components/error-wrapper/componentWithErrorBoundary' import Skeleton from '../../../components/skeleton/Skeleton' import { createCoinMetrics, MetricData } from '../../../utils/coinMetrics' -import { AssetDetailProps } from '../types' const messages = { title: 'Insights', @@ -110,7 +109,11 @@ const MetricRow = componentWithErrorBoundary(MetricRowComponent, { name: 'MetricRow' }) -export const AssetInsights = ({ mint }: AssetDetailProps) => { +type AssetInsightsProps = { + mint: string +} + +export const AssetInsights = ({ mint }: AssetInsightsProps) => { const { data: coinInsights, isPending, diff --git a/packages/web/src/pages/asset-detail-page/components/AssetLeaderboardCard.tsx b/packages/web/src/pages/asset-detail-page/components/AssetLeaderboardCard.tsx index 418b663e968..80d33e03768 100644 --- a/packages/web/src/pages/asset-detail-page/components/AssetLeaderboardCard.tsx +++ b/packages/web/src/pages/asset-detail-page/components/AssetLeaderboardCard.tsx @@ -21,8 +21,6 @@ import { UserListType } from 'store/application/ui/userListModal/types' -import { AssetDetailProps } from '../types' - const messages = { title: 'Members Leaderboard', leaderboard: 'Leaderboard' @@ -38,7 +36,11 @@ const AvatarSkeleton = (props: any) => ( /> ) -export const AssetLeaderboardCard = ({ mint }: AssetDetailProps) => { +type AssetLeaderboardCardProps = { + mint: string +} + +export const AssetLeaderboardCard = ({ mint }: AssetLeaderboardCardProps) => { const { data: leaderboardUsers, isPending: isLeaderboardPending } = useArtistCoinMembers({ mint }) const { data: users, isPending: isUsersPending } = useUsers( diff --git a/packages/web/src/pages/asset-detail-page/components/BalanceSection.tsx b/packages/web/src/pages/asset-detail-page/components/BalanceSection.tsx index fe0bb205a21..b5832ef4125 100644 --- a/packages/web/src/pages/asset-detail-page/components/BalanceSection.tsx +++ b/packages/web/src/pages/asset-detail-page/components/BalanceSection.tsx @@ -20,8 +20,6 @@ import Skeleton from 'components/skeleton/Skeleton' import { useIsMobile } from 'hooks/useIsMobile' import { useRequiresAccountCallback } from 'hooks/useRequiresAccount' -import { AssetDetailProps } from '../types' - type BalanceStateProps = { title: string logoURI?: string @@ -149,6 +147,10 @@ const HasBalanceState = ({ ) } +type AssetDetailProps = { + mint: string +} + const BalanceSectionContent = ({ mint }: AssetDetailProps) => { const { data: coinInsights, isPending: coinsLoading } = useArtistCoins({ mint: [mint] @@ -184,7 +186,7 @@ const BalanceSectionContent = ({ mint }: AssetDetailProps) => { // No USDC balance - show add cash modal (uses Coinflow) openAddCashModal() } - }, [openAddCashModal]) + }, [openAddCashModal, openBuySellModal, usdcBalance]) const handleReceive = useRequiresAccountCallback(() => { openReceiveTokensModal({ diff --git a/packages/web/src/pages/asset-detail-page/components/ExternalWallets.tsx b/packages/web/src/pages/asset-detail-page/components/ExternalWallets.tsx index dbc6ea7015d..6e20f2d1ba7 100644 --- a/packages/web/src/pages/asset-detail-page/components/ExternalWallets.tsx +++ b/packages/web/src/pages/asset-detail-page/components/ExternalWallets.tsx @@ -35,7 +35,6 @@ import { AlreadyAssociatedError, useConnectAndAssociateWallets } from '../../../hooks/useConnectAndAssociateWallets' -import { AssetDetailProps } from '../types' const COPIED_TOAST_TIMEOUT = 2000 @@ -163,7 +162,11 @@ const WalletRow = ({ ) } -export const ExternalWallets = ({ mint }: AssetDetailProps) => { +type ExternalWalletsProps = { + mint: string +} + +export const ExternalWallets = ({ mint }: ExternalWalletsProps) => { const { data: userCoins, isLoading } = useUserCoin({ mint }) diff --git a/packages/web/src/pages/asset-detail-page/types.ts b/packages/web/src/pages/asset-detail-page/types.ts deleted file mode 100644 index 198dcda5ab4..00000000000 --- a/packages/web/src/pages/asset-detail-page/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type AssetDetailProps = { - mint: string -}