diff --git a/apps/app/Models/QueryParams.ts b/apps/app/Models/QueryParams.ts index d7a0bbf5..e1462f22 100644 --- a/apps/app/Models/QueryParams.ts +++ b/apps/app/Models/QueryParams.ts @@ -15,6 +15,7 @@ export class QueryParams { hideFrom?: boolean = false; hideTo?: boolean = false; transferAmount?: string = ""; + receiveAmount?: string = ""; balances?: string = ""; account?: string = ""; buttonTextColor?: string = ""; diff --git a/apps/app/components/Common/TypingEffect.tsx b/apps/app/components/Common/TypingEffect.tsx deleted file mode 100644 index c4c5f09f..00000000 --- a/apps/app/components/Common/TypingEffect.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { motion, useInView } from "framer-motion" -import { useEffect, useRef } from "react" - -type TypingEffectProps = { - text: string; - onComplete?: () => void; - withShine?: boolean; - className?: string; -} - -export function TypingEffect({ text = 'Typing Effect', onComplete, withShine = true, className }: TypingEffectProps) { - const ref = useRef(null); - const isInView = useInView(ref, { once: true }); - - useEffect(() => { - if (isInView && onComplete) { - const totalDuration = (text.length - 1) * 0.1 + 0.05; - const timeout = setTimeout(() => { - onComplete(); - }, totalDuration * 1000); - - return () => clearTimeout(timeout); - } - }, [isInView, text.length, onComplete]); - - const shineClasses = withShine - ? "text-transparent bg-[linear-gradient(120deg,var(--color-primary-text-tertiary)_40%,var(--color-primary-text),var(--color-primary-text-tertiary)_60%)] bg-size-[200%_100%] bg-clip-text animate-shine" - : ""; - - return ( -
- {text.split('').map((letter, index) => ( - - {letter} - - ))} -
- ); -} diff --git a/apps/app/components/DTOs/SwapFormValues.ts b/apps/app/components/DTOs/SwapFormValues.ts index 1b8ea15e..55ed41c4 100644 --- a/apps/app/components/DTOs/SwapFormValues.ts +++ b/apps/app/components/DTOs/SwapFormValues.ts @@ -2,6 +2,7 @@ import { ExtendedNetwork, ExtendedToken } from "../../Models/Network"; export type SwapFormValues = { amount?: string; + receiveAmount?: string; destination_address?: string; fromCurrency?: ExtendedToken; toCurrency?: ExtendedToken; diff --git a/apps/app/components/FeeDetails/index.tsx b/apps/app/components/FeeDetails/index.tsx index d19c08dc..e179447e 100644 --- a/apps/app/components/FeeDetails/index.tsx +++ b/apps/app/components/FeeDetails/index.tsx @@ -23,7 +23,7 @@ export interface QuoteComponentProps { } export default function QuoteDetails({ values, quote, isQuoteLoading }: QuoteComponentProps) { - const { toCurrency: toAsset, fromCurrency, amount } = values || {}; + const { toCurrency: toAsset, fromCurrency } = values || {}; const [isAccordionOpen, setIsAccordionOpen] = useState(false); if (!quote) return null diff --git a/apps/app/components/Icons/Cancell.tsx b/apps/app/components/Icons/Cancell.tsx deleted file mode 100644 index 84a09db7..00000000 --- a/apps/app/components/Icons/Cancell.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -const CancelIcon = (props) => ( - - - - - - - -); - -export default CancelIcon; \ No newline at end of file diff --git a/apps/app/components/Icons/CheckedIcon.tsx b/apps/app/components/Icons/CheckedIcon.tsx deleted file mode 100644 index c362c7ee..00000000 --- a/apps/app/components/Icons/CheckedIcon.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { SVGProps } from "react" - -const CheckedIcon = (props: SVGProps) => { - return - - - -} - -export default CheckedIcon; \ No newline at end of file diff --git a/apps/app/components/Icons/CircleX.tsx b/apps/app/components/Icons/CircleX.tsx deleted file mode 100644 index b10e6404..00000000 --- a/apps/app/components/Icons/CircleX.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { SVGProps } from "react" - -const XCircle = (props: SVGProps) => { - return - - -} - -export default XCircle; \ No newline at end of file diff --git a/apps/app/components/Icons/DiscordLogo.tsx b/apps/app/components/Icons/DiscordLogo.tsx deleted file mode 100644 index 11696324..00000000 --- a/apps/app/components/Icons/DiscordLogo.tsx +++ /dev/null @@ -1,14 +0,0 @@ -const DiscordLogo = (props) => ( - - - - - - - - - - -); - -export default DiscordLogo; \ No newline at end of file diff --git a/apps/app/components/Icons/ManualTransferSVG.tsx b/apps/app/components/Icons/ManualTransferSVG.tsx deleted file mode 100644 index 20e6a08b..00000000 --- a/apps/app/components/Icons/ManualTransferSVG.tsx +++ /dev/null @@ -1,104 +0,0 @@ -const ManualTransferSVG = () => { - return - - - - - - - - - - - - - - - - - Transfer to deposit address - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -} - -export default ManualTransferSVG; \ No newline at end of file diff --git a/apps/app/components/Icons/Question.tsx b/apps/app/components/Icons/Question.tsx deleted file mode 100644 index 1a746866..00000000 --- a/apps/app/components/Icons/Question.tsx +++ /dev/null @@ -1,8 +0,0 @@ - -const QuestionIcon = (props) => ( - - - - ) - -export default QuestionIcon; \ No newline at end of file diff --git a/apps/app/components/Input/Amount.tsx b/apps/app/components/Input/Amount.tsx deleted file mode 100644 index 06c6454a..00000000 --- a/apps/app/components/Input/Amount.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { useFormikContext } from "formik"; -import { Input } from "@/components/shadcn/input"; -import { forwardRef, useEffect, useMemo, useRef } from "react"; -import { SwapFormValues } from "../DTOs/SwapFormValues"; -import NumericInput from "./NumericInput"; -import { formatUsd } from "@/components/utils/formatUsdAmount"; -import clsx from "clsx"; -import { useUsdTokenSync } from "@/hooks/useUsdTokenSync"; -import { ArrowUpDown } from "lucide-react"; - -interface AmountFieldProps { - fee: unknown; - actionValue?: number; - actionValueUsd?: string; - className?: string; - showToggle?: boolean; -} - -const AmountField = forwardRef(function AmountField({ actionValue, actionValueUsd, className, showToggle }: AmountFieldProps, ref: any) { - const { values, handleChange } = useFormikContext(); - const { fromCurrency, amount } = values || {}; - const { setFieldValue } = useFormikContext(); - const name = "amount" - const amountRef = useRef(ref) - const suffixRef = useRef(null); - - const { sourceCurrencyPriceInUsd, isUsdMode, usdAmount, handleToggle, handleUsdInputChange, } = useUsdTokenSync({ fromCurrency, amount, setFieldValue, }); - - // --- Token mode display computations --- - - const requestedAmountInUsd = useMemo(() => { - const amountNumber = Number(amount); - if (isNaN(amountNumber) || amountNumber <= 0 || !sourceCurrencyPriceInUsd) - return undefined; - return formatUsd(sourceCurrencyPriceInUsd * amountNumber) - }, [amount, sourceCurrencyPriceInUsd]); - - const actionValueInUsd = useMemo(() => { - const amountNumber = Number(actionValue); - if (isNaN(amountNumber) || amountNumber <= 0) - return undefined; - if (actionValueUsd) return formatUsd(Number(actionValueUsd)); - if (!sourceCurrencyPriceInUsd) return undefined; - return formatUsd(sourceCurrencyPriceInUsd * amountNumber) - }, [actionValue, actionValueUsd, sourceCurrencyPriceInUsd]); - - // --- USD mode display computations --- - - const actionValueAsUsd = useMemo(() => { - if (actionValue === undefined || actionValue <= 0) - return undefined; - if (actionValueUsd) return actionValueUsd; - if (!sourceCurrencyPriceInUsd) return undefined; - return (actionValue * sourceCurrencyPriceInUsd).toFixed(2).replace(/\.?0+$/, ''); - }, [actionValue, actionValueUsd, sourceCurrencyPriceInUsd]); - - const actionValueAsToken = useMemo(() => { - if (actionValue === undefined || actionValue <= 0) return undefined; - const precision = fromCurrency?.decimals || 6; - return formatTokenAmount(actionValue, precision); - }, [actionValue, fromCurrency?.decimals]); - - const formattedTokenAmount = useMemo(() => { - const num = Number(amount); - if (isNaN(num) || num <= 0) return '0'; - const precision = fromCurrency?.decimals || 6; - return formatTokenAmount(num, precision); - }, [amount, fromCurrency?.decimals]); - - // --- Suffix positioning for token mode --- - - useEffect(() => { - if (isUsdMode) return; - const input = amountRef.current; - const suffix = suffixRef.current; - if (!input || !suffix) return; - const font = getFontFromElement(input); - const width = getTextWidth(actionValue?.toString() || amount || "0", font); - suffix.style.left = `${width + 16}px`; - }, [amount, requestedAmountInUsd, actionValue, isUsdMode]); - - const placeholder = '0' - const step = 1 / Math.pow(10, fromCurrency?.decimals || 1) - const canToggle = !!sourceCurrencyPriceInUsd; - - const toggleButton = canToggle ? ( - - ) : null; - - // --- USD mode render --- - - if (isUsdMode) { - const previewUsd = actionValueAsUsd; - const previewToken = actionValueAsToken; - - return ( -
-
- $ - -
-
- {toggleButton} - - - {`${previewToken ?? formattedTokenAmount}`} - - - {` ${fromCurrency?.symbol || ''}`} - - -
-
- ); - } - - // --- Token mode render (default) --- - - return ( -
- { - /^[0-9]*[.,]?[0-9]*$/.test(e.target.value) && handleChange(e); - }} - /> -
- {toggleButton} - {`${actionValueInUsd ?? requestedAmountInUsd ?? '$0'}`} -
-
- ) -}); - -export default AmountField - -function getTextWidth(text: string = '', font: string): number { - if (typeof document === "undefined") return 0; - - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - if (!context) return 0; - - context.font = font; - return context.measureText(text).width; -} - -function getFontFromElement(el: HTMLElement | null): string { - if (!el) return '28px sans-serif'; - const style = window.getComputedStyle(el); - return `${style.fontSize} ${style.fontFamily}`; -} - -function formatTokenAmount(value: number, precision: number): string { - const fixed = value.toFixed(precision).replace(/\.?0+$/, ''); - const [intPart, decPart] = fixed.split('.'); - const formattedInt = Number(intPart).toLocaleString('en-US'); - return decPart ? `${formattedInt}.${decPart}` : formattedInt; -} diff --git a/apps/app/components/Input/Amount/MinMax.tsx b/apps/app/components/Input/Amount/MinMax.tsx index ae644990..6c4caf45 100644 --- a/apps/app/components/Input/Amount/MinMax.tsx +++ b/apps/app/components/Input/Amount/MinMax.tsx @@ -8,8 +8,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/shadcn/too import { useSelectedAccount } from "@/context/swapAccounts"; import { useBalance } from "@/lib/balances/useBalance"; import { getNativeToken } from "@/Models/Network"; -import { useUsdModeStore } from "@/stores/usdModeStore"; -import { skipNextUsdSync } from "@/hooks/useUsdTokenSync"; type MinMaxProps = { fromCurrency: ExtendedToken, @@ -21,10 +19,8 @@ type MinMaxProps = { const MinMax = (props: MinMaxProps) => { - const { setFieldValue, values } = useFormikContext(); + const { setValues } = useFormikContext(); const { fromCurrency, from, limitsMinAmount, limitsMaxAmount, onActionHover } = props; - const isUsdMode = useUsdModeStore(s => s.isUsdMode); - const setUsdAmount = useUsdModeStore(s => s.setUsdAmount); const selectedSourceAccount = useSelectedAccount("from", from?.caip2Id); const { gasData } = useSWRGas(selectedSourceAccount?.address, from, fromCurrency) @@ -62,28 +58,22 @@ const MinMax = (props: MinMaxProps) => { return (tokenAmount * fromCurrency.priceInUsd).toFixed(2).replace(/\.?0+$/, ''); } - const handleSetValue = (value: string, usdValue?: string) => { + const handleSetValue = (value: string) => { mutateBalances() - if (isUsdMode && usdValue) { - if (values.amount !== value) { - skipNextUsdSync(); - } - setUsdAmount(usdValue); - } - setFieldValue('amount', value, true) + setValues(prev => ({ ...prev, amount: value, receiveAmount: '' }), true) onActionHover(undefined) } const handleSetHalfAmount = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - handleSetValue(halfOfBalance.toString(), computeUsdValue(halfOfBalance)) + handleSetValue(halfOfBalance.toString()) } const handleSetMaxAmount = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - handleSetValue(maxAllowedAmount.toString(), computeUsdValue(maxAllowedAmount)) + handleSetValue(maxAllowedAmount.toString()) } const showMaxTooltip = !!(walletBalance?.amount && shouldPayGasWithTheToken) diff --git a/apps/app/components/Input/Amount/ReceiveAmount.tsx b/apps/app/components/Input/Amount/ReceiveAmount.tsx deleted file mode 100644 index 0130edb8..00000000 --- a/apps/app/components/Input/Amount/ReceiveAmount.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { FC, useMemo } from "react"; -import { ExtendedToken } from "@/Models/Network"; -import type { SwapQuote } from "@train-protocol/react"; -import NumberFlow from "@number-flow/react"; -import clsx from "clsx"; -import formatAmount from "@/lib/formatAmount"; -import { useUsdModeStore } from "@/stores/usdModeStore"; - -type ReceiveAmountProps = { - destination_token: ExtendedToken | undefined; - quote: SwapQuote | undefined; - isQuoteLoading: boolean; -} - -export const ReceiveAmount: FC = ({ destination_token, quote, isQuoteLoading }) => { - const isUsdMode = useUsdModeStore(s => s.isUsdMode); - const receive_amount_in_base_units = quote?.receiveAmount - const receive_amount = destination_token ? formatAmount(BigInt(receive_amount_in_base_units ?? 0), destination_token?.decimals) : null; - const receiveAmountInUsd = useMemo(() => { - if (!receive_amount || !destination_token?.priceInUsd) return undefined; - return (Number(receive_amount) * destination_token.priceInUsd).toFixed(2); - }, [receive_amount, destination_token?.priceInUsd]); - - const primaryEmpty = isUsdMode ? !receiveAmountInUsd : !receive_amount; - - return ( -
-
-
- {isUsdMode ? ( - - ) : ( - - )} -
-
-
- - {isUsdMode ? ( - - ) : ( - - )} - -
-
- ) -} diff --git a/apps/app/components/Input/AmountField.tsx b/apps/app/components/Input/AmountField.tsx new file mode 100644 index 00000000..11dcb5da --- /dev/null +++ b/apps/app/components/Input/AmountField.tsx @@ -0,0 +1,246 @@ +import { useFormikContext } from "formik"; +import { useCallback, useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import NumberFlow from "@number-flow/react"; +import { ArrowUpDown } from "lucide-react"; +import { Input } from "@/components/shadcn/input"; +import { SwapFormValues } from "@/components/DTOs/SwapFormValues"; +import { useUsdTokenSync } from "@/hooks/useUsdTokenSync"; +import { isScientific } from "@/components/utils/RoundDecimals"; +import type { SwapQuote } from "@train-protocol/react"; +import formatAmount from "@/lib/formatAmount"; + +// Caps on significant digits shown in NumberFlow. Above the cap, render `...` to indicate truncation. +const PRIMARY_MAX_SIG_DIGITS = 10; +const SECONDARY_MAX_SIG_DIGITS = 11; + +interface AmountFieldProps { + side: 'source' | 'destination'; + actionValue?: number; + actionValueUsd?: string; + className?: string; + showToggle?: boolean; + isQuoteLoading?: boolean; + quote?: SwapQuote; +} + +const AmountField = ({ side, actionValue, actionValueUsd, className, showToggle, isQuoteLoading, quote }: AmountFieldProps) => { + const { values, setValues } = useFormikContext(); + + const fieldName: 'amount' | 'receiveAmount' = side === 'source' ? 'amount' : 'receiveAmount'; + const oppositeField: 'amount' | 'receiveAmount' = side === 'source' ? 'receiveAmount' : 'amount'; + const quoteDirection: 'source' | 'destination' = values?.receiveAmount ? 'destination' : 'source'; + const token = side === 'source' ? values?.fromCurrency : values?.toCurrency; + const quoteAmount = side === 'source' ? quote?.amount : quote?.receiveAmount; + const quoteDerivedAmount = quoteAmount && token?.decimals != null ? formatAmount(BigInt(quoteAmount), token.decimals) : ''; + + + const stableDerivedAmountRef = useRef(quoteDerivedAmount); + if (!isQuoteLoading) stableDerivedAmountRef.current = quoteDerivedAmount; + + const currentAmount = quoteDirection === side + ? (values?.[fieldName] ?? '') + : (isQuoteLoading ? stableDerivedAmountRef.current : quoteDerivedAmount); + + const amountRef = useRef(null); + const suffixRef = useRef(null); + + const { tokenPriceInUsd, isUsdMode, usdAmount, toggleMode, handleUsdInputChange, } = useUsdTokenSync({ side, token }); + + const [inputFocused, setInputFocused] = useState(false); + const handleTokenChange = useCallback((e: React.ChangeEvent) => { + const v = sanitizeDecimalInput(e.target.value, token?.decimals); + if (v === null) return; + setValues(prev => ({ ...prev, [fieldName]: v, [oppositeField]: '' }), true); + }, [setValues, token?.decimals, fieldName, oppositeField]); + + const handleFocus = useCallback(() => { + setInputFocused(true); + }, []); + + + const tokenNum = (() => { const n = Number(currentAmount); return isNaN(n) ? 0 : n; })(); + const usdValue = tokenPriceInUsd && tokenNum > 0 ? tokenNum * tokenPriceInUsd : 0; + const precision = token?.decimals || 6; + const actionValueAsUsd = actionValue !== undefined && actionValue > 0 + ? (actionValueUsd ?? (tokenPriceInUsd ? (actionValue * tokenPriceInUsd).toFixed(2).replace(/\.?0+$/, '') : undefined)) + : undefined; + + const actionValueAsToken = actionValue !== undefined && actionValue > 0 ? formatTokenAmount(actionValue, precision) : undefined; + + const formattedActionTokenValue = actionValue === undefined || actionValue < 0 ? '' + : isScientific(actionValue) ? actionValue.toFixed(token?.decimals ?? 0).replace(/\.?0+$/, '') + : actionValue.toString(); + + const showActionPreview = actionValue !== undefined && !isNaN(Number(actionValue)); + + useEffect(() => { + if (isUsdMode) return; + const input = amountRef.current; + const suffix = suffixRef.current; + if (!input || !suffix) return; + const font = getFontFromElement(input); + const width = getTextWidth(actionValue?.toString() || currentAmount || '0', font); + suffix.style.left = `${width + 16}px`; + }, [currentAmount, actionValue, isUsdMode, amountRef]); + + const step = 1 / Math.pow(10, token?.decimals || 1); + + const onTogglePress = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + amountRef.current?.blur(); + toggleMode(); + }, [toggleMode]); + + const toggleButton = side === 'source' && tokenPriceInUsd ? ( + + ) : null; + + const handleWrapperClick = useCallback(() => { + amountRef.current?.focus(); + }, []); + + + const localUsdString = tokenPriceInUsd && tokenNum > 0 ? (tokenNum * tokenPriceInUsd).toFixed(2).replace(/\.?0+$/, '') : ''; + + const inputValue = isUsdMode + ? (actionValueAsUsd ?? (quoteDirection === side && usdAmount ? usdAmount : localUsdString)) + : (currentAmount ?? ''); + const inputOnChange = isUsdMode ? handleUsdInputChange : handleTokenChange; + + const isPrimaryTruncated = tokenNum > 0 && isFinite(tokenNum) && Number(tokenNum.toPrecision(PRIMARY_MAX_SIG_DIGITS)) !== tokenNum; + const isSecondaryTruncated = tokenNum > 0 && isFinite(tokenNum) && Number(tokenNum.toPrecision(SECONDARY_MAX_SIG_DIGITS)) !== tokenNum; + + const showOverlay = isUsdMode ? !inputFocused && !actionValueAsUsd : !inputFocused && !showActionPreview; + const hideInput = showOverlay || (showActionPreview && !isUsdMode); + const overlayValue = isUsdMode ? usdValue : tokenNum; + const overlayFormat = isUsdMode ? { minimumFractionDigits: 0, maximumFractionDigits: 2 } : { maximumSignificantDigits: PRIMARY_MAX_SIG_DIGITS }; + const hasValue = hideInput ? overlayValue > 0 : !!inputValue; + const textColor = hasValue ? "text-primary-text" : "text-secondary-text"; + + return ( +
+
+ {isUsdMode && ( + $ + )} +
+ setInputFocused(false)} + className={clsx( + "text-[28px] leading-[34px] focus-visible:ring-0 focus-visible:border-transparent font-normal px-0 truncate bg-secondary-500 border-0 placeholder:text-secondary-text text-primary-text transition-none [font-kerning:none] [font-variant-ligatures:none]", + hideInput && "text-transparent placeholder:text-transparent", + !inputFocused && isQuoteLoading && "animate-pulse-stronger", + )} + /> + {!isUsdMode && showActionPreview && ( + + {formattedActionTokenValue} + + )} + + + {!isUsdMode && isPrimaryTruncated && ...} + +
+
+ +
+ {toggleButton} + {isUsdMode ? ( + + {actionValueAsToken ? ( + {actionValueAsToken} + ) : ( + + + {isSecondaryTruncated && ...} + + )} + {` ${token?.symbol || ''}`} + + ) : ( + actionValueAsUsd ? ( + {`$${actionValueAsUsd}`} + ) : ( + + ) + )} +
+
+ ); +}; + +export default AmountField; + +function getTextWidth(text: string = '', font: string): number { + if (typeof document === "undefined") return 0; + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) return 0; + context.font = font; + return context.measureText(text).width; +} + +function getFontFromElement(el: HTMLElement | null): string { + if (!el) return '28px sans-serif'; + const style = window.getComputedStyle(el); + return `${style.fontSize} ${style.fontFamily}`; +} + +function formatTokenAmount(value: number, precision: number): string { + const fixed = value.toFixed(precision).replace(/\.?0+$/, ''); + const [intPart, decPart] = fixed.split('.'); + const formattedInt = Number(intPart).toLocaleString('en-US'); + return decPart ? `${formattedInt}.${decPart}` : formattedInt; +} + +function sanitizeDecimalInput(raw: string, maxDecimals?: number): string | null { + const v = raw.replace(',', '.'); + if (v !== '' && !/^[0-9]*[.,]?[0-9]*$/.test(v)) return null; + if (maxDecimals != null && v.includes('.')) { + const [whole, decs = ''] = v.split('.'); + if (decs.length > maxDecimals) { + return maxDecimals === 0 ? whole : `${whole}.${decs.slice(0, maxDecimals)}`; + } + } + return v; +} diff --git a/apps/app/components/Input/DestinationPicker.tsx b/apps/app/components/Input/DestinationPicker.tsx index 08e5fb9c..4a63db61 100644 --- a/apps/app/components/Input/DestinationPicker.tsx +++ b/apps/app/components/Input/DestinationPicker.tsx @@ -1,20 +1,15 @@ import RoutePicker from "./RoutePicker"; import Address from "./Address"; import DestinationWalletPicker from "./DestinationWalletPicker"; -import { useFormikContext } from "formik"; -import { SwapFormValues } from "../DTOs/SwapFormValues"; -import { ReceiveAmount } from "./Amount/ReceiveAmount"; +import AmountField from "./AmountField"; import type { SwapQuote } from "@train-protocol/react"; type Props = { - quote?: SwapQuote; isQuoteLoading?: boolean; + quote?: SwapQuote; } -const DestinationPicker = ({ quote, isQuoteLoading }: Props) => { - const { values } = useFormikContext() - const { toCurrency } = values - +const DestinationPicker = ({ isQuoteLoading, quote }: Props) => { return (
@@ -31,11 +26,7 @@ const DestinationPicker = ({ quote, isQuoteLoading }: Props) => {
- +
diff --git a/apps/app/components/Input/NumericInput.tsx b/apps/app/components/Input/NumericInput.tsx deleted file mode 100644 index f7037833..00000000 --- a/apps/app/components/Input/NumericInput.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useField, useFormikContext } from "formik"; -import { Input } from "@/components/shadcn/input"; -import { ChangeEvent, FC, forwardRef } from "react"; -import { SwapFormValues } from "@/components/DTOs/SwapFormValues"; -import { classNames } from '@/components/utils/classNames' -import { isScientific } from "@/components/utils/RoundDecimals"; - -type Input = { - tempValue?: number; - label?: JSX.Element | JSX.Element[] - disabled?: boolean; - placeholder: string; - minLength?: number; - maxLength?: number; - precision?: number; - step?: number; - name: string; - className?: string; - children?: JSX.Element | JSX.Element[] | null; - ref?: any; - onChange?: (e: ChangeEvent) => void; - onFocus?: () => void; - onBlur?: () => void; -} - -// Use with Formik -const NumericInput: FC = forwardRef( - function NumericInput({ label, disabled, tempValue, placeholder, minLength, maxLength, precision, step, name, className, children, onChange, onFocus, onBlur }, ref) { - const { handleChange } = useFormikContext(); - const [field] = useField(name) - - const formattedTempValue = Number(tempValue) >= 0 ? isScientific(tempValue) - ? (!isNaN(Number(tempValue)) - ? Number(tempValue).toFixed(precision ?? 0).replace(/\.?0+$/, '') - : '') - : tempValue?.toString() - : ''; - - return
- {label && - - } -
- { - !isNaN(Number(tempValue)) && - - {formattedTempValue} - - } - { - isNaN(Number(tempValue)) && - ) => { replaceComma(event); limitDecimalPlaces(event, precision) }} - onFocus={onFocus} - onBlur={onBlur} - type="text" - step={step} - name={name} - id={name} - ref={ref} - className={classNames( - 'h-12 leading-4 placeholder:text-secondary-text bg-secondary-500 focus-visible:ring-0 focus-visible:border-transparent blockfont-semibold border-0 px-0', - className - )} - onChange={onChange ? onChange : e => { - /^[0-9]*[.,]?[0-9]*$/.test(e.target.value) && handleChange(e); - }} - />} - {<>{children}} -
-
; - }); - -function limitDecimalPlaces(e, count) { - if (e.target.value.indexOf('.') == -1) { return; } - if ((e.target.value.length - e.target.value.indexOf('.')) > count) { - e.target.value = ParseFloat(e.target.value, count); - } -} - -function ParseFloat(str, val) { - str = str.toString(); - str = str.slice(0, (str.indexOf(".")) + val + 1); - return Number(str); -} - -function replaceComma(e) { - var val = e.target.value; - if (val.match(/\,/)) { - val = val.replace(/\,/g, '.'); - e.target.value = val; - } -} - -export default NumericInput \ No newline at end of file diff --git a/apps/app/components/Input/SourcePicker.tsx b/apps/app/components/Input/SourcePicker.tsx index 64c89cee..dd0eade4 100644 --- a/apps/app/components/Input/SourcePicker.tsx +++ b/apps/app/components/Input/SourcePicker.tsx @@ -1,20 +1,20 @@ import SourceWalletPicker from "./SourceWalletPicker"; import RoutePicker from "./RoutePicker"; -import AmountField from "./Amount" +import AmountField from "./AmountField" import { useFormikContext } from "formik"; import { SwapFormValues } from "../DTOs/SwapFormValues"; import MinMax from "./Amount/MinMax"; -import type { SwapQuote } from "@train-protocol/react"; import clsx from "clsx"; import { useClickOutside } from "@/hooks/useClickOutside"; import { useState } from "react"; +import type { SwapQuote } from "@train-protocol/react"; type Props = { - quote?: SwapQuote; isQuoteLoading?: boolean; + quote?: SwapQuote; } -const SourcePicker = ({ quote }: Props) => { +const SourcePicker = ({ isQuoteLoading, quote }: Props) => { const { values } = useFormikContext() const { fromCurrency, from } = values || {} @@ -58,7 +58,13 @@ const SourcePicker = ({ quote }: Props) => { }
- +
diff --git a/apps/app/components/Sceletons.tsx b/apps/app/components/Sceletons.tsx deleted file mode 100644 index db5f82b6..00000000 --- a/apps/app/components/Sceletons.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { ChevronRight, Clock } from "lucide-react" -import BackgroundField from "./backgroundField" -import { classNames } from "./utils/classNames" - -export const SwapHistoryComponentSceleton = () => { - - return
-
-
- - - - - - - - - - - - - - - - {[...Array(5)]?.map((item, index) => ( - - - - - - - - - - - - ))} - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
- {index !== 0 ?
: null} -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -} - -export const SwapDetailsComponentSceleton = () => { - return
-
-
-
-
-
-
- {[...Array(8)]?.map((item, index) => ( -
-
-
-
-
-
-
- ))} -
-
-
-
-} - -export const DocInFrameSceleton = () => { - return
-
-
-
-
- {[...Array(8)]?.map((item, index) => -
-
-
-
-
-
-
- )} -
-
-
-
-} - -export const RewardsComponentSceleton = () => { - return ( -
-
-
-
-
-
- Pending EarningsNext Airdrop} withoutBorder> -
-
-
-
-
- -
-
-
- - Total EarningsCurrent Value} withoutBorder> -
-
-
-
-
-
- -
-
- -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
- {[...Array(4)]?.map((user, index) => ( -
-
-
- )) - } -
-
-
-
-
- ) -} - -export const RewardsComponentLeaderboardSceleton = () => { - return ( -
-
-
-
-
-
-
- {[...Array(4)]?.map((user, index) => ( -
-
-
- )) - } -
-
-
-
- ) -} \ No newline at end of file diff --git a/apps/app/components/Swap/Atomic/Form.tsx b/apps/app/components/Swap/Atomic/Form.tsx index b39aa860..9b290807 100644 --- a/apps/app/components/Swap/Atomic/Form.tsx +++ b/apps/app/components/Swap/Atomic/Form.tsx @@ -22,7 +22,7 @@ type SwapFormProps = { const SwapForm: FC = ({ polling = true, onQuoteChange }) => { const { values, - errors, isValid, isSubmitting + errors, isValid, isSubmitting, } = useFormikContext(); const { to: destination, @@ -41,35 +41,33 @@ const SwapForm: FC = ({ polling = true, onQuoteChange }) => { const shouldConnectWallet = values.from && !wallets.length; const shouldConnectDestinationWallet = values.to && !hasRequiredDestinationWallet(destination, providers); - return <> -
- -
- {!(query?.hideFrom && values?.from) &&
- -
} - {!(query?.hideFrom && values?.from) && !(query?.hideTo && values?.to) && } - {!(query?.hideTo && values?.to) &&
- -
} -
- -
- - - -
- + return
+ +
+ {!(query?.hideFrom && values?.from) &&
+ +
} + {!(query?.hideFrom && values?.from) && !(query?.hideTo && values?.to) && } + {!(query?.hideTo && values?.to) &&
+ +
} +
+ +
+ + + +
} export default SwapForm \ No newline at end of file diff --git a/apps/app/components/Swap/Atomic/index.tsx b/apps/app/components/Swap/Atomic/index.tsx index d6a05ce1..2d099da8 100644 --- a/apps/app/components/Swap/Atomic/index.tsx +++ b/apps/app/components/Swap/Atomic/index.tsx @@ -16,7 +16,6 @@ import { resolvePersistantQueryParams } from "@/helpers/querryHelper"; import { buildSwapQuery } from "@/helpers/swapUrl"; import { useSwapStore } from "@/stores/swapStore"; import { useActiveSwap } from "@/hooks/useActiveSwap"; - import AtomicPage from "../AtomicChat"; import { useRecentNetworksStore } from "@/stores/recentRoutesStore"; @@ -71,7 +70,7 @@ export default function Form() { throw new Error("Please login first") } - if (!values.amount) throw new Error("No amount specified") + if (!values.amount && !values.receiveAmount) throw new Error("No amount specified") if (!values.destination_address) throw new Error("Please enter a valid address") if (!values.fromCurrency) throw new Error("No source asset") if (!values.toCurrency) throw new Error("No destination asset") @@ -107,7 +106,7 @@ export default function Form() { innerRef={formikRef} initialValues={initialValues} validateOnMount={true} - validate={MainStepValidation()} + validate={MainStepValidation} onSubmit={handleSubmit} > <> diff --git a/apps/app/components/Swap/AtomicChat/Actions/UserActions.tsx b/apps/app/components/Swap/AtomicChat/Actions/UserActions.tsx index e2e45276..90937251 100644 --- a/apps/app/components/Swap/AtomicChat/Actions/UserActions.tsx +++ b/apps/app/components/Swap/AtomicChat/Actions/UserActions.tsx @@ -10,6 +10,7 @@ import { Address } from "@/lib/address"; import { useSwapStore } from "@/stores/swapStore"; import { useFormikContext } from "formik"; import type { SwapFormValues } from "@/components/DTOs/SwapFormValues"; +import formatAmount from "@/lib/formatAmount"; type UserCommitActionProps = { quote?: SwapQuote @@ -27,7 +28,9 @@ export const UserLockAction: FC = ({ quote, solverId, typ const destination_network = values.to const source_asset = values.fromCurrency const destination_asset = values.toCurrency - const amount = values.amount ? Number(values.amount) : undefined + const amount = (quote?.amount && source_asset?.decimals != null) + ? Number(formatAmount(BigInt(quote.amount), source_asset.decimals)) + : (values.amount ? Number(values.amount) : undefined) const address = values.destination_address const { provider } = useWallet(source_network, 'withdrawal') diff --git a/apps/app/components/Swap/AtomicChat/AtomicContent/index.tsx b/apps/app/components/Swap/AtomicChat/AtomicContent/index.tsx index 172b5424..eecf25b2 100644 --- a/apps/app/components/Swap/AtomicChat/AtomicContent/index.tsx +++ b/apps/app/components/Swap/AtomicChat/AtomicContent/index.tsx @@ -12,6 +12,7 @@ import { HTLCStatus } from "@train-protocol/react"; import { Loader2 } from "lucide-react"; import { useFormikContext } from "formik"; import { useSettingsState } from "@/context/settings"; +import formatAmount from "@/lib/formatAmount"; type AtomicContentProps = { quote?: SwapQuote @@ -28,7 +29,14 @@ const AtomicContent: FC = ({ quote, isQuoteLoading = false } const destination_network = swap.destinationNetwork ? networks.find(n => n.caip2Id == swap.destinationNetwork?.caip2Id) : values?.to const source_asset = swap.sourceToken ? source_network?.tokens.find(t => t.contract == swap.sourceToken?.contract) : values?.fromCurrency const destination_asset = swap.destinationToken ? destination_network?.tokens.find(t => t.contract == swap.destinationToken?.contract) : values?.toCurrency - const amount = swap.requestedAmount ? Number(swap.requestedAmount) : (values?.amount ? Number(values.amount) : undefined) + let amount: number | undefined + if (swap.requestedAmount != null) { + amount = Number(swap.requestedAmount) + } else if (quote?.amount && source_asset?.decimals != null) { + amount = Number(formatAmount(BigInt(quote.amount), source_asset.decimals)) + } else if (values?.amount != null) { + amount = Number(values.amount) + } const hashlock = swap.hashlock const { status: commitStatus } = swap diff --git a/apps/app/components/Swap/AtomicChat/index.tsx b/apps/app/components/Swap/AtomicChat/index.tsx index 249c5114..6d4548f7 100644 --- a/apps/app/components/Swap/AtomicChat/index.tsx +++ b/apps/app/components/Swap/AtomicChat/index.tsx @@ -20,7 +20,6 @@ const Swap: FC = ({ type }) => { const destinationNetwork = swap.destinationNetwork ?? values?.to const sourceAsset = swap.sourceToken ?? values?.fromCurrency const destinationAsset = swap.destinationToken ?? values?.toCurrency - const amount = swap.requestedAmount ?? values?.amount const hashlock = swap.hashlock const quoteParams = useMemo(() => { @@ -30,9 +29,10 @@ const Swap: FC = ({ type }) => { to: destinationNetwork?.caip2Id, fromCurrency: sourceAsset, toCurrency: destinationAsset, - amount: amount != null ? String(amount) : undefined, + amount: values?.amount, + receiveAmount: values?.receiveAmount, }); - }, [hashlock, sourceNetwork?.caip2Id, destinationNetwork?.caip2Id, sourceAsset, destinationAsset, amount]); + }, [hashlock, sourceNetwork?.caip2Id, destinationNetwork?.caip2Id, sourceAsset, destinationAsset, values?.amount, values?.receiveAmount]); const { quote, solverId, isQuoteLoading } = useQuoteData(quoteParams, 42000); diff --git a/apps/app/components/Swap/FormButton.tsx b/apps/app/components/Swap/FormButton.tsx index 0c51899d..5f5b1955 100644 --- a/apps/app/components/Swap/FormButton.tsx +++ b/apps/app/components/Swap/FormButton.tsx @@ -32,7 +32,8 @@ const FormButton = ({ const { isLoggedIn } = useSharedSecretDerivation(); const { open: openLogin } = useLoginModalStore(); - if (values.from && values.to && values.fromCurrency && values.toCurrency && values.amount && !quote && !isQuoteLoading) { + const hasUserAmount = values.amount || values.receiveAmount; + if (values.from && values.to && values.fromCurrency && values.toCurrency && hasUserAmount && !quote && !isQuoteLoading) { return ; } - const isAztecDestination = values?.to?.caip2Id === KnownInternalNames.Networks.AztecDevnet; - - if (values?.to && !values?.destination_address && !isAztecDestination) { + if (values?.to && !values?.destination_address) { return (
{() => ( @@ -96,6 +95,7 @@ function ActionText(errors: FormikErrors, actionDisplayName: str || errors.fromCurrency as string || errors.toCurrency as string || errors.amount as string + || errors.receiveAmount as string || (actionDisplayName) } diff --git a/apps/app/components/Swap/messages/Message.tsx b/apps/app/components/Swap/messages/Message.tsx index 45d6328e..4a53994b 100644 --- a/apps/app/components/Swap/messages/Message.tsx +++ b/apps/app/components/Swap/messages/Message.tsx @@ -32,7 +32,7 @@ const WalletMessage: FC = ({ header, details, status }) => {
-

{header}

+

{header}

{details ?

{details}

: null}
@@ -49,7 +49,7 @@ export const WalletUnknownError: FC = () => {
-

Wallet error

+

Wallet error

An error occurred, the swap wasn't initiated your assets were not moved.

diff --git a/apps/app/components/Tooltips/ClickTooltip.tsx b/apps/app/components/Tooltips/ClickTooltip.tsx deleted file mode 100644 index 0d5b892a..00000000 --- a/apps/app/components/Tooltips/ClickTooltip.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Info } from 'lucide-react'; -import { FC } from "react"; -import { Popover, PopoverContent, PopoverTrigger } from '../shadcn/popover'; -import { classNames } from '../utils/classNames'; - -type Props = { - text: string | JSX.Element | JSX.Element[]; - moreClassNames?: string, - side?: 'left' | 'right' | 'top' | 'bottom' -} - -const ClickTooltip: FC = (({ text, moreClassNames, side }) => { - return ( - - - - {text} - - ) -}) - -export default ClickTooltip \ No newline at end of file diff --git a/apps/app/components/WarningMessage.tsx b/apps/app/components/WarningMessage.tsx deleted file mode 100644 index 8a361710..00000000 --- a/apps/app/components/WarningMessage.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { AlertOctagon, Scroll } from "lucide-react"; -import { FC } from "react"; - -type messageType = 'warning' | 'informing' - -type Props = { - children: JSX.Element | JSX.Element[] | string; - messageType?: messageType; - className?: string -} - -function constructIcons(messageType: messageType) { - - let iconStyle: JSX.Element - - switch (messageType) { - case 'warning': - iconStyle = ; - break; - case 'informing': - iconStyle = ; - break; - } - return iconStyle -} - -const WarningMessage: FC = (({ children, className, messageType = 'warning' }) => { - return ( -
-
- -
- - {constructIcons(messageType)} - - {children} -
-
-
- ) -}) - -export default WarningMessage; \ No newline at end of file diff --git a/apps/app/hooks/useFee.ts b/apps/app/hooks/useFee.ts index e4608e6c..63cba5c8 100644 --- a/apps/app/hooks/useFee.ts +++ b/apps/app/hooks/useFee.ts @@ -39,26 +39,33 @@ type Props = { to: string | undefined fromCurrency: Token | undefined toCurrency: Token | undefined - amount: string | number | undefined + amount?: string | number + receiveAmount?: string | number } export function useQuoteData(formValues: Props | undefined, refreshInterval?: number): UseQuoteData { - const { fromCurrency, toCurrency, from, to, amount } = formValues || {} + const { fromCurrency, toCurrency, from, to, amount, receiveAmount } = formValues || {} + const isReverse = receiveAmount != null && receiveAmount !== '' + const decimals = isReverse ? toCurrency?.decimals : fromCurrency?.decimals + const rawAmount = isReverse ? receiveAmount : amount const convertedAmount = useMemo(() => { - if (amount == null || amount === '' || !fromCurrency?.decimals) return undefined + if (rawAmount == null || rawAmount === '' || !decimals) return undefined try { - return parseUnits(String(amount), fromCurrency.decimals).toString() + return parseUnits(String(rawAmount), decimals).toString() } catch { return undefined } - }, [amount, fromCurrency?.decimals]) + }, [rawAmount, decimals]) const [debouncedAmount, setDebouncedAmount] = useState(convertedAmount) const [isDebouncing, setIsDebouncing] = useState(false) useEffect(() => { - if (convertedAmount === debouncedAmount) return + if (convertedAmount === debouncedAmount) { + setIsDebouncing(false) + return + } setIsDebouncing(true) const handler = setTimeout(() => { @@ -71,11 +78,13 @@ export function useQuoteData(formValues: Props | undefined, refreshInterval?: nu } }, [convertedAmount, debouncedAmount]) - const canGetQuote = !!(from && to && fromCurrency && toCurrency && debouncedAmount && !isDebouncing) + const hasQuoteParams = !!(from && to && fromCurrency && toCurrency) + const hasValidAmount = !!debouncedAmount && Number(debouncedAmount) > 0 + const canGetQuote = !!(hasQuoteParams && hasValidAmount && !isDebouncing) - // Use React package's useQuote hook const { bestQuote, bestSolver, isLoading, error, refetch } = useQuote({ - amount: debouncedAmount ?? '', + amount: !isReverse ? (debouncedAmount ?? '') : undefined, + receiveAmount: isReverse ? (debouncedAmount ?? '') : undefined, sourceNetwork: from ?? '', destinationNetwork: to ?? '', sourceTokenContract: fromCurrency?.contract || undefined, @@ -86,9 +95,9 @@ export function useQuoteData(formValues: Props | undefined, refreshInterval?: nu }) return { - quote: (error || !canGetQuote) ? undefined : bestQuote as SwapQuote | undefined, - solverId: (error || !canGetQuote) ? undefined : bestSolver?.solver?.id, - isQuoteLoading: isLoading, + quote: (error || !hasQuoteParams || !hasValidAmount) ? undefined : bestQuote as SwapQuote | undefined, + solverId: (error || !hasQuoteParams || !hasValidAmount) ? undefined : bestSolver?.solver?.id, + isQuoteLoading: isLoading || isDebouncing, isDebouncing, quoteError: error as unknown as QuoteError | undefined, mutateFee: refetch, @@ -98,6 +107,7 @@ export function useQuoteData(formValues: Props | undefined, refreshInterval?: nu export function transformFormValuesToQuoteArgs(values: SwapFormValues): Props | undefined { return { amount: values.amount, + receiveAmount: values.receiveAmount, from: values.from?.caip2Id, to: values.to?.caip2Id, fromCurrency: values.fromCurrency, @@ -112,13 +122,15 @@ export function buildQuoteParamsFromAtomic(params: { fromCurrency?: Token toCurrency?: Token amount?: string | number + receiveAmount?: string | number }): Props | undefined { - if (!params.from || !params.to || !params.fromCurrency || !params.toCurrency || params.amount == null || params.amount === '') return undefined + if (!params.from || !params.to || !params.fromCurrency || !params.toCurrency || (!params.amount && !params.receiveAmount)) return undefined return { from: params.from, to: params.to, fromCurrency: params.fromCurrency, toCurrency: params.toCurrency, - amount: String(params.amount), + amount: params.amount ? String(params.amount) : undefined, + receiveAmount: params.receiveAmount ? String(params.receiveAmount) : undefined, } } diff --git a/apps/app/hooks/useUsdTokenSync.ts b/apps/app/hooks/useUsdTokenSync.ts index ef3a4dc6..66111005 100644 --- a/apps/app/hooks/useUsdTokenSync.ts +++ b/apps/app/hooks/useUsdTokenSync.ts @@ -1,125 +1,81 @@ import { useCallback, useEffect, useRef } from "react"; +import { useFormikContext } from "formik"; import { useUsdModeStore } from "@/stores/usdModeStore"; import { resolveTokenUsdPrice } from "@/helpers/tokenHelper"; import { Token } from "@/Models/Network"; - -let _skipNextSync = false; - -export function skipNextUsdSync() { - _skipNextSync = true; -} +import { SwapFormValues } from "@/components/DTOs/SwapFormValues"; interface UseUsdTokenSyncArgs { - fromCurrency: Token | undefined; - amount: string | undefined; - setFieldValue: (field: string, value: any, shouldValidate?: boolean) => void; + side: 'source' | 'destination'; + token: Token | undefined; } interface UseUsdTokenSyncReturn { - sourceCurrencyPriceInUsd: number | undefined; + tokenPriceInUsd: number | undefined; isUsdMode: boolean; usdAmount: string; - handleToggle: () => void; + toggleMode: () => void; handleUsdInputChange: (e: React.ChangeEvent) => void; } export function useUsdTokenSync({ - fromCurrency, - amount, - setFieldValue, + side, + token, }: UseUsdTokenSyncArgs): UseUsdTokenSyncReturn { const isUsdMode = useUsdModeStore(s => s.isUsdMode); const usdAmount = useUsdModeStore(s => s.usdAmount); const setUsdAmount = useUsdModeStore(s => s.setUsdAmount); const toggleMode = useUsdModeStore(s => s.toggleMode); + const { values, setValues } = useFormikContext(); - const sourceCurrencyPriceInUsd = resolveTokenUsdPrice(fromCurrency); + const tokenPriceInUsd = resolveTokenUsdPrice(token); - const prevPriceRef = useRef(sourceCurrencyPriceInUsd); - const prevTokenSymbolRef = useRef(fromCurrency?.symbol); - const internalAmountChangeRef = useRef(false); - const currentAmountRef = useRef(amount); - currentAmountRef.current = amount; - const prevAmountRef = useRef(amount); + const fieldName = side === 'source' ? 'amount' : 'receiveAmount'; + const oppositeField = side === 'source' ? 'receiveAmount' : 'amount'; + const isActiveSide = side === 'destination' ? !!values?.receiveAmount : !values?.receiveAmount; + + const prevPriceRef = useRef(tokenPriceInUsd); + const prevTokenSymbolRef = useRef(token?.symbol); const computeAndSetTokenAmount = useCallback((usdValue: string) => { let newAmount: string; - if (!sourceCurrencyPriceInUsd || sourceCurrencyPriceInUsd === 0 || !usdValue) { + if (!usdValue) { newAmount = ''; + } else if (!tokenPriceInUsd || tokenPriceInUsd === 0) { + newAmount = '0'; } else { const usdNum = Number(usdValue); if (isNaN(usdNum) || usdNum <= 0) { - newAmount = ''; + newAmount = '0'; } else { - const precision = fromCurrency?.decimals || 6; - const tokenAmount = usdNum / sourceCurrencyPriceInUsd; + const precision = token?.decimals || 6; + const tokenAmount = usdNum / tokenPriceInUsd; const truncated = Math.trunc(tokenAmount * Math.pow(10, precision)) / Math.pow(10, precision); newAmount = truncated.toString(); } } - if (newAmount !== (currentAmountRef.current || '')) { - internalAmountChangeRef.current = true; - } - setFieldValue('amount', newAmount, true); - }, [sourceCurrencyPriceInUsd, fromCurrency?.decimals, setFieldValue]); + setValues(prev => ({ ...prev, [fieldName]: newAmount, [oppositeField]: '' }), true); + }, [tokenPriceInUsd, token?.decimals, setValues, fieldName, oppositeField]); - // Recompute token amount when price changes in USD mode + // Recompute token amount when price changes in USD mode (active side only) useEffect(() => { - if (!isUsdMode || !sourceCurrencyPriceInUsd || !usdAmount) { - prevPriceRef.current = sourceCurrencyPriceInUsd; + if (!isActiveSide || !isUsdMode || !tokenPriceInUsd || !usdAmount) { + prevPriceRef.current = tokenPriceInUsd; return; } - if (prevPriceRef.current === sourceCurrencyPriceInUsd) return; - prevPriceRef.current = sourceCurrencyPriceInUsd; + if (prevPriceRef.current === tokenPriceInUsd) return; + prevPriceRef.current = tokenPriceInUsd; computeAndSetTokenAmount(usdAmount); - }, [sourceCurrencyPriceInUsd, isUsdMode, usdAmount, computeAndSetTokenAmount]); + }, [tokenPriceInUsd, isUsdMode, usdAmount, computeAndSetTokenAmount, isActiveSide]); - // Recompute token amount when source token changes in USD mode + // Recompute token amount when token changes in USD mode (active side only) useEffect(() => { - if (!isUsdMode || !sourceCurrencyPriceInUsd || !usdAmount) return; - if (prevTokenSymbolRef.current === fromCurrency?.symbol) return; - prevTokenSymbolRef.current = fromCurrency?.symbol; - prevPriceRef.current = sourceCurrencyPriceInUsd; + if (!isActiveSide || !isUsdMode || !tokenPriceInUsd || !usdAmount) return; + if (prevTokenSymbolRef.current === token?.symbol) return; + prevTokenSymbolRef.current = token?.symbol; + prevPriceRef.current = tokenPriceInUsd; computeAndSetTokenAmount(usdAmount); - }, [fromCurrency?.symbol, isUsdMode, sourceCurrencyPriceInUsd, usdAmount, computeAndSetTokenAmount]); - - // Sync usdAmount when formik amount changes externally (e.g. quick action buttons) - useEffect(() => { - const amountChanged = prevAmountRef.current !== amount; - prevAmountRef.current = amount; - - const skipSync = _skipNextSync; - if (skipSync) _skipNextSync = false; - - if (internalAmountChangeRef.current) { - if (amountChanged) { - internalAmountChangeRef.current = false; - } - return; - } - if (!amountChanged) return; - if (skipSync) return; - if (!isUsdMode || !sourceCurrencyPriceInUsd) return; - - const amountNum = Number(amount); - if (isNaN(amountNum) || amountNum <= 0) { - setUsdAmount(''); - return; - } - setUsdAmount((amountNum * sourceCurrencyPriceInUsd).toFixed(2).replace(/\.?0+$/, '')); - }, [amount, isUsdMode, sourceCurrencyPriceInUsd, setUsdAmount]); - - const handleToggle = useCallback(() => { - if (!isUsdMode && sourceCurrencyPriceInUsd) { - const amountNum = Number(amount); - if (!isNaN(amountNum) && amountNum > 0) { - setUsdAmount((amountNum * sourceCurrencyPriceInUsd).toFixed(2).replace(/\.?0+$/, '')); - } else { - setUsdAmount(''); - } - } - toggleMode(); - }, [isUsdMode, amount, sourceCurrencyPriceInUsd, setUsdAmount, toggleMode]); + }, [token?.symbol, isUsdMode, tokenPriceInUsd, usdAmount, computeAndSetTokenAmount, isActiveSide]); const handleUsdInputChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value.replace(',', '.'); @@ -129,10 +85,10 @@ export function useUsdTokenSync({ }, [setUsdAmount, computeAndSetTokenAmount]); return { - sourceCurrencyPriceInUsd, + tokenPriceInUsd, isUsdMode, usdAmount, - handleToggle, + toggleMode, handleUsdInputChange, }; } diff --git a/apps/app/lib/generateSwapInitialValues.ts b/apps/app/lib/generateSwapInitialValues.ts index 902a8c15..aa5a4e47 100644 --- a/apps/app/lib/generateSwapInitialValues.ts +++ b/apps/app/lib/generateSwapInitialValues.ts @@ -4,7 +4,7 @@ import { Address } from "./address"; import { TrainAppSettings } from "../Models/TrainAppSettings"; export function generateSwapInitialValues(settings: TrainAppSettings, queryParams: QueryParams): SwapFormValues { - const { destAddress, transferAmount, fromAsset, toAsset, from, to } = queryParams + const { destAddress, transferAmount, receiveAmount, fromAsset, toAsset, from, to } = queryParams const { networks } = settings || {} // Find networks by slug (case-insensitive) @@ -34,11 +34,13 @@ export function generateSwapInitialValues(settings: TrainAppSettings, queryParam } let initialAmount = transferAmount || '' + let initialReceiveAmount = initialAmount ? '' : (receiveAmount || '') const result: SwapFormValues = { from: initialSource, to: initialDestination, amount: initialAmount, + receiveAmount: initialReceiveAmount, fromCurrency: initialSourceCurrency, toCurrency: initialDestinationCurrency, destination_address: initialAddress, diff --git a/apps/app/lib/mainStepValidator.ts b/apps/app/lib/mainStepValidator.ts index ea2e76a2..0cacbbf3 100644 --- a/apps/app/lib/mainStepValidator.ts +++ b/apps/app/lib/mainStepValidator.ts @@ -2,43 +2,45 @@ import { FormikErrors } from "formik"; import { SwapFormValues } from "../components/DTOs/SwapFormValues"; import { Address } from "./address"; -export default function MainStepValidation(): ((values: SwapFormValues) => FormikErrors) { - return (values: SwapFormValues) => { - let errors: FormikErrors = {}; - let amount = values.amount ? Number(values.amount) : undefined; +export default function MainStepValidation(values: SwapFormValues): FormikErrors { + let errors: FormikErrors = {}; + const isReverse = !!values.receiveAmount; + const amountField: keyof SwapFormValues = isReverse ? 'receiveAmount' : 'amount'; + const activeAmount = isReverse + ? (values.receiveAmount ? Number(values.receiveAmount) : undefined) + : (values.amount ? Number(values.amount) : undefined); - if (!values.fromCurrency) { - errors.fromCurrency = 'Select source asset'; - } - if (!values.toCurrency) { - errors.toCurrency = 'Select destination asset'; - } - if (!values.from) { - errors.from = 'Select source'; - } - if (!values.to) { - errors.to = 'Select destination'; - } - if (!amount) { - errors.amount = 'Enter an amount'; - } - if (amount && !/^[0-9]*[.,]?[0-9]*$/i.test(amount.toString())) { - errors.amount = 'Invalid amount'; - } - if (amount && amount < 0) { - errors.amount = "Can't be negative"; - } - // if (maxAllowedAmount != undefined && (amount && amount > maxAllowedAmount)) { - // errors.amount = `Max amount is ${maxAllowedAmount}`; - // } - // if (minAllowedAmount != undefined && (amount && amount < minAllowedAmount)) { - // errors.amount = `Min amount is ${minAllowedAmount}`; - // } - if (values.to) { - if (values.destination_address && !Address.isValid(values.destination_address, values.to)) { - errors.destination_address = `Enter a valid ${values.to?.displayName} address`; - } - } - return errors; - }; + if (!values.fromCurrency) { + errors.fromCurrency = 'Select source asset'; + } + if (!values.toCurrency) { + errors.toCurrency = 'Select destination asset'; + } + if (!values.from) { + errors.from = 'Select source'; + } + if (!values.to) { + errors.to = 'Select destination'; + } + if (!activeAmount) { + errors[amountField] = 'Enter an amount'; + } + if (activeAmount && !/^[0-9]*[.,]?[0-9]*$/i.test(activeAmount.toString())) { + errors[amountField] = 'Invalid amount'; + } + if (activeAmount && activeAmount < 0) { + errors[amountField] = "Can't be negative"; + } + // if (maxAllowedAmount != undefined && (amount && amount > maxAllowedAmount)) { + // errors.amount = `Max amount is ${maxAllowedAmount}`; + // } + // if (minAllowedAmount != undefined && (amount && amount < minAllowedAmount)) { + // errors.amount = `Min amount is ${minAllowedAmount}`; + // } + if (values.to) { + if (values.destination_address && !Address.isValid(values.destination_address, values.to)) { + errors.destination_address = `Enter a valid ${values.to?.displayName} address`; + } + } + return errors; } \ No newline at end of file diff --git a/apps/app/stores/usdModeStore.ts b/apps/app/stores/usdModeStore.ts index 563b46b8..4f20d3cc 100644 --- a/apps/app/stores/usdModeStore.ts +++ b/apps/app/stores/usdModeStore.ts @@ -12,7 +12,7 @@ type UsdModeState = { export const useUsdModeStore = create()(persist((set) => ({ isUsdMode: false, usdAmount: '', - toggleMode: () => set((state) => ({ isUsdMode: !state.isUsdMode })), + toggleMode: () => set((state) => ({ isUsdMode: !state.isUsdMode, usdAmount: '' })), setUsdAmount: (amount) => set({ usdAmount: amount }), reset: () => set({ isUsdMode: false, usdAmount: '' }), }), { diff --git a/apps/app/styles/globals.css b/apps/app/styles/globals.css index fb9eafc9..2e4d5def 100644 --- a/apps/app/styles/globals.css +++ b/apps/app/styles/globals.css @@ -602,3 +602,29 @@ path { .group\/accordion.is-focused .accordion-item-focused { background-color: rgb(var(--ls-colors-secondary-400)) !important; } + +@keyframes pulse-strong { + 50% { + opacity: 0.4; + } +} +.animate-pulse-strong { + animation: pulse-strong 1.4s cubic-bezier(0.77, 0, 0.17, 1) infinite; +} + +@keyframes pulse-stronger { + 50% { + opacity: 0.15; + } +} +.animate-pulse-stronger { + animation: pulse-stronger 1.4s cubic-bezier(0.77, 0, 0.17, 1) infinite; +} + +number-flow-react::part(left), +number-flow-react::part(right), +number-flow-react::part(left)::after, +number-flow-react::part(right)::after, +number-flow-react::part(symbol) { + padding: calc(var(--number-flow-mask-height, 0.25em) / 2) 0; +} diff --git a/packages/react/src/hooks/useQuote.ts b/packages/react/src/hooks/useQuote.ts index ff21a02f..cd303124 100644 --- a/packages/react/src/hooks/useQuote.ts +++ b/packages/react/src/hooks/useQuote.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, keepPreviousData } from '@tanstack/react-query' import type { SolverQuote, QuoteDetails } from '@train-protocol/sdk' import { useTrainContext } from '../providers/TrainContext' import { trainQueryKeys } from '../internal/queryKeys' @@ -19,21 +19,19 @@ function useDebouncedValue(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value) useEffect(() => { - if (delayMs <= 0) { - setDebounced(value) - return - } + if (delayMs <= 0) return const timer = setTimeout(() => setDebounced(value), delayMs) return () => clearTimeout(timer) }, [value, delayMs]) - return debounced + return delayMs <= 0 ? value : debounced } export function useQuote(params: QuoteParams): UseQuoteResult { const { apiClient } = useTrainContext() const { amount, + receiveAmount, sourceNetwork, destinationNetwork, sourceTokenContract, @@ -43,13 +41,20 @@ export function useQuote(params: QuoteParams): UseQuoteResult { debounceMs = 300, } = params - const debouncedAmount = useDebouncedValue(amount, debounceMs) - const isDebouncing = debounceMs > 0 && debouncedAmount !== amount + const activeAmount = amount ?? receiveAmount + const debouncedAmount = useDebouncedValue(activeAmount, debounceMs) + const isDebouncing = debounceMs > 0 && debouncedAmount !== activeAmount const canFetch = enabled && !!debouncedAmount && !!sourceNetwork && !!destinationNetwork && Number(debouncedAmount) > 0 + const amountParams = amount != null + ? { amount: debouncedAmount } + : receiveAmount != null + ? { receiveAmount: debouncedAmount } + : {} + const queryKeyParams = { - amount: debouncedAmount, + ...amountParams, sourceNetwork, destinationNetwork, sourceTokenContract, @@ -60,7 +65,7 @@ export function useQuote(params: QuoteParams): UseQuoteResult { queryKey: trainQueryKeys.quote(queryKeyParams), queryFn: async () => { const result = await apiClient.getQuote({ - amount: debouncedAmount, + ...amountParams, sourceNetwork, destinationNetwork, sourceTokenContract, @@ -72,9 +77,10 @@ export function useQuote(params: QuoteParams): UseQuoteResult { enabled: canFetch, refetchInterval: refreshInterval || false, staleTime: 10_000, + placeholderData: keepPreviousData, }) - const quotes = canFetch ? (query.data ?? []) : [] + const quotes = query.data ?? [] const bestSolver = quotes.find(q => q.isBest) const bestQuote = bestSolver?.quote @@ -86,7 +92,7 @@ export function useQuote(params: QuoteParams): UseQuoteResult { quotes, bestQuote, bestSolver, - isLoading: isDebouncing || (canFetch && query.isLoading), + isLoading: isDebouncing || (canFetch && (query.isLoading || query.isPlaceholderData)), error: normalizeQueryError(query.error), refetch, } diff --git a/packages/react/src/internal/queryKeys.ts b/packages/react/src/internal/queryKeys.ts index 61460474..0ac8bdfd 100644 --- a/packages/react/src/internal/queryKeys.ts +++ b/packages/react/src/internal/queryKeys.ts @@ -1,6 +1,7 @@ export const trainQueryKeys = { quote: (params: { - amount: string + amount?: string + receiveAmount?: string sourceNetwork: string destinationNetwork: string sourceTokenContract?: string diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index ff3d7ff3..769f3198 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -123,7 +123,10 @@ export interface StartSwapParams { /** Parameters for quote fetching */ export interface QuoteParams { - amount: string + /** Amount to send on source chain (base units). Provide this OR receiveAmount, not both. */ + amount?: string + /** Amount to receive on destination chain (base units). Provide this OR amount, not both. */ + receiveAmount?: string sourceNetwork: string destinationNetwork: string sourceTokenContract?: string diff --git a/packages/sdk/src/api/types.ts b/packages/sdk/src/api/types.ts index c1de03c5..ac4c1f9b 100644 --- a/packages/sdk/src/api/types.ts +++ b/packages/sdk/src/api/types.ts @@ -74,6 +74,7 @@ type QuoteRoute = { export type QuoteDetails = { signature: string; totalFee: string; + amount?: string; receiveAmount: string; sourceSolverAddress: string; destinationSolverAddress: string;