diff --git a/app/components/UI/CryptoAmountInput.js b/app/components/UI/CryptoAmountInput.js new file mode 100644 index 00000000..84b8d390 --- /dev/null +++ b/app/components/UI/CryptoAmountInput.js @@ -0,0 +1,149 @@ +/* eslint-disable react/no-multi-comp */ + +import React from 'react' +import PropTypes from 'prop-types' +import { asField } from 'informed' +import * as yup from 'yup' +import { convert } from 'lib/utils/btc' +import { formatValue, parseNumber } from 'lib/utils/crypto' +import Input from 'components/UI/Input' + +/** + * @render react + * @name CryptoAmountInput + */ +class CryptoAmountInput extends React.Component { + static propTypes = { + currency: PropTypes.string.isRequired, + required: PropTypes.bool, + onChange: PropTypes.func, + onBlur: PropTypes.func + } + + /** + * Reformat the value when the currency unit has changed. + */ + componentDidUpdate(prevProps) { + const { currency, fieldApi } = this.props + + // Reformat the value when the currency unit has changed. + if (currency !== prevProps.currency) { + const { fieldApi } = this.props + let value = fieldApi.getValue() + const convertedValue = convert(prevProps.currency, currency, value) + const [integer, fractional] = parseNumber(convertedValue, this.getRules().precision) + value = formatValue(integer, fractional) + fieldApi.setValue(value) + } + + // If the value has changed, reformat it if needed. + const valueBefore = prevProps.fieldState.value + const valueAfter = fieldApi.getValue() + if (valueAfter !== valueBefore) { + const [integer, fractional] = parseNumber(valueAfter, this.getRules().precision) + const formattedValue = formatValue(integer, fractional) + if (formattedValue !== valueAfter) { + fieldApi.setValue(formattedValue) + } + } + } + + getRules = () => { + const { currency } = this.props + switch (currency) { + case 'btc': + return { + precision: 8, + placeholder: '0.00000000', + pattern: '[0-9]*.?[0-9]{0,8}?' + } + case 'bits': + return { + precision: 2, + placeholder: '0.00', + pattern: '[0-9]*.?[0-9]{0,2}?' + } + case 'sats': + return { + precision: 0, + placeholder: '00000000', + pattern: '[0-9]*' + } + default: + return { + precision: 2, + pattern: '[0-9]*' + } + } + } + + handleKeyDown = e => { + // Do nothing if the user did select all key combo. + if (e.metaKey && e.key === 'a') { + return + } + + // Do not allow multiple dots. + let { value } = e.target + if (e.key === '.') { + if (value.search(/\./) >= 0) { + e.preventDefault() + } + return + } + + if (e.key.length === 1 && !e.key.match(/^[0-9.]$/)) { + e.preventDefault() + return + } + } + + render() { + const rules = this.getRules() + return ( + + ) + } +} + +const CryptoAmountInputAsField = asField(CryptoAmountInput) + +class WrappedCryptoAmountInputAsField extends React.Component { + validate = value => { + const { disabled, required } = this.props + if (disabled) { + return + } + try { + const validator = yup + .number() + .positive() + .min(0) + .typeError('A number is required') + if (required) { + validator.required() + } + validator.validateSync(Number(value)) + } catch (error) { + return error.message + } + + // Run any additional validation provided by the caller. + const { validate } = this.props + if (validate) { + return validate(value) + } + } + + render() { + return + } +} + +export default WrappedCryptoAmountInputAsField diff --git a/app/components/UI/FiatAmountInput.js b/app/components/UI/FiatAmountInput.js new file mode 100644 index 00000000..37e1aa05 --- /dev/null +++ b/app/components/UI/FiatAmountInput.js @@ -0,0 +1,131 @@ +/* eslint-disable react/no-multi-comp */ + +import React from 'react' +import PropTypes from 'prop-types' +import { asField } from 'informed' +import * as yup from 'yup' +import { convert } from 'lib/utils/btc' +import { formatValue, parseNumber } from 'lib/utils/crypto' +import Input from 'components/UI/Input' + +/** + * @render react + * @name FiatAmountInput + */ +class FiatAmountInput extends React.Component { + static propTypes = { + currency: PropTypes.string.isRequired, + currentTicker: PropTypes.object.isRequired, + required: PropTypes.bool, + onChange: PropTypes.func, + onBlur: PropTypes.func + } + + componentDidUpdate(prevProps) { + const { currency, currentTicker, fieldApi } = this.props + + // Reformat the value when the currency unit has changed. + if (currency !== prevProps.currency) { + const { fieldApi } = this.props + let value = fieldApi.getValue() + const lastPriceInOrigCurrency = currentTicker[prevProps.currency].last + const lastPriceInNewCurrency = currentTicker[currency].last + // Convert to BTC. + const btcValue = convert('fiat', 'btc', value, lastPriceInOrigCurrency) + // Convert to new currency. + const newFiatValue = convert('btc', 'fiat', btcValue, lastPriceInNewCurrency) + const [integer, fractional] = parseNumber(newFiatValue, this.getRules().precision) + value = formatValue(integer, fractional) + fieldApi.setValue(value) + } + + // If the value has changed, reformat it if needed. + const valueBefore = prevProps.fieldState.value + const valueAfter = fieldApi.getValue() + if (valueAfter !== valueBefore) { + const [integer, fractional] = parseNumber(valueAfter, this.getRules().precision) + const formattedValue = formatValue(integer, fractional) + if (formattedValue !== valueAfter) { + fieldApi.setValue(formattedValue) + } + } + } + + getRules() { + return { + precision: 2, + placeholder: '0.00', + pattern: '[0-9]*.?[0-9]{0,2}?' + } + } + + handleKeyDown = e => { + // Do nothing if the user did select all key combo. + if (e.metaKey && e.key === 'a') { + return + } + + // Do not allow multiple dots. + let { value } = e.target + if (e.key === '.') { + if (value.search(/\./) >= 0) { + e.preventDefault() + } + return + } + + if (e.key.length === 1 && !e.key.match(/^[0-9.]$/)) { + e.preventDefault() + return + } + } + + render() { + const rules = this.getRules() + return ( + + ) + } +} + +const FiatAmountInputAsField = asField(FiatAmountInput) + +class WrappedFiatAmountInputAsField extends React.Component { + validate = value => { + const { disabled, required } = this.props + if (disabled) { + return + } + try { + const validator = yup + .number() + .positive() + .min(0) + .typeError('A number is required') + if (required) { + validator.required() + } + validator.validateSync(Number(value)) + } catch (error) { + return error.message + } + + // Run any additional validation provided by the caller. + const { validate } = this.props + if (validate) { + return validate(value) + } + } + + render() { + return + } +} + +export default WrappedFiatAmountInputAsField diff --git a/app/components/UI/index.js b/app/components/UI/index.js index c1539a35..75fd429c 100644 --- a/app/components/UI/index.js +++ b/app/components/UI/index.js @@ -3,7 +3,9 @@ export BackgroundLight from './BackgroundLight' export BackgroundLightest from './BackgroundLightest' export Bar from './Bar' export Button from './Button' +export CryptoAmountInput from './CryptoAmountInput' export Dropdown from './Dropdown' +export FiatAmountInput from './FiatAmountInput' export FormFieldMessage from './FormFieldMessage' export GlobalStyle from './GlobalStyle' export Heading from './Heading' diff --git a/app/lib/utils/btc.js b/app/lib/utils/btc.js index 5e44c444..717235f5 100644 --- a/app/lib/utils/btc.js +++ b/app/lib/utils/btc.js @@ -74,6 +74,25 @@ export function satoshisToFiat(satoshis, price) { return btcToFiat(satoshisToBtc(satoshis), price) } +//////////////////////////// +// fiat to things ///// +////////////////////////// +////////////////////////// +export function fiatToBtc(fiat, price) { + if (fiat === undefined || fiat === null || fiat === '' || !price) return null + + return Number(fiat / price) +} + +export function fiatToBits(fiat, price) { + return btcToBits(fiatToBtc(fiat, price)) +} + +export function fiatToSatoshis(fiat, price) { + return btcToSatoshis(fiatToBtc(fiat, price)) + return btcToFiat(satoshisToBtc(satoshis), price) +} + export function renderCurrency(currency) { switch (currency) { case 'btc': @@ -125,6 +144,17 @@ export function convert(from, to, amount, price) { return amount } break + case 'fiat': + switch (to) { + case 'btc': + return fiatToBtc(amount, price) + case 'bits': + return fiatToBits(amount, price) + case 'sats': + return fiatToSatoshis(amount, price) + case 'fiat': + return amount + } default: return '' } diff --git a/app/lib/utils/crypto.js b/app/lib/utils/crypto.js index 63e16532..ba7c35e4 100644 --- a/app/lib/utils/crypto.js +++ b/app/lib/utils/crypto.js @@ -1,6 +1,57 @@ import bitcoin from 'bitcoinjs-lib' import bech32 from 'lib/utils/bech32' +/** + * Turns parsed number into a string. + */ +export const formatValue = (integer, fractional) => { + let value + if (fractional && fractional.length > 0) { + value = `${integer}.${fractional}` + } else { + // Empty string means `XYZ.` instead of just plain `XYZ`. + if (fractional === '') { + value = `${integer}.` + } else { + value = `${integer}` + } + } + return value +} + +/** + * Splits number into integer and fraction. + */ +export const parseNumber = (_value, precision) => { + let value = String(_value || '') + if (typeof _value === 'string') { + value = _value.replace(/[^0-9.]/g, '') + } + let integer = null + let fractional = null + if (value * 1.0 < 0) { + value = '0.0' + } + // pearse integer and fractional value so that we can reproduce the same string value afterwards + // [0, 0] === 0.0 + // [0, ''] === 0. + // [0, null] === 0 + if (value.match(/^[0-9]*\.[0-9]*$/)) { + ;[integer, fractional] = value.toString().split(/\./) + if (!fractional) { + fractional = '' + } + } else { + integer = value + } + // Limit fractional precision to the correct number of decimal places. + if (fractional && fractional.length > precision) { + fractional = fractional.substring(0, precision) + } + + return [integer, fractional] +} + /** * Test to see if a string is a valid on-chain address. * @param {String} input string to check. diff --git a/package.json b/package.json index 1c5675a9..89581c65 100644 --- a/package.json +++ b/package.json @@ -352,7 +352,8 @@ "styled-reset": "^1.6.0", "tildify": "^1.2.0", "untildify": "^3.0.3", - "validator": "^10.8.0" + "validator": "^10.8.0", + "yup": "^0.26.6" }, "main": "internals/webpack/webpack.config.base.js", "directories": { diff --git a/stories/components/form.stories.js b/stories/components/form.stories.js index cce1df39..caa9286d 100644 --- a/stories/components/form.stories.js +++ b/stories/components/form.stories.js @@ -4,6 +4,8 @@ import { action } from '@storybook/addon-actions' import { Box } from 'rebass' import { Form } from 'informed' import { + CryptoAmountInput, + FiatAmountInput, Page, MainContent, Input, @@ -45,6 +47,40 @@ storiesOf('Components.Form', module)