From 8038fd7d08b44fa470eeec962bc69dcdaa0f638b Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Thu, 9 Jan 2025 11:33:46 +0200 Subject: [PATCH 01/13] add new review component and apply it to SendConfirmation --- locales/base/translation.json | 3 +- src/components/ContactCircle.tsx | 16 +- src/components/Review.tsx | 257 ++++++++++++++++++++++++++ src/send/SendConfirmation.tsx | 306 ++++++++++++------------------- src/tokens/hooks.ts | 38 +++- 5 files changed, 423 insertions(+), 197 deletions(-) create mode 100644 src/components/Review.tsx diff --git a/locales/base/translation.json b/locales/base/translation.json index 1ed632fa50e..9f22e7b44f6 100644 --- a/locales/base/translation.json +++ b/locales/base/translation.json @@ -2831,5 +2831,6 @@ "selectToken": "Select token", "fiatPriceUnavailable": "Price unavailable", "tokenDescription": "{{tokenName}} on {{tokenNetwork}}" - } + }, + "totalPlusFees": "Total + Fees" } diff --git a/src/components/ContactCircle.tsx b/src/components/ContactCircle.tsx index 898c9d6ea6a..b0378dc5919 100644 --- a/src/components/ContactCircle.tsx +++ b/src/components/ContactCircle.tsx @@ -12,7 +12,11 @@ interface Props { backgroundColor?: Colors foregroundColor?: Colors borderColor?: Colors - DefaultIcon?: React.ComponentType<{ foregroundColor?: Colors; backgroundColor?: Colors }> + DefaultIcon?: React.ComponentType<{ + color?: Colors + backgroundColor?: Colors + size?: number + }> } const DEFAULT_ICON_SIZE = 40 @@ -30,7 +34,7 @@ function ContactCircle({ backgroundColor, foregroundColor, borderColor, - DefaultIcon = ({ foregroundColor }) => , + DefaultIcon = ({ color, size = 20 }) => , }: Props) { const address = recipient.address const iconBackgroundColor = backgroundColor ?? getAddressBackgroundColor(address || '0x0') @@ -65,7 +69,13 @@ function ContactCircle({ ) } - return + return ( + + ) } return ( diff --git a/src/components/Review.tsx b/src/components/Review.tsx new file mode 100644 index 00000000000..39a0cdeaff3 --- /dev/null +++ b/src/components/Review.tsx @@ -0,0 +1,257 @@ +import React, { useMemo, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { ScrollView, StyleSheet, Text, View } from 'react-native' +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import SkeletonPlaceholder from 'react-native-skeleton-placeholder' +import BackButton from 'src/components/BackButton' +import ContactCircle from 'src/components/ContactCircle' +import CustomHeader from 'src/components/header/CustomHeader' +import WalletIcon from 'src/icons/navigator/Wallet' +import PhoneIcon from 'src/icons/Phone' +import UserIcon from 'src/icons/User' +import { type Recipient } from 'src/recipients/recipient' +import Colors from 'src/styles/colors' +import { typeScale } from 'src/styles/fonts' +import { Spacing } from 'src/styles/styles' +import variables from 'src/styles/variables' + +export function Review(props: { + title: string + children: ReactNode + headerAction?: ReactNode + isModal?: boolean +}) { + const insets = useSafeAreaInsets() + + return ( + + } + title={props.title} + /> + + {props.children} + + + ) +} + +export function ReviewContent(props: { children: ReactNode }) { + return {props.children} +} + +export function ReviewSummary(props: { children: ReactNode }) { + return {props.children} +} + +export function ReviewSummaryItem(props: { + header: string + icon: ReactNode + title: string + subtitle?: string + testID?: string +}) { + return ( + + + {props.header} + + + {props.icon} + + + {props.title} + + {!!props.subtitle && ( + + {props.subtitle} + + )} + + + + ) +} + +export function ReviewSummaryItemContact({ + header, + recipient, +}: { + header: string + recipient: Recipient +}) { + const { t } = useTranslation() + + const contact = useMemo(() => { + const phone = recipient.displayNumber || recipient.e164PhoneNumber + if (recipient.name) { + return { title: recipient.name, subtitle: phone, icon: UserIcon } + } + + if (phone) { + return { title: phone, icon: PhoneIcon } + } + + if (recipient.address) { + return { title: recipient.address, icon: WalletIcon } + } + + return { title: t('unknown'), icon: UserIcon } + }, [recipient]) + + return ( + + } + /> + ) +} + +export function ReviewDetails(props: { children: ReactNode }) { + return {props.children} +} + +export function ReviewDetailsItem({ + label, + value, + variant = 'default', + isLoading, + testID, +}: { + label: ReactNode + value: ReactNode + variant?: 'default' | 'bold' + isLoading?: boolean + testID?: string +}) { + const textStyle = + variant === 'bold' ? styles.reviewDetailsItemTextBold : styles.reviewDetailsItemText + + return ( + + + + {label} + + {/* TODO Add for Earn Deposit/Withdrawal */} + + + {isLoading ? ( + + + + + + ) : ( + + {value} + + )} + + + ) +} + +export function ReviewFooter(props: { children: ReactNode }) { + return {props.children} +} + +const styles = StyleSheet.create({ + safeAreaView: { + flex: 1, + }, + header: { + paddingHorizontal: variables.contentPadding, + }, + reviewContainer: { + margin: Spacing.Regular16, + gap: Spacing.Thick24, + flex: 1, + justifyContent: 'space-between', + }, + reviewContent: { + gap: Spacing.Thick24, + }, + reviewSummary: { + borderWidth: 1, + borderColor: Colors.gray2, + borderRadius: Spacing.Small12, + backgroundColor: Colors.gray1, + padding: Spacing.Regular16, + gap: Spacing.Regular16, + flexShrink: 1, + }, + reviewSummaryItem: { + gap: 4, + }, + reviewSummaryItemHeader: { + ...typeScale.labelSmall, + color: Colors.gray3, + }, + reviewSummaryItemContent: { + flexDirection: 'row', + gap: 8, + alignItems: 'center', + }, + reviewSummaryItemTitle: { + ...typeScale.labelSemiBoldLarge, + color: Colors.black, + }, + reviewSummaryItemSubtitle: { + ...typeScale.bodySmall, + color: Colors.gray3, + }, + reviewDetails: { + gap: Spacing.Regular16, + width: '100%', + }, + reviewDetailsItem: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: Spacing.Smallest8, + }, + reviewDetailsItemLabel: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + reviewDetailsItemText: { + ...typeScale.bodyMedium, + color: Colors.gray3, + }, + reviewDetailsItemTextBold: { + ...typeScale.labelSemiBoldMedium, + color: Colors.black, + }, + reviewFooter: { + gap: Spacing.Regular16, + }, + loaderContainer: { + height: 20, + width: 96, + }, + loader: { + height: '100%', + width: '100%', + }, +}) diff --git a/src/send/SendConfirmation.tsx b/src/send/SendConfirmation.tsx index bb82c718ec3..fd206459bef 100644 --- a/src/send/SendConfirmation.tsx +++ b/src/send/SendConfirmation.tsx @@ -1,75 +1,115 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useEffect } from 'react' +import BigNumber from 'bignumber.js' +import React, { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Platform, StyleSheet, Text, View } from 'react-native' -import { SafeAreaView } from 'react-native-safe-area-context' import { showError } from 'src/alert/actions' -import { SendEvents } from 'src/analytics/Events' import AppAnalytics from 'src/analytics/AppAnalytics' +import { SendEvents } from 'src/analytics/Events' import { ErrorMessages } from 'src/app/ErrorMessages' import BackButton from 'src/components/BackButton' -import ContactCircle from 'src/components/ContactCircle' -import LineItemRow from 'src/components/LineItemRow' -import ReviewFrame from 'src/components/ReviewFrame' -import ShortenedAddress from 'src/components/ShortenedAddress' -import TokenDisplay from 'src/components/TokenDisplay' -import TokenTotalLineItem from 'src/components/TokenTotalLineItem' -import CustomHeader from 'src/components/header/CustomHeader' -import { e164NumberToAddressSelector } from 'src/identity/selectors' -import { getLocalCurrencyCode } from 'src/localCurrency/selectors' +import Button, { BtnSizes } from 'src/components/Button' +import { + Review, + ReviewContent, + ReviewDetails, + ReviewDetailsItem, + ReviewFooter, + ReviewSummary, + ReviewSummaryItem, + ReviewSummaryItemContact, +} from 'src/components/Review' +import { getDisplayLocalAmount, getDisplayTokenAmount } from 'src/components/TokenEnterAmount' +import TokenIcon from 'src/components/TokenIcon' +import { LocalCurrencySymbol } from 'src/localCurrency/consts' +import { getLocalCurrencyCode, getLocalCurrencySymbol } from 'src/localCurrency/selectors' import { noHeader } from 'src/navigator/Headers' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' -import { getDisplayName } from 'src/recipients/recipient' import { useDispatch, useSelector } from 'src/redux/hooks' import { sendPayment } from 'src/send/actions' import { isSendingSelector } from 'src/send/selectors' import { usePrepareSendTransactions } from 'src/send/usePrepareSendTransactions' -import DisconnectBanner from 'src/shared/DisconnectBanner' -import colors from 'src/styles/colors' -import { typeScale } from 'src/styles/fonts' -import { useAmountAsUsd, useTokenInfo, useTokenToLocalAmount } from 'src/tokens/hooks' +import { NETWORK_NAMES } from 'src/shared/conts' +import { + useAmountAsUsd, + useDisplayAmount, + useTokenInfo, + useTokenToLocalAmount, +} from 'src/tokens/hooks' import { feeCurrenciesSelector } from 'src/tokens/selectors' -import { getFeeCurrencyAndAmounts } from 'src/viem/prepareTransactions' +import { getFeeCurrencyAndAmounts, PreparedTransactionsResult } from 'src/viem/prepareTransactions' import { getSerializablePreparedTransaction } from 'src/viem/preparedTransactionSerialization' import { walletAddressSelector } from 'src/web3/selectors' -type OwnProps = NativeStackScreenProps< +type Props = NativeStackScreenProps< StackParamList, Screens.SendConfirmation | Screens.SendConfirmationModal > -type Props = OwnProps const DEBOUNCE_TIME_MS = 250 export const sendConfirmationScreenNavOptions = noHeader -function SendConfirmation(props: Props) { +function useCalculatedFees({ + localAmount, + prepareTransactionsResult, +}: { + localAmount: BigNumber | null + prepareTransactionsResult: PreparedTransactionsResult | undefined +}) { + const localCurrencySymbol = useSelector(getLocalCurrencySymbol) ?? LocalCurrencySymbol.USD + const { maxFeeAmount, feeCurrency: feeTokenInfo } = + getFeeCurrencyAndAmounts(prepareTransactionsResult) + + const feeAmount = maxFeeAmount ?? new BigNumber(0) + const localMaxFeeAmount = useTokenToLocalAmount(feeAmount, feeTokenInfo?.tokenId) + + const feeDisplayAmount = useDisplayAmount({ + tokenAmount: maxFeeAmount, + token: feeTokenInfo, + approx: true, + }) + + const totalPlusFees = useMemo(() => { + const total = (localAmount ?? new BigNumber(0)).plus(localMaxFeeAmount ?? new BigNumber(0)) + return getDisplayLocalAmount(total, localCurrencySymbol) + }, [localAmount, localMaxFeeAmount, localCurrencySymbol]) + + return { + networkName: feeTokenInfo && NETWORK_NAMES[feeTokenInfo.networkId], + feeDisplayAmount, + totalPlusFees, + } +} + +export default function SendConfirmation(props: Props) { const { t } = useTranslation() + const dispatch = useDispatch() const { origin, transactionData: { recipient, tokenAmount, tokenAddress, tokenId }, } = props.route.params - const { prepareTransactionsResult, refreshPreparedTransactions, clearPreparedTransactions } = - usePrepareSendTransactions() - - const { maxFeeAmount, feeCurrency: feeTokenInfo } = - getFeeCurrencyAndAmounts(prepareTransactionsResult) + const { + prepareTransactionsResult, + refreshPreparedTransactions, + clearPreparedTransactions, + prepareTransactionLoading, + } = usePrepareSendTransactions() const tokenInfo = useTokenInfo(tokenId) const isSending = useSelector(isSendingSelector) const fromModal = props.route.name === Screens.SendConfirmationModal const localCurrencyCode = useSelector(getLocalCurrencyCode) + const localCurrencySymbol = useSelector(getLocalCurrencySymbol) ?? LocalCurrencySymbol.USD const localAmount = useTokenToLocalAmount(tokenAmount, tokenId) const usdAmount = useAmountAsUsd(tokenAmount, tokenId) + const fees = useCalculatedFees({ localAmount, prepareTransactionsResult }) const walletAddress = useSelector(walletAddressSelector) const feeCurrencies = useSelector((state) => feeCurrenciesSelector(state, tokenInfo!.networkId)) - const dispatch = useDispatch() - useEffect(() => { if (!walletAddress || !tokenInfo) { return // should never happen @@ -87,61 +127,9 @@ function SendConfirmation(props: Props) { return () => clearTimeout(debouncedRefreshTransactions) }, [tokenInfo, tokenAmount, recipient, walletAddress, feeCurrencies]) - const e164NumberToAddress = useSelector(e164NumberToAddressSelector) - const showAddress = - !!recipient.e164PhoneNumber && (e164NumberToAddress[recipient.e164PhoneNumber]?.length ?? 0) > 1 - const disableSend = isSending || !prepareTransactionsResult || prepareTransactionsResult.type !== 'possible' - const feeInUsd = - maxFeeAmount && feeTokenInfo?.priceUsd ? maxFeeAmount.times(feeTokenInfo.priceUsd) : undefined - - const FeeContainer = () => { - return ( - - - ) - } - isLoading={!maxFeeAmount} - /> - - ) - } - /> - - - ) - } - const onSend = () => { const preparedTransaction = prepareTransactionsResult && @@ -181,119 +169,53 @@ function SendConfirmation(props: Props) { } return ( - } > - } - /> - - - - - - - - {t('sending')} - - - {getDisplayName(recipient, t)} - - {showAddress && ( - - - - )} - - - + + } + title={getDisplayTokenAmount(tokenAmount, tokenInfo!)} + subtitle={getDisplayLocalAmount(localAmount, localCurrencySymbol)} + /> + + + + + + - - - - + + + + + +