diff --git a/src/components/Common/ActionList.tsx b/src/components/Common/ActionList.tsx index 00caad377..5cccba256 100644 --- a/src/components/Common/ActionList.tsx +++ b/src/components/Common/ActionList.tsx @@ -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' @@ -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() @@ -77,11 +79,14 @@ export default function ActionList({ setFlowStep: setRequestFulfilmentBankFlowStep, setFulfillUsingManteca, setRegionalMethodType: setRequestFulfillmentRegionalMethodType, + setTriggerPayWithPeanut, } = useRequestFulfillmentFlow() const [isGuestVerificationModalOpen, setIsGuestVerificationModalOpen] = useState(false) const [selectedMethod, setSelectedMethod] = useState(null) const [showInviteModal, setShowInviteModal] = useState(false) const { user } = useAuth() + const [isUsePeanutBalanceModalShown, setIsUsePeanutBalanceModalShown] = useState(false) + const [showUsePeanutBalanceModal, setShowUsePeanutBalanceModal] = useState(false) const dispatch = useAppDispatch() @@ -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)) @@ -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) { @@ -222,12 +235,24 @@ export default function ActionList({
{sortedActionMethods.map((method) => { if (flow === 'request' && method.id === 'exchange-or-wallet') { + const shouldShowPeanutBalanceModal = !isUsePeanutBalanceModalShown && hasSufficientPeanutBalance return ( - + onClick={() => { + if (shouldShowPeanutBalanceModal) { + setShowUsePeanutBalanceModal(true) + } + }} + > + {/* Disable daimo pay button if peanut balance is enough to pay for the request */} +
+ +
+
) } @@ -285,6 +310,32 @@ export default function ActionList({ setSelectedMethod(null) }} /> + + { + 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" + /> ) } diff --git a/src/components/Global/Slider/index.tsx b/src/components/Global/Slider/index.tsx index 48346a528..ca4445da9 100644 --- a/src/components/Global/Slider/index.tsx +++ b/src/components/Global/Slider/index.tsx @@ -15,11 +15,11 @@ function Slider({ ...props }: React.ComponentProps) { // Use internal state for the slider value to enable magnetic snapping - const [internalValue, setInternalValue] = React.useState(controlledValue || defaultValue) + const [internalValue, setInternalValue] = React.useState(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]) diff --git a/src/components/Global/TokenAmountInput/index.tsx b/src/components/Global/TokenAmountInput/index.tsx index 68fb25d7d..f077b3dc6 100644 --- a/src/components/Global/TokenAmountInput/index.tsx +++ b/src/components/Global/TokenAmountInput/index.tsx @@ -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 @@ -29,6 +32,8 @@ interface TokenAmountInputProps { showSlider?: boolean maxAmount?: number isInitialInputUsd?: boolean + defaultSliderValue?: number + defaultSliderSuggestedAmount?: number } const TokenAmountInput = ({ @@ -49,10 +54,16 @@ const TokenAmountInput = ({ showSlider = false, maxAmount, isInitialInputUsd = false, + defaultSliderValue, + defaultSliderSuggestedAmount, }: TokenAmountInputProps) => { const { selectedTokenData } = useContext(tokenSelectorContext) const inputRef = useRef(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(tokenValue || '') @@ -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 (
- {/* Input */} - { - 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 */} +
+ { + 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 && ( +
+ )} +
{/* Conversion */} @@ -318,7 +355,11 @@ const TokenAmountInput = ({ )} {showSlider && maxAmount && (
- +
)}
diff --git a/src/components/Payment/PaymentForm/index.tsx b/src/components/Payment/PaymentForm/index.tsx index dbe063479..49061d39c 100644 --- a/src/components/Payment/PaymentForm/index.tsx +++ b/src/components/Payment/PaymentForm/index.tsx @@ -88,6 +88,8 @@ export const PaymentForm = ({ setExternalWalletFulfillMethod, fulfillUsingManteca, setFulfillUsingManteca, + triggerPayWithPeanut, + setTriggerPayWithPeanut, } = useRequestFulfillmentFlow() const recipientUsername = !chargeDetails && recipient?.recipientType === 'USERNAME' ? recipient.identifier : null const { user: recipientUser } = useUserByUsername(recipientUsername) @@ -165,7 +167,7 @@ export const PaymentForm = ({ const isActivePeanutWallet = useMemo(() => !!user && isPeanutWalletConnected, [user, isPeanutWalletConnected]) useEffect(() => { - if (initialSetupDone) return + if (initialSetupDone || showRequestPotInitialView) return if (amount) { setInputTokenAmount(amount) @@ -188,7 +190,7 @@ export const PaymentForm = ({ } setInitialSetupDone(true) - }, [chain, token, amount, initialSetupDone, requestDetails]) + }, [chain, token, amount, initialSetupDone, requestDetails, showRequestPotInitialView]) // reset error when component mounts or recipient changes useEffect(() => { @@ -297,7 +299,7 @@ export const PaymentForm = ({ // Calculate USD value when requested token price is available useEffect(() => { - if (!requestedTokenPriceData?.price || !requestDetails?.tokenAmount) return + if (showRequestPotInitialView || !requestedTokenPriceData?.price || !requestDetails?.tokenAmount) return const tokenAmount = parseFloat(requestDetails.tokenAmount) if (isNaN(tokenAmount) || tokenAmount <= 0) return @@ -307,7 +309,7 @@ export const PaymentForm = ({ const usdValue = formatAmount(tokenAmount * requestedTokenPriceData.price) setInputTokenAmount(usdValue) setUsdValue(usdValue) - }, [requestedTokenPriceData?.price, requestDetails?.tokenAmount]) + }, [requestedTokenPriceData?.price, requestDetails?.tokenAmount, showRequestPotInitialView]) const canInitiatePayment = useMemo(() => { let amountIsSet = false @@ -329,6 +331,7 @@ export const PaymentForm = ({ return recipientExists && amountIsSet && tokenSelected && walletConnected }, [ + showRequestPotInitialView, recipient, inputTokenAmount, usdValue, @@ -588,6 +591,14 @@ export const PaymentForm = ({ } }, [fulfillUsingManteca, chargeDetails, handleInitiatePayment]) + // Trigger payment with peanut from action list + useEffect(() => { + if (triggerPayWithPeanut) { + handleInitiatePayment() + setTriggerPayWithPeanut(false) + } + }, [triggerPayWithPeanut, handleInitiatePayment, setTriggerPayWithPeanut]) + const isInsufficientBalanceError = useMemo(() => { return error?.includes("You don't have enough balance.") }, [error]) @@ -651,15 +662,57 @@ export const PaymentForm = ({ const totalAmountCollected = requestDetails?.totalCollectedAmount ?? 0 + const defaultSliderValue = useMemo(() => { + const charges = requestDetails?.charges + const totalAmount = requestDetails?.tokenAmount ? parseFloat(requestDetails.tokenAmount) : 0 + const totalCollected = totalAmountCollected + + if (totalAmount <= 0) return { percentage: 0, suggestedAmount: 0 } + + // No charges yet - suggest 100% (full pot) + if (!charges || charges.length === 0) { + return { percentage: 100, suggestedAmount: totalAmount } + } + + // Calculate average contribution from existing charges + const contributionAmounts = charges + .map((charge) => parseFloat(charge.tokenAmount)) + .filter((amount) => !isNaN(amount) && amount > 0) + + if (contributionAmounts.length === 0) return { percentage: 0, suggestedAmount: 0 } + + const avgContribution = contributionAmounts.reduce((sum, amt) => sum + amt, 0) / contributionAmounts.length + + // Calculate remaining amount (could be negative if over-contributed) + const remaining = totalAmount - totalCollected + let suggestedAmount: number + + // If pot is already full or over-filled, suggest minimum contribution + if (remaining <= 0) { + // Pot is full/overfilled - suggest the smallest previous contribution or 10% of pot + const minContribution = Math.min(...contributionAmounts) + suggestedAmount = Math.min(minContribution, totalAmount * 0.1) + } else if (remaining < avgContribution) { + // If remaining is less than average, suggest the remaining amount + suggestedAmount = remaining + } else { + // Otherwise, suggest the average contribution (most common pattern) + suggestedAmount = avgContribution + } + + // Convert amount to percentage of total pot + const percentage = (suggestedAmount / totalAmount) * 100 + // Cap at 100% max + return { percentage: Math.min(percentage, 100), suggestedAmount } + }, [requestDetails?.charges, requestDetails?.tokenAmount, totalAmountCollected]) + if (fulfillUsingManteca && chargeDetails) { return } return (
- {!showRequestPotInitialView && ( - - )} +
{isExternalWalletConnected && isUsingExternalWallet && (
- {showRequestPotInitialView && ( + {showRequestPotInitialView && contributors.length > 0 && (

Contributors ({contributors.length})

{contributors.map((contributor, index) => ( diff --git a/src/components/Request/link/views/Create.request.link.view.tsx b/src/components/Request/link/views/Create.request.link.view.tsx index dfa3e3a02..255743211 100644 --- a/src/components/Request/link/views/Create.request.link.view.tsx +++ b/src/components/Request/link/views/Create.request.link.view.tsx @@ -9,7 +9,7 @@ import PeanutActionCard from '@/components/Global/PeanutActionCard' import QRCodeWrapper from '@/components/Global/QRCodeWrapper' import ShareButton from '@/components/Global/ShareButton' import TokenAmountInput from '@/components/Global/TokenAmountInput' -import { BASE_URL, PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' import { TRANSACTIONS } from '@/constants/query.consts' import * as context from '@/context' import { useAuth } from '@/context/authContext' @@ -17,9 +17,8 @@ import { useDebounce } from '@/hooks/useDebounce' import { useWallet } from '@/hooks/wallet/useWallet' import { type IToken } from '@/interfaces' import { type IAttachmentOptions } from '@/redux/types/send-flow.types' -import { chargesApi } from '@/services/charges' import { requestsApi } from '@/services/requests' -import { fetchTokenSymbol, getRequestLink, isNativeCurrency, printableUsdc } from '@/utils' +import { fetchTokenSymbol, formatTokenAmount, getRequestLink, isNativeCurrency, printableUsdc } from '@/utils' import * as Sentry from '@sentry/nextjs' import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' import { useQueryClient } from '@tanstack/react-query' @@ -37,7 +36,11 @@ export const CreateRequestLinkView = () => { const queryClient = useQueryClient() const searchParams = useSearchParams() const paramsAmount = searchParams.get('amount') - const sanitizedAmount = paramsAmount && !isNaN(parseFloat(paramsAmount)) ? paramsAmount : '' + // Sanitize amount and limit to 2 decimal places + const sanitizedAmount = useMemo(() => { + if (!paramsAmount || isNaN(parseFloat(paramsAmount))) return '' + return formatTokenAmount(paramsAmount, 2) ?? '' + }, [paramsAmount]) const merchant = searchParams.get('merchant') const merchantComment = merchant ? `Bill split for ${merchant}` : null diff --git a/src/components/TransactionDetails/PerkIcon.tsx b/src/components/TransactionDetails/PerkIcon.tsx index 25343d5a1..ae08a5a9f 100644 --- a/src/components/TransactionDetails/PerkIcon.tsx +++ b/src/components/TransactionDetails/PerkIcon.tsx @@ -1,7 +1,7 @@ import Image from 'next/image' import { STAR_STRAIGHT_ICON } from '@/assets' -type PerkIconSize = 'small' | 'medium' | 'large' +type PerkIconSize = 'extra-small' | 'small' | 'medium' | 'large' interface PerkIconProps { size?: PerkIconSize @@ -9,6 +9,10 @@ interface PerkIconProps { } const sizeConfig = { + 'extra-small': { + container: 'h-8 w-8', + icon: { width: 16, height: 16 }, + }, small: { container: 'h-10 w-10', icon: { width: 22, height: 22 }, diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index 83654e410..bc95da8e0 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -113,23 +113,19 @@ const TransactionCard: React.FC = ({
{/* txn avatar component handles icon/initials/colors */} - {/*
*/} {isPerkReward ? ( <> - - {status && } + ) : avatarUrl ? ( -
+
Icon - - {status && }
) : ( = ({ isLinkTransaction={isLinkTx} transactionType={type} context="card" - size="small" - // status={status} + size="extra-small" /> )}
diff --git a/src/context/RequestFulfillmentFlowContext.tsx b/src/context/RequestFulfillmentFlowContext.tsx index d8347f52f..82ee98a12 100644 --- a/src/context/RequestFulfillmentFlowContext.tsx +++ b/src/context/RequestFulfillmentFlowContext.tsx @@ -36,6 +36,8 @@ interface RequestFulfillmentFlowContextType { setFulfillUsingManteca: (fulfillUsingManteca: boolean) => void regionalMethodType: 'mercadopago' | 'pix' setRegionalMethodType: (regionalMethodType: 'mercadopago' | 'pix') => void + triggerPayWithPeanut: boolean + setTriggerPayWithPeanut: (triggerPayWithPeanut: boolean) => void } const RequestFulfillmentFlowContext = createContext(undefined) @@ -53,6 +55,7 @@ export const RequestFulfilmentFlowContextProvider: React.FC<{ children: ReactNod const [requesterDetails, setRequesterDetails] = useState(null) const [fulfillUsingManteca, setFulfillUsingManteca] = useState(false) const [regionalMethodType, setRegionalMethodType] = useState<'mercadopago' | 'pix'>('mercadopago') + const [triggerPayWithPeanut, setTriggerPayWithPeanut] = useState(false) // To trigger the pay with peanut from Action List const resetFlow = useCallback(() => { setExternalWalletFulfillMethod(null) @@ -90,6 +93,8 @@ export const RequestFulfilmentFlowContextProvider: React.FC<{ children: ReactNod setFulfillUsingManteca, regionalMethodType, setRegionalMethodType, + triggerPayWithPeanut, + setTriggerPayWithPeanut, }), [ resetFlow, @@ -103,6 +108,7 @@ export const RequestFulfilmentFlowContextProvider: React.FC<{ children: ReactNod requesterDetails, fulfillUsingManteca, regionalMethodType, + triggerPayWithPeanut, ] ) diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 1e7b147a6..f2b2113e0 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -448,15 +448,33 @@ export function formatAmountWithSignificantDigits(amount: number, significantDig return floorFixed(amount, fractionDigits) } -export function formatTokenAmount(amount?: number, maxFractionDigits?: number) { +export function formatTokenAmount(amount?: number | string, maxFractionDigits?: number, forInput: boolean = false) { if (amount === undefined) return undefined maxFractionDigits = maxFractionDigits ?? 6 + // For input mode, preserve progressive typing (e.g., "1.", "0.") + if (forInput && typeof amount === 'string') { + const s = amount.trim() + if (s === '') return '' + const m = s.match(/^(\d*)(?:\.(\d*))?$/) + if (!m) return '' // invalid → empty + const whole = m[1] ?? '' + const fracRaw = m[2] // undefined ⇒ no dot; '' ⇒ dot present with no digits + if (fracRaw === undefined) return whole + if (maxFractionDigits === 0) return whole + const frac = (fracRaw ?? '').slice(0, maxFractionDigits) + return `${whole}.${frac}` + } + + const amountNumber = typeof amount === 'string' ? parseFloat(amount) : amount + + // check for NaN after conversion + if (isNaN(amountNumber)) return undefined + // floor the amount - const flooredAmount = Math.floor(amount * Math.pow(10, maxFractionDigits)) / Math.pow(10, maxFractionDigits) + const flooredAmount = Math.floor(amountNumber * Math.pow(10, maxFractionDigits)) / Math.pow(10, maxFractionDigits) // Convert number to string to count significant digits - const amountString = flooredAmount.toFixed(maxFractionDigits) const significantDigits = amountString.replace(/^0+\./, '').replace(/\.$/, '').replace(/0+$/, '').length @@ -471,14 +489,6 @@ export function formatTokenAmount(amount?: number, maxFractionDigits?: number) { return formattedAmount } -export const formatAmountWithoutComma = (input: string) => { - const numericValue = input.replace(/,/g, '.') - const regex = new RegExp(`^[0-9]*\\.?[0-9]*$`) - if (numericValue === '' || regex.test(numericValue)) { - return numericValue - } else return '' -} - export async function copyTextToClipboardWithFallback(text: string) { if (navigator.clipboard && window.isSecureContext) { try { diff --git a/tailwind.config.js b/tailwind.config.js index e53e6a824..a53c7e5ff 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -187,6 +187,10 @@ module.exports = { '50%': { opacity: '0.3' }, '100%': { opacity: '1' }, }, + blink: { + '0%, 50%': { opacity: '1' }, + '50.01%, 100%': { opacity: '0' }, + }, }, animation: { colorPulse: 'colorPulse 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite', @@ -194,6 +198,7 @@ module.exports = { pulsate: 'pulsate 1.5s ease-in-out infinite', 'pulsate-slow': 'pulsateDeep 4s ease-in-out infinite', 'pulse-strong': 'pulse-strong 1s ease-in-out infinite', + blink: 'blink 1.5s step-end infinite', }, opacity: { 85: '.85',