diff --git a/web/cashtab/src/components/Common/BalanceHeaderToken.js b/web/cashtab/src/components/Common/BalanceHeaderToken.js new file mode 100644 index 000000000..c5537a3af --- /dev/null +++ b/web/cashtab/src/components/Common/BalanceHeaderToken.js @@ -0,0 +1,21 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { formatTokenBalance } from 'utils/formatting'; +import { BalanceHeaderWrap } from 'components/Common/Atoms'; + +const BalanceHeaderToken = ({ balance, ticker, tokenDecimals }) => { + return ( + + {formatTokenBalance(balance, tokenDecimals)} {ticker} + + ); +}; + +// balance may be a string (XEC balance) or a BigNumber object (token balance) +BalanceHeaderToken.propTypes = { + balance: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + ticker: PropTypes.string, + tokenDecimals: PropTypes.number, +}; + +export default BalanceHeaderToken; diff --git a/web/cashtab/src/components/Home/TokenList.js b/web/cashtab/src/components/Home/TokenList.js index ba177db2b..0c38b73fc 100644 --- a/web/cashtab/src/components/Home/TokenList.js +++ b/web/cashtab/src/components/Home/TokenList.js @@ -1,27 +1,30 @@ import React from 'react'; import PropTypes from 'prop-types'; import TokenListItem from './TokenListItem'; import { Link } from 'react-router-dom'; -import { formatBalance } from 'utils/formatting'; +import { formatTokenBalance } from 'utils/formatting'; const TokenList = ({ tokens }) => { return (
{tokens.map(token => ( ))}
); }; TokenList.propTypes = { tokens: PropTypes.array, }; export default TokenList; diff --git a/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap b/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap index 791c24933..284478223 100644 --- a/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap +++ b/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap @@ -1,633 +1,631 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [

MigrationTestAlpha

edit.svg
0 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
Create eToken

Tokens sent to your eToken address will appear here

, ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [

MigrationTestAlpha

edit.svg
0 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
Create eToken

Tokens sent to your eToken address will appear here

, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [

MigrationTestAlpha

edit.svg
0.06 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
Create eToken
, ] `; exports[`Wallet without BCH balance 1`] = ` Array [

MigrationTestAlpha

edit.svg
0 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
Create eToken

Tokens sent to your eToken address will appear here

, ] `; exports[`Without wallet defined 1`] = `

Welcome to Cashtab!

Cashtab is an open source, non-custodial web wallet for eCash .

Want to learn more? Check out the Cashtab documentation.

`; diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js index ded08dd1d..13b704f02 100644 --- a/web/cashtab/src/components/Send/SendToken.js +++ b/web/cashtab/src/components/Send/SendToken.js @@ -1,792 +1,793 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import { WalletContext } from 'utils/context'; import { Form, message, Row, Col, Alert, Descriptions, Modal, Button, Input, } from 'antd'; import PrimaryButton, { SecondaryButton, } from 'components/Common/PrimaryButton'; import { FireTwoTone } from '@ant-design/icons'; import { DestinationAmount, DestinationAddressSingle, AntdFormWrapper, } from 'components/Common/EnhancedInputs'; import useBCH from 'hooks/useBCH'; import { SidePaddingCtn } from 'components/Common/Atoms'; -import BalanceHeader from 'components/Common/BalanceHeader'; +import BalanceHeaderToken from 'components/Common/BalanceHeaderToken'; import { Redirect } from 'react-router-dom'; import useWindowDimensions from 'hooks/useWindowDimensions'; import usePrevious from 'hooks/usePrevious'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { Img } from 'react-image'; import makeBlockie from 'ethereum-blockies-base64'; import BigNumber from 'bignumber.js'; import { currency, parseAddressForParams } from 'components/Common/Ticker.js'; import { Event } from 'utils/GoogleAnalytics'; import { getWalletState, toLegacyToken } from 'utils/cashMethods'; import ApiError from 'components/Common/ApiError'; import { sendTokenNotification, errorNotification, burnTokenNotification, } from 'components/Common/Notifications'; import { isValidXecAddress, isValidEtokenAddress, isValidEtokenBurnAmount, } from 'utils/validation'; import { getTokenStats } from 'utils/chronik'; import { formatDate } from 'utils/formatting'; import styled, { css } from 'styled-components'; import TokenIcon from 'components/Tokens/TokenIcon'; const AntdDescriptionsCss = css` .ant-descriptions-item-label, .ant-input-number, .ant-descriptions-item-content { background-color: ${props => props.theme.contrast} !important; color: ${props => props.theme.dropdownText}; } .ant-descriptions-title { color: ${props => props.theme.lightWhite}; } `; const AntdDescriptionsWrapper = styled.div` ${AntdDescriptionsCss} `; const AirdropButton = styled.div` text-align: center; width: 100%; padding: 10px; border-radius: 5px; background: ${props => props.theme.sentMessage}; a { color: ${props => props.theme.darkBlue}; margin: 0; font-size: 11px; border: 1px solid ${props => props.theme.darkBlue}; border-radius: 5px; padding: 2px 10px; opacity: 0.6; } a:hover { opacity: 1; border-color: ${props => props.theme.eCashBlue}; color: ${props => props.theme.contrast}; background: ${props => props.theme.eCashBlue}; } ${({ received, ...props }) => received && ` text-align: left; background: ${props.theme.receivedMessage}; `} `; const SendToken = ({ tokenId, jestBCH, passLoadingStatus }) => { const { BCH, wallet, apiError, cashtabSettings, chronik } = React.useContext(WalletContext); const walletState = getWalletState(wallet); const { tokens } = walletState; const token = tokens.find(token => token.tokenId === tokenId); const previousToken = usePrevious(token); const [tokenStats, setTokenStats] = useState(null); const [queryStringText, setQueryStringText] = useState(null); const [sendTokenAddressError, setSendTokenAddressError] = useState(false); const [sendTokenAmountError, setSendTokenAmountError] = useState(false); const [eTokenBurnAmount, setETokenBurnAmount] = useState(new BigNumber(1)); const [showConfirmBurnEtoken, setShowConfirmBurnEtoken] = useState(false); const [burnTokenAmountError, setBurnTokenAmountError] = useState(false); const [burnConfirmationValid, setBurnConfirmationValid] = useState(null); const [confirmationOfEtokenToBeBurnt, setConfirmationOfEtokenToBeBurnt] = useState(''); const [bchObj, setBchObj] = useState(false); // Get device window width // If this is less than 769, the page will open with QR scanner open const { width } = useWindowDimensions(); // Load with QR code open if device is mobile and NOT iOS + anything but safari const scannerSupported = cashtabSettings && cashtabSettings.autoCameraOn === true && width < 769 && isMobile && !(isIOS && !isSafari); const [isModalVisible, setIsModalVisible] = useState(false); const [formData, setFormData] = useState({ value: '', address: '', }); const { getRestUrl, sendToken, burnToken } = useBCH(); useEffect(() => { // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); const activeBCH = jestBCH ? jestBCH : BCH; // set the BCH instance to state, for other functions to reference setBchObj(activeBCH); }, [BCH]); // Fetch token stats if you do not have them and API did not return an error if (tokenStats === null) { getTokenStats(chronik, tokenId).then( result => { setTokenStats(result); }, err => { console.log(`Error getting token stats: ${err}`); }, ); } // Clears address and amount fields following sendTokenNotification const clearInputForms = () => { setFormData({ value: '', address: '', }); }; async function submit() { setFormData({ ...formData, }); if ( !formData.address || !formData.value || Number(formData.value <= 0) || sendTokenAmountError ) { return; } // Event("Category", "Action", "Label") // Track number of SLPA send transactions and // SLPA token IDs Event('SendToken.js', 'Send', tokenId); passLoadingStatus(true); const { address, value } = formData; // Clear params from address let cleanAddress = address.split('?')[0]; // Convert to simpleledger prefix if etoken cleanAddress = toLegacyToken(cleanAddress); try { const link = await sendToken(bchObj, chronik, wallet, { tokenId: tokenId, tokenReceiverAddress: cleanAddress, amount: value, }); sendTokenNotification(link); clearInputForms(); } catch (e) { passLoadingStatus(false); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else { message = e.message || e.error || JSON.stringify(e); } errorNotification(e, message, 'Sending eToken'); } } const handleSlpAmountChange = e => { let error = false; const { value, name } = e.target; // test if exceeds balance using BigNumber let isGreaterThanBalance = false; if (!isNaN(value)) { const bigValue = new BigNumber(value); // Returns 1 if greater, -1 if less, 0 if the same, null if n/a isGreaterThanBalance = bigValue.comparedTo(token.balance); } // Validate value for > 0 if (isNaN(value)) { error = 'Amount must be a number'; } else if (value <= 0) { error = 'Amount must be greater than 0'; } else if (token && token.balance && isGreaterThanBalance === 1) { error = `Amount cannot exceed your ${token.info.tokenTicker} balance of ${token.balance}`; } else if (!isNaN(value) && value.toString().includes('.')) { if (value.toString().split('.')[1].length > token.info.decimals) { error = `This token only supports ${token.info.decimals} decimal places`; } } setSendTokenAmountError(error); setFormData(p => ({ ...p, [name]: value, })); }; const handleTokenAddressChange = e => { const { value, name } = e.target; // validate for token address // validate for parameters // show warning that query strings are not supported let error = false; let addressString = value; const isValid = isValidEtokenAddress(addressString); const addressInfo = parseAddressForParams(addressString); /* Model addressInfo = { address: '', queryString: '', amount: null, }; */ const { address, queryString } = addressInfo; // If query string, // Show an alert that only amount and currency.ticker are supported setQueryStringText(queryString); // Is this valid address? if (!isValid) { error = 'Address is not a valid etoken: address'; // If valid address but xec format if (isValidXecAddress(address)) { error = `Cashtab does not support sending eTokens to XEC addresses. Please convert to an eToken address.`; } } setSendTokenAddressError(error); setFormData(p => ({ ...p, [name]: value, })); }; const onMax = async () => { // Clear this error before updating field setSendTokenAmountError(false); try { let value = token.balance; setFormData({ ...formData, value, }); } catch (err) { console.log(`Error in onMax:`); console.log(err); message.error( 'Unable to calculate the max value due to network errors', ); } }; const checkForConfirmationBeforeSendEtoken = () => { if (cashtabSettings.sendModal) { setIsModalVisible(cashtabSettings.sendModal); } else { // if the user does not have the send confirmation enabled in settings then send directly submit(); } }; const handleOk = () => { setIsModalVisible(false); submit(); }; const handleCancel = () => { setIsModalVisible(false); }; const handleEtokenBurnAmountChange = e => { const { value } = e.target; const burnAmount = new BigNumber(value); setETokenBurnAmount(burnAmount); let error = false; if (!isValidEtokenBurnAmount(burnAmount, token.balance)) { error = 'Burn amount must be between 1 and ' + token.balance; } setBurnTokenAmountError(error); }; const onMaxBurn = () => { setETokenBurnAmount(token.balance); // trigger validation on the inserted max value handleEtokenBurnAmountChange({ target: { value: token.balance, }, }); }; async function burn() { if ( !burnConfirmationValid || burnConfirmationValid === null || !eTokenBurnAmount ) { return; } // Event("Category", "Action", "Label") Event('SendToken.js', 'Burn eToken', tokenId); passLoadingStatus(true); try { const link = await burnToken(bchObj, chronik, wallet, { tokenId: tokenId, amount: eTokenBurnAmount, }); burnTokenNotification(link); clearInputForms(); setShowConfirmBurnEtoken(false); setConfirmationOfEtokenToBeBurnt(''); } catch (e) { setShowConfirmBurnEtoken(false); passLoadingStatus(false); setConfirmationOfEtokenToBeBurnt(''); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if (/dust/.test(e.error)) { message = 'Unable to burn due to insufficient eToken utxos.'; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else { message = e.message || e.error || JSON.stringify(e); } errorNotification(e, message, 'Burning eToken'); } } const handleBurnConfirmationInput = e => { const { value } = e.target; if (value && value === `burn ${token.info.tokenTicker}`) { setBurnConfirmationValid(true); } else { setBurnConfirmationValid(false); } setConfirmationOfEtokenToBeBurnt(value); }; const handleBurnAmountInput = () => { if (!burnTokenAmountError) { setShowConfirmBurnEtoken(true); } }; useEffect(() => { /* If the balance has changed, unlock the UI Note that the 'token' dependency changes every time the wallet state is set This useEffect loop can't use token.balance as the dependency as this is occasionally undefined, which the screen UI also makes use of */ if ( token && token.balance && previousToken && previousToken.balance && token.balance.toString() !== previousToken.balance.toString() ) { passLoadingStatus(false); } // In the case of a tx that burns all of a token, also lose the loader if (!token && previousToken) { passLoadingStatus(false); } }, [token]); return ( <>

{token && token.info && formData ? `Are you sure you want to send ${formData.value}${' '} ${token.info.tokenTicker} to ${formData.address}?` : ''}

{!token && } {token && ( {/* eToken burn modal */} setShowConfirmBurnEtoken(false)} >
} placeholder={`Type "burn ${token.info.tokenTicker}" to confirm`} name="etokenToBeBurnt" value={confirmationOfEtokenToBeBurnt} onChange={e => handleBurnConfirmationInput(e) } />
-
handleTokenAddressChange({ target: { name: 'address', value: result, }, }) } inputProps={{ placeholder: `${currency.tokenTicker} Address`, name: 'address', onChange: e => handleTokenAddressChange(e), required: true, value: formData.address, }} /> } /> ) : ( {`identicon ), suffix: token.info.tokenTicker, onChange: e => handleSlpAmountChange(e), required: true, value: formData.value, }} />
{apiError || sendTokenAmountError || sendTokenAddressError ? ( <> Send {token.info.tokenName} ) : ( checkForConfirmationBeforeSendEtoken() } > Send {token.info.tokenName} )}
{queryStringText && ( )} {apiError && } {tokenStats !== null && ( {token.info.decimals} {token.tokenId}
Airdrop XEC to holders
{tokenStats && ( <> { tokenStats.slpTxData .genesisInfo .tokenDocumentUrl } {tokenStats.block && tokenStats.block .timestamp !== null ? formatDate( tokenStats.block .timestamp, navigator.language, ) : 'Just now (Genesis tx confirming)'} {tokenStats.containsBaton ? 'No' : 'Yes'} {new BigNumber( tokenStats.initialTokenQuantity, ) .toFormat( token.info.decimals, ) .toLocaleString()} {new BigNumber( tokenStats.tokenStats.totalBurned, ) .toFormat( token.info.decimals, ) .toLocaleString()} {new BigNumber( tokenStats.tokenStats.totalMinted, ) .toFormat( token.info.decimals, ) .toLocaleString()} {new BigNumber( tokenStats.circulatingSupply, ) .toFormat( token.info.decimals, ) .toLocaleString()} handleEtokenBurnAmountChange( e, ), initialvalue: 1, value: eTokenBurnAmount, prefix: ( ), }} /> )}
)}
)} ); }; /* passLoadingStatus must receive a default prop that is a function in order to pass the rendering unit test in SendToken.test.js status => {console.log(status)} is an arbitrary stub function */ SendToken.defaultProps = { passLoadingStatus: status => { console.log(status); }, }; SendToken.propTypes = { tokenId: PropTypes.string, jestBCH: PropTypes.object, passLoadingStatus: PropTypes.func, }; export default SendToken; diff --git a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap index 6359b2841..6fdafd72b 100644 --- a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap +++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap @@ -1,245 +1,238 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances and tokens 1`] = `null`; exports[`Wallet with BCH balances and tokens and state field 1`] = `
- - - 6.001 - - TBS - - + + TBS
`; exports[`Without wallet defined 1`] = `null`; diff --git a/web/cashtab/src/utils/__tests__/formatting.test.js b/web/cashtab/src/utils/__tests__/formatting.test.js index 18ed3c27e..c6997276e 100644 --- a/web/cashtab/src/utils/__tests__/formatting.test.js +++ b/web/cashtab/src/utils/__tests__/formatting.test.js @@ -1,140 +1,161 @@ +import BigNumber from 'bignumber.js'; import { formatDate, formatFiatBalance, formatSavedBalance, formatBalance, + formatTokenBalance, } from 'utils/formatting'; describe('Correctly executes formatting functions', () => { it(`test formatBalance with an input of 0`, () => { expect(formatBalance('0')).toBe('0'); }); it(`test formatBalance with zero XEC balance input`, () => { expect(formatBalance('0', 'en-US')).toBe('0'); }); it(`test formatBalance with a small XEC balance input with 2+ decimal figures`, () => { expect(formatBalance('1574.5445', 'en-US')).toBe('1,574.54'); }); it(`test formatBalance with 1 Million XEC balance input`, () => { expect(formatBalance('1000000', 'en-US')).toBe('1,000,000'); }); it(`test formatBalance with 1 Billion XEC balance input`, () => { expect(formatBalance('1000000000', 'en-US')).toBe('1,000,000,000'); }); it(`test formatBalance with total supply as XEC balance input`, () => { expect(formatBalance('21000000000000', 'en-US')).toBe( '21,000,000,000,000', ); }); it(`test formatBalance with > total supply as XEC balance input`, () => { expect(formatBalance('31000000000000', 'en-US')).toBe( '31,000,000,000,000', ); }); it(`test formatBalance with no balance`, () => { expect(formatBalance('', 'en-US')).toBe('0'); }); it(`test formatBalance with null input`, () => { expect(formatBalance(null, 'en-US')).toBe('0'); }); it(`test formatBalance with undefined as input`, () => { expect(formatBalance(undefined, 'en-US')).toBe('NaN'); }); it(`test formatBalance with non-numeric input`, () => { expect(formatBalance('CainBCHA', 'en-US')).toBe('NaN'); }); it(`Accepts a valid unix timestamp`, () => { expect(formatDate('1639679649', 'fr')).toBe('16 déc. 2021'); }); it(`Accepts an empty string and generates a new timestamp`, () => { expect(formatDate('', 'en-US')).toBe( new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }), ); }); it(`Accepts no parameter and generates a new timestamp`, () => { expect(formatDate(null, 'en-US')).toBe( new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }), ); }); it(`Accepts 'undefined' as a parameter and generates a new date`, () => { expect(formatDate(undefined, 'en-US')).toBe( new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }), ); }); it(`Rejects an invalid string containing letters.`, () => { expect(formatDate('f', 'en-US')).toBe('Invalid Date'); }); it(`Rejects an invalid string containing numbers.`, () => { expect(formatDate('10000000000000000', 'en-US')).toBe('Invalid Date'); }); it(`test formatSavedBalance with zero XEC balance input`, () => { expect(formatSavedBalance('0', 'en-US')).toBe('0'); }); it(`test formatSavedBalance with a small XEC balance input with 2+ decimal figures`, () => { expect(formatSavedBalance('1574.5445', 'en-US')).toBe('1,574.54'); }); it(`test formatSavedBalance with 1 Million XEC balance input`, () => { expect(formatSavedBalance('1000000', 'en-US')).toBe('1,000,000'); }); it(`test formatSavedBalance with 1 Billion XEC balance input`, () => { expect(formatSavedBalance('1000000000', 'en-US')).toBe('1,000,000,000'); }); it(`test formatSavedBalance with total supply as XEC balance input`, () => { expect(formatSavedBalance('21000000000000', 'en-US')).toBe( '21,000,000,000,000', ); }); it(`test formatSavedBalance with > total supply as XEC balance input`, () => { expect(formatSavedBalance('31000000000000', 'en-US')).toBe( '31,000,000,000,000', ); }); it(`test formatSavedBalance with no balance`, () => { expect(formatSavedBalance('', 'en-US')).toBe('0'); }); it(`test formatSavedBalance with null input`, () => { expect(formatSavedBalance(null, 'en-US')).toBe('0'); }); it(`test formatSavedBalance with undefined sw.state.balance or sw.state.balance.totalBalance as input`, () => { expect(formatSavedBalance(undefined, 'en-US')).toBe('N/A'); }); it(`test formatSavedBalance with non-numeric input`, () => { expect(formatSavedBalance('CainBCHA', 'en-US')).toBe('NaN'); }); it(`test formatFiatBalance with zero XEC balance input`, () => { expect(formatFiatBalance(Number('0'), 'en-US')).toBe('0.00'); }); it(`test formatFiatBalance with a small XEC balance input with 2+ decimal figures`, () => { expect(formatFiatBalance(Number('565.54111'), 'en-US')).toBe('565.54'); }); it(`test formatFiatBalance with a large XEC balance input with 2+ decimal figures`, () => { expect(formatFiatBalance(Number('131646565.54111'), 'en-US')).toBe( '131,646,565.54', ); }); it(`test formatFiatBalance with no balance`, () => { expect(formatFiatBalance('', 'en-US')).toBe(''); }); it(`test formatFiatBalance with null input`, () => { expect(formatFiatBalance(null, 'en-US')).toBe(null); }); it(`test formatFiatBalance with undefined input`, () => { expect(formatFiatBalance(undefined, 'en-US')).toBe(undefined); }); + it(`returns undefined formatTokenBalance with undefined inputs`, () => { + expect(formatTokenBalance(undefined, undefined)).toBe(undefined); + }); + it(`test formatTokenBalance with valid balance & decimal inputs`, () => { + const testBalance = new BigNumber(100.00000001); + expect(formatTokenBalance(testBalance, 8)).toBe('100.00000001'); + }); + it(`returns undefined when passed invalid decimals parameter`, () => { + const testBalance = new BigNumber(100.00000001); + expect(formatTokenBalance(testBalance, 'cheese')).toBe(undefined); + }); + it(`returns undefined when passed invalid balance parameter`, () => { + const testBalance = '100.000010122'; + expect(formatTokenBalance(testBalance, 9)).toBe(undefined); + }); + it(`maintains trailing zeros in balance per tokenDecimal parameter`, () => { + const testBalance = new BigNumber(10000); + expect(formatTokenBalance(testBalance, 8)).toBe('10,000.00000000'); + }); }); diff --git a/web/cashtab/src/utils/formatting.js b/web/cashtab/src/utils/formatting.js index 1cee28a5d..58e5a80f4 100644 --- a/web/cashtab/src/utils/formatting.js +++ b/web/cashtab/src/utils/formatting.js @@ -1,72 +1,113 @@ import { currency } from 'components/Common/Ticker.js'; - +import BigNumber from 'bignumber.js'; export const formatDate = (dateString, userLocale = 'en') => { const options = { month: 'short', day: 'numeric', year: 'numeric' }; const dateFormattingError = 'Unable to format date.'; try { if (dateString) { return new Date(dateString * 1000).toLocaleDateString( userLocale, options, ); } return new Date().toLocaleDateString(userLocale, options); } catch (error) { return dateFormattingError; } }; export const formatFiatBalance = (fiatBalance, optionalLocale) => { try { if (fiatBalance === 0) { return Number(fiatBalance).toFixed(currency.cashDecimals); } if (optionalLocale === undefined) { return fiatBalance.toLocaleString({ maximumFractionDigits: currency.cashDecimals, }); } return fiatBalance.toLocaleString(optionalLocale, { maximumFractionDigits: currency.cashDecimals, }); } catch (err) { return fiatBalance; } }; export const formatSavedBalance = (swBalance, optionalLocale) => { try { if (swBalance === undefined) { return 'N/A'; } else { if (optionalLocale === undefined) { return new Number(swBalance).toLocaleString({ maximumFractionDigits: currency.cashDecimals, }); } else { return new Number(swBalance).toLocaleString(optionalLocale, { maximumFractionDigits: currency.cashDecimals, }); } } } catch (err) { return 'N/A'; } }; export const formatBalance = (unformattedBalance, optionalLocale) => { try { if (optionalLocale === undefined) { return new Number(unformattedBalance).toLocaleString({ maximumFractionDigits: currency.cashDecimals, }); } return new Number(unformattedBalance).toLocaleString(optionalLocale, { maximumFractionDigits: currency.cashDecimals, }); } catch (err) { console.log(`Error in formatBalance for ${unformattedBalance}`); console.log(err); return unformattedBalance; } }; + +// unformattedBalance will always be a BigNumber, tokenDecimal will always be a number +export const formatTokenBalance = ( + unformattedBalance, + tokenDecimal, + defaultLocale = 'en', +) => { + let formattedTokenBalance; + let convertedTokenBalance; + let locale = defaultLocale; + try { + if ( + tokenDecimal === undefined || + unformattedBalance === undefined || + typeof tokenDecimal !== 'number' || + !BigNumber.isBigNumber(unformattedBalance) + ) { + return undefined; + } + if (navigator && navigator.language) { + locale = navigator.language; + } + + // Use toFixed to get a string with the correct decimal places + formattedTokenBalance = new BigNumber(unformattedBalance).toFixed( + tokenDecimal, + ); + // formattedTokenBalance is converted into a number as toLocaleString does not work with a string + convertedTokenBalance = parseFloat( + formattedTokenBalance, + ).toLocaleString(locale, { + minimumFractionDigits: tokenDecimal, + }); + + return convertedTokenBalance; + } catch (err) { + console.log(`Error in formatTokenBalance for ${unformattedBalance}`); + console.log(err); + return unformattedBalance; + } +};