diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index 5447b931..07f34fed 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -1,8 +1,9 @@ -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { forwardRef, useCallback, useRef } from "react"; import styled from "@emotion/styled"; import { css } from "@emotion/react"; -import { formatDecimals } from "utils"; +import { sanitizeNumberInput } from "utils"; import { MOBILE_SCREEN_CLASS } from "constants/layout"; +import { Numeric } from "@xpla/xpla.js"; type InputVariant = "default" | "base" | "primary"; type InputSize = "default" | "large"; @@ -184,43 +185,108 @@ export interface NumberInputProps extends InputProps { } export const NumberInput = forwardRef( - ({ decimals = 18, ...InputProps }, ref) => { - const inputRef = useRef(null); - useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); - useEffect(() => { - const elInput = inputRef.current; - const handleKeydown = (event: Event) => { - const target = event.target as HTMLInputElement; + ({ decimals = 18, onKeyDown, onKeyUp, onChange, ...InputProps }, ref) => { + const isIMEActive = useRef(false); + const isKeyPressing = useRef(false); + const lastValue = useRef(undefined); - target.value = target.value.replace(/[^0-9.]/g, ""); - - if (target.value?.split(".").length > 2) { - event.preventDefault(); - const index = target.value.lastIndexOf("."); - target.value = - target.value.substring(0, index) + - target.value.substring(index + 1, target.value.length); - } + const handleInput = useCallback>( + (event) => { + const target = event.currentTarget; if ( - target.value.includes(".") && - (target.value?.split(".").pop()?.length || 0) > decimals + isIMEActive.current || + !isKeyPressing.current || + target.value.split(".").length > 2 ) { + if (lastValue.current) { + target.value = lastValue.current; + } + } + const sanitizedValue = sanitizeNumberInput(target.value, decimals); + if (target.value !== sanitizedValue) { event.preventDefault(); - target.value = formatDecimals(target.value, decimals); + target.value = sanitizedValue; + } + }, + [decimals], + ); + + const handleKeyDown = useCallback< + React.KeyboardEventHandler + >( + (event) => { + const target = event.currentTarget; + isKeyPressing.current = true; + isIMEActive.current = event.key === "Process"; + lastValue.current = target.value; + if (isIMEActive.current) { + event.preventDefault(); + } + if (event.key.length === 1 && !event.ctrlKey) { + const regex = /[^0-9.]/g; + const isAllowedKey = !regex.test(event.key); + const selectedText = target.value?.substring( + target.selectionStart || 0, + target.selectionEnd || 0, + ); + if ( + !isAllowedKey || + (event.key === "." && + event.currentTarget.value?.includes(".") && + !selectedText.includes(".")) + ) { + event.preventDefault(); + } } - }; - elInput?.addEventListener("keydown", handleKeydown); - elInput?.addEventListener("keyup", handleKeydown); - elInput?.addEventListener("keypress", handleKeydown); - return () => { - if (elInput) { - elInput.removeEventListener("keydown", handleKeydown); - elInput.removeEventListener("keyup", handleKeydown); - elInput.removeEventListener("keypress", handleKeydown); + if (onKeyDown) { + onKeyDown(event); } - }; - }, [decimals]); + }, + [onKeyDown], + ); + + const handleKeyUp = useCallback< + React.KeyboardEventHandler + >( + (event) => { + isKeyPressing.current = false; + if (onKeyUp) { + onKeyUp(event); + } + }, + [onKeyUp], + ); - return ; + const handleChange = useCallback< + React.ChangeEventHandler + >( + (event) => { + const target = event.currentTarget; + if (target.value) { + try { + Numeric.parse(target.value, { throwOnError: true }); + if (onChange) { + onChange(event); + } + } catch (error) { + event.preventDefault(); + } + } + }, + [onChange], + ); + + return ( + + ); }, ); diff --git a/src/main.tsx b/src/main.tsx index 0f6ec847..6a2cb953 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import "simplebar"; import "simplebar/dist/simplebar.css"; import ResizeObserver from "resize-observer-polyfill"; +import "utils/overrideXplaNumeric"; window.ResizeObserver = ResizeObserver; diff --git a/src/pages/Earn/Lockdrop/Stake/index.tsx b/src/pages/Earn/Lockdrop/Stake/index.tsx index 860e8a22..1aa86edd 100644 --- a/src/pages/Earn/Lockdrop/Stake/index.tsx +++ b/src/pages/Earn/Lockdrop/Stake/index.tsx @@ -18,7 +18,7 @@ import { amountToValue, cutDecimal, ellipsisCenter, - filterNumberFormat, + sanitizeNumberInput, formatDateTime, formatNumber, getTokenLink, @@ -252,7 +252,7 @@ function StakePage() { form.setValue(FormKey.lpValue, value); }} {...register(FormKey.lpValue, { - setValueAs: (value) => filterNumberFormat(value, LP_DECIMALS), + setValueAs: (value) => sanitizeNumberInput(value, LP_DECIMALS), required: true, })} /> diff --git a/src/pages/Earn/Pools/Provide/InputGroup.tsx b/src/pages/Earn/Pools/Provide/InputGroup.tsx index 3686971f..525d3dbd 100644 --- a/src/pages/Earn/Pools/Provide/InputGroup.tsx +++ b/src/pages/Earn/Pools/Provide/InputGroup.tsx @@ -13,6 +13,7 @@ import useBalance from "hooks/useBalance"; import { Numeric } from "@xpla/xpla.js"; import { UseControllerProps, useController } from "react-hook-form"; import useDashboardTokenDetail from "hooks/dashboard/useDashboardTokenDetail"; +import { useMemo } from "react"; import AssetValueFormatter from "components/utils/AssetValueFormatter"; interface InputGroupProps extends NumberInputProps { @@ -59,6 +60,22 @@ function InputGroup({ const balance = useBalance(asset?.token); const dashboardToken = useDashboardTokenDetail(asset?.token || ""); + const expectedUsdValue = useMemo(() => { + try { + if (dashboardToken?.price && field.value) { + return `= $${formatNumber( + formatDecimals( + Numeric.parse(dashboardToken?.price || 0).mul(field.value), + 2, + ), + )}`; + } + } catch (error) { + console.log(error); + } + return "-"; + }, [dashboardToken, field.value]); + return ( @@ -134,14 +151,7 @@ function InputGroup({ text-align: right; `} > - {dashboardToken?.price && field.value - ? `= $${formatNumber( - formatDecimals( - Numeric.parse(dashboardToken?.price || 0).mul(field.value), - 2, - ), - )}` - : "-"} + {expectedUsdValue} diff --git a/src/pages/Earn/Pools/Provide/index.tsx b/src/pages/Earn/Pools/Provide/index.tsx index 607192e1..575e6ef2 100644 --- a/src/pages/Earn/Pools/Provide/index.tsx +++ b/src/pages/Earn/Pools/Provide/index.tsx @@ -156,6 +156,8 @@ function ProvidePage() { formData.asset1Value && asset2?.token && formData.asset2Value && + Number(formData.asset1Value) && + Number(formData.asset2Value) && !Numeric.parse(formData.asset1Value).isNaN() && !Numeric.parse(formData.asset2Value).isNaN() ? { @@ -208,36 +210,41 @@ function ProvidePage() { const asset2BalanceMinusFee = useBalanceMinusFee(asset2?.token, feeAmount); const buttonMsg = useMemo(() => { - if (formData.asset1Value) { - if ( - Numeric.parse(formData.asset1Value).gt( - Numeric.parse( - amountToValue(asset1BalanceMinusFee, asset1?.decimals) || 0, - ), - ) - ) { - return `Insufficient ${asset1?.symbol} balance`; - } + try { + if (formData.asset1Value) { + if ( + Numeric.parse(formData.asset1Value).gt( + Numeric.parse( + amountToValue(asset1BalanceMinusFee, asset1?.decimals) || 0, + ), + ) + ) { + return `Insufficient ${asset1?.symbol} balance`; + } - if ( - formData.asset2Value && - Numeric.parse(formData.asset2Value).gt( - Numeric.parse( - amountToValue(asset2BalanceMinusFee, asset2?.decimals) || 0, - ), - ) - ) { - return `Insufficient ${asset2?.symbol} balance`; - } + if ( + formData.asset2Value && + Numeric.parse(formData.asset2Value).gt( + Numeric.parse( + amountToValue(asset2BalanceMinusFee, asset2?.decimals) || 0, + ), + ) + ) { + return `Insufficient ${asset2?.symbol} balance`; + } - if (formData.asset1Value && !formData.asset2Value) { - return `Enter ${asset2?.symbol} amount`; + if (formData.asset1Value && !formData.asset2Value) { + return `Enter ${asset2?.symbol} amount`; + } + return "Add"; } - return "Add"; - } - if (formData.asset2Value && !formData.asset1Value) { - return `Enter ${asset1?.symbol} amount`; + if (formData.asset2Value && !formData.asset1Value) { + return `Enter ${asset1?.symbol} amount`; + } + } catch (error) { + console.log(error); + return "Enter an amount"; } return "Enter an amount"; @@ -321,7 +328,7 @@ function ProvidePage() { }, [asset2BalanceMinusFee, formData.asset2Value, form]); useEffect(() => { - if (simulationResult && !simulationResult.isLoading && !isPoolEmpty) { + if (!simulationResult?.isLoading && !isPoolEmpty) { form.setValue( isReversed ? FormKey.asset1Value : FormKey.asset2Value, amountToValue( @@ -360,7 +367,7 @@ function ProvidePage() { }, }} asset={asset1} - onClick={() => { + onFocus={() => { setIsReversed(false); setBalanceApplied(false); }} @@ -397,12 +404,12 @@ function ProvidePage() { }, }} asset={asset2} - onClick={() => { - setIsReversed(false); + onFocus={() => { + setIsReversed(true); setBalanceApplied(false); }} onBalanceClick={(value) => { - setIsReversed(false); + setIsReversed(true); setBalanceApplied(true); form.setValue(FormKey.asset2Value, value, { shouldValidate: true, @@ -442,7 +449,7 @@ function ProvidePage() { label={ {`1${asset1?.symbol} = ${ - formData.asset1Value && formData.asset2Value + Number(formData.asset1Value) && Number(formData.asset2Value) ? cutDecimal( Numeric.parse(formData.asset2Value || 0) .div(formData.asset1Value || 1) @@ -785,7 +792,8 @@ function ProvidePage() { form.formState.isValidating || simulationResult.isLoading || isFeeLoading || - isFeeFailed + isFeeFailed || + buttonMsg !== "Add" } css={css` margin-bottom: 10px; diff --git a/src/pages/Trade/Swap/index.tsx b/src/pages/Trade/Swap/index.tsx index da5138d8..5b96c9f5 100644 --- a/src/pages/Trade/Swap/index.tsx +++ b/src/pages/Trade/Swap/index.tsx @@ -13,7 +13,7 @@ import useAssets from "hooks/useAssets"; import { amountToValue, cutDecimal, - filterNumberFormat, + sanitizeNumberInput, formatDecimals, formatNumber, valueToAmount, @@ -254,13 +254,13 @@ function SwapPage() { const beliefPrice = useMemo(() => { if (isReversed) { - if (asset2Value && simulationResult.estimatedAmount) { + if (Number(asset2Value) && simulationResult.estimatedAmount) { return Numeric.parse( amountToValue(simulationResult.estimatedAmount, asset1?.decimals) || 0, ).div(asset2Value); } - } else if (asset1Value && simulationResult.estimatedAmount) { + } else if (Number(asset1Value) && simulationResult.estimatedAmount) { return Numeric.parse(asset1Value || 0).div( Numeric.parse( amountToValue(simulationResult.estimatedAmount, asset2?.decimals) || @@ -288,7 +288,7 @@ function SwapPage() { !asset1?.token || !asset1Value || isPoolEmpty || - Numeric.parse(asset1Value).isNaN() + !Number(asset1Value) ) { return undefined; } @@ -331,27 +331,28 @@ function SwapPage() { const asset1BalanceMinusFee = useBalanceMinusFee(asset1Address, feeAmount); const buttonMsg = useMemo(() => { - if (asset1 === undefined || asset2 === undefined) { - return "Select tokens"; - } + try { + if (asset1 === undefined || asset2 === undefined) { + return "Select tokens"; + } - if (isPoolEmpty) { - return "Swap"; - } + if (isPoolEmpty) { + return "Swap"; + } - if (asset1Value) { - if ( - Numeric.parse(asset1Value).gt( - Numeric.parse( + if (asset1Value) { + if ( + Numeric.parse(asset1Value).gt( amountToValue(asset1BalanceMinusFee, asset1?.decimals) || 0, - ), - ) - ) { - return `Insufficient ${asset1?.symbol} balance`; + ) + ) { + return `Insufficient ${asset1?.symbol} balance`; + } + return "Swap"; } - return "Swap"; + } catch (error) { + console.log(error); } - return "Enter an amount"; }, [asset1, asset2, asset1BalanceMinusFee, asset1Value, asset2Value]); @@ -363,7 +364,7 @@ function SwapPage() { balanceApplied && !isReversed && asset1Address === XPLA_ADDRESS && - asset1Value && + Number(asset1Value) && Numeric.parse(asset1Value || 0).gt( Numeric.parse( amountToValue(asset1BalanceMinusFee, asset1?.decimals) || 0, @@ -607,7 +608,7 @@ function SwapPage() { }} {...register(FormKey.asset1Value, { setValueAs: (value) => - filterNumberFormat(value, asset1?.decimals), + sanitizeNumberInput(value, asset1?.decimals), onChange: () => { setBalanceApplied(false); setIsReversed(false); @@ -652,7 +653,7 @@ function SwapPage() { `} > - {dashboardToken1?.price && asset1Value + {dashboardToken1?.price && Number(asset1Value) ? `= $${formatNumber( formatDecimals( Numeric.parse(dashboardToken1?.price || 0).mul( @@ -819,7 +820,7 @@ function SwapPage() { }} {...register(FormKey.asset2Value, { setValueAs: (value) => - filterNumberFormat(value, asset2?.decimals), + sanitizeNumberInput(value, asset2?.decimals), onChange: () => { setIsReversed(true); }, diff --git a/src/types/@xpla/xpla.js.d.ts b/src/types/@xpla/xpla.js.d.ts new file mode 100644 index 00000000..075d4c54 --- /dev/null +++ b/src/types/@xpla/xpla.js.d.ts @@ -0,0 +1,14 @@ +import { Numeric as XplaNumeric } from "@xpla/xpla.js"; + +type CustomNumericOptions = { + throwOnError?: boolean; +}; + +declare module "@xpla/xpla.js" { + namespace Numeric { + function parse( + value: XplaNumeric.Input, + options?: CustomNumericOptions, + ): XplaNumeric.Output; + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index e3fcce51..e4279cae 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -58,37 +58,16 @@ export const amountToValue = (value?: Numeric.Input, decimals = 18) => { } }; -export const filterNumberFormat = (value: string, decimals = 18) => { - if (!value || value.length < 2) { - return value?.replace(/[^0-9]/g, ""); - } - - let t = 0; - let v = 0; - // remove redundant zeros - // replace non-digit characters and preceding multiple zeros - const filtered = value.replaceAll(/[0\\.]/g, "").length - ? value - .replace(/[^0-9\\.]/g, "") - .replace(/^[0]+[\\.]/g, (match: string) => { - v += 1; - return v === 1 ? "0." : match; - }) - .replace(/[\\.]/g, (match: string) => { - t += 1; - return t === 2 ? "" : match; - }) - : "0"; - - // decimal count check - return filtered.includes(".") && - (filtered.split(".").pop()?.length || 0) > decimals - ? filtered.slice(0, filtered.indexOf(".")) + - filtered.slice( - filtered.indexOf("."), - filtered.indexOf(".") + decimals + 1, - ) - : filtered; +export const sanitizeNumberInput = (value: string, decimals = 18) => { + // Remove all non-numeric characters except decimal point and minus sign + const regex = /[^0-9.]/g; + const isNegative = value.startsWith("-"); + const sanitized = value.replace(regex, ""); + const [integerPart, decimalPart] = sanitized.split("."); + + return `${isNegative ? "-" : ""}${integerPart}${ + decimalPart !== undefined ? `.${decimalPart.slice(0, decimals)}` : "" + }`; }; export const getBlockLink = (height?: string, network?: string) => diff --git a/src/utils/overrideXplaNumeric/index.ts b/src/utils/overrideXplaNumeric/index.ts new file mode 100644 index 00000000..c4a60f14 --- /dev/null +++ b/src/utils/overrideXplaNumeric/index.ts @@ -0,0 +1,15 @@ +import { Numeric } from "@xpla/xpla.js"; +import { type CustomNumericOptions } from "types/@xpla/xpla.js"; + +const originalNumericParse = Numeric.parse; +Numeric.parse = (value, options: CustomNumericOptions = {}) => { + try { + return originalNumericParse(value); + } catch (error) { + if (options?.throwOnError) { + throw error; + } + console.log(error); + } + return originalNumericParse(0); +};