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
1 change: 1 addition & 0 deletions src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export default function WithdrawCryptoPage() {

clearErrors()
dispatch(paymentActions.setChargeDetails(null))
setIsPreparingReview(true)

try {
const completeWithdrawData = { ...data, amount: amountToWithdraw }
Expand Down
47 changes: 24 additions & 23 deletions src/components/Global/RouteExpiryTimer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { twMerge } from 'tailwind-merge'

interface RouteExpiryTimerProps {
expiry?: string // ISO string from route
expiry?: string // Unix timestamp in seconds
isLoading?: boolean
onNearExpiry?: () => void // Called when timer gets close to expiry (e.g., 30 seconds)
onExpired?: () => void // Called when timer expires
className?: string
nearExpiryThresholdMs?: number // Default 30 seconds
nearExpiryThresholdPercentage?: number
disableRefetch?: boolean // Disable refetching when user is signing transaction
error?: string | null // Error message to display instead of timer
}
Expand All @@ -24,14 +24,22 @@ const RouteExpiryTimer: React.FC<RouteExpiryTimerProps> = ({
onNearExpiry,
onExpired,
className,
nearExpiryThresholdMs = 5000, // 5 seconds
nearExpiryThresholdPercentage = 0.1, // 10% of total duration
disableRefetch = false,
error = null,
}) => {
const [timeRemaining, setTimeRemaining] = useState<TimeRemaining | null>(null)
const [hasTriggeredNearExpiry, setHasTriggeredNearExpiry] = useState(false)
const [hasExpired, setHasExpired] = useState(false)

const totalDurationMs = useMemo(() => {
if (!expiry) return 0
const expiryMs = parseInt(expiry, 10) * 1000
const diff = expiryMs - Date.now()
return Math.max(0, diff)
}, [expiry])
const nearExpiryThresholdMs = useMemo(() => totalDurationMs * nearExpiryThresholdPercentage, [totalDurationMs])

const calculateTimeRemaining = useCallback((): TimeRemaining | null => {
if (!expiry) return null

Expand Down Expand Up @@ -114,36 +122,29 @@ const RouteExpiryTimer: React.FC<RouteExpiryTimerProps> = ({
return `${paddedMinutes}:${paddedSeconds}`
}

const getProgressPercentage = (): number => {
if (!timeRemaining || !expiry) return 0

// Assuming routes typically have 1-minute expiry (300 seconds)
// This could be made configurable if needed
const totalDurationMs = 1 * 60 * 1000 // 1 minutes
const progressPercentage = useMemo((): number => {
if (!timeRemaining || !totalDurationMs) return 0
const elapsedMs = totalDurationMs - timeRemaining.totalMs
return Math.max(0, Math.min(100, (elapsedMs / totalDurationMs) * 100))
}
}, [timeRemaining, totalDurationMs])

const getProgressColor = (): string => {
const progressColor = useMemo((): string => {
if (!timeRemaining) return 'bg-grey-3'

const percentage = getProgressPercentage()

// Green for first 70%
if (percentage < 70) return 'bg-green-500'
if (progressPercentage < 70) return 'bg-green-500'
// Yellow for 70-85%
if (percentage < 85) return 'bg-yellow-500'
if (progressPercentage < 85) return 'bg-yellow-500'
// Red for final 15%
return 'bg-red'
}
}, [progressPercentage, timeRemaining])

const shouldPulse = (): boolean => {
const shouldPulse = useMemo((): boolean => {
if (isLoading) return true
if (!timeRemaining) return false
// Pulse when in red zone (85%+ progress) OR near expiry threshold
const progressPercentage = getProgressPercentage()
return (progressPercentage >= 85 || timeRemaining.totalMs <= nearExpiryThresholdMs) && timeRemaining.totalMs > 0
}
}, [progressPercentage, timeRemaining, isLoading, nearExpiryThresholdMs])

const getText = (): string => {
if (error) return error
Expand Down Expand Up @@ -177,11 +178,11 @@ const RouteExpiryTimer: React.FC<RouteExpiryTimerProps> = ({
<div
className={twMerge(
'h-full rounded-full transition-all duration-300',
error ? 'w-full bg-red' : isLoading ? 'w-full bg-grey-3' : getProgressColor(),
shouldPulse() ? 'animate-pulse-strong' : ''
error ? 'w-full bg-red' : isLoading ? 'w-full bg-grey-3' : progressColor,
shouldPulse ? 'animate-pulse-strong' : ''
)}
style={{
width: error ? '100%' : isLoading ? '100%' : `${getProgressPercentage()}%`,
width: error ? '100%' : isLoading ? '100%' : `${progressPercentage}%`,
}}
/>
</div>
Expand Down
1 change: 1 addition & 0 deletions src/components/Payment/PaymentForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ export const PaymentForm = ({
disabled={!isAddMoneyFlow && (!!requestDetails?.tokenAmount || !!chargeDetails?.tokenAmount)}
walletBalance={isActivePeanutWallet ? peanutWalletBalance : undefined}
currency={currency}
hideBalance={isAddMoneyFlow}
/>

{/*
Expand Down
26 changes: 17 additions & 9 deletions src/components/Payment/Views/Confirm.payment.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useWallet } from '@/hooks/wallet/useWallet'
import { useAppDispatch, usePaymentStore, useWalletStore } from '@/redux/hooks'
import { paymentActions } from '@/redux/slices/payment-slice'
import { chargesApi } from '@/services/charges'
import { ErrorHandler, formatAmount, printableAddress, areEvmAddressesEqual } from '@/utils'
import { ErrorHandler, formatAmount, areEvmAddressesEqual } from '@/utils'
import { useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'next/navigation'
import { useCallback, useContext, useEffect, useMemo } from 'react'
Expand All @@ -31,6 +31,7 @@ import { formatUnits } from 'viem'
import type { Address } from 'viem'
import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants'
import { captureMessage } from '@sentry/nextjs'
import AddressLink from '@/components/Global/AddressLink'

type ConfirmPaymentViewProps = {
isPintaReq?: boolean
Expand Down Expand Up @@ -106,8 +107,9 @@ export default function ConfirmPaymentView({

return (
<>
<span className="line-through">$ {estimatedGasCostUsd.toFixed(2)}</span>{' '}
<span className="text-gray-400">Sponsored by Peanut!</span>
<span className="line-through">$ {estimatedGasCostUsd.toFixed(2)}</span>
{' – '}
<span className="font-medium text-gray-500">Sponsored by Peanut!</span>
</>
)
}, [estimatedGasCostUsd, isFeeEstimationError, isUsingExternalWallet])
Expand Down Expand Up @@ -465,17 +467,23 @@ export default function ConfirmPaymentView({
/>
)}

{isAddMoneyFlow && <PaymentInfoRow label="From" value={printableAddress(wagmiAddress ?? '')} />}
{isAddMoneyFlow && (
<PaymentInfoRow
label="From"
value={
<AddressLink
isLink={false}
address={wagmiAddress!}
className="text-black no-underline"
/>
}
/>
)}

<PaymentInfoRow
loading={isCalculatingFees || isEstimatingGas || isPreparingTx}
label={isCrossChainPayment ? 'Max network fee' : 'Network fee'}
value={networkFee}
moreInfoText={
isCrossChainPayment
? 'This transaction may face slippage due to token conversion or cross-chain bridging.'
: undefined
}
/>

<PaymentInfoRow hideBottomBorder label="Peanut fee" value={`$ 0.00`} />
Expand Down
15 changes: 4 additions & 11 deletions src/components/Withdraw/views/Confirm.withdraw.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ export default function ConfirmWithdrawView({
if (networkFee < 0.01) return 'Sponsored by Peanut!'
return (
<>
<span className="line-through">$ {networkFee.toFixed(2)}</span>{' '}
<span className="text-gray-400">Sponsored by Peanut!</span>
<span className="line-through">$ {networkFee.toFixed(2)}</span>
{' – '}
<span className="font-medium text-gray-500">Sponsored by Peanut!</span>
</>
)
}, [networkFee])
Expand Down Expand Up @@ -140,15 +141,7 @@ export default function ConfirmWithdrawView({
label="To"
value={<AddressLink isLink={false} address={toAddress} className="text-black no-underline" />}
/>
<PaymentInfoRow
label="Max network fee"
value={networkFeeDisplay}
moreInfoText={
isCrossChain
? 'This transaction may face slippage due to token conversion or cross-chain bridging.'
: undefined
}
/>
<PaymentInfoRow label="Max network fee" value={networkFeeDisplay} />
<PaymentInfoRow hideBottomBorder label="Peanut fee" value={`$ ${peanutFee}`} />
</Card>

Expand Down
24 changes: 22 additions & 2 deletions src/hooks/usePaymentInitiator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ import { getRoute, type PeanutCrossChainRoute } from '@/services/swap'
import { estimateTransactionCostUsd } from '@/app/actions/tokens'
import { captureException } from '@sentry/nextjs'

enum ELoadingStep {
IDLE = 'Idle',
PREPARING_TRANSACTION = 'Preparing Transaction',
SENDING_TRANSACTION = 'Sending Transaction',
CONFIRMING_TRANSACTION = 'Confirming Transaction',
UPDATING_PAYMENT_STATUS = 'Updating Payment Status',
CHARGE_CREATED = 'Charge Created',
ERROR = 'Error',
SUCCESS = 'Success',
FETCHING_CHARGE_DETAILS = 'Fetching Charge Details',
CREATING_CHARGE = 'Creating Charge',
SWITCHING_NETWORK = 'Switching Network',
}

type LoadingStep = `${ELoadingStep}`

export interface InitiatePaymentPayload {
recipient: ParsedURL['recipient']
tokenAmount: string
Expand Down Expand Up @@ -84,7 +100,7 @@ export const usePaymentInitiator = () => {

const [estimatedGasCostUsd, setEstimatedGasCostUsd] = useState<number | undefined>(undefined)
const [estimatedFromValue, setEstimatedFromValue] = useState<string>('0')
const [loadingStep, setLoadingStep] = useState<string>('Idle')
const [loadingStep, setLoadingStep] = useState<LoadingStep>('Idle')
const [error, setError] = useState<string | null>(null)
const [createdChargeDetails, setCreatedChargeDetails] = useState<TRequestChargeResponse | null>(null)
const [transactionHash, setTransactionHash] = useState<string | null>(null)
Expand All @@ -109,7 +125,11 @@ export const usePaymentInitiator = () => {
}, [selectedTokenData, activeChargeDetails])

const isProcessing = useMemo<boolean>(
() => loadingStep !== 'Idle' && loadingStep !== 'Success' && loadingStep !== 'Error',
() =>
loadingStep !== 'Idle' &&
loadingStep !== 'Success' &&
loadingStep !== 'Error' &&
loadingStep !== 'Charge Created',
[loadingStep]
)

Expand Down
Loading