diff --git a/dapp/src/components/TransactionModal/ActionFormModal.tsx b/dapp/src/components/TransactionModal/ActionFormModal.tsx index 8c50be64e..53ab0e42b 100644 --- a/dapp/src/components/TransactionModal/ActionFormModal.tsx +++ b/dapp/src/components/TransactionModal/ActionFormModal.tsx @@ -107,7 +107,7 @@ function ActionFormModal({ type }: { type: ActionFlowType }) { <> {!isLoading && } {heading} - + diff --git a/dapp/src/components/TransactionModal/ActiveStakingStep/StakeFormModal/index.tsx b/dapp/src/components/TransactionModal/ActiveStakingStep/StakeFormModal/index.tsx index 1c5037a76..4149e4fc7 100644 --- a/dapp/src/components/TransactionModal/ActiveStakingStep/StakeFormModal/index.tsx +++ b/dapp/src/components/TransactionModal/ActiveStakingStep/StakeFormModal/index.tsx @@ -3,7 +3,8 @@ import TokenAmountForm from "#/components/shared/TokenAmountForm" import { TokenAmountFormValues } from "#/components/shared/TokenAmountForm/TokenAmountFormBase" import { useMinDepositAmount, useWallet } from "#/hooks" import { FormSubmitButton } from "#/components/shared/Form" -import { BaseFormProps } from "#/types" +import { ACTION_FLOW_TYPES, BaseFormProps } from "#/types" +import { fixedPointNumberToString, getCurrencyByType } from "#/utils" import StakeDetails from "./StakeDetails" function StakeFormModal({ @@ -12,11 +13,16 @@ function StakeFormModal({ const minDepositAmount = useMinDepositAmount() const { balance: tokenBalance } = useWallet() + const { decimals } = getCurrencyByType("bitcoin") + const inputPlaceholder = `Minimum ${fixedPointNumberToString(minDepositAmount, decimals)} BTC` + const tokenAmountLabel = "Wallet balance" + return ( {children} diff --git a/dapp/src/components/shared/Form/FormTokenBalanceInput.tsx b/dapp/src/components/shared/Form/FormTokenBalanceInput.tsx index e813a3033..fc922ea8c 100644 --- a/dapp/src/components/shared/Form/FormTokenBalanceInput.tsx +++ b/dapp/src/components/shared/Form/FormTokenBalanceInput.tsx @@ -7,6 +7,7 @@ export type FormTokenBalanceInputProps = { name: string defaultValue?: bigint } & Omit + export function FormTokenBalanceInput({ name, defaultValue, @@ -17,9 +18,11 @@ export function FormTokenBalanceInput({ const setAmount = useCallback( (value?: bigint) => { if (!meta.touched) logPromiseFailure(helpers.setTouched(true)) + if (meta.error) helpers.setError(undefined) + logPromiseFailure(helpers.setValue(value)) }, - [helpers, meta.touched], + [helpers, meta.touched, meta.error], ) useEffect(() => { @@ -34,7 +37,7 @@ export function FormTokenBalanceInput({ {...field} amount={defaultValue ?? (meta.value as bigint)} setAmount={setAmount} - hasError={Boolean(meta.touched && meta.error)} + hasError={Boolean(meta.error)} errorMsgText={meta.error} /> ) diff --git a/dapp/src/components/shared/NumberFormatInput/index.tsx b/dapp/src/components/shared/NumberFormatInput/index.tsx index ec4f5b274..7d0daac37 100644 --- a/dapp/src/components/shared/NumberFormatInput/index.tsx +++ b/dapp/src/components/shared/NumberFormatInput/index.tsx @@ -1,8 +1,12 @@ import React from "react" -import { NumericFormat, NumericFormatProps } from "react-number-format" +import { + NumberFormatValues, + NumericFormat, + NumericFormatProps, +} from "react-number-format" import { InputProps, chakra, useMultiStyleConfig } from "@chakra-ui/react" -const ChakraWrapper = chakra(NumericFormat) +const ChakraNumericFormat = chakra(NumericFormat) export type NumberFormatInputValues = { formattedValue: string @@ -12,8 +16,9 @@ export type NumberFormatInputValues = { export type NumberFormatInputProps = { onValueChange: (values: NumberFormatInputValues) => void + integerScale?: number } & InputProps & - Pick + Pick /** * Component is from the Threshold Network React Components repository. @@ -29,17 +34,31 @@ const NumberFormatInput = React.forwardRef< >((props, ref) => { const { field: css } = useMultiStyleConfig("Input", props) - const { decimalScale, isDisabled, isInvalid, ...restProps } = props + const { decimalScale, isDisabled, isInvalid, integerScale, ...restProps } = + props + + const handleLengthValidation = (values: NumberFormatValues) => { + const { value, floatValue } = values + if ( + floatValue === undefined || + value === undefined || + integerScale === undefined + ) + return true + + const [integerPart] = value.split(".") + return integerPart.length <= integerScale + } return ( - ) diff --git a/dapp/src/components/shared/TokenAmountForm/TokenAmountFormBase.tsx b/dapp/src/components/shared/TokenAmountForm/TokenAmountFormBase.tsx index 6487627bc..f14f3f2d6 100644 --- a/dapp/src/components/shared/TokenAmountForm/TokenAmountFormBase.tsx +++ b/dapp/src/components/shared/TokenAmountForm/TokenAmountFormBase.tsx @@ -24,9 +24,9 @@ export type TokenAmountFormBaseProps = { formId?: string tokenBalance: bigint tokenBalanceInputPlaceholder: string + tokenAmountLabel?: string currency: CurrencyType withMaxButton: boolean - fiatCurrency?: CurrencyType children?: React.ReactNode defaultAmount?: bigint } @@ -35,8 +35,8 @@ export default function TokenAmountFormBase({ formId, tokenBalance, currency, - fiatCurrency, tokenBalanceInputPlaceholder, + tokenAmountLabel, withMaxButton, children, defaultAmount, @@ -48,10 +48,11 @@ export default function TokenAmountFormBase({ name={TOKEN_AMOUNT_FIELD_NAME} tokenBalance={tokenBalance} placeholder={tokenBalanceInputPlaceholder} + tokenAmountLabel={tokenAmountLabel} currency={currency} - fiatCurrency={fiatCurrency} withMaxButton={withMaxButton} defaultValue={defaultAmount} + autoFocus /> {children} diff --git a/dapp/src/components/shared/TokenAmountForm/index.tsx b/dapp/src/components/shared/TokenAmountForm/index.tsx index 1aa49ad9d..a1360ed8e 100644 --- a/dapp/src/components/shared/TokenAmountForm/index.tsx +++ b/dapp/src/components/shared/TokenAmountForm/index.tsx @@ -1,12 +1,13 @@ import { FormikErrors, withFormik } from "formik" import { getErrorsObj, validateTokenAmount } from "#/utils" -import { BaseFormProps } from "#/types" +import { ActionFlowType, BaseFormProps } from "#/types" import TokenAmountFormBase, { TokenAmountFormBaseProps, TokenAmountFormValues, } from "./TokenAmountFormBase" type TokenAmountFormProps = { + actionType: ActionFlowType minTokenAmount: bigint } & TokenAmountFormBaseProps & BaseFormProps @@ -16,10 +17,14 @@ const TokenAmountForm = withFormik( mapPropsToValues: () => ({ amount: undefined, }), - validate: ({ amount }, { tokenBalance, currency, minTokenAmount }) => { + validate: ( + { amount }, + { tokenBalance, currency, minTokenAmount, actionType }, + ) => { const errors: FormikErrors = {} errors.amount = validateTokenAmount( + actionType, amount, tokenBalance, minTokenAmount, @@ -31,6 +36,8 @@ const TokenAmountForm = withFormik( handleSubmit: (values, { props }) => { props.onSubmitForm(values) }, + validateOnBlur: false, + validateOnChange: false, }, )(TokenAmountFormBase) diff --git a/dapp/src/components/shared/TokenBalanceInput/index.tsx b/dapp/src/components/shared/TokenBalanceInput/index.tsx index 9339c0781..456882fb2 100644 --- a/dapp/src/components/shared/TokenBalanceInput/index.tsx +++ b/dapp/src/components/shared/TokenBalanceInput/index.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from "react" +import React, { ChangeEventHandler, useRef } from "react" import { Box, Button, @@ -15,6 +15,7 @@ import { import { fixedPointNumberToString, getCurrencyByType, + isFormError, userAmountToBigInt, } from "#/utils" import { CurrencyType } from "#/types" @@ -25,6 +26,7 @@ import NumberFormatInput, { NumberFormatInputProps, } from "../NumberFormatInput" import { CurrencyBalance } from "../CurrencyBalance" +import { Alert, AlertIcon, AlertDescription } from "../Alert" const VARIANT = "balance" @@ -42,7 +44,12 @@ function HelperErrorText({ if (hasError) { return ( - {errorMsgText || "Please enter a valid value"} + + + + {errorMsgText || "Please enter a valid value"} + + ) } @@ -102,6 +109,7 @@ export type TokenBalanceInputProps = { fiatCurrency?: CurrencyType setAmount: (value?: bigint) => void withMaxButton?: boolean + tokenAmountLabel?: string } & InputProps & HelperErrorTextProps & Pick @@ -118,27 +126,37 @@ export default function TokenBalanceInput({ hasError = false, fiatCurrency, withMaxButton = false, + tokenAmountLabel = "Amount", ...inputProps }: TokenBalanceInputProps) { const valueRef = useRef(amount) const styles = useMultiStyleConfig("TokenBalanceInput", { size }) - const { decimals } = getCurrencyByType(currency) + const { decimals, symbol } = getCurrencyByType(currency) const handleValueChange = (value: string) => { valueRef.current = value ? userAmountToBigInt(value, decimals) : undefined } + const handleChange: ChangeEventHandler = () => { + setAmount(valueRef?.current) + } + + const isBalanceExceeded = + typeof errorMsgText === "string" && + isFormError("EXCEEDED_VALUE", errorMsgText) + return ( - + Amount - Balance + {tokenAmountLabel} handleValueChange(values.value) } - onChange={() => { - setAmount(valueRef?.current) - }} + onChange={handleChange} decimalScale={decimals} allowNegative={false} + integerScale={10} /> {withMaxButton && ( diff --git a/dapp/src/constants/errors.ts b/dapp/src/constants/errors.ts index 2465ff22e..320b53195 100644 --- a/dapp/src/constants/errors.ts +++ b/dapp/src/constants/errors.ts @@ -1,4 +1,4 @@ -import { ConnectionErrorData } from "#/types" +import { ACTION_FLOW_TYPES, ConnectionErrorData } from "#/types" export const CONNECTION_ERRORS: Record = { REJECTED: { @@ -24,3 +24,19 @@ export const CONNECTION_ERRORS: Record = { description: "We encountered an error. Please try again.", }, } + +export const TOKEN_FORM_ERRORS = { + REQUIRED: "Please enter an amount.", + EXCEEDED_VALUE: + "The amount exceeds your current wallet balance. Add more funds to your wallet or lower the deposit amount.", + INSUFFICIENT_VALUE: (minValue: string) => + `The amount is below the minimum required deposit of ${minValue} BTC.`, +} + +export const ACTION_FORM_ERRORS = { + [ACTION_FLOW_TYPES.STAKE]: TOKEN_FORM_ERRORS, + [ACTION_FLOW_TYPES.UNSTAKE]: { + ...TOKEN_FORM_ERRORS, + EXCEEDED_VALUE: "Your Acre balance is insufficient.", + }, +} diff --git a/dapp/src/theme/Button.ts b/dapp/src/theme/Button.ts index a9e1e05f3..c6ac23059 100644 --- a/dapp/src/theme/Button.ts +++ b/dapp/src/theme/Button.ts @@ -28,6 +28,9 @@ export const buttonTheme: ComponentSingleStyleConfig = { color: "white", _hover: { bg: "brand.500", + _disabled: { + bg: "grey.400", + }, }, _active: { bg: "brand.400", @@ -38,6 +41,10 @@ export const buttonTheme: ComponentSingleStyleConfig = { opacity: 1, }, }, + _disabled: { + bg: "grey.500", + color: "gold.200", + }, }, outline: ({ colorScheme }: StyleFunctionProps) => { const defaultStyles = { diff --git a/dapp/src/theme/FormError.ts b/dapp/src/theme/FormError.ts index d9a52b265..5cc44f3ad 100644 --- a/dapp/src/theme/FormError.ts +++ b/dapp/src/theme/FormError.ts @@ -4,7 +4,9 @@ import { formErrorAnatomy as parts } from "@chakra-ui/anatomy" const baseStyleText = defineStyle({ fontWeight: "medium", - color: "red.400", + color: "grey.700", + mt: 4, + fontSize: "md", }) const multiStyleConfig = createMultiStyleConfigHelpers(parts.keys) diff --git a/dapp/src/theme/Input.ts b/dapp/src/theme/Input.ts index e6e9d8695..32ecb1269 100644 --- a/dapp/src/theme/Input.ts +++ b/dapp/src/theme/Input.ts @@ -8,6 +8,7 @@ const variantBalanceField = defineStyle({ fontWeight: "bold", bg: "opacity.white.5", paddingRight: 20, + minH: 14, // TODO: Set the color correctly without using the chakra variable. caretColor: "var(--chakra-colors-brand-400)", @@ -17,7 +18,7 @@ const variantBalanceField = defineStyle({ }, _invalid: { - color: "red.400", + borderColor: "red.400", }, }) diff --git a/dapp/src/utils/forms.ts b/dapp/src/utils/forms.ts index d1c8caf4f..6196f3b26 100644 --- a/dapp/src/utils/forms.ts +++ b/dapp/src/utils/forms.ts @@ -1,14 +1,8 @@ -import { CurrencyType } from "#/types" +import { ACTION_FORM_ERRORS, TOKEN_FORM_ERRORS } from "#/constants" +import { ActionFlowType, CurrencyType } from "#/types" import { getCurrencyByType } from "./currency" import { fixedPointNumberToString } from "./numbers" -const ERRORS = { - REQUIRED: "Required.", - EXCEEDED_VALUE: "The amount exceeds your current balance.", - INSUFFICIENT_VALUE: (minValue: string) => - `The minimum amount must be at least ${minValue} BTC.`, -} - export function getErrorsObj(errors: { [key in keyof T]: string }) { return (Object.keys(errors) as Array).every((name) => !errors[name]) ? {} @@ -16,23 +10,54 @@ export function getErrorsObj(errors: { [key in keyof T]: string }) { } export function validateTokenAmount( + actionType: ActionFlowType, value: bigint | undefined, maxValue: bigint, minValue: bigint, currency: CurrencyType, ): string | undefined { - if (value === undefined) return ERRORS.REQUIRED + const ERRORS_BY_ACTION_TYPE = ACTION_FORM_ERRORS[actionType] + + if (value === undefined) return ERRORS_BY_ACTION_TYPE.REQUIRED const { decimals } = getCurrencyByType(currency) const isMaximumValueExceeded = value > maxValue const isMinimumValueFulfilled = value >= minValue - if (isMaximumValueExceeded) return ERRORS.EXCEEDED_VALUE + if (isMaximumValueExceeded) return ERRORS_BY_ACTION_TYPE.EXCEEDED_VALUE if (!isMinimumValueFulfilled) - return ERRORS.INSUFFICIENT_VALUE( + return ERRORS_BY_ACTION_TYPE.INSUFFICIENT_VALUE( fixedPointNumberToString(minValue, decimals), ) return undefined } + +type ParametrizedError = (value: number) => string +export const isFormError = ( + type: keyof typeof TOKEN_FORM_ERRORS, + message: string, +) => { + let errorPredicates = [ + ACTION_FORM_ERRORS.STAKE[type], + ACTION_FORM_ERRORS.UNSTAKE[type], + ] + + const isParametrizedError = errorPredicates.every( + (predicate) => typeof predicate === "function", + ) + + if (isParametrizedError) { + const errorParameter = (message.match(/\d*\.\d+|\d+/g) ?? []).map( + parseFloat, + )[0] + + // Already checked that all predicates are functions + errorPredicates = (errorPredicates as unknown as ParametrizedError[]).map( + (predicate) => predicate(errorParameter), + ) + } + + return errorPredicates.includes(message) +}