From e0aa2ab56f1730d7528845b84f9f34f96098fef2 Mon Sep 17 00:00:00 2001 From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com> Date: Fri, 3 Oct 2025 18:01:31 +0200 Subject: [PATCH 01/36] feat: new mobile send flow --- src/assets/translations/en/main.json | 9 +- src/components/Modals/Send/Form.tsx | 9 +- src/components/Modals/Send/SendCommon.tsx | 1 + .../Send/SendMaxButton/SendMaxButton.tsx | 9 +- src/components/Modals/Send/views/Address.tsx | 92 +++++-- src/components/Modals/Send/views/Amount.tsx | 252 ++++++++++++++++++ 6 files changed, 344 insertions(+), 28 deletions(-) create mode 100644 src/components/Modals/Send/views/Amount.tsx diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index f3aeecc3acd..3b1f179c799 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -77,6 +77,7 @@ "rename": "Rename", "forget": "Forget?", "pending": "Pending", + "preview": "Preview", "incomplete": "Incomplete", "available": "Available", "or": "or", @@ -1472,7 +1473,13 @@ "slow": "Slow", "average": "Average", "fast": "Fast", - "enterAmount": "Enter Amount" + "enterAmount": "Enter Amount", + "scanQrCode": "Scan QR Code", + "scanQrCodeDescription": "Tap to scan an address", + "yourAddresses": "Your Addresses", + "to": "To", + "from": "From", + "availableBalance": "%{balance} available" }, "status": { "pendingBody": "Sending %{amount} %{symbol}" diff --git a/src/components/Modals/Send/Form.tsx b/src/components/Modals/Send/Form.tsx index f5c70bc3c74..60c40a5e4a8 100644 --- a/src/components/Modals/Send/Form.tsx +++ b/src/components/Modals/Send/Form.tsx @@ -12,6 +12,7 @@ import { useFormSend } from './hooks/useFormSend/useFormSend' import { SendFormFields, SendRoutes } from './SendCommon' import { maybeFetchChangeAddress } from './utils' import { Address } from './views/Address' +import { Amount } from './views/Amount' import { Confirm } from './views/Confirm' import { Details } from './views/Details' import { Status } from './views/Status' @@ -31,9 +32,9 @@ import { getMixPanel } from '@/lib/mixpanel/mixPanelSingleton' import { MixPanelEvent } from '@/lib/mixpanel/types' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import { - ActionStatus, - ActionType, - GenericTransactionDisplayType, + ActionStatus, + ActionType, + GenericTransactionDisplayType, } from '@/state/slices/actionSlice/types' import { preferences } from '@/state/slices/preferencesSlice/preferencesSlice' import { selectMarketDataByAssetIdUserCurrency } from '@/state/slices/selectors' @@ -42,6 +43,7 @@ import { store, useAppDispatch, useAppSelector } from '@/state/store' const status = const confirm = const details =
+const amount = const address =
export type SendInput = { @@ -307,6 +309,7 @@ export const Form: React.FC = ({ initialAssetId, input = '', acco {selectAssetRouter} {address} + {amount} {details} {qrCodeScanner} {confirm} diff --git a/src/components/Modals/Send/SendCommon.tsx b/src/components/Modals/Send/SendCommon.tsx index 7ad9649e9a9..fb70fc08bb0 100644 --- a/src/components/Modals/Send/SendCommon.tsx +++ b/src/components/Modals/Send/SendCommon.tsx @@ -1,4 +1,5 @@ export enum SendRoutes { + Amount = '/send/amount', Details = '/send/details', Confirm = '/send/confirm', Status = '/send/status', diff --git a/src/components/Modals/Send/SendMaxButton/SendMaxButton.tsx b/src/components/Modals/Send/SendMaxButton/SendMaxButton.tsx index 2be37e6473f..57165445e46 100644 --- a/src/components/Modals/Send/SendMaxButton/SendMaxButton.tsx +++ b/src/components/Modals/Send/SendMaxButton/SendMaxButton.tsx @@ -8,14 +8,7 @@ type SendMaxButtonProps = { export const SendMaxButton = ({ onClick }: SendMaxButtonProps) => { return ( - ) diff --git a/src/components/Modals/Send/views/Address.tsx b/src/components/Modals/Send/views/Address.tsx index 3c6f16f43b6..6bad6e7b4cd 100644 --- a/src/components/Modals/Send/views/Address.tsx +++ b/src/components/Modals/Send/views/Address.tsx @@ -1,8 +1,19 @@ -import { Button, FormControl, FormLabel, Stack } from '@chakra-ui/react' +import { + Button, + Text as CText, + Flex, + FormControl, + FormLabel, + Icon, + Stack, + useColorModeValue, + VStack, +} from '@chakra-ui/react' import { ethChainId } from '@shapeshiftoss/caip' import get from 'lodash/get' import { useCallback, useEffect, useMemo, useState } from 'react' import { useFormContext, useWatch } from 'react-hook-form' +import { TbScan } from 'react-icons/tb' import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' @@ -23,6 +34,13 @@ import { parseAddressInputWithChainId } from '@/lib/address/address' import { selectAssetById } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' +const qrCodeSx = { + svg: { + width: '24px', + height: '24px', + }, +} + export const Address = () => { const [isValidating, setIsValidating] = useState(false) const navigate = useNavigate() @@ -37,6 +55,24 @@ export const Address = () => { const send = useModal('send') const qrCode = useModal('qrCode') const assetId = useWatch({ name: SendFormFields.AssetId }) + const qrBackground = useColorModeValue('blackAlpha.200', 'whiteAlpha.200') + + const qrCodeIcon = useMemo( + () => ( + + + + ), + [qrBackground], + ) const asset = useAppSelector(state => selectAssetById(state, assetId)) @@ -47,7 +83,7 @@ export const Address = () => { trigger(SendFormFields.Input) }, [trigger]) - const handleNext = useCallback(() => navigate(SendRoutes.Details), [navigate]) + const handleNext = useCallback(() => navigate(SendRoutes.Amount), [navigate]) const handleBackClick = useCallback(() => { setValue(SendFormFields.AssetId, '') @@ -103,6 +139,10 @@ export const Address = () => { qrCode.close?.() }, [send, qrCode]) + const handleScanQrCode = useCallback(() => { + qrCode.open({ assetId }) + }, [qrCode, assetId]) + if (!asset) return null return ( @@ -114,21 +154,41 @@ export const Address = () => { - - - {translate('modals.send.sendForm.sendTo')} - - - + + + + {translate('modals.send.sendForm.sendTo')} + + + + + + - + + + + )} + + + + + + + + + + {translate('modals.send.sendForm.from')} + + + + {balancesLoading ? ( + + ) : ( + + {translate('modals.send.sendForm.availableBalance', { + balance: `${localeParts.prefix}${fiatBalance.toFixed(2)}`, + })} + + )} + + + + + + + + + + + ) +} From 18c91022b24f47171ec9b27b4349a4df4598c565 Mon Sep 17 00:00:00 2001 From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com> Date: Mon, 6 Oct 2025 15:57:08 +0200 Subject: [PATCH 02/36] fix: finish amount screen --- src/assets/translations/en/main.json | 3 + .../AccountSelectorDialog.tsx | 408 ++++++++++++++++++ .../AccountSelectorOption.tsx | 96 +++++ src/components/Modals/Send/Form.tsx | 12 +- src/components/Modals/Send/views/Address.tsx | 2 +- .../Send/views/{Amount.tsx => SendAmount.tsx} | 120 +++--- 6 files changed, 572 insertions(+), 69 deletions(-) create mode 100644 src/components/AccountSelectorDialog/AccountSelectorDialog.tsx create mode 100644 src/components/AccountSelectorDialog/AccountSelectorOption.tsx rename src/components/Modals/Send/views/{Amount.tsx => SendAmount.tsx} (75%) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 3b1f179c799..8d4453546f8 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -232,6 +232,9 @@ "network": "Network", "feeEstimate": "Fee Estimate" }, + "accountSelector": { + "chooseAccount": "Choose Account" + }, "quickBuy": { "title": "Quick Buy %{assetOnChain}", "nativeNotAvailable": "Quick Buy not available on native assets", diff --git a/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx b/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx new file mode 100644 index 00000000000..88c17af48bf --- /dev/null +++ b/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx @@ -0,0 +1,408 @@ +import type { BoxProps, ButtonProps } from '@chakra-ui/react' +import { + Box, + Button, + HStack, + Icon, + Text, + useDisclosure, + usePrevious, + VStack, +} from '@chakra-ui/react' +import type { AccountId, AssetId } from '@shapeshiftoss/caip' +import { fromAccountId, fromAssetId } from '@shapeshiftoss/caip' +import type { Asset } from '@shapeshiftoss/types' +import { UtxoAccountType } from '@shapeshiftoss/types' +import { chain } from 'lodash' +import isEmpty from 'lodash/isEmpty' +import sortBy from 'lodash/sortBy' +import type { FC } from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { RiExpandUpDownLine } from 'react-icons/ri' +import { useTranslate } from 'react-polyglot' +import { useSelector } from 'react-redux' + +import { AccountSelectorOption } from './AccountSelectorOption' + +import { AssetIcon } from '@/components/AssetIcon' +import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' +import { Dialog } from '@/components/Modal/components/Dialog' +import { DialogBody } from '@/components/Modal/components/DialogBody' +import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' +import { DialogFooter } from '@/components/Modal/components/DialogFooter' +import { + DialogHeader, + DialogHeaderLeft, + DialogHeaderMiddle, +} from '@/components/Modal/components/DialogHeader' +import { DialogTitle } from '@/components/Modal/components/DialogTitle' +import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { fromBaseUnit } from '@/lib/math' +import { isValidAccountNumber } from '@/lib/utils/accounts' +import type { ReduxState } from '@/state/reducer' +import { accountIdToLabel } from '@/state/slices/portfolioSlice/utils' +import { + selectAssetById, + selectHighestUserCurrencyBalanceAccountByAssetId, + selectMarketDataByAssetIdUserCurrency, + selectPortfolioAccountBalancesBaseUnit, + selectPortfolioAccountIdsByAssetIdFilter, + selectPortfolioAccountMetadata, +} from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +export type AccountSelectorDialogProps = { + assetId: AssetId + onChange: (accountId: AccountId) => void + defaultAccountId?: AccountId + // Auto-selects the account with the highest balance, and sorts the account list descending by balance + autoSelectHighestBalance?: boolean + // Prevents accounts in the dropdown from being selected + disabled?: boolean + buttonProps?: ButtonProps + boxProps?: BoxProps +} + +const utxoAccountTypeToDisplayPriority = (accountType: UtxoAccountType | undefined) => { + switch (accountType) { + case UtxoAccountType.SegwitNative: + return 0 + case UtxoAccountType.SegwitP2sh: + return 1 + case UtxoAccountType.P2pkh: + return 2 + // We found something else, put it at the end + default: + return 3 + } +} + +const chevronIconSx = { + svg: { + h: '18px', + w: '18px', + }, +} + +type AccountIdsByNumberAndType = { + [k: number]: AccountId[] +} + +// Account selection dialog component +const AccountSelectionDialog = ({ + isOpen, + onClose, + accountIdsByNumberAndType, + asset, + autoSelectHighestBalance, + disabled, + selectedAccountId, + onAccountSelect, +}: { + isOpen: boolean + onClose: () => void + accountIdsByNumberAndType: AccountIdsByNumberAndType + asset: Asset + autoSelectHighestBalance: boolean | undefined + disabled: boolean | undefined + selectedAccountId: AccountId | undefined + onAccountSelect: (accountId: AccountId) => void +}) => { + const { assetId } = asset + const translate = useTranslate() + const accountBalances = useSelector(selectPortfolioAccountBalancesBaseUnit) + const accountMetadata = useSelector(selectPortfolioAccountMetadata) + + const getAccountIdsSortedByUtxoAccountType = useCallback( + (accountIds: AccountId[]): AccountId[] => { + return sortBy(accountIds, accountId => + utxoAccountTypeToDisplayPriority(accountMetadata[accountId]?.accountType), + ) + }, + [accountMetadata], + ) + + const getAccountIdsSortedByBalance = useCallback( + (accountIds: AccountId[]): AccountId[] => + chain(accountIds) + .sortBy(accountIds, accountId => + bnOrZero(accountBalances?.[accountId]?.[assetId] ?? 0).toNumber(), + ) + .reverse() + .value(), + [accountBalances, assetId], + ) + + const handleDone = useCallback(() => { + onClose() + }, [onClose]) + + return ( + + + + + + + {translate('accountSelector.chooseAccount')} + + + + + {Object.entries(accountIdsByNumberAndType).map(([accountNumber, accountIds]) => { + const sortedAccountIds = autoSelectHighestBalance + ? getAccountIdsSortedByBalance(accountIds) + : getAccountIdsSortedByUtxoAccountType(accountIds) + + if (accountIds.length === 0) return null + + return ( + + + {sortedAccountIds.map((accountId, index) => { + const cryptoBalance = fromBaseUnit( + accountBalances?.[accountId]?.[assetId] ?? 0, + asset?.precision ?? 0, + ) + const isSelected = selectedAccountId === accountId + + return ( + + ) + })} + + + ) + })} + + + + + + + ) +} + +export const AccountSelectorDialog: FC = memo( + ({ + assetId, + buttonProps, + onChange: handleChange, + disabled, + defaultAccountId, + autoSelectHighestBalance, + boxProps, + }) => { + const filter = useMemo(() => ({ assetId }), [assetId]) + const accountIds = useAppSelector((s: ReduxState) => + selectPortfolioAccountIdsByAssetIdFilter(s, filter), + ) + const marketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, assetId), + ) + + const translate = useTranslate() + const asset = useAppSelector((s: ReduxState) => selectAssetById(s, assetId)) + const { isOpen, onOpen, onClose } = useDisclosure() + const { + number: { localeParts }, + } = useLocaleFormatter() + + if (!asset) throw new Error(`AccountDropdown: no asset found for assetId ${assetId}!`) + + const accountMetadata = useSelector(selectPortfolioAccountMetadata) + const accountBalances = useSelector(selectPortfolioAccountBalancesBaseUnit) + const highestUserCurrencyBalanceAccountId = useAppSelector(state => + selectHighestUserCurrencyBalanceAccountByAssetId(state, { assetId }), + ) + const [selectedAccountId, setSelectedAccountId] = useState( + defaultAccountId, + ) + + // very suspicious of this + // Poor man's componentDidUpdate until we figure out why this re-renders like crazy + const previousSelectedAccountId = usePrevious(selectedAccountId) + const isButtonDisabled = disabled || accountIds.length <= 1 + + /** + * react on selectedAccountId change + */ + useEffect(() => { + if (isEmpty(accountMetadata)) return // not enough data to set an AccountId + if (!selectedAccountId || previousSelectedAccountId === selectedAccountId) return // no-op, this would fire onChange an infuriating amount of times + handleChange(selectedAccountId) + }, [accountMetadata, previousSelectedAccountId, selectedAccountId, handleChange]) + + /** + * react on accountIds on first render + */ + useEffect(() => { + if (!accountIds.length) return + const validatedAccountIdFromArgs = accountIds.find( + accountId => accountId === defaultAccountId, + ) + const firstAccountId = accountIds[0] + // Use the first accountId if we don't have a valid defaultAccountId + const preSelectedAccountId = + validatedAccountIdFromArgs ?? + (autoSelectHighestBalance ? highestUserCurrencyBalanceAccountId : undefined) ?? + firstAccountId + if (previousSelectedAccountId === preSelectedAccountId) return + /** + * assert asset the chainId of the accountId and assetId match + */ + const accountIdChainId = fromAccountId(preSelectedAccountId).chainId + const assetIdChainId = fromAssetId(assetId).chainId + if (accountIdChainId !== assetIdChainId) { + throw new Error('AccountDropdown: chainId mismatch!') + } + setSelectedAccountId(preSelectedAccountId) + }, [ + assetId, + accountIds, + defaultAccountId, + highestUserCurrencyBalanceAccountId, + autoSelectHighestBalance, + previousSelectedAccountId, + ]) + + const handleAccountSelect = useCallback( + (accountId: AccountId) => { + setSelectedAccountId(accountId) + onClose() + }, + [onClose], + ) + + /** + * memoized view bits and bobs + */ + const accountLabel = useMemo( + () => selectedAccountId && accountIdToLabel(selectedAccountId), + [selectedAccountId], + ) + + const accountNumber: number | undefined = useMemo( + () => selectedAccountId && accountMetadata[selectedAccountId]?.bip44Params?.accountNumber, + [accountMetadata, selectedAccountId], + ) + + const rightIcon = useMemo( + () => (isButtonDisabled ? null : ), + [isButtonDisabled], + ) + + /** + * for UTXO-based chains, we can have many accounts for a single account number + * e.g. account 0 can have legacy, segwit, and segwit native + * + * this allows us to render the multiple account varieties and their balances for + * the native asset for UTXO chains, or a single row with the selected asset for + * account based chains that support tokens + */ + const accountIdsByNumberAndType = useMemo(() => { + const initial: AccountIdsByNumberAndType = {} + return accountIds.reduce((acc, accountId) => { + const account = accountMetadata[accountId] + if (!account) return acc + const { accountNumber } = account.bip44Params + if (!acc[accountNumber]) acc[accountNumber] = [] + acc[accountNumber].push(accountId) + return acc + }, initial) + }, [accountIds, accountMetadata]) + + // Get selected account details for the button + const selectedAccountDetails = useMemo(() => { + if (!selectedAccountId || !asset) return null + + const cryptoBalance = fromBaseUnit( + accountBalances?.[selectedAccountId]?.[assetId] ?? 0, + asset?.precision ?? 0, + ) + const fiatBalance = bnOrZero(cryptoBalance).times(marketData?.price ?? 0) + + return { + fiatBalance, + accountAddress: fromAccountId(selectedAccountId).account, + } + }, [selectedAccountId, asset, accountBalances, assetId, marketData]) + + /** + * do NOT remove these checks, this is not a visual thing, this is a safety check! + * + * this component is responsible for selecting the correct account for operations where + * we are sending funds, we need to be paranoid. + */ + if (!accountIds.length) return null + if (!isValidAccountNumber(accountNumber)) return null + if (!Object.keys(accountIdsByNumberAndType).length) return null + if (!accountLabel) return null + + return ( + <> + + + + + + + ) + }, +) diff --git a/src/components/AccountSelectorDialog/AccountSelectorOption.tsx b/src/components/AccountSelectorDialog/AccountSelectorOption.tsx new file mode 100644 index 00000000000..e13abcae154 --- /dev/null +++ b/src/components/AccountSelectorDialog/AccountSelectorOption.tsx @@ -0,0 +1,96 @@ +import { Box, HStack, Radio, Text, VStack } from '@chakra-ui/react' +import type { AccountId, AssetId } from '@shapeshiftoss/caip' +import { fromAccountId } from '@shapeshiftoss/caip' +import { useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' + +import { Amount } from '@/components/Amount/Amount' +import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { ProfileAvatar } from '@/pages/Dashboard/components/ProfileAvatar/ProfileAvatar' +import { selectMarketDataByAssetIdUserCurrency } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type AccountSelectorOptionProps = { + accountId: AccountId + accountNumber: number + cryptoBalance: string + symbol: string + isSelected: boolean + assetId: AssetId + disabled?: boolean + onOptionClick: (accountId: AccountId) => void +} + +const radioSx = { + '&[data-checked]': { + backgroundColor: 'blue.500', + borderColor: 'blue.500', + }, +} + +export const AccountSelectorOption = ({ + accountId, + accountNumber, + cryptoBalance, + symbol, + isSelected, + disabled, + assetId, + onOptionClick, +}: AccountSelectorOptionProps) => { + const translate = useTranslate() + const handleClick = useCallback(() => onOptionClick(accountId), [accountId, onOptionClick]) + const assetMarketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, assetId), + ) + + const balanceUserCurrency = useMemo(() => { + return bnOrZero(cryptoBalance) + .times(assetMarketData?.price ?? 0) + .toFixed(2) + }, [cryptoBalance, assetMarketData?.price]) + + return ( + + + + + + + {translate('accounts.accountNumber', { accountNumber })} + + + + + + + + + + + + + + ) +} diff --git a/src/components/Modals/Send/Form.tsx b/src/components/Modals/Send/Form.tsx index 60c40a5e4a8..06290bd3789 100644 --- a/src/components/Modals/Send/Form.tsx +++ b/src/components/Modals/Send/Form.tsx @@ -12,9 +12,9 @@ import { useFormSend } from './hooks/useFormSend/useFormSend' import { SendFormFields, SendRoutes } from './SendCommon' import { maybeFetchChangeAddress } from './utils' import { Address } from './views/Address' -import { Amount } from './views/Amount' import { Confirm } from './views/Confirm' import { Details } from './views/Details' +import { SendAmount } from './views/SendAmount' import { Status } from './views/Status' import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ActionCenterContext' @@ -32,9 +32,9 @@ import { getMixPanel } from '@/lib/mixpanel/mixPanelSingleton' import { MixPanelEvent } from '@/lib/mixpanel/types' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import { - ActionStatus, - ActionType, - GenericTransactionDisplayType, + ActionStatus, + ActionType, + GenericTransactionDisplayType, } from '@/state/slices/actionSlice/types' import { preferences } from '@/state/slices/preferencesSlice/preferencesSlice' import { selectMarketDataByAssetIdUserCurrency } from '@/state/slices/selectors' @@ -43,7 +43,7 @@ import { store, useAppDispatch, useAppSelector } from '@/state/store' const status = const confirm = const details =
-const amount = +const sendAmount = const address =
export type SendInput = { @@ -309,7 +309,7 @@ export const Form: React.FC = ({ initialAssetId, input = '', acco {selectAssetRouter} {address} - {amount} + {sendAmount} {details} {qrCodeScanner} {confirm} diff --git a/src/components/Modals/Send/views/Address.tsx b/src/components/Modals/Send/views/Address.tsx index 6bad6e7b4cd..7ab9d830c61 100644 --- a/src/components/Modals/Send/views/Address.tsx +++ b/src/components/Modals/Send/views/Address.tsx @@ -1,11 +1,11 @@ import { Button, - Text as CText, Flex, FormControl, FormLabel, Icon, Stack, + Text as CText, useColorModeValue, VStack, } from '@chakra-ui/react' diff --git a/src/components/Modals/Send/views/Amount.tsx b/src/components/Modals/Send/views/SendAmount.tsx similarity index 75% rename from src/components/Modals/Send/views/Amount.tsx rename to src/components/Modals/Send/views/SendAmount.tsx index 7f94a5e7293..dc078f6ca60 100644 --- a/src/components/Modals/Send/views/Amount.tsx +++ b/src/components/Modals/Send/views/SendAmount.tsx @@ -1,20 +1,20 @@ import { - Box, - Button, - Flex, - FormControl, - FormLabel, - HStack, - Icon, - Input, - Skeleton, - Stack, - Text, - VStack, + Box, + Button, + Flex, + FormControl, + FormLabel, + HStack, + Icon, + Input, + Skeleton, + Stack, + Text, + VStack, } from '@chakra-ui/react' import { useCallback } from 'react' import { Controller, useFormContext, useWatch } from 'react-hook-form' -import { FaArrowDown } from 'react-icons/fa' +import { TbSwitchVertical } from 'react-icons/tb' import type { NumberFormatValues } from 'react-number-format' import NumberFormat from 'react-number-format' import { useTranslate } from 'react-polyglot' @@ -24,7 +24,8 @@ import type { SendInput } from '../Form' import { useSendDetails } from '../hooks/useSendDetails/useSendDetails' import { SendFormFields, SendRoutes } from '../SendCommon' -import { AccountDropdown } from '@/components/AccountDropdown/AccountDropdown' +import { AccountSelectorDialog } from '@/components/AccountSelectorDialog/AccountSelectorDialog' +import { Amount } from '@/components/Amount/Amount' import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import { DialogBackButton } from '@/components/Modal/components/DialogBackButton' import { DialogBody } from '@/components/Modal/components/DialogBody' @@ -34,14 +35,13 @@ import { DialogTitle } from '@/components/Modal/components/DialogTitle' import { SendMaxButton } from '@/components/Modals/Send/SendMaxButton/SendMaxButton' import { SlideTransition } from '@/components/SlideTransition' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' -import { useModal } from '@/hooks/useModal/useModal' import { bnOrZero } from '@/lib/bignumber/bignumber' import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' import { selectAssetById } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' const accountDropdownBoxProps = { px: 0, my: 0 } -const accountDropdownButtonProps = { px: 0 } +const accountDropdownButtonProps = { px: 2 } // Custom input component for the amount input const AmountInput = (props: any) => { @@ -62,7 +62,7 @@ const AmountInput = (props: any) => { ) } -export const Amount = () => { +export const SendAmount = () => { const { control, setValue } = useFormContext() const navigate = useNavigate() const translate = useTranslate() @@ -70,24 +70,14 @@ export const Amount = () => { number: { localeParts }, } = useLocaleFormatter() - const { accountId, assetId, to, amountCryptoPrecision, fiatAmount, fiatSymbol } = useWatch({ + const { accountId, assetId, to, amountCryptoPrecision, fiatAmount } = useWatch({ control, }) as Partial const asset = useAppSelector(state => selectAssetById(state, assetId ?? '')) - const send = useModal('send') - const qrCode = useModal('qrCode') - const { - balancesLoading, - fieldName, - cryptoHumanBalance, - fiatBalance, - handleSendMax, - handleInputChange, - isLoading, - toggleIsFiat, - } = useSendDetails() + const { balancesLoading, fieldName, handleSendMax, handleInputChange, isLoading, toggleIsFiat } = + useSendDetails() const handleNextClick = useCallback(() => navigate(SendRoutes.Details), [navigate]) const handleBackClick = useCallback(() => navigate(SendRoutes.Address), [navigate]) @@ -106,20 +96,7 @@ export const Amount = () => { const currentValue = fieldName === SendFormFields.FiatAmount ? fiatAmount : amountCryptoPrecision const isFiat = fieldName === SendFormFields.FiatAmount - const displayPlaceholder = isFiat ? `${fiatSymbol}0.00` : `0.00 ${asset?.symbol}` - - const handleToggleCurrency = useCallback(() => { - // Get the current input value - const currentInputValue = currentValue || '' - - // Toggle the field name first - toggleIsFiat() - - // Then trigger the input change to convert the value - if (currentInputValue) { - handleInputChange(currentInputValue) - } - }, [toggleIsFiat, currentValue, handleInputChange]) + const displayPlaceholder = isFiat ? `${localeParts.prefix}0.00` : `0.00 ${asset?.symbol}` const renderController = useCallback( ({ field: { onChange, value } }: { field: any }) => { @@ -134,6 +111,9 @@ export const Amount = () => { allowedDecimalSeparators={allowedDecimalSeparators} value={value} placeholder={displayPlaceholder} + prefix={isFiat ? localeParts.prefix : ''} + // this is already within a useCallback + // eslint-disable-next-line react-memo/require-usememo onValueChange={(values: NumberFormatValues) => { onChange(values.value) if (values.value !== value) handleInputChange(values.value) @@ -146,6 +126,7 @@ export const Amount = () => { isFiat, localeParts.decimal, localeParts.group, + localeParts.prefix, displayPlaceholder, handleInputChange, ], @@ -167,7 +148,7 @@ export const Amount = () => { { ) : ( - + {fieldName === SendFormFields.AmountCryptoPrecision && ( + + )} + {fieldName === SendFormFields.FiatAmount && ( + + )} - + - {isFiat - ? `${amountCryptoPrecision} ${asset.symbol}` - : `${fiatSymbol}${fiatAmount}`} + {isFiat ? ( + + ) : ( + + )} @@ -205,7 +201,16 @@ export const Amount = () => { - + @@ -213,22 +218,13 @@ export const Amount = () => { {translate('modals.send.sendForm.from')} - - {balancesLoading ? ( - - ) : ( - - {translate('modals.send.sendForm.availableBalance', { - balance: `${localeParts.prefix}${fiatBalance.toFixed(2)}`, - })} - - )} From 05fa17fecc8e5d8fa7a80a2bbfcd6fd4e0c4ee0c Mon Sep 17 00:00:00 2001 From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com> Date: Tue, 7 Oct 2025 11:44:02 +0200 Subject: [PATCH 03/36] fix: send details --- src/assets/translations/en/main.json | 2 + .../AccountSelectorDialog.tsx | 34 +- src/components/Modals/Send/Form.tsx | 3 - src/components/Modals/Send/SendCommon.tsx | 1 - src/components/Modals/Send/views/Confirm.tsx | 275 ++++++++++--- src/components/Modals/Send/views/Details.tsx | 370 ------------------ .../Modals/Send/views/SendAmount.tsx | 4 +- 7 files changed, 232 insertions(+), 457 deletions(-) delete mode 100644 src/components/Modals/Send/views/Details.tsx diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 8d4453546f8..349bf6be875 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -1427,6 +1427,7 @@ } }, "send": { + "confirmTransaction": "Confirm transaction to %{address}", "consolidate": { "body": "ShapeShift honors %{asset}'s security design by using a new address for each transaction. To align with THORChain's protocol, which operates with a single address, we'll consolidate your funds into a single address.", "tooltip": "Send funds to a single address for THORChain compatibility.", @@ -1470,6 +1471,7 @@ "memoExplainer": "Some %{assetSymbol} recipients may require a memo in order for the transaction to be processed correctly", "sendAsset": "Send %{asset}", "sendTo": "Send To", + "chain": "Chain", "sendAmount": "Send Amount", "max": "Max", "transactionFee": "Transaction Fee", diff --git a/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx b/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx index 88c17af48bf..9804b9030a3 100644 --- a/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx +++ b/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx @@ -1,13 +1,13 @@ import type { BoxProps, ButtonProps } from '@chakra-ui/react' import { - Box, - Button, - HStack, - Icon, - Text, - useDisclosure, - usePrevious, - VStack, + Box, + Button, + HStack, + Icon, + Text, + useDisclosure, + usePrevious, + VStack, } from '@chakra-ui/react' import type { AccountId, AssetId } from '@shapeshiftoss/caip' import { fromAccountId, fromAssetId } from '@shapeshiftoss/caip' @@ -31,9 +31,9 @@ import { DialogBody } from '@/components/Modal/components/DialogBody' import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' import { DialogFooter } from '@/components/Modal/components/DialogFooter' import { - DialogHeader, - DialogHeaderLeft, - DialogHeaderMiddle, + DialogHeader, + DialogHeaderLeft, + DialogHeaderMiddle, } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' @@ -43,12 +43,12 @@ import { isValidAccountNumber } from '@/lib/utils/accounts' import type { ReduxState } from '@/state/reducer' import { accountIdToLabel } from '@/state/slices/portfolioSlice/utils' import { - selectAssetById, - selectHighestUserCurrencyBalanceAccountByAssetId, - selectMarketDataByAssetIdUserCurrency, - selectPortfolioAccountBalancesBaseUnit, - selectPortfolioAccountIdsByAssetIdFilter, - selectPortfolioAccountMetadata, + selectAssetById, + selectHighestUserCurrencyBalanceAccountByAssetId, + selectMarketDataByAssetIdUserCurrency, + selectPortfolioAccountBalancesBaseUnit, + selectPortfolioAccountIdsByAssetIdFilter, + selectPortfolioAccountMetadata, } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' diff --git a/src/components/Modals/Send/Form.tsx b/src/components/Modals/Send/Form.tsx index 06290bd3789..c2a55b0bdc5 100644 --- a/src/components/Modals/Send/Form.tsx +++ b/src/components/Modals/Send/Form.tsx @@ -13,7 +13,6 @@ import { SendFormFields, SendRoutes } from './SendCommon' import { maybeFetchChangeAddress } from './utils' import { Address } from './views/Address' import { Confirm } from './views/Confirm' -import { Details } from './views/Details' import { SendAmount } from './views/SendAmount' import { Status } from './views/Status' @@ -42,7 +41,6 @@ import { store, useAppDispatch, useAppSelector } from '@/state/store' const status = const confirm = -const details =
const sendAmount = const address =
@@ -310,7 +308,6 @@ export const Form: React.FC = ({ initialAssetId, input = '', acco {selectAssetRouter} {address} {sendAmount} - {details} {qrCodeScanner} {confirm} {status} diff --git a/src/components/Modals/Send/SendCommon.tsx b/src/components/Modals/Send/SendCommon.tsx index fb70fc08bb0..85b84da4029 100644 --- a/src/components/Modals/Send/SendCommon.tsx +++ b/src/components/Modals/Send/SendCommon.tsx @@ -1,6 +1,5 @@ export enum SendRoutes { Amount = '/send/amount', - Details = '/send/details', Confirm = '/send/confirm', Status = '/send/status', Scan = '/send/scan', diff --git a/src/components/Modals/Send/views/Confirm.tsx b/src/components/Modals/Send/views/Confirm.tsx index d2deba30145..90ead9cf9d3 100644 --- a/src/components/Modals/Send/views/Confirm.tsx +++ b/src/components/Modals/Send/views/Confirm.tsx @@ -2,9 +2,9 @@ import { ExternalLinkIcon } from '@chakra-ui/icons' import { Box, Button, + Text as CText, + Divider, Flex, - FormControl, - FormLabel, HStack, Icon, Input, @@ -13,6 +13,7 @@ import { Stack, useColorModeValue, } from '@chakra-ui/react' +import { keyframes } from '@emotion/react' import { CHAIN_NAMESPACE, fromAccountId, fromAssetId } from '@shapeshiftoss/caip' import type { FeeDataKey } from '@shapeshiftoss/chain-adapters' import { isLedger } from '@shapeshiftoss/hdwallet-ledger' @@ -26,17 +27,18 @@ import { useNavigate } from 'react-router-dom' import type { SendInput } from '../Form' import { useSendFees } from '../hooks/useSendFees/useSendFees' import { SendFormFields, SendRoutes } from '../SendCommon' -import { TxFeeRadioGroup } from '../TxFeeRadioGroup' import { AccountDropdown } from '@/components/AccountDropdown/AccountDropdown' import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' import { InlineCopyButton } from '@/components/InlineCopyButton' import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import { DialogBackButton } from '@/components/Modal/components/DialogBackButton' import { DialogBody } from '@/components/Modal/components/DialogBody' +import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' import { DialogFooter } from '@/components/Modal/components/DialogFooter' -import { DialogHeader } from '@/components/Modal/components/DialogHeader' +import { DialogHeader, DialogHeaderRight } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' import { Row } from '@/components/Row/Row' import { SlideTransition } from '@/components/SlideTransition' @@ -44,12 +46,14 @@ import { RawText, Text } from '@/components/Text' import type { TextPropTypes } from '@/components/Text/Text' import { TooltipWithTouch } from '@/components/TooltipWithTouch' import { getConfig } from '@/config' +import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' import { useWallet } from '@/hooks/useWallet/useWallet' -import { bnOrZero } from '@/lib/bignumber/bignumber' import { middleEllipsis } from '@/lib/utils' import { isUtxoAccountId } from '@/lib/utils/utxo' +import { ProfileAvatar } from '@/pages/Dashboard/components/ProfileAvatar/ProfileAvatar' import { selectAssetById, selectFeeAssetById } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' +import { GasSelectionMenu } from '@/plugins/walletConnectToDapps/components/WalletConnectSigningModal/GasSelectionMenu' export type FeePrice = { [key in FeeDataKey]: { @@ -61,6 +65,74 @@ export type FeePrice = { const accountDropdownButtonProps = { variant: 'ghost', height: 'auto', p: 0, size: 'md' } +// Infinite scroll animation that moves dots down continuously +const infiniteScroll = keyframes` + 0% { + transform: translateY(10px); + } + 100% { + transform: translateY(90px); + } +` + +const AnimatedDots = () => { + const shadowColor = useColorModeValue('#f8fafc', '#1e2024') + const dotPositions = [-75, -55, -35, -15, 5, 25, 45, 65] + + return ( + + + + + {dotPositions.map((topPosition, index) => { + return ( + + ) + })} + + ) +} + export const Confirm = () => { const { control, @@ -84,6 +156,7 @@ export const Confirm = () => { }) as Partial const { fees } = useSendFees() const allowCustomSendNonce = getConfig().VITE_EXPERIMENTAL_CUSTOM_SEND_NONCE + const toBg = useColorModeValue('blackAlpha.300', 'whiteAlpha.300') const feeAsset = useAppSelector(state => selectFeeAssetById(state, assetId ?? '')) const asset = useAppSelector(state => selectAssetById(state, assetId ?? '')) @@ -107,10 +180,10 @@ export const Confirm = () => { [assetId, wallet], ) - const amountWithFees = useMemo(() => { + const feeAmountUserCurrency = useMemo(() => { const { fiatFee } = fees ? fees[feeType as FeeDataKey] : { fiatFee: 0 } - return bnOrZero(fiatAmount).plus(fiatFee).toString() - }, [fiatAmount, fees, feeType]) + return fiatFee + }, [fees, feeType]) const cryptoAmountFee = useMemo(() => { const { txFee } = fees ? fees[feeType as FeeDataKey] : { txFee: 0 } @@ -127,7 +200,7 @@ export const Confirm = () => { [setValue], ) - const handleClick = useCallback(() => navigate(SendRoutes.Details), [navigate]) + const handleBack = useCallback(() => navigate(SendRoutes.Amount), [navigate]) // We don't want this firing -- but need it for typing const handleAccountChange = useCallback(() => {}, []) @@ -142,32 +215,127 @@ export const Confirm = () => { [asset], ) + const confirmTransactionTranslation: TextPropTypes['translation'] = useMemo( + () => ['modals.send.confirmTransaction', { address: middleEllipsis(to ?? '') }], + [to], + ) + + const chainName = useMemo(() => { + const chainAdapterManager = getChainAdapterManager() + const chainName = chainAdapterManager.get(asset?.chainId ?? '')?.getDisplayName() + return chainName + }, [asset?.chainId]) + if (!(to && asset?.name && amountCryptoPrecision && fiatAmount && feeType)) return null return ( - + + + + - - - + + + + + + + + + + + {vanityAddress ? vanityAddress : } + + + + + + + + + + + + {/* @TODO: Use custom receive address avatar */} + + + + + + + + + + {chainName} + + + - + { - - - - - - - {vanityAddress ? vanityAddress : } - - - {shouldShowChangeAddress && ( - + @@ -252,7 +410,7 @@ export const Confirm = () => { )} - + {/* @@ -261,41 +419,30 @@ export const Confirm = () => { - + */} - - - - - - - - - + - - - - - - - - - - - + + + + + + () + + - + + - ), - [asset?.symbol, toggleIsFiat], - ) - - const fiatTokenRowInputLeftElement = useMemo( - () => ( - - ), - [fiatSymbol, toggleIsFiat], - ) - - const tokenRowInputRightElement = useMemo( - () => - wallet?.getVendor() === 'WalletConnect' ? null : , - [wallet, handleSendMax], - ) - - const assetMemoTranslation: TextPropTypes['translation'] = useMemo( - () => ['modals.send.sendForm.assetMemo', { assetSymbol: asset?.symbol ?? '' }], - [asset?.symbol], - ) - - const handleClose = useCallback(() => { - // Sends may be done from the context of a QR code modal, or a send modal, which are similar, but effectively diff. modal refs - send.close?.() - qrCode.close?.() - }, [send, qrCode]) - - const handleArrowBackClick = useCallback(() => navigate(SendRoutes.Address), [navigate]) - const handleAccountCardClick = useCallback(() => navigate('/send/select'), [navigate]) - - const renderController: RenderController = useCallback( - ({ field: { onChange, value } }) => ( - onChange(value)} - value={value} - type='text' - variant='filled' - placeholder={translate('modals.send.sendForm.optionalAssetMemo', { - assetSymbol: asset?.symbol ?? '', - })} - /> - ), - [asset?.symbol, translate], - ) - - if (!(assetId && asset && !isNil(amountCryptoPrecision) && !isNil(fiatAmount) && fiatSymbol)) { - return null - } - - return ( - - - - - {translate('modals.send.sendForm.sendAsset', { asset: asset.name })} - - - - - - - - - - - - - {translate('modals.send.sendForm.sendAmount')} - - - {fieldName === SendFormFields.FiatAmount ? ( - - ) : ( - - {fiatSymbol} - - )} - - - {fieldName === SendFormFields.AmountCryptoPrecision && ( - - )} - {fieldName === SendFormFields.FiatAmount && ( - - )} - - {showMemoField && ( - - - - - - - - - - - - {translate('modals.send.sendForm.charactersRemaining', { - charactersRemaining: remainingMemoChars.toString(), - })} - - - - - )} - - - - {amountFieldError && ( - - - - - )} - - - - - - ) -} diff --git a/src/components/Modals/Send/views/SendAmount.tsx b/src/components/Modals/Send/views/SendAmount.tsx index dc078f6ca60..cbaa581179e 100644 --- a/src/components/Modals/Send/views/SendAmount.tsx +++ b/src/components/Modals/Send/views/SendAmount.tsx @@ -50,7 +50,7 @@ const AmountInput = (props: any) => { size='lg' fontSize='65px' lineHeight='65px' - fontWeight='bold' + fontWeight='normal' textAlign='center' border='none' borderRadius='lg' @@ -79,7 +79,7 @@ export const SendAmount = () => { const { balancesLoading, fieldName, handleSendMax, handleInputChange, isLoading, toggleIsFiat } = useSendDetails() - const handleNextClick = useCallback(() => navigate(SendRoutes.Details), [navigate]) + const handleNextClick = useCallback(() => navigate(SendRoutes.Confirm), [navigate]) const handleBackClick = useCallback(() => navigate(SendRoutes.Address), [navigate]) const handleMaxClick = useCallback(async () => { From 6a44104fbb8ce0d6ffd2a5b884df300cb50d234e Mon Sep 17 00:00:00 2001 From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:51:36 +0200 Subject: [PATCH 04/36] fix: finish betweek quote --- src/assets/translations/en/main.json | 3 +- .../AccountSelectorDialog.tsx | 2 +- src/components/GasSelection/GasSelection.tsx | 146 ++++++++++++++ src/components/GasSelection/index.ts | 2 + src/components/Modals/QrCode/Form.tsx | 13 +- src/components/Modals/Send/Form.tsx | 6 +- .../Modals/Send/components/AnimatedDots.tsx | 78 ++++++++ .../Send/components/SendGasSelection.tsx | 56 ++++++ src/components/Modals/Send/views/Address.tsx | 5 +- src/components/Modals/Send/views/Confirm.tsx | 182 ++++++++---------- .../GasSelectionMenu.tsx | 121 ++---------- 11 files changed, 391 insertions(+), 223 deletions(-) create mode 100644 src/components/GasSelection/GasSelection.tsx create mode 100644 src/components/GasSelection/index.ts create mode 100644 src/components/Modals/Send/components/AnimatedDots.tsx create mode 100644 src/components/Modals/Send/components/SendGasSelection.tsx diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 349bf6be875..7ca04fd99ee 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -1462,7 +1462,8 @@ "amount": "Amount", "transactionFee": "Transaction Fee", "fees": "Fees %{feeType}", - "pendingConfirmation": "Pending confirmation" + "pendingConfirmation": "Pending confirmation", + "holdToSend": "Hold to send" }, "sendForm": { "optionalAssetMemo": "Optional %{assetSymbol} Memo", diff --git a/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx b/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx index 9804b9030a3..886e4670ac1 100644 --- a/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx +++ b/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx @@ -148,7 +148,7 @@ const AccountSelectionDialog = ({ {translate('accountSelector.chooseAccount')} - + {Object.entries(accountIdsByNumberAndType).map(([accountNumber, accountIds]) => { const sortedAccountIds = autoSelectHighestBalance diff --git a/src/components/GasSelection/GasSelection.tsx b/src/components/GasSelection/GasSelection.tsx new file mode 100644 index 00000000000..ed5e1826b28 --- /dev/null +++ b/src/components/GasSelection/GasSelection.tsx @@ -0,0 +1,146 @@ +import { ChevronDownIcon } from '@chakra-ui/icons' +import { + Box, + Button, + HStack, + Menu, + MenuButton, + MenuItem, + MenuList, + Skeleton, + VStack, +} from '@chakra-ui/react' +import { FeeDataKey } from '@shapeshiftoss/chain-adapters' +import type { FC } from 'react' +import { useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' + +import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { RawText } from '@/components/Text' + +export const SPEED_OPTIONS = [ + { value: FeeDataKey.Slow, emoji: '🐌', text: 'Slow' }, + { value: FeeDataKey.Average, emoji: '🟡', text: 'Average' }, + { value: FeeDataKey.Fast, emoji: '⚡', text: 'Fast' }, +] + +const tooltipIconSx = { boxSize: '12px', color: 'text.subtle' } +const chevronIcon = + +export type GasSelectionProps = { + selectedSpeed: FeeDataKey + onSpeedChange: (speed: FeeDataKey) => void + feeAmount: string + feeSymbol: string + fiatFee: string + isLoading?: boolean + showSimulationTooltip?: boolean +} + +export const GasSelection: FC = ({ + selectedSpeed, + onSpeedChange, + feeAmount, + feeSymbol, + fiatFee, + isLoading = false, + showSimulationTooltip = false, +}) => { + const translate = useTranslate() + + const handleSpeedChange = useCallback( + (newSpeed: FeeDataKey) => { + onSpeedChange(newSpeed) + }, + [onSpeedChange], + ) + + const currentSpeedOption = useMemo( + () => SPEED_OPTIONS.find(option => option.value === selectedSpeed), + [selectedSpeed], + ) + + const createMenuItemClickHandler = useCallback( + (speed: FeeDataKey) => () => handleSpeedChange(speed), + [handleSpeedChange], + ) + + if (isLoading) { + return ( + + + + + + + {translate('common.feeEstimate')} + + + + + + ) + } + + return ( + + + + {feeAmount} {feeSymbol} (${fiatFee}) + + + + + {translate('common.feeEstimate')} + + + + + + + + {currentSpeedOption?.emoji} + {currentSpeedOption?.text} + + + + {SPEED_OPTIONS.map(option => ( + + + {option.emoji} + {option.text} + + + ))} + + + + ) +} diff --git a/src/components/GasSelection/index.ts b/src/components/GasSelection/index.ts new file mode 100644 index 00000000000..cfbb3f70c6b --- /dev/null +++ b/src/components/GasSelection/index.ts @@ -0,0 +1,2 @@ +export { GasSelection, SPEED_OPTIONS } from './GasSelection' +export type { GasSelectionProps } from './GasSelection' diff --git a/src/components/Modals/QrCode/Form.tsx b/src/components/Modals/QrCode/Form.tsx index 453b2b7d2c6..259d3997b1f 100644 --- a/src/components/Modals/QrCode/Form.tsx +++ b/src/components/Modals/QrCode/Form.tsx @@ -12,9 +12,9 @@ import { useFormSend } from '../Send/hooks/useFormSend/useFormSend' import { SendFormFields, SendRoutes } from '../Send/SendCommon' import { Address } from '../Send/views/Address' import { Confirm } from '../Send/views/Confirm' -import { Details } from '../Send/views/Details' import { Status } from '../Send/views/Status' +import { SendAmount } from '@/components/Modals/Send/views/SendAmount' import { QrCodeScanner } from '@/components/QrCodeScanner/QrCodeScanner' import { SelectAssetRouter } from '@/components/SelectAssets/SelectAssetRouter' import { useModal } from '@/hooks/useModal/useModal' @@ -176,7 +176,7 @@ export const Form: React.FC = ({ accountId }) => { if (isAmbiguousTransfer) { return navigate(SendRoutes.Select) } - return navigate(SendRoutes.Details) + return navigate(SendRoutes.Amount) } catch (e: any) { setAddressError(e.message) } @@ -191,14 +191,17 @@ export const Form: React.FC = ({ accountId }) => { ) const addressElement = useMemo(() =>
, []) - const detailsElement = useMemo(() =>
, []) + const detailsElement = useMemo(() => , []) const qrCodeScannerElement = useMemo( () => ( ), [handleClose, handleQrSuccess, addressError], ) - const confirmElement = useMemo(() => , []) + const confirmElement = useMemo( + () => , + [methods, handleSubmit], + ) const statusElement = useMemo(() => , []) if (walletConnectDappUrl) @@ -216,7 +219,7 @@ export const Form: React.FC = ({ accountId }) => { - + diff --git a/src/components/Modals/Send/Form.tsx b/src/components/Modals/Send/Form.tsx index c2a55b0bdc5..fb4342a1d21 100644 --- a/src/components/Modals/Send/Form.tsx +++ b/src/components/Modals/Send/Form.tsx @@ -40,7 +40,6 @@ import { selectMarketDataByAssetIdUserCurrency } from '@/state/slices/selectors' import { store, useAppDispatch, useAppSelector } from '@/state/store' const status = -const confirm = const sendAmount = const address =
@@ -291,6 +290,11 @@ export const Form: React.FC = ({ initialAssetId, input = '', acco const location = useLocation() + const confirm = useMemo( + () => , + [handleSubmit, methods], + ) + return ( {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} diff --git a/src/components/Modals/Send/components/AnimatedDots.tsx b/src/components/Modals/Send/components/AnimatedDots.tsx new file mode 100644 index 00000000000..f270803945d --- /dev/null +++ b/src/components/Modals/Send/components/AnimatedDots.tsx @@ -0,0 +1,78 @@ +import { Box, useColorModeValue, useMediaQuery } from '@chakra-ui/react' +import { keyframes } from '@emotion/react' + +import { breakpoints } from '@/theme/theme' + +// Infinite scroll animation that moves dots down continuously +const infiniteScroll = keyframes` + 0% { + transform: translateY(10px); + } + 100% { + transform: translateY(90px); + } +` + +export const AnimatedDots = () => { + const shadowColor = useColorModeValue('#f8fafc', '#1e2024') + const desktopShadowColor = useColorModeValue('#f8fafc', '#323232') + const dotPositions = [-75, -55, -35, -15, 5, 25, 45, 65] + const [isUnderMd] = useMediaQuery(`(max-width: ${breakpoints.md})`, { ssr: false }) + + return ( + + + + + {dotPositions.map((topPosition, index) => { + return ( + + ) + })} + + ) +} diff --git a/src/components/Modals/Send/components/SendGasSelection.tsx b/src/components/Modals/Send/components/SendGasSelection.tsx new file mode 100644 index 00000000000..6def517c3c7 --- /dev/null +++ b/src/components/Modals/Send/components/SendGasSelection.tsx @@ -0,0 +1,56 @@ +import type { FeeDataKey } from '@shapeshiftoss/chain-adapters' +import type { FC } from 'react' +import { useCallback, useMemo } from 'react' +import { useFormContext, useWatch } from 'react-hook-form' + +import type { SendInput } from '../Form' + +import { GasSelection } from '@/components/GasSelection' +import { useSendFees } from '@/components/Modals/Send/hooks/useSendFees/useSendFees' +import { SendFormFields } from '@/components/Modals/Send/SendCommon' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { selectFeeAssetById } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +export const SendGasSelection: FC = () => { + const { setValue, control } = useFormContext() + const { fees } = useSendFees() + const { feeType, assetId } = useWatch({ + control, + }) as Partial + + const feeAsset = useAppSelector(state => selectFeeAssetById(state, assetId ?? '')) + + const feeAmountUserCurrency = useMemo(() => { + const { fiatFee } = fees ? fees[feeType as FeeDataKey] : { fiatFee: 0 } + return bnOrZero(fiatFee).toFixed(2) + }, [fees, feeType]) + + const cryptoAmountFee = useMemo(() => { + const { txFee } = fees ? fees[feeType as FeeDataKey] : { txFee: 0 } + return bnOrZero(txFee).toFixed(8) + }, [fees, feeType]) + + const selectedSpeed = feeType as FeeDataKey + + const handleSpeedChange = useCallback( + (newSpeed: FeeDataKey) => { + setValue(SendFormFields.FeeType, newSpeed) + }, + [setValue], + ) + + const feeSymbol = feeAsset?.symbol ?? 'ETH' + + return ( + + ) +} diff --git a/src/components/Modals/Send/views/Address.tsx b/src/components/Modals/Send/views/Address.tsx index 7ab9d830c61..bf4e0907bf0 100644 --- a/src/components/Modals/Send/views/Address.tsx +++ b/src/components/Modals/Send/views/Address.tsx @@ -1,11 +1,11 @@ import { Button, + Text as CText, Flex, FormControl, FormLabel, Icon, Stack, - Text as CText, useColorModeValue, VStack, } from '@chakra-ui/react' @@ -175,7 +175,8 @@ export const Address = () => { justifyContent='flex-start' height='auto' background='transparent' - px={0} + m={-2} + p={2} > diff --git a/src/components/Modals/Send/views/Confirm.tsx b/src/components/Modals/Send/views/Confirm.tsx index 90ead9cf9d3..37b3d32c00c 100644 --- a/src/components/Modals/Send/views/Confirm.tsx +++ b/src/components/Modals/Send/views/Confirm.tsx @@ -12,18 +12,20 @@ import { Skeleton, Stack, useColorModeValue, + useMediaQuery, } from '@chakra-ui/react' -import { keyframes } from '@emotion/react' import { CHAIN_NAMESPACE, fromAccountId, fromAssetId } from '@shapeshiftoss/caip' import type { FeeDataKey } from '@shapeshiftoss/chain-adapters' import { isLedger } from '@shapeshiftoss/hdwallet-ledger' import type { ChangeEvent } from 'react' -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useFormContext, useWatch } from 'react-hook-form' import { TbArrowsSplit2 } from 'react-icons/tb' import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' +import { useLongPress } from 'use-long-press' +import { SendGasSelection } from '../components/SendGasSelection' import type { SendInput } from '../Form' import { useSendFees } from '../hooks/useSendFees/useSendFees' import { SendFormFields, SendRoutes } from '../SendCommon' @@ -40,20 +42,23 @@ import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButt import { DialogFooter } from '@/components/Modal/components/DialogFooter' import { DialogHeader, DialogHeaderRight } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' +import { AnimatedDots } from '@/components/Modals/Send/components/AnimatedDots' import { Row } from '@/components/Row/Row' import { SlideTransition } from '@/components/SlideTransition' import { RawText, Text } from '@/components/Text' import type { TextPropTypes } from '@/components/Text/Text' import { TooltipWithTouch } from '@/components/TooltipWithTouch' import { getConfig } from '@/config' +import { defaultLongPressConfig } from '@/constants/longPress' import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' import { useWallet } from '@/hooks/useWallet/useWallet' import { middleEllipsis } from '@/lib/utils' import { isUtxoAccountId } from '@/lib/utils/utxo' +import { vibrate } from '@/lib/vibrate' import { ProfileAvatar } from '@/pages/Dashboard/components/ProfileAvatar/ProfileAvatar' -import { selectAssetById, selectFeeAssetById } from '@/state/slices/selectors' +import { selectAssetById } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -import { GasSelectionMenu } from '@/plugins/walletConnectToDapps/components/WalletConnectSigningModal/GasSelectionMenu' +import { breakpoints } from '@/theme/theme' export type FeePrice = { [key in FeeDataKey]: { @@ -65,75 +70,13 @@ export type FeePrice = { const accountDropdownButtonProps = { variant: 'ghost', height: 'auto', p: 0, size: 'md' } -// Infinite scroll animation that moves dots down continuously -const infiniteScroll = keyframes` - 0% { - transform: translateY(10px); - } - 100% { - transform: translateY(90px); - } -` - -const AnimatedDots = () => { - const shadowColor = useColorModeValue('#f8fafc', '#1e2024') - const dotPositions = [-75, -55, -35, -15, 5, 25, 45, 65] - - return ( - - +const LONG_PRESS_SECONDS_THRESHOLD = 2000 - - {dotPositions.map((topPosition, index) => { - return ( - - ) - })} - - ) +export type ConfirmProps = { + handleSubmit: () => void } -export const Confirm = () => { +export const Confirm = ({ handleSubmit }: ConfirmProps) => { const { control, formState: { isSubmitting }, @@ -157,8 +100,8 @@ export const Confirm = () => { const { fees } = useSendFees() const allowCustomSendNonce = getConfig().VITE_EXPERIMENTAL_CUSTOM_SEND_NONCE const toBg = useColorModeValue('blackAlpha.300', 'whiteAlpha.300') + const [isSmallerThanMd] = useMediaQuery(`(max-width: ${breakpoints.md})`, { ssr: false }) - const feeAsset = useAppSelector(state => selectFeeAssetById(state, assetId ?? '')) const asset = useAppSelector(state => selectAssetById(state, assetId ?? '')) const { state: { wallet }, @@ -180,16 +123,6 @@ export const Confirm = () => { [assetId, wallet], ) - const feeAmountUserCurrency = useMemo(() => { - const { fiatFee } = fees ? fees[feeType as FeeDataKey] : { fiatFee: 0 } - return fiatFee - }, [fees, feeType]) - - const cryptoAmountFee = useMemo(() => { - const { txFee } = fees ? fees[feeType as FeeDataKey] : { txFee: 0 } - return txFee.toString() - }, [fees, feeType]) - const borderColor = useColorModeValue('gray.100', 'gray.750') const handleNonceChange = useCallback( @@ -226,6 +159,48 @@ export const Confirm = () => { return chainName }, [asset?.chainId]) + const [isLongPressing, setIsLongPressing] = useState(false) + const [progress, setProgress] = useState(0) + + useEffect(() => { + if (!isLongPressing) return + + const startTime = Date.now() + + const updateProgress = () => { + const elapsed = Date.now() - startTime + const progressPercent = Math.min((elapsed / LONG_PRESS_SECONDS_THRESHOLD) * 100, 100) + setProgress(progressPercent) + + if (progressPercent < 100) { + requestAnimationFrame(updateProgress) + } + } + + requestAnimationFrame(updateProgress) + }, [isLongPressing]) + + const longPressHandlers = useLongPress(() => {}, { + ...defaultLongPressConfig, + threshold: LONG_PRESS_SECONDS_THRESHOLD, + cancelOnMovement: 25, + onFinish: () => { + vibrate('heavy') + setIsLongPressing(false) + setProgress(0) + handleSubmit() + }, + onCancel: () => { + setIsLongPressing(false) + setProgress(0) + }, + onStart: () => { + vibrate('light') + setIsLongPressing(true) + setProgress(0) + }, + }) + if (!(to && asset?.name && amountCryptoPrecision && fiatAmount && feeType)) return null return ( @@ -410,16 +385,6 @@ export const Confirm = () => { )} - {/* - - - - {translate('modals.send.sendForm.transactionFee')} - - - - - */} { borderLeftColor='border.base' borderTopRadius='20' > - - - - - () - - - - - + diff --git a/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/GasSelectionMenu.tsx b/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/GasSelectionMenu.tsx index b89033d0e47..9a71b0887e9 100644 --- a/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/GasSelectionMenu.tsx +++ b/src/plugins/walletConnectToDapps/components/WalletConnectSigningModal/GasSelectionMenu.tsx @@ -1,24 +1,10 @@ -import { ChevronDownIcon } from '@chakra-ui/icons' -import { - Box, - Button, - HStack, - Menu, - MenuButton, - MenuItem, - MenuList, - Skeleton, - VStack, -} from '@chakra-ui/react' import type { ChainId } from '@shapeshiftoss/caip' import { bnOrZero, FeeDataKey } from '@shapeshiftoss/chain-adapters' import type { FC } from 'react' -import { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useEffect } from 'react' import { useFormContext, useWatch } from 'react-hook-form' -import { useTranslate } from 'react-polyglot' -import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' -import { RawText } from '@/components/Text' +import { GasSelection } from '@/components/GasSelection' import { useSimulateEvmTransaction } from '@/plugins/walletConnectToDapps/hooks/useSimulateEvmTransaction' import type { CustomTransactionData, TransactionParams } from '@/plugins/walletConnectToDapps/types' @@ -27,17 +13,7 @@ type GasSelectionMenuProps = { chainId: ChainId } -const SPEED_OPTIONS = [ - { value: FeeDataKey.Slow, emoji: '🐌', text: 'Slow' }, - { value: FeeDataKey.Average, emoji: '🟡', text: 'Average' }, - { value: FeeDataKey.Fast, emoji: '⚡', text: 'Fast' }, -] - -const tooltipIconSx = { boxSize: '12px', color: 'text.subtle' } -const chevronIcon = - export const GasSelectionMenu: FC = ({ transaction, chainId }) => { - const translate = useTranslate() const { setValue } = useFormContext() const { speed } = useWatch() @@ -68,92 +44,17 @@ export const GasSelectionMenu: FC = ({ transaction, chain [setValue], ) - const currentSpeedOption = useMemo( - () => SPEED_OPTIONS.find(option => option.value === selectedSpeed), - [selectedSpeed], - ) - - const createMenuItemClickHandler = useCallback( - (speed: FeeDataKey) => () => handleSpeedChange(speed), - [handleSpeedChange], - ) - if (!fee?.feeAsset) return null - if (gasFeeDataQuery.isLoading || simulationQuery.isLoading) { - return ( - - - - - - - {translate('common.feeEstimate')} - - - - - - ) - } - - if (!fee) return null - return ( - - - - {fee.txFeeCryptoPrecision} {fee.feeAsset.symbol} (${fee.fiatFee}) - - - - - {translate('common.feeEstimate')} - - - - - - - - {currentSpeedOption?.emoji} - {currentSpeedOption?.text} - - - - {SPEED_OPTIONS.map(option => ( - - - {option.emoji} - {option.text} - - - ))} - - - + ) } From 5a59b8154e4212d143956282f5a309720e05321a Mon Sep 17 00:00:00 2001 From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:50:18 +0200 Subject: [PATCH 05/36] fix: hnnng desktop hnnng --- .../AccountSelectorDialog.tsx | 34 +- .../Modals/Send/AddressInput/AddressInput.tsx | 61 ++- src/components/Modals/Send/Form.tsx | 15 +- .../components/AddressInputWithDropdown.tsx | 102 +++++ .../Modals/Send/components/QRCodeIcon.tsx | 31 ++ src/components/Modals/Send/views/Address.tsx | 6 +- src/components/Modals/Send/views/Confirm.tsx | 2 +- .../Modals/Send/views/SendAmount.tsx | 401 +++++++++++++++--- 8 files changed, 535 insertions(+), 117 deletions(-) create mode 100644 src/components/Modals/Send/components/AddressInputWithDropdown.tsx create mode 100644 src/components/Modals/Send/components/QRCodeIcon.tsx diff --git a/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx b/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx index 886e4670ac1..a5f97b7afac 100644 --- a/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx +++ b/src/components/AccountSelectorDialog/AccountSelectorDialog.tsx @@ -1,13 +1,13 @@ import type { BoxProps, ButtonProps } from '@chakra-ui/react' import { - Box, - Button, - HStack, - Icon, - Text, - useDisclosure, - usePrevious, - VStack, + Box, + Button, + HStack, + Icon, + Text, + useDisclosure, + usePrevious, + VStack, } from '@chakra-ui/react' import type { AccountId, AssetId } from '@shapeshiftoss/caip' import { fromAccountId, fromAssetId } from '@shapeshiftoss/caip' @@ -31,9 +31,9 @@ import { DialogBody } from '@/components/Modal/components/DialogBody' import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' import { DialogFooter } from '@/components/Modal/components/DialogFooter' import { - DialogHeader, - DialogHeaderLeft, - DialogHeaderMiddle, + DialogHeader, + DialogHeaderLeft, + DialogHeaderMiddle, } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' @@ -43,12 +43,12 @@ import { isValidAccountNumber } from '@/lib/utils/accounts' import type { ReduxState } from '@/state/reducer' import { accountIdToLabel } from '@/state/slices/portfolioSlice/utils' import { - selectAssetById, - selectHighestUserCurrencyBalanceAccountByAssetId, - selectMarketDataByAssetIdUserCurrency, - selectPortfolioAccountBalancesBaseUnit, - selectPortfolioAccountIdsByAssetIdFilter, - selectPortfolioAccountMetadata, + selectAssetById, + selectHighestUserCurrencyBalanceAccountByAssetId, + selectMarketDataByAssetIdUserCurrency, + selectPortfolioAccountBalancesBaseUnit, + selectPortfolioAccountIdsByAssetIdFilter, + selectPortfolioAccountMetadata, } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' diff --git a/src/components/Modals/Send/AddressInput/AddressInput.tsx b/src/components/Modals/Send/AddressInput/AddressInput.tsx index 400cf4fa1a2..c9c3e63df50 100644 --- a/src/components/Modals/Send/AddressInput/AddressInput.tsx +++ b/src/components/Modals/Send/AddressInput/AddressInput.tsx @@ -1,5 +1,12 @@ -import type { SpaceProps } from '@chakra-ui/react' -import { IconButton, InputGroup, InputRightElement, Textarea } from '@chakra-ui/react' +import type { InputProps, SpaceProps } from '@chakra-ui/react' +import { + IconButton, + Input, + InputGroup, + InputLeftElement, + InputRightElement, + Text, +} from '@chakra-ui/react' import { useCallback, useMemo } from 'react' import type { ControllerProps, ControllerRenderProps, FieldValues } from 'react-hook-form' import { Controller, useFormContext, useWatch } from 'react-hook-form' @@ -17,7 +24,7 @@ type AddressInputProps = { enableQr?: boolean placeholder?: string pe?: SpaceProps['pe'] -} +} & InputProps const qrCodeIcon = @@ -26,6 +33,7 @@ export const AddressInput = ({ placeholder, enableQr = false, pe = 10, + ...props }: AddressInputProps) => { const navigate = useNavigate() const translate = useTranslate() @@ -53,26 +61,35 @@ export const AddressInput = ({ }: { field: ControllerRenderProps }) => ( -