From 5180526154371c5378c38224592894933d1cc313 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov <18644653+chybisov@users.noreply.github.com> Date: Tue, 30 Jul 2024 12:23:04 +0200 Subject: [PATCH] feat: improve the display of estimated duration and fees (#278) --- .../ActiveTransactionItem.tsx | 2 +- .../src/components/Card/CardIconButton.tsx | 5 +- .../src/components/FeeBreakdownTooltip.tsx | 14 +-- .../widget/src/components/IconTypography.ts | 4 +- .../RouteCard/RouteCardEssentials.tsx | 34 +++--- .../Step/DestinationWalletAddress.tsx | 6 +- packages/widget/src/components/Step/Step.tsx | 2 +- .../src/components/Step/StepProcess.style.tsx | 9 -- .../src/components/Step/StepProcess.tsx | 12 +- .../widget/src/components/Step/StepTimer.tsx | 109 +++++++++++++----- .../StepActions/StepActions.style.tsx | 8 -- .../src/components/TransactionDetails.tsx | 33 ++---- packages/widget/src/hooks/timer/useTimer.ts | 6 +- packages/widget/src/i18n/en.json | 3 +- .../TransactionDetailsPage.tsx | 11 +- .../TransactionPage/TokenValueBottomSheet.tsx | 51 +++++--- .../pages/TransactionPage/TransactionPage.tsx | 37 ++++-- .../widget/src/pages/TransactionPage/utils.ts | 31 +++-- packages/widget/src/stores/routes/types.ts | 12 +- packages/widget/src/types/events.ts | 9 +- packages/widget/src/utils/converters.ts | 2 +- packages/widget/src/utils/fees.ts | 24 ++++ 22 files changed, 254 insertions(+), 170 deletions(-) delete mode 100644 packages/widget/src/components/Step/StepProcess.style.tsx diff --git a/packages/widget/src/components/ActiveTransactions/ActiveTransactionItem.tsx b/packages/widget/src/components/ActiveTransactions/ActiveTransactionItem.tsx index ac9811c92..6f0d73e73 100644 --- a/packages/widget/src/components/ActiveTransactions/ActiveTransactionItem.tsx +++ b/packages/widget/src/components/ActiveTransactions/ActiveTransactionItem.tsx @@ -41,7 +41,7 @@ export const ActiveTransactionItem: React.FC<{ return ; default: return ( - + ); diff --git a/packages/widget/src/components/Card/CardIconButton.tsx b/packages/widget/src/components/Card/CardIconButton.tsx index 8dcb6d19d..5b08ccfd0 100644 --- a/packages/widget/src/components/Card/CardIconButton.tsx +++ b/packages/widget/src/components/Card/CardIconButton.tsx @@ -1,7 +1,10 @@ +import type { IconButtonProps, LinkProps } from '@mui/material'; import { IconButton as MuiIconButton, styled } from '@mui/material'; import { getContrastAlphaColor } from '../../utils/colors.js'; -export const CardIconButton = styled(MuiIconButton)(({ theme }) => { +export const CardIconButton = styled(MuiIconButton)< + IconButtonProps & Pick +>(({ theme }) => { return { padding: theme.spacing(0.5), backgroundColor: getContrastAlphaColor(theme, 0.04), diff --git a/packages/widget/src/components/FeeBreakdownTooltip.tsx b/packages/widget/src/components/FeeBreakdownTooltip.tsx index 982f632be..d2bca8dce 100644 --- a/packages/widget/src/components/FeeBreakdownTooltip.tsx +++ b/packages/widget/src/components/FeeBreakdownTooltip.tsx @@ -1,42 +1,36 @@ -import type { Route } from '@lifi/sdk'; import { Box, Tooltip, Typography } from '@mui/material'; import type { TFunction } from 'i18next'; import type { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; import { formatUnits } from 'viem'; import type { FeesBreakdown } from '../utils/fees.js'; -import { getFeeCostsBreakdown, getGasCostsBreakdown } from '../utils/fees.js'; export interface FeeBreakdownTooltipProps { - route: Route; gasCosts?: FeesBreakdown[]; feeCosts?: FeesBreakdown[]; children: ReactElement; } export const FeeBreakdownTooltip: React.FC = ({ - route, gasCosts, feeCosts, children, }) => { const { t } = useTranslation(); - const _gasCosts = gasCosts ?? getGasCostsBreakdown(route); - const _feeCosts = feeCosts ?? getFeeCostsBreakdown(route, false); return ( - {_gasCosts.length ? ( + {gasCosts?.length ? ( {t('main.fees.network')} - {getFeeBreakdownTypography(_gasCosts, t)} + {getFeeBreakdownTypography(gasCosts, t)} ) : null} - {_feeCosts.length ? ( + {feeCosts?.length ? ( {t('main.fees.provider')} - {getFeeBreakdownTypography(_feeCosts, t)} + {getFeeBreakdownTypography(feeCosts, t)} ) : null} diff --git a/packages/widget/src/components/IconTypography.ts b/packages/widget/src/components/IconTypography.ts index 9454e244d..3c7cda363 100644 --- a/packages/widget/src/components/IconTypography.ts +++ b/packages/widget/src/components/IconTypography.ts @@ -1,6 +1,6 @@ -import { Typography, alpha, styled } from '@mui/material'; +import { alpha, Box, styled } from '@mui/material'; -export const IconTypography = styled(Typography)(({ theme }) => ({ +export const IconTypography = styled(Box)(({ theme }) => ({ color: theme.palette.mode === 'light' ? alpha(theme.palette.common.black, 0.32) diff --git a/packages/widget/src/components/RouteCard/RouteCardEssentials.tsx b/packages/widget/src/components/RouteCard/RouteCardEssentials.tsx index d09f6273b..e2120b27d 100644 --- a/packages/widget/src/components/RouteCard/RouteCardEssentials.tsx +++ b/packages/widget/src/components/RouteCard/RouteCardEssentials.tsx @@ -1,7 +1,7 @@ import { AccessTimeFilled, LocalGasStationRounded } from '@mui/icons-material'; import { Box, Tooltip, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import { getFeeCostsBreakdown } from '../../utils/fees.js'; +import { getAccumulatedFeeCostsBreakdown } from '../../utils/fees.js'; import { FeeBreakdownTooltip } from '../FeeBreakdownTooltip.js'; import { IconTypography } from '../IconTypography.js'; import { TokenRate } from '../TokenRate/TokenRate.js'; @@ -11,15 +11,15 @@ export const RouteCardEssentials: React.FC = ({ route, }) => { const { t, i18n } = useTranslation(); - const executionTimeMinutes = Math.ceil( - route.steps - .map((step) => step.estimate.executionDuration) - .reduce((duration, x) => duration + x, 0) / 60, + const executionTimeSeconds = Math.floor( + route.steps.reduce( + (duration, step) => duration + step.estimate.executionDuration, + 0, + ), ); - const gasCostUSD = parseFloat(route.gasCostUSD || '0'); - const feeCosts = getFeeCostsBreakdown(route, false); - const fees = - gasCostUSD + feeCosts.reduce((sum, feeCost) => sum + feeCost.amountUSD, 0); + const executionTimeMinutes = Math.floor(executionTimeSeconds / 60); + const { gasCosts, feeCosts, combinedFeesUSD } = + getAccumulatedFeeCostsBreakdown(route); return ( = ({ > - + @@ -38,11 +38,12 @@ export const RouteCardEssentials: React.FC = ({ {t(`format.currency`, { - value: fees, + value: combinedFeesUSD, })} @@ -55,12 +56,15 @@ export const RouteCardEssentials: React.FC = ({ - {executionTimeMinutes.toLocaleString(i18n.language, { + {(executionTimeSeconds < 60 + ? executionTimeSeconds + : executionTimeMinutes + ).toLocaleString(i18n.language, { style: 'unit', - unit: 'minute', + unit: executionTimeSeconds < 60 ? 'second' : 'minute', unitDisplay: 'narrow', })} diff --git a/packages/widget/src/components/Step/DestinationWalletAddress.tsx b/packages/widget/src/components/Step/DestinationWalletAddress.tsx index 437cf3735..01d4bf799 100644 --- a/packages/widget/src/components/Step/DestinationWalletAddress.tsx +++ b/packages/widget/src/components/Step/DestinationWalletAddress.tsx @@ -2,8 +2,8 @@ import type { LiFiStepExtended } from '@lifi/sdk'; import { LinkRounded, Wallet } from '@mui/icons-material'; import { Box, Link, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import { CardIconButton } from '../Card/CardIconButton.js'; import { CircularIcon } from './CircularProgress.style.js'; -import { LinkButton } from './StepProcess.style.js'; export const DestinationWalletAddress: React.FC<{ step: LiFiStepExtended; @@ -38,7 +38,7 @@ export const DestinationWalletAddress: React.FC<{ address: toAddress, })} - - + ); diff --git a/packages/widget/src/components/Step/Step.tsx b/packages/widget/src/components/Step/Step.tsx index 8d3d5b673..1a47ede30 100644 --- a/packages/widget/src/components/Step/Step.tsx +++ b/packages/widget/src/components/Step/Step.tsx @@ -73,7 +73,7 @@ export const Step: React.FC<{ }} > {getCardTitle()} - + diff --git a/packages/widget/src/components/Step/StepProcess.style.tsx b/packages/widget/src/components/Step/StepProcess.style.tsx deleted file mode 100644 index 5c0c4d431..000000000 --- a/packages/widget/src/components/Step/StepProcess.style.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { IconButtonProps, LinkProps } from '@mui/material'; -import { styled } from '@mui/material'; -import { CardIconButton } from '../Card/CardIconButton.js'; - -export const LinkButton = styled(CardIconButton)( - ({ theme }) => ({ - padding: theme.spacing(0.5), - }), -); diff --git a/packages/widget/src/components/Step/StepProcess.tsx b/packages/widget/src/components/Step/StepProcess.tsx index 2f478cf87..5ba24648a 100644 --- a/packages/widget/src/components/Step/StepProcess.tsx +++ b/packages/widget/src/components/Step/StepProcess.tsx @@ -1,9 +1,9 @@ import type { LiFiStep, Process } from '@lifi/sdk'; -import { LinkRounded } from '@mui/icons-material'; +import { OpenInNewRounded } from '@mui/icons-material'; import { Box, Link, Typography } from '@mui/material'; import { useProcessMessage } from '../../hooks/useProcessMessage.js'; +import { CardIconButton } from '../Card/CardIconButton.js'; import { CircularProgress } from './CircularProgress.js'; -import { LinkButton } from './StepProcess.style.js'; export const StepProcess: React.FC<{ step: LiFiStep; @@ -28,15 +28,15 @@ export const StepProcess: React.FC<{ {title} {process.txLink ? ( - - - + + ) : null} {message ? ( diff --git a/packages/widget/src/components/Step/StepTimer.tsx b/packages/widget/src/components/Step/StepTimer.tsx index 8f40e6bd4..4562be851 100644 --- a/packages/widget/src/components/Step/StepTimer.tsx +++ b/packages/widget/src/components/Step/StepTimer.tsx @@ -1,11 +1,22 @@ import type { LiFiStepExtended } from '@lifi/sdk'; +import { AccessTimeFilled } from '@mui/icons-material'; +import { Box, Tooltip } from '@mui/material'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useTimer } from '../../hooks/timer/useTimer.js'; +import { IconTypography } from '../IconTypography.js'; + +const getExecutionProcess = (step: LiFiStepExtended) => + step.execution?.process.findLast( + (process) => + process.type === 'SWAP' || + process.type === 'CROSS_CHAIN' || + process.type === 'RECEIVING_CHAIN', + ); const getExpiryTimestamp = (step: LiFiStepExtended) => new Date( - (step.execution?.process[0]?.startedAt ?? Date.now()) + + (getExecutionProcess(step)?.startedAt ?? Date.now()) + step.estimate.executionDuration * 1000, ); @@ -15,8 +26,12 @@ export const StepTimer: React.FC<{ }> = ({ step, hideInProgress }) => { const { t, i18n } = useTranslation(); const [isExpired, setExpired] = useState(false); - const [isExecutionStarted, setExecutionStarted] = useState(!!step.execution); - const [expiryTimestamp] = useState(() => getExpiryTimestamp(step)); + const [isExecutionStarted, setExecutionStarted] = useState( + () => !!getExecutionProcess(step), + ); + const [expiryTimestamp, setExpiryTimestamp] = useState(() => + getExpiryTimestamp(step), + ); const { seconds, minutes, isRunning, pause, resume, restart } = useTimer({ autoStart: false, expiryTimestamp, @@ -24,41 +39,59 @@ export const StepTimer: React.FC<{ }); useEffect(() => { - if (isExpired || !step.execution) { + const executionProcess = getExecutionProcess(step); + if (!executionProcess) { + return; + } + const shouldRestart = executionProcess.status === 'FAILED'; + const shouldPause = executionProcess.status === 'ACTION_REQUIRED'; + const shouldStart = + executionProcess.status === 'STARTED' || + executionProcess.status === 'PENDING'; + const shouldResume = executionProcess.status === 'PENDING'; + if (isExecutionStarted && shouldRestart) { + setExecutionStarted(false); + setExpired(false); return; } - if (!isExecutionStarted) { + if (isExecutionStarted && isExpired) { + return; + } + if (!isExecutionStarted && shouldStart) { + const expiryTimestamp = getExpiryTimestamp(step); setExecutionStarted(true); - restart(getExpiryTimestamp(step)); + setExpired(false); + setExpiryTimestamp(expiryTimestamp); + restart(expiryTimestamp); + return; } - const shouldBePaused = step.execution.process.some( - (process) => - process.status === 'ACTION_REQUIRED' || process.status === 'FAILED', - ); - if (isRunning && shouldBePaused) { + if (isRunning && shouldPause) { pause(); - } else if (!isRunning && !shouldBePaused) { + } else if (!isRunning && shouldResume) { resume(); } - }, [ - expiryTimestamp, - isExecutionStarted, - isExpired, - isRunning, - pause, - restart, - resume, - step, - ]); + }, [isExecutionStarted, isExpired, isRunning, pause, restart, resume, step]); if (!isExecutionStarted) { - return Math.ceil(step.estimate.executionDuration / 60).toLocaleString( - i18n.language, - { - style: 'unit', - unit: 'minute', - unitDisplay: 'narrow', - }, + const showSeconds = step.estimate.executionDuration < 60; + const duration = showSeconds + ? Math.floor(step.estimate.executionDuration) + : Math.floor(step.estimate.executionDuration / 60); + return ( + + + + + + + {duration.toLocaleString(i18n.language, { + style: 'unit', + unit: showSeconds ? 'second' : 'minute', + unitDisplay: 'narrow', + })} + + + ); } @@ -72,7 +105,19 @@ export const StepTimer: React.FC<{ return null; } - return isTimerExpired - ? t('main.inProgress') - : `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`; + return isTimerExpired ? ( + t('main.inProgress') + ) : ( + + + + + + {`${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`} + + + ); }; diff --git a/packages/widget/src/components/StepActions/StepActions.style.tsx b/packages/widget/src/components/StepActions/StepActions.style.tsx index b8979b927..ffcb5d71b 100644 --- a/packages/widget/src/components/StepActions/StepActions.style.tsx +++ b/packages/widget/src/components/StepActions/StepActions.style.tsx @@ -71,11 +71,3 @@ export const StepAvatar = styled(AvatarMasked)(({ theme }) => ({ color: theme.palette.text.primary, backgroundColor: 'transparent', })); - -export const IconTypography = styled(Typography)(({ theme }) => ({ - color: - theme.palette.mode === 'light' - ? alpha(theme.palette.common.black, 0.32) - : alpha(theme.palette.common.white, 0.4), - lineHeight: 0, -})); diff --git a/packages/widget/src/components/TransactionDetails.tsx b/packages/widget/src/components/TransactionDetails.tsx index 1e86183a1..b98410b20 100644 --- a/packages/widget/src/components/TransactionDetails.tsx +++ b/packages/widget/src/components/TransactionDetails.tsx @@ -9,7 +9,7 @@ import { Box, Collapse, Tooltip, Typography } from '@mui/material'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { isRouteDone } from '../stores/routes/utils.js'; -import { getFeeCostsBreakdown, getGasCostsBreakdown } from '../utils/fees.js'; +import { getAccumulatedFeeCostsBreakdown } from '../utils/fees.js'; import { convertToSubscriptFormat, formatTokenAmount, @@ -35,18 +35,8 @@ export const TransactionDetails: React.FC = ({ const toggleCard = () => { setCardExpanded((cardExpanded) => !cardExpanded); }; - - const gasCosts = getGasCostsBreakdown(route); - const feeCosts = getFeeCostsBreakdown(route, false); - const gasCostUSD = gasCosts.reduce( - (sum, gasCost) => sum + gasCost.amountUSD, - 0, - ); - const feeCostUSD = feeCosts.reduce( - (sum, feeCost) => sum + feeCost.amountUSD, - 0, - ); - const fees = gasCostUSD + feeCostUSD; + const { gasCosts, feeCosts, gasCostUSD, feeCostUSD, combinedFeesUSD } = + getAccumulatedFeeCostsBreakdown(route); const fromTokenAmount = formatTokenAmount( BigInt(route.fromAmount), @@ -80,11 +70,7 @@ export const TransactionDetails: React.FC = ({ - + = ({ color="text.primary" fontWeight="600" lineHeight={1.429} + data-value={combinedFeesUSD} > - {t(`format.currency`, { value: fees })} + {t(`format.currency`, { value: combinedFeesUSD })} @@ -118,7 +105,7 @@ export const TransactionDetails: React.FC = ({ {t('main.fees.network')} - + {t(`format.currency`, { value: gasCostUSD, @@ -129,11 +116,7 @@ export const TransactionDetails: React.FC = ({ {feeCosts.length ? ( {t('main.fees.provider')} - + {t(`format.currency`, { value: feeCostUSD, diff --git a/packages/widget/src/hooks/timer/useTimer.ts b/packages/widget/src/hooks/timer/useTimer.ts index 95eb48a73..05ba7dd4c 100644 --- a/packages/widget/src/hooks/timer/useTimer.ts +++ b/packages/widget/src/hooks/timer/useTimer.ts @@ -23,10 +23,12 @@ export function useTimer({ autoStart = true, }: UseTimerProps) { const [expiryTimestamp, setExpiryTimestamp] = useState(expiry); - const [seconds, setSeconds] = useState(getSecondsFromExpiry(expiryTimestamp)); + const [seconds, setSeconds] = useState(() => + getSecondsFromExpiry(expiryTimestamp), + ); const [isRunning, setIsRunning] = useState(autoStart); const [didStart, setDidStart] = useState(autoStart); - const [delay, setDelay] = useState( + const [delay, setDelay] = useState(() => getDelayFromExpiryTimestamp(expiryTimestamp, DEFAULT_DELAY), ); diff --git a/packages/widget/src/i18n/en.json b/packages/widget/src/i18n/en.json index 8bf311d6b..43773ea6a 100644 --- a/packages/widget/src/i18n/en.json +++ b/packages/widget/src/i18n/en.json @@ -169,7 +169,7 @@ }, "tooltip": { "deselectAll": "Deselect all", - "estimatedTime": "Estimated execution time in minutes.", + "estimatedTime": "Estimated time to complete the swap or bridge transaction, excluding chain switching and token approval.", "minReceived": "The estimated minimum amount may change until the swapping/bridging transaction is signed. For 2-step transfers, this applies until the second step transaction is signed.", "notFound": { "text": "We couldn't find this page.", @@ -196,7 +196,6 @@ "provider": "Provider fee" }, "from": "From", - "gasCost": "Gas cost", "inProgress": "in progress", "maxSlippage": "Max. slippage", "minReceived": "Min. received", diff --git a/packages/widget/src/pages/TransactionDetailsPage/TransactionDetailsPage.tsx b/packages/widget/src/pages/TransactionDetailsPage/TransactionDetailsPage.tsx index 710569cf9..798ed233a 100644 --- a/packages/widget/src/pages/TransactionDetailsPage/TransactionDetailsPage.tsx +++ b/packages/widget/src/pages/TransactionDetailsPage/TransactionDetailsPage.tsx @@ -1,10 +1,11 @@ import type { FullStatusData } from '@lifi/sdk'; import { ContentCopyRounded } from '@mui/icons-material'; -import { Box, IconButton, Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; import { Card } from '../../components/Card/Card.js'; +import { CardIconButton } from '../../components/Card/CardIconButton.js'; import { CardTitle } from '../../components/Card/CardTitle.js'; import { ContractComponent } from '../../components/ContractComponent/ContractComponent.js'; import { PageContainer } from '../../components/PageContainer.js'; @@ -124,10 +125,10 @@ export const TransactionDetailsPage: React.FC = () => { }} > {t('main.supportId')} - - - - + + + + = ({ const { t } = useTranslation(); const ref = useRef(); useSetContentHeight(ref); + const { gasCosts, feeCosts, gasCostUSD, feeCostUSD } = + getAccumulatedFeeCostsBreakdown(route); + const fromAmountUSD = parseFloat(route.fromAmountUSD); + const toAmountUSD = parseFloat(route.toAmountUSD); return ( @@ -62,11 +68,23 @@ const TokenValueBottomSheetContent: React.FC = ({ - {t('main.gasCost')} - - {t('format.currency', { value: route.gasCostUSD })} - + {t('main.fees.network')} + + + {t('format.currency', { value: gasCostUSD })} + + + {feeCostUSD ? ( + + {t('main.fees.provider')} + + + {t('format.currency', { value: feeCostUSD })} + + + + ) : null} {t('main.receiving')} @@ -75,7 +93,15 @@ const TokenValueBottomSheetContent: React.FC = ({ {t('main.valueLoss')} - {calcValueLoss(route)} + + {calculateValueLossPercentage( + fromAmountUSD, + toAmountUSD, + gasCostUSD, + feeCostUSD, + )} + % +