-
+ {/* Only show banner if not on landing page */}
+ {pathName !== '/' && (
+
+ )}
{/* Fixed top navbar */}
{showFullPeanutWallet && (
diff --git a/src/app/(mobile-ui)/support/page.tsx b/src/app/(mobile-ui)/support/page.tsx
index 0fd00a6cd..d9ec886cf 100644
--- a/src/app/(mobile-ui)/support/page.tsx
+++ b/src/app/(mobile-ui)/support/page.tsx
@@ -4,7 +4,7 @@ const SupportPage = () => {
return (
)
}
diff --git a/src/app/actions/tokens.ts b/src/app/actions/tokens.ts
index 0d760e855..84a832984 100644
--- a/src/app/actions/tokens.ts
+++ b/src/app/actions/tokens.ts
@@ -5,7 +5,8 @@ import { type ITokenPriceData } from '@/interfaces'
import type { Address } from 'viem'
import { parseAbi } from 'viem'
import { type ChainId, getPublicClient } from '@/app/actions/clients'
-import { getTokenDetails } from '@/utils'
+import { getTokenDetails, isStableCoin, areEvmAddressesEqual } from '@/utils'
+import { IUserBalance } from '@/interfaces'
type IMobulaMarketData = {
id: number
@@ -50,6 +51,49 @@ type IMobulaMarketData = {
}
}
+type IMobulaContractBalanceData = {
+ address: string //of the contract
+ balance: number
+ balanceRaw: string
+ chainId: string // this chainId is og the type evm:
+ decimals: number
+}
+
+type IMobulaCrossChainBalanceData = {
+ balance: number
+ balanceRaw: string
+ chainId: string
+ address: string //of the token
+}
+
+type IMobulaAsset = {
+ id: number
+ name: string
+ symbol: string
+ logo: string
+ decimals: string[]
+ contracts: string[]
+ blockchains: string[]
+}
+
+type IMobulaAssetData = {
+ contracts_balances: IMobulaContractBalanceData[]
+ cross_chain_balances: Record // key is the same as in asset.blockchains price_change_24h: number
+ estimated_balance: number
+ price: number
+ token_balance: number
+ allocation: number
+ asset: IMobulaAsset
+ wallets: string[]
+}
+
+type IMobulaPortfolioData = {
+ total_wallet_balance: number
+ wallets: string[]
+ assets: IMobulaAssetData[]
+ balances_length: number
+}
+
const ERC20_DATA_ABI = parseAbi([
'function symbol() view returns (string)',
'function name() view returns (string)',
@@ -146,3 +190,61 @@ export const fetchTokenDetails = unstable_cache(
tags: ['fetchTokenDetails'],
}
)
+
+export const fetchWalletBalances = unstable_cache(
+ async (address: string): Promise<{ balances: IUserBalance[]; totalBalance: number }> => {
+ const mobulaResponse = await fetchWithSentry(`https://api.mobula.io/api/1/wallet/portfolio?wallet=${address}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ authorization: process.env.MOBULA_API_KEY!,
+ },
+ })
+
+ if (!mobulaResponse.ok) throw new Error('Failed to fetch wallet balances')
+
+ const json: { data: IMobulaPortfolioData } = await mobulaResponse.json()
+ const assets = json.data.assets
+ .filter((a: IMobulaAssetData) => !!a.price)
+ .filter((a: IMobulaAssetData) => !!a.token_balance)
+ const balances = []
+ for (const asset of assets) {
+ const symbol = asset.asset.symbol
+ const price = isStableCoin(symbol) || estimateIfIsStableCoinFromPrice(asset.price) ? 1 : asset.price
+ /*
+ Mobula returns balances per asset, IE: USDC on arbitrum, mainnet
+ and optimism are all part of the same "asset", here we need to
+ divide it
+ */
+ for (const chain of asset.asset.blockchains) {
+ const address = asset.cross_chain_balances[chain].address
+ const contractInfo = asset.contracts_balances.find((c) => areEvmAddressesEqual(c.address, address))
+ const crossChainBalance = asset.cross_chain_balances[chain]
+ balances.push({
+ chainId: crossChainBalance.chainId,
+ address,
+ name: asset.asset.name,
+ symbol,
+ decimals: contractInfo!.decimals,
+ price,
+ amount: crossChainBalance.balance,
+ currency: 'usd',
+ logoURI: asset.asset.logo,
+ value: (crossChainBalance.balance * price).toString(),
+ })
+ }
+ }
+ const totalBalance = balances.reduce(
+ (acc: number, balance: IUserBalance) => acc + balance.amount * balance.price,
+ 0
+ )
+ return {
+ balances,
+ totalBalance,
+ }
+ },
+ ['fetchWalletBalances'],
+ {
+ tags: ['fetchWalletBalances'],
+ revalidate: 5, // 5 seconds
+ }
+)
diff --git a/src/app/api/walletconnect/fetch-wallet-balance/route.ts b/src/app/api/walletconnect/fetch-wallet-balance/route.ts
deleted file mode 100644
index 740234417..000000000
--- a/src/app/api/walletconnect/fetch-wallet-balance/route.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { NextRequest } from 'next/server'
-import { fetchWithSentry } from '@/utils'
-
-export async function POST(request: NextRequest) {
- try {
- const body = await request.json()
- const projectID = process.env.NEXT_PUBLIC_WC_PROJECT_ID ?? ''
-
- if (!projectID) throw new Error('API_KEY not found in env')
-
- const apiResponse = await fetchWithSentry(
- `https://rpc.walletconnect.com/v1/account/${body.address}/balance?currency=usd&projectId=${projectID}`,
- {
- method: 'GET',
- // mode: 'no-cors', // Enable this locally
- headers: {
- 'Content-Type': 'application/json',
- 'x-sdk-version': '4.1.5',
- },
- }
- )
-
- if (!apiResponse.ok) return new Response('Internal Server Error', { status: 500 })
-
- const apiResponseJson = await apiResponse.text()
-
- return new Response(apiResponseJson, {
- status: 200,
- headers: {
- 'Content-Type': 'application/json',
- },
- })
- } catch (error) {
- console.error('Error in fetching wallet balance:', error)
- return new Response('Internal Server Error', { status: 500 })
- }
-}
diff --git a/src/assets/illustrations/hand-token.svg b/src/assets/illustrations/hand-token.svg
deleted file mode 100644
index bf09c3ff2..000000000
--- a/src/assets/illustrations/hand-token.svg
+++ /dev/null
@@ -1,24 +0,0 @@
-
diff --git a/src/assets/illustrations/index.ts b/src/assets/illustrations/index.ts
index 5aedaba48..3b8598b5e 100644
--- a/src/assets/illustrations/index.ts
+++ b/src/assets/illustrations/index.ts
@@ -4,7 +4,6 @@ export { default as ClaimChainsBadge } from './claim-chains-badge.svg'
export { default as Cloud } from './cloud.svg'
export { default as Eyes } from './eyes.svg'
export { default as HandThumbsUp } from './hand-thumbs-up.svg'
-export { default as HandToken } from './hand-token.svg'
export { default as AboutPeanut } from './hero-description.svg'
export { default as PeanutArmHoldingBeer } from './peanut-arm-holding-beer.svg'
export { default as PEANUT_LOGO_BLACK } from './peanut-logo-dark.svg'
diff --git a/src/components/0_Bruddle/Button.tsx b/src/components/0_Bruddle/Button.tsx
index ef0659f6d..3c0d3aef2 100644
--- a/src/components/0_Bruddle/Button.tsx
+++ b/src/components/0_Bruddle/Button.tsx
@@ -1,4 +1,4 @@
-import React, { forwardRef, useEffect, useRef } from 'react'
+import React, { forwardRef, useEffect, useRef, useState, useCallback } from 'react'
import { twMerge } from 'tailwind-merge'
import { Icon, IconName } from '../Global/Icons/Icon'
import Loading from '../Global/Loading'
@@ -30,6 +30,12 @@ export interface ButtonProps extends React.ButtonHTMLAttributes void
+ onLongPressStart?: () => void
+ onLongPressEnd?: () => void
+ }
}
const buttonVariants: Record = {
@@ -82,6 +88,8 @@ export const Button = forwardRef(
iconSize,
iconClassName,
iconContainerClassName,
+ longPress,
+ onClick,
...props
},
ref
@@ -89,12 +97,114 @@ export const Button = forwardRef(
const localRef = useRef(null)
const buttonRef = (ref as React.RefObject) || localRef
+ // Long press state
+ const [isLongPressed, setIsLongPressed] = useState(false)
+ const [pressTimer, setPressTimer] = useState(null)
+ const [pressProgress, setPressProgress] = useState(0)
+ const [progressInterval, setProgressInterval] = useState(null)
+
useEffect(() => {
if (!buttonRef.current) return
buttonRef.current.setAttribute('translate', 'no')
buttonRef.current.classList.add('notranslate')
}, [])
+ // Long press handlers
+ const handlePressStart = useCallback(() => {
+ if (!longPress) return
+
+ longPress.onLongPressStart?.()
+ setPressProgress(0)
+
+ const duration = longPress.duration || 2000
+ const updateInterval = 16 // ~60fps
+ const increment = (100 / duration) * updateInterval
+
+ // Progress animation
+ const progressTimer = setInterval(() => {
+ setPressProgress((prev) => {
+ const newProgress = prev + increment
+ if (newProgress >= 100) {
+ clearInterval(progressTimer)
+ return 100
+ }
+ return newProgress
+ })
+ }, updateInterval)
+
+ setProgressInterval(progressTimer)
+
+ // Long press completion timer
+ const timer = setTimeout(() => {
+ setIsLongPressed(true)
+ longPress.onLongPress?.()
+ clearInterval(progressTimer)
+ }, duration)
+
+ setPressTimer(timer)
+ }, [longPress])
+
+ const handlePressEnd = useCallback(() => {
+ if (!longPress) return
+
+ if (pressTimer) {
+ clearTimeout(pressTimer)
+ setPressTimer(null)
+ }
+
+ if (progressInterval) {
+ clearInterval(progressInterval)
+ setProgressInterval(null)
+ }
+
+ if (isLongPressed) {
+ longPress.onLongPressEnd?.()
+ setIsLongPressed(false)
+ }
+
+ setPressProgress(0)
+ }, [longPress, pressTimer, progressInterval, isLongPressed])
+
+ const handlePressCancel = useCallback(() => {
+ if (!longPress) return
+
+ if (pressTimer) {
+ clearTimeout(pressTimer)
+ setPressTimer(null)
+ }
+
+ if (progressInterval) {
+ clearInterval(progressInterval)
+ setProgressInterval(null)
+ }
+
+ setIsLongPressed(false)
+ setPressProgress(0)
+ }, [longPress, pressTimer, progressInterval])
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (longPress && !isLongPressed) {
+ // If long press is enabled but not completed, don't trigger onClick
+ return
+ }
+ onClick?.(e)
+ },
+ [longPress, isLongPressed, onClick]
+ )
+
+ // Cleanup timers on unmount
+ useEffect(() => {
+ return () => {
+ if (pressTimer) {
+ clearTimeout(pressTimer)
+ }
+ if (progressInterval) {
+ clearInterval(progressInterval)
+ }
+ }
+ }, [pressTimer, progressInterval])
+
const buttonClasses = twMerge(
`btn w-full flex items-center gap-2 transition-all duration-100 active:translate-x-[3px] active:translate-y-[${shadowSize}px] active:shadow-none notranslate`,
buttonVariants[variant],
@@ -102,6 +212,7 @@ export const Button = forwardRef(
size && buttonSizes[size],
shape === 'square' && 'btn-square',
shadowSize && buttonShadows[shadowType || 'primary'][shadowSize],
+
className
)
@@ -118,12 +229,39 @@ export const Button = forwardRef(
)
}
+ // Use children as display text (no text changes for long press)
+ const displayText = children
+
return (
-
))}
>
diff --git a/src/components/LandingPage/imageAssets.tsx b/src/components/LandingPage/imageAssets.tsx
index 754eab273..f6a6ff88e 100644
--- a/src/components/LandingPage/imageAssets.tsx
+++ b/src/components/LandingPage/imageAssets.tsx
@@ -131,14 +131,6 @@ export const HeroImages = () => {
src={Star.src}
className="absolute right-[1.5%] top-[-12%] w-8 sm:right-[6%] sm:top-[8%] md:right-[5%] md:top-[8%] md:w-12 lg:right-[10%]"
/>
- {/*
*/}
>
)
}
diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts
index 86e117aca..f6765dbf7 100644
--- a/src/utils/__tests__/bridge.utils.test.ts
+++ b/src/utils/__tests__/bridge.utils.test.ts
@@ -20,7 +20,7 @@ describe('bridge.utils', () => {
const offrampConfig = getCurrencyConfig('US', 'offramp')
expect(offrampConfig).toEqual({
currency: 'usd',
- paymentRail: 'ach_pull',
+ paymentRail: 'ach',
})
})
@@ -96,7 +96,7 @@ describe('bridge.utils', () => {
const config = getOfframpCurrencyConfig('US')
expect(config).toEqual({
currency: 'usd',
- paymentRail: 'ach_pull',
+ paymentRail: 'ach',
})
})
@@ -198,7 +198,7 @@ describe('bridge.utils', () => {
const offrampConfig = getCurrencyConfig('US', 'offramp')
expect(onrampConfig.paymentRail).toBe('ach_push')
- expect(offrampConfig.paymentRail).toBe('ach_pull')
+ expect(offrampConfig.paymentRail).toBe('ach')
expect(onrampConfig.currency).toBe(offrampConfig.currency)
})
diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts
index 227e2953d..1b644c002 100644
--- a/src/utils/balance.utils.ts
+++ b/src/utils/balance.utils.ts
@@ -1,88 +1,7 @@
import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants'
import { ChainValue, IUserBalance } from '@/interfaces'
-import { areEvmAddressesEqual, fetchWithSentry, isAddressZero } from '@/utils'
import * as Sentry from '@sentry/nextjs'
import { formatUnits } from 'viem'
-import { NATIVE_TOKEN_ADDRESS } from './token.utils'
-
-export async function fetchWalletBalances(
- address: string
-): Promise<{ balances: IUserBalance[]; totalBalance: number }> {
- try {
- const apiResponse = await fetchWithSentry('/api/walletconnect/fetch-wallet-balance', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ address }),
- })
-
- if (!apiResponse.ok) {
- throw new Error('API request failed')
- }
-
- const apiResponseJson = await apiResponse.json()
-
- const processedBalances = apiResponseJson.balances
- .filter((balance: any) => balance.value > 0.009)
- .map((item: any) => ({
- chainId: item?.chainId ? item.chainId.split(':')[1] : '1',
- address: item?.address ? item.address.split(':')[2] : NATIVE_TOKEN_ADDRESS,
- name: item.name,
- symbol: item.symbol,
- decimals: parseInt(item.quantity.decimals),
- price: item.price,
- amount: parseFloat(item.quantity.numeric),
- currency: 'usd',
- logoURI: item.iconUrl,
- value: item.value.toString(),
- }))
- .map((balance: any) =>
- balance.chainId === '8508132'
- ? { ...balance, chainId: '534352' }
- : balance.chainId === '81032'
- ? { ...balance, chainId: '81457' }
- : balance.chainId === '59160'
- ? { ...balance, chainId: '59144' }
- : balance
- )
- .sort((a: any, b: any) => {
- const valueA = parseFloat(a.value)
- const valueB = parseFloat(b.value)
-
- if (valueA === valueB) {
- if (isAddressZero(a.address)) return -1
- if (isAddressZero(b.address)) return 1
- return b.amount - a.amount
- }
- return valueB - valueA
- })
-
- const totalBalance = processedBalances.reduce((acc: number, balance: any) => acc + Number(balance.value), 0)
-
- return {
- balances: processedBalances,
- totalBalance,
- }
- } catch (error) {
- console.error('Error fetching wallet balances:', error)
- if (error instanceof Error && error.message !== 'API request failed') {
- Sentry.captureException(error)
- }
- return { balances: [], totalBalance: 0 }
- }
-}
-
-export function balanceByToken(
- balances: IUserBalance[],
- chainId: string,
- tokenAddress: string
-): IUserBalance | undefined {
- if (!chainId || !tokenAddress) return undefined
- return balances.find(
- (balance) => balance.chainId === chainId && areEvmAddressesEqual(balance.address, tokenAddress)
- )
-}
export function calculateValuePerChain(balances: IUserBalance[]): ChainValue[] {
let result: ChainValue[] = []