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..0936830f1 --- /dev/null +++ b/packages/widget/src/components/IconCircle/IconCircle.style.tsx @@ -0,0 +1,79 @@ +import type { Theme } from '@mui/material' +import { Box, styled } from '@mui/material' +import type { StatusIcon } from './statusIcons' + +export const iconCircleSize = 90 + +interface StatusColorConfig { + color: string + mixAmount: number + lightDarken: number + darkDarken: number +} + +export const getStatusColor = ( + status: StatusIcon, + 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..55c2f4e03 --- /dev/null +++ b/packages/widget/src/components/IconCircle/IconCircle.tsx @@ -0,0 +1,29 @@ +import type { BoxProps } from '@mui/material' +import { useTheme } from '@mui/material' +import { + getStatusColor, + IconCircleRoot, + iconCircleSize, +} from './IconCircle.style.js' +import { type StatusIcon, statusIcons } from './statusIcons.js' + +interface IconCircleProps extends Omit { + status: StatusIcon + size?: number +} + +export const IconCircle: React.FC = ({ + status, + size = iconCircleSize, + ...rest +}) => { + const theme = useTheme() + const colorConfig = getStatusColor(status, theme) + const Icon = statusIcons[status] + + return ( + + + + ) +} diff --git a/packages/widget/src/components/IconCircle/statusIcons.ts b/packages/widget/src/components/IconCircle/statusIcons.ts new file mode 100644 index 000000000..09e4b31a2 --- /dev/null +++ b/packages/widget/src/components/IconCircle/statusIcons.ts @@ -0,0 +1,14 @@ +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' + +export type StatusIcon = 'success' | 'error' | 'warning' | 'info' + +export const statusIcons: Record = { + success: Done, + error: ErrorRounded, + warning: WarningRounded, + info: InfoRounded, +} 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/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..053d9039b --- /dev/null +++ b/packages/widget/src/components/RouteCard/RouteDetails.style.tsx @@ -0,0 +1,35 @@ +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: 12, + fontWeight: 500, + lineHeight: 1.3334, + color: theme.vars.palette.text.secondary, +})) + +export const DetailValue = styled(Typography)(() => ({ + fontSize: 12, + fontWeight: 700, + lineHeight: 1.3334, + textAlign: 'right', +})) + +export const DetailInfoIcon = styled(InfoOutlined)(({ theme }) => ({ + fontSize: 16, + color: theme.vars.palette.text.secondary, + cursor: 'help', +})) diff --git a/packages/widget/src/components/RouteCard/RouteDetails.tsx b/packages/widget/src/components/RouteCard/RouteDetails.tsx new file mode 100644 index 000000000..e43c72838 --- /dev/null +++ b/packages/widget/src/components/RouteCard/RouteDetails.tsx @@ -0,0 +1,213 @@ +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 '../Step/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/Step/StepActions.style.tsx b/packages/widget/src/components/Step/StepActions.style.tsx new file mode 100644 index 000000000..7aee57da6 --- /dev/null +++ b/packages/widget/src/components/Step/StepActions.style.tsx @@ -0,0 +1,92 @@ +import { + Box, + StepConnector as MuiStepConnector, + StepLabel as MuiStepLabel, + stepConnectorClasses, + stepLabelClasses, + styled, + Typography, +} from '@mui/material' +import { AvatarMasked } from '../Avatar/Avatar.style.js' + +export const StepConnector = styled(MuiStepConnector, { + shouldForwardProp: (prop) => + !['active', 'completed', 'error'].includes(prop as string), +})(({ theme }) => ({ + marginLeft: theme.spacing(2.375), + [`.${stepConnectorClasses.line}`]: { + minHeight: 8, + borderLeftWidth: 2, + borderColor: `rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.12)`, + ...theme.applyStyles('dark', { + borderColor: `rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.16)`, + }), + }, +})) + +export const StepLabel = styled(MuiStepLabel, { + shouldForwardProp: (prop) => + !['active', 'completed', 'error', 'disabled'].includes(prop as string), +})(({ theme }) => ({ + padding: 0, + alignItems: 'center', + [`.${stepLabelClasses.iconContainer}`]: { + paddingLeft: theme.spacing(0.5), + paddingRight: theme.spacing(2.5), + }, + [`.${stepLabelClasses.labelContainer}`]: { + minHeight: 24, + display: 'flex', + alignItems: 'center', + }, + [`&.${stepLabelClasses.disabled}`]: { + cursor: 'inherit', + }, +})) + +export const StepLabelTypography = styled(Typography)(({ theme }) => ({ + fontSize: 12, + fontWeight: 500, + lineHeight: 1.325, + color: theme.vars.palette.text.secondary, + padding: theme.spacing(0.5, 0), +})) + +export const StepContent = styled(Box, { + shouldForwardProp: (prop) => !['last'].includes(prop as string), +})<{ last: boolean }>(({ theme }) => ({ + margin: theme.spacing(0, 0, 0, 2.375), + paddingLeft: theme.spacing(4.375), + borderLeft: `2px solid rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.12)`, + ...theme.applyStyles('dark', { + borderLeft: `2px solid rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.16)`, + }), + variants: [ + { + props: ({ last }) => last, + style: { + borderLeft: 'none', + paddingLeft: theme.spacing(4.625), + ...theme.applyStyles('dark', { + borderLeft: 'none', + }), + }, + }, + ], +})) + +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: 12, + fontWeight: 700, +})) diff --git a/packages/widget/src/components/Step/StepActions.tsx b/packages/widget/src/components/Step/StepActions.tsx new file mode 100644 index 000000000..0b43d4949 --- /dev/null +++ b/packages/widget/src/components/Step/StepActions.tsx @@ -0,0 +1,348 @@ +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 { + Box, + Collapse, + Divider, + Step as MuiStep, + Stepper, + Typography, +} from '@mui/material' +import type { MouseEventHandler } from 'react' +import { Fragment, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useAvailableChains } from '../../hooks/useAvailableChains.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 type { + IncludedStepsProps, + StepDetailsLabelProps, +} from '../StepActions/types.js' +import { + StepActionsHeader, + StepActionsTitle, + StepConnector, + StepContent, + StepLabel, + StepLabelTypography, +} from './StepActions.style.js' + +export const StepActions: React.FC<{ + route: RouteExtended +}> = ({ route }) => { + const { t } = useTranslation() + const [cardExpanded, setCardExpanded] = useState(false) + + const handleExpand: MouseEventHandler = (e) => { + e.stopPropagation() + setCardExpanded((expanded) => !expanded) + } + + const includedSteps = route.steps.flatMap((step) => step.includedSteps) + + return ( + + + {t('main.route')} + ({ + borderRadius: theme.vars.shape.borderRadiusSecondary, + })} + > + {cardExpanded ? ( + + ) : ( + + {includedSteps.map((includedStep, index) => ( + + {index > 0 ? ( + + ) : null} + + {includedStep.toolDetails.name[0]} + + + ))} + + + )} + + + + {route.steps.map((step) => ( + + ))} + + + ) +} + +const IncludedSteps: React.FC = ({ step }) => { + const { subvariant, subvariantOptions, feeConfig, hiddenUI } = + useWidgetConfig() + const { isGaslessStep } = useEthereumContext() + + let includedSteps = step.includedSteps + if (hiddenUI?.includes(HiddenUI.IntegratorStepDetails)) { + const feeCollectionStep = includedSteps.find( + (step) => step.tool === 'feeCollection' + ) + if (feeCollectionStep) { + includedSteps = structuredClone( + includedSteps.filter((step) => step.tool !== 'feeCollection') + ) + includedSteps[0].estimate.fromAmount = + feeCollectionStep.estimate.fromAmount + } + } + + // biome-ignore lint/correctness/noNestedComponentDefinitions: part of the flow + const StepIconComponent = ({ icon }: StepIconProps) => { + const includedStep = includedSteps?.[Number(icon) - 1] + const feeCollectionStep = + includedStep?.type === 'protocol' && + includedStep?.tool === 'feeCollection' + const toolName = + feeCollectionStep && feeConfig?.name + ? feeConfig?.name + : includedStep?.toolDetails.name + const toolLogoURI = + feeCollectionStep && feeConfig?.logoURI + ? feeConfig?.logoURI + : includedStep?.toolDetails.logoURI + return toolLogoURI ? ( + + {toolName?.[0]} + + ) : null + } + + const hasGaslessSupport = !!isGaslessStep?.(step) + + return ( + + } + activeStep={-1} + > + {includedSteps.map((step, i, includedSteps) => ( + + + {step.type === 'custom' && subvariant === 'custom' ? ( + + ) : step.type === 'cross' ? ( + + ) : step.type === 'protocol' ? ( + + ) : ( + + )} + + + + + + ))} + + + ) +} + +const StepDetailsContent: React.FC<{ + step: StepExtended +}> = ({ step }) => { + const { t } = useTranslation() + + const sameTokenProtocolStep = + step.action.fromToken.chainId === step.action.toToken.chainId && + step.action.fromToken.address === step.action.toToken.address + + let fromAmount: string | undefined + if (sameTokenProtocolStep) { + const estimatedFromAmount = + BigInt(step.estimate.fromAmount) - BigInt(step.estimate.toAmount) + + fromAmount = + estimatedFromAmount > 0n + ? formatTokenAmount(estimatedFromAmount, step.action.fromToken.decimals) + : formatTokenAmount( + BigInt(step.estimate.fromAmount), + step.action.fromToken.decimals + ) + } else { + fromAmount = formatTokenAmount( + BigInt(step.estimate.fromAmount), + step.action.fromToken.decimals + ) + } + + const showToAmount = + step.type !== 'custom' && step.tool !== 'custom' && !sameTokenProtocolStep + + return ( + + {!showToAmount ? ( + <> + {t('format.tokenAmount', { + value: formatTokenAmount( + BigInt(step.estimate.fromAmount), + step.action.fromToken.decimals + ), + })}{' '} + {step.action.fromToken.symbol} + {' - '} + + ) : null} + {t('format.tokenAmount', { + value: fromAmount, + })}{' '} + {step.action.fromToken.symbol} + {showToAmount ? ( + <> + + {t('format.tokenAmount', { + value: formatTokenAmount( + BigInt(step.execution?.toAmount ?? step.estimate.toAmount), + step.execution?.toToken?.decimals ?? step.action.toToken.decimals + ), + })}{' '} + {step.execution?.toToken?.symbol ?? step.action.toToken.symbol} + + ) : ( + ` (${t('format.currency', { + value: formatTokenPrice( + fromAmount, + step.action.fromToken.priceUSD, + step.action.fromToken.decimals + ), + })})` + )} + + ) +} + +const CustomStepDetailsLabel: React.FC = ({ + step, + subvariant, + subvariantOptions, +}) => { + const { t } = useTranslation() + + if (!subvariant) { + return null + } + + // FIXME: step transaction request overrides step tool details, but not included step tool details + const toolDetails = + subvariant === 'custom' && + (step as unknown as LiFiStep).includedSteps?.length > 0 + ? (step as unknown as LiFiStep).includedSteps.find( + (step) => step.tool === 'custom' && step.toolDetails.key !== 'custom' + )?.toolDetails || step.toolDetails + : step.toolDetails + + const stepDetailsKey = + (subvariant === 'custom' && subvariantOptions?.custom) || 'checkout' + + return ( + + {t(`main.${stepDetailsKey}StepDetails`, { + tool: toolDetails.name, + })} + + ) +} + +const BridgeStepDetailsLabel: React.FC< + Omit +> = ({ step }) => { + const { t } = useTranslation() + const { getChainById } = useAvailableChains() + return ( + + {t('main.bridgeStepDetails', { + from: getChainById(step.action.fromChainId)?.name, + to: getChainById(step.action.toChainId)?.name, + tool: step.toolDetails.name, + })} + + ) +} + +const SwapStepDetailsLabel: React.FC< + Omit +> = ({ step }) => { + const { t } = useTranslation() + const { getChainById } = useAvailableChains() + return ( + + {t('main.swapStepDetails', { + chain: getChainById(step.action.fromChainId)?.name, + tool: step.toolDetails.name, + })} + + ) +} + +const ProtocolStepDetailsLabel: React.FC< + Omit +> = ({ step, feeConfig, relayerSupport }) => { + const { t } = useTranslation() + return ( + + {step.toolDetails.key === 'feeCollection' + ? feeConfig?.name || + (relayerSupport + ? t('main.fees.relayerService') + : t('main.fees.defaultIntegrator')) + : step.toolDetails.key === 'gasZip' + ? t('main.refuelStepDetails', { + tool: step.toolDetails.name, + }) + : step.toolDetails.name} + + ) +} diff --git a/packages/widget/src/components/StepActions/types.ts b/packages/widget/src/components/StepActions/types.ts index 45b6c9ed0..1c588de38 100644 --- a/packages/widget/src/components/StepActions/types.ts +++ b/packages/widget/src/components/StepActions/types.ts @@ -1,4 +1,4 @@ -import type { LiFiStep, Step } from '@lifi/sdk' +import type { LiFiStepExtended, Step } from '@lifi/sdk' import type { BoxProps } from '@mui/material' import type { SubvariantOptions, @@ -7,7 +7,7 @@ import type { } from '../../types/widget.js' export interface StepActionsProps extends BoxProps { - step: LiFiStep + step: LiFiStepExtended dense?: boolean } @@ -20,5 +20,5 @@ export interface StepDetailsLabelProps { } export interface IncludedStepsProps { - step: LiFiStep + step: LiFiStepExtended } 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/useTokenRateText.ts b/packages/widget/src/hooks/useTokenRateText.ts new file mode 100644 index 000000000..0746157bf --- /dev/null +++ b/packages/widget/src/hooks/useTokenRateText.ts @@ -0,0 +1,52 @@ +import { formatUnits, type RouteExtended } from '@lifi/sdk' +import type { MouseEventHandler } from 'react' +import { useTranslation } from 'react-i18next' +import { create } from 'zustand' + +interface TokenRateState { + isForward: boolean + toggleIsForward(): void +} + +const useTokenRateStore = create((set) => ({ + isForward: true, + toggleIsForward: () => set((state) => ({ isForward: !state.isForward })), +})) + +export function useTokenRateText(route: RouteExtended) { + const { t } = useTranslation() + const { isForward, toggleIsForward } = useTokenRateStore() + + const lastStep = route.steps.at(-1) + + const fromToken = { + ...route.fromToken, + amount: BigInt(route.fromAmount), + } + const toToken = { + ...(lastStep?.execution?.toToken ?? + lastStep?.action.toToken ?? + route.toToken), + amount: lastStep?.execution?.toAmount + ? BigInt(lastStep.execution.toAmount) + : BigInt(route.toAmount), + } + + const fromToRate = + Number.parseFloat(formatUnits(toToken.amount!, toToken.decimals)) / + Number.parseFloat(formatUnits(fromToken.amount!, fromToken.decimals)) + const toFromRate = + Number.parseFloat(formatUnits(fromToken.amount!, fromToken.decimals)) / + Number.parseFloat(formatUnits(toToken.amount!, toToken.decimals)) + + const rateText = isForward + ? `1 ${fromToken.symbol} ≈ ${t('format.tokenAmount', { value: fromToRate })} ${toToken.symbol}` + : `1 ${toToken.symbol} ≈ ${t('format.tokenAmount', { value: toFromRate })} ${fromToken.symbol}` + + 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..5948a2b97 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), @@ -71,8 +76,20 @@ export const useTransactionHistory = () => { }, }) + const routeExecutions = useMemo( + () => + (transactions ?? []).flatMap((transaction) => { + const routeExecution = buildRouteFromTxHistory( + transaction as FullStatusData, + tools + ) + return routeExecution ? [routeExecution] : [] + }), + [tools, transactions] + ) + return { - data, + data: routeExecutions, isLoading, } } diff --git a/packages/widget/src/i18n/en.json b/packages/widget/src/i18n/en.json index 1f988bee4..a52aff8ec 100644 --- a/packages/widget/src/i18n/en.json +++ b/packages/widget/src/i18n/en.json @@ -199,6 +199,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": { @@ -286,6 +287,9 @@ "rateChange": "Rate change", "receiving": "Receiving", "refuelStepDetails": "Get gas via {{tool}}", + "route": "Route", + "exchangeRate": "Exchange rate", + "estimatedTime": "Estimated time", "selectChain": "Select chain", "selectChainAndToken": "Select chain and token", "selectToken": "Select token", @@ -304,6 +308,8 @@ "stepSwapAndBuy": "Swap and buy", "stepSwapAndDeposit": "Swap and deposit", "swapStepDetails": "Swap on {{chain}} via {{tool}}", + "receipts": "Receipts", + "sentToWallet": "Sent to wallet", "transferId": "Transfer ID", "tags": { "cheapest": "Best Return", diff --git a/packages/widget/src/pages/TransactionDetailsPage/ActionRow.style.tsx b/packages/widget/src/pages/TransactionDetailsPage/ActionRow.style.tsx new file mode 100644 index 000000000..ed4b5575c --- /dev/null +++ b/packages/widget/src/pages/TransactionDetailsPage/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/pages/TransactionDetailsPage/ActionRow.tsx b/packages/widget/src/pages/TransactionDetailsPage/ActionRow.tsx new file mode 100644 index 000000000..abdd10645 --- /dev/null +++ b/packages/widget/src/pages/TransactionDetailsPage/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/pages/TransactionDetailsPage/ContactSupportButton.style.tsx b/packages/widget/src/pages/TransactionDetailsPage/ContactSupportButton.style.tsx new file mode 100644 index 000000000..33d240e38 --- /dev/null +++ b/packages/widget/src/pages/TransactionDetailsPage/ContactSupportButton.style.tsx @@ -0,0 +1,10 @@ +import { styled } from '@mui/material' +import { ButtonTertiary } from '../../components/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/pages/TransactionDetailsPage/ContactSupportButton.tsx b/packages/widget/src/pages/TransactionDetailsPage/ContactSupportButton.tsx index 9474dc1e0..0d2ceb8f8 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 { useContactSupport } from '../../hooks/useContactSupport.js' +import { ButtonChip } from './ContactSupportButton.style.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/DateLabel.style.tsx b/packages/widget/src/pages/TransactionDetailsPage/DateLabel.style.tsx new file mode 100644 index 000000000..2047284f3 --- /dev/null +++ b/packages/widget/src/pages/TransactionDetailsPage/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/pages/TransactionDetailsPage/DateLabel.tsx b/packages/widget/src/pages/TransactionDetailsPage/DateLabel.tsx new file mode 100644 index 000000000..506547b9e --- /dev/null +++ b/packages/widget/src/pages/TransactionDetailsPage/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/pages/TransactionDetailsPage/ReceiptsCard.style.tsx b/packages/widget/src/pages/TransactionDetailsPage/ReceiptsCard.style.tsx new file mode 100644 index 000000000..572658e82 --- /dev/null +++ b/packages/widget/src/pages/TransactionDetailsPage/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/TransactionDetailsPage/ReceiptsCard.tsx b/packages/widget/src/pages/TransactionDetailsPage/ReceiptsCard.tsx new file mode 100644 index 000000000..9d1670888 --- /dev/null +++ b/packages/widget/src/pages/TransactionDetailsPage/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/TransactionDetailsPage/SentToWalletRow.tsx b/packages/widget/src/pages/TransactionDetailsPage/SentToWalletRow.tsx new file mode 100644 index 000000000..82eb929ac --- /dev/null +++ b/packages/widget/src/pages/TransactionDetailsPage/SentToWalletRow.tsx @@ -0,0 +1,57 @@ +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 { useExplorer } from '../../hooks/useExplorer.js' +import { shortenAddress } from '../../utils/wallet.js' +import { ActionRow } from './ActionRow.js' +import { ActionIconCircle } from './ActionRow.style.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')}: ${shortenAddress(toAddress)}`} + endAdornment={ + + + + + {addressLink ? ( + + + + ) : undefined} + + } + /> + ) +} diff --git a/packages/widget/src/pages/TransactionDetailsPage/StepActionRow.tsx b/packages/widget/src/pages/TransactionDetailsPage/StepActionRow.tsx new file mode 100644 index 000000000..32c110afa --- /dev/null +++ b/packages/widget/src/pages/TransactionDetailsPage/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 { IconCircle } from '../../components/IconCircle/IconCircle.js' +import { useActionMessage } from '../../hooks/useActionMessage.js' +import { ActionRow } from './ActionRow.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/TransactionDetailsPage/StepActionsList.tsx b/packages/widget/src/pages/TransactionDetailsPage/StepActionsList.tsx new file mode 100644 index 000000000..8a2aaa06e --- /dev/null +++ b/packages/widget/src/pages/TransactionDetailsPage/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/TransactionDetailsPage/TransactionDetailsPage.tsx b/packages/widget/src/pages/TransactionDetailsPage/TransactionDetailsPage.tsx index d9d5adcbc..0525a78c3 100644 --- a/packages/widget/src/pages/TransactionDetailsPage/TransactionDetailsPage.tsx +++ b/packages/widget/src/pages/TransactionDetailsPage/TransactionDetailsPage.tsx @@ -1,12 +1,12 @@ 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 { 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' @@ -15,22 +15,21 @@ 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 { DateLabel } from './DateLabel.js' +import { ReceiptsCard } from './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() @@ -97,57 +96,33 @@ export const TransactionDetailsPage: React.FC = () => { (storedRouteExecution ? 1 : 1000) // local and BE routes have different ms handling ) - return isLoading && !storedRouteExecution ? ( - - ) : ( - - - - {startedAt.toLocaleString(i18n.language, { - dateStyle: 'long', - })} - - - {startedAt.toLocaleString(i18n.language, { - timeStyle: 'short', - })} - - - {getStepList(routeExecution?.route, subvariant)} + if (isLoading && !storedRouteExecution) { + return + } + + if (!routeExecution?.route) { + return null + } + + return ( + + + + + + + {subvariant === 'custom' && 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.style.tsx b/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryItem.style.tsx new file mode 100644 index 000000000..2047284f3 --- /dev/null +++ b/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryItem.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/pages/TransactionHistoryPage/TransactionHistoryItem.tsx b/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryItem.tsx index c6c87cf2f..a4828dbef 100644 --- a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryItem.tsx +++ b/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryItem.tsx @@ -1,118 +1,47 @@ -import type { - ExtendedTransactionInfo, - FullStatusData, - StatusResponse, - TokenAmount, -} from '@lifi/sdk' -import { Box, Typography } from '@mui/material' +import type { RouteExtended } from '@lifi/sdk' +import { Box } from '@mui/material' import { useNavigate } from '@tanstack/react-router' +import { memo } from 'react' 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 { RouteTokens } from '../../components/RouteCard/RouteTokens.js' import { navigationRoutes } from '../../utils/navigationRoutes.js' +import { + DateLabelContainer, + DateLabelText, +} from './TransactionHistoryItem.style.js' export const TransactionHistoryItem: React.FC<{ - transaction: StatusResponse - start: number -}> = ({ transaction, start }) => { + route: RouteExtended + transactionHash: string + // startedAt in ms + startedAt: number +}> = memo(({ route, transactionHash, startedAt }) => { 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, - }, + search: { transactionHash }, }) } - 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, - } + const date = new Date(startedAt) return ( - - - - {startedAt.toLocaleString(i18n.language, { dateStyle: 'long' })} - - - {startedAt.toLocaleString(i18n.language, { - timeStyle: 'short', - })} - - - - - - - - + + + + + {date.toLocaleString(i18n.language, { dateStyle: 'long' })} + + + {date.toLocaleString(i18n.language, { timeStyle: 'short' })} + + + ) -} +}) diff --git a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryPage.tsx b/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryPage.tsx index 0f496d0fb..e69ebb902 100644 --- a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryPage.tsx +++ b/packages/widget/src/pages/TransactionHistoryPage/TransactionHistoryPage.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' @@ -7,6 +6,7 @@ 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 { getSourceTxHash } from '../../stores/routes/utils.js' import { minTransactionListHeight } from './constants.js' import { TransactionHistoryEmpty } from './TransactionHistoryEmpty.js' import { TransactionHistoryItem } from './TransactionHistoryItem.js' @@ -26,17 +26,18 @@ export const TransactionHistoryPage = () => { const getItemKey = useCallback( (index: number) => { - return `${(transactions[index] as FullStatusData).transactionId}-${index}` + const txHash = getSourceTxHash(transactions[index].route) ?? '' + return `${txHash}-${index}` }, [transactions] ) - const { getVirtualItems, getTotalSize } = useVirtualizer({ + const { getVirtualItems, getTotalSize, measureElement } = useVirtualizer({ count: transactions.length, overscan: 3, paddingEnd: 12, getScrollElement: () => parentRef.current, - estimateSize: () => 186, + estimateSize: () => 208, getItemKey, }) @@ -75,13 +76,31 @@ export const TransactionHistoryPage = () => { disablePadding > {getVirtualItems().map((item) => { - const transaction = transactions[item.index] + const listItem = transactions[item.index] + const txHash = getSourceTxHash(listItem.route) ?? '' return ( - + ref={measureElement} + data-index={item.index} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + paddingBottom: 16, + transform: `translateY(${item.start}px)`, + }} + > + + ) })} diff --git a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistorySkeleton.tsx b/packages/widget/src/pages/TransactionHistoryPage/TransactionHistorySkeleton.tsx index 8c7fc2905..a62bd3041 100644 --- a/packages/widget/src/pages/TransactionHistoryPage/TransactionHistorySkeleton.tsx +++ b/packages/widget/src/pages/TransactionHistoryPage/TransactionHistorySkeleton.tsx @@ -1,57 +1,51 @@ +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' -import { TokenDivider } from '../../components/Token/Token.style.js' export const TransactionHistoryItemSkeleton = () => { return ( - - - ({ - borderRadius: theme.vars.shape.borderRadius, - })} - /> - ({ - borderRadius: theme.vars.shape.borderRadius, - })} - /> - - - + + - + ({ + borderRadius: theme.vars.shape.borderRadius, + })} + /> + ({ + borderRadius: theme.vars.shape.borderRadius, + })} + /> + + + + + + + - )