From 7e7c5f3c6f858f568d463da4844abde801df85e0 Mon Sep 17 00:00:00 2001 From: Satish Ravi Date: Wed, 10 Apr 2024 16:41:52 -0700 Subject: [PATCH] feat(send): allow entering amount in fiat (#5213) ### Description Allows entering fiat amount with upto 2 decimal places. Automatically adds grouping separators to input ### Test plan Unit tests. Tested manually on iOS and android with different separators https://github.com/valora-inc/wallet/assets/5062591/e4f1f522-3572-4d80-83a5-b4683f17bbb6 ### Related issues - Fixes ACT-1142 ### Backwards compatibility Yes ### Network scalability N/A --- e2e/src/usecases/HandleDeepLinkSend.js | 4 +- e2e/src/usecases/SecureSend.js | 6 +- e2e/src/usecases/Send.js | 32 +- src/analytics/Properties.tsx | 46 ++- src/jumpstart/JumpstartEnterAmount.test.tsx | 13 +- src/jumpstart/JumpstartEnterAmount.tsx | 11 +- src/send/EnterAmount.test.tsx | 319 ++++++++++++++++---- src/send/EnterAmount.tsx | 283 ++++++++++++----- src/send/SendEnterAmount.test.tsx | 9 +- src/send/SendEnterAmount.tsx | 23 +- src/send/types.ts | 2 + 11 files changed, 533 insertions(+), 215 deletions(-) diff --git a/e2e/src/usecases/HandleDeepLinkSend.js b/e2e/src/usecases/HandleDeepLinkSend.js index f787f0a6be7..4da9c55a273 100644 --- a/e2e/src/usecases/HandleDeepLinkSend.js +++ b/e2e/src/usecases/HandleDeepLinkSend.js @@ -71,8 +71,8 @@ export default HandleDeepLinkSend = () => { await launchDeepLink(PAY_URL) await waitForElementId('SendEnterAmount/TokenSelect', 10_000) await expect(element(by.text('cUSD')).atIndex(0)).toBeVisible() - await element(by.id('SendEnterAmount/Input')).replaceText('0.01') - await element(by.id('SendEnterAmount/Input')).tapReturnKey() + await element(by.id('SendEnterAmount/TokenAmountInput')).replaceText('0.01') + await element(by.id('SendEnterAmount/TokenAmountInput')).tapReturnKey() await waitForElementByIdAndTap('SendEnterAmount/ReviewButton', 30_000) await addComment(commentText) diff --git a/e2e/src/usecases/SecureSend.js b/e2e/src/usecases/SecureSend.js index 1baaf2c584e..5effa3cc0e2 100644 --- a/e2e/src/usecases/SecureSend.js +++ b/e2e/src/usecases/SecureSend.js @@ -68,9 +68,9 @@ export default SecureSend = () => { await waitForElementByIdAndTap('cUSDSymbol', 30_000) // Enter the amount and review - await element(by.id('SendEnterAmount/Input')).tap() - await element(by.id('SendEnterAmount/Input')).replaceText(AMOUNT_TO_SEND) - await element(by.id('SendEnterAmount/Input')).tapReturnKey() + await element(by.id('SendEnterAmount/TokenAmountInput')).tap() + await element(by.id('SendEnterAmount/TokenAmountInput')).replaceText(AMOUNT_TO_SEND) + await element(by.id('SendEnterAmount/TokenAmountInput')).tapReturnKey() await element(by.id('SendEnterAmount/ReviewButton')).tap() // Write a comment. diff --git a/e2e/src/usecases/Send.js b/e2e/src/usecases/Send.js index c5b70a227e1..0b867074f96 100644 --- a/e2e/src/usecases/Send.js +++ b/e2e/src/usecases/Send.js @@ -50,7 +50,7 @@ export default Send = () => { it('Then tapping send button should navigate to Send Enter Amount screen', async () => { await element(by.id('SendOrInviteButton')).tap() - await waitForElementId('SendEnterAmount/Input', 30_000) + await waitForElementId('SendEnterAmount/TokenAmountInput', 30_000) }) it('Then should be able to change token', async () => { @@ -66,9 +66,9 @@ export default Send = () => { }) it('Then should be able to enter amount and navigate to review screen', async () => { - await waitForElementByIdAndTap('SendEnterAmount/Input', 30_000) - await element(by.id('SendEnterAmount/Input')).replaceText('0.01') - await element(by.id('SendEnterAmount/Input')).tapReturnKey() + await waitForElementByIdAndTap('SendEnterAmount/TokenAmountInput', 30_000) + await element(by.id('SendEnterAmount/TokenAmountInput')).replaceText('0.01') + await element(by.id('SendEnterAmount/TokenAmountInput')).tapReturnKey() await waitForElementByIdAndTap('SendEnterAmount/ReviewButton', 30_000) await isElementVisible('ConfirmButton') }) @@ -86,10 +86,10 @@ export default Send = () => { it('Then should be able to edit amount', async () => { await element(by.id('BackChevron')).tap() await isElementVisible('SendEnterAmount/ReviewButton') - await element(by.id('SendEnterAmount/Input')).tap() - await waitForElementByIdAndTap('SendEnterAmount/Input', 30_000) - await element(by.id('SendEnterAmount/Input')).replaceText('0.01') - await element(by.id('SendEnterAmount/Input')).tapReturnKey() + await element(by.id('SendEnterAmount/TokenAmountInput')).tap() + await waitForElementByIdAndTap('SendEnterAmount/TokenAmountInput', 30_000) + await element(by.id('SendEnterAmount/TokenAmountInput')).replaceText('0.01') + await element(by.id('SendEnterAmount/TokenAmountInput')).tapReturnKey() await waitForElementByIdAndTap('SendEnterAmount/ReviewButton', 30_000) let amount = await element(by.id('SendAmount')).getAttributes() jestExpect(amount.text).toEqual('0.01 cEUR') @@ -128,7 +128,7 @@ export default Send = () => { it('Then should be able to click on recent recipient', async () => { await element(by.text('0xe5f5...8846')).atIndex(0).tap() - await waitForElementId('SendEnterAmount/Input', 30_000) + await waitForElementId('SendEnterAmount/TokenAmountInput', 30_000) }) it('Then should be able to choose token', async () => { @@ -138,9 +138,9 @@ export default Send = () => { }) it('Then should be able to enter amount and navigate to review screen', async () => { - await waitForElementByIdAndTap('SendEnterAmount/Input', 30_000) - await element(by.id('SendEnterAmount/Input')).replaceText('0.01') - await element(by.id('SendEnterAmount/Input')).tapReturnKey() + await waitForElementByIdAndTap('SendEnterAmount/TokenAmountInput', 30_000) + await element(by.id('SendEnterAmount/TokenAmountInput')).replaceText('0.01') + await element(by.id('SendEnterAmount/TokenAmountInput')).tapReturnKey() await waitForElementByIdAndTap('SendEnterAmount/ReviewButton', 30_000) await isElementVisible('ConfirmButton') }) @@ -195,7 +195,7 @@ export default Send = () => { it('Then tapping send button should navigate to Send Enter Amount screen', async () => { await element(by.id('SendOrInviteButton')).tap() - await waitForElementId('SendEnterAmount/Input', 30_000) + await waitForElementId('SendEnterAmount/TokenAmountInput', 30_000) }) it('Then should be able to select token', async () => { @@ -205,9 +205,9 @@ export default Send = () => { }) it('Then should be able to enter amount and navigate to review screen', async () => { - await waitForElementByIdAndTap('SendEnterAmount/Input', 30_000) - await element(by.id('SendEnterAmount/Input')).replaceText('0.01') - await element(by.id('SendEnterAmount/Input')).tapReturnKey() + await waitForElementByIdAndTap('SendEnterAmount/TokenAmountInput', 30_000) + await element(by.id('SendEnterAmount/TokenAmountInput')).replaceText('0.01') + await element(by.id('SendEnterAmount/TokenAmountInput')).tapReturnKey() await waitForElementByIdAndTap('SendEnterAmount/ReviewButton', 30_000) await isElementVisible('ConfirmButton') }) diff --git a/src/analytics/Properties.tsx b/src/analytics/Properties.tsx index c83a27108e6..5e0501d0289 100644 --- a/src/analytics/Properties.tsx +++ b/src/analytics/Properties.tsx @@ -69,11 +69,10 @@ import { NotificationReceiveState } from 'src/notifications/types' import { AdventureCardName } from 'src/onboarding/types' import { PointsActivity } from 'src/points/types' import { RecipientType } from 'src/recipients/recipient' -import { QrCode } from 'src/send/types' +import { AmountEnteredIn, QrCode } from 'src/send/types' import { Field } from 'src/swap/types' import { TokenDetailsActionName } from 'src/tokens/types' import { NetworkId, TokenTransactionTypeV2, TransactionStatus } from 'src/transactions/types' -import { Currency } from 'src/utils/currencies' type Web3LibraryProps = { web3Library: 'contract-kit' | 'viem' } @@ -539,30 +538,21 @@ interface SendEventsProperties { } [SendEvents.send_cancel]: undefined [SendEvents.send_amount_back]: undefined - [SendEvents.send_amount_continue]: - | { - origin: SendOrigin - isScan: boolean - localCurrencyExchangeRate?: string | null - localCurrency: LocalCurrencyCode - localCurrencyAmount: string | null - underlyingCurrency: Currency - underlyingAmount: string | null - } - | { - origin: SendOrigin - recipientType: RecipientType - isScan: boolean - localCurrencyExchangeRate?: string | null - localCurrency: LocalCurrencyCode - localCurrencyAmount: string | null - underlyingTokenAddress: string | null - underlyingTokenSymbol: string - underlyingAmount: string | null - amountInUsd: string | null - tokenId: string | null - networkId: string | null - } + [SendEvents.send_amount_continue]: { + origin: SendOrigin + recipientType: RecipientType + isScan: boolean + localCurrencyExchangeRate?: string | null + localCurrency: LocalCurrencyCode + localCurrencyAmount: string | null + underlyingTokenAddress: string | null + underlyingTokenSymbol: string + underlyingAmount: string | null + amountInUsd: string | null + amountEnteredIn: AmountEnteredIn + tokenId: string | null + networkId: string | null + } [SendEvents.send_confirm_back]: undefined [SendEvents.send_confirm_send]: | { @@ -1528,7 +1518,9 @@ interface JumpstartEventsProperties { [JumpstartEvents.jumpstart_send_amount_exceeds_threshold]: JumpstartDepositProperties & { thresholdUsd: number } - [JumpstartEvents.jumpstart_send_amount_continue]: JumpstartSendProperties + [JumpstartEvents.jumpstart_send_amount_continue]: JumpstartSendProperties & { + amountEnteredIn: AmountEnteredIn + } [JumpstartEvents.jumpstart_send_confirm]: JumpstartSendProperties [JumpstartEvents.jumpstart_send_start]: JumpstartSendProperties [JumpstartEvents.jumpstart_send_succeeded]: JumpstartSendProperties diff --git a/src/jumpstart/JumpstartEnterAmount.test.tsx b/src/jumpstart/JumpstartEnterAmount.test.tsx index 0f88a2b0d69..a4a89f5c859 100644 --- a/src/jumpstart/JumpstartEnterAmount.test.tsx +++ b/src/jumpstart/JumpstartEnterAmount.test.tsx @@ -122,7 +122,7 @@ describe('JumpstartEnterAmount', () => { ) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '.25') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '.25') await waitFor(() => expect(executeSpy).toHaveBeenCalledTimes(1)) expect(executeSpy).toHaveBeenCalledWith({ @@ -142,7 +142,7 @@ describe('JumpstartEnterAmount', () => { ) // default selected token is cEUR, priceUsd: '1.16' so max send amount will be 50 / 1.16 = 43.10 - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '43.5') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '43.5') await waitFor(() => expect(executeSpy).toHaveBeenCalledTimes(1)) expect(getByText('review')).toBeDisabled() @@ -153,7 +153,7 @@ describe('JumpstartEnterAmount', () => { ).toBeTruthy() expect(getByText('jumpstartEnterAmountScreen.maxAmountWarning.description')).toBeTruthy() - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '43') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '43') await waitFor(() => expect(getByText('review')).not.toBeDisabled()) expect(queryByText('jumpstartEnterAmountScreen.maxAmountWarning.title')).toBeFalsy() @@ -169,7 +169,7 @@ describe('JumpstartEnterAmount', () => { ) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '.25') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '.25') await waitFor(() => expect(executeSpy).toHaveBeenCalledTimes(1)) fireEvent.press(getByText('review')) @@ -192,6 +192,7 @@ describe('JumpstartEnterAmount', () => { tokenAmount: '0.25', tokenId: mockCeurTokenId, tokenSymbol: 'cEUR', + amountEnteredIn: 'token', } ) }) @@ -205,7 +206,7 @@ describe('JumpstartEnterAmount', () => { expect(store.getActions()).toEqual([depositTransactionFlowStarted()]) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '.25') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '.25') await waitFor(() => expect(executeSpy).toHaveBeenCalledTimes(1)) expect(getByTestId('SendEnterAmount/ReviewButton')).toBeEnabled() @@ -225,7 +226,7 @@ describe('JumpstartEnterAmount', () => { // depositTransactionFlowStarted should not be dispatched expect(updatedStore.getActions()).toEqual([]) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '.30') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '.30') // prepare transaction for a second time on this screen await waitFor(() => expect(executeSpy).toHaveBeenCalledTimes(2)) // review button should remain disabled diff --git a/src/jumpstart/JumpstartEnterAmount.tsx b/src/jumpstart/JumpstartEnterAmount.tsx index 3fedf32fdab..0b554f0af98 100644 --- a/src/jumpstart/JumpstartEnterAmount.tsx +++ b/src/jumpstart/JumpstartEnterAmount.tsx @@ -16,7 +16,8 @@ import { getLocalCurrencyCode, usdToLocalCurrencyRateSelector } from 'src/localC import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { useSelector } from 'src/redux/hooks' -import EnterAmount from 'src/send/EnterAmount' +import EnterAmount, { ProceedArgs } from 'src/send/EnterAmount' +import { AmountEnteredIn } from 'src/send/types' import { getDynamicConfigParams } from 'src/statsig' import { DynamicConfigs } from 'src/statsig/constants' import { StatsigDynamicConfigs } from 'src/statsig/types' @@ -66,12 +67,13 @@ function JumpstartEnterAmount() { }, [jumpstartLink.privateKey]) const handleProceed = useAsyncCallback( - async (parsedAmount: BigNumber, token: TokenBalance) => { + async ({ tokenAmount, token, amountEnteredIn }: ProceedArgs) => { const link = await createJumpstartLink(jumpstartLink.privateKey, token.networkId) return { link, - parsedAmount, + parsedAmount: tokenAmount, token, + amountEnteredIn, } }, { @@ -79,10 +81,12 @@ function JumpstartEnterAmount() { link, parsedAmount, token, + amountEnteredIn, }: { link: string parsedAmount: BigNumber token: TokenBalance + amountEnteredIn: AmountEnteredIn }) => { if (prepareJumpstartTransactions.result?.type !== 'possible') { // should never happen @@ -110,6 +114,7 @@ function JumpstartEnterAmount() { amountInUsd: parsedAmount.multipliedBy(token.priceUsd ?? 0).toFixed(2), tokenId: token.tokenId, networkId: token.networkId, + amountEnteredIn, }) }, onError: (error) => { diff --git a/src/send/EnterAmount.test.tsx b/src/send/EnterAmount.test.tsx index aebc6c7f2b5..ea0d1e079b2 100644 --- a/src/send/EnterAmount.test.tsx +++ b/src/send/EnterAmount.test.tsx @@ -136,8 +136,8 @@ describe('EnterAmount', () => { ) - expect(getByTestId('SendEnterAmount/Input')).toBeTruthy() - expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.00') + expect(getByTestId('SendEnterAmount/TokenAmountInput')).toBeTruthy() + expect(getByTestId('SendEnterAmount/LocalAmountInput')).toBeTruthy() expect(getByTestId('SendEnterAmount/Max')).toBeTruthy() expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('POOF') expect( @@ -155,8 +155,8 @@ describe('EnterAmount', () => { ) - expect(getByTestId('SendEnterAmount/Input')).toBeTruthy() - expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.00') + expect(getByTestId('SendEnterAmount/TokenAmountInput')).toBeTruthy() + expect(getByTestId('SendEnterAmount/LocalAmountInput')).toBeTruthy() expect(getByTestId('SendEnterAmount/Max')).toBeTruthy() expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('ETH') expect( @@ -165,60 +165,135 @@ describe('EnterAmount', () => { expect(getByTestId('SendEnterAmount/ReviewButton')).toBeDisabled() }) - it('entering amount updates local amount', () => { - const store = createMockStore(mockStore) + describe.each([ + { decimal: '.', group: ',' }, + { decimal: ',', group: '.' }, + ])('with decimal separator "$decimal" and group separator "$group"', ({ decimal, group }) => { + const replaceSeparators = (value: string) => + value.replace(/\./g, '|').replace(/,/g, group).replace(/\|/g, decimal) - const { getByTestId } = render( - - - - ) + function renderComponent() { + const store = createMockStore(mockStore) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '10000.5') - expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱1,330.07') - }) + const { getByTestId } = render( + + + + ) - it('entering amount with comma as decimal separator updates local amount', () => { - jest - .mocked(getNumberFormatSettings) - .mockReturnValue({ decimalSeparator: ',', groupingSeparator: '.' }) - BigNumber.config({ - FORMAT: { - decimalSeparator: ',', - groupSeparator: '.', - groupSize: 3, - }, + const tokenAmountInput = getByTestId('SendEnterAmount/TokenAmountInput') + const localAmountInput = getByTestId('SendEnterAmount/LocalAmountInput') + + const changeTokenAmount = (value: string) => { + fireEvent.changeText(tokenAmountInput, replaceSeparators(value)) + } + const changeLocalAmount = (value: string) => { + fireEvent.changeText(localAmountInput, replaceSeparators(value)) + } + + return { tokenAmountInput, localAmountInput, changeTokenAmount, changeLocalAmount } + } + + beforeEach(() => { + jest + .mocked(getNumberFormatSettings) + .mockReturnValue({ decimalSeparator: decimal, groupingSeparator: group }) + BigNumber.config({ + FORMAT: { + decimalSeparator: decimal, + groupSeparator: group, + groupSize: 3, + }, + }) }) - const store = createMockStore(mockStore) - const { getByTestId } = render( - - - - ) + it('entering one amount updates the other amount', () => { + const { tokenAmountInput, localAmountInput, changeTokenAmount, changeLocalAmount } = + renderComponent() - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '10000,5') - expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱1.330,07') - }) + changeTokenAmount('10000.5') + expect(tokenAmountInput.props.value).toBe(replaceSeparators('10000.5')) + expect(localAmountInput.props.value).toBe(replaceSeparators(`₱1,330.07`)) - it('only allows numeric input', () => { - const store = createMockStore(mockStore) + changeLocalAmount('1000.5') + expect(localAmountInput.props.value).toBe(replaceSeparators(`₱1,000.5`)) + expect(tokenAmountInput.props.value).toBe(replaceSeparators('7522.5563909774436090226')) + }) - const { getByTestId } = render( - - - - ) + it('only allows numeric input with decimal separators for token amount', () => { + const { tokenAmountInput, changeTokenAmount } = renderComponent() - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '10.5') - expect(getByTestId('SendEnterAmount/Input').props.value).toBe('10.5') - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '10.5.1') - expect(getByTestId('SendEnterAmount/Input').props.value).toBe('10.5') - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), 'abc') - expect(getByTestId('SendEnterAmount/Input').props.value).toBe('10.5') + changeTokenAmount('10.5') + expect(tokenAmountInput.props.value).toBe(replaceSeparators('10.5')) + changeTokenAmount('10.5.1') + expect(tokenAmountInput.props.value).toBe(replaceSeparators('10.5')) + changeTokenAmount('abc') + expect(tokenAmountInput.props.value).toBe(replaceSeparators('10.5')) + }) + + it('starting with decimal separator prefixes 0 for token amount', () => { + const { tokenAmountInput, changeTokenAmount } = renderComponent() + + changeTokenAmount('.25') + expect(tokenAmountInput.props.value).toBe(replaceSeparators('0.25')) + }) + + it('adds group separators and currency symbol for local amount', () => { + const { localAmountInput, changeLocalAmount } = renderComponent() + + changeLocalAmount('₱100000000') + expect(localAmountInput.props.value).toBe(replaceSeparators(`₱100,000,000`)) + }) + + it('only allows numeric input with 2 decimals for local amount', () => { + const { localAmountInput, changeLocalAmount } = renderComponent() + + changeLocalAmount('10.25') + expect(localAmountInput.props.value).toBe(replaceSeparators(`₱10.25`)) + changeLocalAmount('10.258') + expect(localAmountInput.props.value).toBe(replaceSeparators(`₱10.25`)) + changeLocalAmount('10.5.1') + expect(localAmountInput.props.value).toBe(replaceSeparators(`₱10.25`)) + changeLocalAmount('abc') + expect(localAmountInput.props.value).toBe(replaceSeparators(`₱10.25`)) + }) + + it('starting with decimal separator prefixes 0 for local amount', () => { + const { localAmountInput, changeLocalAmount } = renderComponent() + + changeLocalAmount('.25') + expect(localAmountInput.props.value).toBe(replaceSeparators(`₱0.25`)) + }) + + it('entering invalid local amount with a valid token amount does not update anything', () => { + const { tokenAmountInput, localAmountInput, changeTokenAmount, changeLocalAmount } = + renderComponent() + + changeTokenAmount('10.5') + expect(tokenAmountInput.props.value).toBe(replaceSeparators('10.5')) + expect(localAmountInput.props.value).toBe(replaceSeparators('₱1.40')) + changeLocalAmount('abc') + expect(tokenAmountInput.props.value).toBe(replaceSeparators('10.5')) + expect(localAmountInput.props.value).toBe(replaceSeparators('₱1.40')) + }) + + it('entering invalid token amount with a valid local amount does not update anything', () => { + const { tokenAmountInput, localAmountInput, changeTokenAmount, changeLocalAmount } = + renderComponent() + + changeLocalAmount('133') + expect(tokenAmountInput.props.value).toBe(replaceSeparators('1000')) + expect(localAmountInput.props.value).toBe(replaceSeparators('₱133')) + changeTokenAmount('abc') + expect(tokenAmountInput.props.value).toBe(replaceSeparators('1000')) + expect(localAmountInput.props.value).toBe(replaceSeparators('₱133')) + }) }) - it('starting with decimal separator prefixes 0', () => { + it.each([ + { testPrefix: 'clearing one amount', text: '', expectedTokenValue: '', expectedLocalValue: '' }, + { testPrefix: 'entering 0', text: '0', expectedTokenValue: '0', expectedLocalValue: '₱0' }, + ])('$testPrefix clears the other amount', ({ text, expectedTokenValue, expectedLocalValue }) => { const store = createMockStore(mockStore) const { getByTestId } = render( @@ -227,8 +302,21 @@ describe('EnterAmount', () => { ) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '.25') - expect(getByTestId('SendEnterAmount/Input').props.value).toBe('0.25') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '2') + expect(getByTestId('SendEnterAmount/TokenAmountInput').props.value).toBe('2') + expect(getByTestId('SendEnterAmount/LocalAmountInput').props.value).toBe('₱0.27') + + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), text) + expect(getByTestId('SendEnterAmount/TokenAmountInput').props.value).toBe(expectedTokenValue) + expect(getByTestId('SendEnterAmount/LocalAmountInput').props.value).toBe('') + + fireEvent.changeText(getByTestId('SendEnterAmount/LocalAmountInput'), '1.33') + expect(getByTestId('SendEnterAmount/TokenAmountInput').props.value).toBe('10') + expect(getByTestId('SendEnterAmount/LocalAmountInput').props.value).toBe('₱1.33') + + fireEvent.changeText(getByTestId('SendEnterAmount/LocalAmountInput'), text) + expect(getByTestId('SendEnterAmount/TokenAmountInput').props.value).toBe('') + expect(getByTestId('SendEnterAmount/LocalAmountInput').props.value).toBe(expectedLocalValue) }) it('selecting new token updates token and network info', async () => { @@ -268,6 +356,52 @@ describe('EnterAmount', () => { }) }) + it('selecting new token with token amount entered updates local amount', async () => { + const store = createMockStore(mockStore) + + const { getByTestId, getByText } = render( + + + + ) + + expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('POOF') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '1') + expect(getByTestId('SendEnterAmount/TokenAmountInput').props.value).toBe('1') + expect(getByTestId('SendEnterAmount/LocalAmountInput').props.value).toBe('₱0.13') + fireEvent.press(getByTestId('SendEnterAmount/TokenSelect')) + await waitFor(() => expect(getByText('Ether')).toBeTruthy()) + fireEvent.press(getByText('Ether')) + expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('ETH') + expect(getByTestId('SendEnterAmount/TokenAmountInput').props.value).toBe('1') + expect(getByTestId('SendEnterAmount/LocalAmountInput').props.value).toBe('₱1,995.00') + }) + + it('selecting new token with local amount entered updates token amount', async () => { + const store = createMockStore(mockStore) + + const { getByTestId, getByText } = render( + + + + ) + + expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('POOF') + fireEvent.changeText(getByTestId('SendEnterAmount/LocalAmountInput'), '1') + expect(getByTestId('SendEnterAmount/TokenAmountInput').props.value).toBe( + '7.5187969924812030075' + ) + expect(getByTestId('SendEnterAmount/LocalAmountInput').props.value).toBe('₱1') + fireEvent.press(getByTestId('SendEnterAmount/TokenSelect')) + await waitFor(() => expect(getByText('Ether')).toBeTruthy()) + fireEvent.press(getByText('Ether')) + expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('ETH') + expect(getByTestId('SendEnterAmount/TokenAmountInput').props.value).toBe( + '0.0005012531328320802' + ) + expect(getByTestId('SendEnterAmount/LocalAmountInput').props.value).toBe('₱1') + }) + it('pressing max fills in max available amount', () => { const store = createMockStore(mockStore) @@ -278,8 +412,8 @@ describe('EnterAmount', () => { ) fireEvent.press(getByTestId('SendEnterAmount/Max')) - expect(getByTestId('SendEnterAmount/Input').props.value).toBe('5') - expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.67') + expect(getByTestId('SendEnterAmount/TokenAmountInput').props.value).toBe('5') + expect(getByTestId('SendEnterAmount/LocalAmountInput').props.value).toBe('₱0.67') expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1) expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.max_pressed, { networkId: NetworkId['celo-alfajores'], @@ -288,7 +422,25 @@ describe('EnterAmount', () => { }) }) - it('entering amount above balance displays error message', () => { + it('entering token amount above balance displays error message', () => { + const store = createMockStore(mockStore) + + const { getByTestId, queryByTestId } = render( + + + + ) + + // token balance 5 + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '7') + expect(getByTestId('SendEnterAmount/LowerAmountError')).toBeTruthy() + expect(queryByTestId('SendEnterAmount/MaxAmountWarning')).toBeFalsy() + expect(queryByTestId('SendEnterAmount/NotEnoughForGasWarning')).toBeFalsy() + expect(getByTestId('SendEnterAmount/ReviewButton')).toBeDisabled() + expect(queryByTestId('SendEnterAmount/FeePlaceholder')).toBeTruthy() + }) + + it('entering local amount above balance displays error message', () => { const store = createMockStore(mockStore) const { getByTestId, queryByTestId } = render( @@ -297,7 +449,8 @@ describe('EnterAmount', () => { ) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '7') + // token balance 5 => local balance 0.67 + fireEvent.changeText(getByTestId('SendEnterAmount/LocalAmountInput'), '.68') expect(getByTestId('SendEnterAmount/LowerAmountError')).toBeTruthy() expect(queryByTestId('SendEnterAmount/MaxAmountWarning')).toBeFalsy() expect(queryByTestId('SendEnterAmount/NotEnoughForGasWarning')).toBeFalsy() @@ -321,7 +474,7 @@ describe('EnterAmount', () => { ) expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('POOF') - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '2') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '2') expect(queryByTestId('SendEnterAmount/LowerAmountError')).toBeFalsy() expect(queryByTestId('SendEnterAmount/MaxAmountWarning')).toBeFalsy() expect(getByTestId('SendEnterAmount/NotEnoughForGasWarning')).toBeTruthy() @@ -352,7 +505,7 @@ describe('EnterAmount', () => { ) expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('CELO') - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '9.9999') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '9.9999') expect(queryByTestId('SendEnterAmount/LowerAmountError')).toBeFalsy() expect(getByTestId('SendEnterAmount/MaxAmountWarning')).toBeTruthy() expect(queryByTestId('SendEnterAmount/NotEnoughForGasWarning')).toBeFalsy() @@ -360,7 +513,41 @@ describe('EnterAmount', () => { expect(getByTestId('SendEnterAmount/FeeInCrypto')).toBeTruthy() }) - it('able to press Review when prepareTransactionsResult is type possible', () => { + it('able to press Review when prepareTransactionsResult is type possible (input in token)', () => { + const { getByTestId, queryByTestId } = render( + + + + ) + + expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('CELO') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '8') + expect(queryByTestId('SendEnterAmount/LowerAmountError')).toBeFalsy() + expect(queryByTestId('SendEnterAmount/MaxAmountWarning')).toBeFalsy() + expect(queryByTestId('SendEnterAmount/NotEnoughForGasWarning')).toBeFalsy() + expect(getByTestId('SendEnterAmount/ReviewButton')).toBeEnabled() + expect(queryByTestId('SendEnterAmount/FeePlaceholder')).toBeFalsy() + expect(getByTestId('SendEnterAmount/FeeInCrypto')).toHaveTextContent('~0.006 CELO') + fireEvent.press(getByTestId('SendEnterAmount/ReviewButton')) + expect(onPressProceedSpy).toHaveBeenCalledTimes(1) + expect(onPressProceedSpy).toHaveBeenLastCalledWith({ + amountEnteredIn: 'token', + localAmount: new BigNumber(5.32), + tokenAmount: new BigNumber(8), + token: mockStoreBalancesToTokenBalances([mockStoreTokenBalances[mockCeloTokenId]])[0], + }) + }) + + it('able to press Review when prepareTransactionsResult is type possible (input in local)', () => { const { getByTestId, queryByTestId } = render( { ) expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('CELO') - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '8') + fireEvent.changeText(getByTestId('SendEnterAmount/LocalAmountInput'), '5') expect(queryByTestId('SendEnterAmount/LowerAmountError')).toBeFalsy() expect(queryByTestId('SendEnterAmount/MaxAmountWarning')).toBeFalsy() expect(queryByTestId('SendEnterAmount/NotEnoughForGasWarning')).toBeFalsy() @@ -386,6 +573,12 @@ describe('EnterAmount', () => { expect(getByTestId('SendEnterAmount/FeeInCrypto')).toHaveTextContent('~0.006 CELO') fireEvent.press(getByTestId('SendEnterAmount/ReviewButton')) expect(onPressProceedSpy).toHaveBeenCalledTimes(1) + expect(onPressProceedSpy).toHaveBeenLastCalledWith({ + amountEnteredIn: 'local', + localAmount: new BigNumber(5), + tokenAmount: new BigNumber('7.51879699248120300752'), + token: mockStoreBalancesToTokenBalances([mockStoreTokenBalances[mockCeloTokenId]])[0], + }) }) it('clears prepared transactions and refreshes when new token or amount is selected', async () => { @@ -402,8 +595,8 @@ describe('EnterAmount', () => { ) expect(getByTestId('SendEnterAmount/FeePlaceholder')).toHaveTextContent('~ CELO') - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '8') - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '9') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '8') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '9') jest.runAllTimers() expect(onRefreshPreparedTransactionsSpy).toHaveBeenCalledTimes( 1 // not twice since timers were not run between the two amount changes (zero to 8 and 8 to 9) @@ -461,7 +654,7 @@ describe('EnterAmount', () => { ) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '100') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '100') expect(getByTestId('SendEnterAmount/FeePlaceholder')).toBeTruthy() }) it('shows fee placeholder if prepare transactions result is not possible', () => { @@ -479,7 +672,7 @@ describe('EnterAmount', () => { ) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '1') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '1') expect(getByTestId('SendEnterAmount/FeePlaceholder')).toBeTruthy() }) it('shows fee amount if available', () => { @@ -494,7 +687,7 @@ describe('EnterAmount', () => { ) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '1') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '1') expect(getByTestId('SendEnterAmount/FeeInCrypto')).toHaveTextContent('~0.006 CELO') }) it('shows fee loading if prepare transactions result is undefined', () => { @@ -506,7 +699,7 @@ describe('EnterAmount', () => { ) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '1') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '1') expect(queryByTestId('SendEnterAmount/FeePlaceholder')).toBeFalsy() expect(getByTestId('SendEnterAmount/FeeLoading')).toBeTruthy() }) diff --git a/src/send/EnterAmount.tsx b/src/send/EnterAmount.tsx index 92f4d5e7e36..acd0c13e334 100644 --- a/src/send/EnterAmount.tsx +++ b/src/send/EnterAmount.tsx @@ -2,7 +2,14 @@ import { parseInputAmount } from '@celo/utils/lib/parsing' import BigNumber from 'bignumber.js' import React, { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Platform, TextInput as RNTextInput, StyleSheet, Text } from 'react-native' +import { + Platform, + TextInput as RNTextInput, + StyleProp, + StyleSheet, + Text, + TextStyle, +} from 'react-native' import { View } from 'react-native-animatable' import { getNumberFormatSettings } from 'react-native-localize' import { SafeAreaView } from 'react-native-safe-area-context' @@ -25,15 +32,26 @@ import TokenIcon, { IconSize } from 'src/components/TokenIcon' import Touchable from 'src/components/Touchable' import CustomHeader from 'src/components/header/CustomHeader' import DownArrowIcon from 'src/icons/DownArrowIcon' +import { LocalCurrencySymbol } from 'src/localCurrency/consts' +import { getLocalCurrencySymbol } from 'src/localCurrency/selectors' import { useSelector } from 'src/redux/hooks' +import { AmountEnteredIn } from 'src/send/types' import { NETWORK_NAMES } from 'src/shared/conts' import Colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' +import { useLocalToTokenAmount, useTokenToLocalAmount } from 'src/tokens/hooks' import { feeCurrenciesSelector } from 'src/tokens/selectors' import { TokenBalance } from 'src/tokens/slice' import { PreparedTransactionsResult, getFeeCurrencyAndAmounts } from 'src/viem/prepareTransactions' +export interface ProceedArgs { + tokenAmount: BigNumber + localAmount: BigNumber | null + token: TokenBalance + amountEnteredIn: AmountEnteredIn +} + interface Props { tokens: TokenBalance[] defaultToken?: TokenBalance @@ -46,7 +64,7 @@ interface Props { ): void prepareTransactionError?: Error tokenSelectionDisabled?: boolean - onPressProceed(amount: BigNumber, token: TokenBalance): void + onPressProceed(args: ProceedArgs): void disableProceed?: boolean children?: React.ReactNode } @@ -105,15 +123,16 @@ function EnterAmount({ }: Props) { const { t } = useTranslation() - // the startPosition and textInputRef variables exist to ensure TextInput - // displays the start of the value for long values on Android - // https://github.com/facebook/react-native/issues/14845 - const [startPosition, setStartPosition] = useState(0) - const textInputRef = useRef(null) + const tokenAmountInputRef = useRef(null) + const localAmountInputRef = useRef(null) const tokenBottomSheetRef = useRef(null) const [token, setToken] = useState(() => defaultToken ?? tokens[0]) - const [amount, setAmount] = useState('') + const [tokenAmountInput, setTokenAmountInput] = useState('') + const [localAmountInput, setLocalAmountInput] = useState('') + const [enteredIn, setEnteredIn] = useState('token') + // this should never be null, just adding a default to make TS happy + const localCurrencySymbol = useSelector(getLocalCurrencySymbol) ?? LocalCurrencySymbol.USD const onTokenPickerSelect = () => { tokenBottomSheetRef.current?.snapToIndex(0) @@ -134,8 +153,10 @@ function EnterAmount({ // eventually we may want to do something smarter here, like subtracting gas fees from the max amount if // this is a gas-paying token. for now, we are just showing a warning to the user prompting them to lower the amount // if there is not enough for gas - setAmount(token.balance.toString()) - textInputRef.current?.blur() + setTokenAmountInput(token.balance.toString()) + setEnteredIn('token') + tokenAmountInputRef.current?.blur() + localAmountInputRef.current?.blur() ValoraAnalytics.track(SendEvents.max_pressed, { tokenId: token.tokenId, tokenAddress: token.address, @@ -143,14 +164,46 @@ function EnterAmount({ }) } - const handleSetStartPosition = (value?: number) => { - if (Platform.OS === 'android') { - setStartPosition(value) - } - } + const { decimalSeparator, groupingSeparator } = getNumberFormatSettings() + const parsedTokenAmount = useMemo( + () => parseInputAmount(tokenAmountInput, decimalSeparator), + [tokenAmountInput] + ) + const parsedLocalAmount = useMemo( + () => + parseInputAmount( + localAmountInput.replaceAll(groupingSeparator, '').replace(localCurrencySymbol, ''), + decimalSeparator + ), + [localAmountInput] + ) - const { decimalSeparator } = getNumberFormatSettings() - const parsedAmount = useMemo(() => parseInputAmount(amount, decimalSeparator), [amount]) + const tokenToLocal = useTokenToLocalAmount(parsedTokenAmount, token.tokenId) + const localToToken = useLocalToTokenAmount(parsedLocalAmount, token.tokenId) + const { tokenAmount, localAmount } = useMemo(() => { + if (enteredIn === 'token') { + setLocalAmountInput( + tokenToLocal && tokenToLocal.gt(0) + ? `${localCurrencySymbol}${tokenToLocal.toFormat(2)}` // automatically adds grouping separators + : '' + ) + return { + tokenAmount: parsedTokenAmount, + localAmount: tokenToLocal, + } + } else { + setTokenAmountInput( + localToToken && localToToken.gt(0) + ? // no group separator for token amount + localToToken.toFormat({ decimalSeparator }) + : '' + ) + return { + tokenAmount: localToToken, + localAmount: parsedLocalAmount, + } + } + }, [tokenAmountInput, localAmountInput, enteredIn, token]) const { maxFeeAmount, feeCurrency } = getFeeCurrencyAndAmounts(prepareTransactionsResult) @@ -159,16 +212,20 @@ function EnterAmount({ useEffect(() => { onClearPreparedTransactions() - if (parsedAmount.isLessThanOrEqualTo(0) || parsedAmount.isGreaterThan(token.balance)) { + if ( + !tokenAmount || + tokenAmount.isLessThanOrEqualTo(0) || + tokenAmount.isGreaterThan(token.balance) + ) { return } const debouncedRefreshTransactions = setTimeout(() => { - return onRefreshPreparedTransactions(parsedAmount, token, feeCurrencies) + return onRefreshPreparedTransactions(tokenAmount, token, feeCurrencies) }, FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME) return () => clearTimeout(debouncedRefreshTransactions) - }, [parsedAmount, token]) + }, [tokenAmount, token]) - const showLowerAmountError = token.balance.lt(amount) + const showLowerAmountError = token.balance.lt(tokenAmount ?? 0) const showMaxAmountWarning = !showLowerAmountError && prepareTransactionsResult && @@ -186,7 +243,7 @@ function EnterAmount({ const { tokenId: feeTokenId, symbol: feeTokenSymbol } = feeCurrency ?? feeCurrencies[0] let feeAmountSection = if ( - amount === '' || + tokenAmountInput === '' || showLowerAmountError || (prepareTransactionsResult && !maxFeeAmount) || prepareTransactionError @@ -196,6 +253,46 @@ function EnterAmount({ feeAmountSection = } + const onTokenAmountInputChange = (value: string) => { + if (!value) { + setTokenAmountInput('') + setEnteredIn('token') + } else { + if (value.startsWith(decimalSeparator)) { + value = `0${value}` + } + // only allow numbers and one decimal separator + if (value.match(/^(?:\d+[.,]?\d*|[.,]\d*|[.,])$/)) { + setTokenAmountInput(value) + setEnteredIn('token') + } + } + } + + const onLocalAmountInputChange = (value: string) => { + // remove leading currency symbol and grouping separators + if (value.startsWith(localCurrencySymbol)) { + value = value.slice(1) + } + value = value.replaceAll(groupingSeparator, '') + if (!value) { + setLocalAmountInput('') + setEnteredIn('local') + } else { + if (value.startsWith(decimalSeparator)) { + value = `0${value}` + } + + // only allow numbers, one decimal separator, and two decimal places + if (value.match(/^(\d+([.,])?\d{0,2}|[.,]\d{0,2}|[.,])$/)) { + setLocalAmountInput( + `${localCurrencySymbol}${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, groupingSeparator) + ) + setEnteredIn('local') + } + } + } + return ( } /> @@ -204,53 +301,14 @@ function EnterAmount({ {t('sendEnterAmountScreen.title')} - { - handleSetStartPosition(undefined) - if (!value) { - setAmount('') - } else { - if (value.startsWith(decimalSeparator)) { - value = `0${value}` - } - setAmount( - (prev) => value.match(/^(?:\d+[.,]?\d*|[.,]\d*|[.,])$/)?.join('') ?? prev - ) - } - }} - value={amount} - placeholder="0" - // hide input when loading to prevent the UI height from jumping - style={styles.input} - keyboardType="decimal-pad" - // Work around for RN issue with Samsung keyboards - // https://github.com/facebook/react-native/issues/22005 - autoCapitalize="words" - autoFocus={true} - // unset lineHeight to allow ellipsis on long inputs on iOS. For - // android, ellipses doesn't work and unsetting line height causes - // height changes when amount is entered - inputStyle={[ - styles.inputText, - Platform.select({ ios: { lineHeight: undefined } }), - showLowerAmountError && { color: Colors.error }, - ]} - testID="SendEnterAmount/Input" - onBlur={() => { - handleSetStartPosition(0) - }} - onFocus={() => { - handleSetStartPosition(amount?.length ?? 0) - }} - onSelectionChange={() => { - handleSetStartPosition(undefined) - }} - selection={ - Platform.OS === 'android' && typeof startPosition === 'number' - ? { start: startPosition } - : undefined - } + )} - onPressProceed(parsedAmount, token)} + onPress={() => + tokenAmount && + onPressProceed({ tokenAmount, localAmount, token, amountEnteredIn: enteredIn }) + } text={t('review')} style={styles.reviewButton} size={BtnSizes.FULL} @@ -359,6 +422,73 @@ function EnterAmount({ ) } +function AmountInput({ + inputValue, + onInputChange, + inputRef, + inputStyle, + autoFocus, + placeholder = '0', + testID = 'AmountInput', +}: { + inputValue: string + onInputChange(value: string): void + inputRef: React.MutableRefObject + inputStyle?: StyleProp + autoFocus?: boolean + placeholder?: string + testID?: string +}) { + // the startPosition and inputRef variables exist to ensure TextInput + // displays the start of the value for long values on Android + // https://github.com/facebook/react-native/issues/14845 + const [startPosition, setStartPosition] = useState(0) + + const handleSetStartPosition = (value?: number) => { + if (Platform.OS === 'android') { + setStartPosition(value) + } + } + + return ( + + { + handleSetStartPosition(undefined) + onInputChange(value) + }} + value={inputValue || undefined} + placeholder={placeholder} + keyboardType="decimal-pad" + // Work around for RN issue with Samsung keyboards + // https://github.com/facebook/react-native/issues/22005 + autoCapitalize="words" + autoFocus={autoFocus} + // unset lineHeight to allow ellipsis on long inputs on iOS. For + // android, ellipses doesn't work and unsetting line height causes + // height changes when amount is entered + inputStyle={[inputStyle, Platform.select({ ios: { lineHeight: undefined } })]} + testID={testID} + onBlur={() => { + handleSetStartPosition(0) + }} + onFocus={() => { + handleSetStartPosition(inputValue?.length ?? 0) + }} + onSelectionChange={() => { + handleSetStartPosition(undefined) + }} + selection={ + Platform.OS === 'android' && typeof startPosition === 'number' + ? { start: startPosition } + : undefined + } + /> + + ) +} + const styles = StyleSheet.create({ safeAreaContainer: { flex: 1, @@ -422,7 +552,6 @@ const styles = StyleSheet.create({ }, localAmount: { ...typeScale.labelMedium, - flex: 1, }, maxTouchable: { paddingHorizontal: 12, @@ -466,7 +595,7 @@ const styles = StyleSheet.create({ borderRadius: 100, }, lowerAmountError: { - color: Colors.error, + color: Colors.errorDark, ...typeScale.labelXSmall, paddingLeft: Spacing.Regular16, }, diff --git a/src/send/SendEnterAmount.test.tsx b/src/send/SendEnterAmount.test.tsx index 3186c82011d..762837e4b28 100644 --- a/src/send/SendEnterAmount.test.tsx +++ b/src/send/SendEnterAmount.test.tsx @@ -130,7 +130,7 @@ describe('SendEnterAmount', () => { ) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '.25') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '.25') await waitFor(() => expect(refreshPreparedTransactionsSpy).toHaveBeenCalledTimes(1)) expect(refreshPreparedTransactionsSpy).toHaveBeenCalledWith({ @@ -159,17 +159,17 @@ describe('SendEnterAmount', () => { ) - fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '8') + fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '8') await waitFor(() => expect(getByText('review')).not.toBeDisabled()) fireEvent.press(getByText('review')) await waitFor(() => expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1)) expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.send_amount_continue, { - amountInUsd: null, + amountInUsd: '106.01', isScan: false, localCurrency: 'PHP', - localCurrencyAmount: '140.9891060477188235021376', + localCurrencyAmount: '140.99', localCurrencyExchangeRate: '1.33', networkId: 'celo-alfajores', origin: 'app_send_flow', @@ -178,6 +178,7 @@ describe('SendEnterAmount', () => { underlyingAmount: '8', underlyingTokenAddress: mockCeloAddress, underlyingTokenSymbol: 'CELO', + amountEnteredIn: 'token', }) expect(navigate).toHaveBeenCalledWith(Screens.SendConfirmation, { origin: params.origin, diff --git a/src/send/SendEnterAmount.tsx b/src/send/SendEnterAmount.tsx index 0225b5c5345..27c61300f21 100644 --- a/src/send/SendEnterAmount.tsx +++ b/src/send/SendEnterAmount.tsx @@ -8,13 +8,13 @@ import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' import { useSelector } from 'src/redux/hooks' -import EnterAmount from 'src/send/EnterAmount' +import EnterAmount, { ProceedArgs } from 'src/send/EnterAmount' import { lastUsedTokenIdSelector } from 'src/send/selectors' import { usePrepareSendTransactions } from 'src/send/usePrepareSendTransactions' import { COMMENT_PLACEHOLDER_FOR_FEE_ESTIMATE } from 'src/send/utils' import { sortedTokensWithBalanceOrShowZeroBalanceSelector } from 'src/tokens/selectors' import { TokenBalance } from 'src/tokens/slice' -import { convertTokenToLocalAmount, getSupportedNetworkIdsForSend } from 'src/tokens/utils' +import { getSupportedNetworkIdsForSend } from 'src/tokens/utils' import Logger from 'src/utils/Logger' import { walletAddressSelector } from 'src/web3/selectors' @@ -42,28 +42,22 @@ function SendEnterAmount({ route }: Props) { const localCurrencyCode = useSelector(getLocalCurrencyCode) const localCurrencyExchangeRate = useSelector(usdToLocalCurrencyRateSelector) - const handleReviewSend = (parsedAmount: BigNumber, token: TokenBalance) => { + const handleReviewSend = ({ tokenAmount, localAmount, token, amountEnteredIn }: ProceedArgs) => { if (!prepareTransactionsResult || prepareTransactionsResult.type !== 'possible') { // should never happen because button is disabled if send is not possible throw new Error('No prepared transactions found') } - const sendAmountInLocalCurrency = convertTokenToLocalAmount({ - tokenAmount: parsedAmount, - tokenInfo: token, - usdToLocalRate: localCurrencyExchangeRate, - }) - navigate(Screens.SendConfirmation, { origin, isFromScan, transactionData: { tokenId: token.tokenId, recipient, - inputAmount: parsedAmount, + inputAmount: tokenAmount, amountIsInLocalCurrency: false, tokenAddress: token.address!, - tokenAmount: parsedAmount, + tokenAmount, }, }) ValoraAnalytics.track(SendEvents.send_amount_continue, { @@ -72,11 +66,12 @@ function SendEnterAmount({ route }: Props) { recipientType: recipient.recipientType, localCurrencyExchangeRate, localCurrency: localCurrencyCode, - localCurrencyAmount: sendAmountInLocalCurrency?.toString() ?? null, + localCurrencyAmount: localAmount?.toFixed(2) ?? null, underlyingTokenAddress: token.address, underlyingTokenSymbol: token.symbol, - underlyingAmount: parsedAmount.toString(), - amountInUsd: null, + underlyingAmount: tokenAmount.toString(), + amountInUsd: tokenAmount.multipliedBy(token.priceUsd ?? 0).toFixed(2), + amountEnteredIn, tokenId: token.tokenId, networkId: token.networkId, }) diff --git a/src/send/types.ts b/src/send/types.ts index d0749dc22ab..ab492338d96 100644 --- a/src/send/types.ts +++ b/src/send/types.ts @@ -21,3 +21,5 @@ export interface TransactionDataInput { tokenAmount: BigNumber comment?: string } + +export type AmountEnteredIn = 'local' | 'token'