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)
))
+ .add('CryptoAmountInput', () => (
+
+ ))
+ .add('FiatAmountInput', () => (
+
+ ))
.add('Lightning Invoice Textarea', () => (
diff --git a/test/unit/utils/crypto.spec.js b/test/unit/utils/crypto.spec.js
index 89664d63..3fc22533 100644
--- a/test/unit/utils/crypto.spec.js
+++ b/test/unit/utils/crypto.spec.js
@@ -1,5 +1,5 @@
/* eslint-disable max-len */
-import { isLn, isOnchain } from 'lib/utils/crypto'
+import { formatValue, isLn, isOnchain } from 'lib/utils/crypto'
const VALID_BITCOIN_MAINNET_LN =
'lnbc10u1pduey89pp57gt0mqvh9gv4m5kkxmy9a0a46ha5jlzr3mcfcz2fx8tzu63vpjksdq8w3jhxaqcqzystfg0drarrx89nvpegwykvfr4fypvwz2d9ktcr6tj5s08f0nn8gdjnv74y9amksk3rjw7englhjrsev70k77vwf603qh2pr4tnqeue6qp5n92gy'
@@ -98,3 +98,28 @@ describe('Crypto.isOnchain', () => {
})
})
})
+
+describe('formatValue', () => {
+ const test = (from, to) => {
+ expect(formatValue(from[0], from[1])).toEqual(to)
+ }
+
+ it('turns parsed number into a string', () => {
+ test(['0', null], '0')
+ test(['00', null], '00')
+ test(['0', ''], '0.')
+
+ test(['1', null], '1')
+ test(['01', null], '01')
+ test(['1', ''], '1.')
+
+ test(['0', '0'], '0.0')
+ test(['0', '1'], '0.1')
+
+ test(['0', '01'], '0.01')
+ test(['0', '10'], '0.10')
+
+ test(['1', '0'], '1.0')
+ test(['01', '0'], '01.0')
+ })
+})
diff --git a/yarn.lock b/yarn.lock
index b0f63921..7a0404d5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -711,6 +711,13 @@
core-js "^2.5.7"
regenerator-runtime "^0.12.0"
+"@babel/runtime@7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.0.0.tgz#adeb78fedfc855aa05bc041640f3f6f98e85424c"
+ integrity sha512-7hGhzlcmg01CvH1EHdSPVXYX1aJ8KCEyz6I9xYIi/asDtzBPMyMhVibhM/K6g/5qnKBwjZtp10bNZIEFTRW1MA==
+ dependencies:
+ regenerator-runtime "^0.12.0"
+
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2":
version "7.1.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.2.tgz#81c89935f4647706fc54541145e6b4ecfef4b8e3"
@@ -7543,6 +7550,11 @@ flush-write-stream@^1.0.0:
inherits "^2.0.1"
readable-stream "^2.0.4"
+fn-name@~2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/fn-name/-/fn-name-2.0.1.tgz#5214d7537a4d06a4a301c0cc262feb84188002e7"
+ integrity sha1-UhTXU3pNBqSjAcDMJi/rhBiAAuc=
+
follow-redirects@^1.0.0, follow-redirects@^1.3.0:
version "1.5.9"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.9.tgz#c9ed9d748b814a39535716e531b9196a845d89c6"
@@ -12845,6 +12857,11 @@ prop-types@15.x, prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, pr
loose-envify "^1.3.1"
object-assign "^4.1.1"
+property-expr@^1.5.0:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-1.5.1.tgz#22e8706894a0c8e28d58735804f6ba3a3673314f"
+ integrity sha512-CGuc0VUTGthpJXL36ydB6jnbyOf/rAHFvmVrJlH+Rg0DqqLFQGAP6hIaxD/G0OAmBJPhXDHuEJigrp0e0wFV6g==
+
property-information@^4.0.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/property-information/-/property-information-4.2.0.tgz#f0e66e07cbd6fed31d96844d958d153ad3eb486e"
@@ -15532,6 +15549,11 @@ symbol.prototype.description@^1.0.0:
dependencies:
has-symbols "^1.0.0"
+synchronous-promise@^2.0.5:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.6.tgz#de76e0ea2b3558c1e673942e47e714a930fa64aa"
+ integrity sha512-TyOuWLwkmtPL49LHCX1caIwHjRzcVd62+GF6h8W/jHOeZUFHpnd2XJDVuUlaTaLPH1nuu2M69mfHr5XbQJnf/g==
+
table@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/table/-/table-4.0.3.tgz#00b5e2b602f1794b9acaf9ca908a76386a7813bc"
@@ -15805,6 +15827,11 @@ toposort@^1.0.0:
resolved "https://registry.yarnpkg.com/toposort/-/toposort-1.0.7.tgz#2e68442d9f64ec720b8cc89e6443ac6caa950029"
integrity sha1-LmhELZ9k7HILjMieZEOsbKqVACk=
+toposort@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330"
+ integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=
+
tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@^2.4.3, tough-cookie@~2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
@@ -17160,6 +17187,18 @@ yauzl@2.4.1:
dependencies:
fd-slicer "~1.0.1"
+yup@^0.26.6:
+ version "0.26.6"
+ resolved "https://registry.yarnpkg.com/yup/-/yup-0.26.6.tgz#07e216a1424861f17958fef1d4775c64ef985724"
+ integrity sha512-Lfj8pAtQ/cDu/wsCuXt2ArQ0uUO/9nfr+EwlD9oQrWIErtjURjdSXYTS1ycN7T/Ok+IUTy23Tdo6Wo0f/wMMBw==
+ dependencies:
+ "@babel/runtime" "7.0.0"
+ fn-name "~2.0.1"
+ lodash "^4.17.10"
+ property-expr "^1.5.0"
+ synchronous-promise "^2.0.5"
+ toposort "^2.0.2"
+
zip-stream@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-1.2.0.tgz#a8bc45f4c1b49699c6b90198baacaacdbcd4ba04"