Skip to content
Merged
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
61 changes: 56 additions & 5 deletions src/components/Common/ActionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { useAuth } from '@/context/authContext'
import { EInviteType } from '@/services/services.types'
import ConfirmInviteModal from '../Global/ConfirmInviteModal'
import Loading from '../Global/Loading'
import { useWallet } from '@/hooks/wallet/useWallet'
import { ActionListCard } from '../ActionListCard'
import { useGeoFilteredPaymentOptions } from '@/hooks/useGeoFilteredPaymentOptions'

Expand Down Expand Up @@ -63,6 +64,7 @@ export default function ActionList({
setClaimToMercadoPago,
setRegionalMethodType,
} = useClaimBankFlow()
const { balance } = useWallet()
const [showMinAmountError, setShowMinAmountError] = useState(false)
const { claimType } = useDetermineBankClaimType(claimLinkData?.sender?.userId ?? '')
const { chargeDetails } = usePaymentStore()
Expand All @@ -77,11 +79,14 @@ export default function ActionList({
setFlowStep: setRequestFulfilmentBankFlowStep,
setFulfillUsingManteca,
setRegionalMethodType: setRequestFulfillmentRegionalMethodType,
setTriggerPayWithPeanut,
} = useRequestFulfillmentFlow()
const [isGuestVerificationModalOpen, setIsGuestVerificationModalOpen] = useState(false)
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null)
const [showInviteModal, setShowInviteModal] = useState(false)
const { user } = useAuth()
const [isUsePeanutBalanceModalShown, setIsUsePeanutBalanceModalShown] = useState(false)
const [showUsePeanutBalanceModal, setShowUsePeanutBalanceModal] = useState(false)

const dispatch = useAppDispatch()

Expand All @@ -101,6 +106,10 @@ export default function ActionList({
isMethodUnavailable: (method) => method.soon || (method.id === 'bank' && requiresVerification),
})

// Check if user has enough Peanut balance to pay for the request
const amountInUsd = usdAmount ? parseFloat(usdAmount) : 0
const hasSufficientPeanutBalance = user && balance && Number(balance) >= amountInUsd

const handleMethodClick = async (method: PaymentMethod) => {
if (flow === 'claim' && claimLinkData) {
const amountInUsd = parseFloat(formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals))
Expand Down Expand Up @@ -138,11 +147,15 @@ export default function ActionList({
break
}
} else if (flow === 'request' && requestLinkData) {
const amountInUsd = usdAmount ? parseFloat(usdAmount) : 0
if (method.id === 'bank' && amountInUsd < 1) {
setShowMinAmountError(true)
return
}

if (!isUsePeanutBalanceModalShown && hasSufficientPeanutBalance) {
setShowUsePeanutBalanceModal(true)
return
}
switch (method.id) {
case 'bank':
if (requestType === BankRequestType.GuestKycNeeded) {
Expand Down Expand Up @@ -222,12 +235,24 @@ export default function ActionList({
<div className="space-y-2">
{sortedActionMethods.map((method) => {
if (flow === 'request' && method.id === 'exchange-or-wallet') {
const shouldShowPeanutBalanceModal = !isUsePeanutBalanceModalShown && hasSufficientPeanutBalance
return (
<ActionListDaimoPayButton
handleContinueWithPeanut={handleContinueWithPeanut}
<div
key={method.id}
showConfirmModal={isInviteLink && !userHasAppAccess}
/>
onClick={() => {
if (shouldShowPeanutBalanceModal) {
setShowUsePeanutBalanceModal(true)
}
}}
>
{/* Disable daimo pay button if peanut balance is enough to pay for the request */}
<div style={{ pointerEvents: shouldShowPeanutBalanceModal ? 'none' : 'auto' }}>
<ActionListDaimoPayButton
handleContinueWithPeanut={handleContinueWithPeanut}
showConfirmModal={isInviteLink && !userHasAppAccess}
/>
</div>
</div>
)
}

Expand Down Expand Up @@ -285,6 +310,32 @@ export default function ActionList({
setSelectedMethod(null)
}}
/>

<ActionModal
visible={showUsePeanutBalanceModal}
onClose={() => {
setShowUsePeanutBalanceModal(false)
setIsUsePeanutBalanceModalShown(true)
}}
title="Use your Peanut balance instead"
description={
'You already have enough funds in your Peanut account. Using this method is instant and avoids delays.'
}
icon="user-plus"
ctas={[
{
text: 'Pay with Peanut',
shadowSize: '4',
onClick: () => {
setShowUsePeanutBalanceModal(false)
setTriggerPayWithPeanut(true)
},
},
]}
iconContainerClassName="bg-primary-1"
preventClose={false}
modalPanelClassName="max-w-md mx-8"
/>
</div>
)
}
Expand Down
6 changes: 3 additions & 3 deletions src/components/Global/Slider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ function Slider({
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
// Use internal state for the slider value to enable magnetic snapping
const [internalValue, setInternalValue] = React.useState<number[]>(controlledValue || defaultValue)
const [internalValue, setInternalValue] = React.useState<number[]>(defaultValue || controlledValue)

// Sync with controlled value if it changes externally
// Sync internal state when controlled value changes from external source
React.useEffect(() => {
if (controlledValue) {
if (controlledValue !== undefined && controlledValue[0] !== internalValue[0]) {
setInternalValue(controlledValue)
}
}, [controlledValue])
Expand Down
99 changes: 70 additions & 29 deletions src/components/Global/TokenAmountInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
'use client'

import { PEANUT_WALLET_TOKEN_DECIMALS, STABLE_COINS } from '@/constants'
import { tokenSelectorContext } from '@/context'
import { formatAmountWithoutComma, formatTokenAmount, formatCurrency } from '@/utils'
import { formatTokenAmount, formatCurrency } from '@/utils'
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import Icon from '../Icon'
import { twMerge } from 'tailwind-merge'
import { Icon as IconComponent } from '@/components/Global/Icons/Icon'
import { Slider } from '../Slider'
import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'

interface TokenAmountInputProps {
className?: string
Expand All @@ -29,6 +32,8 @@ interface TokenAmountInputProps {
showSlider?: boolean
maxAmount?: number
isInitialInputUsd?: boolean
defaultSliderValue?: number
defaultSliderSuggestedAmount?: number
}

const TokenAmountInput = ({
Expand All @@ -49,10 +54,16 @@ const TokenAmountInput = ({
showSlider = false,
maxAmount,
isInitialInputUsd = false,
defaultSliderValue,
defaultSliderSuggestedAmount,
}: TokenAmountInputProps) => {
const { selectedTokenData } = useContext(tokenSelectorContext)
const inputRef = useRef<HTMLInputElement>(null)
const inputType = useMemo(() => (window.innerWidth < 640 ? 'text' : 'number'), [])
const [isFocused, setIsFocused] = useState(false)
const { deviceType } = useDeviceType()
// Only autofocus on desktop (WEB), not on mobile devices (IOS/ANDROID)
const shouldAutoFocus = deviceType === DeviceType.WEB

// Store display value for input field (what user sees when typing)
const [displayValue, setDisplayValue] = useState<string>(tokenValue || '')
Expand Down Expand Up @@ -228,6 +239,17 @@ const TokenAmountInput = ({
}
}

// Sync default slider suggested amount to the input
useEffect(() => {
if (defaultSliderSuggestedAmount) {
const formattedAmount = formatTokenAmount(defaultSliderSuggestedAmount.toString(), 2)
if (formattedAmount) {
setTokenValue(formattedAmount)
setDisplayValue(formattedAmount)
}
}
}, [defaultSliderSuggestedAmount])

return (
<form
ref={formRef}
Expand All @@ -239,33 +261,48 @@ const TokenAmountInput = ({
<div className="flex items-center gap-1 font-bold">
<label className={`text-xl ${displayValue ? 'text-black' : 'text-gray-1'}`}>{displaySymbol}</label>

{/* Input */}
<input
autoFocus
className={`h-12 w-[4ch] max-w-80 bg-transparent text-6xl font-black caret-primary-1 outline-none transition-colors placeholder:text-h1 placeholder:text-gray-1 focus:border-primary-1 dark:border-white dark:bg-n-1 dark:text-white dark:placeholder:text-white/75 dark:focus:border-primary-1`}
placeholder={'0.00'}
onChange={(e) => {
const value = formatAmountWithoutComma(e.target.value)
onChange(value, isInputUsd)
}}
ref={inputRef}
inputMode="decimal"
type={inputType}
value={displayValue}
step="any"
min="0"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (onSubmit) onSubmit()
}
}}
onBlur={() => {
if (onBlur) onBlur()
}}
disabled={disabled}
/>
{/* Input with fake caret */}
<div className="relative">
<input
autoFocus={shouldAutoFocus}
className={`h-12 w-[4ch] max-w-80 bg-transparent text-6xl font-black caret-primary-1 outline-none transition-colors placeholder:text-h1 placeholder:text-gray-1 focus:border-primary-1 dark:border-white dark:bg-n-1 dark:text-white dark:placeholder:text-white/75 dark:focus:border-primary-1`}
placeholder={'0.00'}
onChange={(e) => {
let value = e.target.value
// USD/currency → 2 decimals; token input → allow `decimals` (<= 6)
const maxDecimals =
displayMode === 'FIAT' || displayMode === 'STABLE' || isInputUsd ? 2 : decimals
const formattedAmount = formatTokenAmount(value, maxDecimals, true)
if (formattedAmount !== undefined) {
value = formattedAmount
}
onChange(value, isInputUsd)
}}
ref={inputRef}
inputMode="decimal"
type={inputType}
value={displayValue}
step="any"
min="0"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (onSubmit) onSubmit()
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => {
setIsFocused(false)
if (onBlur) onBlur()
}}
disabled={disabled}
/>
{/* Fake blinking caret shown when not focused and input is empty */}
{!isFocused && !displayValue && (
<div className="pointer-events-none absolute left-0 top-1/2 h-12 w-[1px] -translate-y-1/2 animate-blink bg-primary-1" />
)}
</div>
</div>

{/* Conversion */}
Expand Down Expand Up @@ -318,7 +355,11 @@ const TokenAmountInput = ({
)}
{showSlider && maxAmount && (
<div className="mt-2 h-14">
<Slider onValueChange={onSliderValueChange} value={sliderValue} />
<Slider
onValueChange={onSliderValueChange}
value={sliderValue}
defaultValue={[defaultSliderValue ? defaultSliderValue : 100]}
/>
</div>
)}
</form>
Expand Down
Loading
Loading