diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index 8ce1ff0e2..72f009bd5 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -26,6 +26,10 @@ import { OnrampConfirmationModal } from '@/components/AddMoney/components/Onramp
import { InitiateBridgeKYCModal } from '@/components/Kyc/InitiateBridgeKYCModal'
import InfoCard from '@/components/Global/InfoCard'
import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs'
+import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
+import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard'
+import { getLimitsWarningCardProps } from '@/features/limits/utils'
+import { useExchangeRate } from '@/hooks/useExchangeRate'
// Step type for URL state
type BridgeBankStep = 'inputAmount' | 'kyc' | 'collectUserDetails' | 'showDetails'
@@ -97,6 +101,42 @@ export default function OnrampBankPage() {
return getMinimumAmount(selectedCountry.id)
}, [selectedCountry?.id])
+ // get local currency for the selected country (EUR, MXN, USD)
+ const localCurrency = useMemo(() => {
+ if (!selectedCountry?.id) return 'USD'
+ return getCurrencyConfig(selectedCountry.id, 'onramp').currency.toUpperCase()
+ }, [selectedCountry?.id])
+
+ // get exchange rate: local currency → USD (for limits validation)
+ // skip for USD since it's 1:1
+ const { exchangeRate, isLoading: isRateLoading } = useExchangeRate({
+ sourceCurrency: localCurrency,
+ destinationCurrency: 'USD',
+ enabled: localCurrency !== 'USD',
+ })
+
+ // convert input amount to USD for limits validation
+ // bridge limits are always in USD, but user inputs in local currency
+ const usdEquivalent = useMemo(() => {
+ if (!rawTokenAmount) return 0
+ const numericAmount = parseFloat(rawTokenAmount.replace(/,/g, ''))
+ if (isNaN(numericAmount)) return 0
+
+ // for USD, no conversion needed
+ if (localCurrency === 'USD') return numericAmount
+
+ // convert local currency to USD
+ return exchangeRate > 0 ? numericAmount * exchangeRate : 0
+ }, [rawTokenAmount, localCurrency, exchangeRate])
+
+ // validate against user's bridge limits
+ // uses USD equivalent to correctly compare against USD-denominated limits
+ const limitsValidation = useLimitsValidation({
+ flowType: 'onramp',
+ amount: usdEquivalent,
+ currency: 'USD',
+ })
+
// Determine initial step based on KYC status (only when URL has no step)
useEffect(() => {
// If URL already has a step, respect it (allows deep linking)
@@ -341,6 +381,8 @@ export default function OnrampBankPage() {
}
if (urlState.step === 'inputAmount') {
+ const showLimitsCard = limitsValidation.isBlocking || limitsValidation.isWarning
+
return (
@@ -364,11 +406,24 @@ export default function OnrampBankPage() {
hideBalance
/>
-
+ {/* limits warning/error card */}
+ {showLimitsCard &&
+ (() => {
+ const limitsCardProps = getLimitsWarningCardProps({
+ validation: limitsValidation,
+ flowType: 'onramp',
+ currency: 'USD',
+ })
+ return limitsCardProps ? : null
+ })()}
+
+ {!limitsValidation.isBlocking && (
+
+ )}
Continue
- {error.showError && !!error.errorMessage && }
+ {/* only show error if limits blocking card is not displayed (warnings can coexist) */}
+ {error.showError && !!error.errorMessage && !limitsValidation.isBlocking && (
+
+ )}
('none')
@@ -383,6 +388,14 @@ export default function QRPayPage() {
}
}, [paymentProcessor, simpleFiPayment, paymentLock?.code, paymentLock?.paymentAgainstAmount, amount])
+ // validate payment against user's limits
+ // currency comes from payment lock/simplefi - hook normalizes it internally
+ const limitsValidation = useLimitsValidation({
+ flowType: 'qr-payment',
+ amount: usdAmount,
+ currency: currency?.code,
+ })
+
// Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount
// This way points are cached by the time success view shows
// Only Manteca QR payments give points (SimpleFi does not)
@@ -1535,7 +1548,20 @@ export default function QRPayPage() {
hideBalance
/>
)}
- {balanceErrorMessage && }
+ {/* only show balance error if limits blocking card is not displayed (warnings can coexist) */}
+ {balanceErrorMessage && !limitsValidation.isBlocking && (
+
+ )}
+
+ {/* Limits Warning/Error Card */}
+ {(() => {
+ const limitsCardProps = getLimitsWarningCardProps({
+ validation: limitsValidation,
+ flowType: 'qr-payment',
+ currency: limitsValidation.currency,
+ })
+ return limitsCardProps ? : null
+ })()}
{/* Information Card */}
@@ -1560,7 +1586,8 @@ export default function QRPayPage() {
shouldBlockPay ||
!usdAmount ||
usdAmount === '0.00' ||
- isWaitingForWebSocket
+ isWaitingForWebSocket ||
+ limitsValidation.isBlocking
}
>
{isLoading || isWaitingForWebSocket
diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
index 1436a8f7f..c2c27fcca 100644
--- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
@@ -45,12 +45,13 @@ import {
} from '@/constants/manteca.consts'
import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { TRANSACTIONS } from '@/constants/query.consts'
+import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
+import { MIN_MANTECA_WITHDRAW_AMOUNT } from '@/constants/payment.consts'
+import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard'
+import { getLimitsWarningCardProps } from '@/features/limits/utils'
type MantecaWithdrawStep = 'amountInput' | 'bankDetails' | 'review' | 'success' | 'failure'
-const MAX_WITHDRAW_AMOUNT = '2000'
-const MIN_WITHDRAW_AMOUNT = '1'
-
export default function MantecaWithdrawFlow() {
const flowId = useId() // Unique ID per flow instance to prevent cache collisions
const [currencyAmount, setCurrencyAmount] = useState(undefined)
@@ -94,7 +95,6 @@ export default function MantecaWithdrawFlow() {
const {
code: currencyCode,
- symbol: currencySymbol,
price: currencyPrice,
isLoading: isCurrencyLoading,
} = useCurrency(selectedCountry?.currency!)
@@ -102,6 +102,14 @@ export default function MantecaWithdrawFlow() {
// Initialize KYC flow hook
const { isMantecaKycRequired } = useMantecaKycFlow({ country: selectedCountry })
+ // validates withdrawal against user's limits
+ // currency comes from country config - hook normalizes it internally
+ const limitsValidation = useLimitsValidation({
+ flowType: 'offramp',
+ amount: usdAmount,
+ currency: selectedCountry?.currency,
+ })
+
// WebSocket listener for KYC status updates
useWebSocket({
username: user?.user.username ?? undefined,
@@ -290,10 +298,9 @@ export default function MantecaWithdrawFlow() {
return
}
const paymentAmount = parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS)
- if (paymentAmount < parseUnits(MIN_WITHDRAW_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) {
- setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_WITHDRAW_AMOUNT}`)
- } else if (paymentAmount > parseUnits(MAX_WITHDRAW_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) {
- setBalanceErrorMessage(`Withdraw amount exceeds maximum limit of $${MAX_WITHDRAW_AMOUNT}`)
+ // only check min amount and balance here - max amount is handled by limits validation
+ if (paymentAmount < parseUnits(MIN_MANTECA_WITHDRAW_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) {
+ setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_MANTECA_WITHDRAW_AMOUNT}`)
} else if (paymentAmount > balance) {
setBalanceErrorMessage('Not enough balance to complete withdrawal.')
} else {
@@ -429,6 +436,17 @@ export default function MantecaWithdrawFlow() {
balance ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : undefined
}
/>
+
+ {/* limits warning/error card - uses centralized helper for props */}
+ {(() => {
+ const limitsCardProps = getLimitsWarningCardProps({
+ validation: limitsValidation,
+ flowType: 'offramp',
+ currency: limitsValidation.currency,
+ })
+ return limitsCardProps ? : null
+ })()}
+
Continue
- {balanceErrorMessage && }
+ {/* only show balance error if limits blocking card is not displayed (warnings can coexist) */}
+ {balanceErrorMessage && !limitsValidation.isBlocking && (
+
+ )}
)}
diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx
index 19cc8feb8..f808f8de6 100644
--- a/src/app/(mobile-ui)/withdraw/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/page.tsx
@@ -14,6 +14,9 @@ import { getCountryFromAccount } from '@/utils/bridge.utils'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState, useRef, useContext } from 'react'
import { formatUnits } from 'viem'
+import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
+import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard'
+import { getLimitsWarningCardProps } from '@/features/limits/utils'
type WithdrawStep = 'inputAmount' | 'selectMethod'
@@ -79,6 +82,14 @@ export default function WithdrawPage() {
return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : ''
}, [balance])
+ // validate against user's limits for bank withdrawals
+ // note: crypto withdrawals don't have fiat limits
+ const limitsValidation = useLimitsValidation({
+ flowType: 'offramp',
+ amount: rawTokenAmount,
+ currency: 'USD',
+ })
+
// clear errors and reset any persisted state when component mounts to ensure clean state
useEffect(() => {
setError({ showError: false, errorMessage: '' })
@@ -247,6 +258,10 @@ export default function WithdrawPage() {
}, [rawTokenAmount, maxDecimalAmount, error.showError, selectedTokenData?.price])
if (step === 'inputAmount') {
+ // only show limits card for bank/manteca withdrawals, not crypto
+ const showLimitsCard =
+ selectedMethod?.type !== 'crypto' && (limitsValidation.isBlocking || limitsValidation.isWarning)
+
return (
+
+ {/* limits warning/error card for bank withdrawals */}
+ {showLimitsCard &&
+ (() => {
+ const limitsCardProps = getLimitsWarningCardProps({
+ validation: limitsValidation,
+ flowType: 'offramp',
+ currency: 'USD',
+ })
+ return limitsCardProps ? : null
+ })()}
+
Continue
- {error.showError && !!error.errorMessage && }
+ {/* only show error if limits blocking card is not displayed (warnings can coexist) */}
+ {error.showError && !!error.errorMessage && !limitsValidation.isBlocking && (
+
+ )}
)
diff --git a/src/components/AddMoney/components/InputAmountStep.tsx b/src/components/AddMoney/components/InputAmountStep.tsx
index e58cec3bc..a85a5923c 100644
--- a/src/components/AddMoney/components/InputAmountStep.tsx
+++ b/src/components/AddMoney/components/InputAmountStep.tsx
@@ -8,8 +8,14 @@ import { useRouter } from 'next/navigation'
import ErrorAlert from '@/components/Global/ErrorAlert'
import { useCurrency } from '@/hooks/useCurrency'
import PeanutLoading from '@/components/Global/PeanutLoading'
+import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard'
+import { getLimitsWarningCardProps, type LimitCurrency } from '@/features/limits/utils'
+import type { LimitValidationResult } from '@/features/limits/hooks/useLimitsValidation'
type ICurrency = ReturnType
+
+type LimitsValidationWithUser = LimitValidationResult & { isMantecaUser?: boolean }
+
interface InputAmountStepProps {
onSubmit: () => void
isLoading: boolean
@@ -21,6 +27,9 @@ interface InputAmountStepProps {
setCurrentDenomination?: (denomination: string) => void
initialDenomination?: string
setDisplayedAmount?: (value: string) => void
+ limitsValidation?: LimitsValidationWithUser
+ // required - must be provided by caller based on the payment flow's currency (ARS, BRL, USD)
+ limitsCurrency: LimitCurrency
}
const InputAmountStep = ({
@@ -34,6 +43,8 @@ const InputAmountStep = ({
setCurrentDenomination,
initialDenomination,
setDisplayedAmount,
+ limitsValidation,
+ limitsCurrency,
}: InputAmountStepProps) => {
const router = useRouter()
@@ -41,6 +52,14 @@ const InputAmountStep = ({
return
}
+ const limitsCardProps = limitsValidation
+ ? getLimitsWarningCardProps({
+ validation: limitsValidation,
+ flowType: 'onramp',
+ currency: limitsCurrency,
+ })
+ : null
+
return (
router.back()} />
@@ -66,6 +85,10 @@ const InputAmountStep = ({
setCurrentDenomination={setCurrentDenomination}
hideBalance
/>
+
+ {/* limits warning/error card */}
+ {limitsCardProps && }
+
This must exactly match what you send from your bank
@@ -74,13 +97,14 @@ const InputAmountStep = ({
variant="purple"
shadowSize="4"
onClick={onSubmit}
- disabled={!!error || isLoading || !parseFloat(tokenAmount)}
+ disabled={!!error || isLoading || !parseFloat(tokenAmount) || limitsValidation?.isBlocking}
className="w-full"
loading={isLoading}
>
Continue
- {error && }
+ {/* only show error if limits blocking card is not displayed (warnings can coexist) */}
+ {error && !limitsValidation?.isBlocking && }
)
diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx
index a3bada947..8fba376f6 100644
--- a/src/components/AddMoney/components/MantecaAddMoney.tsx
+++ b/src/components/AddMoney/components/MantecaAddMoney.tsx
@@ -14,10 +14,11 @@ import { mantecaApi } from '@/services/manteca'
import { parseUnits } from 'viem'
import { useQueryClient } from '@tanstack/react-query'
import useKycStatus from '@/hooks/useKycStatus'
-import { MAX_MANTECA_DEPOSIT_AMOUNT, MIN_MANTECA_DEPOSIT_AMOUNT } from '@/constants/payment.consts'
+import { MIN_MANTECA_DEPOSIT_AMOUNT } from '@/constants/payment.consts'
import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { TRANSACTIONS } from '@/constants/query.consts'
import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs'
+import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
// Step type for URL state
type MantecaStep = 'inputAmount' | 'depositDetails'
@@ -67,6 +68,14 @@ const MantecaAddMoney: FC = () => {
const currencyData = useCurrency(selectedCountry?.currency ?? 'ARS')
const { user, fetchUser } = useAuth()
+ // validates deposit amount against user's limits
+ // currency comes from country config - hook normalizes it internally
+ const limitsValidation = useLimitsValidation({
+ flowType: 'onramp',
+ amount: usdAmount,
+ currency: selectedCountry?.currency,
+ })
+
useWebSocket({
username: user?.user.username ?? undefined,
autoConnect: !!user?.user.username,
@@ -79,7 +88,7 @@ const MantecaAddMoney: FC = () => {
},
})
- // Validate USD amount (for min/max checks which are in USD)
+ // Validate USD amount (min check only - max is handled by limits validation)
useEffect(() => {
if (!usdAmount || usdAmount === '0.00') {
setError(null)
@@ -88,8 +97,6 @@ const MantecaAddMoney: FC = () => {
const paymentAmount = parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS)
if (paymentAmount < parseUnits(MIN_MANTECA_DEPOSIT_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) {
setError(`Deposit amount must be at least $${MIN_MANTECA_DEPOSIT_AMOUNT}`)
- } else if (paymentAmount > parseUnits(MAX_MANTECA_DEPOSIT_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) {
- setError(`Deposit amount exceeds maximum limit of $${MAX_MANTECA_DEPOSIT_AMOUNT}`)
} else {
setError(null)
}
@@ -207,6 +214,8 @@ const MantecaAddMoney: FC = () => {
setCurrentDenomination={handleDenominationChange}
initialDenomination={currentDenomination}
setDisplayedAmount={handleDisplayedAmountChange}
+ limitsValidation={limitsValidation}
+ limitsCurrency={limitsValidation.currency}
/>
{isKycModalOpen && (
>> =
clip: (props) => ,
info: (props) => ,
'external-link': (props) => ,
+ 'info-filled': (props) => ,
plus: (props) => ,
alert: (props) => ,
switch: (props) => ,
diff --git a/src/constants/countryCurrencyMapping.ts b/src/constants/countryCurrencyMapping.ts
index 26c926cca..d7f0d4e71 100644
--- a/src/constants/countryCurrencyMapping.ts
+++ b/src/constants/countryCurrencyMapping.ts
@@ -48,3 +48,38 @@ const countryCurrencyMappings: CountryCurrencyMapping[] = [
]
export default countryCurrencyMappings
+
+// country/currency utility functions
+
+/**
+ * generates flag url from iso2 country code
+ * uses flagcdn.com pattern used throughout the app
+ */
+export function getFlagUrl(iso2: string, size: 160 | 320 = 160): string {
+ return `https://flagcdn.com/w${size}/${iso2.toLowerCase()}.png`
+}
+
+/**
+ * maps currency code to its flag code (iso2)
+ * useful for getting flag from currency like ARS -> ar
+ */
+export function getCurrencyFlagCode(currencyCode: string): string | null {
+ const mapping = countryCurrencyMappings.find((m) => m.currencyCode.toUpperCase() === currencyCode.toUpperCase())
+ return mapping?.flagCode ?? null
+}
+
+/**
+ * gets flag url directly from currency code
+ * combines getCurrencyFlagCode + getFlagUrl
+ */
+export function getCurrencyFlagUrl(currencyCode: string, size: 160 | 320 = 160): string | null {
+ const flagCode = getCurrencyFlagCode(currencyCode)
+ return flagCode ? getFlagUrl(flagCode, size) : null
+}
+
+/**
+ * gets country info from currency code
+ */
+export function getCountryByCurrency(currencyCode: string): CountryCurrencyMapping | null {
+ return countryCurrencyMappings.find((m) => m.currencyCode.toUpperCase() === currencyCode.toUpperCase()) ?? null
+}
diff --git a/src/constants/payment.consts.ts b/src/constants/payment.consts.ts
index 9d51ef5b9..9c3914a04 100644
--- a/src/constants/payment.consts.ts
+++ b/src/constants/payment.consts.ts
@@ -7,6 +7,10 @@ export const MIN_PIX_AMOUNT = 5
export const MAX_MANTECA_DEPOSIT_AMOUNT = 2000
export const MIN_MANTECA_DEPOSIT_AMOUNT = 1
+// withdraw limits for manteca regional offramps (in USD)
+export const MAX_MANTECA_WITHDRAW_AMOUNT = 2000
+export const MIN_MANTECA_WITHDRAW_AMOUNT = 1
+
// QR payment limits for manteca (PIX, MercadoPago, QR3)
export const MIN_MANTECA_QR_PAYMENT_AMOUNT = 0.1 // Manteca provider minimum
export const MAX_QR_PAYMENT_AMOUNT_FOREIGN = 2000 // max per transaction for foreign users
diff --git a/src/features/limits/components/LimitsProgressBar.tsx b/src/features/limits/components/LimitsProgressBar.tsx
index e5f1273cd..58f2079dd 100644
--- a/src/features/limits/components/LimitsProgressBar.tsx
+++ b/src/features/limits/components/LimitsProgressBar.tsx
@@ -1,7 +1,7 @@
'use client'
import { twMerge } from 'tailwind-merge'
-import { getLimitColorClass } from '../utils/limits.utils'
+import { getLimitColorClass } from '../utils'
interface LimitsProgressBarProps {
total: number
diff --git a/src/features/limits/components/LimitsWarningCard.tsx b/src/features/limits/components/LimitsWarningCard.tsx
new file mode 100644
index 000000000..4bc625ae5
--- /dev/null
+++ b/src/features/limits/components/LimitsWarningCard.tsx
@@ -0,0 +1,77 @@
+'use client'
+
+import InfoCard from '@/components/Global/InfoCard'
+import { Icon } from '@/components/Global/Icons/Icon'
+import Link from 'next/link'
+import { useModalsContext } from '@/context/ModalsContext'
+import { twMerge } from 'tailwind-merge'
+import { LIMITS_COPY, type LimitsWarningItem } from '../utils'
+
+export type LimitsWarningType = 'warning' | 'error'
+
+export interface LimitsWarningCardProps {
+ type: LimitsWarningType
+ title: string
+ items: LimitsWarningItem[]
+ showSupportLink?: boolean
+ className?: string
+}
+
+/**
+ * reusable card for displaying limit warnings (yellow) or blocking errors (red)
+ * used across qr payments, add money, and withdraw flows
+ */
+export default function LimitsWarningCard({
+ type,
+ title,
+ items,
+ showSupportLink = true,
+ className,
+}: LimitsWarningCardProps) {
+ const { openSupportWithMessage } = useModalsContext()
+
+ return (
+
+
+ {items.map((item, index) => (
+
+ {item.isLink && item.href ? (
+
+ {item.icon && (
+
+ )}
+ {item.text}
+
+ ) : (
+ item.text
+ )}
+
+ ))}
+
+ {showSupportLink && (
+ <>
+
+ openSupportWithMessage(LIMITS_COPY.SUPPORT_MESSAGE)}
+ className="flex items-center gap-1 text-xs md:text-sm"
+ >
+
+
+ Need higher limits? Contact support.
+
+
+ >
+ )}
+
+ }
+ />
+ )
+}
diff --git a/src/features/limits/consts.ts b/src/features/limits/consts.ts
index 741375396..8e3904403 100644
--- a/src/features/limits/consts.ts
+++ b/src/features/limits/consts.ts
@@ -1,46 +1,45 @@
-// region path to provider mapping for navigation
-export const BRIDGE_REGIONS = ['europe', 'north-america', 'mexico', 'argentina', 'brazil']
-export const MANTECA_REGIONS = ['latam']
-
-// map region paths to bridge page region query param
-export const REGION_TO_BRIDGE_PARAM: Record = {
- europe: 'europe',
- 'north-america': 'us',
- mexico: 'mexico',
- argentina: 'argentina',
- brazil: 'brazil',
-}
-
-// region types for url state (source region from limits page)
-export type BridgeRegion = 'us' | 'mexico' | 'europe' | 'argentina' | 'brazil'
-
-// regions that show main limits (bank transfers)
-export const BANK_TRANSFER_REGIONS: BridgeRegion[] = ['us', 'mexico', 'europe']
-
-// qr-only countries config
-export const QR_COUNTRIES = [
- { id: 'argentina', name: 'Argentina', flag: 'https://flagcdn.com/w160/ar.png' },
- { id: 'brazil', name: 'Brazil', flag: 'https://flagcdn.com/w160/br.png' },
-] as const
-
-export type QrCountryId = (typeof QR_COUNTRIES)[number]['id']
+import { getFlagUrl } from '@/constants/countryCurrencyMapping'
+import { MANTECA_ALPHA3_TO_ALPHA2, countryData } from '@/components/AddMoney/consts'
export const LIMITS_PROVIDERS = ['bridge', 'manteca'] as const
export type LimitsProvider = (typeof LIMITS_PROVIDERS)[number]
-// currency/country to flag mapping
-export const LIMITS_CURRENCY_FLAGS: Record = {
- ARS: 'https://flagcdn.com/w160/ar.png',
- BRL: 'https://flagcdn.com/w160/br.png',
- ARG: 'https://flagcdn.com/w160/ar.png',
- BRA: 'https://flagcdn.com/w160/br.png',
+// qr-only countries - derived from manteca supported countries
+// these are countries where bridge users can make qr payments
+
+// manteca countries are qr-payment enabled countries (argentina, brazil)
+const MANTECA_COUNTRY_ISO2 = Object.values(MANTECA_ALPHA3_TO_ALPHA2) // ['AR', 'BR']
+
+// derive qr country data from centralized countryData
+const derivedQrCountries = countryData
+ .filter((c) => c.type === 'country' && c.iso2 && MANTECA_COUNTRY_ISO2.includes(c.iso2))
+ .map((c) => ({
+ id: c.path, // 'argentina', 'brazil'
+ name: c.title, // 'Argentina', 'Brazil'
+ flagCode: c.iso2!.toLowerCase(), // 'ar', 'br'
+ }))
+
+export type QrCountryId = 'argentina' | 'brazil'
+
+/**
+ * get qr-only country with resolved flag url
+ * avoids hardcoding flag urls - uses centralized getFlagUrl
+ */
+export function getQrCountryWithFlag(id: QrCountryId) {
+ const country = derivedQrCountries.find((c) => c.id === id)
+ if (!country) return null
+ return {
+ ...country,
+ flag: getFlagUrl(country.flagCode),
+ }
}
-// currency to symbol mapping
-export const LIMITS_CURRENCY_SYMBOLS: Record = {
- ARS: 'ARS',
- BRL: 'R$',
- USD: '$',
+/**
+ * get all qr-only countries with resolved flag urls
+ */
+export function getQrCountriesWithFlags() {
+ return derivedQrCountries.map((country) => ({
+ ...country,
+ flag: getFlagUrl(country.flagCode),
+ }))
}
-
-export type LimitsPeriod = 'monthly' | 'yearly'
diff --git a/src/features/limits/hooks/useLimitsValidation.ts b/src/features/limits/hooks/useLimitsValidation.ts
new file mode 100644
index 000000000..b2743c350
--- /dev/null
+++ b/src/features/limits/hooks/useLimitsValidation.ts
@@ -0,0 +1,311 @@
+'use client'
+
+import { useMemo } from 'react'
+import { useLimits } from '@/hooks/useLimits'
+import useKycStatus from '@/hooks/useKycStatus'
+import type { MantecaLimit } from '@/interfaces'
+import {
+ MAX_QR_PAYMENT_AMOUNT_FOREIGN,
+ MAX_MANTECA_DEPOSIT_AMOUNT,
+ MAX_MANTECA_WITHDRAW_AMOUNT,
+} from '@/constants/payment.consts'
+import { formatExtendedNumber } from '@/utils/general.utils'
+import { mapToLimitCurrency, type LimitCurrency } from '../utils'
+
+// threshold for showing warning (percentage of limit remaining after transaction)
+const WARNING_THRESHOLD_PERCENT = 30
+
+export type LimitValidationResult = {
+ isBlocking: boolean
+ isWarning: boolean
+ remainingLimit: number | null
+ totalLimit: number | null
+ message: string | null
+ daysUntilReset: number | null
+ // currency the limit is denominated in (may differ from transaction currency)
+ // e.g. foreign qr users have USD limits even when paying in ARS
+ limitCurrency: LimitCurrency | null
+}
+
+export type LimitFlowType = 'onramp' | 'offramp' | 'qr-payment'
+export { type LimitCurrency } from '../utils'
+
+interface UseLimitsValidationOptions {
+ flowType: LimitFlowType
+ amount: number | string | null | undefined
+ /**
+ * currency code for the transaction (ARS, BRL, USD)
+ * for qr payments this determines which manteca limit to check
+ * defaults to USD
+ */
+ currency?: LimitCurrency | string
+}
+
+/**
+ * hook to validate amounts against user's transaction limits
+ * automatically determines if user is local (manteca) or foreign (bridge) based on their kyc status
+ * returns warning/blocking state based on remaining limits
+ */
+export function useLimitsValidation({ flowType, amount, currency: currencyInput }: UseLimitsValidationOptions) {
+ const { mantecaLimits, bridgeLimits, isLoading, hasMantecaLimits, hasBridgeLimits } = useLimits()
+ const { isUserMantecaKycApproved, isUserBridgeKycApproved } = useKycStatus()
+
+ // normalize currency to valid LimitCurrency type
+ const currency = mapToLimitCurrency(currencyInput)
+
+ // determine if user is "local" (has manteca kyc for latam operations)
+ // this replaces the external isLocalUser parameter
+ const isLocalUser = isUserMantecaKycApproved
+
+ // parse amount to number - strip commas to handle "1,200" format
+ const numericAmount = useMemo(() => {
+ if (!amount) return 0
+ const sanitized = typeof amount === 'string' ? amount.replace(/,/g, '') : amount
+ const parsed = typeof sanitized === 'string' ? parseFloat(sanitized) : sanitized
+ return isNaN(parsed) ? 0 : parsed
+ }, [amount])
+
+ // get relevant manteca limit based on currency
+ const relevantMantecaLimit = useMemo(() => {
+ if (!mantecaLimits || mantecaLimits.length === 0) return null
+ return mantecaLimits.find((limit) => limit.asset === currency) ?? null
+ }, [mantecaLimits, currency])
+
+ // calculate days until monthly reset (first of next month)
+ const daysUntilMonthlyReset = useMemo(() => {
+ const now = new Date()
+ const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
+ const diffTime = nextMonth.getTime() - now.getTime()
+ return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
+ }, [])
+
+ // validate for manteca users (argentina/brazil)
+ const mantecaValidation = useMemo(() => {
+ if (!isUserMantecaKycApproved || !relevantMantecaLimit) {
+ return {
+ isBlocking: false,
+ isWarning: false,
+ remainingLimit: null,
+ totalLimit: null,
+ message: null,
+ daysUntilReset: null,
+ limitCurrency: null,
+ }
+ }
+
+ const monthlyLimit = parseFloat(relevantMantecaLimit.monthlyLimit)
+ const availableMonthly = parseFloat(relevantMantecaLimit.availableMonthlyLimit)
+
+ // per-transaction max for manteca (different for onramp vs offramp)
+ const perTxMax = flowType === 'onramp' ? MAX_MANTECA_DEPOSIT_AMOUNT : MAX_MANTECA_WITHDRAW_AMOUNT
+
+ // effective limit is the lower of per-tx max and available monthly
+ const effectiveLimit = Math.min(perTxMax, availableMonthly)
+
+ // check if amount exceeds per-transaction max first (more restrictive)
+ // per-tx limits are in USD
+ if (numericAmount > perTxMax) {
+ return {
+ isBlocking: true,
+ isWarning: false,
+ remainingLimit: perTxMax,
+ totalLimit: perTxMax,
+ message: `Maximum ${flowType === 'onramp' ? 'deposit' : 'withdrawal'} is $${formatExtendedNumber(perTxMax)} per transaction`,
+ daysUntilReset: null, // per-tx limit doesn't reset
+ limitCurrency: 'USD',
+ }
+ }
+
+ // check if amount exceeds remaining monthly limit
+ // monthly limits are in local currency
+ if (numericAmount > availableMonthly) {
+ return {
+ isBlocking: true,
+ isWarning: false,
+ remainingLimit: availableMonthly,
+ totalLimit: monthlyLimit,
+ message: `Amount exceeds your remaining limit of ${formatExtendedNumber(availableMonthly)} ${currency}`,
+ daysUntilReset: daysUntilMonthlyReset,
+ limitCurrency: currency,
+ }
+ }
+
+ // check if amount is close to limit (warning)
+ const afterTransaction = availableMonthly - numericAmount
+ const afterPercent = monthlyLimit > 0 ? (afterTransaction / monthlyLimit) * 100 : 0
+
+ if (afterPercent < WARNING_THRESHOLD_PERCENT && numericAmount > 0) {
+ return {
+ isBlocking: false,
+ isWarning: true,
+ remainingLimit: effectiveLimit,
+ totalLimit: monthlyLimit,
+ message: `This transaction will use most of your remaining limit`,
+ daysUntilReset: daysUntilMonthlyReset,
+ limitCurrency: currency,
+ }
+ }
+
+ return {
+ isBlocking: false,
+ isWarning: false,
+ remainingLimit: effectiveLimit,
+ totalLimit: monthlyLimit,
+ message: null,
+ daysUntilReset: daysUntilMonthlyReset,
+ limitCurrency: currency,
+ }
+ }, [isUserMantecaKycApproved, relevantMantecaLimit, numericAmount, currency, daysUntilMonthlyReset, flowType])
+
+ // validate for bridge users (us/europe/mexico) - per transaction limits
+ // bridge limits are always in USD
+ const bridgeValidation = useMemo(() => {
+ if (!isUserBridgeKycApproved || !bridgeLimits) {
+ return {
+ isBlocking: false,
+ isWarning: false,
+ remainingLimit: null,
+ totalLimit: null,
+ message: null,
+ daysUntilReset: null,
+ limitCurrency: null,
+ }
+ }
+
+ // bridge has per-transaction limits, not cumulative
+ const perTxLimit =
+ flowType === 'onramp'
+ ? parseFloat(bridgeLimits.onRampPerTransaction)
+ : parseFloat(bridgeLimits.offRampPerTransaction)
+
+ if (numericAmount > perTxLimit) {
+ return {
+ isBlocking: true,
+ isWarning: false,
+ remainingLimit: perTxLimit,
+ totalLimit: perTxLimit,
+ message: `Amount exceeds per-transaction limit of $${formatExtendedNumber(perTxLimit)}`,
+ daysUntilReset: null,
+ limitCurrency: 'USD',
+ }
+ }
+
+ // warning when close to per-tx limit
+ const usagePercent = perTxLimit > 0 ? (numericAmount / perTxLimit) * 100 : 0
+ if (usagePercent > 80 && numericAmount > 0) {
+ return {
+ isBlocking: false,
+ isWarning: true,
+ remainingLimit: perTxLimit,
+ totalLimit: perTxLimit,
+ message: `This amount is close to your per-transaction limit`,
+ daysUntilReset: null,
+ limitCurrency: 'USD',
+ }
+ }
+
+ return {
+ isBlocking: false,
+ isWarning: false,
+ remainingLimit: perTxLimit,
+ totalLimit: perTxLimit,
+ message: null,
+ daysUntilReset: null,
+ limitCurrency: 'USD',
+ }
+ }, [isUserBridgeKycApproved, bridgeLimits, flowType, numericAmount])
+
+ // qr payment validation for foreign users (non-manteca kyc)
+ // foreign qr limits are always in USD
+ const foreignQrValidation = useMemo(() => {
+ if (flowType !== 'qr-payment' || isLocalUser) {
+ return {
+ isBlocking: false,
+ isWarning: false,
+ remainingLimit: null,
+ totalLimit: null,
+ message: null,
+ daysUntilReset: null,
+ limitCurrency: null,
+ }
+ }
+
+ // foreign users have a per-transaction limit for qr payments
+ if (numericAmount > MAX_QR_PAYMENT_AMOUNT_FOREIGN) {
+ return {
+ isBlocking: true,
+ isWarning: false,
+ remainingLimit: MAX_QR_PAYMENT_AMOUNT_FOREIGN,
+ totalLimit: MAX_QR_PAYMENT_AMOUNT_FOREIGN,
+ message: `QR payment limit is $${MAX_QR_PAYMENT_AMOUNT_FOREIGN.toLocaleString()} per transaction`,
+ daysUntilReset: null,
+ limitCurrency: 'USD',
+ }
+ }
+
+ return {
+ isBlocking: false,
+ isWarning: false,
+ remainingLimit: MAX_QR_PAYMENT_AMOUNT_FOREIGN,
+ totalLimit: MAX_QR_PAYMENT_AMOUNT_FOREIGN,
+ message: null,
+ daysUntilReset: null,
+ limitCurrency: 'USD',
+ }
+ }, [flowType, isLocalUser, numericAmount])
+
+ // combined result - prioritize manteca for local users, bridge for others
+ const validation = useMemo(() => {
+ // for qr payments
+ if (flowType === 'qr-payment') {
+ // local users (manteca kyc) use manteca limits
+ if (isLocalUser && isUserMantecaKycApproved) {
+ return mantecaValidation
+ }
+ // foreign users have fixed per-tx limit
+ return foreignQrValidation
+ }
+
+ // for onramp/offramp - check which provider applies
+ // only use manteca if there's a relevant limit for the currency (prevents skipping bridge validation)
+ if (isUserMantecaKycApproved && hasMantecaLimits && relevantMantecaLimit) {
+ return mantecaValidation
+ }
+ if (isUserBridgeKycApproved && hasBridgeLimits) {
+ return bridgeValidation
+ }
+
+ // no kyc - no limits to validate
+ return {
+ isBlocking: false,
+ isWarning: false,
+ remainingLimit: null,
+ totalLimit: null,
+ message: null,
+ daysUntilReset: null,
+ limitCurrency: null,
+ }
+ }, [
+ flowType,
+ isLocalUser,
+ isUserMantecaKycApproved,
+ isUserBridgeKycApproved,
+ hasMantecaLimits,
+ hasBridgeLimits,
+ relevantMantecaLimit,
+ mantecaValidation,
+ bridgeValidation,
+ foreignQrValidation,
+ ])
+
+ return {
+ ...validation,
+ isLoading,
+ // the effective currency being used for validation
+ currency,
+ // convenience getters
+ hasLimits: hasMantecaLimits || hasBridgeLimits,
+ isMantecaUser: isUserMantecaKycApproved,
+ isBridgeUser: isUserBridgeKycApproved,
+ }
+}
diff --git a/src/features/limits/utils.ts b/src/features/limits/utils.ts
new file mode 100644
index 000000000..98927c758
--- /dev/null
+++ b/src/features/limits/utils.ts
@@ -0,0 +1,209 @@
+'use client'
+
+import type { MantecaLimit } from '@/interfaces'
+import { SYMBOLS_BY_CURRENCY_CODE } from '@/hooks/useCurrency'
+import { getCurrencyFlagUrl } from '@/constants/countryCurrencyMapping'
+import { formatExtendedNumber } from '@/utils/general.utils'
+import type { LimitValidationResult, LimitFlowType } from './hooks/useLimitsValidation'
+import type { IconName } from '@/components/Global/Icons/Icon'
+
+// limits period type, used in tabs for limits page
+export type LimitsPeriod = 'monthly' | 'yearly'
+
+// region routing configuration
+// maps region paths to their respective limits page routes
+const REGION_ROUTES: Record = {
+ // bridge regions
+ europe: { provider: 'bridge', param: 'europe' },
+ 'north-america': { provider: 'bridge', param: 'us' },
+ mexico: { provider: 'bridge', param: 'mexico' },
+ argentina: { provider: 'bridge', param: 'argentina' },
+ brazil: { provider: 'bridge', param: 'brazil' },
+ latam: { provider: 'manteca' },
+}
+
+// regions that show bank transfer limits (not qr-only)
+export const BANK_TRANSFER_REGIONS = ['us', 'mexico', 'europe'] as const
+export type BridgeRegion = 'us' | 'mexico' | 'europe' | 'argentina' | 'brazil'
+
+// ux copy constants
+export const LIMITS_COPY = {
+ BLOCKING_TITLE: 'This amount exceeds your limit.',
+ WARNING_TITLE: "You're close to your limit.",
+ CHECK_LIMITS: 'Check my limits.',
+ SUPPORT_MESSAGE: 'Hi, I would like to increase my payment limits.',
+} as const
+
+// utility functions - used across the limits feature
+
+/**
+ * determines which provider route to navigate to based on region path
+ */
+export function getProviderRoute(regionPath: string, hasMantecaKyc: boolean): string {
+ const route = REGION_ROUTES[regionPath]
+
+ // latam region goes to manteca if user has manteca kyc
+ if (regionPath === 'latam' && hasMantecaKyc) {
+ return '/limits/manteca'
+ }
+
+ if (route) {
+ if (route.provider === 'manteca') {
+ return '/limits/manteca'
+ }
+ return `/limits/bridge${route.param ? `?region=${route.param}` : ''}`
+ }
+
+ // default fallback
+ return '/limits/bridge?region=us'
+}
+
+/**
+ * maps a currency string to a valid limit currency type
+ * defaults to USD for unsupported currencies
+ */
+export type LimitCurrency = 'ARS' | 'BRL' | 'USD'
+
+export function mapToLimitCurrency(currency?: string): LimitCurrency {
+ const upper = currency?.toUpperCase()
+ if (upper === 'ARS' || upper === 'BRL') return upper as LimitCurrency
+ return 'USD'
+}
+
+/**
+ * get currency symbol from currency code
+ * uses centralized SYMBOLS_BY_CURRENCY_CODE from useCurrency
+ */
+export function getCurrencySymbol(currency: string): string {
+ return SYMBOLS_BY_CURRENCY_CODE[currency.toUpperCase()] || currency.toUpperCase()
+}
+
+/**
+ * get flag url from currency code
+ * uses centralized getCurrencyFlagUrl from countryCurrencyMapping
+ */
+export function getFlagUrlForCurrency(currency: string): string | null {
+ return getCurrencyFlagUrl(currency)
+}
+
+/**
+ * format amount with currency symbol
+ */
+export function formatAmountWithCurrency(amount: number, currency: string): string {
+ const symbol = getCurrencySymbol(currency)
+ // add space for currency codes (length > 1), not for symbols like $ or €
+ const separator = symbol.length > 1 && symbol === symbol.toUpperCase() ? ' ' : ''
+ return `${symbol}${separator}${formatExtendedNumber(amount)}`
+}
+
+/**
+ * get limit and remaining values for the selected period
+ */
+export function getLimitData(limit: MantecaLimit, period: LimitsPeriod) {
+ if (period === 'monthly') {
+ return {
+ limit: parseFloat(limit.monthlyLimit),
+ remaining: parseFloat(limit.availableMonthlyLimit),
+ }
+ }
+ return {
+ limit: parseFloat(limit.yearlyLimit),
+ remaining: parseFloat(limit.availableYearlyLimit),
+ }
+}
+
+// limit color thresholds
+const LIMIT_HEALTHY_THRESHOLD = 70 // >70% remaining = green
+const LIMIT_WARNING_THRESHOLD = 20 // 20-70% remaining = yellow, <20% = red
+
+/**
+ * get color class for remaining percentage
+ * used by both progress bar and text coloring
+ */
+export function getLimitColorClass(remainingPercent: number, type: 'bg' | 'text'): string {
+ if (remainingPercent > LIMIT_HEALTHY_THRESHOLD) {
+ return type === 'bg' ? 'bg-success-3' : 'text-success-1'
+ }
+ if (remainingPercent > LIMIT_WARNING_THRESHOLD) {
+ return type === 'bg' ? 'bg-yellow-1' : 'text-yellow-1'
+ }
+ return type === 'bg' ? 'bg-error-4' : 'text-error-4'
+}
+
+// limits warning card helper - eliminates DRY violations
+// generates props for LimitsWarningCard based on validation result
+
+export interface LimitsWarningItem {
+ text: string
+ isLink?: boolean
+ href?: string
+ icon?: IconName
+}
+
+interface LimitsWarningCardPropsResult {
+ type: 'warning' | 'error'
+ title: string
+ items: LimitsWarningItem[]
+ showSupportLink: boolean
+}
+
+interface GetLimitsWarningCardPropsOptions {
+ validation: LimitValidationResult & { isMantecaUser?: boolean }
+ flowType: LimitFlowType
+ currency?: string
+}
+
+/**
+ * generates LimitsWarningCard props from validation result
+ * centralizes the logic that was duplicated across 4+ files
+ * uses validation.limitCurrency to show correct currency (e.g. USD for foreign qr limits)
+ */
+export function getLimitsWarningCardProps({
+ validation,
+ flowType,
+ currency,
+}: GetLimitsWarningCardPropsOptions): LimitsWarningCardPropsResult | null {
+ // no warning needed if not blocking or warning
+ if (!validation.isBlocking && !validation.isWarning) {
+ return null
+ }
+
+ const items: LimitsWarningItem[] = []
+ // use limitCurrency from validation (correct currency for the limit) or fallback to transaction currency
+ const limitCurrency = validation.limitCurrency ?? currency ?? 'USD'
+ const currencySymbol = getCurrencySymbol(limitCurrency)
+ const formattedLimit = formatExtendedNumber(validation.remainingLimit ?? 0)
+
+ // build the limit message based on flow type
+ let limitMessage = ''
+ if (flowType === 'onramp') {
+ limitMessage = `You can add up to ${currencySymbol === '$' ? '' : currencySymbol + ' '}${currencySymbol === '$' ? '$' : ''}${formattedLimit}${validation.daysUntilReset ? '' : ' per transaction'}`
+ } else if (flowType === 'offramp') {
+ limitMessage = `You can withdraw up to ${currencySymbol === '$' ? '' : currencySymbol + ' '}${currencySymbol === '$' ? '$' : ''}${formattedLimit}${validation.daysUntilReset ? '' : ' per transaction'}`
+ } else {
+ // qr-payment
+ limitMessage = `You can pay up to ${currencySymbol === '$' ? '' : currencySymbol + ' '}${currencySymbol === '$' ? '$' : ''}${formattedLimit} per transaction`
+ }
+
+ items.push({ text: limitMessage })
+
+ // add days until reset if applicable
+ if (validation.daysUntilReset) {
+ items.push({ text: `Limit resets in ${validation.daysUntilReset} days.` })
+ }
+
+ // add check limits link
+ items.push({
+ text: LIMITS_COPY.CHECK_LIMITS,
+ isLink: true,
+ href: '/limits',
+ icon: 'external-link',
+ })
+
+ return {
+ type: validation.isBlocking ? 'error' : 'warning',
+ title: validation.isBlocking ? LIMITS_COPY.BLOCKING_TITLE : LIMITS_COPY.WARNING_TITLE,
+ items,
+ showSupportLink: validation.isMantecaUser ?? false,
+ }
+}
diff --git a/src/features/limits/utils/limits.utils.ts b/src/features/limits/utils/limits.utils.ts
deleted file mode 100644
index ef4b4542d..000000000
--- a/src/features/limits/utils/limits.utils.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { MantecaLimit } from '@/interfaces'
-import { BRIDGE_REGIONS, MANTECA_REGIONS, REGION_TO_BRIDGE_PARAM, type LimitsPeriod } from '../consts'
-
-/**
- * determines which provider route to navigate to based on region path
- * includes region query param for bridge limits page
- */
-export function getProviderRoute(regionPath: string, hasMantecaKyc: boolean): string {
- // latam region always goes to manteca if user has manteca kyc
- if (MANTECA_REGIONS.includes(regionPath) && hasMantecaKyc) {
- return '/limits/manteca'
- }
- // bridge regions go to bridge with region param
- if (BRIDGE_REGIONS.includes(regionPath)) {
- const regionParam = REGION_TO_BRIDGE_PARAM[regionPath] || 'us'
- return `/limits/bridge?region=${regionParam}`
- }
- // default to bridge for any other unlocked region
- return '/limits/bridge?region=us'
-}
-
-/**
- * get limit and remaining values for the selected period
- */
-export function getLimitData(limit: MantecaLimit, period: LimitsPeriod) {
- if (period === 'monthly') {
- return {
- limit: parseFloat(limit.monthlyLimit),
- remaining: parseFloat(limit.availableMonthlyLimit),
- }
- }
- return {
- limit: parseFloat(limit.yearlyLimit),
- remaining: parseFloat(limit.availableYearlyLimit),
- }
-}
-
-// thresholds for limit usage coloring
-const LIMIT_HEALTHY_THRESHOLD = 70 // >70% remaining = green
-const LIMIT_WARNING_THRESHOLD = 20 // 20-70% remaining = yellow, <20% = red
-
-/**
- * get color class for remaining percentage
- * used by both progress bar and text coloring
- */
-export function getLimitColorClass(remainingPercent: number, type: 'bg' | 'text'): string {
- if (remainingPercent > LIMIT_HEALTHY_THRESHOLD) {
- return type === 'bg' ? 'bg-success-3' : 'text-success-1'
- }
- if (remainingPercent > LIMIT_WARNING_THRESHOLD) {
- return type === 'bg' ? 'bg-yellow-1' : 'text-yellow-1'
- }
- return type === 'bg' ? 'bg-error-4' : 'text-error-4'
-}
diff --git a/src/features/limits/views/BridgeLimitsView.tsx b/src/features/limits/views/BridgeLimitsView.tsx
index 185b7ca06..8bbb6fc5e 100644
--- a/src/features/limits/views/BridgeLimitsView.tsx
+++ b/src/features/limits/views/BridgeLimitsView.tsx
@@ -10,10 +10,10 @@ import { MAX_QR_PAYMENT_AMOUNT_FOREIGN } from '@/constants/payment.consts'
import Image from 'next/image'
import * as Accordion from '@radix-ui/react-accordion'
import { useQueryState, parseAsStringEnum } from 'nuqs'
-import { useState } from 'react'
+import { useState, useMemo } from 'react'
import PeanutLoading from '@/components/Global/PeanutLoading'
-import { BANK_TRANSFER_REGIONS, QR_COUNTRIES, type BridgeRegion, type QrCountryId } from '../consts'
-import { formatExtendedNumber } from '@/utils/general.utils'
+import { getQrCountriesWithFlags, type QrCountryId } from '../consts'
+import { BANK_TRANSFER_REGIONS, type BridgeRegion, formatAmountWithCurrency } from '../utils'
import LimitsError from '../components/LimitsError'
import LimitsDocsLink from '../components/LimitsDocsLink'
import EmptyState from '@/components/Global/EmptyStates/EmptyState'
@@ -34,17 +34,17 @@ const BridgeLimitsView = () => {
parseAsStringEnum(['us', 'mexico', 'europe', 'argentina', 'brazil']).withDefault('us')
)
- // local state for qr accordion (doesn't affect url)
- const [expandedCountry, setExpandedCountry] = useState(undefined)
+ // local state for qr accordion - auto-expand if region is a qr country
+ const [expandedCountry, setExpandedCountry] = useState(
+ region === 'argentina' || region === 'brazil' ? region : undefined
+ )
- // determine what to show based on source region
- const showBankTransferLimits = BANK_TRANSFER_REGIONS.includes(region)
+ // get qr countries with resolved flag urls (uses centralized flag utility)
+ const qrCountries = useMemo(() => getQrCountriesWithFlags(), [])
- // format limit amount with currency symbol using shared util
- const formatLimit = (amount: string, asset: string) => {
- const symbol = asset === 'USD' ? '$' : asset
- return `${symbol}${formatExtendedNumber(amount)}`
- }
+ // determine what to show based on source region
+ // cast needed because region type is wider than BANK_TRANSFER_REGIONS tuple
+ const showBankTransferLimits = (BANK_TRANSFER_REGIONS as readonly string[]).includes(region)
return (
@@ -66,16 +66,22 @@ const BridgeLimitsView = () => {
Add money: up to{' '}
- {formatLimit(bridgeLimits.onRampPerTransaction, bridgeLimits.asset)} per
- transaction
+ {formatAmountWithCurrency(
+ parseFloat(bridgeLimits.onRampPerTransaction),
+ bridgeLimits.asset
+ )}{' '}
+ per transaction
Withdrawing: up to{' '}
- {formatLimit(bridgeLimits.offRampPerTransaction, bridgeLimits.asset)} per
- transaction
+ {formatAmountWithCurrency(
+ parseFloat(bridgeLimits.offRampPerTransaction),
+ bridgeLimits.asset
+ )}{' '}
+ per transaction
@@ -94,11 +100,11 @@ const BridgeLimitsView = () => {
value={expandedCountry}
onValueChange={(value) => setExpandedCountry(value as QrCountryId | undefined)}
>
- {QR_COUNTRIES.map((country, index) => (
+ {qrCountries.map((country, index) => (
diff --git a/src/features/limits/views/LimitsPageView.tsx b/src/features/limits/views/LimitsPageView.tsx
index 34f0edafd..8579fc7c8 100644
--- a/src/features/limits/views/LimitsPageView.tsx
+++ b/src/features/limits/views/LimitsPageView.tsx
@@ -13,8 +13,8 @@ import { useMemo } from 'react'
import CryptoLimitsSection from '../components/CryptoLimitsSection'
import FiatLimitsLockedCard from '../components/FiatLimitsLockedCard'
import { REST_OF_WORLD_GLOBE_ICON } from '@/assets'
-import { getProviderRoute } from '../utils/limits.utils'
import InfoCard from '@/components/Global/InfoCard'
+import { getProviderRoute } from '../utils'
const LimitsPageView = () => {
const router = useRouter()
diff --git a/src/features/limits/views/MantecaLimitsView.tsx b/src/features/limits/views/MantecaLimitsView.tsx
index 1e9c7c3fb..9fdc5738b 100644
--- a/src/features/limits/views/MantecaLimitsView.tsx
+++ b/src/features/limits/views/MantecaLimitsView.tsx
@@ -10,10 +10,14 @@ import PeriodToggle from '../components/PeriodToggle'
import LimitsProgressBar from '../components/LimitsProgressBar'
import Image from 'next/image'
import PeanutLoading from '@/components/Global/PeanutLoading'
-import { LIMITS_CURRENCY_FLAGS, LIMITS_CURRENCY_SYMBOLS, type LimitsPeriod } from '../consts'
-import { getLimitData, getLimitColorClass } from '../utils/limits.utils'
+import {
+ getLimitData,
+ getLimitColorClass,
+ formatAmountWithCurrency,
+ getFlagUrlForCurrency,
+ type LimitsPeriod,
+} from '../utils'
import IncreaseLimitsButton from '../components/IncreaseLimitsButton'
-import { formatExtendedNumber } from '@/utils/general.utils'
import LimitsError from '../components/LimitsError'
import LimitsDocsLink from '../components/LimitsDocsLink'
import EmptyState from '@/components/Global/EmptyStates/EmptyState'
@@ -27,14 +31,6 @@ const MantecaLimitsView = () => {
const { mantecaLimits, isLoading, error } = useLimits()
const [period, setPeriod] = useState('monthly')
- // format amount with currency symbol using shared util
- const formatLimitAmount = (amount: number, currency: string) => {
- const symbol = LIMITS_CURRENCY_SYMBOLS[currency] || currency
- // add space for currency codes (length > 1), not for symbols like $ or €
- const separator = symbol.length > 1 && symbol === symbol.toUpperCase() ? ' ' : ''
- return `${symbol}${separator}${formatExtendedNumber(amount)}`
- }
-
return (
router.back()} titleClassName="text-xl md:text-2xl" />
@@ -49,8 +45,8 @@ const MantecaLimitsView = () => {
{mantecaLimits.map((limit) => {
const limitData = getLimitData(limit, period)
- const flagUrl =
- LIMITS_CURRENCY_FLAGS[limit.asset] || LIMITS_CURRENCY_FLAGS[limit.exchangeCountry]
+ // use centralized flag url utility
+ const flagUrl = getFlagUrlForCurrency(limit.asset)
// calculate remaining percentage for text color
const remainingPercent =
@@ -75,7 +71,7 @@ const MantecaLimitsView = () => {
- {formatLimitAmount(limitData.limit, limit.asset)}
+ {formatAmountWithCurrency(limitData.limit, limit.asset)}
@@ -85,7 +81,7 @@ const MantecaLimitsView = () => {
Remaining this {period === 'monthly' ? 'month' : 'year'}
- {formatLimitAmount(limitData.remaining, limit.asset)}
+ {formatAmountWithCurrency(limitData.remaining, limit.asset)}