diff --git a/package-lock.json b/package-lock.json index 47ef5e9c..0db4619c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-icons": "^4.11.0", + "react-imask": "^7.1.3", "react-redux": "^8.1.2", "tailwindcss": "3.3.2", "typescript": "5.1.6" @@ -688,6 +689,18 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.23.4.tgz", + "integrity": "sha512-zQyB4MJGM+rvd4pM58n26kf3xbiitw9MHzL8oLiBMKb8MCtVDfV5nDzzJWWzLMtbvKI9wN6XwJYl479qF4JluQ==", + "dependencies": { + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", @@ -3344,6 +3357,16 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/core-js-pure": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.33.3.tgz", + "integrity": "sha512-taJ00IDOP+XYQEA2dAe4ESkmHt1fL8wzYDo3mRWQey8uO9UojlBFMneA65kMyxfYP7106c6LzWaq7/haDT6BCQ==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4976,6 +4999,17 @@ "node": ">= 4" } }, + "node_modules/imask": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/imask/-/imask-7.1.3.tgz", + "integrity": "sha512-jZCqTI5Jgukhl2ff+znBQd8BiHOTlnFYCIgggzHYDdoJsHmSSWr1BaejcYBxsjy4ZIs8Rm0HhbOxQcobcdESRQ==", + "dependencies": { + "@babel/runtime-corejs3": "^7.22.6" + }, + "engines": { + "npm": ">=4.0.0" + } + }, "node_modules/immer": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.2.tgz", @@ -7996,6 +8030,21 @@ "react": "*" } }, + "node_modules/react-imask": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/react-imask/-/react-imask-7.1.3.tgz", + "integrity": "sha512-anCnzdkqpDzNwe7ot76kQSvmnz4Sw7AW/QFjjLh3B87HVNv9e2oHC+1m9hQKSIui2Tqm7w68ooMgDFsCQlDMyg==", + "dependencies": { + "imask": "^7.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "npm": ">=4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index d402d8b8..14025c79 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-icons": "^4.11.0", + "react-imask": "^7.1.3", "react-redux": "^8.1.2", "tailwindcss": "3.3.2", "typescript": "5.1.6" diff --git a/src/app/components/OrderBook.tsx b/src/app/components/OrderBook.tsx index b5a9efea..62561ab7 100644 --- a/src/app/components/OrderBook.tsx +++ b/src/app/components/OrderBook.tsx @@ -51,8 +51,8 @@ function OrderBookRow(props: OrderBookRowProps) { function CurrentPriceRow() { const trades = useAppSelector((state) => state.accountHistory.trades); const orderBook = useAppSelector((state) => state.orderBook); - const priceMaxDecimals = useAppSelector( - (state) => state.pairSelector.priceMaxDecimals + const token2MaxDecimals = useAppSelector( + (state) => state.pairSelector.token2.decimals ); let spreadString = ""; @@ -73,7 +73,7 @@ function CurrentPriceRow() { const spread = utils.displayPositiveNumber( orderBook.spread, N_DIGITS, - priceMaxDecimals + token2MaxDecimals ); const spreadPercent = utils.displayPositiveNumber( orderBook.spreadPercent, diff --git a/src/app/components/order_input/AmountInput.tsx b/src/app/components/order_input/AmountInput.tsx index 839431bc..f6a7c26e 100644 --- a/src/app/components/order_input/AmountInput.tsx +++ b/src/app/components/order_input/AmountInput.tsx @@ -11,6 +11,7 @@ import { } from "redux/orderInputSlice"; import { BottomRightErrorLabel } from "components/BottomRightErrorLabel"; import { getLocaleSeparators } from "utils"; +import { IMaskInput } from "react-imask"; export const enum PayReceive { PAY = "YOU PAY:", @@ -21,7 +22,7 @@ interface TokenInputFiledProps extends TokenInput { payReceive: string; disabled?: boolean; onFocus?: () => void; - onChange: (event: React.ChangeEvent) => void; + onAccept: (value: any) => void; } export function AmountInput(props: TokenInputFiledProps) { @@ -41,7 +42,7 @@ export function AmountInput(props: TokenInputFiledProps) { disabled, payReceive, onFocus, - onChange, + onAccept, } = props; const { decimalSeparator } = getLocaleSeparators(); @@ -78,18 +79,22 @@ export function AmountInput(props: TokenInputFiledProps) { {symbol} - + onAccept={onAccept} + > diff --git a/src/app/components/order_input/LimitOrderInput.tsx b/src/app/components/order_input/LimitOrderInput.tsx index 5ca7642f..b1a8ee0f 100644 --- a/src/app/components/order_input/LimitOrderInput.tsx +++ b/src/app/components/order_input/LimitOrderInput.tsx @@ -118,7 +118,9 @@ function PriceInput() { value={price} onChange={(event: React.ChangeEvent) => { dispatch( - orderInputSlice.actions.setPrice(numberOrEmptyInput(event)) + orderInputSlice.actions.setPrice( + numberOrEmptyInput(event.target.value) + ) ); }} /> @@ -195,9 +197,10 @@ export function LimitOrderInput() { payReceive={ side === OrderSide.BUY ? PayReceive.RECEIVE : PayReceive.PAY } - onChange={(event) => { + onAccept={(value) => { + dispatch(orderInputSlice.actions.resetValidation()); dispatch( - orderInputSlice.actions.setAmountToken1(numberOrEmptyInput(event)) + orderInputSlice.actions.setAmountToken1(numberOrEmptyInput(value)) ); }} /> diff --git a/src/app/components/order_input/MarketOrderInput.tsx b/src/app/components/order_input/MarketOrderInput.tsx index 207c9c8b..dc26f087 100644 --- a/src/app/components/order_input/MarketOrderInput.tsx +++ b/src/app/components/order_input/MarketOrderInput.tsx @@ -49,7 +49,6 @@ export function MarketOrderInput() { const slippageValidationResult = useAppSelector(validateSlippageInput); const dispatch = useAppDispatch(); - useEffect(() => { if ( tartgetToken.amount !== "" && @@ -89,7 +88,6 @@ export function MarketOrderInput() { }) ); }, [token2, dispatch]); - return (
@@ -99,13 +97,13 @@ export function MarketOrderInput() { onFocus={() => { dispatch(orderInputSlice.actions.setSide(OrderSide.SELL)); }} - onChange={(event) => { + onAccept={(value) => { + dispatch(orderInputSlice.actions.resetValidation()); dispatch( - orderInputSlice.actions.setAmountToken1(numberOrEmptyInput(event)) + orderInputSlice.actions.setAmountToken1(numberOrEmptyInput(value)) ); }} /> - { dispatch(orderInputSlice.actions.setSide(OrderSide.BUY)); }} - onChange={(event) => { + onAccept={(value) => { + dispatch(orderInputSlice.actions.resetValidation()); dispatch( - orderInputSlice.actions.setAmountToken2(numberOrEmptyInput(event)) + orderInputSlice.actions.setAmountToken2(numberOrEmptyInput(value)) ); }} /> @@ -148,7 +147,7 @@ export function MarketOrderInput() { onChange={(event) => { dispatch( orderInputSlice.actions.setSlippage( - uiSlippageToSlippage(numberOrEmptyInput(event)) + uiSlippageToSlippage(numberOrEmptyInput(event.target.value)) ) ); }} diff --git a/src/app/debounce.ts b/src/app/debounce.ts new file mode 100644 index 00000000..afaeef4c --- /dev/null +++ b/src/app/debounce.ts @@ -0,0 +1,20 @@ +import { useState, useEffect } from "react"; + +//ToDo: Maybe move to utils.ts? +// This function allows you to debounce a function call. +// Which means it will wait for the specified delay before executing the function. +export default function useDebounce(value: any, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/app/redux/orderInputSlice.ts b/src/app/redux/orderInputSlice.ts index a0e03303..df25dab8 100644 --- a/src/app/redux/orderInputSlice.ts +++ b/src/app/redux/orderInputSlice.ts @@ -18,6 +18,19 @@ export enum OrderTab { LIMIT = "LIMIT", } +export enum ErrorMessage { + UNSPECIFIED_PRICE = "Price must be specified", + NONZERO_PRICE = "Price must be greater than 0", + HIGH_PRICE = "Price is significantly higher than best sell", + LOW_PRICE = "Price is significantly lower than best buy", + EXCESSIVE_DECIMALS = "Too many decimal places", + INSUFFICIENT_FUNDS = "Insufficient funds", + //Slippage + UNSPECIFIED_SLIPPAGE = "Slippage must be specified", + NEGATIVE_SLIPPAGE = "Slippage must be positive", + HIGH_SLIPPAGE = "High slippage entered", +} + export const PLATFORM_BADGE_ID = 4; //TODO: Get this data from the platform badge export const PLATFORM_FEE = 0.001; //TODO: Get this data from the platform badge @@ -28,6 +41,10 @@ interface QuoteWithPriceTokenAddress extends Quote { priceTokenAddress: string; } +export interface TokenInfo extends adex.TokenInfo { + decimals?: number; +} + export interface ValidationResult { valid: boolean; message: string; @@ -38,6 +55,7 @@ export interface TokenInput { symbol: string; iconUrl: string; amount: number | ""; + decimals: number; } export interface OrderInputState { @@ -76,11 +94,13 @@ export const initialTokenInput = { symbol: "", iconUrl: "", amount: 0, + decimals: 0, }; const initialValidationResult = { valid: true, message: "", + decimals: 0, }; export const initialState: OrderInputState = { @@ -110,8 +130,11 @@ export const selectTargetToken = (state: RootState) => { const selectSlippage = (state: RootState) => state.orderInput.slippage; const selectPrice = (state: RootState) => state.orderInput.price; const selectSide = (state: RootState) => state.orderInput.side; -const selectPriceMaxDecimals = (state: RootState) => { - return state.pairSelector.priceMaxDecimals; +const selectToken1MaxDecimals = (state: RootState) => { + return state.pairSelector.token1.decimals; +}; +const selectToken2MaxDecimals = (state: RootState) => { + return state.pairSelector.token2.decimals; }; // TODO: find out if it's possible to do the same with less boilerplate @@ -232,6 +255,15 @@ export const orderInputSlice = createSlice({ state.tab = action.payload; }, updateAdex(state, action: PayloadAction) { + //This clears up any validation when switching pairs + /* + state.validationToken1 = initialValidationResult; + state.validationToken2 = initialValidationResult; + + //Clear up any previous inputs + state.token1 = initialTokenInput; + state.token2 = initialTokenInput; + */ const serializedState: adex.StaticState = JSON.parse( JSON.stringify(action.payload) ); @@ -243,6 +275,7 @@ export const orderInputSlice = createSlice({ symbol: adexToken1.symbol, iconUrl: adexToken1.iconUrl, amount: "", + decimals: serializedState.currentPairInfo.token1MaxDecimals, }; } if (state.token2.address !== adexToken2.address) { @@ -251,6 +284,7 @@ export const orderInputSlice = createSlice({ symbol: adexToken2.symbol, iconUrl: adexToken2.iconUrl, amount: "", + decimals: serializedState.currentPairInfo.token2MaxDecimals, }; } @@ -309,6 +343,7 @@ export const orderInputSlice = createSlice({ const validation = _validateAmountWithBalance({ amount, balance, + decimals: state.token1.decimals, }); if (tokenAddress === state.token1.address) { state.validationToken1 = validation; @@ -333,6 +368,10 @@ export const orderInputSlice = createSlice({ togglePostOnly(state) { state.postOnly = !state.postOnly; }, + resetValidation(state) { + state.validationToken1 = initialValidationResult; + state.validationToken2 = initialValidationResult; + }, }, // asynchronous reducers @@ -500,15 +539,15 @@ export const validateSlippageInput = createSelector( [selectSlippage], (slippage) => { if (slippage === "") { - return { valid: false, message: "Slippage must be specified" }; + return { valid: false, message: ErrorMessage.UNSPECIFIED_SLIPPAGE }; } if (slippage < 0) { - return { valid: false, message: "Slippage must be positive" }; + return { valid: false, message: ErrorMessage.NEGATIVE_SLIPPAGE }; } if (slippage >= 0.05) { - return { valid: true, message: "High slippage entered" }; + return { valid: true, message: ErrorMessage.HIGH_SLIPPAGE }; } return { valid: true, message: "" }; @@ -518,29 +557,42 @@ export const validateSlippageInput = createSelector( export const validatePriceInput = createSelector( [ selectPrice, - selectPriceMaxDecimals, + selectToken1MaxDecimals, + selectToken2MaxDecimals, selectBestBuy, selectBestSell, selectSide, ], - (price, priceMaxDecimals, bestBuy, bestSell, side) => { + ( + price, + selectToken1MaxDecimals, + selectToken2MaxDecimals, + bestBuy, + bestSell, + side + ) => { if (price === "") { - return { valid: false, message: "Price must be specified" }; + return { valid: false, message: ErrorMessage.UNSPECIFIED_PRICE }; } if (price <= 0) { - return { valid: false, message: "Price must be greater than 0" }; + return { valid: false, message: ErrorMessage.NONZERO_PRICE }; } - if (price.toString().split(".")[1]?.length > priceMaxDecimals) { - return { valid: false, message: "Too many decimal places" }; - } + if (selectToken1MaxDecimals !== undefined) + if (price.toString().split(".")[1]?.length > selectToken1MaxDecimals) { + return { valid: false, message: ErrorMessage.EXCESSIVE_DECIMALS }; + } + if (selectToken2MaxDecimals !== undefined) + if (price.toString().split(".")[1]?.length > selectToken2MaxDecimals) { + return { valid: false, message: ErrorMessage.EXCESSIVE_DECIMALS }; + } if (bestSell) { if (side === OrderSide.BUY && price > bestSell * 1.05) { return { valid: true, - message: "Price is significantly higher than best sell", + message: ErrorMessage.HIGH_PRICE, }; } } @@ -549,7 +601,7 @@ export const validatePriceInput = createSelector( if (side === OrderSide.SELL && price < bestBuy * 0.95) { return { valid: true, - message: "Price is significantly lower than best buy", + message: ErrorMessage.LOW_PRICE, }; } } @@ -560,19 +612,19 @@ export const validatePriceInput = createSelector( function _validateAmount(amount: number | ""): ValidationResult { let valid = true; let message = ""; - if (amount === "" || amount === undefined) { return { valid, message }; } - - if (amount.toString().split(".")[1]?.length > adex.AMOUNT_MAX_DECIMALS) { + /* + if (amount.toString().split(".")[1]?.length > decimals) { + console.log(amount.toString().split(".")[1]?.length, " vs ", decimals); valid = false; - message = "Too many decimal places"; + message = ErrorMessage.EXCESSIVE_DECIMALS; } - +*/ if (amount <= 0) { valid = false; - message = "Amount must be greater than 0"; + message = ErrorMessage.NONZERO_PRICE; } return { valid, message }; @@ -584,9 +636,10 @@ function _validateAmountWithBalance({ }: { amount: number | ""; balance: number; + decimals: number | 0; }): ValidationResult { if ((balance || 0) < (amount || 0)) { - return { valid: false, message: "Insufficient funds" }; + return { valid: false, message: ErrorMessage.INSUFFICIENT_FUNDS }; } else { return _validateAmount(amount); } diff --git a/src/app/redux/pairSelectorSlice.ts b/src/app/redux/pairSelectorSlice.ts index 8bdbbb17..f01e1533 100644 --- a/src/app/redux/pairSelectorSlice.ts +++ b/src/app/redux/pairSelectorSlice.ts @@ -7,12 +7,12 @@ export const AMOUNT_MAX_DECIMALS = adex.AMOUNT_MAX_DECIMALS; export interface TokenInfo extends adex.TokenInfo { balance?: number; + decimals?: number; } export interface PairSelectorState { name: string; address: string; - priceMaxDecimals: number; token1: TokenInfo; token2: TokenInfo; pairsList: adex.PairInfo[]; @@ -23,12 +23,12 @@ export const initalTokenInfo: TokenInfo = { symbol: "", name: "", iconUrl: "", + decimals: 8, }; const initialState: PairSelectorState = { name: "", address: "", - priceMaxDecimals: 0, token1: { ...initalTokenInfo }, token2: { ...initalTokenInfo }, pairsList: [], @@ -90,7 +90,8 @@ export const pairSelectorSlice = createSlice({ ) => { const adexState = action.payload; - state.priceMaxDecimals = adexState.currentPairInfo.priceMaxDecimals; + state.token1.decimals = adexState.currentPairInfo.token1MaxDecimals; + state.token2.decimals = adexState.currentPairInfo.token2MaxDecimals; // unpacking to avoid loosing balances info state.token1 = { diff --git a/src/app/utils.ts b/src/app/utils.ts index 7f6023b9..e80d87e5 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -15,26 +15,9 @@ export function getLocaleSeparators(): { decimalSeparator: string; thousandsSeparator: string; } { - // we don't want users locale settings but - // the actual settings for number formatting - // (which may be different from the default than their locale default - // and can be edited manually by users in OS settings) => - // not toLocaleString but toString - let decimalSeparator = Number(1.1).toString().substring(1, 2); - let thousandsSeparator = Number(10000).toString().substring(2, 3); - - // we always have a thousands separator - // if the platform doesn't have one we add space - if (thousandsSeparator == "0") { - thousandsSeparator = " "; - } - if (thousandsSeparator == "") { - thousandsSeparator = " "; - } - return { - decimalSeparator, - thousandsSeparator, + decimalSeparator: ".", + thousandsSeparator: " ", }; } @@ -299,12 +282,13 @@ export const formatPercentageChange = (percChange: number | null): string => { return "(0.00%)"; }; -export function numberOrEmptyInput(event: React.ChangeEvent) { +export function numberOrEmptyInput(event: string) { let amount: number | ""; - if (event.target.value === "") { + + if (event === "") { amount = ""; } else { - amount = Number(event.target.value); + amount = Number(event); } return amount; }