diff --git a/.changeset/bright-rivers-queue.md b/.changeset/bright-rivers-queue.md new file mode 100644 index 000000000..e4a03fd77 --- /dev/null +++ b/.changeset/bright-rivers-queue.md @@ -0,0 +1,11 @@ +--- +'@lifi/widget-provider': minor +'@lifi/widget-provider-transak': minor +'@lifi/widget-provider-mesh': minor +'@lifi/widget-checkout': minor +'@lifi/widget': minor +--- + +Add quote-aware Transak checkout wiring by introducing onramp fiat-currencies and quote API contracts, extending onramp session payload/response fields, and carrying provider funding session metadata through checkout session state. + +Switch checkout cash funding to a fiat-first flow with live quote-driven route amounts, dynamic fiat currencies/payment methods, and persisted funding session ids for resume/reconciliation paths. diff --git a/.gitignore b/.gitignore index a2e9791a1..c15a6e44a 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ next-env.d.ts .claude/worktrees/ .omc/ .cursor/ +.codex/ +.agents/ +AGENTS.md diff --git a/packages/widget-checkout/src/components/CashHandoffSheet.tsx b/packages/widget-checkout/src/components/CashHandoffSheet.tsx new file mode 100644 index 000000000..07f05ec1a --- /dev/null +++ b/packages/widget-checkout/src/components/CashHandoffSheet.tsx @@ -0,0 +1,159 @@ +import { + Box, + Button, + Drawer, + Stack, + type Theme, + Typography, +} from '@mui/material' +import { + type JSX, + startTransition, + useCallback, + useEffect, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' + +interface CashHandoffSheetProps { + open: boolean + depositAddress: string + onContinue: () => void + onGoBack: () => void + container?: HTMLElement | null +} + +const modalSx = { + position: 'absolute' as const, + inset: 0, + overflow: 'hidden', +} + +const paperSx = (theme: Theme) => ({ + position: 'absolute' as const, + backgroundImage: 'none', + backgroundColor: theme.vars.palette.background.default, + borderTopLeftRadius: theme.vars.shape.borderRadius, + borderTopRightRadius: theme.vars.shape.borderRadius, +}) + +const backdropSx = { + position: 'absolute' as const, + inset: 0, + backgroundColor: 'rgb(0 0 0 / 32%)', + backdropFilter: 'blur(3px)', +} + +const titleId = 'checkout-cash-handoff-title' +const bodyId = 'checkout-cash-handoff-body' + +export const CashHandoffSheet: React.FC = ({ + open, + depositAddress, + onContinue, + onGoBack, + container, +}): JSX.Element => { + const { t } = useTranslation() + const [drawerOpen, setDrawerOpen] = useState(open) + const [isInert, setIsInert] = useState(!open) + + useEffect(() => { + if (open) { + setIsInert(false) + setDrawerOpen(true) + } else { + setIsInert(true) + startTransition(() => setDrawerOpen(false)) + } + }, [open]) + + const handleClose = useCallback(() => { + setIsInert(true) + startTransition(() => { + setDrawerOpen(false) + onGoBack() + }) + }, [onGoBack]) + + return ( + + + + {t('checkout.cashHandoff.title')} + + + {t('checkout.cashHandoff.body')} + + + + {t('checkout.cashHandoff.addressLabel')} + + + {depositAddress} + + + {t('checkout.cashHandoff.addressHint')} + + + + + + + + + ) +} diff --git a/packages/widget-checkout/src/components/CheckoutAmountInput.tsx b/packages/widget-checkout/src/components/CheckoutAmountInput.tsx index 85de920c3..72849378f 100644 --- a/packages/widget-checkout/src/components/CheckoutAmountInput.tsx +++ b/packages/widget-checkout/src/components/CheckoutAmountInput.tsx @@ -24,10 +24,13 @@ import type { CardProps } from '@mui/material' import { Box, Typography } from '@mui/material' import { styled } from '@mui/material/styles' import type { ChangeEvent, ComponentProps, ReactNode } from 'react' -import { useLayoutEffect, useRef, useState } from 'react' +import { useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useCheckoutNavigate } from '../hooks/useCheckoutNavigate.js' import { useIsWalletFundedFlow } from '../hooks/useIsWalletFundedFlow.js' +import { useCheckoutFlowStore } from '../stores/useCheckoutFlowStore.js' +import { useFiatCurrencyStore } from '../stores/useFiatCurrencyStore.js' +import { getCurrencySymbol } from '../utils/fiatFormat.js' import { checkoutNavigationRoutes } from '../utils/navigationRoutes.js' import { CheckoutPriceFormHelperText } from './CheckoutPriceFormHelperText.js' @@ -45,11 +48,13 @@ const CheckoutInputCard: React.FC> = styled( export type CheckoutAmountInputProps = FormTypeProps & CardProps & { sendSlot?: ReactNode + presetsSlot?: ReactNode } export const CheckoutAmountInput: React.FC = ({ formType, sendSlot, + presetsSlot, ...props }) => { const { disabledUI } = useWidgetConfig() @@ -69,6 +74,7 @@ export const CheckoutAmountInput: React.FC = ({ bottomAdornment={} disabled={disabled} sendSlot={sendSlot} + presetsSlot={presetsSlot} {...props} /> ) @@ -82,6 +88,7 @@ const CheckoutAmountInputBase: React.FC< bottomAdornment?: ReactNode disabled?: boolean sendSlot?: ReactNode + presetsSlot?: ReactNode } > = ({ formType, @@ -90,8 +97,10 @@ const CheckoutAmountInputBase: React.FC< bottomAdornment, disabled, sendSlot, + presetsSlot, ...props }) => { + const { i18n } = useTranslation() const ref = useRef(null) const isEditingRef = useRef(false) @@ -101,28 +110,46 @@ const CheckoutAmountInputBase: React.FC< const [value] = useFieldValues(amountKey) const { setFieldValue } = useFieldActions() const { inputMode } = useInputModeStore() + const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) + const fiatCurrency = useFiatCurrencyStore((s) => s.currency) const isWalletFunded = useIsWalletFundedFlow() + const isCashFlow = fundingSource === 'cash' + const [cashFiatAmount] = useFieldValues('cashFiatAmount') const currentInputMode = inputMode[formType] let displayValue: string if (isEditingRef.current) { if (currentInputMode === 'price') { - displayValue = formattedPriceInput + displayValue = isCashFlow ? cashFiatAmount : formattedPriceInput } else { displayValue = value as string } } else { if (currentInputMode === 'price') { - const priceValue = formatTokenPrice(value as string, token?.priceUSD) - displayValue = formatInputAmount( - priceValue.toFixed(usdDecimals), - usdDecimals - ) + if (isCashFlow) { + displayValue = formatInputAmount(cashFiatAmount, usdDecimals) + } else { + const priceValue = formatTokenPrice(value as string, token?.priceUSD) + displayValue = formatInputAmount( + priceValue.toFixed(usdDecimals), + usdDecimals + ) + } } else { displayValue = value as string } } + const currencyPrefix = useMemo( + () => + currentInputMode === 'price' + ? getCurrencySymbol(fiatCurrency, i18n.language) + : '', + [currentInputMode, fiatCurrency, i18n.language] + ) + const stripCurrencyPrefix = (input: string): string => + currencyPrefix ? input.split(currencyPrefix).join('').trim() : input + const handleChange = ( event: ChangeEvent ) => { @@ -131,11 +158,19 @@ const CheckoutAmountInputBase: React.FC< let formattedValue: string if (currentInputMode === 'price') { - const cleanInputValue = inputValue.replace('$', '') + const cleanInputValue = stripCurrencyPrefix(inputValue) formattedValue = formatInputAmount(cleanInputValue, usdDecimals, true) - const tokenValue = priceToTokenAmount(formattedValue, token?.priceUSD) setFormattedPriceInput(formattedValue) - setFieldValue(amountKey, tokenValue, { isDirty: true, isTouched: true }) + if (isCashFlow) { + setFieldValue('cashFiatAmount', formattedValue, { + isDirty: true, + isTouched: true, + }) + setFieldValue(amountKey, '', { isDirty: true, isTouched: true }) + } else { + const tokenValue = priceToTokenAmount(formattedValue, token?.priceUSD) + setFieldValue(amountKey, tokenValue, { isDirty: true, isTouched: true }) + } } else { formattedValue = formatInputAmount(inputValue, token?.decimals, true) setFieldValue(amountKey, formattedValue, { @@ -153,14 +188,24 @@ const CheckoutAmountInputBase: React.FC< let formattedValue: string if (currentInputMode === 'price') { - const cleanInputValue = inputValue.replace('$', '') + const cleanInputValue = stripCurrencyPrefix(inputValue) formattedValue = formatInputAmount(cleanInputValue, usdDecimals) - const tokenValue = priceToTokenAmount(formattedValue, token?.priceUSD) - const formattedAmount = formatInputAmount(tokenValue, token?.decimals) - setFieldValue(amountKey, formattedAmount, { - isDirty: true, - isTouched: true, - }) + if (isCashFlow) { + setFieldValue('cashFiatAmount', formattedValue, { + isDirty: true, + isTouched: true, + }) + if (!formattedValue) { + setFieldValue(amountKey, '', { isDirty: true, isTouched: true }) + } + } else { + const tokenValue = priceToTokenAmount(formattedValue, token?.priceUSD) + const formattedAmount = formatInputAmount(tokenValue, token?.decimals) + setFieldValue(amountKey, formattedAmount, { + isDirty: true, + isTouched: true, + }) + } } else { formattedValue = formatInputAmount(inputValue, token?.decimals) setFieldValue(amountKey, formattedValue, { @@ -207,13 +252,17 @@ const CheckoutAmountInputBase: React.FC< ) : null} - + - {bottomAdornment} + + {bottomAdornment} + + {presetsSlot ? ( + {presetsSlot} + ) : null} ) } diff --git a/packages/widget-checkout/src/components/CheckoutAmountPresets.tsx b/packages/widget-checkout/src/components/CheckoutAmountPresets.tsx new file mode 100644 index 000000000..e06e8b78a --- /dev/null +++ b/packages/widget-checkout/src/components/CheckoutAmountPresets.tsx @@ -0,0 +1,60 @@ +import { + FormKeyHelper, + useFieldActions, + useFieldValues, +} from '@lifi/widget/shared' +import { Chip, Stack } from '@mui/material' +import type { JSX } from 'react' +import { useFiatCurrencyStore } from '../stores/useFiatCurrencyStore.js' +import { normalizeFiatAmount } from '../utils/fiatFormat.js' + +const PRESET_AMOUNTS = [20, 50, 100, 200] as const + +export const CheckoutAmountPresets: React.FC = (): JSX.Element => { + const currency = useFiatCurrencyStore((s) => s.currency) + const [cashFiatAmount] = useFieldValues('cashFiatAmount') + const { setFieldValue } = useFieldActions() + + const selected = Number.parseFloat(normalizeFiatAmount(cashFiatAmount)) + const setPresetAmount = (amount: number) => { + setFieldValue('cashFiatAmount', String(amount), { + isDirty: true, + isTouched: true, + }) + setFieldValue(FormKeyHelper.getAmountKey('from'), '', { + isDirty: true, + isTouched: true, + }) + } + + return ( + + {PRESET_AMOUNTS.map((amount) => { + const isSelected = selected === amount + return ( + setPresetAmount(amount)} + sx={{ + height: 'auto', + fontSize: 13, + fontWeight: 600, + bgcolor: isSelected ? 'primary.main' : 'action.hover', + color: isSelected ? 'primary.contrastText' : 'text.primary', + border: 'none', + '& .MuiChip-label': { + px: 1.5, + py: 1, + }, + '&:hover': { + bgcolor: isSelected ? 'primary.main' : 'action.selected', + }, + }} + /> + ) + })} + + ) +} diff --git a/packages/widget-checkout/src/components/CheckoutFiatOriginToken.tsx b/packages/widget-checkout/src/components/CheckoutFiatOriginToken.tsx new file mode 100644 index 000000000..656b1625c --- /dev/null +++ b/packages/widget-checkout/src/components/CheckoutFiatOriginToken.tsx @@ -0,0 +1,52 @@ +import { Avatar, Box, Typography } from '@mui/material' +import type { JSX } from 'react' +import { useTranslation } from 'react-i18next' +import { + formatFiat, + getCurrencyName, + getCurrencySymbol, +} from '../utils/fiatFormat.js' + +interface CheckoutFiatOriginTokenProps { + currency: string + amount: string +} + +export const CheckoutFiatOriginToken: React.FC< + CheckoutFiatOriginTokenProps +> = ({ currency, amount }): JSX.Element => { + const { i18n } = useTranslation() + + return ( + + + {getCurrencySymbol(currency, i18n.language)} + + + + {formatFiat(amount, currency, i18n.language)} + + + {getCurrencyName(currency, i18n.language)} + + + + ) +} diff --git a/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx b/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx index 41d6b2fa8..6604b9923 100644 --- a/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx +++ b/packages/widget-checkout/src/components/CheckoutFlowCtaButton.tsx @@ -1,6 +1,8 @@ +import { parseUnits } from '@lifi/sdk' import { BaseTransactionButton, formatTokenAmount, + useFieldValues, useToAddressRequirements, useWidgetEvents, WidgetEvent, @@ -8,10 +10,12 @@ import { import { Button } from '@mui/material' import { useNavigate } from '@tanstack/react-router' import type { JSX } from 'react' -import { useCallback } from 'react' +import { Fragment, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useCheckoutModal } from '../CheckoutModal.js' import { useCheckoutFlowQuote } from '../hooks/useCheckoutFlowQuote.js' import { useFrozenQuote } from '../hooks/useFrozenQuote.js' +import { useOnRampQuote } from '../hooks/useOnRampQuote.js' import { useResolvedCheckoutRecipient } from '../hooks/useResolvedCheckoutRecipient.js' import { useOnRampSessionByCategory } from '../providers/OnRampProvider/OnRampProvider.js' import { @@ -19,10 +23,12 @@ import { useCheckoutFlowStore, } from '../stores/useCheckoutFlowStore.js' import { useFiatCurrencyStore } from '../stores/useFiatCurrencyStore.js' +import { normalizeFiatAmount } from '../utils/fiatFormat.js' import { checkoutAbsolutePaths, checkoutNavigationRoutes, } from '../utils/navigationRoutes.js' +import { CashHandoffSheet } from './CashHandoffSheet.js' const ctaLabelKey = { wallet: 'button.pay', @@ -54,11 +60,21 @@ export const CheckoutFlowCtaButton: React.FC = (): JSX.Element => { (s) => s.selectedExchangeAccount ) const fiatCurrency = useFiatCurrencyStore((s) => s.currency) + const paymentMethod = useFiatCurrencyStore((s) => s.paymentMethod) + const [cashFiatAmount] = useFieldValues('cashFiatAmount') + const onRampQuote = useOnRampQuote() const onRampSession = useOnRampSessionByCategory( fundingSource === 'cash' || fundingSource === 'exchange' ? fundingSource : null ) + const normalizedCashFiatAmount = normalizeFiatAmount(cashFiatAmount) + const parsedFiatAmount = Number.parseFloat(normalizedCashFiatAmount) + const hasFiatAmount = + Number.isFinite(parsedFiatAmount) && parsedFiatAmount > 0 + + const panelEl = useCheckoutModal()?.panelEl ?? null + const [handoffOpen, setHandoffOpen] = useState(false) const handleWalletDeposit = useCallback(() => { if (!route) { @@ -88,26 +104,27 @@ export const CheckoutFlowCtaButton: React.FC = (): JSX.Element => { if (!route || !depositAddress || !onRampSession) { return } - freeze(route) + freeze( + route, + fundingSource === 'cash' + ? { fiatCurrency, fiatAmount: normalizedCashFiatAmount || undefined } + : undefined + ) setFrozenRouteId(route.id) const cryptoAmount = formatTokenAmount( BigInt(route.fromAmount), route.fromToken.decimals ) - const priceUSD = Number.parseFloat(route.fromToken.priceUSD ?? '') - const cryptoAmountNumber = Number.parseFloat(cryptoAmount) - const fiatAmount = - Number.isFinite(priceUSD) && - priceUSD > 0 && - Number.isFinite(cryptoAmountNumber) && - cryptoAmountNumber > 0 - ? (cryptoAmountNumber * priceUSD).toFixed(2) - : undefined onRampSession.open({ depositAddress, amount: cryptoAmount, fiatCurrency, - fiatAmount, + fiatAmount: + fundingSource === 'cash' + ? normalizedCashFiatAmount || undefined + : undefined, + paymentMethod: + fundingSource === 'cash' ? (paymentMethod ?? undefined) : undefined, fromChainId: route.fromChainId, fromTokenAddress: route.fromToken.address, accessTokens: selectedExchangeAccount @@ -129,6 +146,9 @@ export const CheckoutFlowCtaButton: React.FC = (): JSX.Element => { freeze, setFrozenRouteId, fiatCurrency, + normalizedCashFiatAmount, + paymentMethod, + fundingSource, navigate, selectedExchangeAccount, i18n.language, @@ -145,7 +165,6 @@ export const CheckoutFlowCtaButton: React.FC = (): JSX.Element => { const needsRecipient = isUserSettable && !recipient - // Only the wallet flow may connect-on-demand; other sources fund without a wallet. if (fundingSource === 'wallet') { return ( { ) } - // A failed step leaves no deposit address, so the CTA can never enable. - if (isError) { + const isCash = fundingSource === 'cash' + let cashRouteMatchesQuote = !isCash + if (isCash && route && onRampQuote.data?.funding?.estimatedAmount) { + try { + cashRouteMatchesQuote = + parseUnits( + onRampQuote.data.funding.estimatedAmount, + route.fromToken.decimals + ).toString() === route.fromAmount + } catch { + cashRouteMatchesQuote = false + } + } + + const cashNotReady = + isCash && + (!hasFiatAmount || + !onRampQuote.isReady || + onRampQuote.isFetching || + onRampQuote.isDebouncePending || + !cashRouteMatchesQuote) + + if (isError || (isCash && onRampQuote.isError)) { return ( + + + {isCash && depositAddress ? ( + { + setHandoffOpen(false) + handleOnRampDeposit() + }} + onGoBack={() => setHandoffOpen(false)} + /> + ) : null} + ) } diff --git a/packages/widget-checkout/src/components/CheckoutPriceFormHelperText.tsx b/packages/widget-checkout/src/components/CheckoutPriceFormHelperText.tsx index 5c253d0de..79cad40c8 100644 --- a/packages/widget-checkout/src/components/CheckoutPriceFormHelperText.tsx +++ b/packages/widget-checkout/src/components/CheckoutPriceFormHelperText.tsx @@ -29,20 +29,23 @@ export const CheckoutPriceFormHelperText: React.NamedExoticComponent { @@ -81,37 +84,43 @@ export const CheckoutPriceFormHelperText: React.NamedExoticComponent - {canTogglePrice ? : null} - - {priceOrAmountLabel} - - {currentInputMode === 'price' && token?.symbol ? ( - - {token.symbol} - - ) : null} + {isPriceSymbolLoading ? ( + + ) : ( + <> + {canTogglePrice ? : null} + + {priceOrAmountLabel} + + {currentInputMode === 'price' && token?.symbol ? ( + + {token.symbol} + + ) : null} + + )} {isWalletFunded ? ( isBalanceLoading && tokenAddress ? ( diff --git a/packages/widget-checkout/src/components/CheckoutReceiveCard.tsx b/packages/widget-checkout/src/components/CheckoutReceiveCard.tsx index e6df7d11b..f2dae8506 100644 --- a/packages/widget-checkout/src/components/CheckoutReceiveCard.tsx +++ b/packages/widget-checkout/src/components/CheckoutReceiveCard.tsx @@ -1,9 +1,11 @@ import type { Route } from '@lifi/sdk' import { + AvatarBadgedDefault, ChainAvatar, FeeBreakdownTooltip, FormKeyHelper, formatDuration, + formatInputAmount, formatTokenAmount, formatTokenPrice, getAccumulatedFeeCostsBreakdown, @@ -15,20 +17,164 @@ import { TokenAvatar, TokenRate, useChain, + useFieldActions, useFieldValues, useToken, } from '@lifi/widget/shared' import AccessTimeFilled from '@mui/icons-material/AccessTimeFilled' import ExpandMoreRounded from '@mui/icons-material/ExpandMoreRounded' import LocalGasStationRounded from '@mui/icons-material/LocalGasStationRounded' -import { Box, Collapse, IconButton, Skeleton, Typography } from '@mui/material' -import { useState } from 'react' +import { + Box, + Collapse, + IconButton, + Skeleton, + Stack, + Typography, +} from '@mui/material' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useCheckoutRoutes } from '../hooks/useCheckoutRoutes.js' +import { useOnRampQuote } from '../hooks/useOnRampQuote.js' +import { useCheckoutFlowStore } from '../stores/useCheckoutFlowStore.js' +import { formatFiat, normalizeFiatAmount } from '../utils/fiatFormat.js' + export const CheckoutReceiveCard: React.FC = () => { + const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) + const [cashFiatAmount] = useFieldValues('cashFiatAmount') + const parsedTypedFiatAmount = Number.parseFloat( + normalizeFiatAmount(cashFiatAmount) + ) + const hasTypedFiatAmount = + Number.isFinite(parsedTypedFiatAmount) && parsedTypedFiatAmount > 0 + + if (fundingSource === 'cash' && !hasTypedFiatAmount) { + return + } + + return +} + +const CheckoutReceiveIdleCard: React.FC = () => { + const { t } = useTranslation() + const [toChainId, toTokenAddress] = useFieldValues( + FormKeyHelper.getChainKey('to'), + FormKeyHelper.getTokenKey('to') + ) + const { chain } = useChain(toChainId) + const { token: toToken } = useToken(toChainId, toTokenAddress) + + return ( + + + {toToken && chain ? ( + + ) : ( + + )} + + + {t('header.receive')} + + + {toToken?.symbol ?? '—'} + + + + + + + 0 + + + + {t('format.currency', { value: 0 })} + + + • + + + {chain?.logoURI ? ( + + ) : null} + + {chain?.name ?? '—'} + + + + + + + ) +} + +const CheckoutReceiveCardWithRoutes: React.FC = () => { const { t, i18n } = useTranslation() + const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) + const isCash = fundingSource === 'cash' const [expanded, setExpanded] = useState(false) - const [fromAmount] = useFieldValues('fromAmount') + const { setFieldValue } = useFieldActions() + const onRampQuote = useOnRampQuote() + const [fromAmount, cashFiatAmount] = useFieldValues( + 'fromAmount', + 'cashFiatAmount' + ) const [toChainId, toTokenAddress] = useFieldValues( FormKeyHelper.getChainKey('to'), FormKeyHelper.getTokenKey('to') @@ -44,12 +190,20 @@ export const CheckoutReceiveCard: React.FC = () => { refetchTime, } = useCheckoutRoutes() + const parsedTypedFiatAmount = Number.parseFloat( + normalizeFiatAmount(cashFiatAmount) + ) + const hasTypedFiatAmount = + Number.isFinite(parsedTypedFiatAmount) && parsedTypedFiatAmount > 0 const parsedAmount = Number.parseFloat( typeof fromAmount === 'string' ? fromAmount.replace(',', '.') : `${fromAmount ?? ''}` ) - const hasAmount = Number.isFinite(parsedAmount) && parsedAmount > 0 + const hasAmount = + (!isCash || hasTypedFiatAmount) && + Number.isFinite(parsedAmount) && + parsedAmount > 0 const route = routes?.[0] as Route | undefined const routeNotFound = hasAmount && !route && !isLoading && !isFetching && isFetched @@ -99,316 +253,486 @@ export const CheckoutReceiveCard: React.FC = () => { : { gasCosts: [], feeCosts: [], combinedFeesUSD: 0 } const handleToggleExpanded = () => { - if (!route) { + if (!isCash && !route) { return } setExpanded((prev) => !prev) } + const estimatedFundingAmount = onRampQuote.data?.funding?.estimatedAmount + useEffect(() => { + if (!isCash) { + return + } + if (!hasTypedFiatAmount || onRampQuote.isError) { + if (!fromAmount) { + return + } + setFieldValue(FormKeyHelper.getAmountKey('from'), '', { + isDirty: true, + isTouched: true, + }) + return + } + if ( + onRampQuote.isDebouncePending || + !onRampQuote.isReady || + !estimatedFundingAmount + ) { + return + } + setFieldValue( + FormKeyHelper.getAmountKey('from'), + formatInputAmount(estimatedFundingAmount), + { isDirty: true, isTouched: true } + ) + }, [ + fromAmount, + hasTypedFiatAmount, + isCash, + estimatedFundingAmount, + onRampQuote.isError, + onRampQuote.isDebouncePending, + onRampQuote.isReady, + setFieldValue, + ]) + + const cashFees = onRampQuote.data?.fees + const cashFeeRows = cashFees?.breakdown?.length + ? cashFees.breakdown.map((fee, index) => ({ + label: + fee.name || + fee.label || + fee.type || + t('checkout.cashQuote.feeFallback', { index: index + 1 }), + value: formatFiat(fee.amount, cashFees.currency, i18n.language), + })) + : cashFees?.total?.amount + ? [ + { + label: t('checkout.cashQuote.totalFeesLabel'), + value: formatFiat( + cashFees.total.amount, + cashFees.currency, + i18n.language + ), + }, + ] + : [] + const minimumReceived = route + ? `${t('format.tokenAmount', { + value: formatTokenAmount( + BigInt(route.toAmountMin), + route.toToken.decimals + ), + })} ${route.toToken.symbol}` + : null + + const cashDetailsPending = + isCash && + hasTypedFiatAmount && + !onRampQuote.isError && + (onRampQuote.isLoading || + onRampQuote.isFetching || + onRampQuote.isDebouncePending || + (Boolean(estimatedFundingAmount) && !route)) + const hasExpandableContent = isCash + ? hasTypedFiatAmount && + (cashDetailsPending || cashFeeRows.length > 0 || Boolean(minimumReceived)) + : Boolean(route) + return ( - + <> - - {toToken && chain ? ( - - ) : ( - - )} - - - {t('header.receive')} - - - {toToken?.symbol ?? '—'} - + + + {toToken && chain ? ( + + ) : ( + + )} + + + {t('header.receive')} + + + {toToken?.symbol ?? '—'} + + + {hasAmount ? ( + + ) : null} - {hasAmount ? ( - - ) : null} - - {routeNotFound ? ( - - ) : ( - <> - - - {showLoading ? ( - - 0 - - ) : ( - - {toAmountDisplay} - - )} - - - {toUsdDisplay} - - - • - - - {priceImpactStr} - - - • - + {routeNotFound ? ( + + ) : ( + <> + + + {showLoading ? ( + + 0 + + ) : ( + + {toAmountDisplay} + + )} - {chain?.logoURI ? ( - - ) : null} - {chain?.name ?? '—'} + {toUsdDisplay} - - - - - - - - - - - {route ? : null} - - - {route ? ( - <> - - - + - - + • + - {!combinedFeesUSD - ? t('main.fees.free') - : t('format.currency', { - value: combinedFeesUSD, - })} + {priceImpactStr} - - - - - - - - {formatDuration(executionTimeSeconds, i18n.language)} - - - - ) : ( - <> - - - - - - {toUsdZero} - - - - - - + + ) : null} + + • + + + {chain?.logoURI ? ( + + ) : null} - {formatDuration(0, i18n.language)} + {chain?.name ?? '—'} - - )} + + + + + - - - {route ? : null} - - - )} - + + {!isCash ? ( + + + {route ? : null} + + + {route ? ( + <> + + + + + + + {!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 ? ( + + + {t('checkout.cashQuote.guaranteedMinimum')} + + + {minimumReceived} + + + ) : null} + + ) : null + ) : route ? ( + + ) : null} + + + )} + + {isCash && hasTypedFiatAmount ? ( + + {minimumReceived + ? t('checkout.cashQuote.refundNote', { value: minimumReceived }) + : ' '} + + ) : null} + ) } diff --git a/packages/widget-checkout/src/components/CheckoutRecipientCard.tsx b/packages/widget-checkout/src/components/CheckoutRecipientCard.tsx index 0b3c1e8b2..7494cebdf 100644 --- a/packages/widget-checkout/src/components/CheckoutRecipientCard.tsx +++ b/packages/widget-checkout/src/components/CheckoutRecipientCard.tsx @@ -6,6 +6,7 @@ import { 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' @@ -48,9 +49,18 @@ export const CheckoutRecipientCard: React.FC = (): JSX.Element | null => { } 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, + }, + })} /> diff --git a/packages/widget-checkout/src/components/FiatCurrencyChip.tsx b/packages/widget-checkout/src/components/FiatCurrencyChip.tsx index 9675a8963..80dcb2b62 100644 --- a/packages/widget-checkout/src/components/FiatCurrencyChip.tsx +++ b/packages/widget-checkout/src/components/FiatCurrencyChip.tsx @@ -3,12 +3,14 @@ import type { JSX } from 'react' import { useTranslation } from 'react-i18next' import { useCheckoutNavigate } from '../hooks/useCheckoutNavigate.js' import { useFiatCurrencyStore } from '../stores/useFiatCurrencyStore.js' +import { getCurrencyName } from '../utils/fiatFormat.js' import { checkoutNavigationRoutes } from '../utils/navigationRoutes.js' export const FiatCurrencyChip: React.FC = (): JSX.Element => { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const navigate = useCheckoutNavigate() const currency = useFiatCurrencyStore((s) => s.currency) + const currencyName = getCurrencyName(currency, i18n.language) const handleClick = (): void => { navigate({ to: checkoutNavigationRoutes.selectCash }) @@ -47,7 +49,7 @@ export const FiatCurrencyChip: React.FC = (): JSX.Element => { variant="body2" sx={{ fontSize: 14, fontWeight: 700, lineHeight: '20px' }} > - {currency} · {t(`checkout.fiatCurrency.${currency}`)} + {currency} · {currencyName} diff --git a/packages/widget-checkout/src/hooks/useCheckoutStatusSources.ts b/packages/widget-checkout/src/hooks/useCheckoutStatusSources.ts index 94bdff434..5da39afce 100644 --- a/packages/widget-checkout/src/hooks/useCheckoutStatusSources.ts +++ b/packages/widget-checkout/src/hooks/useCheckoutStatusSources.ts @@ -5,9 +5,15 @@ import { useCheckoutToAddress } from './useCheckoutToAddress.js' import { useFrozenQuote } from './useFrozenQuote.js' import { useResumeRecord } from './useResumeKey.js' +export interface FiatOrigin { + currency: string + amount: string +} + export interface CheckoutStatusSources { frozenRoute: Route | undefined recipientAddress: string | null + fiatOrigin: FiatOrigin | undefined } // The status API reports the solver's addresses for intent/deposit flows, and @@ -20,7 +26,8 @@ export function useCheckoutStatusSources(): CheckoutStatusSources { const { frozen } = useFrozenQuote() const resumeRecord = useResumeRecord() - const frozenRoute = frozen?.route ?? resumeRecord?.frozenQuote?.route + const frozenQuote = frozen ?? resumeRecord?.frozenQuote + const frozenRoute = frozenQuote?.route // Frozen route's toAddress is only a fallback for resumed flows where config is absent. const recipientAddress = useMemo( @@ -28,5 +35,14 @@ export function useCheckoutStatusSources(): CheckoutStatusSources { [configuredToAddress, frozenRoute] ) - return { frozenRoute, recipientAddress } + const fiatOrigin = useMemo(() => { + const currency = frozenQuote?.fiatCurrency + const amount = frozenQuote?.fiatAmount + if (!currency || !amount || !(Number.parseFloat(amount) > 0)) { + return undefined + } + return { currency, amount } + }, [frozenQuote?.fiatCurrency, frozenQuote?.fiatAmount]) + + return { frozenRoute, recipientAddress, fiatOrigin } } diff --git a/packages/widget-checkout/src/hooks/useFrozenQuote.tsx b/packages/widget-checkout/src/hooks/useFrozenQuote.tsx index 0307beba4..a34b1b0a4 100644 --- a/packages/widget-checkout/src/hooks/useFrozenQuote.tsx +++ b/packages/widget-checkout/src/hooks/useFrozenQuote.tsx @@ -18,6 +18,13 @@ export interface FrozenQuote { id: string route: Route expiresAt: number + fiatCurrency?: string + fiatAmount?: string +} + +export interface FrozenQuoteMeta { + fiatCurrency?: string + fiatAmount?: string } interface FrozenQuoteState { @@ -65,7 +72,7 @@ export interface UseFrozenQuote { expired: boolean /** Milliseconds until the frozen quote expires (0 once expired/unset). */ remainingMs: number - freeze: (route: Route) => void + freeze: (route: Route, meta?: FrozenQuoteMeta) => void clear: () => void } @@ -86,11 +93,13 @@ export function useFrozenQuote(): UseFrozenQuote { const remainingMs = frozen ? Math.max(0, frozen.expiresAt - now) : 0 const freeze = useCallback( - (route: Route) => { + (route: Route, meta?: FrozenQuoteMeta) => { setFrozen({ id: route.id, route, expiresAt: Date.now() + FROZEN_QUOTE_TTL_MS, + fiatCurrency: meta?.fiatCurrency, + fiatAmount: meta?.fiatAmount, }) setNow(Date.now()) }, diff --git a/packages/widget-checkout/src/hooks/useOnRampFiatCurrencies.ts b/packages/widget-checkout/src/hooks/useOnRampFiatCurrencies.ts new file mode 100644 index 000000000..dabd11ad2 --- /dev/null +++ b/packages/widget-checkout/src/hooks/useOnRampFiatCurrencies.ts @@ -0,0 +1,115 @@ +'use client' +import { + FormKeyHelper, + useFieldValues, + useWidgetConfig, +} from '@lifi/widget/shared' +import { + type OnrampFiatCurrenciesRequest, + type OnrampFiatCurrenciesResponse, + postCheckoutSession, + useCheckoutConfig, +} from '@lifi/widget-provider/checkout' +import { useQuery } from '@tanstack/react-query' + +export interface OnRampFiatCurrenciesResult { + data: OnrampFiatCurrenciesResponse | undefined + isLoading: boolean + isFetching: boolean + isError: boolean + error: Error | null + refetch: () => void +} + +// The endpoint returns the raw on-ramp provider shape (`fiatCurrencies` keyed +// by `symbol`); normalize to the widget contract here. Prefers `currencies` so +// it keeps working if the backend starts returning the normalized shape. +interface RawOnrampPaymentOption { + id: string + name?: string + isActive?: boolean +} + +interface RawOnrampFiatCurrency { + currency?: string + symbol?: string + paymentOptions?: RawOnrampPaymentOption[] + isAllowed?: boolean +} + +interface RawOnrampFiatCurrenciesResponse { + defaultCurrency?: string + currencies?: RawOnrampFiatCurrency[] + fiatCurrencies?: RawOnrampFiatCurrency[] +} + +function normalizeFiatCurrencies( + raw: RawOnrampFiatCurrenciesResponse +): OnrampFiatCurrenciesResponse { + const list = raw.currencies ?? raw.fiatCurrencies ?? [] + return { + defaultCurrency: raw.defaultCurrency, + currencies: list + .filter((item) => item.isAllowed !== false) + .map((item) => ({ + currency: item.currency ?? item.symbol ?? '', + paymentOptions: (item.paymentOptions ?? []) + .filter((option) => option.isActive !== false) + .map((option) => ({ id: option.id, name: option.name })), + })) + .filter((item) => item.currency), + } +} + +export function useOnRampFiatCurrencies(): OnRampFiatCurrenciesResult { + const [chainId, tokenAddress] = useFieldValues( + FormKeyHelper.getChainKey('from'), + FormKeyHelper.getTokenKey('from') + ) + const { apiUrl, integrator } = useCheckoutConfig() + const { apiKey } = useWidgetConfig() + + const enabled = + Boolean(apiUrl) && + Boolean(apiKey) && + typeof chainId === 'number' && + Boolean(tokenAddress) + + const query = useQuery({ + queryKey: ['onramp-fiat-currencies', integrator, chainId, tokenAddress], + queryFn: async () => { + const response = await postCheckoutSession< + OnrampFiatCurrenciesRequest, + RawOnrampFiatCurrenciesResponse + >({ + baseUrl: apiUrl!, + endpointPath: '/v1/checkout/onramp/fiat-currencies', + apiKey: apiKey!, + integrator, + body: { + chainId: chainId as number, + tokenAddress: tokenAddress as string, + }, + }) + if (!response.ok) { + throw new Error( + `Onramp fiat currencies request failed (${response.status})` + ) + } + return normalizeFiatCurrencies(response.data) + }, + enabled, + staleTime: 24 * 60 * 60 * 1000, + }) + + return { + data: query.data, + isLoading: query.isLoading, + isFetching: query.isFetching, + isError: query.isError, + error: query.error, + refetch: () => { + void query.refetch() + }, + } +} diff --git a/packages/widget-checkout/src/hooks/useOnRampQuote.ts b/packages/widget-checkout/src/hooks/useOnRampQuote.ts new file mode 100644 index 000000000..273fa148d --- /dev/null +++ b/packages/widget-checkout/src/hooks/useOnRampQuote.ts @@ -0,0 +1,108 @@ +'use client' +import { + FormKeyHelper, + useDebouncedWatch, + useFieldValues, + useWidgetConfig, +} from '@lifi/widget/shared' +import { + type OnrampQuoteRequest, + type OnrampQuoteResponse, + postCheckoutSession, + useCheckoutConfig, +} from '@lifi/widget-provider/checkout' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useFiatCurrencyStore } from '../stores/useFiatCurrencyStore.js' +import { normalizeFiatAmount } from '../utils/fiatFormat.js' + +export interface OnRampQuoteResult { + data: OnrampQuoteResponse | undefined + isLoading: boolean + isFetching: boolean + isError: boolean + isReady: boolean + isDebouncePending: boolean + error: Error | null + debouncedFiatAmount: string + refetch: () => void +} + +export function useOnRampQuote(): OnRampQuoteResult { + const [chainId, tokenAddress] = useFieldValues( + FormKeyHelper.getChainKey('from'), + FormKeyHelper.getTokenKey('from') + ) + const [cashFiatAmount] = useFieldValues('cashFiatAmount') + const [debouncedCashFiatAmount] = useDebouncedWatch(350, 'cashFiatAmount') + const { apiUrl, integrator } = useCheckoutConfig() + const { apiKey } = useWidgetConfig() + const fiatCurrency = useFiatCurrencyStore((s) => s.currency) + const paymentMethod = useFiatCurrencyStore((s) => s.paymentMethod) + + const normalizedCashFiatAmount = normalizeFiatAmount(cashFiatAmount) + const debouncedFiatAmount = normalizeFiatAmount(debouncedCashFiatAmount) + const parsedTypedAmount = Number.parseFloat(normalizedCashFiatAmount) + const parsedAmount = Number.parseFloat(debouncedFiatAmount) + const hasCurrentAmount = + Number.isFinite(parsedTypedAmount) && parsedTypedAmount > 0 + const hasValidAmount = Number.isFinite(parsedAmount) && parsedAmount > 0 + const enabled = + hasCurrentAmount && + hasValidAmount && + Boolean(apiUrl) && + Boolean(apiKey) && + typeof chainId === 'number' && + Boolean(tokenAddress) && + Boolean(fiatCurrency) + + const query = useQuery({ + queryKey: [ + 'onramp-quote', + integrator, + chainId, + tokenAddress, + fiatCurrency, + debouncedFiatAmount, + paymentMethod, + ], + queryFn: async () => { + const response = await postCheckoutSession< + OnrampQuoteRequest, + OnrampQuoteResponse + >({ + baseUrl: apiUrl!, + endpointPath: '/v1/checkout/onramp/quote', + apiKey: apiKey!, + integrator, + body: { + chainId: chainId as number, + tokenAddress: tokenAddress as string, + fiatCurrency, + fiatAmount: debouncedFiatAmount, + ...(paymentMethod ? { paymentMethod } : {}), + }, + }) + if (!response.ok) { + throw new Error(`Onramp quote request failed (${response.status})`) + } + return response.data + }, + enabled, + staleTime: 0, + placeholderData: keepPreviousData, + }) + + return { + data: query.data, + isLoading: query.isLoading, + isFetching: query.isFetching, + isError: query.isError, + isReady: Boolean(query.data) && !query.isFetching && !query.isError, + isDebouncePending: normalizedCashFiatAmount !== debouncedFiatAmount, + error: query.error, + debouncedFiatAmount, + refetch: () => { + void query.refetch() + }, + } +} diff --git a/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts b/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts index 9a91120f8..37663659c 100644 --- a/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts +++ b/packages/widget-checkout/src/hooks/usePendingCheckoutWriter.ts @@ -32,6 +32,7 @@ interface CashSuccessWriteArgs { fromChain: number provider: PendingProvider fundingSource: 'cash' | 'exchange' + fundingSessionId?: string frozenQuote?: PersistedFrozenQuote } @@ -151,6 +152,7 @@ export function usePendingCheckoutWriter(): PendingCheckoutWriter { fromChain, provider, fundingSource, + fundingSessionId, frozenQuote, }: CashSuccessWriteArgs) => { if (!enabled) { @@ -165,6 +167,7 @@ export function usePendingCheckoutWriter(): PendingCheckoutWriter { depositAddress, fromChain, provider, + fundingSessionId, frozenQuote, ...displayFields(frozenQuote), status: 'confirmed-no-hash', diff --git a/packages/widget-checkout/src/pages/CheckoutTransactionDetailsPage/CheckoutTransactionDetailsPage.tsx b/packages/widget-checkout/src/pages/CheckoutTransactionDetailsPage/CheckoutTransactionDetailsPage.tsx index a20f0df5a..27d6c90ce 100644 --- a/packages/widget-checkout/src/pages/CheckoutTransactionDetailsPage/CheckoutTransactionDetailsPage.tsx +++ b/packages/widget-checkout/src/pages/CheckoutTransactionDetailsPage/CheckoutTransactionDetailsPage.tsx @@ -17,6 +17,7 @@ import { Box, Button } from '@mui/material' import { useLocation, useNavigate } from '@tanstack/react-router' import { type JSX, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { CheckoutFiatOriginToken } from '../../components/CheckoutFiatOriginToken.js' import { useCheckoutStatusSources } from '../../hooks/useCheckoutStatusSources.js' import { useCheckoutTransactionStatus } from '../../hooks/useCheckoutTransactionStatus.js' import { CheckoutTransactionDetailsSkeleton } from './CheckoutTransactionDetailsSkeleton.js' @@ -43,7 +44,8 @@ export const CheckoutTransactionDetailsPage: React.FC = (): JSX.Element => { : t('checkout.transactionStatus.detailsTitle') ) const { getTransactionLink } = useExplorer() - const { frozenRoute, recipientAddress } = useCheckoutStatusSources() + const { frozenRoute, recipientAddress, fiatOrigin } = + useCheckoutStatusSources() const route = useMemo(() => { if (!status || !tools) { @@ -100,7 +102,17 @@ export const CheckoutTransactionDetailsPage: React.FC = (): JSX.Element => { ) : null} - + + ) : undefined + } + /> { const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) const isTransferFlow = fundingSource === 'transfer' - const { frozenRoute, recipientAddress } = useCheckoutStatusSources() + const { frozenRoute, recipientAddress, fiatOrigin } = + useCheckoutStatusSources() const { status, phase, isLoading, notFound, isError, refetch } = useCheckoutTransactionStatus({ @@ -324,6 +325,7 @@ export const CheckoutTransactionStatusPage: React.FC = (): JSX.Element => { status={undefined} frozenRoute={frozenRoute} recipientAddress={recipientAddress} + fiatOrigin={fiatOrigin} watching /> ) : ( @@ -467,6 +469,7 @@ export const CheckoutTransactionStatusPage: React.FC = (): JSX.Element => { status={undefined} frozenRoute={frozenRoute} recipientAddress={recipientAddress} + fiatOrigin={fiatOrigin} /> ) @@ -478,6 +481,7 @@ export const CheckoutTransactionStatusPage: React.FC = (): JSX.Element => { status={status} frozenRoute={frozenRoute} recipientAddress={recipientAddress} + fiatOrigin={fiatOrigin} /> ) diff --git a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusExecuting.tsx b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusExecuting.tsx index d8143cb4d..bb3682356 100644 --- a/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusExecuting.tsx +++ b/packages/widget-checkout/src/pages/CheckoutTransactionStatusPage/StatusExecuting.tsx @@ -3,12 +3,15 @@ import { Card, RouteTokens } from '@lifi/widget/shared' import { Box, CircularProgress, Stack, Typography } from '@mui/material' import { type JSX, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { CheckoutFiatOriginToken } from '../../components/CheckoutFiatOriginToken.js' +import type { FiatOrigin } from '../../hooks/useCheckoutStatusSources.js' import { StatusStepList } from './StatusStepList.js' interface StatusExecutingProps { status: StatusResponse | undefined frozenRoute?: Route recipientAddress?: string | null + fiatOrigin?: FiatOrigin watching?: boolean } @@ -26,6 +29,7 @@ export function StatusExecuting({ status, frozenRoute, recipientAddress, + fiatOrigin, watching, }: StatusExecutingProps): JSX.Element { const { t } = useTranslation() @@ -113,7 +117,17 @@ export function StatusExecuting({ {frozenRoute ? ( - + + ) : undefined + } + /> ) : null} diff --git a/packages/widget-checkout/src/pages/EnterAmountPage/EnterAmountPage.tsx b/packages/widget-checkout/src/pages/EnterAmountPage/EnterAmountPage.tsx index e4d4556d5..af4ae1bd5 100644 --- a/packages/widget-checkout/src/pages/EnterAmountPage/EnterAmountPage.tsx +++ b/packages/widget-checkout/src/pages/EnterAmountPage/EnterAmountPage.tsx @@ -1,8 +1,12 @@ import { + FormKeyHelper, MainWarningMessages, PageContainer, PoweredBy, + useFieldActions, + useFieldValues, useHeader, + useInputModeStore, useWidgetConfig, } from '@lifi/widget/shared' import { Box } from '@mui/material' @@ -10,6 +14,7 @@ import type { JSX, ReactNode } from 'react' import { useLayoutEffect } from 'react' import { useTranslation } from 'react-i18next' 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' @@ -38,29 +43,53 @@ export const EnterAmountPage: React.FC = (): JSX.Element => { useHeader(t(headerKeyByFlow[fundingSource])) const showPoweredBy = !hiddenUI?.poweredBy const overrideExchanges = useCheckoutExchangesOverride() + const setInputMode = useInputModeStore((s) => s.setInputMode) + const { setFieldValue } = useFieldActions() + const [cashFiatAmount] = useFieldValues('cashFiatAmount') useOnRampPreconnect() - // Safety net: ensure the IF override is applied even when the user lands here - // without going through the SelectSourcePage handlers (deep link / refresh). useLayoutEffect(() => { if (fundingSource !== 'wallet') { overrideExchanges([...INTENT_FACTORY_ONLY]) } }, [fundingSource, overrideExchanges]) + useLayoutEffect(() => { + if (fundingSource !== 'cash') { + return + } + const previousMode = useInputModeStore.getState().inputMode.from + if (previousMode !== 'price') { + setInputMode('from', 'price') + } + return () => { + setInputMode('from', previousMode) + } + }, [fundingSource, setInputMode]) + + const isCashFlow = fundingSource === 'cash' + + useLayoutEffect(() => { + if (isCashFlow && !cashFiatAmount) { + setFieldValue(FormKeyHelper.getAmountKey('from'), '') + } + }, [cashFiatAmount, isCashFlow, setFieldValue]) + let sendSlot: ReactNode | undefined - if (fundingSource === 'cash') { + if (isCashFlow) { sendSlot = } return ( - + : undefined} + /> - - - - {/* Warnings cover wallet balance / gas — only show for wallet flow. */} + {isWalletFunded ? : null} diff --git a/packages/widget-checkout/src/pages/SelectCashCurrencyPage/SelectCashCurrencyPage.tsx b/packages/widget-checkout/src/pages/SelectCashCurrencyPage/SelectCashCurrencyPage.tsx index 7fbb198eb..9f28ee8e7 100644 --- a/packages/widget-checkout/src/pages/SelectCashCurrencyPage/SelectCashCurrencyPage.tsx +++ b/packages/widget-checkout/src/pages/SelectCashCurrencyPage/SelectCashCurrencyPage.tsx @@ -1,47 +1,93 @@ import { + FormKeyHelper, ListItemButton, PageContainer, SearchInput, + useFieldActions, useHeader, } from '@lifi/widget/shared' -import { Avatar, Box, List, ListItemAvatar, ListItemText } from '@mui/material' -import { type ChangeEvent, type JSX, useState } from 'react' +import type { OnrampFiatCurrency } from '@lifi/widget-provider/checkout' +import { + Alert, + Avatar, + Box, + Button, + List, + ListItemAvatar, + ListItemText, + Skeleton, +} from '@mui/material' +import { type ChangeEvent, type JSX, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useCheckoutNavigate } from '../../hooks/useCheckoutNavigate.js' +import { useOnRampFiatCurrencies } from '../../hooks/useOnRampFiatCurrencies.js' import { FIAT_CURRENCIES, - type FiatCurrency, useFiatCurrencyStore, } from '../../stores/useFiatCurrencyStore.js' +import { currencyToFlag } from '../../utils/currencyToFlag.js' +import { getCurrencyName } from '../../utils/fiatFormat.js' import { checkoutNavigationRoutes } from '../../utils/navigationRoutes.js' -const FIAT_CURRENCY_FLAGS: Record = { - USD: '🇺🇸', - EUR: '🇪🇺', - GBP: '🇬🇧', -} - export const SelectCashCurrencyPage: React.FC = (): JSX.Element => { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const navigate = useCheckoutNavigate() + const { data, isLoading, isError, refetch } = useOnRampFiatCurrencies() + const currency = useFiatCurrencyStore((s) => s.currency) const setCurrency = useFiatCurrencyStore((s) => s.setCurrency) + const seedCurrency = useFiatCurrencyStore((s) => s.seedCurrency) + const setPaymentMethod = useFiatCurrencyStore((s) => s.setPaymentMethod) + const { setFieldValue } = useFieldActions() const [search, setSearch] = useState('') useHeader(t('header.currency')) - const handleSelect = (currency: FiatCurrency): void => { - setCurrency(currency) + const currencies = useMemo( + () => + data?.currencies?.length + ? data.currencies + : FIAT_CURRENCIES.map((item) => ({ + currency: item, + paymentOptions: [], + })), + [data?.currencies] + ) + + useEffect(() => { + if (data?.defaultCurrency) { + seedCurrency(data.defaultCurrency) + } + }, [data?.defaultCurrency, seedCurrency]) + + const selectedCurrency = currencies.find((item) => item.currency === currency) + + useEffect(() => { + setPaymentMethod(selectedCurrency?.paymentOptions[0]?.id ?? null) + }, [selectedCurrency?.paymentOptions, setPaymentMethod]) + + const handleSelect = (nextCurrency: string): void => { + if (nextCurrency !== currency) { + setFieldValue('cashFiatAmount', '') + setFieldValue(FormKeyHelper.getAmountKey('from'), '') + setFieldValue(FormKeyHelper.getAmountKey('to'), '') + } + setCurrency(nextCurrency) + const next = currencies.find((item) => item.currency === nextCurrency) + setPaymentMethod(next?.paymentOptions[0]?.id ?? null) navigate({ to: checkoutNavigationRoutes.enterAmount }) } const normalizedSearch = search.trim().toLowerCase() - const filtered = FIAT_CURRENCIES.filter((c) => { + const filtered = currencies.filter(({ currency: itemCurrency }) => { if (!normalizedSearch) { return true } - const description = t(`checkout.fiatCurrency.${c}`).toLowerCase() + const description = getCurrencyName( + itemCurrency, + i18n.language + ).toLowerCase() return ( - c.toLowerCase().includes(normalizedSearch) || + itemCurrency.toLowerCase().includes(normalizedSearch) || description.includes(normalizedSearch) ) }) @@ -60,25 +106,53 @@ export const SelectCashCurrencyPage: React.FC = (): JSX.Element => { /> - {filtered.map((c) => ( - handleSelect(c)} - sx={{ height: 60, marginBottom: '4px' }} + {isLoading + ? Array.from({ length: 3 }).map((_, index) => ( + + + + + + + + )) + : null} + {isError ? ( + void refetch()}> + {t('button.tryAgain')} + + } + sx={{ mb: 1 }} > - - - {FIAT_CURRENCY_FLAGS[c]} - - - - - ))} + {t('checkout.cashCurrency.error')} + + ) : null} + {!isLoading && + filtered.map((c) => ( + handleSelect(c.currency)} + sx={{ height: 60, marginBottom: '4px' }} + > + + + {currencyToFlag(c.currency)} + + + + + ))} ) diff --git a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx index e5e6d045f..072eaa0e3 100644 --- a/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx +++ b/packages/widget-checkout/src/pages/SelectSourcePage/SelectSourcePage.tsx @@ -34,6 +34,7 @@ import { useOnRampSessionByCategory, } from '../../providers/OnRampProvider/OnRampProvider.js' import { useCheckoutFlowStore } from '../../stores/useCheckoutFlowStore.js' +import { useFiatCurrencyStore } from '../../stores/useFiatCurrencyStore.js' import { DEFAULT_FROM_CHAIN_ID, DEFAULT_FROM_TOKEN_ADDRESS, @@ -59,6 +60,7 @@ export const SelectSourcePage: React.FC = () => { (s) => s.setSelectedExchangeAccount ) const resetFlow = useCheckoutFlowStore((s) => s.reset) + const resetFiat = useFiatCurrencyStore((s) => s.reset) const overrideExchanges = useCheckoutExchangesOverride() const { setFieldValue } = useFieldActions() const { integrator } = useCheckoutConfig() @@ -70,7 +72,6 @@ export const SelectSourcePage: React.FC = () => { (s) => s.removeAccount ) - // Capture fundingSource before resetFlow fires (runs after first render). const fundingSource = useCheckoutFlowStore((s) => s.fundingSource) const wasExchangeFlow = fundingSource === 'exchange' @@ -144,16 +145,12 @@ export const SelectSourcePage: React.FC = () => { const prev = prevHasWalletConnectedRef.current prevHasWalletConnectedRef.current = hasWalletConnected - // After opening the wallet menu from this screen, skip staying on funding source — - // go straight to token selection once the wallet connects. if (prev === false && hasWalletConnected) { goToToken() } }, [hasWalletConnected, goToToken]) const handlePayFromWallet = useCallback(() => { - // The wallet flow pays directly from the connected wallet, so it keeps the - // integrator's full route set — no IF-only override (unlike the deposit flows). setFundingSource('wallet') if (hasWalletConnected) { goToToken() @@ -165,7 +162,6 @@ export const SelectSourcePage: React.FC = () => { const handleTransferCrypto = useCallback(() => { overrideExchanges([...INTENT_FACTORY_ONLY]) setFundingSource('transfer') - // IF deposits can't accept the native gas token; reset a carried-over pick. if (isNativeToken(prevTokenAddress)) { setFieldValue(FormKeyHelper.getChainKey('from'), DEFAULT_FROM_CHAIN_ID) setFieldValue( @@ -186,9 +182,6 @@ export const SelectSourcePage: React.FC = () => { const pinExchangeSource = useCallback(() => { overrideExchanges([...INTENT_FACTORY_ONLY]) setFundingSource('exchange') - // Exchange deposits are limited to USDC/USDT/ETH on mainnet — pin the - // from-chain and seed a valid mainnet token so a stale non-mainnet - // selection doesn't leak into the curated token list, balance, or quote. setFieldValue(FormKeyHelper.getChainKey('from'), DEFAULT_FROM_CHAIN_ID) setFieldValue(FormKeyHelper.getTokenKey('from'), DEFAULT_FROM_TOKEN_ADDRESS) setFieldValue(FormKeyHelper.getAmountKey('from'), '') @@ -202,7 +195,6 @@ export const SelectSourcePage: React.FC = () => { const handleReuseExchange = useCallback( (account: ConnectedCexAccount) => { - // ConnectedCexAccount is a superset of OnRampAccessToken — pass through. setSelectedExchangeAccount(account) pinExchangeSource() goToToken() @@ -223,15 +215,14 @@ export const SelectSourcePage: React.FC = () => { const handleDepositCash = useCallback(() => { overrideExchanges([...INTENT_FACTORY_ONLY]) setFundingSource('cash') - // Cash deposits aren't wallet-funded, so seed the from-token to USDC on - // mainnet — otherwise the form keeps the prior wallet/transfer selection - // (default ETH) and the quote, balance, and Transak session all run - // against the wrong token. + resetFiat() setFieldValue(FormKeyHelper.getChainKey('from'), DEFAULT_FROM_CHAIN_ID) setFieldValue(FormKeyHelper.getTokenKey('from'), DEFAULT_FROM_TOKEN_ADDRESS) setFieldValue(FormKeyHelper.getAmountKey('from'), '') + setFieldValue(FormKeyHelper.getAmountKey('to'), '') + setFieldValue('cashFiatAmount', '') navigate({ to: checkoutNavigationRoutes.selectCash }) - }, [navigate, overrideExchanges, setFieldValue, setFundingSource]) + }, [navigate, overrideExchanges, resetFiat, setFieldValue, setFundingSource]) const payFromWalletIcons = useMemo( () => diff --git a/packages/widget-checkout/src/providers/OnRampProvider/OnRampProvider.tsx b/packages/widget-checkout/src/providers/OnRampProvider/OnRampProvider.tsx index 72561fffc..a5b269be6 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 { depositTxHash: string | null acknowledgeDepositTxHash: () => void resolvedDepositAddress: string | null + fundingSessionId: string | null providerName: string isOpen: boolean isLoading: boolean @@ -84,6 +85,7 @@ export function useActiveOnRampDeposit(): ActiveOnRampDeposit | null { depositTxHash: session.depositTxHash, acknowledgeDepositTxHash: session.acknowledgeDepositTxHash, resolvedDepositAddress: session.resolvedDepositAddress, + fundingSessionId: session.fundingSessionId, providerName: provider.name, isOpen: session.isOpen, isLoading: session.isLoading, diff --git a/packages/widget-checkout/src/providers/PendingCheckoutPersistenceBridge.tsx b/packages/widget-checkout/src/providers/PendingCheckoutPersistenceBridge.tsx index 3b1e21af7..7f443b453 100644 --- a/packages/widget-checkout/src/providers/PendingCheckoutPersistenceBridge.tsx +++ b/packages/widget-checkout/src/providers/PendingCheckoutPersistenceBridge.tsx @@ -38,6 +38,7 @@ export function PendingCheckoutPersistenceBridge({ fromChain, provider, fundingSource, + fundingSessionId: result.fundingSessionId, frozenQuote: frozen ?? undefined, }) } diff --git a/packages/widget-checkout/src/stores/useFiatCurrencyStore.tsx b/packages/widget-checkout/src/stores/useFiatCurrencyStore.tsx index f6a2348c7..4fc4e08fd 100644 --- a/packages/widget-checkout/src/stores/useFiatCurrencyStore.tsx +++ b/packages/widget-checkout/src/stores/useFiatCurrencyStore.tsx @@ -8,13 +8,18 @@ import { import type { StoreApi, UseBoundStore } from 'zustand' import { create } from 'zustand' -export type FiatCurrency = 'USD' | 'EUR' | 'GBP' +export type FiatCurrency = string export const FIAT_CURRENCIES: readonly FiatCurrency[] = ['USD', 'EUR', 'GBP'] export interface FiatCurrencyState { currency: FiatCurrency + paymentMethod: string | null + currencyTouched: boolean setCurrency: (currency: FiatCurrency) => void + setPaymentMethod: (paymentMethod: string | null) => void + seedCurrency: (currency: FiatCurrency) => void + reset: () => void } type FiatCurrencyStore = UseBoundStore> @@ -22,7 +27,25 @@ type FiatCurrencyStore = UseBoundStore> export function createFiatCurrencyStore(): FiatCurrencyStore { return create((set) => ({ currency: 'USD', - setCurrency: (currency) => set({ currency }), + paymentMethod: null, + currencyTouched: false, + setCurrency: (currency) => + set((state) => + state.currency === currency + ? { currencyTouched: true } + : { currency, paymentMethod: null, currencyTouched: true } + ), + setPaymentMethod: (paymentMethod) => set({ paymentMethod }), + seedCurrency: (currency) => + set((state) => + state.currencyTouched ? state : { currency, paymentMethod: null } + ), + reset: () => + set({ + currency: 'USD', + paymentMethod: null, + currencyTouched: false, + }), })) } diff --git a/packages/widget-checkout/src/stores/usePendingCheckoutStore.ts b/packages/widget-checkout/src/stores/usePendingCheckoutStore.ts index e263ea46c..59d5a54c7 100644 --- a/packages/widget-checkout/src/stores/usePendingCheckoutStore.ts +++ b/packages/widget-checkout/src/stores/usePendingCheckoutStore.ts @@ -3,7 +3,7 @@ import type { Route } from '@lifi/sdk' import { create, type StoreApi, type UseBoundStore } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' -export const PENDING_RECORD_VERSION = 4 +export const PENDING_RECORD_VERSION = 5 export const PENDING_STORAGE_KEY = 'lifi-checkout-pending' export const PENDING_TTL_MS: number = 24 * 60 * 60 * 1000 @@ -15,6 +15,8 @@ export interface PersistedFrozenQuote { id: string route: Route expiresAt: number + fiatCurrency?: string + fiatAmount?: string } export interface PendingRecord { @@ -28,6 +30,7 @@ export interface PendingRecord { depositAddress?: string fromChain?: number provider?: PendingProvider + fundingSessionId?: string frozenRouteId?: string frozenQuote?: PersistedFrozenQuote // Display fields, written so the activity card renders without the quote. @@ -141,6 +144,7 @@ export function buildPendingRecord( depositAddress: partial.depositAddress, fromChain: partial.fromChain, provider: partial.provider, + fundingSessionId: partial.fundingSessionId, frozenRouteId: partial.frozenRouteId, frozenQuote: partial.frozenQuote, fromAmount: partial.fromAmount, diff --git a/packages/widget-checkout/src/utils/currencyToFlag.ts b/packages/widget-checkout/src/utils/currencyToFlag.ts new file mode 100644 index 000000000..690ac16ff --- /dev/null +++ b/packages/widget-checkout/src/utils/currencyToFlag.ts @@ -0,0 +1,22 @@ +const A_CODE_POINT = 'A'.charCodeAt(0) +const REGIONAL_INDICATOR_A = 0x1f1e6 + +const CURRENCY_REGION_OVERRIDES: Record = { + EUR: 'EU', + USD: 'US', + GBP: 'GB', +} + +export function currencyToFlag(currency: string): string { + const upper = currency.toUpperCase() + const region = CURRENCY_REGION_OVERRIDES[upper] ?? upper.slice(0, 2) + if (!/^[A-Z]{2}$/.test(region)) { + return '💱' + } + const chars = [...region].map((char) => + String.fromCodePoint( + REGIONAL_INDICATOR_A + (char.charCodeAt(0) - A_CODE_POINT) + ) + ) + return chars.join('') +} diff --git a/packages/widget-checkout/src/utils/fiatFormat.ts b/packages/widget-checkout/src/utils/fiatFormat.ts new file mode 100644 index 000000000..a2fd01604 --- /dev/null +++ b/packages/widget-checkout/src/utils/fiatFormat.ts @@ -0,0 +1,83 @@ +export function normalizeFiatAmount(input: string): string { + return input.trim().replace(',', '.') +} + +const numberFormatCache = new Map() +const symbolCache = new Map() +const displayNamesCache = new Map() + +function getNumberFormat( + language: string, + currency: string +): Intl.NumberFormat { + const key = `${language}:${currency}` + let formatter = numberFormatCache.get(key) + if (!formatter) { + formatter = new Intl.NumberFormat(language, { + style: 'currency', + currency, + maximumFractionDigits: 2, + }) + numberFormatCache.set(key, formatter) + } + return formatter +} + +export function formatFiat( + value: string, + currency: string, + language: string +): string { + const parsed = Number.parseFloat(value) + if (!Number.isFinite(parsed)) { + return `${value} ${currency}` + } + try { + return getNumberFormat(language, currency).format(parsed) + } catch { + return `${value} ${currency}` + } +} + +export function getCurrencySymbol(currency: string, language: string): string { + const key = `${language}:${currency}` + const cached = symbolCache.get(key) + if (cached !== undefined) { + return cached + } + let symbol = currency + try { + symbol = + new Intl.NumberFormat(language, { + style: 'currency', + currency, + currencyDisplay: 'narrowSymbol', + }) + .formatToParts(0) + .find((part) => part.type === 'currency')?.value ?? currency + } catch { + symbol = currency + } + symbolCache.set(key, symbol) + return symbol +} + +export function getCurrencyName(currency: string, language: string): string { + let displayNames = displayNamesCache.get(language) + if (displayNames === undefined) { + try { + displayNames = new Intl.DisplayNames(language, { type: 'currency' }) + } catch { + displayNames = null + } + displayNamesCache.set(language, displayNames) + } + if (!displayNames) { + return currency + } + try { + return displayNames.of(currency) ?? currency + } catch { + return currency + } +} diff --git a/packages/widget-provider-mesh/src/MeshHost.tsx b/packages/widget-provider-mesh/src/MeshHost.tsx index 20f2686fb..419b522b7 100644 --- a/packages/widget-provider-mesh/src/MeshHost.tsx +++ b/packages/widget-provider-mesh/src/MeshHost.tsx @@ -390,6 +390,7 @@ export const MeshHost: FC = ({ widgetConfig }) => { depositTxHash, acknowledgeDepositTxHash, resolvedDepositAddress: null, + fundingSessionId: null, mountTargetId: null, }), [ diff --git a/packages/widget-provider-transak/src/TransakHost.tsx b/packages/widget-provider-transak/src/TransakHost.tsx index 3b3693685..9ed3b6b5e 100644 --- a/packages/widget-provider-transak/src/TransakHost.tsx +++ b/packages/widget-provider-transak/src/TransakHost.tsx @@ -62,6 +62,7 @@ export const TransakHost: FC = ({ widgetConfig }) => { const [resolvedDepositAddress, setResolvedDepositAddress] = useState< string | null >(null) + const [fundingSessionId, setFundingSessionId] = useState(null) // `useId()` can return ids with colons (e.g. `:r0:`); Transak's SDK looks // the container up via `#${id}` selector and throws on those. const reactId = useId() @@ -84,6 +85,7 @@ export const TransakHost: FC = ({ widgetConfig }) => { setFailure(null) setWidgetUrl(null) setResolvedDepositAddress(null) + setFundingSessionId(null) }, []) const openDepositFlow = useCallback( @@ -95,6 +97,7 @@ export const TransakHost: FC = ({ widgetConfig }) => { setFailure(null) setWidgetUrl(null) setResolvedDepositAddress(null) + setFundingSessionId(null) setIsLoading(true) if (!apiUrl) { @@ -136,6 +139,7 @@ export const TransakHost: FC = ({ widgetConfig }) => { ? { fiatAmount: args.fiatAmount } : { amount: args.amount }), fiatCurrency: args.fiatCurrency, + ...(args.paymentMethod ? { paymentMethod: args.paymentMethod } : {}), } const canRetryWithNativeEth = @@ -212,8 +216,12 @@ export const TransakHost: FC = ({ widgetConfig }) => { return } - debug('session received', { widgetUrl: res.data.widgetUrl }) + debug('session received', { + widgetUrl: res.data.widgetUrl, + fundingSessionId: res.data.fundingSessionId, + }) setWidgetUrl(res.data.widgetUrl) + setFundingSessionId(res.data.fundingSessionId ?? null) } catch (e) { const message = e instanceof Error @@ -260,12 +268,14 @@ export const TransakHost: FC = ({ widgetConfig }) => { const closeRef = useRef(close) const cancelRef = useRef(cancel) const openDepositFlowRef = useRef(openDepositFlow) + const fundingSessionIdRef = useRef(fundingSessionId) useEffect(() => { onErrorRef.current = onError closeRef.current = close cancelRef.current = cancel openDepositFlowRef.current = openDepositFlow - }, [onError, close, cancel, openDepositFlow]) + fundingSessionIdRef.current = fundingSessionId + }, [onError, close, cancel, openDepositFlow, fundingSessionId]) useEffect(() => { if (!open || !widgetUrl || isLoading) { @@ -323,6 +333,11 @@ export const TransakHost: FC = ({ widgetConfig }) => { if (orderWalletAddress) { setResolvedDepositAddress(orderWalletAddress) } + // Prefer the resumed order's partnerOrderId so reconciliation matches it. + const orderFundingSessionId = + typeof order.partnerOrderId === 'string' && order.partnerOrderId + ? order.partnerOrderId + : fundingSessionIdRef.current debug('order extracted', { orderId, @@ -344,6 +359,7 @@ export const TransakHost: FC = ({ widgetConfig }) => { order.chainId ?? order.networkId ?? lastArgs?.fromChainId ?? 0 ), depositAddress: orderWalletAddress ?? undefined, + fundingSessionId: orderFundingSessionId ?? undefined, }) debug('closing modal (preserving depositTxHash)') @@ -433,6 +449,7 @@ export const TransakHost: FC = ({ widgetConfig }) => { depositTxHash: null, acknowledgeDepositTxHash: () => {}, resolvedDepositAddress, + fundingSessionId, mountTargetId, }), [ @@ -445,6 +462,7 @@ export const TransakHost: FC = ({ widgetConfig }) => { open, openDepositFlow, resolvedDepositAddress, + fundingSessionId, ] ) diff --git a/packages/widget-provider/src/checkout/api.ts b/packages/widget-provider/src/checkout/api.ts index 742902041..12b4266ef 100644 --- a/packages/widget-provider/src/checkout/api.ts +++ b/packages/widget-provider/src/checkout/api.ts @@ -5,12 +5,65 @@ export interface OnrampSessionRequest { chainId: number integrator: string amount?: string - fiatCurrency?: 'USD' | 'EUR' | 'GBP' + fiatCurrency?: string fiatAmount?: string + paymentMethod?: string + countryCode?: string } export interface OnrampSessionResponse { widgetUrl: string + fundingSessionId?: string +} + +export interface OnrampFiatCurrenciesRequest { + tokenAddress: string + chainId: number + countryCode?: string +} + +export interface OnrampPaymentOption { + id: string + name?: string +} + +export interface OnrampFiatCurrency { + currency: string + paymentOptions: OnrampPaymentOption[] +} + +export interface OnrampFiatCurrenciesResponse { + defaultCurrency?: string + currencies: OnrampFiatCurrency[] +} + +export interface OnrampQuoteRequest { + tokenAddress: string + chainId: number + fiatCurrency: string + fiatAmount: string + paymentMethod?: string + countryCode?: string +} + +export interface OnrampQuoteFee { + name?: string + label?: string + type?: string + amount: string +} + +export interface OnrampQuoteResponse { + fiat: { amount: string; currency: string } + funding: { estimatedAmount: string; symbol: string; decimals: number } + fees: { + currency: string + total: { amount: string } + breakdown?: OnrampQuoteFee[] + } + warnings?: string[] + paymentMethod?: string + provider: 'TRANSAK' } /** Body for `POST /v1/checkout/cex/session` (Mesh CEX funding). */ diff --git a/packages/widget-provider/src/checkout/contexts/OnRampSessionsContext.test.ts b/packages/widget-provider/src/checkout/contexts/OnRampSessionsContext.test.ts index b94bb869f..19dd51d4c 100644 --- a/packages/widget-provider/src/checkout/contexts/OnRampSessionsContext.test.ts +++ b/packages/widget-provider/src/checkout/contexts/OnRampSessionsContext.test.ts @@ -16,6 +16,7 @@ function makeSession(): OnRampSession { depositTxHash: null, acknowledgeDepositTxHash: noop, resolvedDepositAddress: null, + fundingSessionId: null, mountTargetId: null, } } diff --git a/packages/widget-provider/src/checkout/index.ts b/packages/widget-provider/src/checkout/index.ts index fb4c164db..cf3b4cdce 100644 --- a/packages/widget-provider/src/checkout/index.ts +++ b/packages/widget-provider/src/checkout/index.ts @@ -2,6 +2,13 @@ export type { CexSessionRequest, CexSessionResponse, CheckoutSessionApiError, + OnrampFiatCurrenciesRequest, + OnrampFiatCurrenciesResponse, + OnrampFiatCurrency, + OnrampPaymentOption, + OnrampQuoteFee, + OnrampQuoteRequest, + OnrampQuoteResponse, OnrampSessionRequest, OnrampSessionResponse, } from './api.js' diff --git a/packages/widget-provider/src/checkout/types.ts b/packages/widget-provider/src/checkout/types.ts index 747ef2d93..3dc81c2d2 100644 --- a/packages/widget-provider/src/checkout/types.ts +++ b/packages/widget-provider/src/checkout/types.ts @@ -90,6 +90,7 @@ export interface OnRampSession { * the user resumes a prior pending order (Transak). `null` until reported. */ resolvedDepositAddress: string | null + fundingSessionId: string | null /** * DOM element id the provider's SDK targets. The widget renders a * `
` inside its hosted modal when this is set. @@ -149,7 +150,9 @@ export interface OnRampAccessToken { export interface OnRampOpenArgs { depositAddress: string amount: string - fiatCurrency?: 'USD' | 'EUR' | 'GBP' + fiatCurrency?: string + paymentMethod?: string + countryCode?: string /** * Previously-connected exchange accounts to resume. When set, the provider * (Mesh) re-enters its flow at the asset/transfer step instead of the catalog @@ -219,6 +222,7 @@ export interface CheckoutResult { chainId: number /** Address the resolved order funds, when the provider reports one (Transak). */ depositAddress?: string + fundingSessionId?: string } export interface CheckoutError { diff --git a/packages/widget-provider/src/checkout/utils/sessionClient.ts b/packages/widget-provider/src/checkout/utils/sessionClient.ts index 7ef91f8c5..dae2d1d84 100644 --- a/packages/widget-provider/src/checkout/utils/sessionClient.ts +++ b/packages/widget-provider/src/checkout/utils/sessionClient.ts @@ -2,7 +2,11 @@ import type { CheckoutSessionApiError } from '../api.js' export interface CheckoutSessionRequestArgs { baseUrl: string - endpointPath: '/v1/checkout/onramp/session' | '/v1/checkout/cex/session' + endpointPath: + | '/v1/checkout/onramp/session' + | '/v1/checkout/cex/session' + | '/v1/checkout/onramp/fiat-currencies' + | '/v1/checkout/onramp/quote' apiKey: string integrator?: string body: TBody diff --git a/packages/widget/src/components/RouteCard/RouteTokens.tsx b/packages/widget/src/components/RouteCard/RouteTokens.tsx index 70d3cb226..f16c7856e 100644 --- a/packages/widget/src/components/RouteCard/RouteTokens.tsx +++ b/packages/widget/src/components/RouteCard/RouteTokens.tsx @@ -9,7 +9,8 @@ export const RouteTokens: React.FC<{ route: RouteExtended showEssentials?: boolean defaultExpanded?: boolean -}> = ({ route, showEssentials, defaultExpanded }) => { + fromSlot?: React.ReactNode +}> = ({ route, showEssentials, defaultExpanded, fromSlot }) => { const { mode } = useWidgetConfig() const fromToken = { @@ -30,7 +31,7 @@ export const RouteTokens: React.FC<{ return ( - {fromToken ? : null} + {fromSlot ?? (fromToken ? : null)}