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 && ( + + )}
+} + +export default async function ProviderLimitsPage({ params }: ProviderLimitsPageProps) { + const { provider } = await params + + // validate provider - notFound() is safe in server components + if (!LIMITS_PROVIDERS.includes(provider as LimitsProvider)) { + notFound() + } + + return ( + + {provider === 'bridge' && } + {provider === 'manteca' && } + + ) +} diff --git a/src/app/(mobile-ui)/limits/page.tsx b/src/app/(mobile-ui)/limits/page.tsx new file mode 100644 index 000000000..7ffd92336 --- /dev/null +++ b/src/app/(mobile-ui)/limits/page.tsx @@ -0,0 +1,10 @@ +import PageContainer from '@/components/0_Bruddle/PageContainer' +import LimitsPageView from '@/features/limits/views/LimitsPageView' + +export default function LimitsPageRoute() { + return ( + + + + ) +} diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 47a7556d2..15c272f5b 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -66,6 +66,10 @@ import { useModalsContext } from '@/context/ModalsContext' import maintenanceConfig from '@/config/underMaintenance.config' import PointsCard from '@/components/Common/PointsCard' import { TRANSACTIONS } from '@/constants/query.consts' +import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' +import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' +import { getLimitsWarningCardProps } from '@/features/limits/utils' +import useKycStatus from '@/hooks/useKycStatus' const MAX_QR_PAYMENT_AMOUNT = '2000' const MIN_QR_PAYMENT_AMOUNT = '0.1' @@ -118,6 +122,7 @@ export default function QRPayPage() { }, [paymentProcessor]) const { shouldBlockPay, kycGateState } = useQrKycGate(paymentProcessor) + const { isUserMantecaKycApproved } = useKycStatus() const queryClient = useQueryClient() const [isShaking, setIsShaking] = useState(false) const [shakeIntensity, setShakeIntensity] = useState('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/app/not-found.tsx b/src/app/not-found.tsx index 5ceecfdc9..7a66bb1ad 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -7,11 +7,11 @@ export default function NotFound() { return (
-
+

Not found

Peanutman crying 😭

Woah there buddy, you're not supposed to be here.

- + Take me home, I'm scared
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) => , @@ -274,6 +279,7 @@ const iconComponents: Record>> = 'plus-circle': (props) => , 'minus-circle': (props) => , 'arrow-exchange': (props) => , + meter: (props) => , // custom icons 'txn-off': TxnOffIcon, docs: DocsIcon, diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index cbf3439b1..c4426cfad 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -87,6 +87,8 @@ export const Profile = () => { position="middle" /> + +
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 49b09ddff..9c3914a04 100644 --- a/src/constants/payment.consts.ts +++ b/src/constants/payment.consts.ts @@ -7,8 +7,13 @@ 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 /** * validate if amount meets minimum requirement for a payment method diff --git a/src/constants/query.consts.ts b/src/constants/query.consts.ts index adcfb970c..49bf5e433 100644 --- a/src/constants/query.consts.ts +++ b/src/constants/query.consts.ts @@ -3,6 +3,7 @@ export const TRANSACTIONS = 'transactions' export const CONTACTS = 'contacts' export const CLAIM_LINK = 'claimLink' export const CLAIM_LINK_XCHAIN = 'claimLinkXChain' +export const LIMITS = 'limits' // Balance-decreasing operations (for mutation tracking) export const BALANCE_DECREASE = 'balance-decrease' diff --git a/src/features/limits/components/CryptoLimitsSection.tsx b/src/features/limits/components/CryptoLimitsSection.tsx new file mode 100644 index 000000000..6d306db20 --- /dev/null +++ b/src/features/limits/components/CryptoLimitsSection.tsx @@ -0,0 +1,24 @@ +'use client' + +import Card from '@/components/Global/Card' +import { Icon } from '@/components/Global/Icons/Icon' +import { Tooltip } from '@/components/Tooltip' + +/** + * displays crypto limits section - crypto transactions have no limits + */ +export default function CryptoLimitsSection() { + return ( +
+

Crypto limits

+ +
+ No limits + + + +
+
+
+ ) +} diff --git a/src/features/limits/components/FiatLimitsLockedCard.tsx b/src/features/limits/components/FiatLimitsLockedCard.tsx new file mode 100644 index 000000000..0cf7fab8a --- /dev/null +++ b/src/features/limits/components/FiatLimitsLockedCard.tsx @@ -0,0 +1,42 @@ +'use client' + +import Card from '@/components/Global/Card' +import { Icon } from '@/components/Global/Icons/Icon' +import { Button } from '@/components/0_Bruddle/Button' +import { useRouter } from 'next/navigation' + +/** + * displays a card prompting users without kyc to complete verification + * to unlock fiat limits + */ +export default function FiatLimitsLockedCard() { + const router = useRouter() + + return ( +
+

Unlock fiat limits

+ +
+
+ +
+
+
Fiat limits locked
+
+ Complete identity verification to unlock fiat payments and see your limits +
+
+ +
+
+
+ ) +} diff --git a/src/features/limits/components/IncreaseLimitsButton.tsx b/src/features/limits/components/IncreaseLimitsButton.tsx new file mode 100644 index 000000000..eb69a37c2 --- /dev/null +++ b/src/features/limits/components/IncreaseLimitsButton.tsx @@ -0,0 +1,19 @@ +'use client' + +import { Button } from '@/components/0_Bruddle/Button' +import { useModalsContext } from '@/context/ModalsContext' + +const INCREASE_LIMITS_MESSAGE = 'Hi, I would like to increase my payment limits.' + +/** + * button that opens support drawer with prefilled message to request limit increase + */ +export default function IncreaseLimitsButton() { + const { openSupportWithMessage } = useModalsContext() + + return ( + + ) +} diff --git a/src/features/limits/components/LimitsDocsLink.tsx b/src/features/limits/components/LimitsDocsLink.tsx new file mode 100644 index 000000000..4090b7550 --- /dev/null +++ b/src/features/limits/components/LimitsDocsLink.tsx @@ -0,0 +1,13 @@ +export default function LimitsDocsLink() { + return ( + + See more about limits + + ) +} diff --git a/src/features/limits/components/LimitsError.tsx b/src/features/limits/components/LimitsError.tsx new file mode 100644 index 000000000..33733e3c6 --- /dev/null +++ b/src/features/limits/components/LimitsError.tsx @@ -0,0 +1,22 @@ +'use client' +import { Button } from '@/components/0_Bruddle/Button' +import EmptyState from '@/components/Global/EmptyStates/EmptyState' +import { useRouter } from 'next/navigation' + +export default function LimitsError() { + const router = useRouter() + return ( +
+ +
+ +
+
+ ) +} diff --git a/src/features/limits/components/LimitsProgressBar.tsx b/src/features/limits/components/LimitsProgressBar.tsx new file mode 100644 index 000000000..58f2079dd --- /dev/null +++ b/src/features/limits/components/LimitsProgressBar.tsx @@ -0,0 +1,36 @@ +'use client' + +import { twMerge } from 'tailwind-merge' +import { getLimitColorClass } from '../utils' + +interface LimitsProgressBarProps { + total: number + remaining: number +} + +/** + * progress bar for limits display + * + * note: not using Global/ProgressBar because that component is designed for + * request pots goals with specific labels ("contributed", "remaining"), markers, + * and goal-achieved states. this component serves a different purpose - showing + * limit usage with color thresholds based on remaining percentage. + */ +const LimitsProgressBar = ({ total, remaining }: LimitsProgressBarProps) => { + const remainingPercent = total > 0 ? (remaining / total) * 100 : 0 + const clampedPercent = Math.min(Math.max(remainingPercent, 0), 100) + + return ( +
+
+
+ ) +} + +export default LimitsProgressBar 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/components/PeriodToggle.tsx b/src/features/limits/components/PeriodToggle.tsx new file mode 100644 index 000000000..cfe8dbf32 --- /dev/null +++ b/src/features/limits/components/PeriodToggle.tsx @@ -0,0 +1,36 @@ +'use client' + +import { Root, List, Trigger } from '@radix-ui/react-tabs' + +type Period = 'monthly' | 'yearly' + +interface PeriodToggleProps { + value: Period + onChange: (period: Period) => void + className?: string +} + +/** + * pill toggle for switching between monthly and yearly limit views + * uses radix tabs for accessibility + */ +export default function PeriodToggle({ value, onChange, className }: PeriodToggleProps) { + return ( + onChange(v as Period)} className={className}> + + + Monthly + + + Yearly + + + + ) +} diff --git a/src/features/limits/consts.ts b/src/features/limits/consts.ts new file mode 100644 index 000000000..8e3904403 --- /dev/null +++ b/src/features/limits/consts.ts @@ -0,0 +1,45 @@ +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] + +// 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), + } +} + +/** + * get all qr-only countries with resolved flag urls + */ +export function getQrCountriesWithFlags() { + return derivedQrCountries.map((country) => ({ + ...country, + flag: getFlagUrl(country.flagCode), + })) +} 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/views/BridgeLimitsView.tsx b/src/features/limits/views/BridgeLimitsView.tsx new file mode 100644 index 000000000..8bbb6fc5e --- /dev/null +++ b/src/features/limits/views/BridgeLimitsView.tsx @@ -0,0 +1,153 @@ +'use client' + +import NavHeader from '@/components/Global/NavHeader' +import Card from '@/components/Global/Card' +import { Icon } from '@/components/Global/Icons/Icon' +import { useLimits } from '@/hooks/useLimits' +import useKycStatus from '@/hooks/useKycStatus' +import { useRouter } from 'next/navigation' +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, useMemo } from 'react' +import PeanutLoading from '@/components/Global/PeanutLoading' +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' + +/** + * displays bridge limits for na/europe/mx users + * shows per-transaction limits and qr payment limits for foreign users + * url state: ?region=us|mexico|europe|argentina|brazil (persists source region) + */ +const BridgeLimitsView = () => { + const router = useRouter() + const { bridgeLimits, isLoading, error } = useLimits() + const { isUserMantecaKycApproved } = useKycStatus() + + // url state for source region (where user came from) + const [region] = useQueryState( + 'region', + parseAsStringEnum(['us', 'mexico', 'europe', 'argentina', 'brazil']).withDefault('us') + ) + + // local state for qr accordion - auto-expand if region is a qr country + const [expandedCountry, setExpandedCountry] = useState( + region === 'argentina' || region === 'brazil' ? region : undefined + ) + + // get qr countries with resolved flag urls (uses centralized flag utility) + const qrCountries = useMemo(() => getQrCountriesWithFlags(), []) + + // 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 ( +
+ router.back()} titleClassName="text-xl md:text-2xl" /> + + {isLoading && } + + {error && } + + {!isLoading && !error && bridgeLimits && ( + <> + {/* main limits card - only for bank transfer regions */} + {showBankTransferLimits && ( +
+

Fiat limits:

+ +
+
+ + + Add money: up to{' '} + {formatAmountWithCurrency( + parseFloat(bridgeLimits.onRampPerTransaction), + bridgeLimits.asset + )}{' '} + per transaction + +
+
+ + + Withdrawing: up to{' '} + {formatAmountWithCurrency( + parseFloat(bridgeLimits.offRampPerTransaction), + bridgeLimits.asset + )}{' '} + per transaction + +
+
+
+
+ )} + + {/* qr payment limits accordion - for bridge users without manteca kyc */} + {!isUserMantecaKycApproved && ( +
+

QR payment limits:

+ + setExpandedCountry(value as QrCountryId | undefined)} + > + {qrCountries.map((country, index) => ( + + + +
+ {country.name} + {country.name} +
+ +
+
+ +
+ + + Paying with QR: up to $ + {MAX_QR_PAYMENT_AMOUNT_FOREIGN.toLocaleString()} per transaction + +
+
+
+ ))} +
+
+
+ )} + + + + )} + + {!isLoading && !error && !bridgeLimits && } +
+ ) +} + +export default BridgeLimitsView diff --git a/src/features/limits/views/LimitsPageView.tsx b/src/features/limits/views/LimitsPageView.tsx new file mode 100644 index 000000000..8579fc7c8 --- /dev/null +++ b/src/features/limits/views/LimitsPageView.tsx @@ -0,0 +1,212 @@ +'use client' + +import { ActionListCard } from '@/components/ActionListCard' +import { getCardPosition } from '@/components/Global/Card' +import NavHeader from '@/components/Global/NavHeader' +import StatusBadge from '@/components/Global/Badges/StatusBadge' +import { Button } from '@/components/0_Bruddle/Button' +import { useIdentityVerification, type Region } from '@/hooks/useIdentityVerification' +import useKycStatus from '@/hooks/useKycStatus' +import Image from 'next/image' +import { useRouter } from 'next/navigation' +import { useMemo } from 'react' +import CryptoLimitsSection from '../components/CryptoLimitsSection' +import FiatLimitsLockedCard from '../components/FiatLimitsLockedCard' +import { REST_OF_WORLD_GLOBE_ICON } from '@/assets' +import InfoCard from '@/components/Global/InfoCard' +import { getProviderRoute } from '../utils' + +const LimitsPageView = () => { + const router = useRouter() + const { unlockedRegions, lockedRegions } = useIdentityVerification() + const { isUserKycApproved, isUserBridgeKycUnderReview, isUserMantecaKycApproved } = useKycStatus() + + // check if user has any kyc at all + const hasAnyKyc = isUserKycApproved + + // rest of world region config (static) + const restOfWorldRegion: Region = { + path: 'rest-of-the-world', + name: 'Rest of the world', + icon: REST_OF_WORLD_GLOBE_ICON, + } + + // filter locked regions and check for rest of world + const { filteredLockedRegions, hasRestOfWorld } = useMemo(() => { + const filtered: Region[] = [] + let hasRoW = false + for (const r of lockedRegions) { + if (r.path === 'rest-of-the-world') { + hasRoW = true + } else { + filtered.push(r) + } + } + return { filteredLockedRegions: filtered, hasRestOfWorld: hasRoW } + }, [lockedRegions]) + + return ( +
+ router.replace('/profile')} + titleClassName="text-xl md:text-2xl" + /> + + {/* page description */} + + + {/* fiat limits section */} + {!hasAnyKyc && } + + {/* unlocked regions */} + {unlockedRegions.length > 0 && ( + + )} + + {/* locked regions - only render if there are actual locked regions */} + {filteredLockedRegions.length > 0 && ( + + )} + + {/* rest of world - always shown with coming soon */} + {hasRestOfWorld && ( +
+

Other regions

+ + } + position="single" + title={restOfWorldRegion.name} + onClick={() => {}} + isDisabled={true} + rightContent={} + /> +
+ )} + + {/* crypto limits section */} + +
+ ) +} + +export default LimitsPageView + +interface UnlockedRegionsListProps { + regions: Region[] + hasMantecaKyc: boolean +} + +const UnlockedRegionsList = ({ regions, hasMantecaKyc }: UnlockedRegionsListProps) => { + const router = useRouter() + + return ( +
+ {regions.length > 0 &&

Unlocked regions

} + {regions.map((region, index) => ( + + } + position={getCardPosition(index, regions.length)} + title={region.name} + onClick={() => { + const route = getProviderRoute(region.path, hasMantecaKyc) + router.push(route) + }} + description={region.description} + descriptionClassName="text-xs" + rightContent={ + + } + /> + ))} +
+ ) +} + +interface LockedRegionsListProps { + regions: Region[] + isBridgeKycPending: boolean +} + +const LockedRegionsList = ({ regions, isBridgeKycPending }: LockedRegionsListProps) => { + const router = useRouter() + + // check if a region should show pending status + // bridge kyc pending affects europe and north-america regions + const isPendingRegion = (regionPath: string) => { + if (!isBridgeKycPending) return false + return regionPath === 'europe' || regionPath === 'north-america' + } + + return ( +
+ {regions.length > 0 &&

Locked regions

} + {regions.map((region, index) => { + const isPending = isPendingRegion(region.path) + return ( + + } + position={getCardPosition(index, regions.length)} + title={region.name} + onClick={() => { + if (!isPending) { + router.push(`/profile/identity-verification/${region.path}`) + } + }} + isDisabled={isPending} + description={region.description} + descriptionClassName="text-xs" + rightContent={ + isPending ? ( + + ) : ( + + ) + } + /> + ) + })} +
+ ) +} diff --git a/src/features/limits/views/MantecaLimitsView.tsx b/src/features/limits/views/MantecaLimitsView.tsx new file mode 100644 index 000000000..9fdc5738b --- /dev/null +++ b/src/features/limits/views/MantecaLimitsView.tsx @@ -0,0 +1,110 @@ +'use client' + +import NavHeader from '@/components/Global/NavHeader' +import Card from '@/components/Global/Card' +import { Icon } from '@/components/Global/Icons/Icon' +import { useLimits } from '@/hooks/useLimits' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import PeriodToggle from '../components/PeriodToggle' +import LimitsProgressBar from '../components/LimitsProgressBar' +import Image from 'next/image' +import PeanutLoading from '@/components/Global/PeanutLoading' +import { + getLimitData, + getLimitColorClass, + formatAmountWithCurrency, + getFlagUrlForCurrency, + type LimitsPeriod, +} from '../utils' +import IncreaseLimitsButton from '../components/IncreaseLimitsButton' +import LimitsError from '../components/LimitsError' +import LimitsDocsLink from '../components/LimitsDocsLink' +import EmptyState from '@/components/Global/EmptyStates/EmptyState' + +/** + * displays manteca limits for latam users + * shows monthly/yearly limits per currency with remaining amounts + */ +const MantecaLimitsView = () => { + const router = useRouter() + const { mantecaLimits, isLoading, error } = useLimits() + const [period, setPeriod] = useState('monthly') + + return ( +
+ router.back()} titleClassName="text-xl md:text-2xl" /> + + {isLoading && } + + {error && } + + {!isLoading && !error && mantecaLimits && mantecaLimits.length > 0 && ( + <> + {/* limit cards per currency */} +
+ {mantecaLimits.map((limit) => { + const limitData = getLimitData(limit, period) + // use centralized flag url utility + const flagUrl = getFlagUrlForCurrency(limit.asset) + + // calculate remaining percentage for text color + const remainingPercent = + limitData.limit > 0 ? (limitData.remaining / limitData.limit) * 100 : 0 + + return ( + +
+
+ {flagUrl && ( + {limit.asset} + )} + {limit.asset} total allowed +
+ +
+ +
+ {formatAmountWithCurrency(limitData.limit, limit.asset)} +
+ + + +
+ + Remaining this {period === 'monthly' ? 'month' : 'year'} + + + {formatAmountWithCurrency(limitData.remaining, limit.asset)} + +
+
+ ) + })} + {/* info text */} +
+ +

Applies to adding money, withdraws and QR payments

+
+
+ + + + + + )} + + {!isLoading && !error && (!mantecaLimits || mantecaLimits.length === 0) && ( + + )} +
+ ) +} + +export default MantecaLimitsView diff --git a/src/hooks/useLimits.ts b/src/hooks/useLimits.ts new file mode 100644 index 000000000..f6b652541 --- /dev/null +++ b/src/hooks/useLimits.ts @@ -0,0 +1,69 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import Cookies from 'js-cookie' +import { fetchWithSentry } from '@/utils/sentry.utils' +import { PEANUT_API_URL } from '@/constants/general.consts' +import type { UserLimitsResponse } from '@/interfaces' +import { LIMITS } from '@/constants/query.consts' + +interface UseLimitsOptions { + enabled?: boolean +} + +/** + * hook to fetch user's fiat transaction limits + * returns limits from both bridge (na/europe/mx) and manteca (latam) providers + * returns null values if user hasn't completed respective kyc + */ +export function useLimits(options: UseLimitsOptions = {}) { + const { enabled = true } = options + + const fetchLimits = async (): Promise => { + const token = Cookies.get('jwt-token') + if (!token) { + return { manteca: null, bridge: null } + } + + const url = `${PEANUT_API_URL}/users/limits` + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + } + + const response = await fetchWithSentry(url, { method: 'GET', headers }) + + // 400 means user has no kyc - return empty limits + if (response.status === 400) { + return { manteca: null, bridge: null } + } + + if (!response.ok) { + throw new Error(`Failed to fetch limits: ${response.statusText}`) + } + + return await response.json() + } + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: [LIMITS], + queryFn: fetchLimits, + enabled, + staleTime: 5 * 60 * 1000, // 5 minutes - limits don't change often + gcTime: 10 * 60 * 1000, + refetchOnMount: true, + refetchOnWindowFocus: true, + }) + + return { + limits: data ?? null, + bridgeLimits: data?.bridge ?? null, + mantecaLimits: data?.manteca ?? null, + isLoading, + error, + refetch, + // convenience flags + hasBridgeLimits: !!data?.bridge, + hasMantecaLimits: !!data?.manteca && data.manteca.length > 0, + } +} diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 6d8df29c1..f121fc926 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -356,3 +356,25 @@ export interface ContactsResponse { total: number hasMore: boolean } + +// limits types for fiat transactions +export interface BridgeLimits { + onRampPerTransaction: string + offRampPerTransaction: string + asset: string +} + +export interface MantecaLimit { + exchangeCountry: string // 'ARG', 'BRA', etc. + type: 'EXCHANGE' | 'REMITTANCE' + asset: string // 'ARS', 'BRL', etc. + yearlyLimit: string + availableYearlyLimit: string + monthlyLimit: string + availableMonthlyLimit: string +} + +export interface UserLimitsResponse { + manteca: MantecaLimit[] | null + bridge: BridgeLimits | null +}