diff --git a/dapp/package.json b/dapp/package.json index a3dfaea79..383bc7456 100644 --- a/dapp/package.json +++ b/dapp/package.json @@ -22,7 +22,8 @@ "@ledgerhq/wallet-api-client-react": "^1.1.2", "framer-motion": "^10.16.5", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-number-format": "^5.3.1" }, "devDependencies": { "@thesis-co/eslint-config": "^0.6.1", diff --git a/dapp/src/components/shared/CurrencyBalance/index.tsx b/dapp/src/components/shared/CurrencyBalance/index.tsx index 11301c7a7..889419dad 100644 --- a/dapp/src/components/shared/CurrencyBalance/index.tsx +++ b/dapp/src/components/shared/CurrencyBalance/index.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from "react" -import { Box, useMultiStyleConfig } from "@chakra-ui/react" +import { Box, useMultiStyleConfig, TextProps } from "@chakra-ui/react" import { formatTokenAmount, toLocaleString } from "../../../utils" import { CurrencyType } from "../../../types" import { CURRENCIES_BY_TYPE } from "../../../constants" @@ -11,7 +11,7 @@ export type CurrencyBalanceProps = { desiredDecimals?: number size?: string variant?: "greater-balance" -} +} & TextProps export function CurrencyBalance({ currencyType, @@ -20,6 +20,7 @@ export function CurrencyBalance({ desiredDecimals = 2, size, variant, + ...textProps }: CurrencyBalanceProps) { const styles = useMultiStyleConfig("CurrencyBalance", { size, variant }) @@ -38,10 +39,10 @@ export function CurrencyBalance({ return ( - + {balance} - + {currency.symbol} diff --git a/dapp/src/components/shared/NumberFormatInput/index.tsx b/dapp/src/components/shared/NumberFormatInput/index.tsx new file mode 100644 index 000000000..a7792f156 --- /dev/null +++ b/dapp/src/components/shared/NumberFormatInput/index.tsx @@ -0,0 +1,47 @@ +import React from "react" +import { NumericFormat } from "react-number-format" +import { InputProps, chakra, useMultiStyleConfig } from "@chakra-ui/react" + +const ChakraWrapper = chakra(NumericFormat) + +export type NumberFormatInputValues = { + formattedValue: string + value: string + floatValue: number +} + +type NumberFormatInputProps = { + onValueChange: (values: NumberFormatInputValues) => void + decimalScale?: number +} & InputProps + +/** + * Component is from the Threshold Network React Components repository. + * It has been used because it supports the thousandth separator + * and can be easily integrated with Chakra UI. + * + * More info: + * https://github.com/threshold-network/components/blob/main/src/components/NumberFormatInput/index.tsx + */ +const NumberFormatInput = React.forwardRef< + HTMLInputElement, + NumberFormatInputProps +>((props, ref) => { + const { field: css } = useMultiStyleConfig("Input", props) + + const { decimalScale, isDisabled, ...restProps } = props + + return ( + + ) +}) + +export default NumberFormatInput diff --git a/dapp/src/components/shared/TokenBalanceInput/index.tsx b/dapp/src/components/shared/TokenBalanceInput/index.tsx new file mode 100644 index 000000000..907f533d0 --- /dev/null +++ b/dapp/src/components/shared/TokenBalanceInput/index.tsx @@ -0,0 +1,170 @@ +import React, { useMemo } from "react" +import { + Box, + Button, + HStack, + Icon, + InputGroup, + InputProps, + InputRightElement, + TypographyProps, + createStylesContext, + useMultiStyleConfig, +} from "@chakra-ui/react" +import { fixedPointNumberToString } from "../../../utils" +import { CurrencyType } from "../../../types" +import { CURRENCIES_BY_TYPE } from "../../../constants" +import NumberFormatInput, { + NumberFormatInputValues, +} from "../NumberFormatInput" +import { CurrencyBalance } from "../CurrencyBalance" +import { Alert } from "../../../static/icons" + +const VARIANT = "balance" +const [StylesProvider, useStyles] = createStylesContext("TokenBalanceInput") + +type HelperErrorTextProps = { + errorMsgText?: string | JSX.Element + hasError?: boolean + helperText?: string | JSX.Element +} + +function HelperErrorText({ + helperText, + errorMsgText, + hasError, +}: HelperErrorTextProps) { + const styles = useStyles() + + if (hasError) { + return ( + + {errorMsgText || "Please enter a valid value"} + + ) + } + + if (helperText) { + return ( + + + {helperText} + + ) + } + + return null +} + +type FiatCurrencyBalanceProps = { + fiatAmount?: string + fiatCurrencyType?: CurrencyType +} + +function FiatCurrencyBalance({ + fiatAmount, + fiatCurrencyType, +}: FiatCurrencyBalanceProps) { + const { helperText } = useStyles() + const textProps = helperText as TypographyProps + + if (fiatAmount && fiatCurrencyType) { + return ( + + ) + } + + return null +} + +type TokenBalanceInputProps = { + amount?: string + currencyType: CurrencyType + tokenBalance: string | number + placeholder?: string + size?: "lg" | "md" + setAmount: (value: string) => void +} & InputProps & + HelperErrorTextProps & + FiatCurrencyBalanceProps + +export default function TokenBalanceInput({ + amount, + currencyType, + tokenBalance, + placeholder, + size = "lg", + setAmount, + errorMsgText, + helperText, + hasError = false, + fiatAmount, + fiatCurrencyType, + ...inputProps +}: TokenBalanceInputProps) { + const styles = useMultiStyleConfig("TokenBalanceInput", { size }) + + const tokenBalanceAmount = useMemo( + () => + fixedPointNumberToString( + BigInt(tokenBalance || 0), + CURRENCIES_BY_TYPE[currencyType].decimals, + ), + [currencyType, tokenBalance], + ) + + return ( + + + + Amount + + + + Balance + + + + + + + setAmount(values.value) + } + {...inputProps} + /> + + + + + + + {!hasError && !helperText && ( + + )} + + + ) +} diff --git a/dapp/src/constants/currency.ts b/dapp/src/constants/currency.ts index c35c7c9c6..420adb8b6 100644 --- a/dapp/src/constants/currency.ts +++ b/dapp/src/constants/currency.ts @@ -27,5 +27,5 @@ export const CURRENCY_ID_ETHEREUM = export const CURRENCIES_BY_TYPE: Record = { bitcoin: BITCOIN, ethereum: ETHEREUM, - usd: ETHEREUM, + usd: USD, } diff --git a/dapp/src/static/icons/Alert.tsx b/dapp/src/static/icons/Alert.tsx new file mode 100644 index 000000000..e76365d89 --- /dev/null +++ b/dapp/src/static/icons/Alert.tsx @@ -0,0 +1,24 @@ +import React from "react" +import { createIcon } from "@chakra-ui/react" + +export const Alert = createIcon({ + displayName: "Alert", + viewBox: "0 0 16 16", + path: [ + + + , + + + + + , + ], +}) diff --git a/dapp/src/static/icons/index.ts b/dapp/src/static/icons/index.ts index 0752c5839..ea843affd 100644 --- a/dapp/src/static/icons/index.ts +++ b/dapp/src/static/icons/index.ts @@ -3,3 +3,4 @@ export * from "./Bitcoin" export * from "./Ethereum" export * from "./ArrowUpRight" export * from "./AcreLogo" +export * from "./Alert" diff --git a/dapp/src/theme/Input.ts b/dapp/src/theme/Input.ts new file mode 100644 index 000000000..3bc2aeb25 --- /dev/null +++ b/dapp/src/theme/Input.ts @@ -0,0 +1,40 @@ +import { inputAnatomy as parts } from "@chakra-ui/anatomy" +import { createMultiStyleConfigHelpers, defineStyle } from "@chakra-ui/react" + +const { definePartsStyle, defineMultiStyleConfig } = + createMultiStyleConfigHelpers(parts.keys) + +const variantBalanceField = defineStyle({ + border: "1px solid", + borderColor: "gold.300", + color: "grey.700", + fontWeight: "bold", + bg: "opacity.white.5", + paddingRight: 20, + // TODO: Set the color correctly without using the chakra variable. + caretColor: "var(--chakra-colors-brand-400)", + + _placeholder: { + color: "grey.300", + fontWeight: "medium", + }, +}) + +const variantBalanceElement = defineStyle({ + h: "100%", + width: 14, + mr: 2, +}) + +const variantBalance = definePartsStyle({ + field: variantBalanceField, + element: variantBalanceElement, +}) + +const variants = { + balance: variantBalance, +} + +const Input = defineMultiStyleConfig({ variants }) + +export default Input diff --git a/dapp/src/theme/TokenBalanceInput.ts b/dapp/src/theme/TokenBalanceInput.ts new file mode 100644 index 000000000..36b16066e --- /dev/null +++ b/dapp/src/theme/TokenBalanceInput.ts @@ -0,0 +1,99 @@ +import { createMultiStyleConfigHelpers, defineStyle } from "@chakra-ui/react" + +const PARTS = [ + "container", + "labelContainer", + "label", + "balance", + "helperText", + "errorMsgText", +] + +const { defineMultiStyleConfig, definePartsStyle } = + createMultiStyleConfigHelpers(PARTS) + +const baseStyleContainer = defineStyle({ + display: "flex", + flexDirection: "column", + gap: 1, +}) + +const baseStyleLabelContainer = defineStyle({ + display: "flex", + justifyContent: "space-between", +}) + +const baseStyleLabel = defineStyle({ + fontWeight: "semibold", +}) + +const baseStyleBalance = defineStyle({ + fontWeight: "medium", + color: "grey.500", +}) + +const baseStyleHelperText = defineStyle({ + fontWeight: "medium", + color: "grey.500", +}) + +const baseStyleErrorMsgText = defineStyle({ + fontWeight: "medium", + color: "red.400", +}) + +const baseStyle = definePartsStyle({ + container: baseStyleContainer, + labelContainer: baseStyleLabelContainer, + label: baseStyleLabel, + balance: baseStyleBalance, + helperText: baseStyleHelperText, + errorMsgText: baseStyleErrorMsgText, +}) + +const sizeMd = definePartsStyle({ + label: { + fontSize: "sm", + lineHeight: "sm", + }, + balance: { + fontSize: "sm", + lineHeight: "sm", + }, + helperText: { + fontSize: "sm", + lineHeight: "sm", + }, + errorMsgText: { + fontSize: "sm", + lineHeight: "sm", + }, +}) + +const sizeLg = definePartsStyle({ + label: { + fontSize: "md", + lineHeight: "md", + }, + balance: { + fontSize: "md", + lineHeight: "md", + }, + helperText: { + fontSize: "sm", + lineHeight: "sm", + }, + errorMsgText: { + fontSize: "sm", + lineHeight: "sm", + }, +}) + +const sizes = { + md: sizeMd, + lg: sizeLg, +} + +const TokenBalanceInput = defineMultiStyleConfig({ baseStyle, sizes }) + +export default TokenBalanceInput diff --git a/dapp/src/theme/index.ts b/dapp/src/theme/index.ts index 024a55b1f..68afde1dd 100644 --- a/dapp/src/theme/index.ts +++ b/dapp/src/theme/index.ts @@ -7,6 +7,8 @@ import Tooltip from "./Tooltip" import { colors, fonts, lineHeights } from "./utils" import Heading from "./Heading" import CurrencyBalance from "./CurrencyBalance" +import TokenBalanceInput from "./TokenBalanceInput" +import Input from "./Input" const defaultTheme = { colors, @@ -28,6 +30,8 @@ const defaultTheme = { CurrencyBalance, Card, Tooltip, + Input, + TokenBalanceInput, }, } diff --git a/dapp/src/theme/utils/colors.ts b/dapp/src/theme/utils/colors.ts index f9a53d8f0..2ab5f3a12 100644 --- a/dapp/src/theme/utils/colors.ts +++ b/dapp/src/theme/utils/colors.ts @@ -54,6 +54,9 @@ export const colors = { 700: "#231F20", // Acre Dirt }, opacity: { + white: { + 5: "rgba(255, 255, 255, 0.50)", + }, grey: { 700: { 5: "rgba(35, 31, 32, 0.05)", diff --git a/dapp/src/utils/numbers.ts b/dapp/src/utils/numbers.ts index d2de47cca..e3c9077c4 100644 --- a/dapp/src/utils/numbers.ts +++ b/dapp/src/utils/numbers.ts @@ -65,3 +65,42 @@ export const formatSatoshiAmount = ( amount: number | string, desiredDecimals = 2, ) => formatTokenAmount(amount, 8, desiredDecimals) + +/** + * Converts a fixed point number with a bigint amount and a decimals field + * indicating the orders of magnitude in `amount` behind the decimal point into + * a string in US decimal format (no thousands separators, . for the decimal + * separator). + * + * Used in cases where precision is critical. + * + * This function is based on the solution used by the Taho extension. + * More info: https://github.com/tahowallet/extension/blob/main/background/lib/fixed-point.ts#L172-L214 + */ +export function fixedPointNumberToString( + amount: bigint, + decimals: number, + trimTrailingZeros = true, +): string { + const undecimaledAmount = amount.toString() + const preDecimalLength = undecimaledAmount.length - decimals + + const preDecimalCharacters = + preDecimalLength > 0 + ? undecimaledAmount.substring(0, preDecimalLength) + : "0" + const postDecimalCharacters = + "0".repeat(Math.max(-preDecimalLength, 0)) + + undecimaledAmount.substring(preDecimalLength) + + const trimmedPostDecimalCharacters = trimTrailingZeros + ? postDecimalCharacters.replace(/0*$/, "") + : postDecimalCharacters + + const decimalString = + trimmedPostDecimalCharacters.length > 0 + ? `.${trimmedPostDecimalCharacters}` + : "" + + return `${preDecimalCharacters}${decimalString}` +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ef71f0cc..042be719e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-number-format: + specifier: ^5.3.1 + version: 5.3.1(react-dom@18.2.0)(react@18.2.0) devDependencies: '@thesis-co/eslint-config': specifier: ^0.6.1 @@ -13099,6 +13102,17 @@ packages: /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + /react-number-format@5.3.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-qpYcQLauIeEhCZUZY9jXZnnroOtdy3jYaS1zQ3M1Sr6r/KMOBEIGNIb7eKT19g2N1wbYgFgvDzs19hw5TrB8XQ==} + peerDependencies: + react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-refresh@0.14.0: resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} engines: {node: '>=0.10.0'}