Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bright-rivers-queue.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ next-env.d.ts
.claude/worktrees/
.omc/
.cursor/
.codex/
.agents/
AGENTS.md
159 changes: 159 additions & 0 deletions packages/widget-checkout/src/components/CashHandoffSheet.tsx
Original file line number Diff line number Diff line change
@@ -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<CashHandoffSheetProps> = ({
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 (
<Drawer
container={container ?? undefined}
anchor="bottom"
open={drawerOpen}
onClose={handleClose}
ModalProps={{ sx: modalSx }}
slotProps={{
paper: {
sx: paperSx,
'aria-labelledby': titleId,
'aria-describedby': bodyId,
},
backdrop: { sx: backdropSx },
}}
disableAutoFocus
inert={isInert}
>
<Box sx={{ px: 3, pt: 3, pb: 2 }}>
<Typography id={titleId} variant="h6" sx={{ mb: 1, fontWeight: 700 }}>
{t('checkout.cashHandoff.title')}
</Typography>
<Typography
id={bodyId}
variant="body2"
color="text.secondary"
sx={{ fontSize: 12 }}
>
{t('checkout.cashHandoff.body')}
</Typography>
<Box
sx={{
mt: 2,
p: 2,
borderRadius: 1.5,
bgcolor: 'background.paper',
}}
>
<Typography
sx={{ fontSize: 12, fontWeight: 600, color: 'text.secondary' }}
>
{t('checkout.cashHandoff.addressLabel')}
</Typography>
<Typography
sx={{
fontSize: 12,
fontWeight: 700,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
mt: 0.5,
mb: 1,
}}
>
{depositAddress}
</Typography>
<Typography sx={{ fontSize: 12, color: 'text.secondary' }}>
{t('checkout.cashHandoff.addressHint')}
</Typography>
</Box>
<Stack spacing={1} sx={{ mt: 3 }}>
<Button variant="contained" autoFocus fullWidth onClick={onContinue}>
{t('checkout.cashHandoff.continue')}
</Button>
<Button
variant="text"
fullWidth
onClick={onGoBack}
sx={{
color: 'text.primary',
fontWeight: 700,
bgcolor: 'transparent',
'&:hover': { bgcolor: 'action.hover' },
}}
>
{t('checkout.cashHandoff.goBack')}
</Button>
</Stack>
</Box>
</Drawer>
)
}
102 changes: 81 additions & 21 deletions packages/widget-checkout/src/components/CheckoutAmountInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -45,11 +48,13 @@ const CheckoutInputCard: React.FC<ComponentProps<typeof InputCard>> = styled(
export type CheckoutAmountInputProps = FormTypeProps &
CardProps & {
sendSlot?: ReactNode
presetsSlot?: ReactNode
}

export const CheckoutAmountInput: React.FC<CheckoutAmountInputProps> = ({
formType,
sendSlot,
presetsSlot,
...props
}) => {
const { disabledUI } = useWidgetConfig()
Expand All @@ -69,6 +74,7 @@ export const CheckoutAmountInput: React.FC<CheckoutAmountInputProps> = ({
bottomAdornment={<CheckoutPriceFormHelperText formType={formType} />}
disabled={disabled}
sendSlot={sendSlot}
presetsSlot={presetsSlot}
{...props}
/>
)
Expand All @@ -82,6 +88,7 @@ const CheckoutAmountInputBase: React.FC<
bottomAdornment?: ReactNode
disabled?: boolean
sendSlot?: ReactNode
presetsSlot?: ReactNode
}
> = ({
formType,
Expand All @@ -90,8 +97,10 @@ const CheckoutAmountInputBase: React.FC<
bottomAdornment,
disabled,
sendSlot,
presetsSlot,
...props
}) => {
const { i18n } = useTranslation()
const ref = useRef<HTMLInputElement>(null)

const isEditingRef = useRef(false)
Expand All @@ -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<HTMLInputElement | HTMLTextAreaElement>
) => {
Expand All @@ -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, {
Expand All @@ -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, {
Expand Down Expand Up @@ -207,13 +252,17 @@ const CheckoutAmountInputBase: React.FC<
</Box>
) : null}
</Box>
<Box sx={{ px: 2, flex: 1, display: 'flex', alignItems: 'center' }}>
<Box
sx={{ px: 2, pt: 1.5, flex: 1, display: 'flex', alignItems: 'center' }}
>
<FormControl fullWidth>
<Input
inputRef={ref}
size="small"
autoComplete="off"
placeholder={currentInputMode === 'price' ? '$0' : '0'}
placeholder={
currentInputMode === 'price' ? `${currencyPrefix}0` : '0'
}
startAdornment={startAdornment}
inputProps={{
inputMode: 'decimal',
Expand All @@ -223,7 +272,7 @@ const CheckoutAmountInputBase: React.FC<
value={
currentInputMode === 'price'
? displayValue
? `$${displayValue}`
? `${currencyPrefix}${displayValue}`
: ''
: displayValue
}
Expand All @@ -243,7 +292,18 @@ const CheckoutAmountInputBase: React.FC<
/>
</FormControl>
</Box>
<Box sx={{ px: 2, pb: 2 }}>{bottomAdornment}</Box>
<Box
sx={{
px: 2,
pt: presetsSlot ? 1.5 : 0.5,
pb: presetsSlot ? 0 : 2,
}}
>
{bottomAdornment}
</Box>
{presetsSlot ? (
<Box sx={{ px: 2, pt: 1.5, pb: 2 }}>{presetsSlot}</Box>
) : null}
</CheckoutInputCard>
)
}
Expand Down
Loading