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 && ( + + )} - {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 + })()} + - {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 + })()} + - {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 && ( + <> +
+ + + )} +
+ } + /> + ) +} 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)}