give
/
src
/
DonorDashboards
/
resources
/
js
/
app
/
components
/
subscription-manager
/
amount-control
/
index.js
give
/
src
/
DonorDashboards
/
resources
/
js
/
app
/
components
/
subscription-manager
/
amount-control
Last commit date
index.js
4 years ago
style.scss
4 years ago
index.js
152 lines
| 1 | import {useCallback, useEffect, useState} from 'react'; |
| 2 | import CurrencyInput, {formatValue} from 'react-currency-input-field'; |
| 3 | import {__, sprintf} from '@wordpress/i18n'; |
| 4 | |
| 5 | import {toUniqueId} from '../../../utils'; |
| 6 | import FieldRow from '../../field-row'; |
| 7 | import SelectControl from '../../select-control'; |
| 8 | |
| 9 | import './style.scss'; |
| 10 | |
| 11 | const CUSTOM_AMOUNT = 'custom_amount'; |
| 12 | |
| 13 | /** |
| 14 | * Converts a float to minor currency. |
| 15 | * |
| 16 | * @param {string} float |
| 17 | * @param {number} precision |
| 18 | * @return {number} |
| 19 | */ |
| 20 | const minorOfFloat = (float, precision) => Number.parseFloat(float) * Math.pow(10, precision); |
| 21 | |
| 22 | /** |
| 23 | * Quick and cheap knock-off of the classnames package. |
| 24 | * Do not re-use. Just use the real package. |
| 25 | * |
| 26 | * @param {...any} className |
| 27 | * @return {string} |
| 28 | */ |
| 29 | const cx = (...classNames) => classNames.filter(Boolean).join(' '); |
| 30 | |
| 31 | /** |
| 32 | * This control provides preset options however it allows the user to specify a |
| 33 | * custom option. |
| 34 | * |
| 35 | * Heads up, you probably want to take a good look at what’s happening before |
| 36 | * using this elsewhere. |
| 37 | */ |
| 38 | const AmountControl = ({currency, onChange, value, options, min, max}) => { |
| 39 | // This is the configuration for the CurrencyInput as well as formatting |
| 40 | // values in validation errors |
| 41 | const formatConfig = { |
| 42 | decimalScale: currency.numberDecimals, |
| 43 | decimalsLimit: currency.numberDecimals, |
| 44 | prefix: currency.currencyPosition === 'before' ? currency.symbol : null, |
| 45 | suffix: currency.currencyPosition === 'after' ? currency.symbol : null, |
| 46 | decimalSeparator: currency.decimalSeparator, |
| 47 | groupSeparator: currency.thousandsSeparator, |
| 48 | }; |
| 49 | |
| 50 | const [validationError, setValidationError] = useState(); |
| 51 | const clearValidationError = () => setValidationError(null); |
| 52 | |
| 53 | // The select value acts as a proxy for the actual value. |
| 54 | const [selectValue, setSelectValue] = useState( |
| 55 | // Determine whether the value is one of the predefined values and set |
| 56 | // the select input’s initial value accordingly. |
| 57 | () => (options.map((option) => option.value).includes(value) ? value : CUSTOM_AMOUNT) |
| 58 | ); |
| 59 | // We only call the onChange if the value is one of the predefined values. |
| 60 | // Otherwise, we effectively delegate control to the currency input. |
| 61 | useEffect(() => { |
| 62 | if (selectValue !== CUSTOM_AMOUNT) { |
| 63 | onChange(selectValue); |
| 64 | // I’ve omitted this from the deps array since this shouldn’t run |
| 65 | // when the validationError changes. This is just checking to see if |
| 66 | // we need to clear it. |
| 67 | if (validationError) clearValidationError(); |
| 68 | } |
| 69 | }, [selectValue, onChange]); |
| 70 | |
| 71 | // Ideally, we’d just use the value from the event.target, however, that’s |
| 72 | // formatted all nicely and we want a float, so we can just use the |
| 73 | const validateCustomAmount = useCallback(() => { |
| 74 | if (value) { |
| 75 | const minorOfValue = minorOfFloat(value, currency.numberDecimals); |
| 76 | const minorOfMin = minorOfFloat(min, currency.numberDecimals); |
| 77 | const minorOfMax = minorOfFloat(max, currency.numberDecimals); |
| 78 | |
| 79 | if (minorOfValue > minorOfMax) { |
| 80 | setValidationError( |
| 81 | sprintf(__('Amount must be less than %s.', 'give'), formatValue({value: max, ...formatConfig})) |
| 82 | ); |
| 83 | } else if (minorOfValue < minorOfMin) { |
| 84 | setValidationError( |
| 85 | sprintf(__('Amount must be more than %s.', 'give'), formatValue({value: min, ...formatConfig})) |
| 86 | ); |
| 87 | } else { |
| 88 | clearValidationError(); |
| 89 | } |
| 90 | } else { |
| 91 | setValidationError( |
| 92 | sprintf(__('Amount must be more than %s.', 'give'), formatValue({value: min, ...formatConfig})) |
| 93 | ); |
| 94 | } |
| 95 | }, [currency.numberDecimals, min, max, value]); |
| 96 | |
| 97 | const customAmountInputId = toUniqueId(); |
| 98 | |
| 99 | return ( |
| 100 | <div className="give-donor-dashboard-amount-inputs"> |
| 101 | <FieldRow> |
| 102 | <div> |
| 103 | <SelectControl |
| 104 | label={__('Subscription Amount', 'give')} |
| 105 | options={options} |
| 106 | value={selectValue} |
| 107 | onChange={setSelectValue} |
| 108 | /> |
| 109 | </div> |
| 110 | <div> |
| 111 | {selectValue === CUSTOM_AMOUNT && ( |
| 112 | <div className="give-donor-dashboard-currency-control"> |
| 113 | <label |
| 114 | className="give-donor-dashboard-currency-control__label" |
| 115 | htmlFor={customAmountInputId} |
| 116 | > |
| 117 | {__('Custom Amount', 'give')} |
| 118 | </label> |
| 119 | <div |
| 120 | className={cx( |
| 121 | 'give-donor-dashboard-currency-control__input', |
| 122 | validationError && 'has-error' |
| 123 | )} |
| 124 | > |
| 125 | <CurrencyInput |
| 126 | id={customAmountInputId} |
| 127 | name="custom-amount" |
| 128 | placeholder={__('Enter amount...', 'give')} |
| 129 | value={value ?? ''} |
| 130 | onValueChange={onChange} |
| 131 | onBlur={validateCustomAmount} |
| 132 | allowNegativeValue={false} |
| 133 | {...formatConfig} |
| 134 | /> |
| 135 | </div> |
| 136 | </div> |
| 137 | )} |
| 138 | </div> |
| 139 | </FieldRow> |
| 140 | {validationError && ( |
| 141 | <FieldRow> |
| 142 | <div className="give-donor-dashboard-amount-inputs__validation-error-message"> |
| 143 | {validationError} |
| 144 | </div> |
| 145 | </FieldRow> |
| 146 | )} |
| 147 | </div> |
| 148 | ); |
| 149 | }; |
| 150 | |
| 151 | export default AmountControl; |
| 152 |