diff --git a/packages/widget/src/AppDefault.tsx b/packages/widget/src/AppDefault.tsx index 54f92fc41..32f35785f 100644 --- a/packages/widget/src/AppDefault.tsx +++ b/packages/widget/src/AppDefault.tsx @@ -8,7 +8,7 @@ import { } from '@tanstack/react-router' import { AppLayout } from './AppLayout.js' import { NotFound } from './components/NotFound.js' -import { ActiveTransactionsPage } from './pages/ActiveTransactionsPage/ActiveTransactionsPage.js' +import { ActivitiesPage } from './pages/ActivitiesPage/ActivitiesPage.js' import { LanguagesPage } from './pages/LanguagesPage.js' import { MainPage } from './pages/MainPage/MainPage.js' import { RoutesPage } from './pages/RoutesPage/RoutesPage.js' @@ -22,7 +22,6 @@ import { SendToConfiguredWalletPage } from './pages/SendToWallet/SendToConfigure import { SendToWalletPage } from './pages/SendToWallet/SendToWalletPage.js' import { SettingsPage } from './pages/SettingsPage/SettingsPage.js' import { TransactionDetailsPage } from './pages/TransactionDetailsPage/TransactionDetailsPage.js' -import { TransactionHistoryPage } from './pages/TransactionHistoryPage/TransactionHistoryPage.js' import { TransactionPage } from './pages/TransactionPage/TransactionPage.js' import { navigationRoutes } from './utils/navigationRoutes.js' @@ -134,23 +133,6 @@ const routesTransactionExecutionDetailsRoute = createRoute({ component: TransactionDetailsPage, }) -const activeTransactionsLayoutRoute = createRoute({ - getParentRoute: () => rootRoute, - path: navigationRoutes.activeTransactions, -}) - -const activeTransactionsIndexRoute = createRoute({ - getParentRoute: () => activeTransactionsLayoutRoute, - path: '/', - component: ActiveTransactionsPage, -}) - -const activeTransactionExecutionRoute = createRoute({ - getParentRoute: () => activeTransactionsLayoutRoute, - path: navigationRoutes.transactionExecution, - component: TransactionPage, -}) - const sendToWalletLayoutRoute = createRoute({ getParentRoute: () => rootRoute, path: navigationRoutes.sendToWallet, @@ -186,23 +168,29 @@ const configuredWalletsRoute = createRoute({ component: SendToConfiguredWalletPage, }) -const transactionHistoryLayoutRoute = createRoute({ +const activitiesLayoutRoute = createRoute({ getParentRoute: () => rootRoute, - path: navigationRoutes.transactionHistory, + path: navigationRoutes.activities, }) -const transactionHistoryIndexRoute = createRoute({ - getParentRoute: () => transactionHistoryLayoutRoute, +const activitiesIndexRoute = createRoute({ + getParentRoute: () => activitiesLayoutRoute, path: '/', - component: TransactionHistoryPage, + component: ActivitiesPage, }) -const transactionHistoryDetailsRoute = createRoute({ - getParentRoute: () => transactionHistoryLayoutRoute, +const activitiesDetailsRoute = createRoute({ + getParentRoute: () => activitiesLayoutRoute, path: navigationRoutes.transactionDetails, component: TransactionDetailsPage, }) +const activitiesExecutionRoute = createRoute({ + getParentRoute: () => activitiesLayoutRoute, + path: navigationRoutes.transactionExecution, + component: TransactionPage, +}) + const transactionExecutionLayoutRoute = createRoute({ getParentRoute: () => rootRoute, path: navigationRoutes.transactionExecution, @@ -245,10 +233,6 @@ const routeTree = rootRoute.addChildren([ transactionExecutionIndexRoute, transactionExecutionDetailsRoute, ]), - activeTransactionsLayoutRoute.addChildren([ - activeTransactionsIndexRoute, - activeTransactionExecutionRoute, - ]), sendToWalletLayoutRoute.addChildren([ sendToWalletIndexRoute, sendToWalletBookmarksRoute, @@ -256,9 +240,10 @@ const routeTree = rootRoute.addChildren([ sendToWalletConnectedWalletsRoute, ]), configuredWalletsRoute, - transactionHistoryLayoutRoute.addChildren([ - transactionHistoryIndexRoute, - transactionHistoryDetailsRoute, + activitiesLayoutRoute.addChildren([ + activitiesIndexRoute, + activitiesDetailsRoute, + activitiesExecutionRoute, ]), ]) diff --git a/packages/widget/src/components/ActionRow/ActionRow.style.tsx b/packages/widget/src/components/ActionRow/ActionRow.style.tsx new file mode 100644 index 000000000..ed4b5575c --- /dev/null +++ b/packages/widget/src/components/ActionRow/ActionRow.style.tsx @@ -0,0 +1,27 @@ +import { Box, styled, Typography } from '@mui/material' + +export const ActionRowContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: theme.spacing(1), + borderRadius: theme.vars.shape.borderRadiusTertiary, + backgroundColor: `rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.04)`, +})) + +export const ActionIconCircle = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 24, + height: 24, + borderRadius: '50%', + backgroundColor: `color-mix(in srgb, rgb(${theme.vars.palette.success.mainChannel}) 12%, ${theme.vars.palette.background.paper})`, +})) + +export const ActionRowLabel = styled(Typography)(({ theme }) => ({ + flex: 1, + fontSize: 12, + fontWeight: 500, + color: theme.vars.palette.text.primary, +})) diff --git a/packages/widget/src/components/ActionRow/ActionRow.tsx b/packages/widget/src/components/ActionRow/ActionRow.tsx new file mode 100644 index 000000000..abdd10645 --- /dev/null +++ b/packages/widget/src/components/ActionRow/ActionRow.tsx @@ -0,0 +1,22 @@ +import type { FC, ReactNode } from 'react' +import { ActionRowContainer, ActionRowLabel } from './ActionRow.style.js' + +interface ActionRowProps { + message: string + startAdornment: ReactNode + endAdornment?: ReactNode +} + +export const ActionRow: FC = ({ + message, + startAdornment, + endAdornment, +}) => { + return ( + + {startAdornment} + {message} + {endAdornment} + + ) +} diff --git a/packages/widget/src/components/ActiveTransactions/ActiveTransactionItem.tsx b/packages/widget/src/components/ActiveTransactions/ActiveTransactionItem.tsx deleted file mode 100644 index 71ed28188..000000000 --- a/packages/widget/src/components/ActiveTransactions/ActiveTransactionItem.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import ArrowForward from '@mui/icons-material/ArrowForward' -import ErrorRounded from '@mui/icons-material/ErrorRounded' -import InfoRounded from '@mui/icons-material/InfoRounded' -import { ListItemAvatar, ListItemText, Typography } from '@mui/material' -import { useNavigate } from '@tanstack/react-router' -import { useActionMessage } from '../../hooks/useActionMessage.js' -import { useRouteExecution } from '../../hooks/useRouteExecution.js' -import { RouteExecutionStatus } from '../../stores/routes/types.js' -import { navigationRoutes } from '../../utils/navigationRoutes.js' -import { TokenAvatarGroup } from '../Avatar/Avatar.style.js' -import { TokenAvatar } from '../Avatar/TokenAvatar.js' -import { StepTimer } from '../Timer/StepTimer.js' -import { ListItem, ListItemButton } from './ActiveTransactions.style.js' - -export const ActiveTransactionItem: React.FC<{ - routeId: string - dense?: boolean -}> = ({ routeId, dense }) => { - const navigate = useNavigate() - const { route, status } = useRouteExecution({ - routeId, - executeInBackground: true, - }) - - const lastActiveStep = route?.steps.findLast((step) => step.execution) - const lastActiveAction = lastActiveStep?.execution?.actions?.at(-1) - - const { title } = useActionMessage(lastActiveStep, lastActiveAction) - - if (!route || !lastActiveStep) { - return null - } - - const handleClick = () => { - navigate({ - to: navigationRoutes.transactionExecution, - search: { routeId }, - }) - } - - const getStatusComponent = () => { - switch (lastActiveAction?.status) { - case 'ACTION_REQUIRED': - case 'MESSAGE_REQUIRED': - case 'RESET_REQUIRED': - return - case 'FAILED': - return - default: - return ( - - - - ) - } - } - - const ListItemComponent = dense ? ListItem : ListItemButton - - return ( - - - - - - - - - {route.fromToken.symbol} - - {route.toToken.symbol} - - } - secondary={ - status !== RouteExecutionStatus.Done ? ( - - {title} - - ) : null - } - /> - {getStatusComponent()} - - ) -} diff --git a/packages/widget/src/components/ActiveTransactions/ActiveTransactions.style.ts b/packages/widget/src/components/ActiveTransactions/ActiveTransactions.style.ts deleted file mode 100644 index a5b80ef2f..000000000 --- a/packages/widget/src/components/ActiveTransactions/ActiveTransactions.style.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - listItemSecondaryActionClasses, - ListItem as MuiListItem, - ListItemButton as MuiListItemButton, - styled, -} from '@mui/material' - -export const ListItemButton = styled(MuiListItemButton)(({ theme }) => ({ - borderRadius: theme.vars.shape.borderRadius, - paddingLeft: theme.spacing(1.5), - paddingRight: theme.spacing(1.5), - height: 64, - '&:hover': { - backgroundColor: `rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.04)`, - }, -})) - -export const ListItem = styled(MuiListItem, { - shouldForwardProp: (prop) => prop !== 'disableRipple', -})(({ theme }) => ({ - padding: theme.spacing(0), - [`.${listItemSecondaryActionClasses.root}`]: { - right: theme.spacing(3), - }, - '&:hover': { - cursor: 'pointer', - }, -})) diff --git a/packages/widget/src/components/ActiveTransactions/ActiveTransactions.tsx b/packages/widget/src/components/ActiveTransactions/ActiveTransactions.tsx deleted file mode 100644 index 0e6c9f417..000000000 --- a/packages/widget/src/components/ActiveTransactions/ActiveTransactions.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { CardProps } from '@mui/material' -import { Stack } from '@mui/material' -import { useNavigate } from '@tanstack/react-router' -import { useTranslation } from 'react-i18next' -import { useExecutingRoutesIds } from '../../stores/routes/useExecutingRoutesIds.js' -import { navigationRoutes } from '../../utils/navigationRoutes.js' -import { ButtonTertiary } from '../ButtonTertiary.js' -import { Card } from '../Card/Card.js' -import { CardTitle } from '../Card/CardTitle.js' -import { ActiveTransactionItem } from './ActiveTransactionItem.js' - -export const ActiveTransactions: React.FC = (props) => { - const { t } = useTranslation() - const navigate = useNavigate() - const executingRoutes = useExecutingRoutesIds() - - if (!executingRoutes?.length) { - return null - } - - const handleShowAll = () => { - navigate({ to: navigationRoutes.activeTransactions }) - } - - const hasShowAll = executingRoutes?.length > 2 - - return ( - - {t('header.activeTransactions')} - - {executingRoutes.slice(0, 2).map((routeId) => ( - - ))} - {hasShowAll ? ( - - {t('button.showAll')} - - ) : null} - - - ) -} diff --git a/packages/widget/src/components/AmountInput/AmountInput.style.tsx b/packages/widget/src/components/AmountInput/AmountInput.style.tsx index 3a87a276e..14278a1ce 100644 --- a/packages/widget/src/components/AmountInput/AmountInput.style.tsx +++ b/packages/widget/src/components/AmountInput/AmountInput.style.tsx @@ -46,13 +46,13 @@ export const Input = styled(InputBase)(({ theme }) => ({ })) export const AmountInputCardTitle = styled(CardTitle)(({ theme }) => ({ - padding: theme.spacing(2, 0, 0, 0), + padding: theme.spacing(0), })) export const AmountInputCardHeader = styled(Box)(({ theme }) => ({ - padding: theme.spacing(0, 2, 0, 2), + margin: theme.spacing(2, 2, 0, 2), display: 'flex', justifyContent: 'space-between', - alignItems: 'start', - height: 30, + alignItems: 'center', + height: 28, })) diff --git a/packages/widget/src/components/AmountInput/AmountInput.tsx b/packages/widget/src/components/AmountInput/AmountInput.tsx index 3c463c4c9..b7155b125 100644 --- a/packages/widget/src/components/AmountInput/AmountInput.tsx +++ b/packages/widget/src/components/AmountInput/AmountInput.tsx @@ -28,13 +28,11 @@ import { minInputFontSize, } from './AmountInput.style.js' import { AmountInputEndAdornment } from './AmountInputEndAdornment.js' +import { AmountInputHeaderBadge } from './AmountInputHeaderBadge.js' import { AmountInputStartAdornment } from './AmountInputStartAdornment.js' import { PriceFormHelperText } from './PriceFormHelperText.js' -export const AmountInput: React.FC = ({ - formType, - ...props -}) => { +export const AmountInput: React.FC = ({ formType }) => { const { disabledUI } = useWidgetConfig() const [chainId, tokenAddress] = useFieldValues( @@ -49,12 +47,9 @@ export const AmountInput: React.FC = ({ : undefined - } + endAdornment={} bottomAdornment={} disabled={disabled} - {...props} /> ) } @@ -75,7 +70,6 @@ const AmountInputBase: React.FC< endAdornment, bottomAdornment, disabled, - ...props }) => { const { t } = useTranslation() const { subvariant, subvariantOptions } = useWidgetConfig() @@ -171,7 +165,7 @@ const AmountInputBase: React.FC< : t('header.send') return ( - + {title} {endAdornment} @@ -204,6 +198,7 @@ const AmountInputBase: React.FC< {bottomAdornment} + {!disabled ? : undefined} ) } diff --git a/packages/widget/src/components/AmountInput/AmountInputAdornment.style.tsx b/packages/widget/src/components/AmountInput/AmountInputAdornment.style.tsx deleted file mode 100644 index a596e3a14..000000000 --- a/packages/widget/src/components/AmountInput/AmountInputAdornment.style.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Box, styled } from '@mui/material' -import { cardClasses } from '@mui/material/Card' -import { ButtonTertiary } from '../ButtonTertiary.js' - -export const ButtonContainer = styled(Box)(({ theme }) => ({ - display: 'flex', - gap: theme.spacing(0.5), -})) - -export const MaxButton = styled(ButtonTertiary)(({ theme }) => ({ - padding: theme.spacing(0.5, 1, 0.5, 1), - margin: theme.spacing(0, 0, 0, 0.5), - lineHeight: 1, - fontSize: '0.75rem', - minWidth: 'unset', - height: 24, - opacity: 0, - transform: 'scale(0.85) translateY(-10px)', - transition: - 'opacity 200ms cubic-bezier(0.4, 0, 0.2, 1), transform 200ms cubic-bezier(0.4, 0, 0.2, 1)', - '&[data-delay="0"]': { - [`.${cardClasses.root}:hover &`]: { - opacity: 1, - transform: 'scale(1) translateY(0)', - transitionDelay: '75ms', - }, - [`.${cardClasses.root}:not(:hover) &`]: { - opacity: 0, - transform: 'scale(0.85) translateY(-10px)', - transitionDelay: '0ms', - }, - }, - '&[data-delay="1"]': { - [`.${cardClasses.root}:hover &`]: { - opacity: 1, - transform: 'scale(1) translateY(0)', - transitionDelay: '50ms', - }, - [`.${cardClasses.root}:not(:hover) &`]: { - opacity: 0, - transform: 'scale(0.85) translateY(-10px)', - transitionDelay: '25ms', - }, - }, - '&[data-delay="2"]': { - [`.${cardClasses.root}:hover &`]: { - opacity: 1, - transform: 'scale(1) translateY(0)', - transitionDelay: '25ms', - }, - [`.${cardClasses.root}:not(:hover) &`]: { - opacity: 0, - transform: 'scale(0.85) translateY(-10px)', - transitionDelay: '50ms', - }, - }, - '&[data-delay="3"]': { - [`.${cardClasses.root}:hover &`]: { - opacity: 1, - transform: 'scale(1) translateY(0)', - transitionDelay: '0ms', - }, - [`.${cardClasses.root}:not(:hover) &`]: { - opacity: 0, - transform: 'scale(0.85) translateY(-10px)', - transitionDelay: '75ms', - }, - }, -})) diff --git a/packages/widget/src/components/AmountInput/AmountInputEndAdornment.style.tsx b/packages/widget/src/components/AmountInput/AmountInputEndAdornment.style.tsx new file mode 100644 index 000000000..1842f493c --- /dev/null +++ b/packages/widget/src/components/AmountInput/AmountInputEndAdornment.style.tsx @@ -0,0 +1,12 @@ +import { Box, styled } from '@mui/material' +import { ButtonChip } from '../ButtonChip/ButtonChip.js' + +export const ButtonContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + padding: theme.spacing(0, 2, 2, 2), +})) + +export const AmountInputButton = styled(ButtonChip)(() => ({ + flex: 1, +})) diff --git a/packages/widget/src/components/AmountInput/AmountInputEndAdornment.tsx b/packages/widget/src/components/AmountInput/AmountInputEndAdornment.tsx index 0bc4ab1d5..182eb4a7c 100644 --- a/packages/widget/src/components/AmountInput/AmountInputEndAdornment.tsx +++ b/packages/widget/src/components/AmountInput/AmountInputEndAdornment.tsx @@ -1,5 +1,4 @@ import { formatUnits } from '@lifi/sdk' -import { InputAdornment } from '@mui/material' import { memo } from 'react' import { useTranslation } from 'react-i18next' import { useAvailableChains } from '../../hooks/useAvailableChains.js' @@ -9,7 +8,10 @@ import type { FormTypeProps } from '../../stores/form/types.js' import { FormKeyHelper } from '../../stores/form/types.js' import { useFieldActions } from '../../stores/form/useFieldActions.js' import { useFieldValues } from '../../stores/form/useFieldValues.js' -import { ButtonContainer, MaxButton } from './AmountInputAdornment.style.js' +import { + AmountInputButton, + ButtonContainer, +} from './AmountInputEndAdornment.style.js' export const AmountInputEndAdornment = memo(({ formType }: FormTypeProps) => { const { t } = useTranslation() @@ -69,24 +71,24 @@ export const AmountInputEndAdornment = memo(({ formType }: FormTypeProps) => { } } + if (formType !== 'from' || !token?.amount) { + return null + } + return ( - - {formType === 'from' && token?.amount ? ( - - handlePercentage(25)} data-delay="0"> - 25% - - handlePercentage(50)} data-delay="1"> - 50% - - handlePercentage(75)} data-delay="2"> - 75% - - - {t('button.max')} - - - ) : null} - + + handlePercentage(25)}> + 25% + + handlePercentage(50)}> + 50% + + handlePercentage(75)}> + 75% + + + {t('button.max')} + + ) }) diff --git a/packages/widget/src/components/AmountInput/AmountInputHeaderBadge.tsx b/packages/widget/src/components/AmountInput/AmountInputHeaderBadge.tsx new file mode 100644 index 000000000..2c50ebf69 --- /dev/null +++ b/packages/widget/src/components/AmountInput/AmountInputHeaderBadge.tsx @@ -0,0 +1,126 @@ +import { useAccount } from '@lifi/wallet-management' +import { useChainTypeFromAddress } from '@lifi/widget-provider' +import Close from '@mui/icons-material/Close' +import Wallet from '@mui/icons-material/Wallet' +import { Box, IconButton, Typography } from '@mui/material' +import { useNavigate } from '@tanstack/react-router' +import type { MouseEvent } from 'react' +import { useTranslation } from 'react-i18next' +import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' +import { useBookmarkActions } from '../../stores/bookmarks/useBookmarkActions.js' +import { useBookmarks } from '../../stores/bookmarks/useBookmarks.js' +import { useFieldActions } from '../../stores/form/useFieldActions.js' +import { useFieldValues } from '../../stores/form/useFieldValues.js' +import { DisabledUI, HiddenUI } from '../../types/widget.js' +import { defaultChainIdsByType } from '../../utils/chainType.js' +import { navigationRoutes } from '../../utils/navigationRoutes.js' +import { shortenAddress } from '../../utils/wallet.js' +import { AccountAvatar } from '../Avatar/AccountAvatar.js' +import { ButtonChip } from '../ButtonChip/ButtonChip.js' + +export const AmountInputHeaderBadge: React.FC = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const { + subvariant, + subvariantOptions, + toAddress, + toAddresses, + disabledUI, + hiddenUI, + } = useWidgetConfig() + const { setFieldValue } = useFieldActions() + const [toAddressValue, toChainId, toTokenAddress] = useFieldValues( + 'toAddress', + 'toChain', + 'toToken' + ) + const { selectedBookmark } = useBookmarks() + const { setSelectedBookmark } = useBookmarkActions() + const { accounts } = useAccount() + const { getChainTypeFromAddress } = useChainTypeFromAddress() + + const hiddenToAddress = hiddenUI?.includes(HiddenUI.ToAddress) + const disabledToAddress = disabledUI?.includes(DisabledUI.ToAddress) + const showWalletBadge = + !hiddenToAddress && !(disabledToAddress && !toAddressValue) + + if (!showWalletBadge) { + return null + } + + const label = + subvariant === 'custom' && subvariantOptions?.custom === 'deposit' + ? t('header.depositTo') + : t('header.sendToWallet') + + const handleClick = disabledToAddress + ? undefined + : () => + navigate({ + to: toAddresses?.length + ? navigationRoutes.configuredWallets + : navigationRoutes.sendToWallet, + }) + + const handleRemove = (e: MouseEvent) => { + e.stopPropagation() + setFieldValue('toAddress', '', { isTouched: true }) + setSelectedBookmark() + } + + const matchingConnectedAccount = accounts.find( + (account) => account.address === toAddressValue + ) + + const chainType = !matchingConnectedAccount + ? selectedBookmark?.chainType || + (toAddressValue ? getChainTypeFromAddress(toAddressValue) : undefined) + : undefined + + const chainId = + toChainId && toTokenAddress + ? toChainId + : matchingConnectedAccount + ? matchingConnectedAccount.chainId + : chainType + ? defaultChainIdsByType[chainType] + : undefined + + return ( + ({ + display: 'flex', + alignItems: 'center', + gap: 0.75, + borderRadius: theme.shape.borderRadius, + height: 32, + })} + > + + {toAddressValue ? ( + + ) : null} + + {toAddressValue ? shortenAddress(toAddressValue) : label} + + + {toAddressValue && !disabledToAddress ? ( + + + + ) : ( + + )} + + ) +} diff --git a/packages/widget/src/components/Avatar/AccountAvatar.tsx b/packages/widget/src/components/Avatar/AccountAvatar.tsx index e8b1575f4..da0ccffe7 100644 --- a/packages/widget/src/components/Avatar/AccountAvatar.tsx +++ b/packages/widget/src/components/Avatar/AccountAvatar.tsx @@ -12,6 +12,9 @@ interface AccountAvatarProps { account?: Account toAddress?: ToAddress empty?: boolean + size?: number + badgeSize?: number + badgeBorderWidthPx?: number } export const AccountAvatar = ({ @@ -19,21 +22,37 @@ export const AccountAvatar = ({ account, empty, toAddress, + size, + badgeSize, + badgeBorderWidthPx, }: AccountAvatarProps) => { const { chain } = useChain(chainId) + const walletIconSize = size ? Math.floor(size * 0.5) : 20 + const avatar = empty ? ( - + ) : account?.connector || toAddress?.logoURI ? ( {(toAddress?.name || account?.connector?.name)?.[0]} ) : ( - - + + ) @@ -41,7 +60,7 @@ export const AccountAvatar = ({ } + badgeContent={} > {avatar} diff --git a/packages/widget/src/components/Avatar/Avatar.style.tsx b/packages/widget/src/components/Avatar/Avatar.style.tsx index 18d0b3db1..0e88e55bf 100644 --- a/packages/widget/src/components/Avatar/Avatar.style.tsx +++ b/packages/widget/src/components/Avatar/Avatar.style.tsx @@ -10,12 +10,13 @@ import { import { getAvatarMask } from './utils.js' export const AvatarMasked = styled(MuiAvatar, { - shouldForwardProp: (prop) => prop !== 'avatarSize' && prop !== 'badgeSize', -})<{ avatarSize?: number; badgeSize?: number }>( - ({ avatarSize = 40, badgeSize = 16 }) => ({ + shouldForwardProp: (prop: string) => + !['avatarSize', 'badgeSize', 'badgeBorderWidthPx'].includes(prop), +})<{ avatarSize?: number; badgeSize?: number; badgeBorderWidthPx?: number }>( + ({ avatarSize = 40, badgeSize = 16, badgeBorderWidthPx = 2.5 }) => ({ width: avatarSize, height: avatarSize, - mask: getAvatarMask(badgeSize), + mask: getAvatarMask(badgeSize, badgeBorderWidthPx), }) ) @@ -37,24 +38,27 @@ export const TokenAvatarGroup = styled(AvatarGroup)(({ theme }) => ({ })) export const AvatarDefault = styled(Box, { - shouldForwardProp: (prop) => prop !== 'badgeSize', -})<{ badgeSize?: number }>(({ theme, badgeSize = 16 }) => { - const root = theme.components?.MuiAvatar?.styleOverrides?.root as CSSObject - return { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: '50%', - height: root?.height, - width: root?.width, - color: theme.vars.palette.text.secondary, - mask: getAvatarMask(badgeSize), - background: theme.vars.palette.grey[300], - ...theme.applyStyles('dark', { - background: theme.vars.palette.grey[800], - }), + shouldForwardProp: (prop) => + prop !== 'badgeSize' && prop !== 'badgeBorderWidthPx', +})<{ badgeSize?: number; badgeBorderWidthPx?: number }>( + ({ theme, badgeSize = 16, badgeBorderWidthPx = 2.5 }) => { + const root = theme.components?.MuiAvatar?.styleOverrides?.root as CSSObject + return { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '50%', + height: root?.height, + width: root?.width, + color: theme.vars.palette.text.secondary, + mask: getAvatarMask(badgeSize, badgeBorderWidthPx), + background: theme.vars.palette.grey[300], + ...theme.applyStyles('dark', { + background: theme.vars.palette.grey[800], + }), + } } -}) +) export const AvatarDefaultBadge = styled(Box, { shouldForwardProp: (prop) => prop !== 'size', diff --git a/packages/widget/src/components/Avatar/utils.ts b/packages/widget/src/components/Avatar/utils.ts index 9c7de02d4..f0f19c225 100644 --- a/packages/widget/src/components/Avatar/utils.ts +++ b/packages/widget/src/components/Avatar/utils.ts @@ -1,6 +1,7 @@ -const borderWidthPx = 2.5 - // 14% is the right bottom offset of the MUI's badge (MuiBadge-badge class) -export const getAvatarMask = (badgeSize: number) => { - return `radial-gradient(circle ${badgeSize / 2 + borderWidthPx}px at calc(100% - 14%) calc(100% - 14%), #fff0 96%, #fff) 100% 100% / 100% 100% no-repeat` +export const getAvatarMask = ( + badgeSize: number, + badgeBorderWidthPx: number = 2.5 +) => { + return `radial-gradient(circle ${badgeSize / 2 + badgeBorderWidthPx}px at calc(100% - 14%) calc(100% - 14%), #fff0 96%, #fff) 100% 100% / 100% 100% no-repeat` } diff --git a/packages/widget/src/components/ButtonChip/ButtonChip.tsx b/packages/widget/src/components/ButtonChip/ButtonChip.tsx new file mode 100644 index 000000000..725e765db --- /dev/null +++ b/packages/widget/src/components/ButtonChip/ButtonChip.tsx @@ -0,0 +1,10 @@ +import { styled } from '@mui/material' +import { ButtonTertiary } from '../ButtonTertiary.js' + +export const ButtonChip = styled(ButtonTertiary)(({ theme }) => ({ + padding: theme.spacing(0.5, 1.5), + fontSize: 12, + fontWeight: 700, + lineHeight: 1.3334, + height: 'auto', +})) diff --git a/packages/widget/src/components/Card/CardLabel.tsx b/packages/widget/src/components/Card/CardLabel.tsx index c59053785..1e560f1a3 100644 --- a/packages/widget/src/components/Card/CardLabel.tsx +++ b/packages/widget/src/components/Card/CardLabel.tsx @@ -48,7 +48,7 @@ export const CardLabelTypography = styled(Typography, { padding: theme.spacing(0.75, 1.5), fontSize: 12, lineHeight: 1, - fontWeight: '600', + fontWeight: 600, variants: [ { props: { diff --git a/packages/widget/src/components/DateLabel/DateLabel.style.tsx b/packages/widget/src/components/DateLabel/DateLabel.style.tsx new file mode 100644 index 000000000..2047284f3 --- /dev/null +++ b/packages/widget/src/components/DateLabel/DateLabel.style.tsx @@ -0,0 +1,11 @@ +import { Box, styled, Typography } from '@mui/material' + +export const DateLabelContainer = styled(Box)({ + display: 'flex', + justifyContent: 'space-between', +}) + +export const DateLabelText = styled(Typography)({ + fontSize: 12, + fontWeight: 500, +}) diff --git a/packages/widget/src/components/DateLabel/DateLabel.tsx b/packages/widget/src/components/DateLabel/DateLabel.tsx new file mode 100644 index 000000000..506547b9e --- /dev/null +++ b/packages/widget/src/components/DateLabel/DateLabel.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from 'react-i18next' +import { DateLabelContainer, DateLabelText } from './DateLabel.style.js' + +interface DateLabelProps { + date: Date +} + +export const DateLabel: React.FC = ({ date }) => { + const { i18n } = useTranslation() + + return ( + + + {date.toLocaleString(i18n.language, { dateStyle: 'long' })} + + + {date.toLocaleString(i18n.language, { timeStyle: 'short' })} + + + ) +} diff --git a/packages/widget/src/components/ExecutionProgress/ExecutionProgress.tsx b/packages/widget/src/components/ExecutionProgress/ExecutionProgress.tsx new file mode 100644 index 000000000..00bb5cf74 --- /dev/null +++ b/packages/widget/src/components/ExecutionProgress/ExecutionProgress.tsx @@ -0,0 +1,66 @@ +import type { RouteExtended } from '@lifi/sdk' +import { Box, Typography } from '@mui/material' +import { useRouteExecutionMessage } from '../../hooks/useRouteExecutionMessage.js' +import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' +import { RouteExecutionStatus } from '../../stores/routes/types.js' +import { hasEnumFlag } from '../../utils/enum.js' +import { ExecutionStatusIndicator } from './ExecutionStatusIndicator.js' + +export const ExecutionProgress: React.FC<{ + route: RouteExtended + status: RouteExecutionStatus +}> = ({ route, status }) => { + const { + feeConfig, + subvariant, + contractSecondaryComponent, + contractCompactComponent, + } = useWidgetConfig() + const { title, message } = useRouteExecutionMessage(route, status) + + const showContractComponent = + subvariant === 'custom' && + hasEnumFlag(status, RouteExecutionStatus.Done) && + (contractCompactComponent || contractSecondaryComponent) + + const VcComponent = + status === RouteExecutionStatus.Done ? feeConfig?._vcComponent : undefined + + return ( + + {!showContractComponent ? ( + + + + ) : ( + contractCompactComponent || contractSecondaryComponent + )} + {title && ( + + {title} + + )} + {message ? ( + + {message} + + ) : null} + {VcComponent ? : null} + + ) +} diff --git a/packages/widget/src/components/ExecutionProgress/ExecutionStatusIndicator.tsx b/packages/widget/src/components/ExecutionProgress/ExecutionStatusIndicator.tsx new file mode 100644 index 000000000..d4ae2cc33 --- /dev/null +++ b/packages/widget/src/components/ExecutionProgress/ExecutionStatusIndicator.tsx @@ -0,0 +1,36 @@ +import type { RouteExtended } from '@lifi/sdk' +import { RouteExecutionStatus } from '../../stores/routes/types.js' +import { hasEnumFlag } from '../../utils/enum.js' +import { IconCircle } from '../IconCircle/IconCircle.js' +import { TimerRing } from '../Timer/StepStatusTimer.js' + +interface ExecutionStatusIndicatorProps { + route: RouteExtended + status: RouteExecutionStatus +} + +export const ExecutionStatusIndicator: React.FC< + ExecutionStatusIndicatorProps +> = ({ route, status }) => { + const step = route.steps.at(-1) + + if (hasEnumFlag(status, RouteExecutionStatus.Done)) { + if ( + hasEnumFlag(status, RouteExecutionStatus.Partial) || + hasEnumFlag(status, RouteExecutionStatus.Refunded) + ) { + return + } + return + } + + if (hasEnumFlag(status, RouteExecutionStatus.Failed)) { + return + } + + if (step?.execution?.status === 'ACTION_REQUIRED') { + return + } + + return +} diff --git a/packages/widget/src/components/FeeBreakdownTooltip.tsx b/packages/widget/src/components/FeeBreakdownTooltip.tsx index 35181e6cd..652a2543b 100644 --- a/packages/widget/src/components/FeeBreakdownTooltip.tsx +++ b/packages/widget/src/components/FeeBreakdownTooltip.tsx @@ -42,7 +42,6 @@ export const FeeBreakdownTooltip: React.FC = ({ ) : null} } - sx={{ cursor: 'help' }} > {children} diff --git a/packages/widget/src/components/Header/ActivitiesButton.style.tsx b/packages/widget/src/components/Header/ActivitiesButton.style.tsx new file mode 100644 index 000000000..e6ce1eb75 --- /dev/null +++ b/packages/widget/src/components/Header/ActivitiesButton.style.tsx @@ -0,0 +1,36 @@ +import { Badge, Box, IconButton, styled } from '@mui/material' +import type { RouteExecutionIndicator } from '../../stores/routes/useRouteExecutionIndicators.js' + +export const HistoryIconButton = styled(IconButton, { + shouldForwardProp: (prop) => prop !== 'indicator', +})<{ indicator: RouteExecutionIndicator }>(({ theme, indicator }) => + indicator !== 'idle' + ? { + backgroundColor: `color-mix(in srgb, rgb(${theme.vars.palette.info.mainChannel}) 8%, ${theme.vars.palette.background.paper})`, + '&:hover': { + backgroundColor: `color-mix(in srgb, rgb(${theme.vars.palette.info.mainChannel}) 12%, ${theme.vars.palette.background.paper})`, + }, + } + : {} +) + +export const ErrorBadge = styled(Badge)(({ theme }) => ({ + '& .MuiBadge-badge': { + padding: 0, + minWidth: 'unset', + width: 16, + height: 16, + borderRadius: '50%', + backgroundColor: theme.vars.palette.background.paper, + boxShadow: `0 0 0 2px ${theme.vars.palette.background.paper}`, + top: -2, + left: 10, + }, +})) + +export const ProgressContainer = styled(Box)({ + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}) diff --git a/packages/widget/src/components/Header/ActivitiesButton.tsx b/packages/widget/src/components/Header/ActivitiesButton.tsx new file mode 100644 index 000000000..77abbf58f --- /dev/null +++ b/packages/widget/src/components/Header/ActivitiesButton.tsx @@ -0,0 +1,58 @@ +import ErrorRounded from '@mui/icons-material/ErrorRounded' +import ReceiptLong from '@mui/icons-material/ReceiptLong' +import { Tooltip } from '@mui/material' +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { useRouteExecutionIndicator } from '../../stores/routes/useRouteExecutionIndicators.js' +import { navigationRoutes } from '../../utils/navigationRoutes.js' +import { ProgressFill, ProgressTrack } from '../Timer/StepStatusTimer.style.js' +import { + ErrorBadge, + HistoryIconButton, + ProgressContainer, +} from './ActivitiesButton.style.js' + +export const ActivitiesButton = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const indicator = useRouteExecutionIndicator() + + return ( + + navigate({ to: navigationRoutes.activities })} + > + + } + overlap="circular" + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + > + + {indicator !== 'idle' ? ( + <> + + + + ) : null} + + + + + + ) +} diff --git a/packages/widget/src/components/Header/NavigationHeader.tsx b/packages/widget/src/components/Header/NavigationHeader.tsx index 173203ba7..2218f53b9 100644 --- a/packages/widget/src/components/Header/NavigationHeader.tsx +++ b/packages/widget/src/components/Header/NavigationHeader.tsx @@ -9,12 +9,12 @@ import { navigationRoutes, navigationRoutesValues, } from '../../utils/navigationRoutes.js' +import { ActivitiesButton } from './ActivitiesButton.js' import { BackButton } from './BackButton.js' import { CloseDrawerButton } from './CloseDrawerButton.js' import { HeaderAppBar, HeaderControlsContainer } from './Header.style.js' import { SettingsButton } from './SettingsButton.js' import { SplitNavigationTabs } from './SplitNavigationTabs.js' -import { TransactionHistoryButton } from './TransactionHistoryButton.js' export const NavigationHeader: React.FC = () => { const { subvariant, hiddenUI, variant, defaultUI, subvariantOptions } = @@ -61,7 +61,7 @@ export const NavigationHeader: React.FC = () => { {pathname === navigationRoutes.home ? ( {account.isConnected && !hiddenUI?.includes(HiddenUI.History) && ( - + )} {variant === 'drawer' && diff --git a/packages/widget/src/components/Header/TransactionHistoryButton.tsx b/packages/widget/src/components/Header/TransactionHistoryButton.tsx deleted file mode 100644 index 4824ac022..000000000 --- a/packages/widget/src/components/Header/TransactionHistoryButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import ReceiptLong from '@mui/icons-material/ReceiptLong' -import { IconButton, Tooltip } from '@mui/material' -import { useNavigate } from '@tanstack/react-router' -import { useTranslation } from 'react-i18next' -import { navigationRoutes } from '../../utils/navigationRoutes.js' - -export const TransactionHistoryButton = () => { - const { t } = useTranslation() - const navigate = useNavigate() - - return ( - - navigate({ to: navigationRoutes.transactionHistory })} - > - - - - ) -} diff --git a/packages/widget/src/components/IconCircle/IconCircle.style.tsx b/packages/widget/src/components/IconCircle/IconCircle.style.tsx new file mode 100644 index 000000000..0482b9cd9 --- /dev/null +++ b/packages/widget/src/components/IconCircle/IconCircle.style.tsx @@ -0,0 +1,80 @@ +import type { Theme } from '@mui/material' +import { Box, styled } from '@mui/material' + +export const iconCircleSize = 90 + +interface StatusColorConfig { + color: string + mixAmount: number + lightDarken: number + darkDarken: number +} + +export type StatusColor = 'success' | 'error' | 'warning' | 'info' + +export const getStatusColor = ( + status: StatusColor, + theme: Theme +): StatusColorConfig => { + switch (status) { + case 'success': + return { + color: theme.vars.palette.success.mainChannel, + mixAmount: 12, + lightDarken: 0, + darkDarken: 0, + } + case 'error': + return { + color: theme.vars.palette.error.mainChannel, + mixAmount: 12, + lightDarken: 0, + darkDarken: 0, + } + case 'warning': + return { + color: theme.vars.palette.warning.mainChannel, + mixAmount: 48, + lightDarken: 0.32, + darkDarken: 0, + } + case 'info': + return { + color: theme.vars.palette.info.mainChannel, + mixAmount: 12, + lightDarken: 0, + darkDarken: 0, + } + } +} + +export const iconSizeRatio = 48 / 90 + +export const IconCircleRoot = styled(Box, { + shouldForwardProp: (prop) => prop !== 'colorConfig' && prop !== 'circleSize', +})<{ colorConfig: StatusColorConfig; circleSize: number }>( + ({ theme, colorConfig, circleSize }) => { + const svgSize = Math.round(circleSize * iconSizeRatio) + return { + backgroundColor: `color-mix(in srgb, rgb(${colorConfig.color}) ${colorConfig.mixAmount}%, ${theme.vars.palette.background.paper})`, + borderRadius: '50%', + width: circleSize, + height: circleSize, + display: 'grid', + position: 'relative', + placeItems: 'center', + '& > svg': { + color: `color-mix(in srgb, rgb(${colorConfig.color}) ${(1 - colorConfig.lightDarken) * 100}%, black)`, + width: svgSize, + height: svgSize, + }, + ...theme.applyStyles('dark', { + '& > svg': { + color: `color-mix(in srgb, rgb(${colorConfig.color}) ${(1 - colorConfig.darkDarken) * 100}%, black)`, + width: svgSize, + height: svgSize, + }, + }), + } + } +) diff --git a/packages/widget/src/components/IconCircle/IconCircle.tsx b/packages/widget/src/components/IconCircle/IconCircle.tsx new file mode 100644 index 000000000..93d3091d9 --- /dev/null +++ b/packages/widget/src/components/IconCircle/IconCircle.tsx @@ -0,0 +1,33 @@ +import type { BoxProps } from '@mui/material' +import { useTheme } from '@mui/material' +import type { StatusColor } from './IconCircle.style.js' +import { + getStatusColor, + IconCircleRoot, + iconCircleSize, +} from './IconCircle.style.js' +import { statusIcons } from './statusIcons.js' + +interface IconCircleProps extends Omit { + status: StatusColor + size?: number +} + +export const IconCircle: React.FC = ({ + status, + size = iconCircleSize, + ...rest +}) => { + const theme = useTheme() + const colorConfig = getStatusColor(status, theme) + const Icon = statusIcons[status] + + return ( + + + + ) +} + +export type { StatusColor } from './IconCircle.style.js' +export { iconCircleSize } from './IconCircle.style.js' diff --git a/packages/widget/src/components/IconCircle/statusIcons.ts b/packages/widget/src/components/IconCircle/statusIcons.ts new file mode 100644 index 000000000..25ba7aac7 --- /dev/null +++ b/packages/widget/src/components/IconCircle/statusIcons.ts @@ -0,0 +1,13 @@ +import Done from '@mui/icons-material/Done' +import ErrorRounded from '@mui/icons-material/ErrorRounded' +import InfoRounded from '@mui/icons-material/InfoRounded' +import WarningRounded from '@mui/icons-material/WarningRounded' +import type { SvgIcon } from '@mui/material' +import type { StatusColor } from './IconCircle.style.js' + +export const statusIcons: Record = { + success: Done, + error: ErrorRounded, + warning: WarningRounded, + info: InfoRounded, +} diff --git a/packages/widget/src/components/Messages/GasRefuelMessage.tsx b/packages/widget/src/components/Messages/GasRefuelMessage.tsx index 406d8c885..67437b4e7 100644 --- a/packages/widget/src/components/Messages/GasRefuelMessage.tsx +++ b/packages/widget/src/components/Messages/GasRefuelMessage.tsx @@ -1,5 +1,4 @@ import EvStation from '@mui/icons-material/EvStation' -import type { BoxProps } from '@mui/material' import { Box, Collapse, Typography } from '@mui/material' import type { ChangeEvent } from 'react' import { useTranslation } from 'react-i18next' @@ -9,7 +8,7 @@ import { useSettingsActions } from '../../stores/settings/useSettingsActions.js' import { AlertMessage } from './AlertMessage.js' import { InfoMessageSwitch } from './GasRefuelMessage.style.js' -export const GasRefuelMessage: React.FC = (props) => { +export const GasRefuelMessage = () => { const { t } = useTranslation() const { setValue } = useSettingsActions() @@ -55,7 +54,6 @@ export const GasRefuelMessage: React.FC = (props) => { /> } - {...props} > { } }) -export const ReverseTokensButtonEmpty = styled(Box)(({ theme }) => { - return { - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - margin: theme.spacing(1), - } -}) +export const ReverseTokensButtonEmpty = styled(Box)(({ theme }) => ({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + margin: theme.spacing(1), +})) diff --git a/packages/widget/src/components/ReverseTokensButton/ReverseTokensButton.tsx b/packages/widget/src/components/ReverseTokensButton/ReverseTokensButton.tsx index d2f776532..25a34f19c 100644 --- a/packages/widget/src/components/ReverseTokensButton/ReverseTokensButton.tsx +++ b/packages/widget/src/components/ReverseTokensButton/ReverseTokensButton.tsx @@ -1,4 +1,3 @@ -import ArrowDownward from '@mui/icons-material/ArrowDownward' import ArrowForward from '@mui/icons-material/ArrowForward' import { useAvailableChains } from '../../hooks/useAvailableChains.js' import { useToAddressAutoPopulate } from '../../hooks/useToAddressAutoPopulate.js' @@ -6,9 +5,7 @@ import { useToAddressReset } from '../../hooks/useToAddressReset.js' import { useFieldActions } from '../../stores/form/useFieldActions.js' import { IconCard, ReverseContainer } from './ReverseTokensButton.style.js' -export const ReverseTokensButton: React.FC<{ vertical?: boolean }> = ({ - vertical, -}) => { +export const ReverseTokensButton = () => { const { setFieldValue, getFieldValues } = useFieldActions() const { getChainById } = useAvailableChains() const { tryResetToAddress } = useToAddressReset() @@ -53,11 +50,7 @@ export const ReverseTokensButton: React.FC<{ vertical?: boolean }> = ({ return ( - {vertical ? ( - - ) : ( - - )} + ) diff --git a/packages/widget/src/components/RouteCard/RouteCard.style.ts b/packages/widget/src/components/RouteCard/RouteCard.style.ts deleted file mode 100644 index bffe01b61..000000000 --- a/packages/widget/src/components/RouteCard/RouteCard.style.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Box, styled } from '@mui/material' - -export const TokenContainer = styled(Box)(() => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - height: 40, -})) diff --git a/packages/widget/src/components/RouteCard/RouteCard.tsx b/packages/widget/src/components/RouteCard/RouteCard.tsx index 76611e8a9..ed810a8eb 100644 --- a/packages/widget/src/components/RouteCard/RouteCard.tsx +++ b/packages/widget/src/components/RouteCard/RouteCard.tsx @@ -1,23 +1,15 @@ import type { TokenAmount } from '@lifi/sdk' -import ExpandLess from '@mui/icons-material/ExpandLess' -import ExpandMore from '@mui/icons-material/ExpandMore' -import { Box, Collapse, Tooltip } from '@mui/material' -import type { MouseEventHandler } from 'react' -import { useMemo, useState } from 'react' +import { Box, Tooltip } from '@mui/material' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' import { HiddenUI, type RouteLabel } from '../../types/widget.js' import { getAccumulatedFeeCostsBreakdown } from '../../utils/fees.js' import type { CardProps } from '../Card/Card.js' import { Card } from '../Card/Card.js' -import { CardIconButton } from '../Card/CardIconButton.js' import { CardLabel, CardLabelTypography } from '../Card/CardLabel.js' -import { StepActions } from '../StepActions/StepActions.js' -import { Token } from '../Token/Token.js' import { getMatchingLabels } from './getMatchingLabels.js' -import { TokenContainer } from './RouteCard.style.js' -import { RouteCardEssentials } from './RouteCardEssentials.js' -import { RouteCardEssentialsExpanded } from './RouteCardEssentialsExpanded.js' +import { RouteToken } from './RouteToken.js' import type { RouteCardProps } from './types.js' export const RouteCard: React.FC< @@ -32,12 +24,6 @@ export const RouteCard: React.FC< const { t } = useTranslation() const { subvariant, subvariantOptions, routeLabels, hiddenUI } = useWidgetConfig() - const [cardExpanded, setCardExpanded] = useState(defaultExpanded) - - const handleExpand: MouseEventHandler = (e) => { - e.stopPropagation() - setCardExpanded((expanded) => !expanded) - } const token: TokenAmount = subvariant === 'custom' && subvariantOptions?.custom !== 'deposit' @@ -119,33 +105,13 @@ export const RouteCard: React.FC< ))} ) : null} - - - {!defaultExpanded ? ( - - {cardExpanded ? ( - - ) : ( - - )} - - ) : null} - - - {route.steps.map((step) => ( - - ))} - - - + ) diff --git a/packages/widget/src/components/RouteCard/RouteCardEssentials.style.tsx b/packages/widget/src/components/RouteCard/RouteCardEssentials.style.tsx new file mode 100644 index 000000000..e5ad43534 --- /dev/null +++ b/packages/widget/src/components/RouteCard/RouteCardEssentials.style.tsx @@ -0,0 +1,36 @@ +import { Box, styled, Typography } from '@mui/material' + +export const EssentialsContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flex: 1, + marginTop: theme.spacing(2), + gap: theme.spacing(1.5), +})) + +export const EssentialsRateTypography = styled(Typography)(({ theme }) => ({ + fontSize: 14, + fontWeight: 500, + color: theme.vars.palette.text.secondary, + cursor: 'pointer', + '&:hover': { + color: theme.vars.palette.text.primary, + }, + transition: theme.transitions.create(['color'], { + duration: theme.transitions.duration.enteringScreen, + easing: theme.transitions.easing.easeOut, + }), +})) + +export const EssentialsValueTypography = styled(Typography)(({ theme }) => ({ + fontSize: 14, + color: theme.vars.palette.text.primary, + fontWeight: 600, +})) + +export const EssentialsIconValueContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.75), +})) diff --git a/packages/widget/src/components/RouteCard/RouteCardEssentials.tsx b/packages/widget/src/components/RouteCard/RouteCardEssentials.tsx index 6e91f4ff3..78880ede4 100644 --- a/packages/widget/src/components/RouteCard/RouteCardEssentials.tsx +++ b/packages/widget/src/components/RouteCard/RouteCardEssentials.tsx @@ -1,18 +1,26 @@ import AccessTimeFilled from '@mui/icons-material/AccessTimeFilled' import LocalGasStationRounded from '@mui/icons-material/LocalGasStationRounded' -import { Box, Tooltip, Typography } from '@mui/material' +import { Box, Tooltip } from '@mui/material' import { useTranslation } from 'react-i18next' +import { useTokenRateText } from '../../hooks/useTokenRateText.js' import { getAccumulatedFeeCostsBreakdown } from '../../utils/fees.js' import { formatDuration } from '../../utils/format.js' import { FeeBreakdownTooltip } from '../FeeBreakdownTooltip.js' import { IconTypography } from '../IconTypography.js' -import { TokenRate } from '../TokenRate/TokenRate.js' +import { + EssentialsContainer, + EssentialsIconValueContainer, + EssentialsRateTypography, + EssentialsValueTypography, +} from './RouteCardEssentials.style.js' import type { RouteCardEssentialsProps } from './types.js' export const RouteCardEssentials: React.FC = ({ route, + showDuration = true, }) => { const { t, i18n } = useTranslation() + const { rateText, toggleRate } = useTokenRateText(route) const executionTimeSeconds = Math.floor( route.steps.reduce( (duration, step) => duration + step.estimate.executionDuration, @@ -23,77 +31,44 @@ export const RouteCardEssentials: React.FC = ({ const { gasCosts, feeCosts, combinedFeesUSD } = getAccumulatedFeeCostsBreakdown(route) return ( - - - + + + + {rateText} + + + - - + + - + {!combinedFeesUSD ? t('main.fees.free') : t('format.currency', { value: combinedFeesUSD, })} - - + + - - - - - - - {formatDuration(executionTimeSeconds, i18n.language)} - - - + {showDuration && ( + + + + + + + {formatDuration(executionTimeSeconds, i18n.language)} + + + + )} - + ) } diff --git a/packages/widget/src/components/RouteCard/RouteCardEssentialsExpanded.tsx b/packages/widget/src/components/RouteCard/RouteCardEssentialsExpanded.tsx deleted file mode 100644 index 021af1061..000000000 --- a/packages/widget/src/components/RouteCard/RouteCardEssentialsExpanded.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import Layers from '@mui/icons-material/Layers' -import { Box, Typography } from '@mui/material' -import { useTranslation } from 'react-i18next' -import { IconTypography } from '../IconTypography.js' -import type { RouteCardEssentialsProps } from './types.js' - -export const RouteCardEssentialsExpanded: React.FC< - RouteCardEssentialsProps -> = ({ route }) => { - const { t } = useTranslation() - return ( - - - - - - - {route.steps.length} - - - - - {t('tooltip.numberOfSteps')} - - - - ) -} diff --git a/packages/widget/src/components/RouteCard/RouteDetails.style.tsx b/packages/widget/src/components/RouteCard/RouteDetails.style.tsx new file mode 100644 index 000000000..4db63665d --- /dev/null +++ b/packages/widget/src/components/RouteCard/RouteDetails.style.tsx @@ -0,0 +1,34 @@ +import InfoOutlined from '@mui/icons-material/InfoOutlined' +import { Box, styled, Typography } from '@mui/material' + +export const DetailRow = styled(Box)(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: theme.spacing(1), +})) + +export const DetailLabelContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), +})) + +export const DetailLabel = styled(Typography)(({ theme }) => ({ + fontSize: 14, + fontWeight: 500, + lineHeight: 1.3334, + color: theme.vars.palette.text.secondary, +})) + +export const DetailValue = styled(Typography)(() => ({ + fontSize: 14, + fontWeight: 700, + lineHeight: 1.3334, + textAlign: 'right', +})) + +export const DetailInfoIcon = styled(InfoOutlined)(({ theme }) => ({ + fontSize: 16, + color: theme.vars.palette.text.secondary, +})) diff --git a/packages/widget/src/components/RouteCard/RouteDetails.tsx b/packages/widget/src/components/RouteCard/RouteDetails.tsx new file mode 100644 index 000000000..e1e18641d --- /dev/null +++ b/packages/widget/src/components/RouteCard/RouteDetails.tsx @@ -0,0 +1,224 @@ +import type { RouteExtended } from '@lifi/sdk' +import { useEthereumContext } from '@lifi/widget-provider' +import { Box, Tooltip } from '@mui/material' +import { useTranslation } from 'react-i18next' +import { useTokenRateText } from '../../hooks/useTokenRateText.js' +import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' +import { isRouteDone } from '../../stores/routes/utils.js' +import { getAccumulatedFeeCostsBreakdown } from '../../utils/fees.js' +import { + formatDuration, + formatTokenAmount, + formatTokenPrice, +} from '../../utils/format.js' +import { getPriceImpact } from '../../utils/getPriceImpact.js' +import { FeeBreakdownTooltip } from '../FeeBreakdownTooltip.js' +import { StepActions } from '../StepActions/StepActions.js' +import { + DetailInfoIcon, + DetailLabel, + DetailLabelContainer, + DetailRow, + DetailValue, +} from './RouteDetails.style.js' + +interface RouteDetailsProps { + route: RouteExtended +} + +export const RouteDetails = ({ route }: RouteDetailsProps) => { + const { t, i18n } = useTranslation() + const { rateText, toggleRate } = useTokenRateText(route) + + const { feeConfig } = useWidgetConfig() + + const { isGaslessStep } = useEthereumContext() + + const { gasCosts, feeCosts, gasCostUSD, feeCostUSD } = + getAccumulatedFeeCostsBreakdown(route) + + const priceImpact = getPriceImpact({ + fromAmount: BigInt(route.fromAmount), + toAmount: BigInt(route.toAmount), + fromToken: route.fromToken, + toToken: route.toToken, + }) + + let feeAmountUSD = 0 + let feePercentage = 0 + + const feeCollectionStep = route.steps[0].includedSteps.find( + (includedStep) => includedStep.tool === 'feeCollection' + ) + + if (feeCollectionStep) { + const estimatedFromAmount = + BigInt(feeCollectionStep.estimate.fromAmount) - + BigInt(feeCollectionStep.estimate.toAmount) + + feeAmountUSD = formatTokenPrice( + estimatedFromAmount, + feeCollectionStep.action.fromToken.priceUSD, + feeCollectionStep.action.fromToken.decimals + ) + + feePercentage = + feeCollectionStep.estimate.feeCosts?.reduce( + (percentage, feeCost) => + percentage + Number.parseFloat(feeCost.percentage || '0'), + 0 + ) ?? 0 + } + + const executionTimeSeconds = Math.floor( + route.steps.reduce( + (duration, step) => duration + step.estimate.executionDuration, + 0 + ) + ) + + const hasGaslessSupport = route.steps.every((step) => isGaslessStep?.(step)) + + const showIntegratorFeeCollectionDetails = + (feeAmountUSD || Number.isFinite(feeConfig?.fee)) && !hasGaslessSupport + + return ( + + + + + {t('main.fees.network')} + + + + + + {!gasCostUSD + ? t('main.fees.free') + : t('format.currency', { + value: gasCostUSD, + })} + + + {feeCosts.length ? ( + + + {t('main.fees.provider')} + + + + + + {t('format.currency', { + value: feeCostUSD, + })} + + + ) : null} + {showIntegratorFeeCollectionDetails ? ( + + + + {feeConfig?.name || t('main.fees.defaultIntegrator')} + {feeConfig?.showFeePercentage && ( + <> ({t('format.percent', { value: feePercentage })}) + )} + + {feeConfig?.showFeeTooltip && + (feeConfig?.name || feeConfig?.feeTooltipComponent) ? ( + + + + ) : null} + + + {t('format.currency', { + value: feeAmountUSD, + })} + + + ) : null} + + + {t('main.priceImpact')} + + + + + + {t('format.percent', { + value: priceImpact, + usePlusSign: true, + })} + + + {!isRouteDone(route) ? ( + <> + + + {t('main.maxSlippage')} + + + + + + {route.steps[0].action.slippage + ? t('format.percent', { + value: route.steps[0].action.slippage, + }) + : t('button.auto')} + + + + + {t('main.minReceived')} + + + + + + {t('format.tokenAmount', { + value: formatTokenAmount( + BigInt(route.toAmountMin), + route.toToken.decimals + ), + })}{' '} + {route.toToken.symbol} + + + + ) : null} + + + {t('main.exchangeRate')} + + + + + + {rateText} + + + + + {t('main.estimatedTime')} + + + + + + {formatDuration(executionTimeSeconds, i18n.language)} + + + + ) +} diff --git a/packages/widget/src/components/RouteCard/RouteToken.style.tsx b/packages/widget/src/components/RouteCard/RouteToken.style.tsx new file mode 100644 index 000000000..862273717 --- /dev/null +++ b/packages/widget/src/components/RouteCard/RouteToken.style.tsx @@ -0,0 +1,7 @@ +import { Box, styled } from '@mui/material' + +export const TokenContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), +})) diff --git a/packages/widget/src/components/RouteCard/RouteToken.tsx b/packages/widget/src/components/RouteCard/RouteToken.tsx new file mode 100644 index 000000000..df2652804 --- /dev/null +++ b/packages/widget/src/components/RouteCard/RouteToken.tsx @@ -0,0 +1,70 @@ +import type { RouteExtended, TokenAmount } from '@lifi/sdk' +import ExpandLess from '@mui/icons-material/ExpandLess' +import ExpandMore from '@mui/icons-material/ExpandMore' +import { Box, Collapse } from '@mui/material' +import { type MouseEventHandler, useState } from 'react' +import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' +import { HiddenUI } from '../../types/widget.js' +import { CardIconButton } from '../Card/CardIconButton.js' +import { Token } from '../Token/Token.js' +import { RouteCardEssentials } from './RouteCardEssentials.js' +import { RouteDetails } from './RouteDetails.js' +import { TokenContainer } from './RouteToken.style.js' + +interface RouteTokenProps { + route: RouteExtended + token: TokenAmount + impactToken?: TokenAmount + defaultExpanded?: boolean + showEssentials?: boolean +} + +export const RouteToken = ({ + route, + token, + impactToken, + defaultExpanded, + showEssentials, +}: RouteTokenProps) => { + const { hiddenUI } = useWidgetConfig() + + const [cardExpanded, setCardExpanded] = useState(defaultExpanded) + + const handleExpand: MouseEventHandler = (e) => { + e.stopPropagation() + setCardExpanded((expanded) => !expanded) + } + + return ( + + + + {!defaultExpanded ? ( + + {cardExpanded ? ( + + ) : ( + + )} + + ) : null} + + + + + {showEssentials ? ( + + + + ) : null} + + ) +} diff --git a/packages/widget/src/components/RouteCard/RouteTokens.tsx b/packages/widget/src/components/RouteCard/RouteTokens.tsx new file mode 100644 index 000000000..67835b250 --- /dev/null +++ b/packages/widget/src/components/RouteCard/RouteTokens.tsx @@ -0,0 +1,55 @@ +import type { RouteExtended } from '@lifi/sdk' +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward' +import { Box } from '@mui/material' +import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' +import { Token } from '../Token/Token.js' +import { RouteToken } from './RouteToken.js' + +export const RouteTokens: React.FC<{ + route: RouteExtended + showEssentials?: boolean +}> = ({ route, showEssentials }) => { + const { subvariant, defaultUI } = useWidgetConfig() + + const fromToken = { + ...route.steps[0].action.fromToken, + amount: BigInt(route.steps[0].action.fromAmount), + } + + const lastStepIndex = route.steps.length - 1 + const toToken = { + ...(route.steps[lastStepIndex].execution?.toToken ?? + route.steps[lastStepIndex].action.toToken), + amount: route.steps[lastStepIndex].execution?.toAmount + ? BigInt(route.steps[lastStepIndex].execution.toAmount) + : subvariant === 'custom' + ? BigInt(route.toAmount) + : BigInt(route.steps[lastStepIndex].estimate.toAmount), + } + + return ( + + {fromToken ? : null} + + + + {toToken ? ( + + ) : null} + + ) +} diff --git a/packages/widget/src/components/RouteCard/types.ts b/packages/widget/src/components/RouteCard/types.ts index 09c79c62a..bca2ec801 100644 --- a/packages/widget/src/components/RouteCard/types.ts +++ b/packages/widget/src/components/RouteCard/types.ts @@ -1,4 +1,4 @@ -import type { Route } from '@lifi/sdk' +import type { Route, RouteExtended } from '@lifi/sdk' export interface RouteCardProps { route: Route @@ -8,7 +8,8 @@ export interface RouteCardProps { } export interface RouteCardEssentialsProps { - route: Route + route: RouteExtended + showDuration?: boolean } export interface RouteCardSkeletonProps { diff --git a/packages/widget/src/components/Routes/Routes.tsx b/packages/widget/src/components/Routes/Routes.tsx index 0434c4476..fc6937dfd 100644 --- a/packages/widget/src/components/Routes/Routes.tsx +++ b/packages/widget/src/components/Routes/Routes.tsx @@ -5,7 +5,6 @@ import { useRoutes } from '../../hooks/useRoutes.js' import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' import { navigationRoutes } from '../../utils/navigationRoutes.js' import { ButtonTertiary } from '../ButtonTertiary.js' -import type { CardProps } from '../Card/Card.js' import { Card } from '../Card/Card.js' import { CardTitle } from '../Card/CardTitle.js' import { ProgressToNextUpdate } from '../ProgressToNextUpdate.js' @@ -13,7 +12,7 @@ import { RouteCard } from '../RouteCard/RouteCard.js' import { RouteCardSkeleton } from '../RouteCard/RouteCardSkeleton.js' import { RouteNotFoundCard } from '../RouteCard/RouteNotFoundCard.js' -export const Routes: React.FC = (props) => { +export const Routes = () => { const { t } = useTranslation() const navigate = useNavigate() const { subvariant, subvariantOptions, useRecommendedRoute } = @@ -51,7 +50,7 @@ export const Routes: React.FC = (props) => { : t('header.receive') return ( - + {title} = (props) => { - const prefersNarrowView = useMediaQuery((theme: Theme) => - theme.breakpoints.down('sm') - ) +export const SelectChainAndToken = () => { const { disabledUI, hiddenUI, subvariant } = useWidgetConfig() - const [fromChain, toChain, fromToken, toToken] = useFieldValues( - 'fromChain', - 'toChain', - 'fromToken', - 'toToken' - ) - const hiddenReverse = subvariant === 'refuel' || disabledUI?.includes(DisabledUI.FromToken) || @@ -32,40 +20,22 @@ export const SelectChainAndToken: React.FC = (props) => { const hiddenToToken = subvariant === 'custom' || hiddenUI?.includes(HiddenUI.ToToken) - const isCompact = - !!fromChain && - !!toChain && - !!fromToken && - !!toToken && - !prefersNarrowView && - !hiddenToToken && - !hiddenFromToken + const showReverseButton = !hiddenToToken && !hiddenFromToken return ( - + {!hiddenFromToken ? ( - + ) : null} - {!hiddenToToken && !hiddenFromToken ? ( + {showReverseButton ? ( !hiddenReverse ? ( - + ) : ( ) ) : null} {!hiddenToToken ? ( - + ) : null} ) diff --git a/packages/widget/src/components/SelectTokenButton/SelectTokenButton.style.tsx b/packages/widget/src/components/SelectTokenButton/SelectTokenButton.style.tsx index cda1e94c0..703f7ed62 100644 --- a/packages/widget/src/components/SelectTokenButton/SelectTokenButton.style.tsx +++ b/packages/widget/src/components/SelectTokenButton/SelectTokenButton.style.tsx @@ -8,69 +8,39 @@ import { Card } from '../Card/Card.js' import { CardHeader } from '../Card/CardHeader.js' export const SelectTokenCardHeader = styled(CardHeader, { - shouldForwardProp: (prop) => - !['selected', 'compact'].includes(prop as string), -})<{ selected?: boolean; compact?: boolean }>( - ({ theme, selected, compact }) => ({ - padding: theme.spacing(2), - [`.${cardHeaderClasses.title}`]: { - color: theme.vars.palette.text.secondary, - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden', - width: 256, - fontSize: compact && !selected ? 16 : 18, - fontWeight: 500, - [theme.breakpoints.down(theme.breakpoints.values.sm)]: { - width: 224, - }, - [theme.breakpoints.down(theme.breakpoints.values.xs)]: { - width: 180, - fontSize: 16, - }, - }, - [`.${cardHeaderClasses.subheader}`]: { - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden', - width: 256, - [theme.breakpoints.down(theme.breakpoints.values.sm)]: { - width: 224, - }, - [theme.breakpoints.down(theme.breakpoints.values.xs)]: { - width: 180, - }, + shouldForwardProp: (prop) => !['selected'].includes(prop as string), +})<{ selected?: boolean }>(({ theme, selected }) => ({ + padding: theme.spacing(2), + [`.${cardHeaderClasses.title}`]: { + color: theme.vars.palette.text.secondary, + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + width: 96, + fontSize: !selected ? 16 : 18, + fontWeight: 500, + [theme.breakpoints.down(theme.breakpoints.values.xs)]: { + fontSize: 16, }, - variants: [ - { - props: ({ selected }) => selected, - style: { - [`.${cardHeaderClasses.title}`]: { - color: theme.vars.palette.text.primary, - fontWeight: 600, - }, + }, + [`.${cardHeaderClasses.subheader}`]: { + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + width: 96, + }, + variants: [ + { + props: ({ selected }) => selected, + style: { + [`.${cardHeaderClasses.title}`]: { + color: theme.vars.palette.text.primary, + fontWeight: 600, }, }, - { - props: ({ compact }) => compact, - style: { - [`.${cardHeaderClasses.title}`]: { - width: 96, - [theme.breakpoints.down(theme.breakpoints.values.sm)]: { - width: 96, - }, - }, - [`.${cardHeaderClasses.subheader}`]: { - width: 96, - [theme.breakpoints.down(theme.breakpoints.values.sm)]: { - width: 96, - }, - }, - }, - }, - ], - }) -) + }, + ], +})) export const SelectTokenCard = styled(Card)(({ theme }) => { const cardVariant = theme.components?.MuiCard?.defaultProps?.variant @@ -87,14 +57,13 @@ export const SelectTokenCard = styled(Card)(({ theme }) => { }) export const CardContent = styled(MuiCardContent, { - shouldForwardProp: (prop) => - !['formType', 'compact', 'mask'].includes(prop as string), -})<{ formType: FormType; compact: boolean; mask?: boolean }>( - ({ theme, formType, compact, mask = true }) => { + shouldForwardProp: (prop) => !['formType', 'mask'].includes(prop as string), +})<{ formType: FormType; mask?: boolean }>( + ({ theme, formType, mask = true }) => { const cardVariant = theme.components?.MuiCard?.defaultProps?.variant const direction = formType === 'to' ? '-8px' : 'calc(100% + 8px)' - const horizontal = compact ? direction : '50%' - const vertical = compact ? '50%' : direction + const horizontal = direction + const vertical = '50%' return { padding: 0, transition: theme.transitions.create(['background-color'], { diff --git a/packages/widget/src/components/SelectTokenButton/SelectTokenButton.tsx b/packages/widget/src/components/SelectTokenButton/SelectTokenButton.tsx index ad8e3ea86..87894d9c1 100644 --- a/packages/widget/src/components/SelectTokenButton/SelectTokenButton.tsx +++ b/packages/widget/src/components/SelectTokenButton/SelectTokenButton.tsx @@ -2,7 +2,6 @@ import { Skeleton } from '@mui/material' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useChain } from '../../hooks/useChain.js' -import { useSwapOnly } from '../../hooks/useSwapOnly.js' import { useToken } from '../../hooks/useToken.js' import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' import { useChainOrderStore } from '../../stores/chains/ChainOrderStore.js' @@ -10,7 +9,6 @@ import type { ChainOrderState } from '../../stores/chains/types.js' import type { FormTypeProps } from '../../stores/form/types.js' import { FormKeyHelper } from '../../stores/form/types.js' import { useFieldValues } from '../../stores/form/useFieldValues.js' -import { HiddenUI } from '../../types/widget.js' import { navigationRoutes } from '../../utils/navigationRoutes.js' import { AvatarBadgedDefault, AvatarBadgedSkeleton } from '../Avatar/Avatar.js' import { TokenAvatar } from '../Avatar/TokenAvatar.js' @@ -23,14 +21,12 @@ import { export const SelectTokenButton: React.FC< FormTypeProps & { - compact: boolean hiddenReverse?: boolean } -> = ({ formType, compact, hiddenReverse }) => { +> = ({ formType, hiddenReverse }) => { const { t } = useTranslation() const navigate = useNavigate() - const { disabledUI, subvariant, hiddenUI } = useWidgetConfig() - const swapOnly = useSwapOnly() + const { disabledUI, subvariant } = useWidgetConfig() const tokenKey = FormKeyHelper.getTokenKey(formType) const [chainId, tokenAddress] = useFieldValues( FormKeyHelper.getChainKey(formType), @@ -56,27 +52,20 @@ export const SelectTokenButton: React.FC< const isSelected = !!(chain && token) const onClick = !disabledUI?.includes(tokenKey) ? handleClick : undefined - const defaultPlaceholder = - formType === 'to' && subvariant === 'refuel' - ? t('main.selectChain') - : (formType === 'to' && swapOnly) || - hiddenUI?.includes(HiddenUI.ChainSelect) - ? t('main.selectToken') - : t('main.selectChainAndToken') + const defaultPlaceholder = `${t('main.select')}...` const cardTitle: string = formType === 'from' && subvariant === 'custom' ? t('header.payWith') : t(`main.${formType}`) return ( - + {cardTitle} {chainId && tokenAddress && (isChainLoading || isTokenLoading) ? ( } title={} subheader={} - compact={compact} /> ) : ( )} diff --git a/packages/widget/src/components/SendToWallet/SendToWallet.style.tsx b/packages/widget/src/components/SendToWallet/SendToWallet.style.tsx deleted file mode 100644 index 895044ad8..000000000 --- a/packages/widget/src/components/SendToWallet/SendToWallet.style.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { cardHeaderClasses, styled } from '@mui/material' -import { CardHeader } from '../Card/CardHeader.js' - -export const SendToWalletCardHeader = styled(CardHeader, { - shouldForwardProp: (prop) => !['selected'].includes(prop as string), -})<{ selected?: boolean }>(({ theme }) => ({ - width: '100%', - [`.${cardHeaderClasses.title}`]: { - color: theme.vars.palette.text.secondary, - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden', - fontWeight: 500, - width: 254, - [theme.breakpoints.down(theme.breakpoints.values.sm)]: { - width: 224, - }, - }, - [`.${cardHeaderClasses.subheader}`]: { - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden', - width: 254, - [theme.breakpoints.down(theme.breakpoints.values.sm)]: { - width: 224, - }, - }, - [`.${cardHeaderClasses.action}`]: { - marginRight: 0, - }, - [`.${cardHeaderClasses.action} > button`]: { - fontSize: 16, - }, - variants: [ - { - props: ({ selected }) => selected, - style: { - [`.${cardHeaderClasses.title}`]: { - color: theme.vars.palette.text.primary, - fontWeight: 600, - width: 224, - [theme.breakpoints.down(theme.breakpoints.values.sm)]: { - width: 192, - }, - }, - [`.${cardHeaderClasses.subheader}`]: { - width: 224, - [theme.breakpoints.down(theme.breakpoints.values.sm)]: { - width: 192, - }, - }, - }, - }, - ], -})) diff --git a/packages/widget/src/components/SendToWallet/SendToWalletButton.tsx b/packages/widget/src/components/SendToWallet/SendToWalletButton.tsx deleted file mode 100644 index b1c168c36..000000000 --- a/packages/widget/src/components/SendToWallet/SendToWalletButton.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { useAccount } from '@lifi/wallet-management' -import { useChainTypeFromAddress } from '@lifi/widget-provider' -import CloseRounded from '@mui/icons-material/CloseRounded' -import { Box, Collapse } from '@mui/material' -import { useNavigate } from '@tanstack/react-router' -import { type MouseEventHandler, useEffect, useRef } from 'react' -import { useTranslation } from 'react-i18next' -import { useToAddressRequirements } from '../../hooks/useToAddressRequirements.js' -import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' -import { useBookmarkActions } from '../../stores/bookmarks/useBookmarkActions.js' -import { useBookmarks } from '../../stores/bookmarks/useBookmarks.js' -import { useFieldActions } from '../../stores/form/useFieldActions.js' -import { useFieldValues } from '../../stores/form/useFieldValues.js' -import { useSendToWalletStore } from '../../stores/settings/useSendToWalletStore.js' -import { DisabledUI, HiddenUI } from '../../types/widget.js' -import { defaultChainIdsByType } from '../../utils/chainType.js' -import { navigationRoutes } from '../../utils/navigationRoutes.js' -import { shortenAddress } from '../../utils/wallet.js' -import { AccountAvatar } from '../Avatar/AccountAvatar.js' -import type { CardProps } from '../Card/Card.js' -import { Card } from '../Card/Card.js' -import { CardIconButton } from '../Card/CardIconButton.js' -import { CardTitle } from '../Card/CardTitle.js' -import { SendToWalletCardHeader } from './SendToWallet.style.js' - -export const SendToWalletButton: React.FC = (props) => { - const { t } = useTranslation() - const navigate = useNavigate() - const { - disabledUI, - hiddenUI, - toAddress, - toAddresses, - subvariant, - subvariantOptions, - } = useWidgetConfig() - const { showSendToWallet } = useSendToWalletStore((state) => state) - const [toAddressFieldValue, toChainId, toTokenAddress] = useFieldValues( - 'toAddress', - 'toChain', - 'toToken' - ) - const { setFieldValue } = useFieldActions() - const { selectedBookmark } = useBookmarks() - const { setSelectedBookmark } = useBookmarkActions() - const { accounts } = useAccount() - const { requiredToAddress } = useToAddressRequirements() - const { getChainTypeFromAddress } = useChainTypeFromAddress() - const disabledToAddress = disabledUI?.includes(DisabledUI.ToAddress) - const hiddenToAddress = hiddenUI?.includes(HiddenUI.ToAddress) - - const address = toAddressFieldValue - ? shortenAddress(toAddressFieldValue) - : t('sendToWallet.enterAddress', { - context: 'short', - }) - - const matchingConnectedAccount = accounts.find( - (account) => account.address === toAddressFieldValue - ) - - const chainType = !matchingConnectedAccount - ? selectedBookmark?.chainType || - (toAddressFieldValue - ? getChainTypeFromAddress(toAddressFieldValue) - : undefined) - : undefined - - const chainId = - toChainId && toTokenAddress - ? toChainId - : matchingConnectedAccount - ? matchingConnectedAccount.chainId - : chainType - ? defaultChainIdsByType[chainType] - : undefined - - const isConnectedAccount = - selectedBookmark?.isConnectedAccount && - matchingConnectedAccount?.isConnected - const connectedAccountName = matchingConnectedAccount?.connector?.name - const bookmarkName = selectedBookmark?.name - - const headerTitle = isConnectedAccount - ? connectedAccountName || address - : bookmarkName || connectedAccountName || address - - const headerSubheader = - isConnectedAccount || bookmarkName || connectedAccountName ? address : null - - const disabledForChanges = Boolean(toAddressFieldValue) && disabledToAddress - - const handleOnClick = () => { - navigate({ - to: toAddresses?.length - ? navigationRoutes.configuredWallets - : navigationRoutes.sendToWallet, - }) - } - - const clearSelectedBookmark: MouseEventHandler = (e) => { - e.stopPropagation() - setFieldValue('toAddress', '', { isTouched: true }) - setSelectedBookmark() - } - - // The collapse opens instantly on first page load/component mount when there is an address to display - // After which it needs an animated transition for open and closing. - // collapseTransitionTime is used specify the transition time for opening and closing - const collapseTransitionTime = useRef(0) - - // Timeout is needed here to push the collapseTransitionTime update to the back of the event loop so that it doesn't fired too quickly - useEffect(() => { - const timeout = setTimeout(() => { - collapseTransitionTime.current = 225 - }, 0) - return () => clearTimeout(timeout) - }, []) - - const isOpenCollapse = - !hiddenToAddress && (requiredToAddress || showSendToWallet) - - const title = - subvariant === 'custom' && subvariantOptions?.custom === 'deposit' - ? t('header.depositTo') - : t('header.sendToWallet') - - return ( - - - {title} - - - } - title={headerTitle} - subheader={headerSubheader} - selected={!!toAddressFieldValue || disabledToAddress} - action={ - !!toAddressFieldValue && !disabledForChanges ? ( - - - - ) : null - } - /> - - - - ) -} diff --git a/packages/widget/src/components/SendToWallet/SendToWalletExpandButton.tsx b/packages/widget/src/components/SendToWallet/SendToWalletExpandButton.tsx deleted file mode 100644 index effb0d6dd..000000000 --- a/packages/widget/src/components/SendToWallet/SendToWalletExpandButton.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import Wallet from '@mui/icons-material/Wallet' -import { Button, Tooltip } from '@mui/material' -import { useTranslation } from 'react-i18next' -import { useToAddressRequirements } from '../../hooks/useToAddressRequirements.js' -import { useWidgetEvents } from '../../hooks/useWidgetEvents.js' -import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' -import { useBookmarkActions } from '../../stores/bookmarks/useBookmarkActions.js' -import { useFieldActions } from '../../stores/form/useFieldActions.js' -import { useFieldValues } from '../../stores/form/useFieldValues.js' -import { - sendToWalletStore, - useSendToWalletStore, -} from '../../stores/settings/useSendToWalletStore.js' -import { WidgetEvent } from '../../types/events.js' -import { DisabledUI, HiddenUI } from '../../types/widget.js' - -export const SendToWalletExpandButton: React.FC = () => { - const { t } = useTranslation() - const { disabledUI, hiddenUI } = useWidgetConfig() - const { setFieldValue } = useFieldActions() - const { setSelectedBookmark } = useBookmarkActions() - const emitter = useWidgetEvents() - const { showSendToWallet, setSendToWallet } = useSendToWalletStore( - (state) => state - ) - const [toAddressFieldValue] = useFieldValues('toAddress') - const { requiredToAddress } = useToAddressRequirements() - - if (requiredToAddress || hiddenUI?.includes(HiddenUI.ToAddress)) { - return null - } - - const handleClick = () => { - if (showSendToWallet && !disabledUI?.includes(DisabledUI.ToAddress)) { - setFieldValue('toAddress', '', { isTouched: true }) - setSelectedBookmark() - } - setSendToWallet(!showSendToWallet) - emitter.emit( - WidgetEvent.SendToWalletToggled, - sendToWalletStore.getState().showSendToWallet - ) - } - - const buttonVariant = - showSendToWallet || Boolean(toAddressFieldValue) ? 'contained' : 'text' - - return ( - - - - ) -} diff --git a/packages/widget/src/components/Step/CircularProgress.style.tsx b/packages/widget/src/components/Step/CircularProgress.style.tsx deleted file mode 100644 index 34a9723a0..000000000 --- a/packages/widget/src/components/Step/CircularProgress.style.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import type { ExecutionActionStatus, Substatus } from '@lifi/sdk' -import type { Theme } from '@mui/material' -import { - Box, - circularProgressClasses, - keyframes, - CircularProgress as MuiCircularProgress, - styled, -} from '@mui/material' - -const getStatusColor = ( - theme: Theme, - status?: ExecutionActionStatus, - substatus?: Substatus -) => { - switch (status) { - case 'ACTION_REQUIRED': - case 'MESSAGE_REQUIRED': - case 'RESET_REQUIRED': - return `rgba(${theme.vars.palette.info.mainChannel} / 0.12)` - case 'DONE': - if (substatus === 'PARTIAL' || substatus === 'REFUNDED') { - return `rgba(${theme.vars.palette.warning.mainChannel} / 0.48)` - } - return `rgba(${theme.vars.palette.success.mainChannel} / 0.12)` - case 'FAILED': - return `rgba(${theme.vars.palette.error.mainChannel} / 0.12)` - default: - return null - } -} - -export const CircularIcon = styled(Box, { - shouldForwardProp: (prop: string) => !['status', 'substatus'].includes(prop), -})<{ status?: ExecutionActionStatus; substatus?: Substatus }>( - ({ theme, status, substatus }) => { - const statusColor = getStatusColor(theme, status, substatus) - const isSpecialStatus = [ - 'ACTION_REQUIRED', - 'MESSAGE_REQUIRED', - 'RESET_REQUIRED', - 'DONE', - 'FAILED', - ].includes(status!) - - return { - backgroundColor: isSpecialStatus - ? statusColor! - : theme.vars.palette.background.paper, - borderStyle: 'solid', - borderColor: statusColor || theme.vars.palette.grey[300], - borderWidth: !isSpecialStatus ? 3 : 0, - display: 'grid', - position: 'relative', - placeItems: 'center', - width: 40, - height: 40, - borderRadius: '50%', - ...theme.applyStyles('dark', { - borderColor: statusColor || theme.vars.palette.grey[800], - }), - } - } -) - -const circleAnimation = keyframes` - 0% { - stroke-dashoffset: 129; - transform: rotate(0); - } - 50% { - stroke-dashoffset: 56; - transform: rotate(45deg); - }; - 100% { - stroke-dashoffset: 129; - transform: rotate(360deg); - } -` - -// This `styled()` function invokes keyframes. `styled-components` only supports keyframes -// in string templates. Do not convert these styles in JS object as it will break. -export const CircularProgressPending = styled(MuiCircularProgress)` - color: ${({ theme }) => theme.vars.palette.primary.main}; - ${({ theme }) => - theme.applyStyles('dark', { - color: theme.vars.palette.primary.light, - })} - animation-duration: 3s; - position: absolute; - .${circularProgressClasses.circle} { - animation-duration: 2s; - animation-timing-function: linear; - animation-name: ${circleAnimation}; - stroke-dasharray: 129; - stroke-dashoffset: 129; - stroke-linecap: round; - transform-origin: 100% 100%; - } -` diff --git a/packages/widget/src/components/Step/CircularProgress.tsx b/packages/widget/src/components/Step/CircularProgress.tsx deleted file mode 100644 index 9ea60cc24..000000000 --- a/packages/widget/src/components/Step/CircularProgress.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { ExecutionAction } from '@lifi/sdk' -import Done from '@mui/icons-material/Done' -import ErrorRounded from '@mui/icons-material/ErrorRounded' -import InfoRounded from '@mui/icons-material/InfoRounded' -import WarningRounded from '@mui/icons-material/WarningRounded' -import { - CircularIcon, - CircularProgressPending, -} from './CircularProgress.style.js' - -export function CircularProgress({ action }: { action: ExecutionAction }) { - return ( - - {action.status === 'STARTED' || action.status === 'PENDING' ? ( - - ) : null} - {action.status === 'ACTION_REQUIRED' || - action.status === 'MESSAGE_REQUIRED' || - action.status === 'RESET_REQUIRED' ? ( - - ) : null} - {action.status === 'DONE' && - (action.substatus === 'PARTIAL' || action.substatus === 'REFUNDED') ? ( - ({ - position: 'absolute', - fontSize: '1.5rem', - color: `color-mix(in srgb, ${theme.vars.palette.warning.main} 68%, black)`, - })} - /> - ) : action.status === 'DONE' ? ( - - ) : null} - {action.status === 'FAILED' ? ( - - ) : null} - - ) -} diff --git a/packages/widget/src/components/Step/DestinationWalletAddress.tsx b/packages/widget/src/components/Step/DestinationWalletAddress.tsx deleted file mode 100644 index c45d4f01a..000000000 --- a/packages/widget/src/components/Step/DestinationWalletAddress.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type { LiFiStepExtended } from '@lifi/sdk' -import LinkRounded from '@mui/icons-material/LinkRounded' -import Wallet from '@mui/icons-material/Wallet' -import { Box, Link, Typography } from '@mui/material' -import { useTranslation } from 'react-i18next' -import { CardIconButton } from '../Card/CardIconButton.js' -import { CircularIcon } from './CircularProgress.style.js' - -export const DestinationWalletAddress: React.FC<{ - step: LiFiStepExtended - toAddress: string - toAddressLink: string -}> = ({ step, toAddress, toAddressLink }) => { - const { t } = useTranslation() - const isDone = step.execution?.status === 'DONE' - return ( - - - - - - - {isDone - ? t('main.sentToAddress', { - address: toAddress, - }) - : t('main.sendToAddress', { - address: toAddress, - })} - - - - - - - ) -} diff --git a/packages/widget/src/components/Step/Step.tsx b/packages/widget/src/components/Step/Step.tsx deleted file mode 100644 index 4ab8cd5ac..000000000 --- a/packages/widget/src/components/Step/Step.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type { - ExecutionActionType, - LiFiStepExtended, - TokenAmount, -} from '@lifi/sdk' -import { Box } from '@mui/material' -import { useTranslation } from 'react-i18next' -import { Card } from '../../components/Card/Card.js' -import { CardTitle } from '../../components/Card/CardTitle.js' -import { StepActions } from '../../components/StepActions/StepActions.js' -import { Token } from '../../components/Token/Token.js' -import { useExplorer } from '../../hooks/useExplorer.js' -import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' -import { prepareActions } from '../../utils/prepareActions.js' -import { shortenAddress } from '../../utils/wallet.js' -import { StepTimer } from '../Timer/StepTimer.js' -import { DestinationWalletAddress } from './DestinationWalletAddress.js' -import { StepAction } from './StepAction.js' - -export const Step: React.FC<{ - step: LiFiStepExtended - fromToken?: TokenAmount - toToken?: TokenAmount - impactToken?: TokenAmount - toAddress?: string -}> = ({ step, fromToken, toToken, impactToken, toAddress }) => { - const { t } = useTranslation() - const { subvariant, subvariantOptions } = useWidgetConfig() - const { getAddressLink } = useExplorer() - - // If execution status is failed outside of actions scope, - // show a synthetic action to represent the failed execution - const actions = step.execution?.actions ?? [] - const failedWithoutActions = - step.execution?.status === 'FAILED' && - !actions.some((a) => a.status === 'FAILED') - - const getCardTitle = () => { - const hasBridgeStep = step.includedSteps.some( - (step) => step.type === 'cross' - ) - const hasSwapStep = step.includedSteps.some((step) => step.type === 'swap') - const hasCustomStep = step.includedSteps.some( - (step) => step.type === 'custom' - ) - - const isCustomVariant = hasCustomStep && subvariant === 'custom' - - if (hasBridgeStep && hasSwapStep) { - return isCustomVariant - ? subvariantOptions?.custom === 'deposit' - ? t('main.stepBridgeAndDeposit') - : t('main.stepBridgeAndBuy') - : t('main.stepSwapAndBridge') - } - if (hasBridgeStep) { - return isCustomVariant - ? subvariantOptions?.custom === 'deposit' - ? t('main.stepBridgeAndDeposit') - : t('main.stepBridgeAndBuy') - : t('main.stepBridge') - } - if (hasSwapStep) { - return isCustomVariant - ? subvariantOptions?.custom === 'deposit' - ? t('main.stepSwapAndDeposit') - : t('main.stepSwapAndBuy') - : t('main.stepSwap') - } - return isCustomVariant - ? subvariantOptions?.custom === 'deposit' - ? t('main.stepDeposit') - : t('main.stepBuy') - : t('main.stepSwap') - } - - const formattedToAddress = shortenAddress(toAddress) - const toAddressLink = toAddress - ? getAddressLink(toAddress, step.action.toChainId) - : undefined - - return ( - - - {getCardTitle()} - - - - - - {fromToken ? : null} - - {prepareActions(step.execution?.actions ?? []).map( - (actionsGroup, index) => ( - - ) - )} - {failedWithoutActions ? ( - - ) : null} - {formattedToAddress && toAddressLink ? ( - - ) : null} - {toToken ? ( - - ) : null} - - - ) -} diff --git a/packages/widget/src/components/Step/StepAction.tsx b/packages/widget/src/components/Step/StepAction.tsx deleted file mode 100644 index 7cc8a15ca..000000000 --- a/packages/widget/src/components/Step/StepAction.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { ExecutionAction, LiFiStepExtended } from '@lifi/sdk' -import OpenInNewRounded from '@mui/icons-material/OpenInNewRounded' -import { Box, Link, Typography } from '@mui/material' -import { useActionMessage } from '../../hooks/useActionMessage.js' -import { useExplorer } from '../../hooks/useExplorer.js' -import { CardIconButton } from '../Card/CardIconButton.js' -import { CircularProgress } from './CircularProgress.js' - -export const StepAction: React.FC<{ - step: LiFiStepExtended - actionsGroup: ExecutionAction[] -}> = ({ step, actionsGroup }) => { - const action = actionsGroup.at(-1) - const { title, message } = useActionMessage(step, action) - const { getTransactionLink } = useExplorer() - - if (!action) { - return null - } - - const transactionLink = action.txHash - ? getTransactionLink({ - txHash: action.txHash, - chain: action.chainId, - }) - : action.txLink - ? getTransactionLink({ - txLink: action.txLink, - chain: action.chainId, - }) - : undefined - - return ( - - - - - {title} - - {transactionLink ? ( - - - - ) : null} - - {message ? ( - - {message} - - ) : null} - - ) -} diff --git a/packages/widget/src/components/Step/StepList.tsx b/packages/widget/src/components/Step/StepList.tsx deleted file mode 100644 index a7c122fc4..000000000 --- a/packages/widget/src/components/Step/StepList.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { RouteExtended, TokenAmount } from '@lifi/sdk' -import { Fragment } from 'react' -import { StepDivider } from '../../components/StepDivider/StepDivider.js' -import type { WidgetSubvariant } from '../../types/widget.js' -import { Step } from './Step.js' - -export const getStepList = ( - route?: RouteExtended, - subvariant?: WidgetSubvariant -) => - route?.steps.map((step, index, steps) => { - const lastIndex = steps.length - 1 - const fromToken: TokenAmount | undefined = - index === 0 - ? { - ...step.action.fromToken, - amount: BigInt(step.action.fromAmount), - } - : undefined - let toToken: TokenAmount | undefined - let impactToken: TokenAmount | undefined - if (index === lastIndex) { - toToken = { - ...(step.execution?.toToken ?? step.action.toToken), - amount: step.execution?.toAmount - ? BigInt(step.execution.toAmount) - : subvariant === 'custom' - ? BigInt(route.toAmount) - : BigInt(step.estimate.toAmount), - } - impactToken = { - ...steps[0].action.fromToken, - amount: BigInt(steps[0].action.fromAmount), - } - } - const toAddress = - index === lastIndex && route.fromAddress !== route.toAddress - ? route.toAddress - : undefined - return ( - - - {steps.length > 1 && index !== steps.length - 1 ? ( - - ) : null} - - ) - }) diff --git a/packages/widget/src/components/StepActions/StepActions.style.tsx b/packages/widget/src/components/StepActions/StepActions.style.tsx index 4b6eaf941..a6fe04dfc 100644 --- a/packages/widget/src/components/StepActions/StepActions.style.tsx +++ b/packages/widget/src/components/StepActions/StepActions.style.tsx @@ -31,8 +31,8 @@ export const StepLabel = styled(MuiStepLabel, { padding: 0, alignItems: 'center', [`.${stepLabelClasses.iconContainer}`]: { - paddingLeft: theme.spacing(1.25), - paddingRight: theme.spacing(3.25), + paddingLeft: theme.spacing(0.5), + paddingRight: theme.spacing(2.5), }, [`.${stepLabelClasses.labelContainer}`]: { minHeight: 24, @@ -67,6 +67,9 @@ export const StepContent = styled(Box, { style: { borderLeft: 'none', paddingLeft: theme.spacing(4.625), + ...theme.applyStyles('dark', { + borderLeft: 'none', + }), }, }, ], @@ -76,3 +79,14 @@ export const StepAvatar = styled(AvatarMasked)(({ theme }) => ({ color: theme.vars.palette.text.primary, backgroundColor: 'transparent', })) + +export const StepActionsHeader = styled(Box)(() => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +})) + +export const StepActionsTitle = styled(Typography)(() => ({ + fontSize: 14, + fontWeight: 700, +})) diff --git a/packages/widget/src/components/StepActions/StepActions.tsx b/packages/widget/src/components/StepActions/StepActions.tsx index 3dd0991ec..07ae3bd14 100644 --- a/packages/widget/src/components/StepActions/StepActions.tsx +++ b/packages/widget/src/components/StepActions/StepActions.tsx @@ -1,47 +1,40 @@ -import type { LiFiStep, StepExtended } from '@lifi/sdk' +import type { LiFiStep, RouteExtended, StepExtended } from '@lifi/sdk' import { useEthereumContext } from '@lifi/widget-provider' import ArrowForward from '@mui/icons-material/ArrowForward' import ExpandLess from '@mui/icons-material/ExpandLess' import ExpandMore from '@mui/icons-material/ExpandMore' import type { StepIconProps } from '@mui/material' import { - Badge, Box, Collapse, + Divider, Step as MuiStep, Stepper, Typography, } from '@mui/material' import type { MouseEventHandler } from 'react' -import { useState } from 'react' +import { Fragment, useState } from 'react' import { useTranslation } from 'react-i18next' import { useAvailableChains } from '../../hooks/useAvailableChains.js' -import { lifiLogoUrl } from '../../icons/lifi.js' import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' import { HiddenUI } from '../../types/widget.js' import { formatTokenAmount, formatTokenPrice } from '../../utils/format.js' import { SmallAvatar } from '../Avatar/SmallAvatar.js' import { CardIconButton } from '../Card/CardIconButton.js' import { - StepAvatar, + StepActionsHeader, + StepActionsTitle, StepConnector, StepContent, StepLabel, StepLabelTypography, } from './StepActions.style.js' -import type { - IncludedStepsProps, - StepActionsProps, - StepDetailsLabelProps, -} from './types.js' +import type { IncludedStepsProps, StepDetailsLabelProps } from './types.js' -export const StepActions: React.FC = ({ - step, - dense, - ...other -}) => { +export const StepActions: React.FC<{ + route: RouteExtended +}> = ({ route }) => { const { t } = useTranslation() - const { subvariant } = useWidgetConfig() const [cardExpanded, setCardExpanded] = useState(false) const handleExpand: MouseEventHandler = (e) => { @@ -49,73 +42,48 @@ export const StepActions: React.FC = ({ setCardExpanded((expanded) => !expanded) } - // FIXME: step transaction request overrides step tool details, but not included step tool details - const toolDetails = - subvariant === 'custom' - ? step.includedSteps.find( - (step) => step.tool === 'custom' && step.toolDetails.key !== 'custom' - )?.toolDetails || step.toolDetails - : step.toolDetails + const includedSteps = route.steps.flatMap((step) => step.includedSteps) return ( - - - } - > - - {toolDetails.name[0]} - - - + + {t('main.route')} + + ({ + borderRadius: theme.vars.shape.borderRadiusSecondary, + })} > - - {toolDetails.name?.includes('LI.FI') - ? toolDetails.name - : t('main.stepDetails', { - tool: toolDetails.name, - })} - - {/* */} - - {dense ? ( - - {cardExpanded ? ( - - ) : ( - - )} - - ) : null} - - {dense ? ( - - - - ) : ( - - )} + {cardExpanded ? ( + + ) : ( + + {includedSteps.map((includedStep, index) => ( + + {index > 0 ? ( + + ) : null} + + {includedStep.toolDetails.name[0]} + + + ))} + + + )} + + + + {route.steps.map((step) => ( + + ))} + ) } @@ -157,7 +125,7 @@ const IncludedSteps: React.FC = ({ step }) => { {toolName?.[0]} @@ -169,7 +137,7 @@ const IncludedSteps: React.FC = ({ step }) => { return ( ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: theme.spacing(2), -})) diff --git a/packages/widget/src/components/StepDivider/StepDivider.tsx b/packages/widget/src/components/StepDivider/StepDivider.tsx deleted file mode 100644 index a8f612238..000000000 --- a/packages/widget/src/components/StepDivider/StepDivider.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Divider } from '@mui/material' -import { Container } from './StepDivider.style.js' - -export const StepDivider: React.FC = () => { - return ( - - - - ) -} diff --git a/packages/widget/src/components/Timer/StepStatusTimer.style.tsx b/packages/widget/src/components/Timer/StepStatusTimer.style.tsx new file mode 100644 index 000000000..a57e78d2e --- /dev/null +++ b/packages/widget/src/components/Timer/StepStatusTimer.style.tsx @@ -0,0 +1,42 @@ +import { + Box, + CircularProgress as MuiCircularProgress, + styled, + Typography, +} from '@mui/material' +import { iconCircleSize } from '../IconCircle/IconCircle.style.js' + +export const StatusCircle = styled(Box)({ + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}) + +export const RingContainer = styled(Box)({ + position: 'relative', + width: iconCircleSize, + height: iconCircleSize, +}) + +export const ProgressTrack = styled(MuiCircularProgress)(({ theme }) => ({ + position: 'absolute', + color: theme.vars.palette.grey[300], + ...theme.applyStyles('dark', { + color: theme.vars.palette.grey[800], + }), +})) + +export const ProgressFill = styled(MuiCircularProgress)(({ theme }) => ({ + position: 'absolute', + color: theme.vars.palette.primary.main, + ...theme.applyStyles('dark', { + color: theme.vars.palette.primary.light, + }), +})) + +export const TimerLabel = styled(Typography)({ + fontSize: 18, + fontWeight: 700, +}) diff --git a/packages/widget/src/components/Timer/StepStatusTimer.tsx b/packages/widget/src/components/Timer/StepStatusTimer.tsx new file mode 100644 index 000000000..44c34118c --- /dev/null +++ b/packages/widget/src/components/Timer/StepStatusTimer.tsx @@ -0,0 +1,87 @@ +import type { LiFiStepExtended } from '@lifi/sdk' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useLoopProgress } from '../../hooks/timer/useLoopProgress.js' +import { useTimer } from '../../hooks/timer/useTimer.js' +import { formatTimer, getExpiryTimestamp } from '../../utils/timer.js' +import { iconCircleSize } from '../IconCircle/IconCircle.style.js' +import { + ProgressFill, + ProgressTrack, + RingContainer, + StatusCircle, + TimerLabel, +} from './StepStatusTimer.style.js' + +interface TimerRingProps { + step?: LiFiStepExtended + size?: number + thickness?: number + showLabel?: boolean +} + +export const TimerRing: React.FC = ({ + step, + size = iconCircleSize, + thickness = 2, + showLabel = true, +}) => { + const { i18n } = useTranslation() + const [isExpired, setExpired] = useState(false) + + const signedAt = step?.execution?.signedAt + const totalDuration = (step?.estimate.executionDuration ?? 0) * 1000 + const expiryTimestamp = getExpiryTimestamp(step) + + const { days, hours, minutes, seconds } = useTimer({ + autoStart: Boolean(signedAt), + expiryTimestamp, + onExpire: () => setExpired(true), + }) + + const hasActiveCountdown = + Boolean(signedAt) && !isExpired && (Boolean(minutes) || Boolean(seconds)) + + const remaining = Math.max(expiryTimestamp.getTime() - Date.now(), 0) + const countdownProgress = + totalDuration > 0 + ? Math.min(((totalDuration - remaining) / totalDuration) * 100, 100) + : 0 + + const loopProgress = useLoopProgress({ + active: !hasActiveCountdown, + durationMs: 60_000, + tickMs: 100, + }) + const progress = hasActiveCountdown ? countdownProgress : loopProgress + + return ( + + + + {showLabel && hasActiveCountdown ? ( + + + {formatTimer({ + locale: i18n.language, + days, + hours, + minutes, + seconds, + })} + + + ) : null} + + ) +} diff --git a/packages/widget/src/components/Timer/StepTimer.tsx b/packages/widget/src/components/Timer/StepTimer.tsx index d5c43627c..9d9e9eb97 100644 --- a/packages/widget/src/components/Timer/StepTimer.tsx +++ b/packages/widget/src/components/Timer/StepTimer.tsx @@ -1,66 +1,26 @@ import type { LiFiStepExtended } from '@lifi/sdk' +import { Typography } from '@mui/material' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useTimer } from '../../hooks/timer/useTimer.js' -import { formatTimer } from '../../utils/timer.js' -import { TimerContent } from './TimerContent.js' - -const getExpiryTimestamp = (step: LiFiStepExtended) => { - const execution = step?.execution - if (!execution) { - return new Date() - } - const expiry = new Date( - (execution.signedAt ?? Date.now()) + step.estimate.executionDuration * 1000 - ) - return expiry -} +import { formatTimer, getExpiryTimestamp } from '../../utils/timer.js' export const StepTimer: React.FC<{ step: LiFiStepExtended - hideInProgress?: boolean -}> = ({ step, hideInProgress }) => { - const { i18n } = useTranslation() - +}> = ({ step }) => { if ( step.execution?.status === 'DONE' || - step.execution?.status === 'FAILED' + step.execution?.status === 'FAILED' || + !step.execution?.signedAt ) { return null } - if (!step.execution?.signedAt) { - 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', - })} - - ) - } - - return ( - - ) + return } -const ExecutionTimer = ({ - expiryTimestamp, - hideInProgress, -}: { - expiryTimestamp: Date - hideInProgress?: boolean -}) => { - const { t, i18n } = useTranslation() +const ExecutionTimer = ({ expiryTimestamp }: { expiryTimestamp: Date }) => { + const { i18n } = useTranslation() const [isExpired, setExpired] = useState(false) @@ -73,14 +33,11 @@ const ExecutionTimer = ({ const isTimerExpired = isExpired || (!minutes && !seconds) if (isTimerExpired) { - if (hideInProgress) { - return null - } - return t('main.inProgress') + return null } return ( - + {formatTimer({ locale: i18n.language, days, @@ -88,6 +45,6 @@ const ExecutionTimer = ({ minutes, seconds, })} - + ) } diff --git a/packages/widget/src/components/Timer/TimerContent.tsx b/packages/widget/src/components/Timer/TimerContent.tsx deleted file mode 100644 index 2c2eb5728..000000000 --- a/packages/widget/src/components/Timer/TimerContent.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import AccessTimeFilled from '@mui/icons-material/AccessTimeFilled' -import { Box, Tooltip } from '@mui/material' -import type { FC, PropsWithChildren } from 'react' -import { useTranslation } from 'react-i18next' -import { IconTypography } from '../IconTypography.js' - -export const TimerContent: FC = ({ children }) => { - const { t } = useTranslation() - return ( - - - - - - - {children} - - - - ) -} diff --git a/packages/widget/src/components/Token/Token.tsx b/packages/widget/src/components/Token/Token.tsx index 579698270..82a825195 100644 --- a/packages/widget/src/components/Token/Token.tsx +++ b/packages/widget/src/components/Token/Token.tsx @@ -157,7 +157,7 @@ const TokenBase: FC = ({ ) : null} {impactToken ? ( enableImpactTokenTooltip ? ( - + {t('format.percent', { value: priceImpact, diff --git a/packages/widget/src/components/TokenList/TokenListItem.tsx b/packages/widget/src/components/TokenList/TokenListItem.tsx index 3e1aa71a8..4d279e1f4 100644 --- a/packages/widget/src/components/TokenList/TokenListItem.tsx +++ b/packages/widget/src/components/TokenList/TokenListItem.tsx @@ -206,7 +206,6 @@ const TokenListItemButton: React.FC = memo( display: 'flex', fontSize: 16, color: 'warning.main', - cursor: 'help', }} /> diff --git a/packages/widget/src/components/TokenRate/TokenRate.style.ts b/packages/widget/src/components/TokenRate/TokenRate.style.ts deleted file mode 100644 index fdb6b04d9..000000000 --- a/packages/widget/src/components/TokenRate/TokenRate.style.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { styled, Typography } from '@mui/material' - -export const TokenRateTypography = styled(Typography)(({ theme }) => ({ - fontSize: 14, - lineHeight: 1.429, - fontWeight: 500, - color: theme.vars.palette.text.primary, - cursor: 'pointer', - '&:hover': { - opacity: 1, - }, - opacity: 0.56, - transition: theme.transitions.create(['opacity'], { - duration: theme.transitions.duration.enteringScreen, - easing: theme.transitions.easing.easeOut, - }), -})) diff --git a/packages/widget/src/components/TransactionDetails.tsx b/packages/widget/src/components/TransactionDetails.tsx deleted file mode 100644 index 24aac59b2..000000000 --- a/packages/widget/src/components/TransactionDetails.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import type { RouteExtended } from '@lifi/sdk' -import { useEthereumContext } from '@lifi/widget-provider' -import ExpandLess from '@mui/icons-material/ExpandLess' -import ExpandMore from '@mui/icons-material/ExpandMore' -import LocalGasStationRounded from '@mui/icons-material/LocalGasStationRounded' -import type { CardProps } from '@mui/material' -import { Box, Collapse, Tooltip, Typography } from '@mui/material' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useWidgetConfig } from '../providers/WidgetProvider/WidgetProvider.js' -import { isRouteDone } from '../stores/routes/utils.js' -import { getAccumulatedFeeCostsBreakdown } from '../utils/fees.js' -import { formatTokenAmount, formatTokenPrice } from '../utils/format.js' -import { getPriceImpact } from '../utils/getPriceImpact.js' -import { Card } from './Card/Card.js' -import { CardIconButton } from './Card/CardIconButton.js' -import { FeeBreakdownTooltip } from './FeeBreakdownTooltip.js' -import { IconTypography } from './IconTypography.js' -import { TokenRate } from './TokenRate/TokenRate.js' - -interface TransactionDetailsProps extends CardProps { - route: RouteExtended -} - -export const TransactionDetails: React.FC = ({ - route, - ...props -}) => { - const { t } = useTranslation() - const { feeConfig, defaultUI } = useWidgetConfig() - const [cardExpanded, setCardExpanded] = useState( - defaultUI?.transactionDetailsExpanded ?? false - ) - const { isGaslessStep } = useEthereumContext() - - const toggleCard = () => { - setCardExpanded((cardExpanded) => !cardExpanded) - } - const { gasCosts, feeCosts, gasCostUSD, feeCostUSD, combinedFeesUSD } = - getAccumulatedFeeCostsBreakdown(route) - - const priceImpact = getPriceImpact({ - fromAmount: BigInt(route.fromAmount), - toAmount: BigInt(route.toAmount), - fromToken: route.fromToken, - toToken: route.toToken, - }) - - const feeCollectionStep = route.steps[0].includedSteps.find( - (includedStep) => includedStep.tool === 'feeCollection' - ) - - let feeAmountUSD = 0 - let feePercentage = 0 - - if (feeCollectionStep) { - const estimatedFromAmount = - BigInt(feeCollectionStep.estimate.fromAmount) - - BigInt(feeCollectionStep.estimate.toAmount) - - feeAmountUSD = formatTokenPrice( - estimatedFromAmount, - feeCollectionStep.action.fromToken.priceUSD, - feeCollectionStep.action.fromToken.decimals - ) - - feePercentage = - feeCollectionStep.estimate.feeCosts?.reduce( - (percentage, feeCost) => - percentage + Number.parseFloat(feeCost.percentage || '0'), - 0 - ) ?? 0 - } - - const hasGaslessSupport = route.steps.every((step) => isGaslessStep?.(step)) - - const showIntegratorFeeCollectionDetails = - (feeAmountUSD || Number.isFinite(feeConfig?.fee)) && !hasGaslessSupport - - return ( - - - - - - - - - - - - - {!combinedFeesUSD - ? t('main.fees.free') - : t('format.currency', { value: combinedFeesUSD })} - - - - - - {cardExpanded ? ( - - ) : ( - - )} - - - - - - {t('main.fees.network')} - - - {!gasCostUSD - ? t('main.fees.free') - : t('format.currency', { - value: gasCostUSD, - })} - - - - {feeCosts.length ? ( - - {t('main.fees.provider')} - - - {t('format.currency', { - value: feeCostUSD, - })} - - - - ) : null} - {showIntegratorFeeCollectionDetails ? ( - - - {feeConfig?.name || t('main.fees.defaultIntegrator')} - {feeConfig?.showFeePercentage && ( - <> ({t('format.percent', { value: feePercentage })}) - )} - - {feeConfig?.showFeeTooltip && - (feeConfig?.name || feeConfig?.feeTooltipComponent) ? ( - - - {t('format.currency', { - value: feeAmountUSD, - })} - - - ) : ( - - {t('format.currency', { - value: feeAmountUSD, - })} - - )} - - ) : null} - - {t('main.priceImpact')} - - - {t('format.percent', { - value: priceImpact, - usePlusSign: true, - })} - - - - {!isRouteDone(route) ? ( - <> - - {t('main.maxSlippage')} - - - {route.steps[0].action.slippage - ? t('format.percent', { - value: route.steps[0].action.slippage, - }) - : t('button.auto')} - - - - - {t('main.minReceived')} - - - {t('format.tokenAmount', { - value: formatTokenAmount( - BigInt(route.toAmountMin), - route.toToken.decimals - ), - })}{' '} - {route.toToken.symbol} - - - - - ) : null} - - - - ) -} diff --git a/packages/widget/src/hooks/timer/useLoopProgress.ts b/packages/widget/src/hooks/timer/useLoopProgress.ts new file mode 100644 index 000000000..7f8243d75 --- /dev/null +++ b/packages/widget/src/hooks/timer/useLoopProgress.ts @@ -0,0 +1,36 @@ +import { useEffect, useRef, useState } from 'react' + +interface UseLoopProgressOptions { + active: boolean + durationMs: number + tickMs: number +} + +/** + * Returns a 0–100 progress value that continuously loops over `durationMs`, + * updating every `tickMs`. Useful for animating indeterminate progress rings + * when the actual duration is unknown. The loop resets whenever `active` flips + * from false to true. + */ +export function useLoopProgress({ + active, + durationMs, + tickMs, +}: UseLoopProgressOptions): number { + const [progress, setProgress] = useState(0) + const startRef = useRef(0) + + useEffect(() => { + if (!active) { + return + } + startRef.current = Date.now() + const id = setInterval(() => { + const elapsed = (Date.now() - startRef.current) % durationMs + setProgress((elapsed / durationMs) * 100) + }, tickMs) + return () => clearInterval(id) + }, [active, durationMs, tickMs]) + + return progress +} diff --git a/packages/widget/src/hooks/useContactSupport.ts b/packages/widget/src/hooks/useContactSupport.ts new file mode 100644 index 000000000..8b6f685ee --- /dev/null +++ b/packages/widget/src/hooks/useContactSupport.ts @@ -0,0 +1,16 @@ +import { WidgetEvent } from '../types/events.js' +import { useWidgetEvents } from './useWidgetEvents.js' + +export const useContactSupport = (supportId?: string) => { + const widgetEvents = useWidgetEvents() + + const handleContactSupport = () => { + if (!widgetEvents.all.has(WidgetEvent.ContactSupport)) { + window.open('https://help.li.fi', '_blank', 'nofollow noreferrer') + } else { + widgetEvents.emit(WidgetEvent.ContactSupport, { supportId }) + } + } + + return handleContactSupport +} diff --git a/packages/widget/src/hooks/useDeduplicateRoutes.ts b/packages/widget/src/hooks/useDeduplicateRoutes.ts new file mode 100644 index 000000000..d91387f87 --- /dev/null +++ b/packages/widget/src/hooks/useDeduplicateRoutes.ts @@ -0,0 +1,31 @@ +import type { + ExtendedTransactionInfo, + FullStatusData, + StatusResponse, +} from '@lifi/sdk' +import { useEffect } from 'react' +import { useRouteExecutionStoreContext } from '../stores/routes/RouteExecutionStore.js' +import { getSourceTxHash } from '../stores/routes/utils.js' + +export const useDeduplicateRoutes = (transactions: StatusResponse[]) => { + const store = useRouteExecutionStoreContext() + + useEffect(() => { + if (!transactions.length) { + return + } + // Match by sending txHash — the only reliable link between store routes and API transfers + const apiTxHashes = new Set( + transactions.map( + (t) => ((t as FullStatusData).sending as ExtendedTransactionInfo).txHash + ) + ) + const { routes, deleteRoute } = store.getState() + for (const [id, routeExecution] of Object.entries(routes)) { + const txHash = getSourceTxHash(routeExecution?.route) + if (txHash && apiTxHashes.has(txHash)) { + deleteRoute(id) + } + } + }, [store, transactions]) +} diff --git a/packages/widget/src/hooks/useRouteExecutionMessage.ts b/packages/widget/src/hooks/useRouteExecutionMessage.ts new file mode 100644 index 000000000..c8d7fbdbd --- /dev/null +++ b/packages/widget/src/hooks/useRouteExecutionMessage.ts @@ -0,0 +1,90 @@ +import type { ExecutionActionType, RouteExtended } from '@lifi/sdk' +import { useTranslation } from 'react-i18next' +import { useWidgetConfig } from '../providers/WidgetProvider/WidgetProvider.js' +import { RouteExecutionStatus } from '../stores/routes/types.js' +import { getActionMessage } from '../utils/getActionMessage.js' +import { getErrorMessage } from '../utils/getErrorMessage.js' +import { useAvailableChains } from './useAvailableChains.js' + +export const useRouteExecutionMessage = ( + route: RouteExtended, + status: RouteExecutionStatus +) => { + const { subvariant, subvariantOptions } = useWidgetConfig() + const { t } = useTranslation() + const { getChainById } = useAvailableChains() + + const transactionType = + route.fromChainId === route.toChainId ? 'swap' : 'bridge' + + let title: string | undefined + let message: string | undefined + + switch (status) { + case RouteExecutionStatus.Pending: { + const lastStep = route.steps.at(-1) + const lastAction = lastStep?.execution?.actions?.at(-1) + if (!lastStep || !lastAction) { + break + } + const actionMessage = getActionMessage( + t, + lastStep, + lastAction.type, + lastAction.status, + lastAction.substatus, + subvariant, + subvariantOptions + ) + title = actionMessage.title + message = actionMessage.message + break + } + case RouteExecutionStatus.Done: { + title = + subvariant === 'custom' + ? t( + `success.title.${subvariantOptions?.custom ?? 'checkout'}Successful` + ) + : t(`success.title.${transactionType}Successful`) + break + } + case RouteExecutionStatus.Done | RouteExecutionStatus.Partial: { + title = t(`success.title.${transactionType}PartiallySuccessful`) + message = t('success.message.exchangePartiallySuccessful', { + tool: route.steps.at(-1)?.toolDetails.name, + tokenSymbol: route.steps.at(-1)?.action.toToken.symbol, + }) + break + } + case RouteExecutionStatus.Done | RouteExecutionStatus.Refunded: { + title = t('success.title.refundIssued') + message = t('success.message.exchangePartiallySuccessful', { + tool: route.steps.at(-1)?.toolDetails.name, + tokenSymbol: route.steps.at(-1)?.action.toToken.symbol, + }) + break + } + case RouteExecutionStatus.Failed: { + const step = route.steps.find( + (step) => step.execution?.status === 'FAILED' + ) + if (!step) { + break + } + const action = step.execution?.actions?.find( + (action) => action.status === 'FAILED' + ) || { + status: 'FAILED', + type: 'EXECUTION' as ExecutionActionType, + error: step.execution?.error, + } // synthetic action to represent a failed execution with no actions + const actionMessage = getErrorMessage(t, getChainById, step, action) + title = actionMessage.title + message = actionMessage.message + break + } + } + + return { title, message } +} diff --git a/packages/widget/src/hooks/useToAddressAutoPopulate.ts b/packages/widget/src/hooks/useToAddressAutoPopulate.ts index 24bd48ca5..26172309b 100644 --- a/packages/widget/src/hooks/useToAddressAutoPopulate.ts +++ b/packages/widget/src/hooks/useToAddressAutoPopulate.ts @@ -4,7 +4,6 @@ import { useCallback } from 'react' import { useBookmarkActions } from '../stores/bookmarks/useBookmarkActions.js' import type { FormType } from '../stores/form/types.js' import { useFieldActions } from '../stores/form/useFieldActions.js' -import { useSendToWalletActions } from '../stores/settings/useSendToWalletStore.js' import { useAvailableChains } from './useAvailableChains.js' type UpdateToAddressArgs = { @@ -20,7 +19,6 @@ type UpdateToAddressArgs = { */ export const useToAddressAutoPopulate = () => { const { setFieldValue } = useFieldActions() - const { setSendToWallet } = useSendToWalletActions() const { setSelectedBookmark } = useBookmarkActions() const { getChainById } = useAvailableChains() const { accounts } = useAccount() @@ -81,7 +79,6 @@ export const useToAddressAutoPopulate = () => { chainType: destinationAccount.chainType, isConnectedAccount: true, }) - setSendToWallet(true) return destinationAccount.address } }, @@ -90,7 +87,6 @@ export const useToAddressAutoPopulate = () => { getChainById, setFieldValue, setSelectedBookmark, - setSendToWallet, getChainTypeFromAddress, ] ) diff --git a/packages/widget/src/hooks/useToAddressReset.ts b/packages/widget/src/hooks/useToAddressReset.ts index 32ac94974..776978a38 100644 --- a/packages/widget/src/hooks/useToAddressReset.ts +++ b/packages/widget/src/hooks/useToAddressReset.ts @@ -4,16 +4,13 @@ import { useWidgetConfig } from '../providers/WidgetProvider/WidgetProvider.js' import { useBookmarkActions } from '../stores/bookmarks/useBookmarkActions.js' import { useBookmarks } from '../stores/bookmarks/useBookmarks.js' import { useFieldActions } from '../stores/form/useFieldActions.js' -import { useSendToWalletActions } from '../stores/settings/useSendToWalletStore.js' import { RequiredUI } from '../types/widget.js' export const useToAddressReset = () => { const { requiredUI } = useWidgetConfig() - const { setFieldValue, isDirty } = useFieldActions() + const { setFieldValue } = useFieldActions() const { selectedBookmark } = useBookmarks() const { setSelectedBookmark } = useBookmarkActions() - const { setSendToWallet } = useSendToWalletActions() - const tryResetToAddress = useCallback( (toChain: ExtendedChain) => { const requiredToAddress = requiredUI?.includes(RequiredUI.ToAddress) @@ -24,30 +21,14 @@ export const useToAddressReset = () => { const shouldResetToAddress = !requiredToAddress && !bookmarkSatisfiesToChainType - // The toAddress field is required and always visible when bridging between - // different ecosystems (fromChain and toChain have different chain types). // We reset toAddress on each chain change if it's no longer required, ensuring that - // switching chain types doesn't leave a previously set toAddress value when - // the "Send to Wallet" field is hidden. + // switching chain types doesn't leave a stale toAddress from a different ecosystem. if (shouldResetToAddress) { setFieldValue('toAddress', '', { isTouched: true }) setSelectedBookmark() - // If toAddress was auto-filled (e.g., when making cross-ecosystem bridging and compatible destination wallet was connected) - // and not manually edited by the user, we need to hide "Send to Wallet". - const isToAddressDirty = isDirty('toAddress') - if (!isToAddressDirty) { - setSendToWallet(false) - } } }, - [ - setFieldValue, - isDirty, - setSelectedBookmark, - setSendToWallet, - requiredUI, - selectedBookmark, - ] + [setFieldValue, setSelectedBookmark, requiredUI, selectedBookmark] ) return { diff --git a/packages/widget/src/components/TokenRate/TokenRate.tsx b/packages/widget/src/hooks/useTokenRateText.ts similarity index 71% rename from packages/widget/src/components/TokenRate/TokenRate.tsx rename to packages/widget/src/hooks/useTokenRateText.ts index 4586df71d..0746157bf 100644 --- a/packages/widget/src/components/TokenRate/TokenRate.tsx +++ b/packages/widget/src/hooks/useTokenRateText.ts @@ -1,32 +1,21 @@ import { formatUnits, type RouteExtended } from '@lifi/sdk' -import type { TypographyProps } from '@mui/material' import type { MouseEventHandler } from 'react' import { useTranslation } from 'react-i18next' import { create } from 'zustand' -import { TokenRateTypography } from './TokenRate.style.js' - -interface TokenRateProps extends TypographyProps { - route: RouteExtended -} interface TokenRateState { isForward: boolean toggleIsForward(): void } -const useTokenRate = create((set) => ({ +const useTokenRateStore = create((set) => ({ isForward: true, toggleIsForward: () => set((state) => ({ isForward: !state.isForward })), })) -export const TokenRate: React.FC = ({ route }) => { +export function useTokenRateText(route: RouteExtended) { const { t } = useTranslation() - const { isForward, toggleIsForward } = useTokenRate() - - const toggleRate: MouseEventHandler = (e) => { - e.stopPropagation() - toggleIsForward() - } + const { isForward, toggleIsForward } = useTokenRateStore() const lastStep = route.steps.at(-1) @@ -54,9 +43,10 @@ export const TokenRate: React.FC = ({ route }) => { ? `1 ${fromToken.symbol} ≈ ${t('format.tokenAmount', { value: fromToRate })} ${toToken.symbol}` : `1 ${toToken.symbol} ≈ ${t('format.tokenAmount', { value: toFromRate })} ${fromToken.symbol}` - return ( - - {rateText} - - ) + const toggleRate: MouseEventHandler = (e) => { + e.stopPropagation() + toggleIsForward() + } + + return { rateText, toggleRate } } diff --git a/packages/widget/src/hooks/useTransactionHistory.ts b/packages/widget/src/hooks/useTransactionHistory.ts index c517b59ce..c34eb9800 100644 --- a/packages/widget/src/hooks/useTransactionHistory.ts +++ b/packages/widget/src/hooks/useTransactionHistory.ts @@ -3,16 +3,21 @@ import { type ExtendedTransactionInfo, getTransactionHistory } from '@lifi/sdk' import { useAccount } from '@lifi/wallet-management' import type { QueryFunction } from '@tanstack/react-query' import { useQueries } from '@tanstack/react-query' +import { useMemo } from 'react' import { useSDKClient } from '../providers/SDKClientProvider.js' import { useWidgetConfig } from '../providers/WidgetProvider/WidgetProvider.js' +import type { RouteExecution } from '../stores/routes/types.js' +import { buildRouteFromTxHistory } from '../utils/converters.js' import { getQueryKey } from '../utils/queries.js' +import { useTools } from './useTools.js' export const useTransactionHistory = () => { const { accounts } = useAccount() const { keyPrefix } = useWidgetConfig() const sdkClient = useSDKClient() + const { tools } = useTools() - const { data, isLoading } = useQueries({ + const { data: transactions, isLoading } = useQueries({ queries: accounts.map((account) => ({ queryKey: [ getQueryKey('transaction-history', keyPrefix), @@ -41,7 +46,7 @@ export const useTransactionHistory = () => { enabled: Boolean(account.address), })), combine: (results) => { - const uniqueTransactions = new Map() + const uniqueTransactions = new Map() results.forEach((result) => { if (result.data) { result.data.forEach((transaction) => { @@ -63,16 +68,29 @@ export const useTransactionHistory = () => { ((b?.sending as ExtendedTransactionInfo)?.timestamp ?? 0) - ((a?.sending as ExtendedTransactionInfo)?.timestamp ?? 0) ) - }) as StatusResponse[] + }) return { - data: data, + data, isLoading: results.some((result) => result.isLoading), } }, }) + const routeExecutions = useMemo( + () => + (transactions ?? []).flatMap((transaction) => { + const routeExecution = buildRouteFromTxHistory( + transaction as FullStatusData, + tools + ) + return routeExecution ? [routeExecution] : [] + }), + [tools, transactions] + ) + return { - data, + data: routeExecutions, + rawData: transactions, isLoading, } } diff --git a/packages/widget/src/hooks/useTransactionList.ts b/packages/widget/src/hooks/useTransactionList.ts new file mode 100644 index 000000000..b513fef43 --- /dev/null +++ b/packages/widget/src/hooks/useTransactionList.ts @@ -0,0 +1,88 @@ +import { useAccount } from '@lifi/wallet-management' +import { useMemo } from 'react' +import type { + ActiveItem, + HistoryItem, + LocalItem, + TransactionListItem, +} from '../pages/ActivitiesPage/types.js' +import { useRouteExecutionStore } from '../stores/routes/RouteExecutionStore.js' +import type { + RouteExecution, + RouteExecutionState, +} from '../stores/routes/types.js' +import { RouteExecutionStatus } from '../stores/routes/types.js' +import { getSourceTxHash } from '../stores/routes/utils.js' +import { hasEnumFlag } from '../utils/enum.js' +import { useDeduplicateRoutes } from './useDeduplicateRoutes.js' +import { useTransactionHistory } from './useTransactionHistory.js' + +const routesSelector = (state: RouteExecutionState) => state.routes + +export const useTransactionList = () => { + const { accounts } = useAccount() + const accountAddresses = useMemo( + () => accounts.map((account) => account.address), + [accounts] + ) + + const { + data: apiRouteExecutions, + rawData: rawTransactions, + isLoading, + } = useTransactionHistory() + + useDeduplicateRoutes(rawTransactions ?? []) + + const routes = useRouteExecutionStore(routesSelector) + + const items = useMemo(() => { + const owned = (Object.values(routes) as RouteExecution[]).filter((r) => + accountAddresses.includes(r.route.fromAddress) + ) + const byStartedAt = (a: RouteExecution, b: RouteExecution) => + (b.route.steps[0]?.execution?.startedAt ?? 0) - + (a.route.steps[0]?.execution?.startedAt ?? 0) + + const activeItems: ActiveItem[] = owned + .filter( + (r) => + r.status === RouteExecutionStatus.Pending || + r.status === RouteExecutionStatus.Failed + ) + .sort(byStartedAt) + .map((r) => ({ + type: 'active', + routeId: r.route.id, + startedAt: r.route.steps[0]?.execution?.startedAt ?? 0, + })) + + const localItems: LocalItem[] = owned + .filter((r) => hasEnumFlag(r.status, RouteExecutionStatus.Done)) + .sort(byStartedAt) + .map((r) => ({ + type: 'local', + routeExecution: r, + txHash: getSourceTxHash(r.route) ?? '', + // store startedAt is already in ms + startedAt: r.route.steps[0]?.execution?.startedAt ?? 0, + })) + + const historyItems: HistoryItem[] = apiRouteExecutions.map( + (routeExecution) => ({ + type: 'history', + routeExecution, + txHash: getSourceTxHash(routeExecution.route) ?? '', + // API startedAt is in seconds; multiply by 1000 to normalize to ms + startedAt: + (routeExecution.route.steps[0]?.execution?.startedAt ?? 0) * 1000, + }) + ) + + return [...activeItems, ...localItems, ...historyItems].sort( + (a, b) => b.startedAt - a.startedAt + ) + }, [accountAddresses, apiRouteExecutions, routes]) + + return { items, isLoading } +} diff --git a/packages/widget/src/i18n/en.json b/packages/widget/src/i18n/en.json index 1f988bee4..b0ff6467c 100644 --- a/packages/widget/src/i18n/en.json +++ b/packages/widget/src/i18n/en.json @@ -39,6 +39,7 @@ "max": "MAX", "ok": "Ok", "options": "Options", + "remove": "Remove", "removeTransaction": "Remove transaction", "reset": "Reset", "resetSettings": "Reset settings", @@ -49,12 +50,12 @@ "swap": "Swap", "swapReview": "Review swap", "system": "System", + "retry": "Retry", "tryAgain": "Try again", "viewCoverage": "View coverage", "viewOnExplorer": "View on explorer" }, "header": { - "activeTransactions": "Active transactions", "amount": "Amount", "bookmarkedWallets": "Bookmarked wallets", "bridge": "Bridge", @@ -79,7 +80,7 @@ "swap": "Swap", "to": "Exchange to", "transactionDetails": "Transaction details", - "transactionHistory": "Transaction history", + "activities": "Activities", "walletConnected": "Wallet connected", "youGet": "You get", "youPay": "You pay" @@ -123,7 +124,6 @@ "message": { "accountNotDeployedMessage": "Smart contract account doesn't exist on the destination chain. Sending funds to a non-existent smart contract account can result in permanent loss.", "lowAddressActivity": "This address has low activity on {{chainName}} network. Please verify you're sending to the correct address and network to prevent potential loss of funds.", - "deleteActiveTransactions": "Active transactions are only stored locally and can't be recovered if you delete them.", "deleteTransactionHistory": "Transaction history is only stored locally and can't be recovered if you delete it.", "fundsLossPrevention": "Always ensure smart contract accounts are properly set up on the destination chain and avoid direct transfers to exchanges to prevent fund loss.", "highValueLoss": "The value of the received tokens is significantly lower than the exchanged tokens and transaction cost.", @@ -137,7 +137,6 @@ "slippageUnderRecommendedLimits": "Low slippage tolerance may cause transaction delays or failures." }, "title": { - "deleteActiveTransactions": "Delete all active transactions?", "deleteTransaction": "Delete this transaction?", "deleteTransactionHistory": "Delete transaction history?", "highValueLoss": "High value loss", @@ -199,6 +198,7 @@ "tooltip": { "deselectAll": "Deselect all", "estimatedTime": "Time to complete the swap or bridge transaction, excluding chain switching and token approval.", + "exchangeRate": "The estimated conversion rate between source and destination tokens. Click to toggle the direction.", "feeCollection": "The fee is applied to selected token pairs and ensures we can provide the best experience.", "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": { @@ -207,7 +207,6 @@ }, "multistep": "This route requires multiple transactions to complete the transfer. Each exchange step can contain 1-2 transactions that require a signature.", "gasless": "We handle the gas, so you can transfer assets without holding native tokens. Network costs are included in the transfer.", - "numberOfSteps": "Each exchange step can contain 1-2 transactions that require a signature.", "priceImpact": "The estimated value difference between the source and destination tokens.", "progressToNextUpdate": "Quotes will update in {{value}} seconds. <0/> Click here to update now.", "selectAll": "Select all", @@ -221,6 +220,8 @@ "checkoutStepDetails": "Purchase via {{tool}}", "currentAmount": "Current amount", "depositStepDetails": "Deposit via {{tool}}", + "estimatedTime": "Estimated time", + "exchangeRate": "Exchange rate", "featuredTokens": "Featured tokens", "fees": { "defaultIntegrator": "Integrator fee", @@ -232,7 +233,6 @@ "provider": "Provider fee" }, "from": "From", - "inProgress": "in progress", "maxSlippage": "Max. slippage", "minReceived": "Min. received", "myTokens": "My tokens", @@ -241,6 +241,8 @@ "pinnedTokens": "Pinned tokens", "popularTokens": "Popular tokens", "priceImpact": "Price impact", + "receipts": "Receipts", + "sentToWallet": "Sent to wallet: {{address}}", "process": { "bridge": { "actionRequired": "Sign bridge transaction", @@ -286,9 +288,8 @@ "rateChange": "Rate change", "receiving": "Receiving", "refuelStepDetails": "Get gas via {{tool}}", - "selectChain": "Select chain", - "selectChainAndToken": "Select chain and token", - "selectToken": "Select token", + "route": "Route", + "select": "Select", "sendToAddress": "Send to {{address}}", "sendToWallet": "Send to a different wallet", "sending": "Sending", diff --git a/packages/widget/src/icons/lifi.ts b/packages/widget/src/icons/lifi.ts deleted file mode 100644 index 30c1bd692..000000000 --- a/packages/widget/src/icons/lifi.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const lifiLogoUrl = - 'https://lifinance.github.io/types/src/assets/icons/bridges/lifi.svg' diff --git a/packages/widget/src/pages/ActiveTransactionsPage/ActiveTransactionsEmpty.tsx b/packages/widget/src/pages/ActiveTransactionsPage/ActiveTransactionsEmpty.tsx deleted file mode 100644 index d6c06df12..000000000 --- a/packages/widget/src/pages/ActiveTransactionsPage/ActiveTransactionsEmpty.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import SwapHoriz from '@mui/icons-material/SwapHoriz' -import { Container, Typography } from '@mui/material' -import { useTranslation } from 'react-i18next' - -export const ActiveTransactionsEmpty: React.FC = () => { - const { t } = useTranslation() - return ( - - - - - - {t('info.title.emptyActiveTransactions')} - - - {t('info.message.emptyActiveTransactions')} - - - ) -} diff --git a/packages/widget/src/pages/ActiveTransactionsPage/ActiveTransactionsPage.tsx b/packages/widget/src/pages/ActiveTransactionsPage/ActiveTransactionsPage.tsx deleted file mode 100644 index 129a6fd9c..000000000 --- a/packages/widget/src/pages/ActiveTransactionsPage/ActiveTransactionsPage.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import DeleteOutline from '@mui/icons-material/DeleteOutline' -import type { IconButtonProps } from '@mui/material' -import { - Button, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - IconButton, - List, - useTheme, -} from '@mui/material' -import { useCallback, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { ActiveTransactionItem } from '../../components/ActiveTransactions/ActiveTransactionItem.js' -import { Dialog } from '../../components/Dialog.js' -import { PageContainer } from '../../components/PageContainer.js' -import { useHeader } from '../../hooks/useHeader.js' -import { useRouteExecutionStore } from '../../stores/routes/RouteExecutionStore.js' -import { useExecutingRoutesIds } from '../../stores/routes/useExecutingRoutesIds.js' -import { ActiveTransactionsEmpty } from './ActiveTransactionsEmpty.js' - -const DeleteIconButton: React.FC = ({ onClick }) => { - const theme = useTheme() - - return ( - - - - ) -} - -export const ActiveTransactionsPage = () => { - const { t } = useTranslation() - const executingRoutes = useExecutingRoutesIds() - const deleteRoutes = useRouteExecutionStore((store) => store.deleteRoutes) - const [open, setOpen] = useState(false) - - const toggleDialog = useCallback(() => { - setOpen((open) => !open) - }, []) - - const headerAction = useMemo( - () => - executingRoutes.length ? ( - - ) : undefined, - [executingRoutes.length, toggleDialog] - ) - - useHeader(t('header.activeTransactions'), headerAction) - - if (!executingRoutes.length) { - return - } - - return ( - - - {executingRoutes.map((routeId) => ( - - ))} - - - {t('warning.title.deleteActiveTransactions')} - - - {t('warning.message.deleteActiveTransactions')} - - - - - - - - - ) -} diff --git a/packages/widget/src/pages/ActivitiesPage/ActiveTransactionItem.style.tsx b/packages/widget/src/pages/ActivitiesPage/ActiveTransactionItem.style.tsx new file mode 100644 index 000000000..15c7ece1a --- /dev/null +++ b/packages/widget/src/pages/ActivitiesPage/ActiveTransactionItem.style.tsx @@ -0,0 +1,25 @@ +import { IconButton, styled } from '@mui/material' + +export const DeleteButton = styled(IconButton)(({ theme }) => ({ + padding: theme.spacing(0.5), + backgroundColor: theme.vars.palette.background.paper, + width: 24, + height: 24, + ...theme.applyStyles('dark', { + backgroundColor: theme.vars.palette.background.paper, + }), +})) + +export const RetryButton = styled(IconButton)(({ theme }) => ({ + fontWeight: 700, + fontSize: 12, + height: 24, + minWidth: 'auto', + borderRadius: theme.vars.shape.borderRadius, + padding: theme.spacing(0.5, 1.5), + color: theme.vars.palette.text.primary, + backgroundColor: theme.vars.palette.background.paper, + ...theme.applyStyles('dark', { + backgroundColor: theme.vars.palette.background.paper, + }), +})) diff --git a/packages/widget/src/pages/ActivitiesPage/ActiveTransactionItem.tsx b/packages/widget/src/pages/ActivitiesPage/ActiveTransactionItem.tsx new file mode 100644 index 000000000..59afaf9e8 --- /dev/null +++ b/packages/widget/src/pages/ActivitiesPage/ActiveTransactionItem.tsx @@ -0,0 +1,141 @@ +import type { LiFiStepExtended, RouteExtended } from '@lifi/sdk' +import DeleteOutline from '@mui/icons-material/DeleteOutline' +import { Box } from '@mui/material' +import { useNavigate } from '@tanstack/react-router' +import type { MouseEvent } from 'react' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { ActionRow } from '../../components/ActionRow/ActionRow.js' +import { Card } from '../../components/Card/Card.js' +import { IconCircle } from '../../components/IconCircle/IconCircle.js' +import { RouteTokens } from '../../components/RouteCard/RouteTokens.js' +import { TimerRing } from '../../components/Timer/StepStatusTimer.js' +import { StepTimer } from '../../components/Timer/StepTimer.js' +import { useRouteExecution } from '../../hooks/useRouteExecution.js' +import { useRouteExecutionMessage } from '../../hooks/useRouteExecutionMessage.js' +import { RouteExecutionStatus } from '../../stores/routes/types.js' +import { navigationRoutes } from '../../utils/navigationRoutes.js' +import { DeleteButton, RetryButton } from './ActiveTransactionItem.style.js' + +export const ActiveTransactionItem: React.FC<{ routeId: string }> = memo( + ({ routeId }) => { + const { route, status, restartRoute, deleteRoute } = useRouteExecution({ + routeId, + executeInBackground: true, + }) + + if (!route) { + return null + } + + return ( + + ) + } +) + +const ActiveTransactionItemContent: React.FC<{ + route: RouteExtended + status: RouteExecutionStatus | undefined + restartRoute: () => void + deleteRoute: () => void +}> = ({ route, status, restartRoute, deleteRoute }) => { + const navigate = useNavigate() + const lastStep = route.steps.at(-1) + const { title } = useRouteExecutionMessage( + route, + status ?? RouteExecutionStatus.Pending + ) + + const handleClick = () => { + navigate({ + to: navigationRoutes.transactionExecution, + search: { routeId: route.id }, + }) + } + + const handleDelete = (e: MouseEvent) => { + e.stopPropagation() + deleteRoute() + } + + const handleRetry = () => { + // NB: Do not stop propagation here: + // open the transaction execution page and retry the transaction simultaneously + restartRoute() + } + + const isFailed = status === RouteExecutionStatus.Failed + + return ( + + + {isFailed ? ( + + ) : ( + + )} + + + + ) +} + +const FailedTransactionRow: React.FC<{ + onRetry: () => void + onDelete: (e: MouseEvent) => void +}> = ({ onRetry, onDelete }) => { + const { t } = useTranslation() + return ( + } + message={t('error.title.transactionFailed')} + endAdornment={ + <> + + {t('button.retry')} + + + + + + } + /> + ) +} + +const PendingTransactionRow: React.FC<{ + step: LiFiStepExtended | undefined + title: string | undefined +}> = ({ step, title }) => { + if (!title || step?.execution?.status === 'DONE') { + return null + } + + const isActionRequired = step?.execution?.status === 'ACTION_REQUIRED' + if (isActionRequired) { + return ( + } + message={title} + /> + ) + } + + return ( + + ) : undefined + } + message={title} + endAdornment={step ? : undefined} + /> + ) +} diff --git a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryPage.tsx b/packages/widget/src/pages/ActivitiesPage/ActivitiesPage.tsx similarity index 52% rename from packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryPage.tsx rename to packages/widget/src/pages/ActivitiesPage/ActivitiesPage.tsx index 0f496d0fb..2e1b020d7 100644 --- a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryPage.tsx +++ b/packages/widget/src/pages/ActivitiesPage/ActivitiesPage.tsx @@ -1,4 +1,3 @@ -import type { FullStatusData } from '@lifi/sdk' import { Box, List } from '@mui/material' import { useVirtualizer } from '@tanstack/react-virtual' import { useCallback, useRef } from 'react' @@ -6,19 +5,20 @@ import { useTranslation } from 'react-i18next' import { PageContainer } from '../../components/PageContainer.js' import { useHeader } from '../../hooks/useHeader.js' import { useListHeight } from '../../hooks/useListHeight.js' -import { useTransactionHistory } from '../../hooks/useTransactionHistory.js' +import { useTransactionList } from '../../hooks/useTransactionList.js' +import { ActiveTransactionItem } from './ActiveTransactionItem.js' import { minTransactionListHeight } from './constants.js' import { TransactionHistoryEmpty } from './TransactionHistoryEmpty.js' import { TransactionHistoryItem } from './TransactionHistoryItem.js' import { TransactionHistoryItemSkeleton } from './TransactionHistorySkeleton.js' -export const TransactionHistoryPage = () => { +export const ActivitiesPage = () => { // Parent ref and useVirtualizer should be in one file to avoid blank page (0 virtual items) issue const parentRef = useRef(null) - const { data: transactions, isLoading } = useTransactionHistory() + const { items, isLoading } = useTransactionList() const { t } = useTranslation() - useHeader(t('header.transactionHistory')) + useHeader(t('header.activities')) const { listHeight } = useListHeight({ listParentRef: parentRef, @@ -26,21 +26,25 @@ export const TransactionHistoryPage = () => { const getItemKey = useCallback( (index: number) => { - return `${(transactions[index] as FullStatusData).transactionId}-${index}` + const item = items[index] + if (item.type === 'active') { + return `active-${item.routeId}` + } + return item.txHash || item.routeExecution.route.id }, - [transactions] + [items] ) - const { getVirtualItems, getTotalSize } = useVirtualizer({ - count: transactions.length, + const { getVirtualItems, getTotalSize, measureElement } = useVirtualizer({ + count: items.length, overscan: 3, paddingEnd: 12, getScrollElement: () => parentRef.current, - estimateSize: () => 186, + estimateSize: () => 216, getItemKey, }) - if (!transactions.length && !isLoading) { + if (!items.length && !isLoading) { return } @@ -51,13 +55,8 @@ export const TransactionHistoryPage = () => { > {isLoading ? ( @@ -68,20 +67,35 @@ export const TransactionHistoryPage = () => { ) : ( {getVirtualItems().map((item) => { - const transaction = transactions[item.index] + const listItem = items[item.index] return ( - + ref={measureElement} + data-index={item.index} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + paddingBottom: 16, + transform: `translateY(${item.start}px)`, + }} + > + {listItem.type === 'active' ? ( + + ) : ( + + )} + ) })} diff --git a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryEmpty.tsx b/packages/widget/src/pages/ActivitiesPage/TransactionHistoryEmpty.tsx similarity index 100% rename from packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryEmpty.tsx rename to packages/widget/src/pages/ActivitiesPage/TransactionHistoryEmpty.tsx diff --git a/packages/widget/src/pages/ActivitiesPage/TransactionHistoryItem.tsx b/packages/widget/src/pages/ActivitiesPage/TransactionHistoryItem.tsx new file mode 100644 index 000000000..014b74401 --- /dev/null +++ b/packages/widget/src/pages/ActivitiesPage/TransactionHistoryItem.tsx @@ -0,0 +1,33 @@ +import type { RouteExtended } from '@lifi/sdk' +import { Box } from '@mui/material' +import { useNavigate } from '@tanstack/react-router' +import { memo } from 'react' +import { Card } from '../../components/Card/Card.js' +import { DateLabel } from '../../components/DateLabel/DateLabel.js' +import { RouteTokens } from '../../components/RouteCard/RouteTokens.js' +import { navigationRoutes } from '../../utils/navigationRoutes.js' + +export const TransactionHistoryItem: React.FC<{ + route: RouteExtended + transactionHash: string + // startedAt in ms + startedAt: number +}> = memo(({ route, transactionHash, startedAt }) => { + const navigate = useNavigate() + + const handleClick = () => { + navigate({ + to: navigationRoutes.transactionDetails, + search: { transactionHash }, + }) + } + + return ( + + + + + + + ) +}) diff --git a/packages/widget/src/pages/ActivitiesPage/TransactionHistorySkeleton.tsx b/packages/widget/src/pages/ActivitiesPage/TransactionHistorySkeleton.tsx new file mode 100644 index 000000000..6a4e1572e --- /dev/null +++ b/packages/widget/src/pages/ActivitiesPage/TransactionHistorySkeleton.tsx @@ -0,0 +1,52 @@ +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward' +import { Box, Skeleton } from '@mui/material' +import { Card } from '../../components/Card/Card.js' +import { TokenSkeleton } from '../../components/Token/Token.js' + +export const TransactionHistoryItemSkeleton = () => { + return ( + + + + ({ + borderRadius: theme.vars.shape.borderRadius, + })} + /> + ({ + borderRadius: theme.vars.shape.borderRadius, + })} + /> + + + + + + + + + + + ) +} diff --git a/packages/widget/src/pages/TransactionHistoryPage/constants.ts b/packages/widget/src/pages/ActivitiesPage/constants.ts similarity index 100% rename from packages/widget/src/pages/TransactionHistoryPage/constants.ts rename to packages/widget/src/pages/ActivitiesPage/constants.ts diff --git a/packages/widget/src/pages/ActivitiesPage/types.ts b/packages/widget/src/pages/ActivitiesPage/types.ts new file mode 100644 index 000000000..fed10a440 --- /dev/null +++ b/packages/widget/src/pages/ActivitiesPage/types.ts @@ -0,0 +1,18 @@ +import type { RouteExecution } from '../../stores/routes/types.js' + +export type ActiveItem = { type: 'active'; routeId: string; startedAt: number } +export type LocalItem = { + type: 'local' + routeExecution: RouteExecution + txHash: string + // startedAt in ms + startedAt: number +} +export type HistoryItem = { + type: 'history' + routeExecution: RouteExecution + txHash: string + // startedAt in ms + startedAt: number +} +export type TransactionListItem = ActiveItem | LocalItem | HistoryItem diff --git a/packages/widget/src/pages/MainPage/MainPage.tsx b/packages/widget/src/pages/MainPage/MainPage.tsx index 95201d9e8..dae5537ae 100644 --- a/packages/widget/src/pages/MainPage/MainPage.tsx +++ b/packages/widget/src/pages/MainPage/MainPage.tsx @@ -1,6 +1,5 @@ import { Box } from '@mui/material' import { useTranslation } from 'react-i18next' -import { ActiveTransactions } from '../../components/ActiveTransactions/ActiveTransactions.js' import { AmountInput } from '../../components/AmountInput/AmountInput.js' import { ContractComponent } from '../../components/ContractComponent/ContractComponent.js' import { GasRefuelMessage } from '../../components/Messages/GasRefuelMessage.js' @@ -8,8 +7,6 @@ import { PageContainer } from '../../components/PageContainer.js' import { PoweredBy } from '../../components/PoweredBy/PoweredBy.js' import { Routes } from '../../components/Routes/Routes.js' import { SelectChainAndToken } from '../../components/SelectChainAndToken.js' -import { SendToWalletButton } from '../../components/SendToWallet/SendToWalletButton.js' -import { SendToWalletExpandButton } from '../../components/SendToWallet/SendToWalletExpandButton.js' import { useHeader } from '../../hooks/useHeader.js' import { useWideVariant } from '../../hooks/useWideVariant.js' import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' @@ -43,22 +40,20 @@ export const MainPage: React.FC = () => { useHeader(title) - const marginSx = { marginBottom: 2 } - return ( - - {custom ? ( - {contractComponent} - ) : null} - - {!custom || subvariantOptions?.custom === 'deposit' ? ( - - ) : null} - {!wideVariant ? : null} - - {showGasRefuelMessage ? : null} - + + {custom ? ( + {contractComponent} + ) : null} + + {!custom || subvariantOptions?.custom === 'deposit' ? ( + + ) : null} + {!wideVariant ? : null} + {showGasRefuelMessage ? : null} + + { }} > - {showPoweredBy ? : null} diff --git a/packages/widget/src/pages/SendToWallet/BookmarksPage.tsx b/packages/widget/src/pages/SendToWallet/BookmarksPage.tsx index c3d391685..07c3d824a 100644 --- a/packages/widget/src/pages/SendToWallet/BookmarksPage.tsx +++ b/packages/widget/src/pages/SendToWallet/BookmarksPage.tsx @@ -20,7 +20,6 @@ import type { Bookmark } from '../../stores/bookmarks/types.js' import { useBookmarkActions } from '../../stores/bookmarks/useBookmarkActions.js' import { useBookmarks } from '../../stores/bookmarks/useBookmarks.js' import { useFieldActions } from '../../stores/form/useFieldActions.js' -import { useSendToWalletActions } from '../../stores/settings/useSendToWalletStore.js' import { defaultChainIdsByType } from '../../utils/chainType.js' import { navigationRoutes } from '../../utils/navigationRoutes.js' import { shortenAddress } from '../../utils/wallet.js' @@ -43,7 +42,6 @@ export const BookmarksPage = () => { useBookmarkActions() const navigate = useNavigate() const { setFieldValue } = useFieldActions() - const { setSendToWallet } = useSendToWalletActions() const { variant } = useWidgetConfig() const { getAddressLink } = useExplorer() @@ -59,7 +57,6 @@ export const BookmarksPage = () => { isDirty: true, }) setSelectedBookmark(bookmark) - setSendToWallet(true) navigate({ to: navigationRoutes.home, replace: true }) } diff --git a/packages/widget/src/pages/SendToWallet/ConfirmAddressSheet.tsx b/packages/widget/src/pages/SendToWallet/ConfirmAddressSheet.tsx index 707fdc145..65a76277a 100644 --- a/packages/widget/src/pages/SendToWallet/ConfirmAddressSheet.tsx +++ b/packages/widget/src/pages/SendToWallet/ConfirmAddressSheet.tsx @@ -11,7 +11,6 @@ import { useNavigateBack } from '../../hooks/useNavigateBack.js' import { useSetContentHeight } from '../../hooks/useSetContentHeight.js' import type { Bookmark } from '../../stores/bookmarks/types.js' import { useFieldActions } from '../../stores/form/useFieldActions.js' -import { useSendToWalletActions } from '../../stores/settings/useSendToWalletStore.js' import { IconContainer, SendToWalletButtonRow, @@ -52,8 +51,6 @@ const ConfirmAddressSheetContent: React.FC = ({ const { t } = useTranslation() const navigateBack = useNavigateBack() const { setFieldValue } = useFieldActions() - const { setSendToWallet } = useSendToWalletActions() - const containerRef = useRef(null) useSetContentHeight(containerRef) @@ -64,7 +61,6 @@ const ConfirmAddressSheetContent: React.FC = ({ isDirty: true, }) onConfirm?.(validatedBookmark) - setSendToWallet(true) onClose() navigateBack() } diff --git a/packages/widget/src/pages/SendToWallet/ConnectedWalletsPage.tsx b/packages/widget/src/pages/SendToWallet/ConnectedWalletsPage.tsx index 584fb9b5a..a0ae57246 100644 --- a/packages/widget/src/pages/SendToWallet/ConnectedWalletsPage.tsx +++ b/packages/widget/src/pages/SendToWallet/ConnectedWalletsPage.tsx @@ -17,7 +17,6 @@ import { useHeader } from '../../hooks/useHeader.js' import { useToAddressRequirements } from '../../hooks/useToAddressRequirements.js' import { useBookmarkActions } from '../../stores/bookmarks/useBookmarkActions.js' import { useFieldActions } from '../../stores/form/useFieldActions.js' -import { useSendToWalletActions } from '../../stores/settings/useSendToWalletStore.js' import { navigationRoutes } from '../../utils/navigationRoutes.js' import { shortenAddress } from '../../utils/wallet.js' import { EmptyListIndicator } from './EmptyListIndicator.js' @@ -35,7 +34,6 @@ export const ConnectedWalletsPage = () => { const { requiredToChainType } = useToAddressRequirements() const navigate = useNavigate() const { setFieldValue } = useFieldActions() - const { setSendToWallet } = useSendToWalletActions() const [moreMenuAnchorEl, setMenuAnchorEl] = useState() const moreMenuId = useId() const open = Boolean(moreMenuAnchorEl) @@ -54,7 +52,6 @@ export const ConnectedWalletsPage = () => { chainType: account.chainType!, isConnectedAccount: true, }) - setSendToWallet(true) navigate({ to: navigationRoutes.home, replace: true, diff --git a/packages/widget/src/pages/SendToWallet/RecentWalletsPage.tsx b/packages/widget/src/pages/SendToWallet/RecentWalletsPage.tsx index cff25ae09..0688ca3f1 100644 --- a/packages/widget/src/pages/SendToWallet/RecentWalletsPage.tsx +++ b/packages/widget/src/pages/SendToWallet/RecentWalletsPage.tsx @@ -20,7 +20,6 @@ import type { Bookmark } from '../../stores/bookmarks/types.js' import { useBookmarkActions } from '../../stores/bookmarks/useBookmarkActions.js' import { useBookmarks } from '../../stores/bookmarks/useBookmarks.js' import { useFieldActions } from '../../stores/form/useFieldActions.js' -import { useSendToWalletActions } from '../../stores/settings/useSendToWalletStore.js' import { defaultChainIdsByType } from '../../utils/chainType.js' import { navigationRoutes } from '../../utils/navigationRoutes.js' import { shortenAddress } from '../../utils/wallet.js' @@ -46,7 +45,6 @@ export const RecentWalletsPage = () => { addRecentWallet, } = useBookmarkActions() const { setFieldValue } = useFieldActions() - const { setSendToWallet } = useSendToWalletActions() const moreMenuId = useId() const [moreMenuAnchorEl, setMenuAnchorEl] = useState() const open = Boolean(moreMenuAnchorEl) @@ -61,7 +59,6 @@ export const RecentWalletsPage = () => { isDirty: true, }) setSelectedBookmark(recentWallet) - setSendToWallet(true) navigate({ to: navigationRoutes.home, replace: true, diff --git a/packages/widget/src/pages/TransactionDetailsPage/ContactSupportButton.tsx b/packages/widget/src/pages/TransactionDetailsPage/ContactSupportButton.tsx index 9474dc1e0..17d932eb0 100644 --- a/packages/widget/src/pages/TransactionDetailsPage/ContactSupportButton.tsx +++ b/packages/widget/src/pages/TransactionDetailsPage/ContactSupportButton.tsx @@ -1,7 +1,6 @@ -import { Button } from '@mui/material' import { useTranslation } from 'react-i18next' -import { useWidgetEvents } from '../../hooks/useWidgetEvents.js' -import { WidgetEvent } from '../../types/events.js' +import { ButtonChip } from '../../components/ButtonChip/ButtonChip.js' +import { useContactSupport } from '../../hooks/useContactSupport.js' interface ContactSupportButtonProps { supportId?: string @@ -11,22 +10,11 @@ export const ContactSupportButton = ({ supportId, }: ContactSupportButtonProps) => { const { t } = useTranslation() - const widgetEvents = useWidgetEvents() - - const handleClick = () => { - if (!widgetEvents.all.has(WidgetEvent.ContactSupport)) { - const url = 'https://help.li.fi' - const target = '_blank' - const rel = 'nofollow noreferrer' - window.open(url, target, rel) - } else { - widgetEvents.emit(WidgetEvent.ContactSupport, { supportId }) - } - } + const handleContactSupport = useContactSupport(supportId) return ( - + ) } diff --git a/packages/widget/src/pages/TransactionDetailsPage/TransactionDetailsPage.tsx b/packages/widget/src/pages/TransactionDetailsPage/TransactionDetailsPage.tsx index d9d5adcbc..95a0ee800 100644 --- a/packages/widget/src/pages/TransactionDetailsPage/TransactionDetailsPage.tsx +++ b/packages/widget/src/pages/TransactionDetailsPage/TransactionDetailsPage.tsx @@ -1,45 +1,40 @@ import type { FullStatusData } from '@lifi/sdk' -import { Box, Typography } from '@mui/material' +import { Box } from '@mui/material' import { useLocation, useNavigate } from '@tanstack/react-router' import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { Card } from '../../components/Card/Card.js' import { ContractComponent } from '../../components/ContractComponent/ContractComponent.js' +import { DateLabel } from '../../components/DateLabel/DateLabel.js' import { PageContainer } from '../../components/PageContainer.js' -import { getStepList } from '../../components/Step/StepList.js' -import { TransactionDetails } from '../../components/TransactionDetails.js' +import { RouteTokens } from '../../components/RouteCard/RouteTokens.js' import { internalExplorerUrl } from '../../config/constants.js' import { useExplorer } from '../../hooks/useExplorer.js' import { useHeader } from '../../hooks/useHeader.js' import { useTools } from '../../hooks/useTools.js' import { useTransactionDetails } from '../../hooks/useTransactionDetails.js' import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' -import { useRouteExecutionStore } from '../../stores/routes/RouteExecutionStore.js' import { getSourceTxHash } from '../../stores/routes/utils.js' -import { HiddenUI } from '../../types/widget.js' import { buildRouteFromTxHistory } from '../../utils/converters.js' import { navigationRoutes } from '../../utils/navigationRoutes.js' -import { ContactSupportButton } from './ContactSupportButton.js' +import { ReceiptsCard } from '../TransactionPage/ReceiptsCard.js' import { TransactionDetailsSkeleton } from './TransactionDetailsSkeleton.js' import { TransferIdCard } from './TransferIdCard.js' export const TransactionDetailsPage: React.FC = () => { - const { t, i18n } = useTranslation() + const { t } = useTranslation() const navigate = useNavigate() const { subvariant, subvariantOptions, contractSecondaryComponent, explorerUrls, - hiddenUI, } = useWidgetConfig() const { search }: any = useLocation() const { tools } = useTools() const { getTransactionLink } = useExplorer() - const storedRouteExecution = useRouteExecutionStore( - (store) => store.routes[search?.routeId] - ) const { transaction, isLoading } = useTransactionDetails( - !storedRouteExecution && search?.transactionHash + search?.transactionHash ) const title = @@ -49,20 +44,13 @@ export const TransactionDetailsPage: React.FC = () => { useHeader(title) const routeExecution = useMemo(() => { - if (storedRouteExecution) { - return storedRouteExecution - } if (isLoading) { return } if (transaction) { - const routeExecution = buildRouteFromTxHistory( - transaction as FullStatusData, - tools - ) - return routeExecution + return buildRouteFromTxHistory(transaction as FullStatusData, tools) } - }, [isLoading, storedRouteExecution, tools, transaction]) + }, [isLoading, tools, transaction]) useEffect(() => { if (!isLoading && !routeExecution) { @@ -93,61 +81,34 @@ export const TransactionDetailsPage: React.FC = () => { } const startedAt = new Date( - (routeExecution?.route.steps[0].execution?.startedAt ?? 0) * - (storedRouteExecution ? 1 : 1000) // local and BE routes have different ms handling + (routeExecution?.route.steps[0].execution?.startedAt ?? 0) * 1000 ) - return isLoading && !storedRouteExecution ? ( - - ) : ( - - - - {startedAt.toLocaleString(i18n.language, { - dateStyle: 'long', - })} - - - {startedAt.toLocaleString(i18n.language, { - timeStyle: 'short', - })} - - - {getStepList(routeExecution?.route, subvariant)} + if (isLoading) { + return + } + + if (!routeExecution?.route) { + return null + } + + return ( + + + + + + + + {subvariant === 'custom' && contractSecondaryComponent ? ( - - {contractSecondaryComponent} - + {contractSecondaryComponent} ) : null} - {routeExecution?.route ? ( - - ) : null} - - {!hiddenUI?.includes(HiddenUI.ContactSupport) ? ( - - - + {supportId ? ( + ) : null} ) diff --git a/packages/widget/src/pages/TransactionDetailsPage/TransferIdCard.tsx b/packages/widget/src/pages/TransactionDetailsPage/TransferIdCard.tsx index 0a7dd9e7c..85bb1bd8f 100644 --- a/packages/widget/src/pages/TransactionDetailsPage/TransferIdCard.tsx +++ b/packages/widget/src/pages/TransactionDetailsPage/TransferIdCard.tsx @@ -5,6 +5,9 @@ import { useTranslation } from 'react-i18next' import { Card } from '../../components/Card/Card.js' import { CardIconButton } from '../../components/Card/CardIconButton.js' import { CardTitle } from '../../components/Card/CardTitle.js' +import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' +import { HiddenUI } from '../../types/widget.js' +import { ContactSupportButton } from './ContactSupportButton.js' interface TransferIdCardProps { transferId: string @@ -13,6 +16,7 @@ interface TransferIdCardProps { export const TransferIdCard = ({ transferId, txLink }: TransferIdCardProps) => { const { t } = useTranslation() + const { hiddenUI } = useWidgetConfig() const copyTransferId = async () => { await navigator.clipboard.writeText(transferId) @@ -23,20 +27,19 @@ export const TransferIdCard = ({ transferId, txLink }: TransferIdCardProps) => { } return ( - + - {t('main.transferId')} + {t('main.transferId')} @@ -47,14 +50,15 @@ export const TransferIdCard = ({ transferId, txLink }: TransferIdCardProps) => { ) : null} + {!hiddenUI?.includes(HiddenUI.ContactSupport) ? ( + + ) : null} diff --git a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryItem.tsx b/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryItem.tsx deleted file mode 100644 index c6c87cf2f..000000000 --- a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryItem.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import type { - ExtendedTransactionInfo, - FullStatusData, - StatusResponse, - TokenAmount, -} from '@lifi/sdk' -import { Box, Typography } from '@mui/material' -import { useNavigate } from '@tanstack/react-router' -import { useTranslation } from 'react-i18next' -import { Card } from '../../components/Card/Card.js' -import { Token } from '../../components/Token/Token.js' -import { TokenDivider } from '../../components/Token/Token.style.js' -import { navigationRoutes } from '../../utils/navigationRoutes.js' - -export const TransactionHistoryItem: React.FC<{ - transaction: StatusResponse - start: number -}> = ({ transaction, start }) => { - const { i18n } = useTranslation() - const navigate = useNavigate() - - const sending = transaction.sending as ExtendedTransactionInfo - const receiving = (transaction as FullStatusData) - .receiving as ExtendedTransactionInfo - - const handleClick = () => { - navigate({ - to: navigationRoutes.transactionDetails, - search: { - transactionHash: (transaction as FullStatusData).sending.txHash, - }, - }) - } - - const startedAt = new Date((sending.timestamp ?? 0) * 1000) - - if (!sending.token?.chainId || !receiving.token?.chainId) { - return null - } - - const fromToken: TokenAmount = { - ...sending.token, - amount: BigInt(sending.amount ?? '0'), - priceUSD: sending.token.priceUSD ?? '0', - symbol: sending.token?.symbol ?? '', - decimals: sending.token?.decimals ?? 0, - name: sending.token?.name ?? '', - chainId: sending.token?.chainId, - } - - const toToken: TokenAmount = { - ...receiving.token, - amount: BigInt(receiving.amount ?? '0'), - priceUSD: receiving.token.priceUSD ?? '0', - symbol: receiving.token?.symbol ?? '', - decimals: receiving.token?.decimals ?? 0, - name: receiving.token?.name ?? '', - chainId: receiving.token?.chainId, - } - - return ( - - - - {startedAt.toLocaleString(i18n.language, { dateStyle: 'long' })} - - - {startedAt.toLocaleString(i18n.language, { - timeStyle: 'short', - })} - - - - - - - - - - - ) -} diff --git a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistorySkeleton.tsx b/packages/widget/src/pages/TransactionHistoryPage/TransactionHistorySkeleton.tsx deleted file mode 100644 index 8c7fc2905..000000000 --- a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistorySkeleton.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Box, Skeleton } from '@mui/material' -import { Card } from '../../components/Card/Card.js' -import { TokenSkeleton } from '../../components/Token/Token.js' -import { TokenDivider } from '../../components/Token/Token.style.js' - -export const TransactionHistoryItemSkeleton = () => { - return ( - - - ({ - borderRadius: theme.vars.shape.borderRadius, - })} - /> - ({ - borderRadius: theme.vars.shape.borderRadius, - })} - /> - - - - - - - - - - ) -} diff --git a/packages/widget/src/pages/TransactionPage/ExchangeRateBottomSheet.style.tsx b/packages/widget/src/pages/TransactionPage/ExchangeRateBottomSheet.style.tsx new file mode 100644 index 000000000..07e039730 --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/ExchangeRateBottomSheet.style.tsx @@ -0,0 +1,7 @@ +import { Box, styled } from '@mui/material' + +export const CenterContainer = styled(Box)(() => ({ + display: 'grid', + placeItems: 'center', + position: 'relative', +})) diff --git a/packages/widget/src/pages/TransactionPage/ExchangeRateBottomSheet.tsx b/packages/widget/src/pages/TransactionPage/ExchangeRateBottomSheet.tsx index 30e246af8..9ec7f6f3b 100644 --- a/packages/widget/src/pages/TransactionPage/ExchangeRateBottomSheet.tsx +++ b/packages/widget/src/pages/TransactionPage/ExchangeRateBottomSheet.tsx @@ -1,5 +1,4 @@ import type { ExchangeRateUpdateParams } from '@lifi/sdk' -import WarningRounded from '@mui/icons-material/WarningRounded' import { Box, Button, Typography } from '@mui/material' import type { RefObject } from 'react' import { @@ -12,9 +11,10 @@ import { import { useTranslation } from 'react-i18next' import { BottomSheet } from '../../components/BottomSheet/BottomSheet.js' import type { BottomSheetBase } from '../../components/BottomSheet/types.js' +import { IconCircle } from '../../components/IconCircle/IconCircle.js' import { useSetContentHeight } from '../../hooks/useSetContentHeight.js' import { formatTokenAmount } from '../../utils/format.js' -import { CenterContainer, IconCircle } from './StatusBottomSheet.style.js' +import { CenterContainer } from './ExchangeRateBottomSheet.style.js' export interface ExchangeRateBottomSheetBase { isOpen(): void @@ -111,9 +111,7 @@ const ExchangeRateBottomSheetContent: React.FC< }} > - - - + { + const { t } = useTranslation() + + const toToken = { + ...(route.steps.at(-1)?.execution?.toToken ?? route.toToken), + amount: BigInt( + route.steps.at(-1)?.execution?.toAmount ?? + route.steps.at(-1)?.estimate.toAmount ?? + route.toAmount + ), + } + + return ( + + + + {hasEnumFlag(status, RouteExecutionStatus.Refunded) + ? t('header.refunded') + : t('header.received')} + + + + + ) +} diff --git a/packages/widget/src/pages/TransactionPage/ExecutionProgressCards.tsx b/packages/widget/src/pages/TransactionPage/ExecutionProgressCards.tsx new file mode 100644 index 000000000..0bc928df2 --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/ExecutionProgressCards.tsx @@ -0,0 +1,46 @@ +import type { RouteExtended } from '@lifi/sdk' +import { Box } from '@mui/material' +import { Card } from '../../components/Card/Card.js' +import { ExecutionProgress } from '../../components/ExecutionProgress/ExecutionProgress.js' +import { RouteTokens } from '../../components/RouteCard/RouteTokens.js' +import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' +import { RouteExecutionStatus } from '../../stores/routes/types.js' +import { hasEnumFlag } from '../../utils/enum.js' +import { ExecutionDoneCard } from './ExecutionDoneCard.js' +import { StepActionsList } from './StepActionsList.js' + +interface ExecutionProgressCardsProps { + route: RouteExtended + status: RouteExecutionStatus +} + +export const ExecutionProgressCards: React.FC = ({ + route, + status, +}) => { + const { feeConfig } = useWidgetConfig() + const isDone = hasEnumFlag(status, RouteExecutionStatus.Done) + const toAddress = isDone ? route.toAddress : undefined + + const VcComponent = + status === RouteExecutionStatus.Done ? feeConfig?._vcComponent : undefined + + return ( + + + + + + + + {isDone ? ( + + ) : ( + + + + )} + {VcComponent ? : null} + + ) +} diff --git a/packages/widget/src/pages/TransactionPage/ReceiptsCard.style.tsx b/packages/widget/src/pages/TransactionPage/ReceiptsCard.style.tsx new file mode 100644 index 000000000..572658e82 --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/ReceiptsCard.style.tsx @@ -0,0 +1,21 @@ +import { Box, Link, styled } from '@mui/material' + +export const TransactionList = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1.5), +})) + +export const ExternalLink = styled(Link)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 24, + height: 24, + borderRadius: '50%', + textDecoration: 'none', + color: theme.vars.palette.text.primary, + '&:hover': { + backgroundColor: `rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.04)`, + }, +})) diff --git a/packages/widget/src/pages/TransactionPage/ReceiptsCard.tsx b/packages/widget/src/pages/TransactionPage/ReceiptsCard.tsx new file mode 100644 index 000000000..9d1670888 --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/ReceiptsCard.tsx @@ -0,0 +1,21 @@ +import type { RouteExtended } from '@lifi/sdk' +import { useTranslation } from 'react-i18next' +import { Card } from '../../components/Card/Card.js' +import { CardTitle } from '../../components/Card/CardTitle.js' +import { StepActionsList } from './StepActionsList.js' + +interface ReceiptsCardProps { + route: RouteExtended +} + +export const ReceiptsCard = ({ route }: ReceiptsCardProps) => { + const { t } = useTranslation() + const toAddress = route.toAddress + + return ( + + {t('main.receipts')} + + + ) +} diff --git a/packages/widget/src/pages/TransactionPage/SentToWalletRow.tsx b/packages/widget/src/pages/TransactionPage/SentToWalletRow.tsx new file mode 100644 index 000000000..da78543c7 --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/SentToWalletRow.tsx @@ -0,0 +1,59 @@ +import ContentCopyRounded from '@mui/icons-material/ContentCopyRounded' +import OpenInNew from '@mui/icons-material/OpenInNew' +import Wallet from '@mui/icons-material/Wallet' +import { Box, IconButton } from '@mui/material' +import type { MouseEvent } from 'react' +import { useTranslation } from 'react-i18next' +import { ActionRow } from '../../components/ActionRow/ActionRow.js' +import { ActionIconCircle } from '../../components/ActionRow/ActionRow.style.js' +import { useExplorer } from '../../hooks/useExplorer.js' +import { shortenAddress } from '../../utils/wallet.js' +import { ExternalLink } from './ReceiptsCard.style.js' + +interface SentToWalletRowProps { + toAddress: string + toChainId: number +} + +export const SentToWalletRow: React.FC = ({ + toAddress, + toChainId, +}) => { + const { t } = useTranslation() + const { getAddressLink } = useExplorer() + const addressLink = getAddressLink(toAddress, toChainId) + + const handleCopy = (e: MouseEvent) => { + e.stopPropagation() + navigator.clipboard.writeText(toAddress) + } + + return ( + + + + } + message={t('main.sentToWallet', { + address: shortenAddress(toAddress), + })} + endAdornment={ + + + + + {addressLink ? ( + + + + ) : undefined} + + } + /> + ) +} diff --git a/packages/widget/src/pages/TransactionPage/StatusBottomSheet.style.tsx b/packages/widget/src/pages/TransactionPage/StatusBottomSheet.style.tsx deleted file mode 100644 index 64560fb9c..000000000 --- a/packages/widget/src/pages/TransactionPage/StatusBottomSheet.style.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { Theme } from '@mui/material' -import { Box, styled } from '@mui/material' -import { RouteExecutionStatus } from '../../stores/routes/types.js' - -type StatusColor = RouteExecutionStatus | 'warning' - -const getStatusColor = (status: StatusColor, theme: Theme) => { - switch (status) { - case RouteExecutionStatus.Done: - return { - color: theme.vars.palette.success.mainChannel, - alpha: 0.12, - lightDarken: 0, - darkDarken: 0, - } - case RouteExecutionStatus.Failed: - return { - color: theme.vars.palette.error.mainChannel, - alpha: 0.12, - lightDarken: 0, - darkDarken: 0, - } - case RouteExecutionStatus.Done | RouteExecutionStatus.Partial: - case RouteExecutionStatus.Done | RouteExecutionStatus.Refunded: - case 'warning': - return { - color: theme.vars.palette.warning.mainChannel, - alpha: 0.48, - lightDarken: 0.32, - darkDarken: 0, - } - default: - return { - color: theme.vars.palette.primary.mainChannel, - alpha: 0.12, - lightDarken: 0, - darkDarken: 0, - } - } -} - -export const CenterContainer = styled(Box)(() => ({ - display: 'grid', - placeItems: 'center', - position: 'relative', -})) - -export const IconCircle = styled(Box, { - shouldForwardProp: (prop) => prop !== 'status', -})<{ status: StatusColor }>(({ theme, status }) => { - const statusConfig = getStatusColor(status, theme) - - return { - backgroundColor: `rgba(${statusConfig.color} / ${statusConfig.alpha})`, - borderRadius: '50%', - width: 72, - height: 72, - display: 'grid', - position: 'relative', - placeItems: 'center', - '& > svg': { - color: `color-mix(in srgb, rgb(${statusConfig.color}) ${(1 - statusConfig.lightDarken) * 100}%, black)`, - width: 36, - height: 36, - }, - ...theme.applyStyles('dark', { - '& > svg': { - color: `color-mix(in srgb, rgb(${statusConfig.color}) ${(1 - statusConfig.darkDarken) * 100}%, black)`, - width: 36, - height: 36, - }, - }), - } -}) diff --git a/packages/widget/src/pages/TransactionPage/StatusBottomSheet.tsx b/packages/widget/src/pages/TransactionPage/StatusBottomSheet.tsx deleted file mode 100644 index 74745b0be..000000000 --- a/packages/widget/src/pages/TransactionPage/StatusBottomSheet.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import type { ExecutionActionType } from '@lifi/sdk' -import Done from '@mui/icons-material/Done' -import ErrorRounded from '@mui/icons-material/ErrorRounded' -import InfoRounded from '@mui/icons-material/InfoRounded' -import WarningRounded from '@mui/icons-material/WarningRounded' -import { Box, Button, Typography } from '@mui/material' -import { useNavigate } from '@tanstack/react-router' -import { useCallback, useEffect, useRef } from 'react' -import { useTranslation } from 'react-i18next' -import { BottomSheet } from '../../components/BottomSheet/BottomSheet.js' -import type { BottomSheetBase } from '../../components/BottomSheet/types.js' -import { Card } from '../../components/Card/Card.js' -import { CardTitle } from '../../components/Card/CardTitle.js' -import { Token } from '../../components/Token/Token.js' -import { useAvailableChains } from '../../hooks/useAvailableChains.js' -import { useSetContentHeight } from '../../hooks/useSetContentHeight.js' -import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' -import { useFieldActions } from '../../stores/form/useFieldActions.js' -import { - type RouteExecution, - RouteExecutionStatus, -} from '../../stores/routes/types.js' -import { getSourceTxHash } from '../../stores/routes/utils.js' -import { hasEnumFlag } from '../../utils/enum.js' -import { formatTokenAmount } from '../../utils/format.js' -import { getErrorMessage } from '../../utils/getErrorMessage.js' -import { navigationRoutes } from '../../utils/navigationRoutes.js' -import { CenterContainer, IconCircle } from './StatusBottomSheet.style.js' - -interface StatusBottomSheetContentProps extends RouteExecution { - onClose(): void -} - -export const StatusBottomSheet: React.FC = ({ - status, - route, -}) => { - const ref = useRef(null) - - const onClose = useCallback(() => { - ref.current?.close() - }, []) - - useEffect(() => { - const hasSuccessFlag = hasEnumFlag(status, RouteExecutionStatus.Done) - const hasFailedFlag = hasEnumFlag(status, RouteExecutionStatus.Failed) - if ((hasSuccessFlag || hasFailedFlag) && !ref.current?.isOpen()) { - ref.current?.open() - } - }, [status]) - - return ( - - - - ) -} - -const StatusBottomSheetContent: React.FC = ({ - status, - route, - onClose, -}) => { - const { t } = useTranslation() - const navigate = useNavigate() - const { setFieldValue } = useFieldActions() - const { - subvariant, - subvariantOptions, - contractSecondaryComponent, - contractCompactComponent, - feeConfig, - } = useWidgetConfig() - const { getChainById } = useAvailableChains() - - const ref = useRef(null) - useSetContentHeight(ref) - - const toToken = { - ...(route.steps.at(-1)?.execution?.toToken ?? route.toToken), - amount: BigInt( - route.steps.at(-1)?.execution?.toAmount ?? - route.steps.at(-1)?.estimate.toAmount ?? - route.toAmount - ), - } - - const cleanFields = () => { - setFieldValue('fromAmount', '') - setFieldValue('toAmount', '') - } - - const handleDone = () => { - cleanFields() - navigate({ to: navigationRoutes.home, replace: true }) - } - - const handlePartialDone = () => { - if ( - toToken.chainId !== route.toToken.chainId && - toToken.address !== route.toToken.address - ) { - setFieldValue( - 'fromAmount', - formatTokenAmount(toToken.amount, toToken.decimals), - { isTouched: true } - ) - setFieldValue('fromChain', toToken.chainId, { isTouched: true }) - setFieldValue('fromToken', toToken.address, { isTouched: true }) - setFieldValue('toChain', route.toToken.chainId, { - isTouched: true, - }) - setFieldValue('toToken', route.toToken.address, { - isTouched: true, - }) - } else { - cleanFields() - } - navigate({ to: navigationRoutes.home, replace: true }) - } - - const handleClose = () => { - cleanFields() - onClose() - } - - const handleSeeDetails = () => { - handleClose() - - const transactionHash = getSourceTxHash(route) - - navigate({ - to: navigationRoutes.transactionDetails, - search: { - routeId: route.id, - transactionHash, - }, - replace: true, - }) - } - - const transactionType = - route.fromChainId === route.toChainId ? 'swap' : 'bridge' - - let title: string | undefined - let primaryMessage: string | undefined - let failedMessage: string | undefined - let handlePrimaryButton = handleDone - switch (status) { - case RouteExecutionStatus.Done: { - title = - subvariant === 'custom' - ? t( - `success.title.${subvariantOptions?.custom ?? 'checkout'}Successful` - ) - : t(`success.title.${transactionType}Successful`) - handlePrimaryButton = handleDone - break - } - case RouteExecutionStatus.Done | RouteExecutionStatus.Partial: { - title = t(`success.title.${transactionType}PartiallySuccessful`) - primaryMessage = t('success.message.exchangePartiallySuccessful', { - tool: route.steps.at(-1)?.toolDetails.name, - tokenSymbol: route.steps.at(-1)?.action.toToken.symbol, - }) - handlePrimaryButton = handlePartialDone - break - } - case RouteExecutionStatus.Done | RouteExecutionStatus.Refunded: { - title = t('success.title.refundIssued') - primaryMessage = t('success.message.exchangePartiallySuccessful', { - tool: route.steps.at(-1)?.toolDetails.name, - tokenSymbol: route.steps.at(-1)?.action.toToken.symbol, - }) - break - } - case RouteExecutionStatus.Failed: { - const step = route.steps.find( - (step) => step.execution?.status === 'FAILED' - ) - if (!step) { - break - } - const action = step.execution?.actions?.find( - (action) => action.status === 'FAILED' - ) || { - status: 'FAILED', - type: 'EXECUTION' as ExecutionActionType, - error: step.execution?.error, - } // synthetic action to represent a failed execution with no actions - const actionMessage = getErrorMessage(t, getChainById, step, action) - title = actionMessage.title - failedMessage = actionMessage.message - handlePrimaryButton = handleClose - break - } - default: - break - } - - const showContractComponent = - subvariant === 'custom' && - hasEnumFlag(status, RouteExecutionStatus.Done) && - (contractCompactComponent || contractSecondaryComponent) - - const VcComponent = - status === RouteExecutionStatus.Done ? feeConfig?._vcComponent : undefined - - return ( - - {!showContractComponent ? ( - - - {status === RouteExecutionStatus.Idle ? ( - - ) : null} - {status === RouteExecutionStatus.Done ? ( - - ) : null} - {hasEnumFlag(status, RouteExecutionStatus.Partial) || - hasEnumFlag(status, RouteExecutionStatus.Refunded) ? ( - - ) : null} - {hasEnumFlag(status, RouteExecutionStatus.Failed) ? ( - - ) : null} - - - ) : null} - - - {title} - - - {showContractComponent ? ( - contractCompactComponent || contractSecondaryComponent - ) : hasEnumFlag(status, RouteExecutionStatus.Failed) && failedMessage ? ( - - {failedMessage} - - ) : hasEnumFlag(status, RouteExecutionStatus.Done) ? ( - - - - {hasEnumFlag(status, RouteExecutionStatus.Refunded) - ? t('header.refunded') - : t('header.received')} - - - {primaryMessage && ( - - {primaryMessage} - - )} - - {VcComponent ? : null} - - ) : null} - - {hasEnumFlag(status, RouteExecutionStatus.Done) ? ( - - ) : null} - - - - ) -} diff --git a/packages/widget/src/pages/TransactionPage/StepActionRow.tsx b/packages/widget/src/pages/TransactionPage/StepActionRow.tsx new file mode 100644 index 000000000..184945b5c --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/StepActionRow.tsx @@ -0,0 +1,29 @@ +import type { ExecutionAction, LiFiStepExtended } from '@lifi/sdk' +import OpenInNew from '@mui/icons-material/OpenInNew' +import type React from 'react' +import { ActionRow } from '../../components/ActionRow/ActionRow.js' +import { IconCircle } from '../../components/IconCircle/IconCircle.js' +import { useActionMessage } from '../../hooks/useActionMessage.js' +import { ExternalLink } from './ReceiptsCard.style.js' + +export const StepActionRow: React.FC<{ + step: LiFiStepExtended + action: ExecutionAction + href: string +}> = ({ step, action, href }) => { + const { title } = useActionMessage(step, action) + const isFailed = action?.status === 'FAILED' + return ( + + } + message={title ?? ''} + endAdornment={ + + + + } + /> + ) +} diff --git a/packages/widget/src/pages/TransactionPage/StepActionsList.tsx b/packages/widget/src/pages/TransactionPage/StepActionsList.tsx new file mode 100644 index 000000000..8a2aaa06e --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/StepActionsList.tsx @@ -0,0 +1,68 @@ +import type { RouteExtended } from '@lifi/sdk' +import { useExplorer } from '../../hooks/useExplorer.js' +import { prepareActions } from '../../utils/prepareActions.js' +import { TransactionList } from './ReceiptsCard.style.js' +import { SentToWalletRow } from './SentToWalletRow.js' +import { StepActionRow } from './StepActionRow.js' + +interface StepActionsListProps { + route: RouteExtended + toAddress?: string +} + +export const StepActionsList: React.FC = ({ + route, + toAddress, +}) => { + const { getTransactionLink } = useExplorer() + const stepRows = route.steps + .map((step) => { + const rows = prepareActions(step.execution?.actions ?? []) + .map((actionsGroup) => { + const action = actionsGroup.at(-1) + const href = action?.txHash + ? getTransactionLink({ + txHash: action.txHash, + chain: action.chainId, + }) + : action?.txLink + ? getTransactionLink({ + txLink: action.txLink, + chain: action.chainId, + }) + : undefined + return { action, href } + }) + .filter(({ action, href }) => { + const doneOrFailed = + action?.status === 'DONE' || action?.status === 'FAILED' + return Boolean(href && doneOrFailed) + }) + return { step, rows } + }) + .filter(({ rows }) => rows.length > 0) + + if (!stepRows?.length) { + return null + } + + return ( + + {stepRows.map(({ step, rows }) => ( + + {rows.map(({ action, href }, index) => ( + + ))} + + ))} + {toAddress ? ( + + ) : null} + + ) +} diff --git a/packages/widget/src/pages/TransactionPage/TokenValueBottomSheet.style.tsx b/packages/widget/src/pages/TransactionPage/TokenValueBottomSheet.style.tsx new file mode 100644 index 000000000..5d107ece0 --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/TokenValueBottomSheet.style.tsx @@ -0,0 +1,49 @@ +import { Box, styled, Typography } from '@mui/material' + +export const CenterContainer = styled(Box)(() => ({ + display: 'grid', + placeItems: 'center', + position: 'relative', +})) + +export const ContentContainer = styled(Box)(({ theme }) => ({ + padding: theme.spacing(3), +})) + +export const WarningTitle = styled(Typography)(({ theme }) => ({ + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + fontSize: 18, + fontWeight: 700, +})) + +export const WarningMessage = styled(Typography)(({ theme }) => ({ + paddingBottom: theme.spacing(2), + color: theme.vars.palette.text.secondary, + fontSize: 14, +})) + +export const DetailRow = styled(Box)(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + paddingTop: theme.spacing(0.75), + paddingBottom: theme.spacing(0.75), +})) + +export const DetailLabel = styled(Typography)(({ theme }) => ({ + fontSize: 14, + fontWeight: 500, + color: theme.vars.palette.text.secondary, +})) + +export const DetailValue = styled(Typography)({ + fontSize: 14, + fontWeight: 700, +}) + +export const ButtonRow = styled(Box)(({ theme }) => ({ + display: 'flex', + marginTop: theme.spacing(3), + gap: theme.spacing(1.5), +})) diff --git a/packages/widget/src/pages/TransactionPage/TokenValueBottomSheet.tsx b/packages/widget/src/pages/TransactionPage/TokenValueBottomSheet.tsx index 7f63cfc0b..05863de3f 100644 --- a/packages/widget/src/pages/TransactionPage/TokenValueBottomSheet.tsx +++ b/packages/widget/src/pages/TransactionPage/TokenValueBottomSheet.tsx @@ -1,15 +1,24 @@ import type { Route } from '@lifi/sdk' -import WarningRounded from '@mui/icons-material/WarningRounded' -import { Box, Button, Typography } from '@mui/material' +import { Button } from '@mui/material' import type { RefObject } from 'react' import { forwardRef, useRef } from 'react' import { useTranslation } from 'react-i18next' import { BottomSheet } from '../../components/BottomSheet/BottomSheet.js' import type { BottomSheetBase } from '../../components/BottomSheet/types.js' import { FeeBreakdownTooltip } from '../../components/FeeBreakdownTooltip.js' +import { IconCircle } from '../../components/IconCircle/IconCircle.js' import { useSetContentHeight } from '../../hooks/useSetContentHeight.js' import { getAccumulatedFeeCostsBreakdown } from '../../utils/fees.js' -import { CenterContainer, IconCircle } from './StatusBottomSheet.style.js' +import { + ButtonRow, + CenterContainer, + ContentContainer, + DetailLabel, + DetailRow, + DetailValue, + WarningMessage, + WarningTitle, +} from './TokenValueBottomSheet.style.js' import { calculateValueLossPercentage } from './utils.js' interface TokenValueBottomSheetProps { @@ -50,119 +59,49 @@ const TokenValueBottomSheetContent: React.FC = ({ getAccumulatedFeeCostsBreakdown(route) const fromAmountUSD = Number.parseFloat(route.fromAmountUSD) const toAmountUSD = Number.parseFloat(route.toAmountUSD) + return ( - + - - - - - {t('warning.title.highValueLoss')} - + + {t('warning.title.highValueLoss')} - - {t('warning.message.highValueLoss')} - - - {t('main.sending')} - + {t('warning.message.highValueLoss')} + + {t('main.sending')} + {t('format.currency', { value: route.fromAmountUSD })} - - - - {t('main.fees.network')} + + + + {t('main.fees.network')} - + {!gasCostUSD ? t('main.fees.free') : t('format.currency', { value: gasCostUSD })} - + - + {feeCostUSD ? ( - - {t('main.fees.provider')} + + {t('main.fees.provider')} - + {t('format.currency', { value: feeCostUSD })} - + - + ) : null} - - {t('main.receiving')} - + + {t('main.receiving')} + {t('format.currency', { value: route.toAmountUSD })} - - - - {t('main.valueLoss')} - + + + + {t('main.valueLoss')} + {calculateValueLossPercentage( fromAmountUSD, toAmountUSD, @@ -170,27 +109,16 @@ const TokenValueBottomSheetContent: React.FC = ({ feeCostUSD )} % - - - + + + - - - + + ) } diff --git a/packages/widget/src/pages/TransactionPage/TransactionContent.tsx b/packages/widget/src/pages/TransactionPage/TransactionContent.tsx new file mode 100644 index 000000000..a8d30e19b --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/TransactionContent.tsx @@ -0,0 +1,53 @@ +import type { RouteExtended } from '@lifi/sdk' +import { Box } from '@mui/material' +import { WarningMessages } from '../../components/Messages/WarningMessages.js' +import { RouteExecutionStatus } from '../../stores/routes/types.js' +import { hasEnumFlag } from '../../utils/enum.js' +import { ExecutionProgressCards } from './ExecutionProgressCards.js' +import { TransactionDoneButtons } from './TransactionDoneButtons.js' +import { TransactionFailedButtons } from './TransactionFailedButtons.js' +import { TransactionReview } from './TransactionReview.js' + +interface TransactionContentProps { + route: RouteExtended + status: RouteExecutionStatus + executeRoute: () => void + restartRoute: () => void + deleteRoute: () => void + routeRefreshing: boolean +} + +export const TransactionContent: React.FC = ({ + route, + status, + executeRoute, + restartRoute, + deleteRoute, + routeRefreshing, +}) => { + if (status === RouteExecutionStatus.Idle) { + return ( + + ) + } + + return ( + + + + {status === RouteExecutionStatus.Failed ? ( + + ) : hasEnumFlag(status, RouteExecutionStatus.Done) ? ( + + ) : null} + + ) +} diff --git a/packages/widget/src/pages/TransactionPage/TransactionDoneButtons.tsx b/packages/widget/src/pages/TransactionPage/TransactionDoneButtons.tsx new file mode 100644 index 000000000..072b24a67 --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/TransactionDoneButtons.tsx @@ -0,0 +1,78 @@ +import type { RouteExtended } from '@lifi/sdk' +import { Button } from '@mui/material' +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { useFieldActions } from '../../stores/form/useFieldActions' +import { RouteExecutionStatus } from '../../stores/routes/types' +import { hasEnumFlag } from '../../utils/enum' +import { formatTokenAmount } from '../../utils/format' +import { navigationRoutes } from '../../utils/navigationRoutes' + +interface TransactionDoneButtonsProps { + route: RouteExtended + status: RouteExecutionStatus +} + +export const TransactionDoneButtons = ({ + route, + status, +}: TransactionDoneButtonsProps) => { + const { t } = useTranslation() + const navigate = useNavigate() + const { setFieldValue } = useFieldActions() + + if (!hasEnumFlag(status, RouteExecutionStatus.Done)) { + return null + } + + const cleanFields = () => { + setFieldValue('fromAmount', '') + setFieldValue('toAmount', '') + } + + const handlePartialDone = () => { + const toToken = { + ...(route.steps.at(-1)?.execution?.toToken ?? route.toToken), + amount: BigInt( + route.steps.at(-1)?.execution?.toAmount ?? + route.steps.at(-1)?.estimate.toAmount ?? + route.toAmount + ), + } + if ( + toToken.chainId !== route.toToken.chainId && + toToken.address !== route.toToken.address + ) { + setFieldValue( + 'fromAmount', + formatTokenAmount(toToken.amount, toToken.decimals), + { isTouched: true } + ) + setFieldValue('fromChain', toToken.chainId, { isTouched: true }) + setFieldValue('fromToken', toToken.address, { isTouched: true }) + setFieldValue('toChain', route.toToken.chainId, { + isTouched: true, + }) + setFieldValue('toToken', route.toToken.address, { + isTouched: true, + }) + } else { + cleanFields() + } + } + + const handleClick = () => { + if (hasEnumFlag(status, RouteExecutionStatus.Partial)) { + handlePartialDone() + } else { + cleanFields() + } + navigate({ to: navigationRoutes.home, replace: true }) + } + + return ( + + ) +} diff --git a/packages/widget/src/pages/TransactionPage/TransactionFailedButtons.tsx b/packages/widget/src/pages/TransactionPage/TransactionFailedButtons.tsx new file mode 100644 index 000000000..5c09d4dc5 --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/TransactionFailedButtons.tsx @@ -0,0 +1,54 @@ +import type { RouteExtended } from '@lifi/sdk' +import { Box, Button } from '@mui/material' +import { useTranslation } from 'react-i18next' +import { useContactSupport } from '../../hooks/useContactSupport.js' +import { useNavigateBack } from '../../hooks/useNavigateBack.js' +import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' +import { getSourceTxHash } from '../../stores/routes/utils.js' +import { HiddenUI } from '../../types/widget.js' +import { StartTransactionButton } from './StartTransactionButton.js' + +interface TransactionFailedButtonsProps { + route: RouteExtended + restartRoute: () => void + deleteRoute: () => void +} + +export const TransactionFailedButtons: React.FC< + TransactionFailedButtonsProps +> = ({ route, restartRoute, deleteRoute }) => { + const { t } = useTranslation() + const { hiddenUI } = useWidgetConfig() + const navigateBack = useNavigateBack() + const supportId = getSourceTxHash(route) ?? route.id + const handleContactSupport = useContactSupport(supportId) + + const handleRemoveRoute = () => { + navigateBack() + deleteRoute() + } + + return ( + + + + + + + + + + {!hiddenUI?.includes(HiddenUI.ContactSupport) ? ( + + ) : null} + + ) +} diff --git a/packages/widget/src/pages/TransactionPage/TransactionPage.tsx b/packages/widget/src/pages/TransactionPage/TransactionPage.tsx index 926b8a88a..7fcd4cfb6 100644 --- a/packages/widget/src/pages/TransactionPage/TransactionPage.tsx +++ b/packages/widget/src/pages/TransactionPage/TransactionPage.tsx @@ -1,61 +1,33 @@ import type { ExchangeRateUpdateParams } from '@lifi/sdk' -import Delete from '@mui/icons-material/Delete' -import { Box, Button, Tooltip } from '@mui/material' -import { useLocation, useNavigate } from '@tanstack/react-router' +import { useLocation } from '@tanstack/react-router' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import type { BottomSheetBase } from '../../components/BottomSheet/types.js' import { ContractComponent } from '../../components/ContractComponent/ContractComponent.js' -import { WarningMessages } from '../../components/Messages/WarningMessages.js' import { PageContainer } from '../../components/PageContainer.js' -import { getStepList } from '../../components/Step/StepList.js' -import { TransactionDetails } from '../../components/TransactionDetails.js' -import { useAddressActivity } from '../../hooks/useAddressActivity.js' import { useHeader } from '../../hooks/useHeader.js' -import { useNavigateBack } from '../../hooks/useNavigateBack.js' import { useRouteExecution } from '../../hooks/useRouteExecution.js' import { useWidgetEvents } from '../../hooks/useWidgetEvents.js' import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' -import { useFieldActions } from '../../stores/form/useFieldActions.js' -import { useHeaderStore } from '../../stores/header/useHeaderStore.js' +import { useFormStore } from '../../stores/form/useFormStore.js' import { RouteExecutionStatus } from '../../stores/routes/types.js' import { WidgetEvent } from '../../types/events.js' -import { HiddenUI } from '../../types/widget.js' -import { getAccumulatedFeeCostsBreakdown } from '../../utils/fees.js' -import { navigationRoutes } from '../../utils/navigationRoutes.js' -import { ConfirmToAddressSheet } from './ConfirmToAddressSheet.js' import type { ExchangeRateBottomSheetBase } from './ExchangeRateBottomSheet.js' import { ExchangeRateBottomSheet } from './ExchangeRateBottomSheet.js' import { RouteTracker } from './RouteTracker.js' -import { StartTransactionButton } from './StartTransactionButton.js' -import { StatusBottomSheet } from './StatusBottomSheet.js' -import { TokenValueBottomSheet } from './TokenValueBottomSheet.js' -import { - calculateValueLossPercentage, - getTokenValueLossThreshold, -} from './utils.js' +import { TransactionContent } from './TransactionContent.js' export const TransactionPage = () => { const { t } = useTranslation() - const { setFieldValue } = useFieldActions() const emitter = useWidgetEvents() - const setBackAction = useHeaderStore((state) => state.setBackAction) - const navigate = useNavigate() - const navigateBack = useNavigateBack() - const { - subvariant, - subvariantOptions, - contractSecondaryComponent, - hiddenUI, - } = useWidgetConfig() + const { subvariant, subvariantOptions, contractSecondaryComponent } = + useWidgetConfig() const { search }: any = useLocation() const stateRouteId = search?.routeId const [routeId, setRouteId] = useState(stateRouteId) const [routeRefreshing, setRouteRefreshing] = useState(false) + const setFieldValue = useFormStore((store) => store.setFieldValue) - const tokenValueBottomSheetRef = useRef(null) const exchangeRateBottomSheetRef = useRef(null) - const confirmToAddressSheetRef = useRef(null) const onAcceptExchangeRateUpdate = ( resolver: (value: boolean) => void, @@ -70,13 +42,6 @@ export const TransactionPage = () => { onAcceptExchangeRateUpdate, }) - const { - toAddress, - hasActivity, - isLoading: isLoadingAddressActivity, - isFetched: isActivityAddressFetched, - } = useAddressActivity(route?.toChainId) - const getHeaderTitle = () => { if (subvariant === 'custom') { return t(`header.${subvariantOptions?.custom ?? 'checkout'}`) @@ -104,6 +69,11 @@ export const TransactionPage = () => { [stateRouteId, status] ) + const cleanFields = () => { + setFieldValue('fromAmount', '') + setFieldValue('toAmount', '') + } + useHeader(getHeaderTitle(), headerAction) // biome-ignore lint/correctness/useExhaustiveDependencies: We want to emit event only when the page is mounted @@ -111,165 +81,38 @@ export const TransactionPage = () => { if (status === RouteExecutionStatus.Idle) { emitter.emit(WidgetEvent.ReviewTransactionPageEntered, route) } - }, []) - - if (!route) { - return null - } - - const handleExecuteRoute = () => { - if (tokenValueBottomSheetRef.current?.isOpen()) { - const { gasCostUSD, feeCostUSD } = getAccumulatedFeeCostsBreakdown(route) - const fromAmountUSD = Number.parseFloat(route.fromAmountUSD) - const toAmountUSD = Number.parseFloat(route.toAmountUSD) - emitter.emit(WidgetEvent.RouteHighValueLoss, { - fromAmountUSD, - toAmountUSD, - gasCostUSD, - feeCostUSD, - valueLoss: calculateValueLossPercentage( - fromAmountUSD, - toAmountUSD, - gasCostUSD, - feeCostUSD - ), - }) - } - tokenValueBottomSheetRef.current?.close() - executeRoute() - setFieldValue('fromAmount', '') - if (subvariant === 'custom') { - setFieldValue('fromToken', '') - setFieldValue('toToken', '') - } - // Once transaction is started, set the back action to navigate to the home page - setBackAction(() => { - navigate({ to: navigationRoutes.home, replace: true }) - }) - } - const handleStartClick = async () => { - if (status === RouteExecutionStatus.Idle) { - if ( - toAddress && - !hasActivity && - !isLoadingAddressActivity && - isActivityAddressFetched && - !hiddenUI?.includes(HiddenUI.LowAddressActivityConfirmation) - ) { - confirmToAddressSheetRef.current?.open() - return - } - - const { gasCostUSD, feeCostUSD } = getAccumulatedFeeCostsBreakdown(route) - const fromAmountUSD = Number.parseFloat(route.fromAmountUSD) - const toAmountUSD = Number.parseFloat(route.toAmountUSD) - const tokenValueLossThresholdExceeded = getTokenValueLossThreshold( - fromAmountUSD, - toAmountUSD, - gasCostUSD, - feeCostUSD - ) - if (tokenValueLossThresholdExceeded && subvariant !== 'custom') { - tokenValueBottomSheetRef.current?.open() - } else { - handleExecuteRoute() + // Clean form fields when leaving the page + return () => { + if (status !== RouteExecutionStatus.Idle) { + cleanFields() } } - if (status === RouteExecutionStatus.Failed) { - restartRoute() - } - } - - const handleRemoveRoute = () => { - navigateBack() - deleteRoute() - } + }, []) - const getButtonText = (): string => { - switch (status) { - case RouteExecutionStatus.Idle: - switch (subvariant) { - case 'custom': - return subvariantOptions?.custom === 'deposit' - ? t('button.deposit') - : t('button.buy') - case 'refuel': - return t('button.startBridging') - default: { - const transactionType = - route.fromChainId === route.toChainId ? 'Swapping' : 'Bridging' - return t(`button.start${transactionType}`) - } - } - case RouteExecutionStatus.Failed: - return t('button.tryAgain') - default: - return '' - } + if (!route || !status) { + return null } return ( - - {getStepList(route, subvariant)} + + {subvariant === 'custom' && contractSecondaryComponent ? ( {contractSecondaryComponent} ) : null} - - {status === RouteExecutionStatus.Idle || - status === RouteExecutionStatus.Failed ? ( - <> - - - - {status === RouteExecutionStatus.Failed ? ( - - - - ) : null} - - - ) : null} - {status ? : null} - {subvariant !== 'custom' ? ( - - ) : null} - {!hiddenUI?.includes(HiddenUI.LowAddressActivityConfirmation) ? ( - - ) : null} ) } diff --git a/packages/widget/src/pages/TransactionPage/TransactionReview.tsx b/packages/widget/src/pages/TransactionPage/TransactionReview.tsx new file mode 100644 index 000000000..893585f8d --- /dev/null +++ b/packages/widget/src/pages/TransactionPage/TransactionReview.tsx @@ -0,0 +1,160 @@ +import type { RouteExtended } from '@lifi/sdk' +import { Box } from '@mui/material' +import { useNavigate } from '@tanstack/react-router' +import { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import type { BottomSheetBase } from '../../components/BottomSheet/types.js' +import { Card } from '../../components/Card/Card.js' +import { WarningMessages } from '../../components/Messages/WarningMessages.js' +import { RouteTokens } from '../../components/RouteCard/RouteTokens.js' +import { useAddressActivity } from '../../hooks/useAddressActivity.js' +import { useWidgetEvents } from '../../hooks/useWidgetEvents.js' +import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' +import { useFieldActions } from '../../stores/form/useFieldActions.js' +import { useHeaderStore } from '../../stores/header/useHeaderStore.js' +import { WidgetEvent } from '../../types/events.js' +import { HiddenUI } from '../../types/widget.js' +import { getAccumulatedFeeCostsBreakdown } from '../../utils/fees.js' +import { navigationRoutes } from '../../utils/navigationRoutes.js' +import { ConfirmToAddressSheet } from './ConfirmToAddressSheet.js' +import { StartTransactionButton } from './StartTransactionButton.js' +import { TokenValueBottomSheet } from './TokenValueBottomSheet.js' +import { + calculateValueLossPercentage, + getTokenValueLossThreshold, +} from './utils.js' + +interface TransactionReviewProps { + route: RouteExtended + executeRoute: () => void + routeRefreshing: boolean +} + +export const TransactionReview: React.FC = ({ + route, + executeRoute, + routeRefreshing, +}) => { + const { t } = useTranslation() + const navigate = useNavigate() + const { setFieldValue } = useFieldActions() + const emitter = useWidgetEvents() + const setBackAction = useHeaderStore((state) => state.setBackAction) + const { subvariant, subvariantOptions, hiddenUI } = useWidgetConfig() + + const tokenValueBottomSheetRef = useRef(null) + const confirmToAddressSheetRef = useRef(null) + + const { + toAddress, + hasActivity, + isLoading: isLoadingAddressActivity, + isFetched: isActivityAddressFetched, + } = useAddressActivity(route.toChainId) + + const handleExecuteRoute = () => { + if (tokenValueBottomSheetRef.current?.isOpen()) { + const { gasCostUSD, feeCostUSD } = getAccumulatedFeeCostsBreakdown(route) + const fromAmountUSD = Number.parseFloat(route.fromAmountUSD) + const toAmountUSD = Number.parseFloat(route.toAmountUSD) + emitter.emit(WidgetEvent.RouteHighValueLoss, { + fromAmountUSD, + toAmountUSD, + gasCostUSD, + feeCostUSD, + valueLoss: calculateValueLossPercentage( + fromAmountUSD, + toAmountUSD, + gasCostUSD, + feeCostUSD + ), + }) + } + tokenValueBottomSheetRef.current?.close() + executeRoute() + setFieldValue('fromAmount', '') + if (subvariant === 'custom') { + setFieldValue('fromToken', '') + setFieldValue('toToken', '') + } + setBackAction(() => { + navigate({ to: navigationRoutes.home, replace: true }) + }) + } + + const handleStartClick = () => { + if ( + toAddress && + !hasActivity && + !isLoadingAddressActivity && + isActivityAddressFetched && + !hiddenUI?.includes(HiddenUI.LowAddressActivityConfirmation) + ) { + confirmToAddressSheetRef.current?.open() + return + } + + const { gasCostUSD, feeCostUSD } = getAccumulatedFeeCostsBreakdown(route) + const fromAmountUSD = Number.parseFloat(route.fromAmountUSD) + const toAmountUSD = Number.parseFloat(route.toAmountUSD) + const tokenValueLossThresholdExceeded = getTokenValueLossThreshold( + fromAmountUSD, + toAmountUSD, + gasCostUSD, + feeCostUSD + ) + if (tokenValueLossThresholdExceeded && subvariant !== 'custom') { + tokenValueBottomSheetRef.current?.open() + } else { + handleExecuteRoute() + } + } + + const getButtonText = (): string => { + switch (subvariant) { + case 'custom': + return subvariantOptions?.custom === 'deposit' + ? t('button.deposit') + : t('button.buy') + case 'refuel': + return t('button.startBridging') + default: { + const transactionType = + route.fromChainId === route.toChainId ? 'Swapping' : 'Bridging' + return t(`button.start${transactionType}`) + } + } + } + + return ( + <> + + + + + + + + {subvariant !== 'custom' ? ( + + ) : null} + {!hiddenUI?.includes(HiddenUI.LowAddressActivityConfirmation) ? ( + + ) : null} + + ) +} diff --git a/packages/widget/src/stores/form/FormUpdater.tsx b/packages/widget/src/stores/form/FormUpdater.tsx index a7f83ccfb..b4ea32b77 100644 --- a/packages/widget/src/stores/form/FormUpdater.tsx +++ b/packages/widget/src/stores/form/FormUpdater.tsx @@ -3,7 +3,6 @@ import { useEffect } from 'react' import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' import { useBookmarkActions } from '../../stores/bookmarks/useBookmarkActions.js' import { formDefaultValues } from '../../stores/form/createFormStore.js' -import { useSendToWalletActions } from '../../stores/settings/useSendToWalletStore.js' import type { DefaultValues } from './types.js' import { useFieldActions } from './useFieldActions.js' @@ -12,7 +11,6 @@ export const FormUpdater: React.FC<{ }> = ({ reactiveFormValues }) => { const { toAddress } = useWidgetConfig() const { account } = useAccount() - const { setSendToWallet } = useSendToWalletActions() const { setSelectedBookmark } = useBookmarkActions() const { setUserAndDefaultValues } = useFieldActions() @@ -20,9 +18,6 @@ export const FormUpdater: React.FC<{ // Includes special logic for chain fields, where account.chainId is only a fallback and not a direct reactivity source. // biome-ignore lint/correctness/useExhaustiveDependencies: account.chainId is used as a fallback only and does not need to be a dependency for reactivity. useEffect(() => { - if (reactiveFormValues.toAddress) { - setSendToWallet(true) - } if (toAddress) { setSelectedBookmark(toAddress) } @@ -34,7 +29,6 @@ export const FormUpdater: React.FC<{ toAddress, reactiveFormValues, setUserAndDefaultValues, - setSendToWallet, setSelectedBookmark, ]) diff --git a/packages/widget/src/stores/form/URLSearchParamsBuilder.tsx b/packages/widget/src/stores/form/URLSearchParamsBuilder.tsx index 69cdefc7a..fe927c3ce 100644 --- a/packages/widget/src/stores/form/URLSearchParamsBuilder.tsx +++ b/packages/widget/src/stores/form/URLSearchParamsBuilder.tsx @@ -2,7 +2,6 @@ import { useLocation } from '@tanstack/react-router' import { useEffect } from 'react' import { useAddressValidation } from '../../hooks/useAddressValidation.js' import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js' -import { useSendToWalletActions } from '../../stores/settings/useSendToWalletStore.js' import { useBookmarkActions } from '../bookmarks/useBookmarkActions.js' import type { FormFieldNames } from '../form/types.js' import { useFieldActions } from '../form/useFieldActions.js' @@ -23,7 +22,6 @@ export const URLSearchParamsBuilder = () => { const { pathname } = useLocation() const touchedFields = useTouchedFields() const values = useFieldValues(...formValueKeys) - const { setSendToWallet } = useSendToWalletActions() const { setSelectedBookmark, addRecentWallet } = useBookmarkActions() const { validateAddress } = useAddressValidation() const { buildUrl } = useWidgetConfig() @@ -58,7 +56,6 @@ export const URLSearchParamsBuilder = () => { setUserAndDefaultValues({ toAddress }) setSelectedBookmark(bookmark) addRecentWallet(bookmark) - setSendToWallet(true) } } catch (_) { // Address validation failed @@ -70,7 +67,6 @@ export const URLSearchParamsBuilder = () => { initializeFromAddress() }, [ setUserAndDefaultValues, - setSendToWallet, validateAddress, setSelectedBookmark, addRecentWallet, diff --git a/packages/widget/src/stores/form/useFormRef.ts b/packages/widget/src/stores/form/useFormRef.ts index a751aa84f..2c812df30 100644 --- a/packages/widget/src/stores/form/useFormRef.ts +++ b/packages/widget/src/stores/form/useFormRef.ts @@ -1,12 +1,10 @@ import { useImperativeHandle } from 'react' import { useBookmarkActions } from '../../stores/bookmarks/useBookmarkActions.js' import { formDefaultValues } from '../../stores/form/createFormStore.js' -import { useSendToWalletActions } from '../../stores/settings/useSendToWalletStore.js' import type { FormRef } from '../../types/widget.js' import type { FormStoreStore, GenericFormValue } from './types.js' export const useFormRef = (formStore: FormStoreStore, formRef?: FormRef) => { - const { setSendToWallet } = useSendToWalletActions() const { setSelectedBookmark } = useBookmarkActions() useImperativeHandle(formRef, () => { @@ -26,15 +24,6 @@ export const useFormRef = (formStore: FormStoreStore, formRef?: FormRef) => { (isToAddressObj ? value?.address : value) || formDefaultValues.toAddress - // sets the send to wallet button state to be open - // if there is an address to display - if (address) { - setSendToWallet(address) - } - - // we can assume that the toAddress has been passed as ToAddress object - // and display it accordingly - this ensures that if a name is included - // that it is displayed in the Send To Wallet form field correctly if (isToAddressObj) { setSelectedBookmark(value) } @@ -58,5 +47,5 @@ export const useFormRef = (formStore: FormStoreStore, formRef?: FormRef) => { .setFieldValue(fieldName, sanitizedValue, fieldValueOptions) }, } - }, [formStore, setSendToWallet, setSelectedBookmark]) + }, [formStore, setSelectedBookmark]) } diff --git a/packages/widget/src/stores/routes/createRouteExecutionStore.ts b/packages/widget/src/stores/routes/createRouteExecutionStore.ts index d58adcc96..081674c5c 100644 --- a/packages/widget/src/stores/routes/createRouteExecutionStore.ts +++ b/packages/widget/src/stores/routes/createRouteExecutionStore.ts @@ -3,7 +3,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { hasEnumFlag } from '../../utils/enum.js' import type { PersistStoreProps } from '../types.js' -import type { RouteExecutionState } from './types.js' +import type { RouteExecution, RouteExecutionState } from './types.js' import { RouteExecutionStatus } from './types.js' import { isRouteDone, @@ -21,18 +21,14 @@ export const createRouteExecutionStore = ({ namePrefix }: PersistStoreProps) => if (!get().routes[route.id]) { set((state: RouteExecutionState) => { const routes = { ...state.routes } - // clean previous idle and done routes + // clean previous idle routes Object.keys(routes) .filter( (routeId) => - (!observableRouteIds?.includes(routeId) && - hasEnumFlag( - routes[routeId]!.status, - RouteExecutionStatus.Idle - )) || + !observableRouteIds?.includes(routeId) && hasEnumFlag( routes[routeId]!.status, - RouteExecutionStatus.Done + RouteExecutionStatus.Idle ) ) .forEach((routeId) => { @@ -96,28 +92,6 @@ export const createRouteExecutionStore = ({ namePrefix }: PersistStoreProps) => }) } }, - deleteRoutes: (type) => - set((state: RouteExecutionState) => { - const routes = { ...state.routes } - Object.keys(routes) - .filter((routeId) => - type === 'completed' - ? hasEnumFlag( - routes[routeId]?.status ?? 0, - RouteExecutionStatus.Done - ) - : !hasEnumFlag( - routes[routeId]?.status ?? 0, - RouteExecutionStatus.Done - ) - ) - .forEach((routeId) => { - delete routes[routeId] - }) - return { - routes, - } - }), }), { name: `${namePrefix || 'li.fi'}-widget-routes`, @@ -129,19 +103,23 @@ export const createRouteExecutionStore = ({ namePrefix }: PersistStoreProps) => ...persistedState, } as RouteExecutionState try { - // Remove failed transactions from history after 1 day - const currentTime = Date.now() - const oneDay = 1000 * 60 * 60 * 24 - Object.values(state.routes).forEach((routeExecution) => { - const startedAt = - routeExecution?.route.steps?.find( - (step) => step.execution?.status === 'FAILED' - )?.execution?.startedAt ?? 0 - const outdated = startedAt > 0 && currentTime - startedAt > oneDay - if (routeExecution?.route && outdated) { - delete state.routes[routeExecution.route.id] + // Keep only the most recent 100 routes, evicting the oldest when the + // limit is exceeded. + const maxStoredRoutes = 100 + const allRoutes = Object.values(state.routes) as RouteExecution[] + const storedRoutes = allRoutes + .sort( + (a, b) => + (b.route.steps[0]?.execution?.startedAt ?? 0) - + (a.route.steps[0]?.execution?.startedAt ?? 0) + ) + .slice(0, maxStoredRoutes) + const keepIds = new Set(storedRoutes.map((r) => r.route.id)) + for (const id of Object.keys(state.routes)) { + if (!keepIds.has(id)) { + delete state.routes[id] } - }) + } } catch (error) { console.error(error) } diff --git a/packages/widget/src/stores/routes/types.ts b/packages/widget/src/stores/routes/types.ts index 5a935761d..353aa9e23 100644 --- a/packages/widget/src/stores/routes/types.ts +++ b/packages/widget/src/stores/routes/types.ts @@ -10,7 +10,6 @@ export interface RouteExecutionState { setExecutableRoute: (route: Route, observableRouteIds?: string[]) => void updateRoute: (route: Route) => void deleteRoute: (routeId: string) => void - deleteRoutes: (type: 'completed' | 'active') => void } export enum RouteExecutionStatus { diff --git a/packages/widget/src/stores/routes/useExecutingRoutesIds.ts b/packages/widget/src/stores/routes/useExecutingRoutesIds.ts deleted file mode 100644 index 7b08a423f..000000000 --- a/packages/widget/src/stores/routes/useExecutingRoutesIds.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useAccount } from '@lifi/wallet-management' -import { useRouteExecutionStore } from './RouteExecutionStore.js' -import type { RouteExecution } from './types.js' -import { RouteExecutionStatus } from './types.js' - -export const useExecutingRoutesIds = () => { - const { accounts } = useAccount() - const accountAddresses = accounts.map((account) => account.address) - return useRouteExecutionStore((state) => - (Object.values(state.routes) as RouteExecution[]) - .filter( - (item) => - accountAddresses.includes(item.route.fromAddress) && - (item.status === RouteExecutionStatus.Pending || - item.status === RouteExecutionStatus.Failed) - ) - .sort( - (a, b) => - (b?.route.steps[0].execution?.startedAt ?? 0) - - (a?.route.steps[0].execution?.startedAt ?? 0) - ) - .map(({ route }) => route.id) - ) -} diff --git a/packages/widget/src/stores/routes/useRouteExecutionIndicators.ts b/packages/widget/src/stores/routes/useRouteExecutionIndicators.ts new file mode 100644 index 000000000..10f684029 --- /dev/null +++ b/packages/widget/src/stores/routes/useRouteExecutionIndicators.ts @@ -0,0 +1,43 @@ +import { useAccount } from '@lifi/wallet-management' +import { useCallback, useMemo } from 'react' +import { useRouteExecutionStore } from './RouteExecutionStore.js' +import type { RouteExecution, RouteExecutionState } from './types.js' +import { RouteExecutionStatus } from './types.js' + +export type RouteExecutionIndicator = 'idle' | 'active' | 'failed' + +const isRecentTransaction = (route: RouteExecution): boolean => { + const startedAt = route.route.steps[0]?.execution?.startedAt ?? 0 + return startedAt > 0 && Date.now() - startedAt < 1000 * 60 * 60 * 24 // 1 day +} + +export const useRouteExecutionIndicator = (): RouteExecutionIndicator => { + const { accounts } = useAccount() + const accountAddresses = useMemo( + () => accounts.map((account) => account.address), + [accounts] + ) + const selector = useCallback( + (state: RouteExecutionState): RouteExecutionIndicator => { + const routes = Object.values(state.routes) as RouteExecution[] + const recentOwnedRoutes = routes.filter( + (route) => + accountAddresses.includes(route.route.fromAddress) && + isRecentTransaction(route) + ) + if ( + recentOwnedRoutes.some((r) => r.status === RouteExecutionStatus.Failed) + ) { + return 'failed' + } + if ( + recentOwnedRoutes.some((r) => r.status === RouteExecutionStatus.Pending) + ) { + return 'active' + } + return 'idle' + }, + [accountAddresses] + ) + return useRouteExecutionStore(selector) +} diff --git a/packages/widget/src/stores/settings/types.ts b/packages/widget/src/stores/settings/types.ts index 8726b88fb..a9f4886a1 100644 --- a/packages/widget/src/stores/settings/types.ts +++ b/packages/widget/src/stores/settings/types.ts @@ -48,14 +48,6 @@ export interface SettingsActions { export type SettingsState = SettingsProps & SettingsActions -interface SendToWalletState { - showSendToWallet: boolean -} - -export interface SendToWalletStore extends SendToWalletState { - setSendToWallet(value: boolean): void -} - export interface SplitSubvariantState { state?: SplitSubvariant setState(state: SplitSubvariant): void diff --git a/packages/widget/src/stores/settings/useSendToWalletStore.ts b/packages/widget/src/stores/settings/useSendToWalletStore.ts deleted file mode 100644 index a790dbf47..000000000 --- a/packages/widget/src/stores/settings/useSendToWalletStore.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { create } from 'zustand' -import { useShallow } from 'zustand/shallow' -import type { SendToWalletStore } from './types.js' - -export const sendToWalletStore = create((set) => ({ - showSendToWallet: false, - setSendToWallet: (value) => - set({ - showSendToWallet: value, - }), -})) - -export const useSendToWalletStore = ( - selector: (state: SendToWalletStore) => T -): T => { - return sendToWalletStore(useShallow(selector)) -} - -export const useSendToWalletActions = () => { - const actions = useSendToWalletStore((store) => ({ - setSendToWallet: store.setSendToWallet, - })) - - return actions -} diff --git a/packages/widget/src/utils/navigationRoutes.ts b/packages/widget/src/utils/navigationRoutes.ts index a82fa9d77..89526f8be 100644 --- a/packages/widget/src/utils/navigationRoutes.ts +++ b/packages/widget/src/utils/navigationRoutes.ts @@ -1,6 +1,5 @@ export const navigationRoutes = { home: '/', - activeTransactions: 'active-transactions', bridges: 'bridges', exchanges: 'exchanges', fromChain: 'from-chain', @@ -13,7 +12,7 @@ export const navigationRoutes = { toTokenNative: 'to-token-native', transactionDetails: 'transaction-details', transactionExecution: 'transaction-execution', - transactionHistory: 'transaction-history', + activities: 'activities', sendToWallet: 'send-to-wallet', bookmarks: 'bookmarks', recentWallets: 'recent-wallets', @@ -24,7 +23,6 @@ export const navigationRoutes = { export const navigationRoutesValues = Object.values(navigationRoutes) export const stickyHeaderRoutes = [ - navigationRoutes.activeTransactions, navigationRoutes.bridges, navigationRoutes.exchanges, navigationRoutes.fromChain, @@ -35,7 +33,7 @@ export const stickyHeaderRoutes = [ navigationRoutes.toTokenNative, navigationRoutes.transactionDetails, navigationRoutes.transactionExecution, - navigationRoutes.transactionHistory, + navigationRoutes.activities, navigationRoutes.sendToWallet, navigationRoutes.bookmarks, navigationRoutes.recentWallets, @@ -44,7 +42,6 @@ export const stickyHeaderRoutes = [ ] export const backButtonRoutes = [ - navigationRoutes.activeTransactions, navigationRoutes.bridges, navigationRoutes.exchanges, navigationRoutes.languages, @@ -57,7 +54,7 @@ export const backButtonRoutes = [ navigationRoutes.toTokenNative, navigationRoutes.transactionDetails, navigationRoutes.transactionExecution, - navigationRoutes.transactionHistory, + navigationRoutes.activities, navigationRoutes.sendToWallet, navigationRoutes.bookmarks, navigationRoutes.recentWallets, diff --git a/packages/widget/src/utils/timer.ts b/packages/widget/src/utils/timer.ts index b70386333..61b849e8a 100644 --- a/packages/widget/src/utils/timer.ts +++ b/packages/widget/src/utils/timer.ts @@ -1,3 +1,13 @@ +import type { LiFiStepExtended } from '@lifi/sdk' + +export function getExpiryTimestamp(step?: LiFiStepExtended): Date { + const { signedAt } = step?.execution ?? {} + if (!step || !signedAt) { + return new Date() + } + return new Date(signedAt + step.estimate.executionDuration * 1000) +} + export const formatTimer = ({ days = 0, hours = 0,