diff --git a/src/components/CurrencyInput/CurrencyInput.example.js b/src/components/CurrencyInput/CurrencyInput.example.js
new file mode 100644
index 00000000..c9e3fd1b
--- /dev/null
+++ b/src/components/CurrencyInput/CurrencyInput.example.js
@@ -0,0 +1,53 @@
+/* eslint-disable import/prefer-default-export */
+import React, { PropTypes } from 'react';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import en from 'react-intl/locale-data/en';
+import fi from 'react-intl/locale-data/fi';
+import { currencyDefaultConfig } from '../../util/currency';
+import CurrencyInput from './CurrencyInput';
+
+const defaultConfig = {
+ ...currencyDefaultConfig,
+ currency: 'USD',
+};
+
+// eslint-disable-next-line no-console
+const onChange = price => console.log('CurrencyInput - value:', price);
+
+// Different locales need to be initialized before their currency formatting is in use
+const CurrencyInputWithIntl = ({ locale, ...rest }) => {
+ if (locale === 'en-US') {
+ addLocaleData([...en]);
+ } else {
+ addLocaleData([...fi]);
+ }
+ return (
+
+ );
+};
+
+const { object, string } = PropTypes;
+
+CurrencyInputWithIntl.propTypes = {
+ currencyConfig: object.isRequired,
+ locale: string.isRequired,
+};
+
+// Empty field with en-US locale
+export const EmptyWithEnUS = {
+ component: CurrencyInputWithIntl,
+ props: {
+ currencyConfig: defaultConfig,
+ locale: 'en-US',
+ },
+};
+
+// Default value with fi-FI locale
+export const defaultValueWithFiFI = {
+ component: CurrencyInputWithIntl,
+ props: {
+ currencyConfig: { ...defaultConfig, currency: 'EUR' },
+ locale: 'fi-FI',
+ defaultValue: 9999.99,
+ },
+};
diff --git a/src/components/CurrencyInput/CurrencyInput.js b/src/components/CurrencyInput/CurrencyInput.js
new file mode 100644
index 00000000..845eb393
--- /dev/null
+++ b/src/components/CurrencyInput/CurrencyInput.js
@@ -0,0 +1,210 @@
+/**
+ * CurrencyInput renders an input field that format it's value according to currency formatting rules
+ * onFocus: renders given value in unformatted manner: "9999,99"
+ * onBlur: formats the given input: "9 999,99 €"
+ */
+import React, { Component, PropTypes } from 'react';
+import { intlShape, injectIntl } from 'react-intl';
+import { types } from 'sharetribe-sdk';
+import {
+ convertUnitToSubUnit,
+ currencyDefaultConfig,
+ ensureDotSeparator,
+ ensureSeparator,
+ truncateToSubUnitPrecision,
+} from '../../util/currency';
+
+const allowedInputProps = allProps => {
+ // Strip away props that are not passed to input element (or are overwritten)
+ // eslint-disable-next-line no-unused-vars
+ const { currencyConfig, defaultValue, intl, input, meta, ...inputProps } = allProps;
+ return inputProps || {};
+};
+
+// Convert unformatted value (e.g. 10,00) to Money (or null)
+const getPrice = (unformattedValue, currency) => {
+ const isEmptyString = unformattedValue === '';
+ return isEmptyString
+ ? null
+ : new types.Money(convertUnitToSubUnit(unformattedValue, currency), currency);
+};
+
+class CurrencyInput extends Component {
+ constructor(props) {
+ super(props);
+ const { currencyConfig, defaultValue, intl } = props;
+
+ // We need to handle number format - some locales use dots and some points as decimal separator
+ // TODO Figure out if this could be digged from React-Intl directly somehow
+ const testSubUnitFormat = intl.formatNumber('1.1', currencyConfig);
+ const usesPoint = testSubUnitFormat.indexOf(',') >= 0;
+
+ try {
+ // whatever is passed as a default value, will be converted to currency string
+ // Unformatted value is digits + localized sub unit separator ("9,99")
+ const unformattedValue = defaultValue
+ ? truncateToSubUnitPrecision(
+ ensureSeparator(defaultValue.toString(), usesPoint),
+ currencyConfig.currency,
+ usesPoint
+ )
+ : '';
+ // Formatted value fully localized currency string ("$1,000.99")
+ const formattedValue = defaultValue
+ ? intl.formatNumber(ensureDotSeparator(unformattedValue), currencyConfig)
+ : '';
+
+ this.state = {
+ formattedValue,
+ unformattedValue,
+ value: formattedValue,
+ usesPoint,
+ };
+ } catch (e) {
+ // Print error, if default value isn't supported (see specs: truncateToSubUnitPrecision).
+ // eslint-disable-next-line no-console
+ console.error('CurrencyInput:', e);
+ throw e;
+ }
+
+ this.onInputChange = this.onInputChange.bind(this);
+ this.onInputBlur = this.onInputBlur.bind(this);
+ this.onInputFocus = this.onInputFocus.bind(this);
+ this.updateValues = this.updateValues.bind(this);
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !(nextProps === this.props && nextState === this.state);
+ }
+
+ onInputChange(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ // Update value strings on state
+ const { unformattedValue } = this.updateValues(event);
+ // Notify parent component about current price change
+ const price = getPrice(
+ ensureDotSeparator(unformattedValue),
+ this.props.currencyConfig.currency
+ );
+ this.props.input.onChange(price);
+ }
+
+ onInputBlur(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ const onBlur = this.props.input.onBlur;
+ const currency = this.props.currencyConfig.currency;
+ this.setState(prevState => {
+ if (onBlur) {
+ // If parent component has provided onBlur function, call it with current price.
+ const price = getPrice(ensureDotSeparator(prevState.unformattedValue), currency);
+ onBlur(price);
+ }
+ return {
+ value: prevState.formattedValue,
+ };
+ });
+ }
+
+ onInputFocus(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ const onFocus = this.props.input.onFocus;
+ const currency = this.props.currencyConfig.currency;
+ this.setState(prevState => {
+ if (onFocus) {
+ // If parent component has provided onFocus function, call it with current price.
+ const price = getPrice(ensureDotSeparator(prevState.unformattedValue), currency);
+ onFocus(price);
+ }
+ return {
+ value: prevState.unformattedValue,
+ };
+ });
+ }
+
+ updateValues(event) {
+ try {
+ const { currencyConfig, intl } = this.props;
+ const targetValue = event.target.value;
+ const isEmptyString = targetValue === '';
+ const valueOrZero = isEmptyString ? '0' : targetValue;
+
+ // truncate decimals to subunit precision: 10000.999 => 10000.99
+ const truncatedValueString = truncateToSubUnitPrecision(
+ valueOrZero,
+ currencyConfig.currency,
+ this.state.usesPoint
+ );
+ const unformattedValue = !isEmptyString ? truncatedValueString : '';
+ const formattedValue = !isEmptyString
+ ? intl.formatNumber(ensureDotSeparator(truncatedValueString), currencyConfig)
+ : '';
+
+ this.setState({
+ formattedValue,
+ value: unformattedValue,
+ unformattedValue,
+ });
+
+ return { formattedValue, value: unformattedValue, unformattedValue };
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+
+ // If an error occurs while filling input field, use previous values
+ // This ensures that string like '12.3r' doesn't end up to a state.
+ const { formattedValue, unformattedValue, value } = this.state;
+ return { formattedValue, unformattedValue, value };
+ }
+ }
+
+ render() {
+ const { currencyConfig, defaultValue, placeholder, intl } = this.props;
+ const placeholderText = placeholder || intl.formatNumber(defaultValue, currencyConfig);
+ return (
+
+ );
+ }
+}
+
+CurrencyInput.defaultProps = {
+ currencyConfig: currencyDefaultConfig,
+ defaultValue: 0,
+ input: null,
+ placeholder: null,
+};
+
+const { bool, func, instanceOf, oneOfType, number, shape, string } = PropTypes;
+
+CurrencyInput.propTypes = {
+ currencyConfig: shape({
+ style: string.isRequired,
+ currency: string.isRequired,
+ currencyDisplay: string,
+ useGrouping: bool,
+ minimumFractionDigits: number,
+ maximumFractionDigits: number,
+ }).isRequired,
+ defaultValue: number,
+ intl: intlShape.isRequired,
+ input: shape({
+ value: oneOfType([string, instanceOf(types.Money)]),
+ onBlur: func,
+ onChange: func.isRequired,
+ onFocus: func,
+ }).isRequired,
+
+ placeholder: string,
+};
+
+export default injectIntl(CurrencyInput);
diff --git a/src/components/index.js b/src/components/index.js
index 50ba6519..9dfe723b 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -1,5 +1,6 @@
import AddImages from './AddImages/AddImages';
import BookingInfo from './BookingInfo/BookingInfo';
+import CurrencyInput from './CurrencyInput/CurrencyInput';
import Discussion from './Discussion/Discussion';
import FilterPanel from './FilterPanel/FilterPanel';
import HeroSection from './HeroSection/HeroSection';
@@ -22,6 +23,7 @@ import SearchResultsPanel from './SearchResultsPanel/SearchResultsPanel';
export {
AddImages,
BookingInfo,
+ CurrencyInput,
Discussion,
FilterPanel,
HeroSection,
diff --git a/src/examples.js b/src/examples.js
index 9669a425..6788a687 100644
--- a/src/examples.js
+++ b/src/examples.js
@@ -1,6 +1,7 @@
// components
import * as AddImages from './components/AddImages/AddImages.example';
import * as BookingInfo from './components/BookingInfo/BookingInfo.example';
+import * as CurrencyInput from './components/CurrencyInput/CurrencyInput.example';
import * as ListingCard from './components/ListingCard/ListingCard.example';
import * as Map from './components/Map/Map.example';
import * as NamedLink from './components/NamedLink/NamedLink.example';
@@ -23,6 +24,7 @@ export {
BookingInfo,
ChangeAccountPasswordForm,
ChangePasswordForm,
+ CurrencyInput,
EditListingForm,
HeroSearchForm,
ListingCard,
diff --git a/src/util/currency.js b/src/util/currency.js
new file mode 100644
index 00000000..a654e9d3
--- /dev/null
+++ b/src/util/currency.js
@@ -0,0 +1,106 @@
+import { trimEnd } from 'lodash';
+import Decimal from 'decimal.js';
+
+// This works for USD and EUR
+const SUB_UNITS_DIVISOR = 100;
+
+// Default config for currency formatting (for React-Intl).
+export const currencyDefaultConfig = {
+ style: 'currency',
+ currency: 'USD',
+ currencyDisplay: 'symbol',
+ useGrouping: true,
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+};
+
+// Ensures that the given string uses only dots or points
+// ensureSeparator('9999999,99', false) // => '9999999.99'
+export const ensureSeparator = (str, usePoint = false) => {
+ if (typeof str !== 'string') {
+ throw new Error('Parameter must be a string');
+ }
+ return usePoint ? str.split('.').join(',') : str.split(',').join('.');
+};
+
+// Ensures that the given string uses only dots (e.g. JavaScript floats use dots)
+export const ensureDotSeparator = str => {
+ return ensureSeparator(str, false);
+};
+
+// Convert string to Decimal object (from Decimal.js math library)
+export const convertToDecimal = str => {
+ const dotFormattedStr = ensureDotSeparator(str);
+ try {
+ return new Decimal(dotFormattedStr);
+ } catch (e) {
+ throw e;
+ }
+};
+
+// Converts Decimal object to string
+export const convertDecimalToString = (decimalValue, usePoint = false) => {
+ try {
+ const d = new Decimal(decimalValue);
+ return ensureSeparator(d.toString(), usePoint);
+ } catch (e) {
+ throw e;
+ }
+};
+
+// Limits value to sub-unit precision: "1.4567" -> "1.45"
+// Useful in input fields so this doesn't use rounding.
+export const truncateToSubUnitPrecision = (inputString, currency, usePoint = false) => {
+ if (!(currency === 'USD' || currency === 'EUR')) {
+ // Other currencies sub unit support need to be changed case by case
+ throw new Error('Currency must be either USD or EUR');
+ }
+
+ // '10,' should be passed through, but that format is not supported as valid number
+ const trimmed = trimEnd(inputString, usePoint ? ',' : '.');
+ // create another instance and check if value is convertable
+ const value = convertToDecimal(trimmed, usePoint);
+
+ if (value.isNegative()) {
+ throw new Error('Parameter must be a positive number');
+ }
+
+ // Amount is always counted in subunits
+ // E.g. $10 => 1000¢
+ const amount = value.times(SUB_UNITS_DIVISOR);
+
+ // Amount must be integer
+ // We don't deal with subunit fragments like 1000.345¢
+ if (amount.isInteger()) {
+ // accepted strings: '9', '9,' '9.' '9,99'
+ const decimalCount2 = value.toFixed(2);
+ const decimalPrecisionMax2 = decimalCount2.length >= inputString.length
+ ? inputString
+ : value.toFixed(2);
+ return ensureSeparator(decimalPrecisionMax2, usePoint);
+ } else {
+ // truncate strings ('9.999' => '9.99')
+ const truncated = amount.truncated().dividedBy(SUB_UNITS_DIVISOR);
+ return convertDecimalToString(truncated, usePoint);
+ }
+};
+
+// Converts given value to sub unit value and returns it as a number
+export const convertUnitToSubUnit = (value, currency, usePoint = false) => {
+ if (!(currency === 'USD' || currency === 'EUR')) {
+ throw new Error('Currency must be either USD or EUR');
+ }
+
+ if (!(typeof value === 'string' || typeof value === 'number')) {
+ throw new Error('Value must be either number or string');
+ }
+
+ const val = typeof value === 'string' ? convertToDecimal(value, usePoint) : new Decimal(value);
+ const amount = val.times(SUB_UNITS_DIVISOR);
+
+ if (amount.isInteger()) {
+ return amount.toNumber();
+ } else {
+ throw new Error(`value must divisible by ${SUB_UNITS_DIVISOR}`);
+ }
+};
diff --git a/src/util/currency.test.js b/src/util/currency.test.js
new file mode 100644
index 00000000..d05e59d2
--- /dev/null
+++ b/src/util/currency.test.js
@@ -0,0 +1,185 @@
+import Decimal from 'decimal.js';
+import {
+ convertDecimalToString,
+ convertToDecimal,
+ convertUnitToSubUnit,
+ ensureSeparator,
+ truncateToSubUnitPrecision,
+} from './currency';
+
+describe('currency utils', () => {
+ describe('ensureSeparator(str)', () => {
+ it('changes points in string to dots ', () => {
+ expect(ensureSeparator('0')).toEqual('0');
+ expect(ensureSeparator('0.0')).toEqual('0.0');
+ expect(ensureSeparator('0.')).toEqual('0.');
+ expect(ensureSeparator('0,')).toEqual('0.');
+ expect(ensureSeparator('0,0')).toEqual('0.0');
+ expect(ensureSeparator('0,1234')).toEqual('0.1234');
+ expect(ensureSeparator('asdf')).toEqual('asdf');
+ });
+
+ it('changes dots in string to points ', () => {
+ expect(ensureSeparator('0', true)).toEqual('0');
+ expect(ensureSeparator('0.0', true)).toEqual('0,0');
+ expect(ensureSeparator('0.', true)).toEqual('0,');
+ expect(ensureSeparator('0,', true)).toEqual('0,');
+ expect(ensureSeparator('0,0', true)).toEqual('0,0');
+ expect(ensureSeparator('0,1234', true)).toEqual('0,1234');
+ expect(ensureSeparator('asdf', true)).toEqual('asdf');
+ });
+
+ it('Throws exceptions if parameter is not a string', () => {
+ expect(() => ensureSeparator(0)).toThrowError('Parameter must be a string');
+ expect(() => ensureSeparator(true)).toThrowError('Parameter must be a string');
+ expect(() => ensureSeparator([])).toThrowError('Parameter must be a string');
+ expect(() => ensureSeparator({})).toThrowError('Parameter must be a string');
+ expect(() => ensureSeparator(0, true)).toThrowError('Parameter must be a string');
+ expect(() => ensureSeparator(true, true)).toThrowError('Parameter must be a string');
+ expect(() => ensureSeparator([], true)).toThrowError('Parameter must be a string');
+ expect(() => ensureSeparator({}, true)).toThrowError('Parameter must be a string');
+ });
+ });
+
+ describe('convertToDecimal(valueStr)', () => {
+ it('converts numbers in string format to Decimal', () => {
+ expect(convertToDecimal('0')).toEqual(new Decimal('0'));
+ expect(convertToDecimal('-0')).toEqual(new Decimal('-0'));
+ expect(convertToDecimal('1.23')).toEqual(new Decimal('1.23'));
+ expect(convertToDecimal('999999999.99999999')).toEqual(new Decimal('999999999.99999999'));
+ expect(convertToDecimal('-999999999.99999999')).toEqual(new Decimal('-999999999.99999999'));
+ });
+
+ it('Throws exceptions if string formatted parameter can not be converted to Decimal', () => {
+ expect(() => convertToDecimal('asdf')).toThrowError('[DecimalError] Invalid argument: asdf');
+ expect(() => convertToDecimal('123asdf')).toThrowError(
+ '[DecimalError] Invalid argument: 123asdf'
+ );
+ });
+
+ it('Throws exceptions if parameter is not a string', () => {
+ expect(() => convertToDecimal(true)).toThrowError('Parameter must be a string');
+ expect(() => convertToDecimal([])).toThrowError('Parameter must be a string');
+ expect(() => convertToDecimal(undefined)).toThrowError('Parameter must be a string');
+ expect(() => convertToDecimal(null)).toThrowError('Parameter must be a string');
+ });
+ });
+
+ describe('convertDecimalToString()', () => {
+ it('converts Decimals to string', () => {
+ expect(convertDecimalToString(new Decimal('0'))).toEqual('0');
+ expect(convertDecimalToString(new Decimal('-0'))).toEqual('0');
+ expect(convertDecimalToString(new Decimal('1.23'))).toEqual('1.23');
+ expect(convertDecimalToString(new Decimal('999999999.99999999'))).toEqual(
+ '999999999.99999999'
+ );
+ expect(convertDecimalToString(new Decimal('-999999999.99999999'))).toEqual(
+ '-999999999.99999999'
+ );
+ });
+
+ it('converts numbers in string format to string', () => {
+ expect(convertDecimalToString('0')).toEqual('0');
+ expect(convertDecimalToString('-0')).toEqual('0');
+ expect(convertDecimalToString('1.23')).toEqual('1.23');
+ expect(convertDecimalToString('999999999.99999999')).toEqual('999999999.99999999');
+ expect(convertDecimalToString('-999999999.99999999')).toEqual('-999999999.99999999');
+ });
+
+ it('Throws exceptions if string formatted parameter is not a number', () => {
+ expect(() => convertDecimalToString('asdf')).toThrowError(
+ '[DecimalError] Invalid argument: asdf'
+ );
+ expect(() => convertDecimalToString('123asdf')).toThrowError(
+ '[DecimalError] Invalid argument: 123asdf'
+ );
+ });
+ });
+
+ describe('truncateToSubUnitPrecision(valueStr, currency)', () => {
+ it('positive values', () => {
+ expect(truncateToSubUnitPrecision('0', 'USD')).toEqual('0');
+ expect(truncateToSubUnitPrecision('1', 'EUR')).toEqual('1');
+ expect(truncateToSubUnitPrecision('10', 'USD')).toEqual('10');
+ expect(truncateToSubUnitPrecision('10000', 'EUR')).toEqual('10000');
+ expect(truncateToSubUnitPrecision('10000.00', 'USD')).toEqual('10000.00');
+ expect(truncateToSubUnitPrecision('99.9', 'EUR')).toEqual('99.9');
+ expect(truncateToSubUnitPrecision('99.99', 'USD')).toEqual('99.99');
+ });
+
+ it('truncate excess amount of decimal values', () => {
+ expect(truncateToSubUnitPrecision('99.999999', 'USD')).toEqual('99.99');
+ expect(truncateToSubUnitPrecision('1.111', 'USD')).toEqual('1.11');
+ expect(truncateToSubUnitPrecision('1.110000', 'USD')).toEqual('1.11');
+ });
+
+ it('negative values to throw errors', () => {
+ expect(() => truncateToSubUnitPrecision('-0', 'USD')).toThrowError(
+ 'Parameter must be a positive number'
+ );
+ expect(() => truncateToSubUnitPrecision('-1', 'USD')).toThrowError(
+ 'Parameter must be a positive number'
+ );
+ expect(() => truncateToSubUnitPrecision('-10000', 'USD')).toThrowError(
+ 'Parameter must be a positive number'
+ );
+ expect(() => truncateToSubUnitPrecision('-99.99', 'USD')).toThrowError(
+ 'Parameter must be a positive number'
+ );
+ expect(() => truncateToSubUnitPrecision('-99.99999', 'USD')).toThrowError(
+ 'Parameter must be a positive number'
+ );
+ expect(() => truncateToSubUnitPrecision('-1.111', 'USD')).toThrowError(
+ 'Parameter must be a positive number'
+ );
+ });
+
+ it('text input to throw errors', () => {
+ expect(() => truncateToSubUnitPrecision('asdf', 'USD')).toThrowError();
+ expect(() => truncateToSubUnitPrecision('100asdf', 'USD')).toThrowError();
+ expect(() => truncateToSubUnitPrecision('asdf100', 'USD')).toThrowError();
+ expect(() => truncateToSubUnitPrecision('@', 'USD')).toThrowError();
+ });
+
+ it('wrong currency to throw errors', () => {
+ expect(() => truncateToSubUnitPrecision('asdf', 'JPN')).toThrowError();
+ expect(() => truncateToSubUnitPrecision('100asdf', 'JPN')).toThrowError();
+ expect(() => truncateToSubUnitPrecision('asdf100', 'JPN')).toThrowError();
+ expect(() => truncateToSubUnitPrecision('@', 'JPN')).toThrowError();
+ });
+ });
+
+ describe('convertUnitToSubUnit(value, currency)', () => {
+ it('numbers as value', () => {
+ expect(convertUnitToSubUnit(0, 'USD')).toEqual(0);
+ expect(convertUnitToSubUnit(10, 'USD')).toEqual(1000);
+ expect(convertUnitToSubUnit(1, 'EUR')).toEqual(100);
+ });
+
+ it('strings as value', () => {
+ expect(convertUnitToSubUnit('0', 'USD')).toEqual(0);
+ expect(convertUnitToSubUnit('10', 'USD')).toEqual(1000);
+ expect(convertUnitToSubUnit('1', 'EUR')).toEqual(100);
+ expect(convertUnitToSubUnit('0.10', 'USD')).toEqual(10);
+ expect(convertUnitToSubUnit('10,99', 'USD')).toEqual(1099);
+ });
+
+ it('wrong type', () => {
+ expect(() => convertUnitToSubUnit({}, 'USD')).toThrowError(
+ 'Value must be either number or string'
+ );
+ expect(() => convertUnitToSubUnit([], 'USD')).toThrowError(
+ 'Value must be either number or string'
+ );
+ expect(() => convertUnitToSubUnit(null, 'EUR')).toThrowError(
+ 'Value must be either number or string'
+ );
+ });
+
+ it('wrong currency', () => {
+ expect(() => convertUnitToSubUnit(1, 'JPN')).toThrowError(
+ 'Currency must be either USD or EUR'
+ );
+ });
+ });
+});