diff --git a/.changeset/checkout-flow-cleanup.md b/.changeset/checkout-flow-cleanup.md
new file mode 100644
index 000000000..d18c3adec
--- /dev/null
+++ b/.changeset/checkout-flow-cleanup.md
@@ -0,0 +1,6 @@
+---
+"@lifi/widget-checkout": minor
+"@lifi/widget": patch
+---
+
+Auto-resume a single in-flight checkout deposit on the funding screen, abandon a transfer from the back button (with confirmation), and surface checkout-specific route-not-found copy. Add state-aware status step labels, fail resumed exchange records that lost their session, and hide the redundant token subtext under the cash pay field.
diff --git a/.changeset/checkout-flow-polish.md b/.changeset/checkout-flow-polish.md
new file mode 100644
index 000000000..7231c1ac4
--- /dev/null
+++ b/.changeset/checkout-flow-polish.md
@@ -0,0 +1,16 @@
+---
+"@lifi/widget-checkout": patch
+"@lifi/widget": patch
+---
+
+Polish the checkout flow and align it with the main widget design.
+
+- Cards follow the widget's theme: receive/handoff cards use the shared border radius (so custom shapes like windows95 apply), the from/to token icons and labels line up, and the header back button aligns with page content.
+- The refresh-quotes button now refetches, and the checkout route query keeps its previous result so amount/fee values update in place on refresh and on token/amount changes instead of flashing a skeleton or resetting to zero.
+- Remove the funding-screen activity cards and stop surfacing the Mesh error on the choose-funding-source page.
+- Reuse the widget's send-to-wallet card for the checkout recipient (gated read-only for fixed recipients), and reuse the shared step link styling on the status page.
+- A quote with no deposit address is treated as unavailable instead of a silently disabled button, and going back from the amount screen no longer returns to the set-destination screen.
+- Cash deposit card: move the disclaimer inside the card, smooth the expand animation, and tighten the amount-to-fees spacing.
+- Show the Transak modal close button immediately, and prevent selecting the destination token as the source token.
+
+`@lifi/widget` exposes `SendToWalletButton` (with optional `onEditAddress`/`onClearAddress` overrides and a `requireAddress` flag), `BookmarkStoreProvider`, and the step `ExternalLink` via `@lifi/widget/shared`, and `useRoutes` gains an opt-in `keepPreviousData`.
diff --git a/packages/widget-checkout/src/CheckoutRouter.tsx b/packages/widget-checkout/src/CheckoutRouter.tsx
index 0d82ef623..a5edf0f4a 100644
--- a/packages/widget-checkout/src/CheckoutRouter.tsx
+++ b/packages/widget-checkout/src/CheckoutRouter.tsx
@@ -170,8 +170,8 @@ const routeTree = rootRoute.addChildren([
])
export const CheckoutRouter: React.FC = () => {
- // No deep-resume on mount — pending deposits surface as a tappable activity
- // list on the funding screen.
+ // Always start at home; the funding screen auto-resumes a single in-flight
+ // deposit and surfaces the rest as a tappable activity list.
const [router] = useState(() =>
createRouter({
routeTree,
diff --git a/packages/widget-checkout/src/components/AbandonConfirmationDialog.tsx b/packages/widget-checkout/src/components/AbandonConfirmationDialog.tsx
new file mode 100644
index 000000000..a31f1631f
--- /dev/null
+++ b/packages/widget-checkout/src/components/AbandonConfirmationDialog.tsx
@@ -0,0 +1,32 @@
+import { useTranslation } from 'react-i18next'
+import { ConfirmationBottomSheet } from './ConfirmationBottomSheet.js'
+
+interface AbandonConfirmationDialogProps {
+ open: boolean
+ onCancel: () => void
+ onConfirm: () => void
+ container?: HTMLElement | null
+}
+
+const titleId = 'checkout-abandon-confirmation-title'
+const bodyId = 'checkout-abandon-confirmation-body'
+
+export const AbandonConfirmationDialog: React.FC<
+ AbandonConfirmationDialogProps
+> = ({ open, onCancel, onConfirm, container }) => {
+ const { t } = useTranslation()
+ return (
+
+ )
+}
diff --git a/packages/widget-checkout/src/components/CashHandoffSheet.tsx b/packages/widget-checkout/src/components/CashHandoffSheet.tsx
index 07f05ec1a..ba10ad3eb 100644
--- a/packages/widget-checkout/src/components/CashHandoffSheet.tsx
+++ b/packages/widget-checkout/src/components/CashHandoffSheet.tsx
@@ -110,7 +110,7 @@ export const CashHandoffSheet: React.FC = ({
sx={{
mt: 2,
p: 2,
- borderRadius: 1.5,
+ borderRadius: 1,
bgcolor: 'background.paper',
}}
>
diff --git a/packages/widget-checkout/src/components/CheckoutAmountInput.tsx b/packages/widget-checkout/src/components/CheckoutAmountInput.tsx
index 72849378f..9228cf392 100644
--- a/packages/widget-checkout/src/components/CheckoutAmountInput.tsx
+++ b/packages/widget-checkout/src/components/CheckoutAmountInput.tsx
@@ -352,17 +352,12 @@ const CheckoutTokenFlow: React.FC = ({ formType }) => {
m: 0,
display: 'flex',
alignItems: 'center',
- gap: 0.5,
+ gap: 2,
cursor: isInteractive ? 'pointer' : 'default',
}}
>
{token && chain ? (
-
+
) : (
)}
diff --git a/packages/widget-checkout/src/components/CheckoutPriceFormHelperText.test.tsx b/packages/widget-checkout/src/components/CheckoutPriceFormHelperText.test.tsx
new file mode 100644
index 000000000..3786cebca
--- /dev/null
+++ b/packages/widget-checkout/src/components/CheckoutPriceFormHelperText.test.tsx
@@ -0,0 +1,66 @@
+// @vitest-environment happy-dom
+import { render, screen } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+
+const { fundingSourceRef } = vi.hoisted(() => ({
+ fundingSourceRef: { current: 'cash' as string | null },
+}))
+
+vi.mock('@lifi/widget/shared', () => ({
+ FormKeyHelper: {
+ getChainKey: (f: string) => `${f}.chain`,
+ getTokenKey: (f: string) => `${f}.token`,
+ getAmountKey: (f: string) => `${f}.amount`,
+ },
+ formatTokenAmount: (v: unknown) => String(v),
+ formatTokenPrice: () => '5.00',
+ InputPriceButton: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ useFieldValues: () => ['1', '0xtoken', '5'],
+ useInputModeStore: () => ({
+ inputMode: { from: 'price', to: 'price' },
+ toggleInputMode: () => {},
+ }),
+ useToken: () => ({
+ token: { symbol: 'USDC', decimals: 6, priceUSD: '1' },
+ isLoading: false,
+ }),
+ useTokenAddressBalance: () => ({ token: undefined, isLoading: false }),
+}))
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, opts?: { value?: string }) => opts?.value ?? key,
+ }),
+}))
+
+vi.mock('../hooks/useIsWalletFundedFlow.js', () => ({
+ useIsWalletFundedFlow: () => false,
+}))
+
+vi.mock('../stores/useCheckoutFlowStore.js', () => ({
+ useCheckoutFlowStore: (
+ selector: (s: { fundingSource: string | null }) => unknown
+ ) => selector({ fundingSource: fundingSourceRef.current }),
+}))
+
+import { CheckoutPriceFormHelperText } from './CheckoutPriceFormHelperText.js'
+
+describe('CheckoutPriceFormHelperText', () => {
+ it('renders no subtext for the cash "from" field', () => {
+ fundingSourceRef.current = 'cash'
+ const { container } = render(
+
+ )
+ expect(container.firstChild).toBeNull()
+ expect(screen.queryByText('USDC')).toBeNull()
+ })
+
+ it('renders the token subtext for non-cash funding sources', () => {
+ fundingSourceRef.current = 'transfer'
+ render()
+ expect(screen.queryByText('USDC')).not.toBeNull()
+ })
+})
diff --git a/packages/widget-checkout/src/components/CheckoutPriceFormHelperText.tsx b/packages/widget-checkout/src/components/CheckoutPriceFormHelperText.tsx
index 79cad40c8..671709c2b 100644
--- a/packages/widget-checkout/src/components/CheckoutPriceFormHelperText.tsx
+++ b/packages/widget-checkout/src/components/CheckoutPriceFormHelperText.tsx
@@ -16,6 +16,7 @@ import type React from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useIsWalletFundedFlow } from '../hooks/useIsWalletFundedFlow.js'
+import { useCheckoutFlowStore } from '../stores/useCheckoutFlowStore.js'
import { formatCheckoutBalanceWithToken } from '../utils/formatCheckoutBalance.js'
export const CheckoutPriceFormHelperText: React.NamedExoticComponent =
@@ -28,6 +29,7 @@ export const CheckoutPriceFormHelperText: React.NamedExoticComponent s.fundingSource)
const { token: walletToken, isLoading: walletBalanceLoading } =
useTokenAddressBalance(
@@ -71,6 +73,11 @@ export const CheckoutPriceFormHelperText: React.NamedExoticComponent {
const fundingSource = useCheckoutFlowStore((s) => s.fundingSource)
@@ -68,16 +70,16 @@ const CheckoutReceiveIdleCard: React.FC = () => {
-
+
{toToken && chain ? (
) : (
@@ -188,7 +190,15 @@ const CheckoutReceiveCardWithRoutes: React.FC = () => {
isFetched,
dataUpdatedAt,
refetchTime,
+ refetch,
} = useCheckoutRoutes()
+ const {
+ depositAddress,
+ isLoading: quoteLoading,
+ isFetching: quoteFetching,
+ isFetched: quoteFetched,
+ isError: quoteError,
+ } = useCheckoutFlowQuote()
const parsedTypedFiatAmount = Number.parseFloat(
normalizeFiatAmount(cashFiatAmount)
@@ -207,6 +217,15 @@ const CheckoutReceiveCardWithRoutes: React.FC = () => {
const route = routes?.[0] as Route | undefined
const routeNotFound =
hasAmount && !route && !isLoading && !isFetching && isFetched
+ // Non-wallet flows need a deposit address; a quote without one can't proceed.
+ const depositUnavailable =
+ fundingSource !== 'wallet' &&
+ Boolean(route) &&
+ quoteFetched &&
+ !quoteLoading &&
+ !quoteFetching &&
+ !quoteError &&
+ !depositAddress
const showLoading = hasAmount && !route && (isLoading || isFetching)
const toUsdZero = t('format.currency', { value: 0 })
@@ -328,10 +347,13 @@ const CheckoutReceiveCardWithRoutes: React.FC = () => {
})} ${route.toToken.symbol}`
: null
+ // Only skeleton when there is nothing to show yet; refetches keep prior rows.
+ const hasCashDetails = cashFeeRows.length > 0 || Boolean(minimumReceived)
const cashDetailsPending =
isCash &&
hasTypedFiatAmount &&
!onRampQuote.isError &&
+ !hasCashDetails &&
(onRampQuote.isLoading ||
onRampQuote.isFetching ||
onRampQuote.isDebouncePending ||
@@ -342,283 +364,246 @@ const CheckoutReceiveCardWithRoutes: React.FC = () => {
: Boolean(route)
return (
- <>
+
-
-
- {toToken && chain ? (
-
- ) : (
-
- )}
-
-
- {t('header.receive')}
-
-
- {toToken?.symbol ?? '—'}
-
-
+
+ {toToken && chain ? (
+
+ ) : (
+
+ )}
+
+
+ {t('header.receive')}
+
+
+ {toToken?.symbol ?? '—'}
+
- {hasAmount ? (
-
- ) : null}
+ {hasAmount ? (
+ {
+ refetch()
+ if (isCash) {
+ onRampQuote.refetch()
+ }
+ }}
+ />
+ ) : null}
+
- {routeNotFound ? (
-
- ) : (
- <>
-
-
- {showLoading ? (
-
- 0
-
- ) : (
-
- {toAmountDisplay}
-
- )}
+ {routeNotFound || depositUnavailable ? (
+
+ ) : (
+ <>
+
+
+ {showLoading ? (
+
+ 0
+
+ ) : (
+
+ {toAmountDisplay}
+
+ )}
+
+
+ {toUsdDisplay}
+
+ {!isCash ? (
+ <>
+
+ •
+
+
+ {priceImpactStr}
+
+ >
+ ) : null}
+
+ •
+
-
- {toUsdDisplay}
-
- {!isCash ? (
- <>
-
- •
-
-
- {priceImpactStr}
-
- >
+ {chain?.logoURI ? (
+
) : null}
- •
+ {chain?.name ?? '—'}
-
- {chain?.logoURI ? (
-
- ) : null}
-
- {chain?.name ?? '—'}
-
-
-
-
-
+
+
+
+
- {!isCash ? (
+ {!isCash ? (
+
-
- {route ? : null}
-
-
- {route ? (
- <>
-
-
-
-
-
-
- {!combinedFeesUSD
- ? t('main.fees.free')
- : t('format.currency', {
- value: combinedFeesUSD,
- })}
-
-
-
-
-
-
-
-
- {formatDuration(executionTimeSeconds, i18n.language)}
-
-
- >
- ) : (
- <>
+ {route ? : null}
+
+
+ {route ? (
+ <>
+
{
lineHeight: 1,
}}
>
- {toUsdZero}
-
-
-
-
-
-
-
- {formatDuration(0, i18n.language)}
+ {!combinedFeesUSD
+ ? t('main.fees.free')
+ : t('format.currency', {
+ value: combinedFeesUSD,
+ })}
- >
- )}
-
+
+
+
+
+
+
+ {formatDuration(executionTimeSeconds, i18n.language)}
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ {toUsdZero}
+
+
+
+
+
+
+
+ {formatDuration(0, i18n.language)}
+
+
+ >
+ )}
- ) : null}
-
- {isCash ? (
- hasExpandableContent ? (
-
- {cashDetailsPending ? (
- <>
-
-
- >
- ) : (
- cashFeeRows.map((row, index) => (
-
-
- {row.label}
-
-
- {row.value}
-
-
- ))
- )}
- {!cashDetailsPending && minimumReceived ? (
+
+ ) : null}
+
+ {isCash ? (
+ hasExpandableContent ? (
+
+ {cashDetailsPending ? (
+ <>
+
+
+ >
+ ) : (
+ cashFeeRows.map((row, index) => (
{
- {t('checkout.cashQuote.guaranteedMinimum')}
+ {row.label}
- {minimumReceived}
+ {row.value}
- ) : null}
-
- ) : null
- ) : route ? (
-
- ) : null}
-
- >
- )}
-
- {isCash && hasTypedFiatAmount ? (
-
- {minimumReceived
- ? t('checkout.cashQuote.refundNote', { value: minimumReceived })
- : ' '}
-
- ) : null}
- >
+ ))
+ )}
+ {!cashDetailsPending && minimumReceived ? (
+
+ {t('checkout.cashQuote.refundNote', {
+ value: minimumReceived,
+ })}
+
+ ) : null}
+
+ ) : null
+ ) : route ? (
+
+ ) : null}
+
+ >
+ )}
+
+
)
}
diff --git a/packages/widget-checkout/src/components/CheckoutRecipientCard.tsx b/packages/widget-checkout/src/components/CheckoutRecipientCard.tsx
deleted file mode 100644
index 7494cebdf..000000000
--- a/packages/widget-checkout/src/components/CheckoutRecipientCard.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import {
- ChainAvatar,
- shortenAddress,
- useChain,
- useToken,
- useWidgetConfig,
-} from '@lifi/widget/shared'
-import CloseIcon from '@mui/icons-material/Close'
-import ErrorRounded from '@mui/icons-material/ErrorRounded'
-import { Box, Card, Chip, IconButton, Typography } from '@mui/material'
-import type { JSX } from 'react'
-import { useTranslation } from 'react-i18next'
-import { useCheckoutNavigate } from '../hooks/useCheckoutNavigate.js'
-import { useResolvedCheckoutRecipient } from '../hooks/useResolvedCheckoutRecipient.js'
-import { checkoutNavigationRoutes } from '../utils/navigationRoutes.js'
-
-export const CheckoutRecipientCard: React.FC = (): JSX.Element | null => {
- const { t } = useTranslation()
- const navigate = useCheckoutNavigate()
- const { recipient, isUserSettable, clearUserRecipient } =
- useResolvedCheckoutRecipient()
- const { toChain, toToken } = useWidgetConfig()
- const { token } = useToken(toChain, toToken)
- const { chain: destinationChain } = useChain(toChain)
-
- if (!isUserSettable) {
- return null
- }
-
- if (!recipient) {
- return (
-
- navigate({ to: checkoutNavigationRoutes.setDestination })
- }
- sx={{ p: 2, cursor: 'pointer' }}
- >
-
-
- {t('checkout.whereToSendIt')}
-
- }
- label={t('checkout.required')}
- sx={(theme) => ({
- bgcolor: theme.vars.palette.warning.light,
- color: theme.vars.palette.warning.dark,
- fontWeight: 600,
- '& .MuiChip-icon': {
- color: theme.vars.palette.warning.dark,
- fontSize: 16,
- },
- })}
- />
-
-
- {t('checkout.addWalletToReceive', { token: token?.symbol ?? '' })}
-
-
- )
- }
-
- return (
-
-
- {t('checkout.sendingTo')}
-
-
-
- {destinationChain?.name?.[0] ?? '?'}
-
-
- {shortenAddress(recipient.address)}
-
-
-
-
-
-
- )
-}
diff --git a/packages/widget-checkout/src/components/CheckoutRouteNotFound.tsx b/packages/widget-checkout/src/components/CheckoutRouteNotFound.tsx
new file mode 100644
index 000000000..e6877132c
--- /dev/null
+++ b/packages/widget-checkout/src/components/CheckoutRouteNotFound.tsx
@@ -0,0 +1,47 @@
+import { RouteNotFoundCard } from '@lifi/widget/shared'
+import Route from '@mui/icons-material/Route'
+import { Box, Typography } from '@mui/material'
+import type { JSX } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useCheckoutFlowStore } from '../stores/useCheckoutFlowStore.js'
+
+export const CheckoutRouteNotFound: React.FC = (): JSX.Element => {
+ const { t } = useTranslation()
+ const fundingSource = useCheckoutFlowStore((s) => s.fundingSource)
+
+ // Only the intent-factory-only sources get the checkout-specific copy.
+ if (!fundingSource || fundingSource === 'wallet') {
+ return
+ }
+
+ return (
+
+
+
+
+
+ {t('checkout.routeNotFound.title')}
+
+
+ {t('checkout.routeNotFound.description')}
+
+
+ )
+}
diff --git a/packages/widget-checkout/src/components/Container.style.tsx b/packages/widget-checkout/src/components/Container.style.tsx
index 258972351..a72732bd0 100644
--- a/packages/widget-checkout/src/components/Container.style.tsx
+++ b/packages/widget-checkout/src/components/Container.style.tsx
@@ -24,13 +24,6 @@ export const RelativeContainer: React.FC> =
minHeight: 0,
maxHeight: '100%',
borderRadius: 'inherit',
- '& .MuiPaper-outlined': {
- border: 'none',
- boxShadow: `0 1px 4px color-mix(in srgb, ${theme.vars.palette.common.onBackground} 8%, transparent)`,
- ...theme.applyStyles('dark', {
- boxShadow: `0 1px 4px color-mix(in srgb, ${theme.vars.palette.common.background} 8%, transparent)`,
- }),
- },
}))
export const CssBaselineContainer: React.FC<
diff --git a/packages/widget-checkout/src/components/Header.style.tsx b/packages/widget-checkout/src/components/Header.style.tsx
index 792e6538e..a6e19431f 100644
--- a/packages/widget-checkout/src/components/Header.style.tsx
+++ b/packages/widget-checkout/src/components/Header.style.tsx
@@ -10,7 +10,7 @@ export const HeaderAppBar: React.FC> =
top: 0,
zIndex: 1,
minHeight: 56,
- padding: theme.spacing(1.5, 2),
+ padding: theme.spacing(1.5, 3),
marginBottom: theme.spacing(1.5),
}))
diff --git a/packages/widget-checkout/src/components/Header.test.tsx b/packages/widget-checkout/src/components/Header.test.tsx
index 18ed00857..5f027aba9 100644
--- a/packages/widget-checkout/src/components/Header.test.tsx
+++ b/packages/widget-checkout/src/components/Header.test.tsx
@@ -22,10 +22,18 @@ vi.mock('@lifi/widget/shared', () => ({
useWidgetConfig: () => ({ elementId: 'test' }),
}))
+const { navigateMock, routerGo, routerState } = vi.hoisted(() => ({
+ navigateMock: vi.fn(),
+ routerGo: vi.fn(),
+ routerState: { pathname: '/', historyLength: 1 },
+}))
+
vi.mock('@tanstack/react-router', () => ({
- useLocation: () => ({ pathname: '/' }),
- useRouter: () => ({ history: { length: 1, go: () => {} } }),
- useNavigate: () => () => {},
+ useLocation: () => ({ pathname: routerState.pathname }),
+ useRouter: () => ({
+ history: { length: routerState.historyLength, go: routerGo },
+ }),
+ useNavigate: () => navigateMock,
}))
vi.mock('@lifi/wallet-management', () => ({
@@ -76,6 +84,8 @@ function setup({
describe('Header close button', () => {
beforeEach(() => {
vi.clearAllMocks()
+ routerState.pathname = '/'
+ routerState.historyLength = 1
})
it('calls closeModal directly when idle', () => {
@@ -96,3 +106,36 @@ describe('Header close button', () => {
expect(closeModal).not.toHaveBeenCalled()
})
})
+
+describe('Header back button', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ routerState.pathname = '/'
+ routerState.historyLength = 1
+ })
+
+ it('confirms before abandoning to home from the transfer-deposit page', () => {
+ routerState.pathname = '/transfer-deposit'
+ setup({
+ busy: false,
+ closeModal: vi.fn(),
+ openCloseConfirmation: vi.fn(),
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ // Back opens the confirmation sheet; nothing navigates yet.
+ expect(navigateMock).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel transfer' }))
+ expect(navigateMock).toHaveBeenCalledWith({ to: '/', replace: true })
+ expect(routerGo).not.toHaveBeenCalled()
+ })
+
+ it('hides the back button on status pages', () => {
+ routerState.pathname = '/transaction-execution/transaction-status'
+ setup({
+ busy: false,
+ closeModal: vi.fn(),
+ openCloseConfirmation: vi.fn(),
+ })
+ expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()
+ })
+})
diff --git a/packages/widget-checkout/src/components/Header.tsx b/packages/widget-checkout/src/components/Header.tsx
index fe1bfd418..7eadcd83b 100644
--- a/packages/widget-checkout/src/components/Header.tsx
+++ b/packages/widget-checkout/src/components/Header.tsx
@@ -9,15 +9,17 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import CloseIcon from '@mui/icons-material/Close'
import { Box, IconButton } from '@mui/material'
import { useLocation, useRouter } from '@tanstack/react-router'
-import { useLayoutEffect, useRef } from 'react'
+import { useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useCheckoutModal } from '../CheckoutModal.js'
+import { useAbandonCheckout } from '../hooks/useAbandonCheckout.js'
import { useCheckoutNavigate } from '../hooks/useCheckoutNavigate.js'
import { useIsCheckoutBusy } from '../hooks/useIsCheckoutBusy.js'
import {
backButtonRoutes,
checkoutNavigationRoutes,
} from '../utils/navigationRoutes.js'
+import { AbandonConfirmationDialog } from './AbandonConfirmationDialog.js'
import {
HeaderAppBar,
HeaderControlsContainer,
@@ -38,6 +40,8 @@ export const Header: React.FC = ({ title: titleProp }) => {
const navigate = useCheckoutNavigate()
const modalContext = useCheckoutModal()
const busy = useIsCheckoutBusy()
+ const abandonCheckout = useAbandonCheckout()
+ const [abandonOpen, setAbandonOpen] = useState(false)
const headerRef = useRef(null)
const { setHeaderHeight } = useSetHeaderHeight()
@@ -68,6 +72,10 @@ export const Header: React.FC = ({ title: titleProp }) => {
const isHomePage = pathname === checkoutNavigationRoutes.home
const handleBack = () => {
+ if (path === 'transfer-deposit') {
+ setAbandonOpen(true)
+ return
+ }
if (router.history.length > 1) {
router.history.go(-1)
return
@@ -75,6 +83,12 @@ export const Header: React.FC = ({ title: titleProp }) => {
navigate({ to: checkoutNavigationRoutes.home, replace: true })
}
+ const confirmAbandon = () => {
+ setAbandonOpen(false)
+ abandonCheckout()
+ navigate({ to: checkoutNavigationRoutes.home, replace: true })
+ }
+
const handleClose = () => {
if (busy) {
modalContext?.openCloseConfirmation()
@@ -89,48 +103,56 @@ export const Header: React.FC = ({ title: titleProp }) => {
const titleAlignCenter = true
return (
-
+
+ {title}
+
+
-
-
-
-
+
+
+
+
+
+ setAbandonOpen(false)}
+ onConfirm={confirmAbandon}
+ container={modalContext?.panelEl ?? null}
+ />
+ >
)
}
diff --git a/packages/widget-checkout/src/components/OnRampHostedModals.test.tsx b/packages/widget-checkout/src/components/OnRampHostedModals.test.tsx
index 3092fc2c3..37fe3d641 100644
--- a/packages/widget-checkout/src/components/OnRampHostedModals.test.tsx
+++ b/packages/widget-checkout/src/components/OnRampHostedModals.test.tsx
@@ -98,23 +98,9 @@ describe('OnRampHostedModals — hosted Dialog close guard', () => {
expect(close).not.toHaveBeenCalled()
})
- it('force-close icon is hidden before the 15s grace elapses', () => {
+ it('force-close icon is available immediately when the modal opens', () => {
const { session } = makeSession()
setup(session)
- act(() => {
- vi.advanceTimersByTime(14_900)
- })
- expect(
- screen.queryByRole('button', { name: "Close (transaction won't resume)" })
- ).toBeNull()
- })
-
- it('force-close icon appears after 15s with the spec tooltip', () => {
- const { session } = makeSession()
- setup(session)
- act(() => {
- vi.advanceTimersByTime(15_100)
- })
const btn = screen.getByRole('button', {
name: "Close (transaction won't resume)",
})
diff --git a/packages/widget-checkout/src/components/OnRampHostedModals.tsx b/packages/widget-checkout/src/components/OnRampHostedModals.tsx
index f7c822dae..0a2e7cc81 100644
--- a/packages/widget-checkout/src/components/OnRampHostedModals.tsx
+++ b/packages/widget-checkout/src/components/OnRampHostedModals.tsx
@@ -14,7 +14,7 @@ import {
Tooltip,
Typography,
} from '@mui/material'
-import { type JSX, useEffect, useState } from 'react'
+import type { JSX } from 'react'
import { useTranslation } from 'react-i18next'
import { usePendingCheckoutWriter } from '../hooks/usePendingCheckoutWriter.js'
import { useResumeKey } from '../hooks/useResumeKey.js'
@@ -25,9 +25,6 @@ import {
import { ErrorBoundary } from './ErrorBoundary.js'
import { formatOnRampError } from './formatOnRampError.js'
-// Escape hatch for wedged iframes; Transak SDK owns normal close.
-const FORCE_CLOSE_GRACE_MS = 15_000
-
export function OnRampHostedModals(): JSX.Element {
const metas = useOnRampProviderMetas()
return (
@@ -61,24 +58,9 @@ function HostedModalDialog({
session,
}: HostedModalDialogProps): JSX.Element | null {
const { t } = useTranslation()
- const [showForceClose, setShowForceClose] = useState(false)
const resumeKey = useResumeKey()
const { clearForKey } = usePendingCheckoutWriter()
- useEffect(() => {
- if (!session.isOpen) {
- setShowForceClose(false)
- return
- }
- const timer = setTimeout(
- () => setShowForceClose(true),
- FORCE_CLOSE_GRACE_MS
- )
- return () => {
- clearTimeout(timer)
- }
- }, [session.isOpen])
-
if (!session.mountTargetId) {
return null
}
@@ -115,7 +97,7 @@ function HostedModalDialog({
},
}}
>
- {showForceClose && session.isOpen ? (
+ {session.isOpen ? (
(
+
+
+
+ {children}
+
+
+
+)
+
+function useHarness() {
+ return {
+ abandon: useAbandonCheckout(),
+ seed: useSeedFrozenQuote(),
+ frozen: useFrozenQuote().frozen,
+ fundingSource: useCheckoutFlowStore((s) => s.fundingSource),
+ frozenDepositId: useCheckoutFlowStore((s) => s.frozenDepositId),
+ setFundingSource: useCheckoutFlowStore((s) => s.setFundingSource),
+ setFrozenDepositId: useCheckoutFlowStore((s) => s.setFrozenDepositId),
+ }
+}
+
+describe('useAbandonCheckout', () => {
+ beforeEach(resetStore)
+ afterEach(resetStore)
+
+ it('clears the frozen quote, pending record, and flow state', () => {
+ const key = buildResumeKey('int', 'dep-1')
+ usePendingCheckoutStore.getState().write(
+ key,
+ buildPendingRecord({
+ fundingSource: 'transfer',
+ depositId: 'dep-1',
+ depositAddress: 'dep-1',
+ fromChain: 1,
+ status: 'pending',
+ })
+ )
+
+ const { result } = renderHook(useHarness, { wrapper })
+ act(() => {
+ result.current.setFundingSource('transfer')
+ result.current.setFrozenDepositId('dep-1')
+ result.current.seed({
+ id: 'r1',
+ route: {} as never,
+ expiresAt: Date.now() + 60_000,
+ })
+ })
+
+ expect(result.current.frozen).not.toBeNull()
+ expect(usePendingCheckoutStore.getState().records[key]).toBeDefined()
+
+ act(() => {
+ result.current.abandon()
+ })
+
+ expect(result.current.frozen).toBeNull()
+ expect(usePendingCheckoutStore.getState().records[key]).toBeUndefined()
+ expect(result.current.fundingSource).toBeNull()
+ expect(result.current.frozenDepositId).toBeNull()
+ })
+})
diff --git a/packages/widget-checkout/src/hooks/useAbandonCheckout.ts b/packages/widget-checkout/src/hooks/useAbandonCheckout.ts
new file mode 100644
index 000000000..594c33fc8
--- /dev/null
+++ b/packages/widget-checkout/src/hooks/useAbandonCheckout.ts
@@ -0,0 +1,26 @@
+'use client'
+import { CheckoutContext } from '@lifi/widget-provider/checkout'
+import { useCallback, useContext } from 'react'
+import { CheckoutFlowStoreContext } from '../stores/useCheckoutFlowStore.js'
+import {
+ buildResumeKey,
+ usePendingCheckoutStore,
+} from '../stores/usePendingCheckoutStore.js'
+import { FrozenQuoteStoreContext } from './useFrozenQuote.js'
+
+export function useAbandonCheckout(): () => void {
+ const checkoutContext = useContext(CheckoutContext)
+ const flowStore = useContext(CheckoutFlowStoreContext)
+ const frozenStore = useContext(FrozenQuoteStoreContext)
+ const clearForKey = usePendingCheckoutStore((s) => s.clearForKey)
+
+ return useCallback(() => {
+ const integrator = checkoutContext?.integrator
+ const depositId = flowStore?.getState().frozenDepositId
+ if (integrator && depositId) {
+ clearForKey(buildResumeKey(integrator, depositId))
+ }
+ frozenStore?.getState().set(null)
+ flowStore?.getState().reset()
+ }, [checkoutContext, flowStore, frozenStore, clearForKey])
+}
diff --git a/packages/widget-checkout/src/hooks/useCheckoutRoutes.ts b/packages/widget-checkout/src/hooks/useCheckoutRoutes.ts
index e50cc5cdb..0ca2a7da1 100644
--- a/packages/widget-checkout/src/hooks/useCheckoutRoutes.ts
+++ b/packages/widget-checkout/src/hooks/useCheckoutRoutes.ts
@@ -24,5 +24,7 @@ export function useCheckoutRoutes(): UseRoutesResult {
? (toAddress as string)
: undefined
- return useRoutes({ quoteFromAddress })
+ // Keep the prior quote visible while a token/amount change refetches, so the
+ // receive amount updates in place instead of flashing a skeleton/zero.
+ return useRoutes({ quoteFromAddress, keepPreviousData: true })
}
diff --git a/packages/widget-checkout/src/hooks/useFrozenQuote.tsx b/packages/widget-checkout/src/hooks/useFrozenQuote.tsx
index a34b1b0a4..19bc110d0 100644
--- a/packages/widget-checkout/src/hooks/useFrozenQuote.tsx
+++ b/packages/widget-checkout/src/hooks/useFrozenQuote.tsx
@@ -1,5 +1,6 @@
import type { Route } from '@lifi/sdk'
import {
+ type Context,
createContext,
type JSX,
type PropsWithChildren,
@@ -41,7 +42,8 @@ function createFrozenQuoteStore(): FrozenQuoteStore {
}))
}
-const FrozenQuoteStoreContext = createContext(null)
+export const FrozenQuoteStoreContext: Context =
+ createContext(null)
export function FrozenQuoteStoreProvider({
children,
diff --git a/packages/widget-checkout/src/pages/CheckoutRoutesPage.tsx b/packages/widget-checkout/src/pages/CheckoutRoutesPage.tsx
index a16dc745f..437b8581a 100644
--- a/packages/widget-checkout/src/pages/CheckoutRoutesPage.tsx
+++ b/packages/widget-checkout/src/pages/CheckoutRoutesPage.tsx
@@ -4,7 +4,6 @@ import {
ProgressToNextUpdate,
RouteCard,
RouteCardSkeleton,
- RouteNotFoundCard,
Stack,
useFieldValues,
useHeader,
@@ -15,6 +14,7 @@ import {
import { useNavigate } from '@tanstack/react-router'
import { type JSX, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
+import { CheckoutRouteNotFound } from '../components/CheckoutRouteNotFound.js'
import { useCheckoutRoutes } from '../hooks/useCheckoutRoutes.js'
import { checkoutAbsolutePaths } from '../utils/navigationRoutes.js'
@@ -78,7 +78,7 @@ export const CheckoutRoutesPage = (): JSX.Element => {
sx={{ flex: 1 }}
>
{routeNotFound ? (
-
+
) : isLoading && !routes?.length ? (
Array.from({ length: 3 }).map((_, index) => (
diff --git a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx
index de424d279..5feb90f40 100644
--- a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx
+++ b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx
@@ -155,6 +155,15 @@ export const CheckoutTransactionStatusPage: React.FC = (): JSX.Element => {
}, [depositCancelled, clearForKey, resumeKey, navigate, router])
const isResumed = search.resumed === '1'
+ // Resumed exchange records have no live session, so a poll error can't be retried.
+ const exchangeSessionLost =
+ isResumed && fundingSource === 'exchange' && !deposit
+ useEffect(() => {
+ if (isError && exchangeSessionLost && resumeKey) {
+ markFailed(resumeKey)
+ }
+ }, [isError, exchangeSessionLost, resumeKey, markFailed])
+
const showToast = useCheckoutToastStore((s) => s.show)
const notFoundHandledRef = useRef(false)
useEffect(() => {
@@ -310,7 +319,9 @@ export const CheckoutTransactionStatusPage: React.FC = (): JSX.Element => {
diff --git a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusStepList.test.tsx b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusStepList.test.tsx
index 72363559c..4f51999c9 100644
--- a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusStepList.test.tsx
+++ b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusStepList.test.tsx
@@ -22,6 +22,17 @@ vi.mock('@lifi/widget/shared', () => ({
{endAdornment}
),
+ ExternalLink: ({
+ href,
+ children,
+ }: {
+ href: string
+ children?: ReactNode
+ }) => (
+
+ {children}
+
+ ),
IconCircle: () => ,
SentToWalletRow: () => ,
useAvailableChains: () => ({ getChainById: () => ({ name: 'Ethereum' }) }),
@@ -84,6 +95,9 @@ describe('StatusStepList', () => {
expect(screen.queryAllByRole('progressbar')).toHaveLength(0)
expect(screen.queryAllByTestId('icon-success')).toHaveLength(0)
expect(screen.queryByRole('link')).toBeNull()
+ // Upcoming steps read in the future/base tense.
+ expect(screen.getByText('Receive USDC')).toBeTruthy()
+ expect(screen.getByText('Swap to stETH')).toBeTruthy()
})
it('pending with sparse status: received done, segment spinning, no links', () => {
@@ -97,6 +111,9 @@ describe('StatusStepList', () => {
expect(screen.getAllByTestId('icon-success')).toHaveLength(1)
expect(screen.getAllByRole('progressbar')).toHaveLength(1)
expect(screen.queryByRole('link')).toBeNull()
+ // Completed step reads past, in-flight step reads present-continuous.
+ expect(screen.getByText('USDC received')).toBeTruthy()
+ expect(screen.getByText('Swapping to stETH')).toBeTruthy()
})
it('pending without status: received still spinning', () => {
@@ -122,6 +139,9 @@ describe('StatusStepList', () => {
'https://etherscan.io/tx/0xreceiving'
)
expect(screen.queryByTestId('sent-to-wallet')).not.toBeNull()
+ // Terminal steps read in the past tense.
+ expect(screen.getByText('USDC received')).toBeTruthy()
+ expect(screen.getByText('Swapped to stETH')).toBeTruthy()
})
it('done without segments: received row carries the receiving link', () => {
diff --git a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusStepList.tsx b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusStepList.tsx
index 2e46e4751..ee7a4272b 100644
--- a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusStepList.tsx
+++ b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusStepList.tsx
@@ -1,13 +1,14 @@
import type { ExtendedTransactionInfo, FullStatusData, Route } from '@lifi/sdk'
import {
ActionRow,
+ ExternalLink,
IconCircle,
SentToWalletRow,
useAvailableChains,
useExplorer,
} from '@lifi/widget/shared'
import OpenInNew from '@mui/icons-material/OpenInNew'
-import { Box, CircularProgress, Link, Stack, styled } from '@mui/material'
+import { Box, CircularProgress, Stack } from '@mui/material'
import { type JSX, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -37,20 +38,6 @@ interface StatusStepListProps {
recipientAddress?: string | null
}
-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: `color-mix(in srgb, ${theme.vars.palette.common.onBackground} 4%, transparent)`,
- },
-}))
-
export function StatusStepList({
status,
phase,
@@ -121,12 +108,13 @@ export function StatusStepList({
phase === 'done' ||
sourceConfirmed ||
(phase === 'pending' && Boolean(status))
+ const state: RowState = received ? 'done' : inFlightState
out.push({
key: 'tokenReceived',
- label: t('checkout.transactionStatus.steps.tokenReceived', {
+ label: t(`checkout.transactionStatus.steps.receive.${state}`, {
symbol: fromSymbol,
}),
- state: received ? 'done' : inFlightState,
+ state,
href:
sendingTxLink ??
(segments.length === 0 ? receivingTxLink : undefined),
@@ -138,17 +126,18 @@ export function StatusStepList({
typeof segment.fromChainId === 'number' &&
typeof segment.toChainId === 'number' &&
segment.fromChainId !== segment.toChainId
+ const state: RowState = phase === 'done' ? 'done' : inFlightState
const label = isCrossChain
- ? t('checkout.transactionStatus.steps.bridgedTo', {
+ ? t(`checkout.transactionStatus.steps.bridge.${state}`, {
chain: getChainById(segment.toChainId!)?.name ?? '',
}).trim()
- : t('checkout.transactionStatus.steps.swappedTo', {
+ : t(`checkout.transactionStatus.steps.swap.${state}`, {
symbol: segment.toSymbol ?? '',
})
out.push({
key: `step-${i}`,
label,
- state: phase === 'done' ? 'done' : inFlightState,
+ state,
href: i === segments.length - 1 ? receivingTxLink : undefined,
})
})
diff --git a/packages/widget-checkout/src/pages/EnterAmountPage/EnterAmountPage.tsx b/packages/widget-checkout/src/pages/EnterAmountPage/EnterAmountPage.tsx
index af4ae1bd5..8c28c2f1d 100644
--- a/packages/widget-checkout/src/pages/EnterAmountPage/EnterAmountPage.tsx
+++ b/packages/widget-checkout/src/pages/EnterAmountPage/EnterAmountPage.tsx
@@ -3,6 +3,7 @@ import {
MainWarningMessages,
PageContainer,
PoweredBy,
+ SendToWalletButton,
useFieldActions,
useFieldValues,
useHeader,
@@ -17,16 +18,17 @@ import { CheckoutAmountInput } from '../../components/CheckoutAmountInput.js'
import { CheckoutAmountPresets } from '../../components/CheckoutAmountPresets.js'
import { CheckoutFlowCtaButton } from '../../components/CheckoutFlowCtaButton.js'
import { CheckoutReceiveCard } from '../../components/CheckoutReceiveCard.js'
-import { CheckoutRecipientCard } from '../../components/CheckoutRecipientCard.js'
import { FiatCurrencyChip } from '../../components/FiatCurrencyChip.js'
-import { TermsDisclaimer } from '../../components/TermsDisclaimer.js'
import {
INTENT_FACTORY_ONLY,
useCheckoutExchangesOverride,
} from '../../hooks/useCheckoutExchangesOverride.js'
+import { useCheckoutNavigate } from '../../hooks/useCheckoutNavigate.js'
import { useIsWalletFundedFlow } from '../../hooks/useIsWalletFundedFlow.js'
import { useOnRampPreconnect } from '../../hooks/useOnRampPreconnect.js'
+import { useResolvedCheckoutRecipient } from '../../hooks/useResolvedCheckoutRecipient.js'
import { useCheckoutFlowStore } from '../../stores/useCheckoutFlowStore.js'
+import { checkoutNavigationRoutes } from '../../utils/navigationRoutes.js'
const headerKeyByFlow = {
wallet: 'checkout.payFromWallet',
@@ -46,6 +48,8 @@ export const EnterAmountPage: React.FC = (): JSX.Element => {
const setInputMode = useInputModeStore((s) => s.setInputMode)
const { setFieldValue } = useFieldActions()
const [cashFiatAmount] = useFieldValues('cashFiatAmount')
+ const navigate = useCheckoutNavigate()
+ const { isUserSettable, clearUserRecipient } = useResolvedCheckoutRecipient()
useOnRampPreconnect()
useLayoutEffect(() => {
@@ -89,13 +93,20 @@ export const EnterAmountPage: React.FC = (): JSX.Element => {
presetsSlot={isCashFlow ? : undefined}
/>
-
+ navigate({ to: checkoutNavigationRoutes.setDestination })
+ : null
+ }
+ onClearAddress={clearUserRecipient}
+ />
{isWalletFunded ? : null}
-
{showPoweredBy ? : null}
diff --git a/packages/widget-checkout/src/pages/SelectSourcePage/CheckoutActivitySection.test.tsx b/packages/widget-checkout/src/pages/SelectSourcePage/CheckoutActivitySection.test.tsx
deleted file mode 100644
index 0ac823d88..000000000
--- a/packages/widget-checkout/src/pages/SelectSourcePage/CheckoutActivitySection.test.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-// @vitest-environment happy-dom
-
-import { fireEvent, screen } from '@testing-library/react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import type {
- PendingActivityItem,
- PendingActivityState,
-} from '../../hooks/useCheckoutPendingRecords.js'
-import { renderWithI18n } from '../../test/renderWithI18n.js'
-
-let mockItems: PendingActivityItem[] = []
-const resumeSpy = vi.fn()
-const clearSpy = vi.fn()
-
-vi.mock('../../hooks/useCheckoutPendingRecords.js', () => ({
- useCheckoutPendingRecords: () => mockItems,
-}))
-vi.mock('../../hooks/useResumeCheckout.js', () => ({
- useResumeCheckout: () => resumeSpy,
-}))
-vi.mock('../../stores/usePendingCheckoutStore.js', () => ({
- usePendingCheckoutStore: (selector: (s: unknown) => unknown) =>
- selector({ clearForKey: clearSpy }),
-}))
-vi.mock('@lifi/widget/shared', () => ({
- formatTokenAmount: () => '100',
- useChain: () => ({ chain: { name: 'Arbitrum' } }),
-}))
-
-import { CheckoutActivitySection } from './CheckoutActivitySection.js'
-
-function item(
- key: string,
- state: PendingActivityState,
- depositDetected = false,
- symbol = 'USDC'
-): PendingActivityItem {
- return {
- key,
- state,
- depositDetected,
- record: {
- fromAmount: '100000000',
- tokenDecimals: 6,
- tokenSymbol: symbol,
- fromChain: 42161,
- } as never,
- }
-}
-
-describe('CheckoutActivitySection', () => {
- beforeEach(() => {
- mockItems = []
- resumeSpy.mockReset()
- clearSpy.mockReset()
- })
-
- it('renders nothing when there are no items', () => {
- const { container } = renderWithI18n()
- expect(container.firstChild).toBeNull()
- })
-
- it('renders a compact badge for a single deposit in progress', () => {
- mockItems = [item('int:a', 'deposit')]
- renderWithI18n()
- expect(screen.getByText('Deposit in progress')).toBeTruthy()
- expect(screen.queryByText('Activity')).toBeNull()
- })
-
- it('renders "Refund in progress" for a single refunding deposit', () => {
- mockItems = [item('int:a', 'refund')]
- renderWithI18n()
- expect(screen.getByText('Refund in progress')).toBeTruthy()
- })
-
- it('renders the failed badge for a single failed deposit', () => {
- mockItems = [item('int:a', 'failed')]
- renderWithI18n()
- expect(screen.getByText('Deposit failed. Please resolve')).toBeTruthy()
- })
-
- it('renders a labelled card list and resumes with the detected flag on tap', () => {
- mockItems = [item('int:a', 'deposit', true), item('int:b', 'failed')]
- renderWithI18n()
- expect(screen.getByText('Activity')).toBeTruthy()
- const titles = screen.getAllByText('100 USDC on Arbitrum')
- expect(titles).toHaveLength(2)
- fireEvent.click(titles[0] as HTMLElement)
- expect(resumeSpy).toHaveBeenCalledWith(mockItems[0]?.record, true)
- })
-
- it('dismisses only the failed card without resuming', () => {
- mockItems = [item('int:a', 'deposit'), item('int:b', 'failed')]
- renderWithI18n()
- fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
- expect(clearSpy).toHaveBeenCalledWith('int:b')
- expect(resumeSpy).not.toHaveBeenCalled()
- })
-})
diff --git a/packages/widget-checkout/src/pages/SelectSourcePage/CheckoutActivitySection.tsx b/packages/widget-checkout/src/pages/SelectSourcePage/CheckoutActivitySection.tsx
deleted file mode 100644
index 5c67a7932..000000000
--- a/packages/widget-checkout/src/pages/SelectSourcePage/CheckoutActivitySection.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-'use client'
-import { formatTokenAmount, useChain } from '@lifi/widget/shared'
-import ChevronRightRounded from '@mui/icons-material/ChevronRightRounded'
-import CloseRounded from '@mui/icons-material/CloseRounded'
-import ErrorRounded from '@mui/icons-material/ErrorRounded'
-import { Box, CircularProgress, IconButton, Stack } from '@mui/material'
-import type { JSX } from 'react'
-import { useTranslation } from 'react-i18next'
-import {
- type PendingActivityItem,
- type PendingActivityState,
- useCheckoutPendingRecords,
-} from '../../hooks/useCheckoutPendingRecords.js'
-import { useResumeCheckout } from '../../hooks/useResumeCheckout.js'
-import { usePendingCheckoutStore } from '../../stores/usePendingCheckoutStore.js'
-import {
- FundingOptionCard,
- FundingOptionRow,
- FundingOptionSubtitle,
- FundingOptionTitle,
- FundingSectionLabel,
- FundingSectionStack,
- OptionTextCell,
-} from './SelectSourceFundingOptions.style.js'
-
-function inProgressLabelKey(state: PendingActivityState): string {
- return state === 'refund'
- ? 'checkout.activity.refundInProgress'
- : 'checkout.activity.depositInProgress'
-}
-
-interface ActivityStatusIconProps {
- failed: boolean
- /** Outer box dimension and the glyph/spinner sizing (card vs compact badge). */
- box: number
- errorSize: number
- spinnerSize: number
- spinnerThickness: number
-}
-
-function ActivityStatusIcon({
- failed,
- box,
- errorSize,
- spinnerSize,
- spinnerThickness,
-}: ActivityStatusIconProps): JSX.Element {
- return (
-
- {failed ? (
-
- ) : (
-
- )}
-
- )
-}
-
-interface ActivityCardProps {
- item: PendingActivityItem
- onResume: (item: PendingActivityItem) => void
- onDismiss: (key: string) => void
-}
-
-function ActivityCard({
- item,
- onResume,
- onDismiss,
-}: ActivityCardProps): JSX.Element {
- const { t } = useTranslation()
- const { record, state } = item
- const { chain } = useChain(record.fromChain)
- const failed = state === 'failed'
-
- const title =
- record.fromAmount &&
- record.tokenDecimals !== undefined &&
- record.tokenSymbol
- ? t('checkout.activity.amountOnChain', {
- amount: formatTokenAmount(
- BigInt(record.fromAmount),
- record.tokenDecimals
- ),
- symbol: record.tokenSymbol,
- chain: chain?.name ?? '',
- })
- : t('checkout.activity.deposit')
-
- return (
- onResume(item)}>
-
-
-
- {title}
-
- {failed
- ? t('checkout.activity.couldNotComplete')
- : t(inProgressLabelKey(state))}
-
-
-
- {failed ? (
- {
- e.stopPropagation()
- onDismiss(item.key)
- }}
- >
-
-
- ) : null}
-
-
-
-
- )
-}
-
-export function CheckoutActivitySection(): JSX.Element | null {
- const { t } = useTranslation()
- const items = useCheckoutPendingRecords()
- const resume = useResumeCheckout()
- const clearForKey = usePendingCheckoutStore((s) => s.clearForKey)
-
- if (items.length === 0) {
- return null
- }
-
- const onResume = (item: PendingActivityItem): void =>
- resume(item.record, item.depositDetected)
- const onDismiss = (key: string): void => clearForKey(key)
-
- // Single live deposit → compact one-line badge (Figma "activity" badge variant).
- if (items.length === 1) {
- const item = items[0]
- if (!item) {
- return null
- }
- const failed = item.state === 'failed'
- return (
- onResume(item)}>
-
-
-
-
- {failed
- ? t('checkout.activity.singleFailed')
- : t(inProgressLabelKey(item.state))}
-
-
-
-
-
- )
- }
-
- return (
-
- {t('checkout.activity.title')}
-
- {items.map((item) => (
-
- ))}
-
-
- )
-}
diff --git a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.style.tsx b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.style.tsx
index bbeea2c7a..1c4343cfd 100644
--- a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.style.tsx
+++ b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.style.tsx
@@ -27,21 +27,9 @@ export const FundingSectionLabel: React.FC<
width: '100%',
}))
-export const FundingOptionCard: React.FC> =
- styled(Card)(({ theme }) => ({
- cursor: 'pointer',
- borderRadius: 12,
- border: 'none',
- boxShadow: '0px 2px 8px 0px rgba(0,0,0,0.04)',
- backgroundColor: theme.vars.palette.background.paper,
- overflow: 'hidden',
- transition: theme.transitions.create('box-shadow', {
- duration: theme.transitions.duration.shortest,
- }),
- '&:hover': {
- boxShadow: theme.shadows[3],
- },
- }))
+// Plain MuiCard, same as the wallet-list CardListItemButton, so border/radius/
+// shadow resolve from the theme's outlined variant identically on every theme.
+export const FundingOptionCard: typeof Card = Card
export const FundingOptionRow: React.FC> =
styled(Box)(({ theme }) => ({
diff --git a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.tsx b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.tsx
index 517dd0272..f331c95ed 100644
--- a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.tsx
+++ b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.tsx
@@ -7,7 +7,6 @@ import CreditCardIcon from '@mui/icons-material/CreditCard'
import PowerSettingsNewRounded from '@mui/icons-material/PowerSettingsNewRounded'
import QrCode2Icon from '@mui/icons-material/QrCode2'
import {
- Alert,
Avatar,
CircularProgress,
IconButton,
@@ -61,8 +60,6 @@ export type SelectSourceFundingOptionsProps = {
showConnectExchange?: boolean
/** When true, shows a loading spinner in place of exchange avatars. */
exchangeLoading?: boolean
- /** When set, shows an inline error below the exchange card. */
- exchangeError?: string | null
/** Previously-linked exchange accounts; each renders a one-tap reconnect row. */
connectedExchangeAccounts?: ConnectedCexAccount[]
/** Invoked when the user picks a previously-connected exchange account. */
@@ -85,7 +82,6 @@ export function SelectSourceFundingOptions({
onConnectExchange,
showConnectExchange = false,
exchangeLoading = false,
- exchangeError = null,
connectedExchangeAccounts = [],
onReuseExchange,
onForgetExchange,
@@ -104,7 +100,7 @@ export function SelectSourceFundingOptions({
{t('checkout.useYourTokens')}
-
+
{payFromWalletConnected ? (
-
+
@@ -188,7 +184,6 @@ export function SelectSourceFundingOptions({
onReuseExchange(account)}
- elevation={0}
>
) : null}
-
- {showConnectExchange && exchangeError ? (
-
- {exchangeError}
-
- ) : null}
@@ -300,7 +288,7 @@ export function SelectSourceFundingOptions({
{t('checkout.buyTokens')}
-
+
diff --git a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx
index 072eaa0e3..dccfcbbd0 100644
--- a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx
+++ b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx
@@ -2,6 +2,7 @@ import { parseUnits } from '@lifi/sdk'
import { useAccount, useWalletMenu } from '@lifi/wallet-management'
import {
FormKeyHelper,
+ PageContainer,
PoweredBy,
useChain,
useFieldActions,
@@ -18,21 +19,19 @@ import {
useConnectedCexStore,
} from '@lifi/widget-provider/checkout'
import { useMeshBalance } from '@lifi/widget-provider-mesh'
-import { Alert, Box } from '@mui/material'
+import { Alert, Box, CircularProgress } from '@mui/material'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
-import { formatOnRampError } from '../../components/formatOnRampError.js'
import { Stack } from '../../components/Stack.js'
import {
INTENT_FACTORY_ONLY,
useCheckoutExchangesOverride,
} from '../../hooks/useCheckoutExchangesOverride.js'
import { useCheckoutNavigate } from '../../hooks/useCheckoutNavigate.js'
+import { useCheckoutPendingRecords } from '../../hooks/useCheckoutPendingRecords.js'
+import { useResumeCheckout } from '../../hooks/useResumeCheckout.js'
import { useSelectSourceTopWallets } from '../../hooks/useSelectSourceTopWallets.js'
-import {
- useOnRampProviderByCategory,
- useOnRampSessionByCategory,
-} from '../../providers/OnRampProvider/OnRampProvider.js'
+import { useOnRampSessionByCategory } from '../../providers/OnRampProvider/OnRampProvider.js'
import { useCheckoutFlowStore } from '../../stores/useCheckoutFlowStore.js'
import { useFiatCurrencyStore } from '../../stores/useFiatCurrencyStore.js'
import {
@@ -41,7 +40,7 @@ import {
} from '../../utils/checkoutDefaults.js'
import { isNativeToken } from '../../utils/nativeToken.js'
import { checkoutNavigationRoutes } from '../../utils/navigationRoutes.js'
-import { CheckoutActivitySection } from './CheckoutActivitySection.js'
+import { pickAutoResumeItem } from '../../utils/pickAutoResumeItem.js'
import { SelectSourceFundingOptions } from './SelectSourceFundingOptions.js'
import { SelectSourceMainColumn } from './SelectSourceLayout.js'
@@ -53,7 +52,6 @@ export const SelectSourcePage: React.FC = () => {
const { accounts } = useAccount()
const cashSession = useOnRampSessionByCategory('cash')
const exchangeSession = useOnRampSessionByCategory('exchange')
- const exchangeProvider = useOnRampProviderByCategory('exchange')
const { topWallets, walletOverflowCount } = useSelectSourceTopWallets()
const setFundingSource = useCheckoutFlowStore((s) => s.setFundingSource)
const setSelectedExchangeAccount = useCheckoutFlowStore(
@@ -75,6 +73,21 @@ export const SelectSourcePage: React.FC = () => {
const fundingSource = useCheckoutFlowStore((s) => s.fundingSource)
const wasExchangeFlow = fundingSource === 'exchange'
+ const pendingItems = useCheckoutPendingRecords()
+ const resumeCheckout = useResumeCheckout()
+ const autoResumeItem = useMemo(
+ () => pickAutoResumeItem(pendingItems),
+ [pendingItems]
+ )
+ const autoResumedRef = useRef(false)
+ useEffect(() => {
+ if (autoResumedRef.current || !autoResumeItem) {
+ return
+ }
+ autoResumedRef.current = true
+ resumeCheckout(autoResumeItem.record, autoResumeItem.depositDetected)
+ }, [autoResumeItem, resumeCheckout])
+
const formType = 'from' as const
const [prevChainId, prevTokenAddress, prevAmountStr] = useFieldValues(
FormKeyHelper.getChainKey(formType),
@@ -112,8 +125,12 @@ export const SelectSourcePage: React.FC = () => {
meshRawBalance < prevRequestedRaw
useEffect(() => {
+ // Skip while auto-resuming, else it clobbers the flow the resume just set.
+ if (autoResumeItem) {
+ return
+ }
resetFlow()
- }, [resetFlow])
+ }, [resetFlow, autoResumeItem])
const payFromWalletAccount = useMemo(
() => accounts.find((acct) => acct.isConnected && acct.address) ?? null,
@@ -235,6 +252,24 @@ export const SelectSourcePage: React.FC = () => {
[topWallets]
)
+ // Hold a loader rather than flash the funding options before redirecting.
+ if (autoResumeItem) {
+ return (
+
+
+
+
+
+ )
+ }
+
return (
({
@@ -242,7 +277,6 @@ export const SelectSourcePage: React.FC = () => {
})}
>
-
{showInsufficientFunds && prevToken && prevChain ? (
{t('checkout.insufficientFunds', {
@@ -262,11 +296,6 @@ export const SelectSourcePage: React.FC = () => {
onReuseExchange={handleReuseExchange}
onForgetExchange={handleForgetExchange}
exchangeLoading={exchangeSession?.isLoading ?? false}
- exchangeError={formatOnRampError(
- exchangeSession?.error ?? null,
- exchangeProvider?.name ?? '',
- t
- )}
payFromWalletIcons={payFromWalletIcons}
payFromWalletOverflow={walletOverflowCount}
payFromWalletConnected={payFromWalletConnected}
diff --git a/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx b/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx
index c39faa1d5..a099aa00a 100644
--- a/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx
+++ b/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenList.tsx
@@ -32,6 +32,8 @@ export interface SelectTokenListProps {
* the curated set renders as a flat list.
*/
allowedSymbols?: ReadonlySet
+ /** Token (chain + address) to omit from the list, e.g. the destination token. */
+ excludeToken?: { chainId: number; address: string }
}
type SharedListProps = Omit & {
@@ -50,6 +52,7 @@ const TokenListView: FC = ({
headerRef,
afterTokenSelect,
allowedSymbols,
+ excludeToken,
excludeNative,
selectedChainId,
selectedTokenAddress,
@@ -79,9 +82,18 @@ const TokenListView: FC = ({
)
const filteredTokens = useMemo(() => {
- const withoutNative = excludeNative
- ? tokens.filter((token) => !isNativeToken(token.address))
+ const withoutExcluded = excludeToken
+ ? tokens.filter(
+ (token) =>
+ !(
+ token.chainId === excludeToken.chainId &&
+ token.address.toLowerCase() === excludeToken.address.toLowerCase()
+ )
+ )
: tokens
+ const withoutNative = excludeNative
+ ? withoutExcluded.filter((token) => !isNativeToken(token.address))
+ : withoutExcluded
if (!allowedSymbols || allowedSymbols.size === 0) {
return withoutNative
}
@@ -90,7 +102,7 @@ const TokenListView: FC = ({
return withoutNative
.filter((token) => allowedSymbols.has(token.symbol.toUpperCase()))
.map((token) => ({ ...token, amount: undefined }))
- }, [tokens, allowedSymbols, excludeNative])
+ }, [tokens, allowedSymbols, excludeToken, excludeNative])
const showCategories =
!allowedSymbols && withCategories && !tokenSearchFilter && !isAllNetworks
@@ -155,6 +167,7 @@ export const SelectTokenList: FC = memo(
afterTokenSelect,
isWalletFunded,
allowedSymbols,
+ excludeToken,
}) => {
const [selectedChainId, selectedTokenAddress] = useFieldValues(
FormKeyHelper.getChainKey(formType),
@@ -177,6 +190,7 @@ export const SelectTokenList: FC = memo(
headerRef,
afterTokenSelect,
allowedSymbols,
+ excludeToken,
excludeNative: !isWalletFunded,
selectedChainId,
selectedTokenAddress,
diff --git a/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenPage.tsx b/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenPage.tsx
index 90534e9dd..d0ffcb950 100644
--- a/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenPage.tsx
+++ b/packages/widget-checkout/src/pages/SelectTokenPage/SelectTokenPage.tsx
@@ -54,6 +54,18 @@ export const SelectTokenPage: React.FC = () => {
FormKeyHelper.getTokenKey(formType),
FormKeyHelper.getAmountKey(formType)
)
+ const [toChainId, toTokenAddress] = useFieldValues(
+ FormKeyHelper.getChainKey('to'),
+ FormKeyHelper.getTokenKey('to')
+ )
+ // Source token can't equal the fixed destination token; hide it from the list.
+ const excludeToken = useMemo(
+ () =>
+ toChainId != null && toTokenAddress
+ ? { chainId: Number(toChainId), address: String(toTokenAddress) }
+ : undefined,
+ [toChainId, toTokenAddress]
+ )
const { token: selectedToken } = useToken(
isExchangeFlow ? selectedChainId : undefined,
isExchangeFlow ? selectedTokenAddress : undefined
@@ -127,6 +139,7 @@ export const SelectTokenPage: React.FC = () => {
afterTokenSelect={afterTokenSelect}
isWalletFunded={isWalletFunded}
allowedSymbols={isExchangeFlow ? exchangeAllowedSymbols : undefined}
+ excludeToken={excludeToken}
/>
)
diff --git a/packages/widget-checkout/src/pages/SetDestinationAddressPage/SetDestinationAddressPage.tsx b/packages/widget-checkout/src/pages/SetDestinationAddressPage/SetDestinationAddressPage.tsx
index d4203f53f..ae63a94e9 100644
--- a/packages/widget-checkout/src/pages/SetDestinationAddressPage/SetDestinationAddressPage.tsx
+++ b/packages/widget-checkout/src/pages/SetDestinationAddressPage/SetDestinationAddressPage.tsx
@@ -56,7 +56,8 @@ export const SetDestinationAddressPage: React.FC = (): JSX.Element => {
setUserRecipient({ address, chainType })
// Seed the form now so the route query has the recipient on the next render.
setFieldValue('toAddress', address, { isDirty: false, isTouched: true })
- navigate({ to: checkoutNavigationRoutes.enterAmount })
+ // Replace so Back from enter-amount skips this screen.
+ navigate({ to: checkoutNavigationRoutes.enterAmount, replace: true })
},
[setUserRecipient, setFieldValue, navigate]
)
diff --git a/packages/widget-checkout/src/pages/TransferDepositPage/TransferDepositPage.tsx b/packages/widget-checkout/src/pages/TransferDepositPage/TransferDepositPage.tsx
index 08ad3d967..df1a193a0 100644
--- a/packages/widget-checkout/src/pages/TransferDepositPage/TransferDepositPage.tsx
+++ b/packages/widget-checkout/src/pages/TransferDepositPage/TransferDepositPage.tsx
@@ -7,6 +7,7 @@ import {
useChain,
useHeader,
} from '@lifi/widget/shared'
+import CheckRoundedIcon from '@mui/icons-material/CheckRounded'
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'
import ExpandLessRoundedIcon from '@mui/icons-material/ExpandLessRounded'
import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded'
@@ -104,6 +105,9 @@ export const TransferDepositPage: React.FC = (): JSX.Element => {
useHeader(t('header.depositAddress'))
const [detailsOpen, setDetailsOpen] = useState(true)
+ const [copied, setCopied] = useState(false)
+ const copiedTimer = useRef>(undefined)
+ useEffect(() => () => clearTimeout(copiedTimer.current), [])
if (!frozen || !route || !depositAddress || expired) {
return
@@ -120,6 +124,9 @@ export const TransferDepositPage: React.FC = (): JSX.Element => {
const copyAddress = (): void => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(depositAddress)
+ setCopied(true)
+ clearTimeout(copiedTimer.current)
+ copiedTimer.current = setTimeout(() => setCopied(false), 1500)
}
}
@@ -162,9 +169,23 @@ export const TransferDepositPage: React.FC = (): JSX.Element => {
{shortAddress}
-
-
-
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
diff --git a/packages/widget-checkout/src/providers/CheckoutAppProvider.tsx b/packages/widget-checkout/src/providers/CheckoutAppProvider.tsx
index a444591ce..20b8d63e8 100644
--- a/packages/widget-checkout/src/providers/CheckoutAppProvider.tsx
+++ b/packages/widget-checkout/src/providers/CheckoutAppProvider.tsx
@@ -1,5 +1,6 @@
import type { FormRef, WidgetConfig } from '@lifi/widget/shared'
import {
+ BookmarkStoreProvider,
I18nProvider,
QueryClientProvider,
SDKClientProvider,
@@ -67,15 +68,17 @@ const CheckoutAppShell: React.FC = ({
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
diff --git a/packages/widget-checkout/src/test/renderWithI18n.tsx b/packages/widget-checkout/src/test/renderWithI18n.tsx
index b1fd66478..eb9b5f9d9 100644
--- a/packages/widget-checkout/src/test/renderWithI18n.tsx
+++ b/packages/widget-checkout/src/test/renderWithI18n.tsx
@@ -24,6 +24,12 @@ testI18n.use(initReactI18next).init({
confirm: 'Close checkout',
cancel: 'Cancel',
},
+ abandonConfirmation: {
+ title: 'Cancel this transfer?',
+ body: "Your deposit details will be discarded. Don't send funds to this address after cancelling.",
+ confirm: 'Cancel transfer',
+ cancel: 'Keep transfer',
+ },
activity: {
title: 'Activity',
deposit: 'Deposit',
@@ -44,9 +50,21 @@ testI18n.use(initReactI18next).init({
executing: 'Processing transaction',
detailsTitle: 'Transaction details',
steps: {
- tokenReceived: '{{symbol}} received',
- swappedTo: 'Swapped to {{symbol}}',
- bridgedTo: 'Bridged to {{chain}}',
+ receive: {
+ upcoming: 'Receive {{symbol}}',
+ loading: 'Receiving {{symbol}}',
+ done: '{{symbol}} received',
+ },
+ swap: {
+ upcoming: 'Swap to {{symbol}}',
+ loading: 'Swapping to {{symbol}}',
+ done: 'Swapped to {{symbol}}',
+ },
+ bridge: {
+ upcoming: 'Bridge to {{chain}}',
+ loading: 'Bridging to {{chain}}',
+ done: 'Bridged to {{chain}}',
+ },
},
},
onramp: { errors: { generic: 'Something went wrong.' } },
diff --git a/packages/widget-checkout/src/utils/navigationRoutes.test.ts b/packages/widget-checkout/src/utils/navigationRoutes.test.ts
index 8607125f3..47f724b4f 100644
--- a/packages/widget-checkout/src/utils/navigationRoutes.test.ts
+++ b/packages/widget-checkout/src/utils/navigationRoutes.test.ts
@@ -58,6 +58,7 @@ describe('backButtonRoutes', () => {
it('includes the well-known back-eligible routes', () => {
expect(backButtonRoutes).toContain('enter-amount')
expect(backButtonRoutes).toContain('select-cash')
+ expect(backButtonRoutes).toContain('transfer-deposit')
expect(backButtonRoutes).toContain(
checkoutNavigationRoutes.transactionDetails
)
@@ -65,7 +66,6 @@ describe('backButtonRoutes', () => {
it('excludes status / progress / error routes so they never show a back button', () => {
expect(backButtonRoutes).not.toContain('progress')
- expect(backButtonRoutes).not.toContain('transfer-deposit')
expect(backButtonRoutes).not.toContain(
checkoutNavigationRoutes.transactionExecution
)
diff --git a/packages/widget-checkout/src/utils/navigationRoutes.ts b/packages/widget-checkout/src/utils/navigationRoutes.ts
index 4373cf777..689df30f0 100644
--- a/packages/widget-checkout/src/utils/navigationRoutes.ts
+++ b/packages/widget-checkout/src/utils/navigationRoutes.ts
@@ -31,17 +31,12 @@ export type CheckoutNavigationRoute =
export const checkoutNavigationRoutesValues: CheckoutNavigationRoute[] =
Object.values(checkoutNavigationRoutes)
-/**
- * Routes that surface a back button in the header. Status/progress/error
- * pages (`progress`, `transfer-deposit`, `transaction-execution`,
- * `transaction-status`, every `deposit-error/*`) intentionally omit the
- * back button — once a transfer is committed or terminal state is reached,
- * "back" is misleading.
- */
+// Back on transfer-deposit abandons the transfer (clears quote + record), not resumes it.
export const backButtonRoutes: string[] = [
'enter-amount',
'set-destination',
'select-cash',
+ 'transfer-deposit',
checkoutNavigationRoutes.fromToken,
checkoutNavigationRoutes.fromChain,
checkoutNavigationRoutes.routes,
diff --git a/packages/widget-checkout/src/utils/pickAutoResumeItem.test.ts b/packages/widget-checkout/src/utils/pickAutoResumeItem.test.ts
new file mode 100644
index 000000000..89db3828c
--- /dev/null
+++ b/packages/widget-checkout/src/utils/pickAutoResumeItem.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest'
+import type { PendingActivityItem } from '../hooks/useCheckoutPendingRecords.js'
+import type { PendingRecord } from '../stores/usePendingCheckoutStore.js'
+import { pickAutoResumeItem } from './pickAutoResumeItem.js'
+
+function item(
+ key: string,
+ state: PendingActivityItem['state'],
+ record: Partial
+): PendingActivityItem {
+ return {
+ key,
+ state,
+ depositDetected: false,
+ record: { fundingSource: 'transfer', ...record } as PendingRecord,
+ }
+}
+
+describe('pickAutoResumeItem', () => {
+ it('returns the lone in-progress deposit', () => {
+ const target = item('a', 'deposit', {
+ depositAddress: '0xdep',
+ fromChain: 1,
+ })
+ expect(pickAutoResumeItem([target])).toBe(target)
+ })
+
+ it('resumes a tx-hash record without a deposit address', () => {
+ const target = item('a', 'deposit', {
+ transactionHash: '0xhash',
+ fromChain: 1,
+ })
+ expect(pickAutoResumeItem([target])).toBe(target)
+ })
+
+ it('returns null when the only record is failed', () => {
+ const failed = item('a', 'failed', {
+ depositAddress: '0xdep',
+ fromChain: 1,
+ })
+ expect(pickAutoResumeItem([failed])).toBeNull()
+ })
+
+ it('returns null when there are no records', () => {
+ expect(pickAutoResumeItem([])).toBeNull()
+ })
+
+ it('returns null when multiple in-progress deposits are present', () => {
+ const a = item('a', 'deposit', { depositAddress: '0xa', fromChain: 1 })
+ const b = item('b', 'deposit', { depositAddress: '0xb', fromChain: 1 })
+ expect(pickAutoResumeItem([a, b])).toBeNull()
+ })
+
+ it('ignores failed records when picking the single in-progress one', () => {
+ const live = item('a', 'deposit', { depositAddress: '0xa', fromChain: 1 })
+ const failed = item('b', 'failed', { depositAddress: '0xb', fromChain: 1 })
+ expect(pickAutoResumeItem([live, failed])).toBe(live)
+ })
+
+ it('returns null for an in-progress record with no resumable identifier', () => {
+ const degenerate = item('a', 'deposit', {})
+ expect(pickAutoResumeItem([degenerate])).toBeNull()
+ })
+})
diff --git a/packages/widget-checkout/src/utils/pickAutoResumeItem.ts b/packages/widget-checkout/src/utils/pickAutoResumeItem.ts
new file mode 100644
index 000000000..1dd1cf97c
--- /dev/null
+++ b/packages/widget-checkout/src/utils/pickAutoResumeItem.ts
@@ -0,0 +1,17 @@
+import type { PendingActivityItem } from '../hooks/useCheckoutPendingRecords.js'
+
+// Only a lone in-progress deposit auto-resumes; failed or multiple stay on the funding screen.
+export function pickAutoResumeItem(
+ items: PendingActivityItem[]
+): PendingActivityItem | null {
+ const resumable = items.filter(
+ (item) =>
+ item.state !== 'failed' &&
+ Boolean(
+ item.record.depositAddress ||
+ item.record.transactionHash ||
+ item.record.taskId
+ )
+ )
+ return resumable.length === 1 ? resumable[0] : null
+}
diff --git a/packages/widget-playground/src/components/DeveloperControlsDetailView/DeveloperControlsDetailView.tsx b/packages/widget-playground/src/components/DeveloperControlsDetailView/DeveloperControlsDetailView.tsx
index 0348c82ae..ee76a57f0 100644
--- a/packages/widget-playground/src/components/DeveloperControlsDetailView/DeveloperControlsDetailView.tsx
+++ b/packages/widget-playground/src/components/DeveloperControlsDetailView/DeveloperControlsDetailView.tsx
@@ -12,6 +12,7 @@ import {
useConfigContainer,
useConfigHeaderPosition,
useConfigVariant,
+ usePlaygroundWidgetMode,
} from '../../store/widgetConfig/useConfigValues.js'
import {
clearPlaygroundBookmarkStores,
@@ -20,6 +21,7 @@ import {
} from '../../utils/bookmarkStores.js'
import { docsLinks } from '../../utils/docsLinks.js'
import { isFullHeightLayout } from '../../utils/layout.js'
+import { CheckoutControls } from '../CheckoutControls/CheckoutControls.js'
import { Content, Title, TitleSection } from '../DetailView/DetailView.style.js'
import { DetailViewHeader } from '../DetailView/DetailViewHeader.js'
import {
@@ -38,7 +40,7 @@ import { DeveloperToggleItem } from './DeveloperToggleItem.js'
import { FormValuesControls } from './FormValuesControls.js'
import { WidgetEventsDetailView } from './WidgetEventsDetailView.js'
-type DeveloperControlsSection = 'main' | 'widget-events'
+type DeveloperControlsSection = 'main' | 'widget-events' | 'checkout'
interface DeveloperControlsDetailViewProps {
onBack: () => void
@@ -65,6 +67,8 @@ export const DeveloperControlsDetailView = ({
setFixedFooter,
} = useEditToolsActions()
const { monitoredEvents } = useWidgetEventMonitorValues()
+ const { playgroundWidgetMode } = usePlaygroundWidgetMode()
+ const isCheckoutMode = playgroundWidgetMode === 'checkout'
const [activeSection, setActiveSection] =
useState('main')
@@ -97,7 +101,7 @@ export const DeveloperControlsDetailView = ({
)
return (
-
+
@@ -182,12 +186,32 @@ export const DeveloperControlsDetailView = ({
Configure
+ {isCheckoutMode ? (
+
+ setActiveSection('checkout')}
+ >
+ Configure
+
+
+ ) : null}
{activeSection === 'widget-events' ? (
setActiveSection('main')} />
) : null}
+ {activeSection === 'checkout' ? (
+
+ setActiveSection('main')} />
+
+ ) : null}
)
}
diff --git a/packages/widget-playground/src/components/Sidebar/DrawerControls.tsx b/packages/widget-playground/src/components/Sidebar/DrawerControls.tsx
index aa0bbe818..4c8765e2b 100644
--- a/packages/widget-playground/src/components/Sidebar/DrawerControls.tsx
+++ b/packages/widget-playground/src/components/Sidebar/DrawerControls.tsx
@@ -2,7 +2,6 @@ import AccountBalanceWalletOutlinedIcon from '@mui/icons-material/AccountBalance
import DataObjectOutlinedIcon from '@mui/icons-material/DataObjectOutlined'
import HeightOutlinedIcon from '@mui/icons-material/HeightOutlined'
import PaletteOutlinedIcon from '@mui/icons-material/PaletteOutlined'
-import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
import TableRowsOutlinedIcon from '@mui/icons-material/TableRowsOutlined'
import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined'
import { Divider } from '@mui/material'
@@ -14,14 +13,12 @@ import { useFontInitialisation } from '../../providers/FontLoaderProvider/FontLo
import { useDrawerToolValues } from '../../store/editTools/useDrawerToolValues.js'
import { useEditToolsActions } from '../../store/editTools/useEditToolsActions.js'
import { useConfigActions } from '../../store/widgetConfig/useConfigActions.js'
-import { usePlaygroundWidgetMode } from '../../store/widgetConfig/useConfigValues.js'
import type { SidebarView } from '../../types.js'
import {
clearPlaygroundBookmarkStores,
readPlaygroundBookmarksSeeded,
} from '../../utils/bookmarkStores.js'
import { setQueryStringParam } from '../../utils/setQueryStringParam.js'
-import { CheckoutControls } from '../CheckoutControls/CheckoutControls.js'
import {
SlideViewPanel,
SlideViewTrack,
@@ -56,7 +53,6 @@ const DETAIL_VIEWS: Partial<
wallet: WalletManagementDetailView,
developer: DeveloperControlsDetailView,
themeEdit: ThemeEditDetailView,
- checkout: CheckoutControls,
}
export const DrawerControls = (): JSX.Element => {
@@ -68,7 +64,6 @@ export const DrawerControls = (): JSX.Element => {
const [expandedItem, setExpandedItem] = useState(null)
const { themeLabel, modeValue, variantValue, heightValue, walletValue } =
useSidebarNavLabels()
- const { playgroundWidgetMode } = usePlaygroundWidgetMode()
useFontInitialisation()
@@ -122,13 +117,6 @@ export const DrawerControls = (): JSX.Element => {
value={modeValue}
onClick={() => setActiveView('mode')}
/>
- {playgroundWidgetMode === 'checkout' ? (
- }
- label="Checkout"
- onClick={() => setActiveView('checkout')}
- />
- ) : null}
}
label="Variant"
diff --git a/packages/widget/src/components/SendToWallet/SendToWalletButton.tsx b/packages/widget/src/components/SendToWallet/SendToWalletButton.tsx
index c86ae0902..93d9d7759 100644
--- a/packages/widget/src/components/SendToWallet/SendToWalletButton.tsx
+++ b/packages/widget/src/components/SendToWallet/SendToWalletButton.tsx
@@ -27,7 +27,16 @@ import {
SendToWalletRequiredLabelText,
} from './SendToWalletButton.style.js'
-export const SendToWalletButton: React.FC = (props) => {
+export const SendToWalletButton: React.FC<
+ CardProps & {
+ onEditAddress?: (() => void) | null
+ onClearAddress?: () => void
+ // Keep the card visible (and flag it required when empty) even when the
+ // widget's own toAddress requirement is off, e.g. a user-settable checkout
+ // recipient that must be filled before continuing.
+ requireAddress?: boolean
+ }
+> = ({ onEditAddress, onClearAddress, requireAddress, ...props }) => {
const { t } = useTranslation()
const navigate = useNavigate()
const { disabledUI, hiddenUI, toAddress, toAddresses, mode, modeOptions } =
@@ -87,7 +96,15 @@ export const SendToWalletButton: React.FC = (props) => {
const disabledForChanges = Boolean(toAddressFieldValue) && disabledToAddress
+ // `onEditAddress === null` renders the card read-only (consumer-controlled,
+ // e.g. a fixed checkout recipient); a function overrides the default route nav.
+ const readOnly = onEditAddress === null || disabledForChanges
+
const handleOnClick = () => {
+ if (onEditAddress !== undefined) {
+ onEditAddress?.()
+ return
+ }
navigate({
to: toAddresses?.length
? navigationRoutes.configuredWallets
@@ -97,6 +114,10 @@ export const SendToWalletButton: React.FC = (props) => {
const clearSelectedBookmark: MouseEventHandler = (e) => {
e.stopPropagation()
+ if (onClearAddress) {
+ onClearAddress()
+ return
+ }
setFieldValue('toAddress', '', { isTouched: true })
setSelectedBookmark()
}
@@ -114,8 +135,9 @@ export const SendToWalletButton: React.FC = (props) => {
return () => clearTimeout(timeout)
}, [])
+ const addressRequired = requireAddress || requiredToAddress
const isOpenCollapse =
- !hiddenToAddress && (requiredToAddress || !!toAddressFieldValue)
+ !hiddenToAddress && (addressRequired || !!toAddressFieldValue)
const title =
mode === 'custom' && modeOptions?.custom?.type === 'deposit'
@@ -131,12 +153,12 @@ export const SendToWalletButton: React.FC = (props) => {
>
{title}
- {requiredToAddress && !toAddressFieldValue ? (
+ {addressRequired && !toAddressFieldValue ? (
@@ -165,7 +187,7 @@ export const SendToWalletButton: React.FC = (props) => {
subheader={headerSubheader}
selected={!!toAddressFieldValue || disabledToAddress}
action={
- toAddressFieldValue && !disabledForChanges ? (
+ toAddressFieldValue && !readOnly ? (
diff --git a/packages/widget/src/hooks/useRoutes.ts b/packages/widget/src/hooks/useRoutes.ts
index 35c301f5a..020bdc69b 100644
--- a/packages/widget/src/hooks/useRoutes.ts
+++ b/packages/widget/src/hooks/useRoutes.ts
@@ -13,7 +13,11 @@ import {
useChainTypeFromAddress,
useEthereumContext,
} from '@lifi/widget-provider'
-import { useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+ keepPreviousData,
+ useQuery,
+ useQueryClient,
+} from '@tanstack/react-query'
import { useCallback, useMemo } from 'react'
import { useSDKClient } from '../providers/SDKClientProvider.js'
import { useWidgetConfig } from '../providers/WidgetProvider/WidgetProvider.js'
@@ -43,11 +47,17 @@ interface RoutesProps {
* placeholder, so signer-dependent (relayer/permit2) quotes are skipped.
*/
quoteFromAddress?: string
+ /**
+ * Keep showing the previous result while a new query (e.g. a token/amount
+ * change) is fetching, instead of clearing to a loading state.
+ */
+ keepPreviousData?: boolean
}
export const useRoutes = ({
observableRoute,
quoteFromAddress,
+ keepPreviousData: keepPreviousDataEnabled,
}: RoutesProps = {}): {
routes: Route[] | undefined
isLoading: boolean
@@ -548,6 +558,7 @@ export const useRoutes = ({
},
enabled: isEnabled,
staleTime: refetchTime,
+ placeholderData: keepPreviousDataEnabled ? keepPreviousData : undefined,
refetchInterval(query) {
return Math.min(
Math.abs(refetchTime - (Date.now() - query.state.dataUpdatedAt)),
diff --git a/packages/widget/src/i18n/en.json b/packages/widget/src/i18n/en.json
index d7879fc08..983777e33 100644
--- a/packages/widget/src/i18n/en.json
+++ b/packages/widget/src/i18n/en.json
@@ -387,6 +387,12 @@
"confirm": "Close checkout",
"cancel": "Cancel"
},
+ "abandonConfirmation": {
+ "title": "Cancel this transfer?",
+ "body": "Your deposit details will be discarded. Don't send funds to this address after cancelling.",
+ "confirm": "Cancel transfer",
+ "cancel": "Keep transfer"
+ },
"activity": {
"title": "Activity",
"deposit": "Deposit",
@@ -413,6 +419,7 @@
"expiresIn": "Deposit address expires in {{minutes}}:{{seconds}}",
"addressLabel": "Deposit address",
"copyAddress": "Copy address",
+ "addressCopied": "Copied!",
"regenerate": "Generate new deposit address",
"expired": "This deposit window expired. Generate a new one to continue.",
"polling": "Watching for your deposit…",
@@ -502,9 +509,21 @@
"seeDetails": "See details",
"steps": {
"transferInitiated": "Transfer initiated",
- "tokenReceived": "{{symbol}} received",
- "swappedTo": "Swapped to {{symbol}}",
- "bridgedTo": "Bridged to {{chain}}"
+ "receive": {
+ "upcoming": "Receive {{symbol}}",
+ "loading": "Receiving {{symbol}}",
+ "done": "{{symbol}} received"
+ },
+ "swap": {
+ "upcoming": "Swap to {{symbol}}",
+ "loading": "Swapping to {{symbol}}",
+ "done": "Swapped to {{symbol}}"
+ },
+ "bridge": {
+ "upcoming": "Bridge to {{chain}}",
+ "loading": "Bridging to {{chain}}",
+ "done": "Bridged to {{chain}}"
+ }
}
},
"chooseFundingSource": "Choose funding source",
@@ -514,6 +533,10 @@
"addWalletToReceive": "Add a wallet to receive your {{token}}",
"walletAddressOrEns": "Wallet address or ENS name",
"insufficientFunds": "Insufficient funds — You don't have enough {{symbol}} on {{chain}}. Select a different token to try again.",
+ "routeNotFound": {
+ "title": "Can't buy this token here",
+ "description": "This token isn't available through the payment method you selected. Try a different token, or pay from a connected wallet."
+ },
"useYourTokens": "Use your tokens",
"buyTokens": "Buy tokens",
"or": "or",
diff --git a/packages/widget/src/shared.ts b/packages/widget/src/shared.ts
index b114099d8..42792b949 100644
--- a/packages/widget/src/shared.ts
+++ b/packages/widget/src/shared.ts
@@ -54,11 +54,13 @@ export {
export { RouteNotFoundCard } from './components/RouteCard/RouteNotFoundCard.js'
export { RouteTokens } from './components/RouteCard/RouteTokens.js'
export { SearchInput } from './components/Search/SearchInput.js'
+export { SendToWalletButton } from './components/SendToWallet/SendToWalletButton.js'
export {
type ExecutionRow,
useExecutionRows,
} from './components/StepActions/executionRows.js'
export { SentToWalletRow } from './components/StepActions/SentToWalletRow.js'
+export { ExternalLink } from './components/StepActions/StepActionRow.style.js'
export { StepActionsList } from './components/StepActions/StepActionsList.js'
export { Token } from './components/Token/Token.js'
export { TokenNotFound } from './components/TokenList/TokenNotFound.js'
@@ -128,6 +130,7 @@ export {
} from './providers/WidgetProvider/WidgetProvider.js'
// ── stores ───────────────────────────────────────────────────────────────────
+export { BookmarkStoreProvider } from './stores/bookmarks/BookmarkStore.js'
export { useChainOrderStore } from './stores/chains/ChainOrderStore.js'
export {
FormKeyHelper,