diff --git a/.changeset/checkout-exchange-destination.md b/.changeset/checkout-exchange-destination.md new file mode 100644 index 000000000..fcc6d5f2e --- /dev/null +++ b/.changeset/checkout-exchange-destination.md @@ -0,0 +1,8 @@ +--- +"@lifi/widget-checkout": minor +"@lifi/widget-provider": minor +"@lifi/widget-provider-mesh": minor +"@lifi/widget": patch +--- + +Let users reconnect previously linked exchange accounts and set their own destination address in the checkout flow. diff --git a/packages/widget-checkout/src/CheckoutLayout.tsx b/packages/widget-checkout/src/CheckoutLayout.tsx index 575bf0e3e..a8af31225 100644 --- a/packages/widget-checkout/src/CheckoutLayout.tsx +++ b/packages/widget-checkout/src/CheckoutLayout.tsx @@ -8,9 +8,11 @@ import { CheckoutToastHost } from './components/CheckoutToastHost.js' import { Container, ExpandedContainer } from './components/Container.js' import { Header } from './components/Header.js' import { OnRampHostedModals } from './components/OnRampHostedModals.js' +import { useSyncCheckoutRecipientToForm } from './hooks/useSyncCheckoutRecipientToForm.js' export const CheckoutLayout: React.FC = () => { const { elementId } = useWidgetConfig() + useSyncCheckoutRecipientToForm() return ( rootRoute, + path: checkoutNavigationRoutes.setDestination, + component: SetDestinationAddressPage, +}) + const progressRoute = createRoute({ getParentRoute: () => rootRoute, path: checkoutNavigationRoutes.progress, @@ -147,6 +154,7 @@ const transactionExecutionStatusRoute = createRoute({ const routeTree = rootRoute.addChildren([ indexRoute, enterAmountRoute, + setDestinationRoute, progressRoute, transferDepositRoute, depositErrorRoute, diff --git a/packages/widget-checkout/src/LifiWidgetCheckout.tsx b/packages/widget-checkout/src/LifiWidgetCheckout.tsx index dd1cc66a2..ad2ee3703 100644 --- a/packages/widget-checkout/src/LifiWidgetCheckout.tsx +++ b/packages/widget-checkout/src/LifiWidgetCheckout.tsx @@ -22,6 +22,7 @@ export const LifiWidgetCheckout: ForwardRefExoticComponent< onError: props.onError, config: props.config, resumePending: props.resumePending, + allowUserDestinationAddress: props.allowUserDestinationAddress, }), [ props.integrator, @@ -29,6 +30,7 @@ export const LifiWidgetCheckout: ForwardRefExoticComponent< props.onError, props.config, props.resumePending, + props.allowUserDestinationAddress, ] ) diff --git a/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx b/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx index 56e01c932..d40c6be8a 100644 --- a/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx +++ b/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx @@ -12,6 +12,7 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useCheckoutFlowQuote } from '../hooks/useCheckoutFlowQuote.js' import { useFrozenQuote } from '../hooks/useFrozenQuote.js' +import { useResolvedCheckoutRecipient } from '../hooks/useResolvedCheckoutRecipient.js' import { useOnRampSessionByCategory } from '../providers/OnRampProvider/OnRampProvider.js' import { type CheckoutFundingSource, @@ -33,15 +34,19 @@ const ctaLabelKey = { const statusPath = `/${checkoutNavigationRoutes.transactionExecution}/${checkoutNavigationRoutes.transactionStatus}` export const CheckoutFlowCtaButton: React.FC = (): JSX.Element => { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const navigate = useNavigate() const emitter = useWidgetEvents() const { toAddress, requiredToAddress } = useToAddressRequirements() + const { recipient, isUserSettable } = useResolvedCheckoutRecipient() const { route, routes, depositAddress, setReviewableRoute } = useCheckoutFlowQuote() const { freeze } = useFrozenQuote() const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) ?? 'wallet' const setFrozenRouteId = useCheckoutFlowStore((s) => s.setFrozenRouteId) + const selectedExchangeAccount = useCheckoutFlowStore( + (s) => s.selectedExchangeAccount + ) const fiatCurrency = useFiatCurrencyStore((s) => s.currency) const onRampSession = useOnRampSessionByCategory( fundingSource === 'cash' || fundingSource === 'exchange' @@ -99,6 +104,10 @@ export const CheckoutFlowCtaButton: React.FC = (): JSX.Element => { fiatAmount, fromChainId: route.fromChainId, fromTokenAddress: route.fromToken.address, + accessTokens: selectedExchangeAccount + ? [selectedExchangeAccount] + : undefined, + language: i18n.language, }) navigate({ to: statusPath, @@ -115,6 +124,8 @@ export const CheckoutFlowCtaButton: React.FC = (): JSX.Element => { setFrozenRouteId, fiatCurrency, navigate, + selectedExchangeAccount, + i18n.language, ]) const handlersByFunding: Record void> = { @@ -126,13 +137,15 @@ export const CheckoutFlowCtaButton: React.FC = (): JSX.Element => { const label = t(ctaLabelKey[fundingSource]) + const needsRecipient = isUserSettable && !recipient + // Only the wallet flow may connect-on-demand; other sources fund without a wallet. if (fundingSource === 'wallet') { return ( @@ -145,7 +158,7 @@ export const CheckoutFlowCtaButton: React.FC = (): JSX.Element => { color="primary" fullWidth onClick={handlersByFunding[fundingSource]} - disabled={!route || !depositAddress} + disabled={!route || !depositAddress || needsRecipient} sx={{ flex: 1 }} > {label} diff --git a/packages/widget-checkout/src/components/CheckoutReceiveCard.tsx b/packages/widget-checkout/src/components/CheckoutReceiveCard.tsx index 7fd6158d8..e6df7d11b 100644 --- a/packages/widget-checkout/src/components/CheckoutReceiveCard.tsx +++ b/packages/widget-checkout/src/components/CheckoutReceiveCard.tsx @@ -16,7 +16,6 @@ import { TokenRate, useChain, useFieldValues, - useRoutes, useToken, } from '@lifi/widget/shared' import AccessTimeFilled from '@mui/icons-material/AccessTimeFilled' @@ -25,6 +24,7 @@ import LocalGasStationRounded from '@mui/icons-material/LocalGasStationRounded' import { Box, Collapse, IconButton, Skeleton, Typography } from '@mui/material' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { useCheckoutRoutes } from '../hooks/useCheckoutRoutes.js' export const CheckoutReceiveCard: React.FC = () => { const { t, i18n } = useTranslation() const [expanded, setExpanded] = useState(false) @@ -42,7 +42,7 @@ export const CheckoutReceiveCard: React.FC = () => { isFetched, dataUpdatedAt, refetchTime, - } = useRoutes() + } = useCheckoutRoutes() const parsedAmount = Number.parseFloat( typeof fromAmount === 'string' diff --git a/packages/widget-checkout/src/components/CheckoutRecipientCard.tsx b/packages/widget-checkout/src/components/CheckoutRecipientCard.tsx new file mode 100644 index 000000000..0b3c1e8b2 --- /dev/null +++ b/packages/widget-checkout/src/components/CheckoutRecipientCard.tsx @@ -0,0 +1,88 @@ +import { + ChainAvatar, + shortenAddress, + useChain, + useToken, + useWidgetConfig, +} from '@lifi/widget/shared' +import CloseIcon from '@mui/icons-material/Close' +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')} + + + + + {t('checkout.addWalletToReceive', { token: token?.symbol ?? '' })} + + + ) + } + + return ( + + + {t('checkout.sendingTo')} + + + + {destinationChain?.name?.[0] ?? '?'} + + + {shortenAddress(recipient.address)} + + + + + + + ) +} diff --git a/packages/widget-checkout/src/hooks/useCheckoutConfigError.ts b/packages/widget-checkout/src/hooks/useCheckoutConfigError.ts index b324a37e9..dea57eea6 100644 --- a/packages/widget-checkout/src/hooks/useCheckoutConfigError.ts +++ b/packages/widget-checkout/src/hooks/useCheckoutConfigError.ts @@ -1,15 +1,17 @@ import { useWidgetConfig } from '@lifi/widget/shared' import { useMemo } from 'react' import { useCheckoutToAddress } from './useCheckoutToAddress.js' +import { useResolvedCheckoutRecipient } from './useResolvedCheckoutRecipient.js' -/** Returns the names of required checkout config fields (toAddress/toChain/toToken) that are missing. */ +/** Required config fields that are missing. `toAddress` is fatal only when the user can't set it in-widget. */ export function useCheckoutConfigError(): string[] { const toAddress = useCheckoutToAddress() + const { isUserSettable } = useResolvedCheckoutRecipient() const { toChain, toToken } = useWidgetConfig() return useMemo(() => { const missing: string[] = [] - if (!toAddress) { + if (!toAddress && !isUserSettable) { missing.push('toAddress') } if (!toChain) { @@ -19,5 +21,5 @@ export function useCheckoutConfigError(): string[] { missing.push('toToken') } return missing - }, [toAddress, toChain, toToken]) + }, [toAddress, isUserSettable, toChain, toToken]) } diff --git a/packages/widget-checkout/src/hooks/useCheckoutFlowQuote.ts b/packages/widget-checkout/src/hooks/useCheckoutFlowQuote.ts index 57c3e9cbb..1918ea4de 100644 --- a/packages/widget-checkout/src/hooks/useCheckoutFlowQuote.ts +++ b/packages/widget-checkout/src/hooks/useCheckoutFlowQuote.ts @@ -1,9 +1,10 @@ import type { LiFiStep, Route } from '@lifi/sdk' import { getStepTransaction } from '@lifi/sdk' -import { useRoutes, useSDKClient } from '@lifi/widget/shared' +import { useSDKClient } from '@lifi/widget/shared' import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { extractDepositAddress } from '../utils/extractDepositAddress.js' +import { useCheckoutRoutes } from './useCheckoutRoutes.js' export interface CheckoutFlowQuote { route: Route | undefined @@ -26,7 +27,7 @@ export function useCheckoutFlowQuote(): CheckoutFlowQuote { isFetched: routesFetched, refetch, setReviewableRoute, - } = useRoutes() + } = useCheckoutRoutes() const rawRoute = routes?.[0] const firstStep = rawRoute?.steps?.[0] diff --git a/packages/widget-checkout/src/hooks/useCheckoutRoutes.ts b/packages/widget-checkout/src/hooks/useCheckoutRoutes.ts new file mode 100644 index 000000000..ecf230eaa --- /dev/null +++ b/packages/widget-checkout/src/hooks/useCheckoutRoutes.ts @@ -0,0 +1,137 @@ +import type { Route, RouteOptions } from '@lifi/sdk' +import { getRoutes, parseUnits } from '@lifi/sdk' +import { useAccount } from '@lifi/wallet-management' +import { + useChain, + useFieldValues, + useRoutes, + useSDKClient, + useSettingsStoreContext, + useToken, + useWidgetConfig, +} from '@lifi/widget/shared' +import { useQuery } from '@tanstack/react-query' + +type UseRoutesResult = ReturnType + +/** + * Checkout route source. Walletless funding (cash/transfer/exchange) has no + * `fromAddress`, so it quotes via a direct `getRoutes` using the destination + * address as placeholder. A connected wallet uses the shared `useRoutes` as-is. + */ +export function useCheckoutRoutes(): UseRoutesResult { + const base = useRoutes() + const sdkClient = useSDKClient() + const { exchanges, bridges, feeConfig } = useWidgetConfig() + const [ + fromChainId, + fromTokenAddress, + fromAmount, + toChainId, + toTokenAddress, + toAddress, + ] = useFieldValues( + 'fromChain', + 'fromToken', + 'fromAmount', + 'toChain', + 'toToken', + 'toAddress' + ) + const { chain: fromChain } = useChain(fromChainId) + const { token: fromToken } = useToken(fromChainId, fromTokenAddress) + const { token: toToken } = useToken(toChainId, toTokenAddress) + const { account } = useAccount({ chainType: fromChain?.chainType }) + const useSettingsStore = useSettingsStoreContext() + const slippage: string | undefined = useSettingsStore( + (state: { slippage?: string }) => state.slippage + ) + const routePriority: RouteOptions['order'] = useSettingsStore( + (state: { routePriority?: RouteOptions['order'] }) => state.routePriority + ) + + const hasWallet = Boolean(account.address) + const amountNumber = Number(fromAmount) + const hasAmount = Number.isFinite(amountNumber) && amountNumber > 0 + + const fromAmountRaw = + fromToken && hasAmount + ? parseUnits(String(fromAmount), fromToken.decimals).toString() + : undefined + const formattedSlippage = slippage ? Number.parseFloat(slippage) : undefined + + // A destination is required walletless — it doubles as the from-address placeholder. + const fallbackEnabled = + !hasWallet && + Boolean( + toAddress && + fromAmountRaw && + fromChainId && + fromToken?.address && + toChainId && + toToken?.address + ) + + const fallback = useQuery({ + queryKey: [ + 'checkout-deposit-routes', + toAddress, + fromChainId, + fromToken?.address, + fromAmountRaw, + toChainId, + toToken?.address, + formattedSlippage, + routePriority, + exchanges?.allow, + bridges?.allow, + ], + enabled: fallbackEnabled, + queryFn: async ({ signal }) => { + const result = await getRoutes( + sdkClient, + { + fromAddress: toAddress as string, + fromAmount: fromAmountRaw as string, + fromChainId: fromChainId as number, + fromTokenAddress: fromToken?.address as string, + toAddress: toAddress as string, + toChainId: toChainId as number, + toTokenAddress: toToken?.address as string, + options: { + order: routePriority, + slippage: formattedSlippage, + bridges: bridges?.allow?.length + ? { allow: bridges.allow } + : undefined, + exchanges: exchanges?.allow?.length + ? { allow: exchanges.allow } + : undefined, + fee: feeConfig?.fee, + executionType: 'all', + }, + }, + { signal } + ) + return result.routes + }, + refetchInterval: base.refetchTime, + staleTime: base.refetchTime, + }) + + if (hasWallet || (base.routes && base.routes.length > 0)) { + return base + } + + return { + ...base, + routes: fallback.data, + isLoading: fallbackEnabled && fallback.isLoading, + isFetching: fallback.isFetching, + isFetched: fallback.isFetched, + dataUpdatedAt: fallback.dataUpdatedAt, + refetch: () => { + fallback.refetch() + }, + } +} diff --git a/packages/widget-checkout/src/hooks/useCheckoutToAddress.ts b/packages/widget-checkout/src/hooks/useCheckoutToAddress.ts index 02d74d4d8..1efa13bd8 100644 --- a/packages/widget-checkout/src/hooks/useCheckoutToAddress.ts +++ b/packages/widget-checkout/src/hooks/useCheckoutToAddress.ts @@ -1,17 +1,7 @@ -import { useWidgetConfig } from '@lifi/widget/shared' -import { useMemo } from 'react' +import { useResolvedCheckoutRecipient } from './useResolvedCheckoutRecipient.js' -/** - * The checkout recipient — always the integrator-configured `config.toAddress`, - * never the funding wallet. `null` means misconfigured (handled by CheckoutConfigGuard). - */ +/** The checkout recipient address, or `null` when none is set yet. Never the funding wallet. */ export function useCheckoutToAddress(): string | null { - const { toAddress } = useWidgetConfig() - - return useMemo(() => { - if (!toAddress) { - return null - } - return typeof toAddress === 'string' ? toAddress : toAddress.address - }, [toAddress]) + const { recipient } = useResolvedCheckoutRecipient() + return recipient?.address ?? null } diff --git a/packages/widget-checkout/src/hooks/useResolvedCheckoutRecipient.ts b/packages/widget-checkout/src/hooks/useResolvedCheckoutRecipient.ts new file mode 100644 index 000000000..dda40bbeb --- /dev/null +++ b/packages/widget-checkout/src/hooks/useResolvedCheckoutRecipient.ts @@ -0,0 +1,70 @@ +import type { ChainType } from '@lifi/sdk' +import { useChain, useWidgetConfig } from '@lifi/widget/shared' +import { useCheckoutConfig } from '@lifi/widget-provider/checkout' +import { useCallback, useMemo } from 'react' +import { + type CheckoutRecipient, + useCheckoutRecipientStore, +} from '../stores/useCheckoutRecipientStore.js' + +export interface ResolvedCheckoutRecipient { + /** The resolved recipient — integrator `config.toAddress` wins, else the user-set one. */ + recipient: { address: string; chainType?: ChainType } | null + /** Whether the integrator omitted `toAddress` and lets the user set it. */ + isUserSettable: boolean + /** True once a user-set recipient exists (vs. integrator-configured). */ + isUserSet: boolean + setUserRecipient: (recipient: CheckoutRecipient) => void + clearUserRecipient: () => void +} + +export function useResolvedCheckoutRecipient(): ResolvedCheckoutRecipient { + const { toAddress, toChain } = useWidgetConfig() + const { chain: destinationChain } = useChain(toChain) + const { integrator, allowUserDestinationAddress } = useCheckoutConfig() + const userRecipient = useCheckoutRecipientStore( + (s) => s.recipients[integrator] ?? null + ) + + // Drop a persisted recipient that no longer matches the destination ecosystem. + const validUserRecipient = useMemo(() => { + if (!userRecipient) { + return null + } + if ( + destinationChain && + userRecipient.chainType !== destinationChain.chainType + ) { + return null + } + return userRecipient + }, [userRecipient, destinationChain]) + const setRecipient = useCheckoutRecipientStore((s) => s.setRecipient) + const clearRecipient = useCheckoutRecipientStore((s) => s.clearRecipient) + + const configRecipient = useMemo(() => { + if (!toAddress) { + return null + } + return typeof toAddress === 'string' + ? { address: toAddress } + : { address: toAddress.address, chainType: toAddress.chainType } + }, [toAddress]) + + const setUserRecipient = useCallback( + (recipient: CheckoutRecipient) => setRecipient(integrator, recipient), + [setRecipient, integrator] + ) + const clearUserRecipient = useCallback( + () => clearRecipient(integrator), + [clearRecipient, integrator] + ) + + return { + recipient: configRecipient ?? validUserRecipient, + isUserSettable: Boolean(allowUserDestinationAddress) && !configRecipient, + isUserSet: !configRecipient && Boolean(validUserRecipient), + setUserRecipient, + clearUserRecipient, + } +} diff --git a/packages/widget-checkout/src/hooks/useSyncCheckoutRecipientToForm.ts b/packages/widget-checkout/src/hooks/useSyncCheckoutRecipientToForm.ts new file mode 100644 index 000000000..2e2d747f5 --- /dev/null +++ b/packages/widget-checkout/src/hooks/useSyncCheckoutRecipientToForm.ts @@ -0,0 +1,22 @@ +import { useFieldActions, useFieldValues } from '@lifi/widget/shared' +import { useEffect } from 'react' +import { useResolvedCheckoutRecipient } from './useResolvedCheckoutRecipient.js' + +/** Mirrors the user-set recipient into the form's `toAddress` so route/SDK execution picks it up. */ +export function useSyncCheckoutRecipientToForm(): void { + const { recipient, isUserSettable } = useResolvedCheckoutRecipient() + const [formToAddress] = useFieldValues('toAddress') + const { setFieldValue } = useFieldActions() + + const recipientAddress = recipient?.address + + useEffect(() => { + if (!isUserSettable) { + return + } + const desired = recipientAddress ?? '' + if ((formToAddress ?? '') !== desired) { + setFieldValue('toAddress', desired, { isDirty: false, isTouched: true }) + } + }, [isUserSettable, recipientAddress, formToAddress, setFieldValue]) +} diff --git a/packages/widget-checkout/src/pages/CheckoutRoutesPage.tsx b/packages/widget-checkout/src/pages/CheckoutRoutesPage.tsx index 75a97ad73..a16dc745f 100644 --- a/packages/widget-checkout/src/pages/CheckoutRoutesPage.tsx +++ b/packages/widget-checkout/src/pages/CheckoutRoutesPage.tsx @@ -8,7 +8,6 @@ import { Stack, useFieldValues, useHeader, - useRoutes, useToAddressRequirements, useWidgetEvents, WidgetEvent, @@ -16,6 +15,7 @@ import { import { useNavigate } from '@tanstack/react-router' import { type JSX, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { useCheckoutRoutes } from '../hooks/useCheckoutRoutes.js' import { checkoutAbsolutePaths } from '../utils/navigationRoutes.js' export const CheckoutRoutesPage = (): JSX.Element => { @@ -31,7 +31,7 @@ export const CheckoutRoutesPage = (): JSX.Element => { fromChain, refetch, setReviewableRoute, - } = useRoutes() + } = useCheckoutRoutes() const { account } = useAccount({ chainType: fromChain?.chainType }) const [toAddress] = useFieldValues('toAddress') const { requiredToAddress } = useToAddressRequirements() diff --git a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx index bf0f37d89..310ad3b11 100644 --- a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx +++ b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/CheckoutTransactionStatusPage.tsx @@ -71,9 +71,9 @@ export const CheckoutTransactionStatusPage: React.FC = (): JSX.Element => { // hash, if any, is the funding tx and not the LI.FI-tracked transfer. const deposit = useActiveOnRampDeposit() const providerName = deposit?.providerName ?? '' - // While the provider modal is open nothing has happened yet — the page - // shows only the loader and the deposit poll stays paused. - const isOnRampOpen = deposit?.isOpen === true + // Active across both the session fetch and the open modal — nothing is + // deposited until it ends, so the page holds the loader and pauses polling. + const isOnRampActive = deposit?.isOpen === true || deposit?.isLoading === true const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) const isTransferFlow = fundingSource === 'transfer' @@ -83,7 +83,7 @@ export const CheckoutTransactionStatusPage: React.FC = (): JSX.Element => { transactionHash, depositAddress, fromChain, - pauseDepositPoll: isOnRampOpen, + pauseDepositPoll: isOnRampActive, }) const isRefundInProgress = status?.substatus === 'REFUND_IN_PROGRESS' @@ -245,7 +245,7 @@ export const CheckoutTransactionStatusPage: React.FC = (): JSX.Element => { if (!transactionHash && !status) { return ( - {frozenRoute && !isOnRampOpen ? ( + {frozenRoute && !isOnRampActive ? ( { + + + {/* Warnings cover wallet balance / gas — only show for wallet flow. */} {isWalletFunded ? : null} diff --git a/packages/widget-checkout/src/pages/SelectSourcePage/ConnectedAccountRow.tsx b/packages/widget-checkout/src/pages/SelectSourcePage/ConnectedAccountRow.tsx new file mode 100644 index 000000000..193abf32a --- /dev/null +++ b/packages/widget-checkout/src/pages/SelectSourcePage/ConnectedAccountRow.tsx @@ -0,0 +1,71 @@ +import { Avatar, Box, Typography } from '@mui/material' +import type { JSX, ReactNode } from 'react' +import { + FundingOptionRow, + FundingOptionSubtitle, + FundingOptionTitle, + OptionTextCell, +} from './SelectSourceFundingOptions.style.js' + +export type ConnectedAccountRowProps = { + /** Logo/icon URL; falls back to `fallbackIcon` when absent. */ + iconSrc?: string + fallbackIcon: ReactNode + title: string + subtitle: string + connectedLabel: string + /** Optional trailing control (e.g. a disconnect button). */ + trailing?: ReactNode +} + +/** + * Shared "connected account" card body: avatar + title/subtitle + a + * "Connected" badge. Used for both the linked wallet and a previously-linked + * exchange account. + */ +export function ConnectedAccountRow({ + iconSrc, + fallbackIcon, + title, + subtitle, + connectedLabel, + trailing, +}: ConnectedAccountRowProps): JSX.Element { + return ( + + ({ + width: 40, + height: 40, + flexShrink: 0, + bgcolor: theme.vars.palette.action.hover, + })} + > + {!iconSrc ? fallbackIcon : null} + + + {title} + {subtitle} + + + + {connectedLabel} + + {trailing} + + + ) +} diff --git a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.tsx b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.tsx index cab3a4d07..517dd0272 100644 --- a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.tsx +++ b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourceFundingOptions.tsx @@ -1,20 +1,23 @@ import { shortenAddress } from '@lifi/widget/shared' import type { Account } from '@lifi/widget-provider' +import type { ConnectedCexAccount } from '@lifi/widget-provider/checkout' import AccountBalance from '@mui/icons-material/AccountBalance' import AccountBalanceWallet from '@mui/icons-material/AccountBalanceWallet' import CreditCardIcon from '@mui/icons-material/CreditCard' +import PowerSettingsNewRounded from '@mui/icons-material/PowerSettingsNewRounded' import QrCode2Icon from '@mui/icons-material/QrCode2' import { Alert, Avatar, - Box, CircularProgress, + IconButton, Stack, - Typography, + useColorScheme, } from '@mui/material' import type { JSX } from 'react' import { useTranslation } from 'react-i18next' import { CheckoutDisconnectIconButton } from '../../components/CheckoutDisconnectIconButton.js' +import { ConnectedAccountRow } from './ConnectedAccountRow.js' import { fundingSourceImages } from './fundingSourceAssets.js' import { FundingDividerLine, @@ -60,6 +63,12 @@ export type SelectSourceFundingOptionsProps = { 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. */ + onReuseExchange?: (account: ConnectedCexAccount) => void + /** Invoked when the user disconnects (forgets) a saved exchange account. */ + onForgetExchange?: (account: ConnectedCexAccount) => void payFromWalletIcons: PayFromWalletPreviewIcon[] payFromWalletOverflow: number /** When set, replaces the disconnected “browser wallets” teaser with wallet + address (connected state). */ @@ -77,12 +86,17 @@ export function SelectSourceFundingOptions({ showConnectExchange = false, exchangeLoading = false, exchangeError = null, + connectedExchangeAccounts = [], + onReuseExchange, + onForgetExchange, payFromWalletIcons, payFromWalletOverflow, payFromWalletConnected = null, payFromWalletAccount = null, }: SelectSourceFundingOptionsProps): JSX.Element { const { t } = useTranslation() + const { mode, systemMode } = useColorScheme() + const isDark = (mode === 'system' ? systemMode : mode) === 'dark' return ( @@ -91,92 +105,51 @@ export function SelectSourceFundingOptions({ - - {payFromWalletConnected ? ( - <> - ({ - width: 40, - height: 40, - flexShrink: 0, - bgcolor: theme.vars.palette.action.hover, - fontSize: 16, - fontWeight: 700, - })} - > - {!payFromWalletConnected.icon ? ( - - ) : null} - - - - {t('checkout.payFromWallet')} - - - {shortenAddress(payFromWalletConnected.address) ?? - payFromWalletConnected.address} - - - - - {t('tags.connected')} - - {payFromWalletAccount ? ( - - ) : null} - - - ) : ( - <> - - - - - - {t('checkout.payFromWallet')} - - - {t('checkout.payFromWalletSubtitle')} - - - - {payFromWalletIcons.map((item) => ( - - ))} - {payFromWalletOverflow > 0 ? ( - - {`${Math.min(payFromWalletOverflow, 99)}+`} - - ) : null} - - - )} - + {payFromWalletConnected ? ( + + } + title={t('checkout.payFromWallet')} + subtitle={ + shortenAddress(payFromWalletConnected.address) ?? + payFromWalletConnected.address + } + connectedLabel={t('tags.connected')} + trailing={ + payFromWalletAccount ? ( + + ) : null + } + /> + ) : ( + + + + + + + {t('checkout.payFromWallet')} + + + {t('checkout.payFromWalletSubtitle')} + + + + {payFromWalletIcons.map((item) => ( + + ))} + {payFromWalletOverflow > 0 ? ( + + {`${Math.min(payFromWalletOverflow, 99)}+`} + + ) : null} + + + )} @@ -210,63 +183,109 @@ export function SelectSourceFundingOptions({ - {showConnectExchange ? ( - <> - - - - - - - - {t('checkout.connectExchange')} - - - {t('checkout.connectExchangeSubtitle')} - - - {exchangeLoading ? ( - - ) : ( - - ({ - width: 24, - height: 24, - border: `2px solid ${theme.vars.palette.background.paper}`, - })} - /> - ({ - width: 24, - height: 24, - border: `2px solid ${theme.vars.palette.background.paper}`, - })} - /> - 10+ - - )} - - - {exchangeError ? ( - - {exchangeError} - - ) : null} - + {showConnectExchange && onReuseExchange + ? connectedExchangeAccounts.map((account) => ( + onReuseExchange(account)} + elevation={0} + > + + } + title={t('checkout.payWithExchange', { + exchange: account.brokerName, + })} + subtitle={ + account.accountName || t('checkout.reuseExchangeSubtitle') + } + connectedLabel={t('tags.connected')} + trailing={ + onForgetExchange ? ( + { + e.stopPropagation() + onForgetExchange(account) + }} + > + + + ) : undefined + } + /> + + )) + : null} + + {/* A saved account is the fast path — only offer the generic + "connect exchange" entry when there's nothing to reconnect to. */} + {showConnectExchange && connectedExchangeAccounts.length === 0 ? ( + + + + + + + + {t('checkout.connectExchange')} + + + {t('checkout.connectExchangeSubtitle')} + + + {exchangeLoading ? ( + + ) : ( + + ({ + width: 24, + height: 24, + border: `2px solid ${theme.vars.palette.background.paper}`, + })} + /> + ({ + width: 24, + height: 24, + border: `2px solid ${theme.vars.palette.background.paper}`, + })} + /> + 10+ + + )} + + + ) : null} + + {showConnectExchange && exchangeError ? ( + + {exchangeError} + ) : null} diff --git a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx index 429481629..6750c79bf 100644 --- a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx +++ b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx @@ -9,6 +9,14 @@ import { useHeader, useToken, } from '@lifi/widget/shared' +import { + type ConnectedCexAccount, + connectedCexKey, + useCheckoutConfig, + useCheckoutUserId, + useConnectedCexAccounts, + useConnectedCexStore, +} from '@lifi/widget-provider/checkout' import { useMeshBalance } from '@lifi/widget-provider-mesh' import { Alert, Box } from '@mui/material' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -45,9 +53,20 @@ export const SelectSourcePage: React.FC = () => { const exchangeProvider = useOnRampProviderByCategory('exchange') const { topWallets, walletOverflowCount } = useSelectSourceTopWallets() const setFundingSource = useCheckoutFlowStore((s) => s.setFundingSource) + const setSelectedExchangeAccount = useCheckoutFlowStore( + (s) => s.setSelectedExchangeAccount + ) const resetFlow = useCheckoutFlowStore((s) => s.reset) const overrideExchanges = useCheckoutExchangesOverride() const { setFieldValue } = useFieldActions() + const { integrator } = useCheckoutConfig() + const checkoutUserId = useCheckoutUserId() + const connectedExchangeAccounts = useConnectedCexAccounts( + exchangeSession ? connectedCexKey(integrator, checkoutUserId) : null + ) + const removeConnectedExchangeAccount = useConnectedCexStore( + (s) => s.removeAccount + ) // Capture fundingSource before resetFlow fires (runs after first render). const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) @@ -152,7 +171,7 @@ export const SelectSourcePage: React.FC = () => { goToToken() }, [goToToken, overrideExchanges, setFundingSource]) - const handleConnectExchange = useCallback(() => { + const pinExchangeSource = useCallback(() => { overrideExchanges([...INTENT_FACTORY_ONLY]) setFundingSource('exchange') // Exchange deposits are limited to USDC/USDT/ETH on mainnet — pin the @@ -161,8 +180,33 @@ export const SelectSourcePage: React.FC = () => { setFieldValue(FormKeyHelper.getChainKey('from'), DEFAULT_FROM_CHAIN_ID) setFieldValue(FormKeyHelper.getTokenKey('from'), DEFAULT_FROM_TOKEN_ADDRESS) setFieldValue(FormKeyHelper.getAmountKey('from'), '') + }, [overrideExchanges, setFieldValue, setFundingSource]) + + const handleConnectExchange = useCallback(() => { + setSelectedExchangeAccount(null) + pinExchangeSource() goToToken() - }, [goToToken, overrideExchanges, setFieldValue, setFundingSource]) + }, [goToToken, pinExchangeSource, setSelectedExchangeAccount]) + + const handleReuseExchange = useCallback( + (account: ConnectedCexAccount) => { + // ConnectedCexAccount is a superset of OnRampAccessToken — pass through. + setSelectedExchangeAccount(account) + pinExchangeSource() + goToToken() + }, + [goToToken, pinExchangeSource, setSelectedExchangeAccount] + ) + + const handleForgetExchange = useCallback( + (account: ConnectedCexAccount) => { + removeConnectedExchangeAccount( + connectedCexKey(integrator, checkoutUserId), + account.accountId + ) + }, + [removeConnectedExchangeAccount, integrator, checkoutUserId] + ) const handleDepositCash = useCallback(() => { overrideExchanges([...INTENT_FACTORY_ONLY]) @@ -210,6 +254,9 @@ export const SelectSourcePage: React.FC = () => { showDepositCash={Boolean(cashSession)} onConnectExchange={handleConnectExchange} showConnectExchange={Boolean(exchangeSession)} + connectedExchangeAccounts={connectedExchangeAccounts} + onReuseExchange={handleReuseExchange} + onForgetExchange={handleForgetExchange} exchangeLoading={exchangeSession?.isLoading ?? false} exchangeError={formatOnRampError( exchangeSession?.error ?? null, diff --git a/packages/widget-checkout/src/pages/SetDestinationAddressPage/SetDestinationAddressPage.tsx b/packages/widget-checkout/src/pages/SetDestinationAddressPage/SetDestinationAddressPage.tsx new file mode 100644 index 000000000..a5532d3ef --- /dev/null +++ b/packages/widget-checkout/src/pages/SetDestinationAddressPage/SetDestinationAddressPage.tsx @@ -0,0 +1,148 @@ +import { useAccount, useWalletMenu } from '@lifi/wallet-management' +import { + PageContainer, + useAddressValidation, + useChain, + useFieldActions, + useHeader, + useWidgetConfig, +} from '@lifi/widget/shared' +import WalletOutlinedIcon from '@mui/icons-material/AccountBalanceWalletOutlined' +import { + Box, + Button, + Divider, + ListItemButton, + ListItemIcon, + ListItemText, + TextField, + Typography, +} from '@mui/material' +import { type JSX, useCallback, useEffect, useRef, useState } 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 SetDestinationAddressPage: React.FC = (): JSX.Element => { + const { t } = useTranslation() + useHeader(t('checkout.whereToSendIt')) + const navigate = useCheckoutNavigate() + const { toChain } = useWidgetConfig() + const { chain: destinationChain } = useChain(toChain) + const { validateAddress, isValidating } = useAddressValidation() + const { setUserRecipient } = useResolvedCheckoutRecipient() + const { setFieldValue } = useFieldActions() + const { openWalletMenu } = useWalletMenu() + const { accounts } = useAccount() + + const [value, setValue] = useState('') + const [error, setError] = useState(null) + + const commitRecipient = useCallback( + ( + address: string, + chainType: NonNullable['chainType'] + ) => { + 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 }) + }, + [setUserRecipient, setFieldValue, navigate] + ) + + const handleDone = useCallback(async () => { + setError(null) + const result = await validateAddress({ + value: value.trim(), + chainType: destinationChain?.chainType, + chain: destinationChain, + }) + if (!result.isValid) { + setError(result.error) + return + } + if (destinationChain && result.chainType !== destinationChain.chainType) { + setError( + t('error.title.walletAddressInvalid', { + context: 'chain', + chainName: destinationChain.name, + }) + ) + return + } + commitRecipient(result.address, result.chainType) + }, [value, destinationChain, validateAddress, commitRecipient, t]) + + // Adopt the first connected account matching the destination ecosystem after connect. + const awaitingConnectRef = useRef(false) + const handleConnectWallet = useCallback(() => { + awaitingConnectRef.current = true + openWalletMenu() + }, [openWalletMenu]) + + useEffect(() => { + if (!awaitingConnectRef.current || !destinationChain) { + return + } + const match = accounts.find( + (a) => + a.isConnected && a.address && a.chainType === destinationChain.chainType + ) + if (match?.address) { + awaitingConnectRef.current = false + commitRecipient(match.address, destinationChain.chainType) + } + }, [accounts, destinationChain, commitRecipient]) + + return ( + + + { + setValue(e.target.value) + setError(null) + }} + placeholder={t('checkout.walletAddressOrEns')} + error={Boolean(error)} + helperText={error ?? undefined} + multiline + minRows={2} + fullWidth + slotProps={{ + htmlInput: { 'aria-label': t('checkout.whereToSendIt') }, + }} + /> + + + + + {t('checkout.or')} + + + + + + + + + + + + ) +} diff --git a/packages/widget-checkout/src/providers/CheckoutProvider.tsx b/packages/widget-checkout/src/providers/CheckoutProvider.tsx index 733597342..73585c289 100644 --- a/packages/widget-checkout/src/providers/CheckoutProvider.tsx +++ b/packages/widget-checkout/src/providers/CheckoutProvider.tsx @@ -17,8 +17,15 @@ export const CheckoutProvider: React.FC = ({ onSuccess: config.onSuccess, onError: config.onError, resumePending: config.resumePending, + allowUserDestinationAddress: config.allowUserDestinationAddress, }), - [config.integrator, config.onSuccess, config.onError, config.resumePending] + [ + config.integrator, + config.onSuccess, + config.onError, + config.resumePending, + config.allowUserDestinationAddress, + ] ) return ( diff --git a/packages/widget-checkout/src/providers/OnRampProvider/OnRampProvider.tsx b/packages/widget-checkout/src/providers/OnRampProvider/OnRampProvider.tsx index b12be8694..9427493f8 100644 --- a/packages/widget-checkout/src/providers/OnRampProvider/OnRampProvider.tsx +++ b/packages/widget-checkout/src/providers/OnRampProvider/OnRampProvider.tsx @@ -62,6 +62,7 @@ export interface ActiveOnRampDeposit { acknowledgeDepositTxHash: () => void providerName: string isOpen: boolean + isLoading: boolean } export function useActiveOnRampDeposit(): ActiveOnRampDeposit | null { @@ -81,6 +82,7 @@ export function useActiveOnRampDeposit(): ActiveOnRampDeposit | null { acknowledgeDepositTxHash: session.acknowledgeDepositTxHash, providerName: provider.name, isOpen: session.isOpen, + isLoading: session.isLoading, } } diff --git a/packages/widget-checkout/src/stores/useCheckoutFlowStore.tsx b/packages/widget-checkout/src/stores/useCheckoutFlowStore.tsx index 7a7e41e2a..475c9d6da 100644 --- a/packages/widget-checkout/src/stores/useCheckoutFlowStore.tsx +++ b/packages/widget-checkout/src/stores/useCheckoutFlowStore.tsx @@ -1,3 +1,4 @@ +import type { OnRampAccessToken } from '@lifi/widget-provider/checkout' import { createContext, type JSX, @@ -13,8 +14,11 @@ export type CheckoutFundingSource = 'wallet' | 'transfer' | 'exchange' | 'cash' export interface CheckoutFlowState { fundingSource: CheckoutFundingSource | null frozenRouteId: string | null + /** Exchange account chosen for one-tap reconnect; in-memory, passed to the provider's `open()`. */ + selectedExchangeAccount: OnRampAccessToken | null setFundingSource: (source: CheckoutFundingSource | null) => void setFrozenRouteId: (routeId: string | null) => void + setSelectedExchangeAccount: (account: OnRampAccessToken | null) => void reset: () => void } @@ -24,9 +28,17 @@ export function createCheckoutFlowStore(): CheckoutFlowStore { return create((set) => ({ fundingSource: null, frozenRouteId: null, + selectedExchangeAccount: null, setFundingSource: (fundingSource) => set({ fundingSource }), setFrozenRouteId: (frozenRouteId) => set({ frozenRouteId }), - reset: () => set({ fundingSource: null, frozenRouteId: null }), + setSelectedExchangeAccount: (selectedExchangeAccount) => + set({ selectedExchangeAccount }), + reset: () => + set({ + fundingSource: null, + frozenRouteId: null, + selectedExchangeAccount: null, + }), })) } diff --git a/packages/widget-checkout/src/stores/useCheckoutRecipientStore.ts b/packages/widget-checkout/src/stores/useCheckoutRecipientStore.ts new file mode 100644 index 000000000..cd3cc4d33 --- /dev/null +++ b/packages/widget-checkout/src/stores/useCheckoutRecipientStore.ts @@ -0,0 +1,46 @@ +'use client' +import type { ChainType } from '@lifi/sdk' +import { create, type StoreApi, type UseBoundStore } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' + +export const RECIPIENT_STORAGE_KEY = 'lifi-checkout-recipient' +export const RECIPIENT_RECORD_VERSION = 1 + +export interface CheckoutRecipient { + address: string + chainType: ChainType +} + +interface CheckoutRecipientState { + recipients: Record + setRecipient: (integrator: string, recipient: CheckoutRecipient) => void + clearRecipient: (integrator: string) => void +} + +export const useCheckoutRecipientStore: UseBoundStore< + StoreApi +> = create()( + persist( + (set) => ({ + recipients: {}, + setRecipient: (integrator, recipient) => + set((state) => ({ + recipients: { ...state.recipients, [integrator]: recipient }, + })), + clearRecipient: (integrator) => + set((state) => { + if (!(integrator in state.recipients)) { + return state + } + const { [integrator]: _removed, ...rest } = state.recipients + return { recipients: rest } + }), + }), + { + name: RECIPIENT_STORAGE_KEY, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ recipients: state.recipients }), + version: RECIPIENT_RECORD_VERSION, + } + ) +) diff --git a/packages/widget-checkout/src/stores/useConnectedCexStore.test.tsx b/packages/widget-checkout/src/stores/useConnectedCexStore.test.tsx new file mode 100644 index 000000000..cf4df9ebc --- /dev/null +++ b/packages/widget-checkout/src/stores/useConnectedCexStore.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom +// The store lives in @lifi/widget-provider/checkout, but the test runner lives +// here (its primary consumer), so we exercise the public export from here. +import { + type ConnectedCexAccount, + connectedCexKey, + useConnectedCexAccounts, + useConnectedCexStore, +} from '@lifi/widget-provider/checkout' +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const KEY = connectedCexKey('lifi-int', 'user-1') + +function account( + overrides: Partial = {} +): ConnectedCexAccount { + const now = Date.now() + return { + accountId: 'acc-1', + accountName: 'me@coinbase.com', + accessToken: 'tok', + brokerType: 'coinbase', + brokerName: 'Coinbase', + connectedAt: now, + expiresAt: now + 60_000, + ...overrides, + } +} + +function resetStore(): void { + useConnectedCexStore.setState({ records: {} }) +} + +describe('useConnectedCexStore', () => { + beforeEach(resetStore) + afterEach(resetStore) + + it('writes accounts under the composite key', () => { + act(() => { + useConnectedCexStore.getState().addConnectedAccounts(KEY, [account()]) + }) + expect(useConnectedCexStore.getState().records[KEY]).toHaveLength(1) + }) + + it('de-dupes by accountId, keeping the newest write', () => { + act(() => { + const add = useConnectedCexStore.getState().addConnectedAccounts + add(KEY, [account({ accessToken: 'old' })]) + add(KEY, [account({ accessToken: 'new' })]) + }) + const list = useConnectedCexStore.getState().records[KEY] + expect(list).toHaveLength(1) + expect(list[0].accessToken).toBe('new') + }) + + it('sweeps expired accounts on write', () => { + const past = Date.now() - 1000 + act(() => { + const add = useConnectedCexStore.getState().addConnectedAccounts + add(KEY, [account({ accountId: 'stale', expiresAt: past })]) + add(KEY, [account({ accountId: 'fresh' })]) + }) + const list = useConnectedCexStore.getState().records[KEY] + expect(list).toHaveLength(1) + expect(list[0].accountId).toBe('fresh') + }) + + it('prunes only the touched key, leaving other integrators untouched', () => { + const otherKey = connectedCexKey('other-int', 'user-2') + const past = Date.now() - 1000 + useConnectedCexStore.setState({ + records: { + [otherKey]: [account({ accountId: 'stale', expiresAt: past })], + }, + }) + act(() => { + useConnectedCexStore.getState().addConnectedAccounts(KEY, [account()]) + }) + // A write under KEY must not sweep another integrator's records. + expect(useConnectedCexStore.getState().records[otherKey]).toHaveLength(1) + expect(useConnectedCexStore.getState().records[KEY]).toHaveLength(1) + }) + + it('removeAccount drops one and clears the key when empty', () => { + act(() => { + useConnectedCexStore + .getState() + .addConnectedAccounts(KEY, [account({ accountId: 'acc-1' })]) + useConnectedCexStore.getState().removeAccount(KEY, 'acc-1') + }) + expect(useConnectedCexStore.getState().records[KEY]).toBeUndefined() + }) + + it('useConnectedCexAccounts returns only live accounts', () => { + act(() => { + useConnectedCexStore + .getState() + .addConnectedAccounts(KEY, [account({ accountId: 'live' })]) + }) + const { result } = renderHook(() => useConnectedCexAccounts(KEY)) + expect(result.current).toHaveLength(1) + expect(result.current.every((a) => a.expiresAt > Date.now())).toBe(true) + }) + + it('useConnectedCexAccounts returns a stable empty array for a null key', () => { + const { result, rerender } = renderHook(() => useConnectedCexAccounts(null)) + const first = result.current + rerender() + expect(result.current).toBe(first) + expect(result.current).toHaveLength(0) + }) +}) diff --git a/packages/widget-checkout/src/types/config.ts b/packages/widget-checkout/src/types/config.ts index 2fc2ae10f..0a012fa88 100644 --- a/packages/widget-checkout/src/types/config.ts +++ b/packages/widget-checkout/src/types/config.ts @@ -14,6 +14,12 @@ export interface CheckoutConfig { config?: Partial /** Persist pending checkouts and resume on next mount. @default true */ resumePending?: boolean + /** + * When `true` and `config.toAddress` is omitted, the user sets the + * destination address inside the widget (paste/ENS or connect wallet) + * instead of the checkout blocking as misconfigured. @default false + */ + allowUserDestinationAddress?: boolean } export interface CheckoutModalProps { diff --git a/packages/widget-checkout/src/utils/navigationRoutes.ts b/packages/widget-checkout/src/utils/navigationRoutes.ts index 8ba38da69..4373cf777 100644 --- a/packages/widget-checkout/src/utils/navigationRoutes.ts +++ b/packages/widget-checkout/src/utils/navigationRoutes.ts @@ -12,6 +12,7 @@ export const checkoutAbsolutePaths: { transactionExecution: string } = { export const checkoutNavigationRoutes = { home: '/', enterAmount: '/enter-amount', + setDestination: '/set-destination', progress: '/progress', transferDeposit: '/transfer-deposit', depositError: '/deposit-error/$kind', @@ -39,6 +40,7 @@ export const checkoutNavigationRoutesValues: CheckoutNavigationRoute[] = */ export const backButtonRoutes: string[] = [ 'enter-amount', + 'set-destination', 'select-cash', checkoutNavigationRoutes.fromToken, checkoutNavigationRoutes.fromChain, diff --git a/packages/widget-playground/src/components/CheckoutControls/CheckoutControls.style.tsx b/packages/widget-playground/src/components/CheckoutControls/CheckoutControls.style.tsx new file mode 100644 index 000000000..c4bc27ca8 --- /dev/null +++ b/packages/widget-playground/src/components/CheckoutControls/CheckoutControls.style.tsx @@ -0,0 +1,47 @@ +import { Box, InputBase, inputBaseClasses, styled } from '@mui/material' +import type { FC } from 'react' + +export const Field: FC> = styled(Box)( + ({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }) +) + +export const FieldLabel: FC> = styled(Box)( + ({ theme }) => ({ + fontSize: 14, + fontWeight: 500, + lineHeight: '18px', + color: theme.vars.palette.text.secondary, + }) +) + +export const FieldInput: FC> = styled( + InputBase +)(({ theme }) => ({ + padding: theme.spacing(1.25, 1.5), + borderRadius: 12, + border: '1px solid', + borderColor: theme.vars.palette.divider, + backgroundColor: theme.vars.palette.background.paper, + transition: 'border-color 0.15s', + '&:hover': { + borderColor: `color-mix(in srgb, ${theme.vars.palette.common.onBackground} 24%, transparent)`, + }, + [`&.${inputBaseClasses.focused}`]: { + borderColor: theme.vars.palette.primary.main, + }, + [`& .${inputBaseClasses.input}`]: { + padding: 0, + fontSize: 14, + fontWeight: 500, + lineHeight: '20px', + color: theme.vars.palette.text.primary, + '&::placeholder': { + color: theme.vars.palette.text.secondary, + opacity: 0.6, + }, + }, +})) diff --git a/packages/widget-playground/src/components/CheckoutControls/CheckoutControls.tsx b/packages/widget-playground/src/components/CheckoutControls/CheckoutControls.tsx new file mode 100644 index 000000000..5c35c92a6 --- /dev/null +++ b/packages/widget-playground/src/components/CheckoutControls/CheckoutControls.tsx @@ -0,0 +1,101 @@ +import { ChainType } from '@lifi/widget' +import type { JSX } from 'react' +import { useCallback } from 'react' +import { useConfig } from '../../store/widgetConfig/useConfig.js' +import { useConfigActions } from '../../store/widgetConfig/useConfigActions.js' +import { DetailViewLayout } from '../DetailView/DetailViewLayout.js' +import { Field, FieldInput, FieldLabel } from './CheckoutControls.style.js' + +interface CheckoutControlsProps { + onBack: () => void +} + +export const CheckoutControls = ({ + onBack, +}: CheckoutControlsProps): JSX.Element => { + const { config } = useConfig() + const { setConfig } = useConfigActions() + + const toChainValue = config?.toChain != null ? String(config.toChain) : '' + const toTokenValue = config?.toToken ?? '' + const toAddressValue = + typeof config?.toAddress === 'string' + ? config.toAddress + : (config?.toAddress?.address ?? '') + + const handleChainChange = useCallback( + (event: React.ChangeEvent): void => { + const raw = event.target.value.trim() + const parsed = Number.parseInt(raw, 10) + setConfig({ toChain: Number.isNaN(parsed) ? undefined : parsed }) + }, + [setConfig] + ) + + const handleTokenChange = useCallback( + (event: React.ChangeEvent): void => { + const value = event.target.value.trim() + setConfig({ toToken: value || undefined }) + }, + [setConfig] + ) + + const handleAddressChange = useCallback( + (event: React.ChangeEvent): void => { + const value = event.target.value.trim() + setConfig({ + toAddress: value + ? { address: value, chainType: ChainType.EVM } + : undefined, + }) + }, + [setConfig] + ) + + const handleReset = useCallback((): void => { + setConfig({ toChain: undefined, toToken: undefined, toAddress: undefined }) + }, [setConfig]) + + return ( + + + Destination chain ID + + + + Destination token address + + + + Recipient address (optional) + + + + ) +} diff --git a/packages/widget-playground/src/components/Sidebar/DrawerControls.tsx b/packages/widget-playground/src/components/Sidebar/DrawerControls.tsx index 4c8765e2b..aa0bbe818 100644 --- a/packages/widget-playground/src/components/Sidebar/DrawerControls.tsx +++ b/packages/widget-playground/src/components/Sidebar/DrawerControls.tsx @@ -2,6 +2,7 @@ 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' @@ -13,12 +14,14 @@ 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, @@ -53,6 +56,7 @@ const DETAIL_VIEWS: Partial< wallet: WalletManagementDetailView, developer: DeveloperControlsDetailView, themeEdit: ThemeEditDetailView, + checkout: CheckoutControls, } export const DrawerControls = (): JSX.Element => { @@ -64,6 +68,7 @@ export const DrawerControls = (): JSX.Element => { const [expandedItem, setExpandedItem] = useState(null) const { themeLabel, modeValue, variantValue, heightValue, walletValue } = useSidebarNavLabels() + const { playgroundWidgetMode } = usePlaygroundWidgetMode() useFontInitialisation() @@ -117,6 +122,13 @@ 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-playground/src/components/Widget/CheckoutWidgetView.tsx b/packages/widget-playground/src/components/Widget/CheckoutWidgetView.tsx index bbc695096..6229ee70a 100644 --- a/packages/widget-playground/src/components/Widget/CheckoutWidgetView.tsx +++ b/packages/widget-playground/src/components/Widget/CheckoutWidgetView.tsx @@ -3,8 +3,8 @@ import { LifiWidgetCheckout } from '@lifi/widget-checkout' import { meshProvider } from '@lifi/widget-provider-mesh' import { transakProvider } from '@lifi/widget-provider-transak' import { Box, Button, Typography } from '@mui/material' -import { useAppKit, useAppKitAccount } from '@reown/appkit/react' -import { type JSX, useCallback, useEffect, useMemo, useState } from 'react' +import { useAppKit } from '@reown/appkit/react' +import { type JSX, useCallback, useMemo, useState } from 'react' import { widgetBaseConfig } from '../../defaultWidgetConfig.js' import { useEnvVariables } from '../../providers/EnvVariablesProvider.js' import { useConfig } from '../../store/widgetConfig/useConfig.js' @@ -24,15 +24,6 @@ export function CheckoutWidgetView(): JSX.Element { } = useEnvVariables() const [open, setOpen] = useState(false) const { open: openWallet } = useAppKit() - const { address: connectedAddress } = useAppKitAccount() - - // Keep the first connected address — disconnecting a funding wallet must not wipe the recipient. - const [demoRecipient, setDemoRecipient] = useState() - useEffect(() => { - if (connectedAddress) { - setDemoRecipient(connectedAddress) - } - }, [connectedAddress]) const handleOpen = useCallback(() => { setOpen(true) @@ -42,10 +33,6 @@ export function CheckoutWidgetView(): JSX.Element { setOpen(false) }, []) - const handleConnect = useCallback(() => { - openWallet() - }, [openWallet]) - // TODO(cleanup-remove-integrator-override-heuristic): Remove this heuristic comparison against // widgetBaseConfig.integrator and use a strict precedence contract. const resolvedIntegrator = @@ -53,11 +40,15 @@ export function CheckoutWidgetView(): JSX.Element { ? config.integrator : checkoutIntegrator?.trim() || DEFAULT_CHECKOUT_INTEGRATOR + const configToAddress = + typeof config?.toAddress === 'string' + ? config.toAddress + : config?.toAddress?.address const resolvedToChain = config?.toChain ?? checkoutToChain ?? DEFAULT_CHECKOUT_TO_CHAIN const resolvedToToken = config?.toToken ?? checkoutToToken ?? DEFAULT_CHECKOUT_TO_TOKEN - const resolvedToAddress = checkoutToAddress ?? demoRecipient + const resolvedToAddress = configToAddress ?? checkoutToAddress const checkoutConfig = useMemo( () => ({ @@ -101,36 +92,22 @@ export function CheckoutWidgetView(): JSX.Element { Checkout — opens as a centered widget {resolvedToAddress ? ( - <> - - Recipient: {resolvedToAddress} - - - - ) : ( - <> - - Connect a wallet to set the checkout recipient. - - - - )} + + Recipient: {resolvedToAddress} + + ) : null} + diff --git a/packages/widget-playground/src/types.ts b/packages/widget-playground/src/types.ts index cb292c73d..c9a3168d6 100644 --- a/packages/widget-playground/src/types.ts +++ b/packages/widget-playground/src/types.ts @@ -15,3 +15,4 @@ export type SidebarView = | 'wallet' | 'developer' | 'themeEdit' + | 'checkout' diff --git a/packages/widget-provider-mesh/src/MeshHost.tsx b/packages/widget-provider-mesh/src/MeshHost.tsx index cf06c6c29..e28a75841 100644 --- a/packages/widget-provider-mesh/src/MeshHost.tsx +++ b/packages/widget-provider-mesh/src/MeshHost.tsx @@ -2,6 +2,10 @@ import { type CexSessionRequest, type CexSessionResponse, + type ConnectedCexAccount, + type ConnectedCexBrand, + connectedCexKey, + DEFAULT_CEX_ACCOUNT_TTL_MS, type OnRampError, type OnRampFailure, type OnRampHostWidgetConfig, @@ -10,9 +14,11 @@ import { postCheckoutSession, useCheckoutConfig, useCheckoutUserId, + useConnectedCexStore, useRegisterOnRampSession, } from '@lifi/widget-provider/checkout' import type { + IntegrationAccessToken, LinkEventType, LinkPayload, TransferFinishedPayload, @@ -24,6 +30,45 @@ export interface MeshHostProps { widgetConfig: OnRampHostWidgetConfig } +/** + * Mesh Managed Tokens (MMT): store `tokenId` (stable per userId+brokerType, + * refreshed server-side by Mesh) rather than the short-lived raw `accessToken`. + * On reconnect, the `tokenId` is what goes in the SDK's `accessToken` field; + * falls back to the raw token if `tokenId` is absent. + * + * Both logo variants are stored raw; the consumer picks one against the active + * MUI color scheme at render time. + */ +function toConnectedAccounts( + payload: LinkPayload, + now: number +): ConnectedCexAccount[] { + const at = payload.accessToken + if (!at?.accountTokens?.length) { + return [] + } + const expiresAt = + now + + (at.expiresInSeconds + ? at.expiresInSeconds * 1000 + : DEFAULT_CEX_ACCOUNT_TTL_MS) + const info = at.brokerBrandInfo + const brand: ConnectedCexBrand = { + logoLight: info?.logoLightUrl ?? info?.iconLightUrl, + logoDark: info?.logoDarkUrl ?? info?.iconDarkUrl, + } + return at.accountTokens.map((token) => ({ + accountId: token.account.accountId, + accountName: token.account.accountName, + accessToken: token.tokenId ?? token.accessToken, + brokerType: at.brokerType, + brokerName: at.brokerName, + brand, + connectedAt: now, + expiresAt, + })) +} + /** * Logic-only host: holds Mesh session state, runs the Mesh link SDK (which * provides its own overlay UI), and registers the session into the @@ -149,8 +194,25 @@ export const MeshHost: FC = ({ widgetConfig }) => { } const link = createLink({ - onIntegrationConnected: (_payload: LinkPayload) => { - // Exchange account linked — no action needed for transfer flow + accessTokens: args.accessTokens?.length + ? (args.accessTokens as IntegrationAccessToken[]) + : undefined, + theme: widgetConfig.appearance ?? 'system', + language: args.language, + displayFiatCurrency: args.fiatCurrency, + onIntegrationConnected: (payload: LinkPayload) => { + // Persist the linked account(s) so the checkout can offer a + // one-tap "reconnect" on a later visit (see useConnectedCexStore). + const accounts = toConnectedAccounts(payload, Date.now()) + if (!accounts.length) { + return + } + useConnectedCexStore + .getState() + .addConnectedAccounts( + connectedCexKey(integrator, checkoutUserId), + accounts + ) }, onTransferFinished: (payload: TransferFinishedPayload) => { transferSucceededRef.current = true @@ -185,6 +247,16 @@ export const MeshHost: FC = ({ widgetConfig }) => { kind: 'connection', message: event.payload.errorMessage, } + // Reconnect failed (likely a stale token) — drop the account so + // the one-tap row disappears. If the user recovers in the same + // session, onIntegrationConnected re-adds it. + if (args.accessTokens?.length) { + const key = connectedCexKey(integrator, checkoutUserId) + const remove = useConnectedCexStore.getState().removeAccount + for (const token of args.accessTokens) { + remove(key, token.accountId) + } + } break default: break @@ -288,6 +360,7 @@ export const MeshHost: FC = ({ widgetConfig }) => { onError, onSuccess, widgetConfig.apiKey, + widgetConfig.appearance, ] ) diff --git a/packages/widget-provider-mesh/src/index.ts b/packages/widget-provider-mesh/src/index.ts index b6f0fd78b..8a7488955 100644 --- a/packages/widget-provider-mesh/src/index.ts +++ b/packages/widget-provider-mesh/src/index.ts @@ -14,6 +14,8 @@ export function meshProvider(): OnRampProvider { name: 'Mesh', description: 'Transfer from your exchange account', features: ['Coinbase', 'Binance', '300+ Exchanges'], + // Mesh serves its Link UI (and auto-prewarms its catalog) from here. + preconnectOrigins: ['https://web.meshconnect.com'], Host: MeshHost, } } diff --git a/packages/widget-provider/src/checkout/index.ts b/packages/widget-provider/src/checkout/index.ts index 0365e255c..fb4c164db 100644 --- a/packages/widget-provider/src/checkout/index.ts +++ b/packages/widget-provider/src/checkout/index.ts @@ -17,10 +17,19 @@ export { useRegisterOnRampSession, } from './contexts/OnRampSessionsContext.js' export { useCheckoutUserId } from './hooks/useCheckoutUserId.js' +export { + type ConnectedCexAccount, + type ConnectedCexBrand, + connectedCexKey, + DEFAULT_CEX_ACCOUNT_TTL_MS, + useConnectedCexAccounts, + useConnectedCexStore, +} from './stores/useConnectedCexStore.js' export type { CheckoutContextValue, CheckoutError, CheckoutResult, + OnRampAccessToken, OnRampError, OnRampErrorCode, OnRampFailure, diff --git a/packages/widget-provider/src/checkout/stores/useConnectedCexStore.ts b/packages/widget-provider/src/checkout/stores/useConnectedCexStore.ts new file mode 100644 index 000000000..e2cd48583 --- /dev/null +++ b/packages/widget-provider/src/checkout/stores/useConnectedCexStore.ts @@ -0,0 +1,151 @@ +'use client' +import { useMemo } from 'react' +import { create, type StoreApi, type UseBoundStore } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' +import type { OnRampAccessToken } from '../types.js' + +export const CONNECTED_CEX_VERSION = 1 +export const CONNECTED_CEX_STORAGE_KEY = 'lifi-checkout-connected-cex' +/** Used when Mesh omits `expiresInSeconds` on a connected account. */ +export const DEFAULT_CEX_ACCOUNT_TTL_MS: number = 30 * 60 * 1000 + +/** + * Extends `OnRampAccessToken` so it passes straight back into a provider's + * `open()` without remapping. `accessToken` holds Mesh's stable `tokenId` (Mesh + * Managed Tokens), not a raw credential — but it's still scoped to + * sessionStorage (cleared on tab close) and expired at `expiresAt`. + */ +export interface ConnectedCexBrand { + logoLight?: string + logoDark?: string +} + +export interface ConnectedCexAccount extends OnRampAccessToken { + brand?: ConnectedCexBrand + connectedAt: number + expiresAt: number +} + +interface ConnectedCexState { + records: Record + addConnectedAccounts: (key: string, accounts: ConnectedCexAccount[]) => void + removeAccount: (key: string, accountId: string) => void +} + +export function connectedCexKey(integrator: string, userId: string): string { + return `${integrator}:${userId}` +} + +function sweepExpired( + records: Record, + now: number +): Record { + const out: Record = {} + for (const [key, accounts] of Object.entries(records)) { + const live = accounts.filter((a) => a.expiresAt > now) + if (live.length) { + out[key] = live + } + } + return out +} + +/** De-dupes by `accountId`; newest write wins. */ +function mergeAccounts( + existing: ConnectedCexAccount[] | undefined, + incoming: ConnectedCexAccount[] +): ConnectedCexAccount[] { + const byId = new Map() + for (const account of existing ?? []) { + byId.set(account.accountId, account) + } + for (const account of incoming) { + byId.set(account.accountId, account) + } + return [...byId.values()] +} + +export const useConnectedCexStore: UseBoundStore> = + create()( + persist( + (set) => ({ + records: {}, + addConnectedAccounts: (key, accounts) => { + if (!accounts.length) { + return + } + set((state) => { + // Only prune the touched key — sweeping every integrator's records + // on each write would mutate slots this call has nothing to do + // with. Whole-store hygiene happens once on rehydrate. + const now = Date.now() + const merged = mergeAccounts(state.records[key], accounts).filter( + (a) => a.expiresAt > now + ) + if (!merged.length) { + const { [key]: _removed, ...rest } = state.records + return { records: rest } + } + return { + records: { + ...state.records, + [key]: merged, + }, + } + }) + }, + removeAccount: (key, accountId) => + set((state) => { + const list = state.records[key] + if (!list) { + return state + } + const next = list.filter((a) => a.accountId !== accountId) + if (next.length === list.length) { + return state + } + if (!next.length) { + const { [key]: _removed, ...rest } = state.records + return { records: rest } + } + return { records: { ...state.records, [key]: next } } + }), + }), + { + name: CONNECTED_CEX_STORAGE_KEY, + storage: createJSONStorage(() => sessionStorage), + partialize: (state) => ({ records: state.records }), + onRehydrateStorage: () => (rehydrated, error) => { + if (error || !rehydrated) { + return + } + rehydrated.records = sweepExpired(rehydrated.records, Date.now()) + }, + version: CONNECTED_CEX_VERSION, + } + ) + ) + +const EMPTY_ACCOUNTS: ConnectedCexAccount[] = [] + +/** + * Subscribes to the live (non-expired) connected accounts for `key`. Returns a + * stable empty array when `key` is null or no live accounts exist. + */ +export function useConnectedCexAccounts( + key: string | null +): ConnectedCexAccount[] { + const records = useConnectedCexStore((s) => s.records) + return useMemo(() => { + if (!key) { + return EMPTY_ACCOUNTS + } + const list = records[key] + if (!list?.length) { + return EMPTY_ACCOUNTS + } + const now = Date.now() + const live = list.filter((a) => a.expiresAt > now) + return live.length ? live : EMPTY_ACCOUNTS + }, [records, key]) +} diff --git a/packages/widget-provider/src/checkout/types.ts b/packages/widget-provider/src/checkout/types.ts index e2af76643..9c20c6072 100644 --- a/packages/widget-provider/src/checkout/types.ts +++ b/packages/widget-provider/src/checkout/types.ts @@ -11,6 +11,8 @@ export interface OnRampHostWidgetConfig { apiKey?: string toChain?: number toToken?: string + /** Widget color mode; providers that support theming mirror it (e.g. Mesh's `theme`). */ + appearance?: 'light' | 'dark' | 'system' } /** @@ -126,10 +128,31 @@ export interface OnRampError { params?: Record } +/** + * A previously-linked exchange account passed back into a provider's flow to + * skip re-authentication (Mesh's `IntegrationAccessToken`). Shape mirrored here + * so provider packages need not import a specific SDK's types. + */ +export interface OnRampAccessToken { + accountId: string + accountName: string + accessToken: string + brokerType: string + brokerName: string +} + export interface OnRampOpenArgs { depositAddress: string amount: string fiatCurrency?: 'USD' | 'EUR' | 'GBP' + /** + * Previously-connected exchange accounts to resume. When set, the provider + * (Mesh) re-enters its flow at the asset/transfer step instead of the catalog + * + login. Providers that don't support reconnection ignore this. + */ + accessTokens?: OnRampAccessToken[] + /** Active UI language (e.g. i18n `en`); providers that localize honor it. */ + language?: string /** * Prefilled fiat amount for the provider's widget (e.g. Transak's * `fiatAmount`). Derived from the checkout's EnterAmount value times the @@ -175,6 +198,12 @@ export interface CheckoutContextValue { onError?: (error: CheckoutError) => void /** @default true */ resumePending?: boolean + /** + * When `true` and no `toAddress` is configured, the user is prompted to set + * the destination address in the widget instead of the checkout blocking as + * misconfigured. @default false + */ + allowUserDestinationAddress?: boolean } export interface CheckoutResult { diff --git a/packages/widget/src/i18n/en.json b/packages/widget/src/i18n/en.json index a6d900764..9aad7c8ef 100644 --- a/packages/widget/src/i18n/en.json +++ b/packages/widget/src/i18n/en.json @@ -488,6 +488,11 @@ } }, "chooseFundingSource": "Choose funding source", + "whereToSendIt": "Where to send it", + "sendingTo": "Sending to", + "required": "Required", + "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.", "useYourTokens": "Use your tokens", "buyTokens": "Buy tokens", @@ -499,6 +504,8 @@ "transferCryptoSubtitle": "From any wallet", "connectExchange": "Connect Exchange", "connectExchangeSubtitle": "Link your exchange account", + "payWithExchange": "Pay with {{exchange}}", + "reuseExchangeSubtitle": "Continue with your linked account", "depositWithCash": "Deposit with Cash", "depositWithCashSubtitle": "Debit or credit card", "connectWallet": "Connect wallet", diff --git a/packages/widget/src/shared.ts b/packages/widget/src/shared.ts index 2aeb2308a..30ea9c2e0 100644 --- a/packages/widget/src/shared.ts +++ b/packages/widget/src/shared.ts @@ -72,6 +72,10 @@ export { } from './components/TransactionCard/TransactionCard.style.js' // ── hooks ──────────────────────────────────────────────────────────────────── export { useAddressActivity } from './hooks/useAddressActivity.js' +export { + AddressType, + useAddressValidation, +} from './hooks/useAddressValidation.js' export { useAvailableChains } from './hooks/useAvailableChains.js' export { useChain } from './hooks/useChain.js' export { useContactSupport } from './hooks/useContactSupport.js'