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 (
+
+