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} - - - + + + + + {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,