From 0412ec7cfa3a112b6f81cdf588962a4221f7aef6 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:23:56 +0530 Subject: [PATCH 01/19] feat: add limits page and related components for region-based navigation --- src/components/Limits/LimitsPage.view.tsx | 208 ++++++++++++++++++ .../Limits/components/CryptoLimitsSection.tsx | 24 ++ .../components/FiatLimitsLockedCard.tsx | 42 ++++ src/components/Limits/limits.consts.ts | 12 + src/components/Limits/limits.utils.ts | 19 ++ 5 files changed, 305 insertions(+) create mode 100644 src/components/Limits/LimitsPage.view.tsx create mode 100644 src/components/Limits/components/CryptoLimitsSection.tsx create mode 100644 src/components/Limits/components/FiatLimitsLockedCard.tsx create mode 100644 src/components/Limits/limits.consts.ts create mode 100644 src/components/Limits/limits.utils.ts diff --git a/src/components/Limits/LimitsPage.view.tsx b/src/components/Limits/LimitsPage.view.tsx new file mode 100644 index 000000000..8b2348c6c --- /dev/null +++ b/src/components/Limits/LimitsPage.view.tsx @@ -0,0 +1,208 @@ +'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 { getProviderRoute } from './limits.utils' + +const LimitsPage = () => { + 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 the world region (always locked with "coming soon") + const restOfWorldRegion: Region = useMemo( + () => ({ + path: 'rest-of-the-world', + name: 'Rest of the world', + icon: REST_OF_WORLD_GLOBE_ICON, + }), + [] + ) + + // filter out rest of world from locked regions (we handle it separately) + const filteredLockedRegions = useMemo( + () => lockedRegions.filter((r) => r.path !== 'rest-of-the-world'), + [lockedRegions] + ) + + // check if rest of world is in locked regions + const hasRestOfWorld = useMemo(() => lockedRegions.some((r) => r.path === 'rest-of-the-world'), [lockedRegions]) + + return ( +
+ router.replace('/profile')} titleClassName="text-xl md:text-2xl" /> + + {/* fiat limits section */} + {!hasAnyKyc && } + + {/* unlocked regions */} + {unlockedRegions.length > 0 && ( +
+

Unlocked regions limits

+ +
+ )} + + {/* locked regions */} + {(filteredLockedRegions.length > 0 || hasRestOfWorld) && ( +
+
+

Locked regions

+ +
+
+ )} + +
+

Other regions

+ {/* rest of world - always shown with coming soon */} + {hasRestOfWorld && ( + + } + position="single" + title={restOfWorldRegion.name} + onClick={() => {}} + isDisabled={true} + rightContent={} + /> + )} +
+ + {/* crypto limits section */} + +
+ ) +} + +export default LimitsPage + +interface UnlockedRegionsListProps { + regions: Region[] + hasMantecaKyc: boolean +} + +const UnlockedRegionsList = ({ regions, hasMantecaKyc }: UnlockedRegionsListProps) => { + const router = useRouter() + + return ( +
+ {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.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/components/Limits/components/CryptoLimitsSection.tsx b/src/components/Limits/components/CryptoLimitsSection.tsx new file mode 100644 index 000000000..6d306db20 --- /dev/null +++ b/src/components/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/components/Limits/components/FiatLimitsLockedCard.tsx b/src/components/Limits/components/FiatLimitsLockedCard.tsx new file mode 100644 index 000000000..0cf7fab8a --- /dev/null +++ b/src/components/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/components/Limits/limits.consts.ts b/src/components/Limits/limits.consts.ts new file mode 100644 index 000000000..589d64279 --- /dev/null +++ b/src/components/Limits/limits.consts.ts @@ -0,0 +1,12 @@ +// 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', +} diff --git a/src/components/Limits/limits.utils.ts b/src/components/Limits/limits.utils.ts new file mode 100644 index 000000000..85b7de113 --- /dev/null +++ b/src/components/Limits/limits.utils.ts @@ -0,0 +1,19 @@ +import { BRIDGE_REGIONS, MANTECA_REGIONS, REGION_TO_BRIDGE_PARAM } from './limits.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' +} From 4eb4f2d2b0ebc745590da78cf14fe4bd34c1dd85 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:25:38 +0530 Subject: [PATCH 02/19] feat: add limits page in profile page list --- src/app/(mobile-ui)/limits/page.tsx | 10 ++++++++++ src/components/Profile/index.tsx | 2 ++ 2 files changed, 12 insertions(+) create mode 100644 src/app/(mobile-ui)/limits/page.tsx diff --git a/src/app/(mobile-ui)/limits/page.tsx b/src/app/(mobile-ui)/limits/page.tsx new file mode 100644 index 000000000..ceb16465b --- /dev/null +++ b/src/app/(mobile-ui)/limits/page.tsx @@ -0,0 +1,10 @@ +import PageContainer from '@/components/0_Bruddle/PageContainer' +import LimitsPage from '@/components/Limits/LimitsPage.view' + +export default function LimitsPageRoute() { + return ( + + + + ) +} diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index cbf3439b1..22d309cbe 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -87,6 +87,8 @@ export const Profile = () => { position="middle" /> + +
From 5207ca45c96dca756dae7ad9a39c20a9f24bdbaa Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:36:41 +0530 Subject: [PATCH 03/19] feat: add useLimits hook for fetching user fiat txn limits --- src/constants/query.consts.ts | 1 + src/hooks/useLimits.ts | 65 +++++++++++++++++++++++++++++++++++ src/interfaces/interfaces.ts | 27 +++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 src/hooks/useLimits.ts 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/hooks/useLimits.ts b/src/hooks/useLimits.ts new file mode 100644 index 000000000..14e8f36ae --- /dev/null +++ b/src/hooks/useLimits.ts @@ -0,0 +1,65 @@ +'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 url = `${PEANUT_API_URL}/users/limits` + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${Cookies.get('jwt-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 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..3747af2f4 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -356,3 +356,30 @@ export interface ContactsResponse { total: number hasMore: boolean } + +// limits types for fiat transactions +export interface BridgeLimits { + onRampPerTransaction: string + offRampPerTransaction: string + asset: string +} + +export interface MantecaLimit { + type: 'EXCHANGE' | 'REMITTANCE' + currency: string + monthly?: { + limit: number + used: number + remaining: number + } + yearly?: { + limit: number + used: number + remaining: number + } +} + +export interface UserLimitsResponse { + manteca: MantecaLimit[] | null + bridge: BridgeLimits | null +} From ac7edc59d624cffcbbd71c04b5808bc1e6d85e4f Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:39:35 +0530 Subject: [PATCH 04/19] feat: implement bridge limits page with url based state --- .../(mobile-ui)/limits/[provider]/page.tsx | 18 ++ src/app/not-found.tsx | 4 +- src/components/Global/Icons/Icon.tsx | 3 + .../Limits/BridgeLimitsPage.view.tsx | 159 ++++++++++++++++++ src/components/Limits/limits.consts.ts | 17 ++ src/constants/payment.consts.ts | 1 + 6 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 src/app/(mobile-ui)/limits/[provider]/page.tsx create mode 100644 src/components/Limits/BridgeLimitsPage.view.tsx diff --git a/src/app/(mobile-ui)/limits/[provider]/page.tsx b/src/app/(mobile-ui)/limits/[provider]/page.tsx new file mode 100644 index 000000000..3a6669baa --- /dev/null +++ b/src/app/(mobile-ui)/limits/[provider]/page.tsx @@ -0,0 +1,18 @@ +'use client' + +import PageContainer from '@/components/0_Bruddle/PageContainer' +import BridgeLimitsPage from '@/components/Limits/BridgeLimitsPage.view' +import { LIMITS_PROVIDERS, type LimitsProvider } from '@/components/Limits/limits.consts' +import { useParams, notFound } from 'next/navigation' + +export default function ProviderLimitsPage() { + const params = useParams() + const provider = params.provider as string + + // validate provider + if (!LIMITS_PROVIDERS.includes(provider as LimitsProvider)) { + notFound() + } + + return {provider === 'bridge' && } +} 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/Global/Icons/Icon.tsx b/src/components/Global/Icons/Icon.tsx index 52b72e403..842ebb9cc 100644 --- a/src/components/Global/Icons/Icon.tsx +++ b/src/components/Global/Icons/Icon.tsx @@ -62,6 +62,7 @@ import { CloudUploadOutlined, CompareArrowsRounded, WarningRounded, + SpeedRounded, } from '@mui/icons-material' import { DocsIcon } from './docs' import { PeanutSupportIcon } from './peanut-support' @@ -88,6 +89,7 @@ export type IconName = | 'check-circle' | 'plus-circle' | 'minus-circle' + | 'meter' | 'cancel' | 'download' | 'double-check' @@ -274,6 +276,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/Limits/BridgeLimitsPage.view.tsx b/src/components/Limits/BridgeLimitsPage.view.tsx new file mode 100644 index 000000000..526964b5b --- /dev/null +++ b/src/components/Limits/BridgeLimitsPage.view.tsx @@ -0,0 +1,159 @@ +'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 } from 'react' +import PeanutLoading from '../Global/PeanutLoading' +import { BANK_TRANSFER_REGIONS, QR_COUNTRIES, type BridgeRegion, type QrCountryId } from './limits.consts' + +/** + * 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 BridgeLimitsPage = () => { + 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 (doesn't affect url) + const [expandedCountry, setExpandedCountry] = useState(undefined) + + // determine what to show based on source region + const showBankTransferLimits = BANK_TRANSFER_REGIONS.includes(region) + + // format limit amount with currency symbol + const formatLimit = (amount: string, asset: string) => { + const symbol = asset === 'USD' ? '$' : asset + return `${symbol}${Number(amount).toLocaleString()}` + } + + return ( +
+ router.back()} titleClassName="text-xl md:text-2xl" /> + + {isLoading && } + + {error && ( + +

Failed to load limits. Please try again.

+
+ )} + + {!isLoading && !error && bridgeLimits && ( + <> + {/* main limits card - only for bank transfer regions */} + {showBankTransferLimits && ( +
+

Fiat limits:

+ +
+
+ + + Add money: up to{' '} + {formatLimit(bridgeLimits.onRampPerTransaction, bridgeLimits.asset)} per + transaction + +
+
+ + + Withdrawing: up to{' '} + {formatLimit(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)} + > + {QR_COUNTRIES.map((country, index) => ( + + + +
+ {country.name} + {country.name} +
+ +
+
+ +
+ + + Paying with QR: up to $ + {MAX_QR_PAYMENT_AMOUNT_FOREIGN.toLocaleString()} per transaction + +
+
+
+ ))} +
+
+
+ )} + + + See more about limits + + + )} + + {!isLoading && !error && !bridgeLimits && ( + +

No limits data available.

+
+ )} +
+ ) +} + +export default BridgeLimitsPage diff --git a/src/components/Limits/limits.consts.ts b/src/components/Limits/limits.consts.ts index 589d64279..858ef9d80 100644 --- a/src/components/Limits/limits.consts.ts +++ b/src/components/Limits/limits.consts.ts @@ -10,3 +10,20 @@ export const REGION_TO_BRIDGE_PARAM: Record = { 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'] + +export const LIMITS_PROVIDERS = ['bridge', 'manteca'] as const +export type LimitsProvider = (typeof LIMITS_PROVIDERS)[number] diff --git a/src/constants/payment.consts.ts b/src/constants/payment.consts.ts index 49b09ddff..9d51ef5b9 100644 --- a/src/constants/payment.consts.ts +++ b/src/constants/payment.consts.ts @@ -9,6 +9,7 @@ export const MIN_MANTECA_DEPOSIT_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 From 4cc82667995aa1cc62f1c5db580bad31f7484b4b Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:51:00 +0530 Subject: [PATCH 05/19] feat: manteca limits page --- .../(mobile-ui)/limits/[provider]/page.tsx | 10 +- .../Limits/BridgeLimitsPage.view.tsx | 2 +- src/components/Limits/LimitsPage.view.tsx | 2 +- .../Limits/MantecaLimitsPage.view.tsx | 136 ++++++++++++++++++ .../Limits/components/LimitCard.tsx | 36 +++++ .../Limits/components/LimitsProgressBar.tsx | 43 ++++++ .../Limits/components/PeriodToggle.tsx | 36 +++++ .../{limits.consts.ts => consts.limits.ts} | 17 +++ .../{limits.utils.ts => utils.limits.ts} | 19 ++- src/interfaces/interfaces.ts | 17 +-- 10 files changed, 302 insertions(+), 16 deletions(-) create mode 100644 src/components/Limits/MantecaLimitsPage.view.tsx create mode 100644 src/components/Limits/components/LimitCard.tsx create mode 100644 src/components/Limits/components/LimitsProgressBar.tsx create mode 100644 src/components/Limits/components/PeriodToggle.tsx rename src/components/Limits/{limits.consts.ts => consts.limits.ts} (70%) rename src/components/Limits/{limits.utils.ts => utils.limits.ts} (59%) diff --git a/src/app/(mobile-ui)/limits/[provider]/page.tsx b/src/app/(mobile-ui)/limits/[provider]/page.tsx index 3a6669baa..65f10988c 100644 --- a/src/app/(mobile-ui)/limits/[provider]/page.tsx +++ b/src/app/(mobile-ui)/limits/[provider]/page.tsx @@ -2,7 +2,8 @@ import PageContainer from '@/components/0_Bruddle/PageContainer' import BridgeLimitsPage from '@/components/Limits/BridgeLimitsPage.view' -import { LIMITS_PROVIDERS, type LimitsProvider } from '@/components/Limits/limits.consts' +import { LIMITS_PROVIDERS, type LimitsProvider } from '@/components/Limits/consts.limits' +import MantecaLimitsPage from '@/components/Limits/MantecaLimitsPage.view' import { useParams, notFound } from 'next/navigation' export default function ProviderLimitsPage() { @@ -14,5 +15,10 @@ export default function ProviderLimitsPage() { notFound() } - return {provider === 'bridge' && } + return ( + + {provider === 'bridge' && } + {provider === 'manteca' && } + + ) } diff --git a/src/components/Limits/BridgeLimitsPage.view.tsx b/src/components/Limits/BridgeLimitsPage.view.tsx index 526964b5b..0d141c6d3 100644 --- a/src/components/Limits/BridgeLimitsPage.view.tsx +++ b/src/components/Limits/BridgeLimitsPage.view.tsx @@ -12,7 +12,7 @@ import * as Accordion from '@radix-ui/react-accordion' import { useQueryState, parseAsStringEnum } from 'nuqs' import { useState } from 'react' import PeanutLoading from '../Global/PeanutLoading' -import { BANK_TRANSFER_REGIONS, QR_COUNTRIES, type BridgeRegion, type QrCountryId } from './limits.consts' +import { BANK_TRANSFER_REGIONS, QR_COUNTRIES, type BridgeRegion, type QrCountryId } from './consts.limits' /** * displays bridge limits for na/europe/mx users diff --git a/src/components/Limits/LimitsPage.view.tsx b/src/components/Limits/LimitsPage.view.tsx index 8b2348c6c..280a9f59f 100644 --- a/src/components/Limits/LimitsPage.view.tsx +++ b/src/components/Limits/LimitsPage.view.tsx @@ -13,7 +13,7 @@ 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 './limits.utils' +import { getProviderRoute } from './utils.limits' const LimitsPage = () => { const router = useRouter() diff --git a/src/components/Limits/MantecaLimitsPage.view.tsx b/src/components/Limits/MantecaLimitsPage.view.tsx new file mode 100644 index 000000000..c794a68f0 --- /dev/null +++ b/src/components/Limits/MantecaLimitsPage.view.tsx @@ -0,0 +1,136 @@ +'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 '../Global/PeanutLoading' +import { Button } from '../0_Bruddle/Button' +import { LIMITS_CURRENCY_FLAGS, LIMITS_CURRENCY_SYMBOLS, type LimitsPeriod } from './consts.limits' +import { getLimitData } from './utils.limits' + +/** + * get remaining text color based on remaining percentage + */ +function getRemainingTextColor(remainingPercent: number): string { + if (remainingPercent > 50) return 'text-success-1' + if (remainingPercent > 20) return 'text-yellow-1' + return 'text-error-4' +} + +/** + * displays manteca limits for latam users + * shows monthly/yearly limits per currency with remaining amounts + */ +const MantecaLimitsPage = () => { + const router = useRouter() + const { mantecaLimits, isLoading, error } = useLimits() + const [period, setPeriod] = useState('monthly') + + // format amount with currency symbol + const formatAmount = (amount: number, currency: string) => { + const symbol = LIMITS_CURRENCY_SYMBOLS[currency] || currency + // round to 2 decimal places for display + return `${symbol}${amount.toLocaleString(undefined, { maximumFractionDigits: 2 })}` + } + + return ( +
+ router.back()} titleClassName="text-xl md:text-2xl" /> + + {isLoading && } + + {error && ( + +

Failed to load limits. Please try again.

+
+ )} + + {!isLoading && !error && mantecaLimits && mantecaLimits.length > 0 && ( + <> + {/* limit cards per currency */} +
+ {mantecaLimits.map((limit) => { + const limitData = getLimitData(limit, period) + const flagUrl = + LIMITS_CURRENCY_FLAGS[limit.asset] || LIMITS_CURRENCY_FLAGS[limit.exchangeCountry] + + // calculate remaining percentage for text color + const remainingPercent = + limitData.limit > 0 ? (limitData.remaining / limitData.limit) * 100 : 0 + const remainingTextColor = getRemainingTextColor(remainingPercent) + + return ( + +
+
+ {flagUrl && ( + {limit.asset} + )} + {limit.asset} total allowed +
+ +
+ +
+ {formatAmount(limitData.limit, limit.asset)} +
+ + + +
+ + Remaining this {period === 'monthly' ? 'month' : 'year'} + + + {formatAmount(limitData.remaining, limit.asset)} + +
+
+ ) + })} + {/* info text */} +
+ +

Applies to adding money, withdraws and QR payments

+
+
+ + {/* todo: handle increase limits button click */} + + + + See more about limits + + + )} + + {!isLoading && !error && (!mantecaLimits || mantecaLimits.length === 0) && ( + +

No limits data available.

+
+ )} +
+ ) +} + +export default MantecaLimitsPage diff --git a/src/components/Limits/components/LimitCard.tsx b/src/components/Limits/components/LimitCard.tsx new file mode 100644 index 000000000..120183ff0 --- /dev/null +++ b/src/components/Limits/components/LimitCard.tsx @@ -0,0 +1,36 @@ +'use client' + +import Card from '@/components/Global/Card' +import { Icon } from '@/components/Global/Icons/Icon' +import { twMerge } from 'tailwind-merge' + +interface LimitCardProps { + title: string + items: Array<{ + label: string + value: string + }> + className?: string +} + +/** + * displays a card with limit information + * used for showing bridge and manteca limits + */ +export default function LimitCard({ title, items, className }: LimitCardProps) { + return ( + +

{title}

+
+ {items.map((item, index) => ( +
+ + + {item.label}: {item.value} + +
+ ))} +
+
+ ) +} diff --git a/src/components/Limits/components/LimitsProgressBar.tsx b/src/components/Limits/components/LimitsProgressBar.tsx new file mode 100644 index 000000000..b09933431 --- /dev/null +++ b/src/components/Limits/components/LimitsProgressBar.tsx @@ -0,0 +1,43 @@ +'use client' + +import { twMerge } from 'tailwind-merge' + +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. + * + * colors: + * - green (>50% remaining): healthy usage + * - yellow (20-50% remaining): approaching limit + * - red (<20% remaining): near limit + */ +const LimitsProgressBar = ({ total, remaining }: LimitsProgressBarProps) => { + const remainingPercent = total > 0 ? (remaining / total) * 100 : 0 + const clampedPercent = Math.min(Math.max(remainingPercent, 0), 100) + + const getBarColor = () => { + if (remainingPercent > 50) return 'bg-success-3' + if (remainingPercent > 20) return 'bg-yellow-1' + return 'bg-error-4' + } + + return ( +
+
+
+ ) +} + +export default LimitsProgressBar diff --git a/src/components/Limits/components/PeriodToggle.tsx b/src/components/Limits/components/PeriodToggle.tsx new file mode 100644 index 000000000..cfe8dbf32 --- /dev/null +++ b/src/components/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/components/Limits/limits.consts.ts b/src/components/Limits/consts.limits.ts similarity index 70% rename from src/components/Limits/limits.consts.ts rename to src/components/Limits/consts.limits.ts index 858ef9d80..d8367bb1d 100644 --- a/src/components/Limits/limits.consts.ts +++ b/src/components/Limits/consts.limits.ts @@ -27,3 +27,20 @@ export type QrCountryId = (typeof QR_COUNTRIES)[number]['id'] 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', +} + +// currency to symbol mapping +export const LIMITS_CURRENCY_SYMBOLS: Record = { + ARS: '$', + BRL: 'R$', + USD: '$', +} + +export type LimitsPeriod = 'monthly' | 'yearly' diff --git a/src/components/Limits/limits.utils.ts b/src/components/Limits/utils.limits.ts similarity index 59% rename from src/components/Limits/limits.utils.ts rename to src/components/Limits/utils.limits.ts index 85b7de113..4f2d8d08e 100644 --- a/src/components/Limits/limits.utils.ts +++ b/src/components/Limits/utils.limits.ts @@ -1,4 +1,5 @@ -import { BRIDGE_REGIONS, MANTECA_REGIONS, REGION_TO_BRIDGE_PARAM } from './limits.consts' +import type { MantecaLimit } from '@/interfaces' +import { BRIDGE_REGIONS, MANTECA_REGIONS, REGION_TO_BRIDGE_PARAM, type LimitsPeriod } from './consts.limits' /** * determines which provider route to navigate to based on region path @@ -17,3 +18,19 @@ export function getProviderRoute(regionPath: string, hasMantecaKyc: boolean): st // 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), + } +} diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 3747af2f4..f121fc926 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -365,18 +365,13 @@ export interface BridgeLimits { } export interface MantecaLimit { + exchangeCountry: string // 'ARG', 'BRA', etc. type: 'EXCHANGE' | 'REMITTANCE' - currency: string - monthly?: { - limit: number - used: number - remaining: number - } - yearly?: { - limit: number - used: number - remaining: number - } + asset: string // 'ARS', 'BRL', etc. + yearlyLimit: string + availableYearlyLimit: string + monthlyLimit: string + availableMonthlyLimit: string } export interface UserLimitsResponse { From a48b55ff00cf926ebf4b78e774bca3a88bdf38a1 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 14 Jan 2026 00:12:17 +0530 Subject: [PATCH 06/19] fix: resolve ai review comments --- .../(mobile-ui)/limits/[provider]/page.tsx | 15 ++++---- src/components/Limits/LimitsPage.view.tsx | 36 ++++++++++--------- .../Limits/MantecaLimitsPage.view.tsx | 16 ++------- .../Limits/components/LimitCard.tsx | 36 ------------------- .../Limits/components/LimitsProgressBar.tsx | 17 +++------ src/components/Limits/utils.limits.ts | 18 ++++++++++ 6 files changed, 53 insertions(+), 85 deletions(-) delete mode 100644 src/components/Limits/components/LimitCard.tsx diff --git a/src/app/(mobile-ui)/limits/[provider]/page.tsx b/src/app/(mobile-ui)/limits/[provider]/page.tsx index 65f10988c..e6ac2c056 100644 --- a/src/app/(mobile-ui)/limits/[provider]/page.tsx +++ b/src/app/(mobile-ui)/limits/[provider]/page.tsx @@ -1,16 +1,17 @@ -'use client' - import PageContainer from '@/components/0_Bruddle/PageContainer' import BridgeLimitsPage from '@/components/Limits/BridgeLimitsPage.view' import { LIMITS_PROVIDERS, type LimitsProvider } from '@/components/Limits/consts.limits' import MantecaLimitsPage from '@/components/Limits/MantecaLimitsPage.view' -import { useParams, notFound } from 'next/navigation' +import { notFound } from 'next/navigation' + +interface ProviderLimitsPageProps { + params: Promise<{ provider: string }> +} -export default function ProviderLimitsPage() { - const params = useParams() - const provider = params.provider as string +export default async function ProviderLimitsPage({ params }: ProviderLimitsPageProps) { + const { provider } = await params - // validate provider + // validate provider - notFound() is safe in server components if (!LIMITS_PROVIDERS.includes(provider as LimitsProvider)) { notFound() } diff --git a/src/components/Limits/LimitsPage.view.tsx b/src/components/Limits/LimitsPage.view.tsx index 280a9f59f..0ef1a24f4 100644 --- a/src/components/Limits/LimitsPage.view.tsx +++ b/src/components/Limits/LimitsPage.view.tsx @@ -23,24 +23,26 @@ const LimitsPage = () => { // check if user has any kyc at all const hasAnyKyc = isUserKycApproved - // rest of the world region (always locked with "coming soon") - const restOfWorldRegion: Region = useMemo( - () => ({ - path: 'rest-of-the-world', - name: 'Rest of the world', - icon: REST_OF_WORLD_GLOBE_ICON, - }), - [] - ) - - // filter out rest of world from locked regions (we handle it separately) - const filteredLockedRegions = useMemo( - () => lockedRegions.filter((r) => r.path !== 'rest-of-the-world'), - [lockedRegions] - ) + // 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, + } - // check if rest of world is in locked regions - const hasRestOfWorld = useMemo(() => lockedRegions.some((r) => r.path === 'rest-of-the-world'), [lockedRegions]) + // 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 (
diff --git a/src/components/Limits/MantecaLimitsPage.view.tsx b/src/components/Limits/MantecaLimitsPage.view.tsx index c794a68f0..7a8f80385 100644 --- a/src/components/Limits/MantecaLimitsPage.view.tsx +++ b/src/components/Limits/MantecaLimitsPage.view.tsx @@ -10,18 +10,9 @@ import PeriodToggle from './components/PeriodToggle' import LimitsProgressBar from './components/LimitsProgressBar' import Image from 'next/image' import PeanutLoading from '../Global/PeanutLoading' -import { Button } from '../0_Bruddle/Button' import { LIMITS_CURRENCY_FLAGS, LIMITS_CURRENCY_SYMBOLS, type LimitsPeriod } from './consts.limits' -import { getLimitData } from './utils.limits' - -/** - * get remaining text color based on remaining percentage - */ -function getRemainingTextColor(remainingPercent: number): string { - if (remainingPercent > 50) return 'text-success-1' - if (remainingPercent > 20) return 'text-yellow-1' - return 'text-error-4' -} +import { getLimitData, getLimitColorClass } from './utils.limits' +import { Button } from '../0_Bruddle/Button' /** * displays manteca limits for latam users @@ -63,7 +54,6 @@ const MantecaLimitsPage = () => { // calculate remaining percentage for text color const remainingPercent = limitData.limit > 0 ? (limitData.remaining / limitData.limit) * 100 : 0 - const remainingTextColor = getRemainingTextColor(remainingPercent) return ( @@ -93,7 +83,7 @@ const MantecaLimitsPage = () => { Remaining this {period === 'monthly' ? 'month' : 'year'} - + {formatAmount(limitData.remaining, limit.asset)}
diff --git a/src/components/Limits/components/LimitCard.tsx b/src/components/Limits/components/LimitCard.tsx deleted file mode 100644 index 120183ff0..000000000 --- a/src/components/Limits/components/LimitCard.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client' - -import Card from '@/components/Global/Card' -import { Icon } from '@/components/Global/Icons/Icon' -import { twMerge } from 'tailwind-merge' - -interface LimitCardProps { - title: string - items: Array<{ - label: string - value: string - }> - className?: string -} - -/** - * displays a card with limit information - * used for showing bridge and manteca limits - */ -export default function LimitCard({ title, items, className }: LimitCardProps) { - return ( - -

{title}

-
- {items.map((item, index) => ( -
- - - {item.label}: {item.value} - -
- ))} -
-
- ) -} diff --git a/src/components/Limits/components/LimitsProgressBar.tsx b/src/components/Limits/components/LimitsProgressBar.tsx index b09933431..2270248e0 100644 --- a/src/components/Limits/components/LimitsProgressBar.tsx +++ b/src/components/Limits/components/LimitsProgressBar.tsx @@ -1,6 +1,7 @@ 'use client' import { twMerge } from 'tailwind-merge' +import { getLimitColorClass } from '../utils.limits' interface LimitsProgressBarProps { total: number @@ -14,26 +15,18 @@ interface LimitsProgressBarProps { * 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. - * - * colors: - * - green (>50% remaining): healthy usage - * - yellow (20-50% remaining): approaching limit - * - red (<20% remaining): near limit */ const LimitsProgressBar = ({ total, remaining }: LimitsProgressBarProps) => { const remainingPercent = total > 0 ? (remaining / total) * 100 : 0 const clampedPercent = Math.min(Math.max(remainingPercent, 0), 100) - const getBarColor = () => { - if (remainingPercent > 50) return 'bg-success-3' - if (remainingPercent > 20) return 'bg-yellow-1' - return 'bg-error-4' - } - return (
diff --git a/src/components/Limits/utils.limits.ts b/src/components/Limits/utils.limits.ts index 4f2d8d08e..ad666fd77 100644 --- a/src/components/Limits/utils.limits.ts +++ b/src/components/Limits/utils.limits.ts @@ -34,3 +34,21 @@ export function getLimitData(limit: MantecaLimit, period: LimitsPeriod) { 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, <30% = 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' +} From d767af54974f8f9b9ce17e05d2cb2a43bb40b6dd Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:39:50 +0530 Subject: [PATCH 07/19] feat: add info card in limits page and increase limits button --- src/components/Limits/LimitsPage.view.tsx | 46 ++++++++++--------- .../Limits/MantecaLimitsPage.view.tsx | 7 +-- .../components/IncreaseLimitsButton.tsx | 19 ++++++++ src/components/Profile/index.tsx | 2 +- 4 files changed, 46 insertions(+), 28 deletions(-) create mode 100644 src/components/Limits/components/IncreaseLimitsButton.tsx diff --git a/src/components/Limits/LimitsPage.view.tsx b/src/components/Limits/LimitsPage.view.tsx index 0ef1a24f4..9b8eec055 100644 --- a/src/components/Limits/LimitsPage.view.tsx +++ b/src/components/Limits/LimitsPage.view.tsx @@ -14,6 +14,7 @@ import CryptoLimitsSection from './components/CryptoLimitsSection' import FiatLimitsLockedCard from './components/FiatLimitsLockedCard' import { REST_OF_WORLD_GLOBE_ICON } from '@/assets' import { getProviderRoute } from './utils.limits' +import InfoCard from '../Global/InfoCard' const LimitsPage = () => { const router = useRouter() @@ -46,36 +47,35 @@ const LimitsPage = () => { return (
- router.replace('/profile')} titleClassName="text-xl md:text-2xl" /> + router.replace('/profile')} + titleClassName="text-xl md:text-2xl" + /> + + {/* page description */} + {/* fiat limits section */} {!hasAnyKyc && } {/* unlocked regions */} {unlockedRegions.length > 0 && ( -
-

Unlocked regions limits

- -
+ )} - {/* locked regions */} - {(filteredLockedRegions.length > 0 || hasRestOfWorld) && ( -
-
-

Locked regions

- -
-
+ {/* locked regions - only render if there are actual locked regions */} + {filteredLockedRegions.length > 0 && ( + )} -
-

Other regions

- {/* rest of world - always shown with coming soon */} - {hasRestOfWorld && ( + {/* rest of world - always shown with coming soon */} + {hasRestOfWorld && ( +
+

Other regions

{ isDisabled={true} rightContent={} /> - )} -
+
+ )} {/* crypto limits section */} @@ -113,6 +113,7 @@ const UnlockedRegionsList = ({ regions, hasMantecaKyc }: UnlockedRegionsListProp return (
+ {regions.length > 0 &&

Unlocked regions

} {regions.map((region, index) => ( + {regions.length > 0 &&

Locked regions

} {regions.map((region, index) => { const isPending = isPendingRegion(region.path) return ( diff --git a/src/components/Limits/MantecaLimitsPage.view.tsx b/src/components/Limits/MantecaLimitsPage.view.tsx index 7a8f80385..7b7c4ae95 100644 --- a/src/components/Limits/MantecaLimitsPage.view.tsx +++ b/src/components/Limits/MantecaLimitsPage.view.tsx @@ -12,7 +12,7 @@ import Image from 'next/image' import PeanutLoading from '../Global/PeanutLoading' import { LIMITS_CURRENCY_FLAGS, LIMITS_CURRENCY_SYMBOLS, type LimitsPeriod } from './consts.limits' import { getLimitData, getLimitColorClass } from './utils.limits' -import { Button } from '../0_Bruddle/Button' +import IncreaseLimitsButton from './components/IncreaseLimitsButton' /** * displays manteca limits for latam users @@ -97,10 +97,7 @@ const MantecaLimitsPage = () => {
- {/* todo: handle increase limits button click */} - + openSupportWithMessage(INCREASE_LIMITS_MESSAGE)}> + Increase my limits + + ) +} diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 22d309cbe..c4426cfad 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -87,7 +87,7 @@ export const Profile = () => { position="middle" /> - +
From 55c80354c45d7669df80b524b6d21099fcc2882d Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:43:04 +0530 Subject: [PATCH 08/19] fix: cr comments --- src/components/Limits/utils.limits.ts | 2 +- src/hooks/useLimits.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/Limits/utils.limits.ts b/src/components/Limits/utils.limits.ts index ad666fd77..348194301 100644 --- a/src/components/Limits/utils.limits.ts +++ b/src/components/Limits/utils.limits.ts @@ -37,7 +37,7 @@ export function getLimitData(limit: MantecaLimit, period: LimitsPeriod) { // thresholds for limit usage coloring const LIMIT_HEALTHY_THRESHOLD = 70 // >70% remaining = green -const LIMIT_WARNING_THRESHOLD = 20 // 20-70% remaining = yellow, <30% = red +const LIMIT_WARNING_THRESHOLD = 20 // 20-70% remaining = yellow, <20% = red /** * get color class for remaining percentage diff --git a/src/hooks/useLimits.ts b/src/hooks/useLimits.ts index 14e8f36ae..f6b652541 100644 --- a/src/hooks/useLimits.ts +++ b/src/hooks/useLimits.ts @@ -20,11 +20,15 @@ export function useLimits(options: UseLimitsOptions = {}) { const { enabled = true } = options const fetchLimits = async (): Promise => { - const url = `${PEANUT_API_URL}/users/limits` + 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 ${Cookies.get('jwt-token')}`, + Authorization: `Bearer ${token}`, } const response = await fetchWithSentry(url, { method: 'GET', headers }) @@ -38,7 +42,7 @@ export function useLimits(options: UseLimitsOptions = {}) { throw new Error(`Failed to fetch limits: ${response.statusText}`) } - return response.json() + return await response.json() } const { data, isLoading, error, refetch } = useQuery({ From 8df9bc1dc58ad08d851efa950917524eac843bb7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:55:35 +0530 Subject: [PATCH 09/19] fix: dir structure --- src/app/(mobile-ui)/limits/[provider]/page.tsx | 10 +++++----- src/app/(mobile-ui)/limits/page.tsx | 4 ++-- .../limits}/components/CryptoLimitsSection.tsx | 0 .../limits}/components/FiatLimitsLockedCard.tsx | 0 .../limits}/components/IncreaseLimitsButton.tsx | 0 .../limits}/components/LimitsProgressBar.tsx | 2 +- .../limits}/components/PeriodToggle.tsx | 0 .../limits/consts.ts} | 0 .../limits/utils/limits.utils.ts} | 2 +- .../limits/views/BridgeLimitsView.tsx} | 8 ++++---- .../limits/views/LimitsPageView.tsx} | 12 ++++++------ .../limits/views/MantecaLimitsView.tsx} | 16 ++++++++-------- 12 files changed, 27 insertions(+), 27 deletions(-) rename src/{components/Limits => features/limits}/components/CryptoLimitsSection.tsx (100%) rename src/{components/Limits => features/limits}/components/FiatLimitsLockedCard.tsx (100%) rename src/{components/Limits => features/limits}/components/IncreaseLimitsButton.tsx (100%) rename src/{components/Limits => features/limits}/components/LimitsProgressBar.tsx (95%) rename src/{components/Limits => features/limits}/components/PeriodToggle.tsx (100%) rename src/{components/Limits/consts.limits.ts => features/limits/consts.ts} (100%) rename src/{components/Limits/utils.limits.ts => features/limits/utils/limits.utils.ts} (97%) rename src/{components/Limits/BridgeLimitsPage.view.tsx => features/limits/views/BridgeLimitsView.tsx} (98%) rename src/{components/Limits/LimitsPage.view.tsx => features/limits/views/LimitsPageView.tsx} (96%) rename src/{components/Limits/MantecaLimitsPage.view.tsx => features/limits/views/MantecaLimitsView.tsx} (92%) diff --git a/src/app/(mobile-ui)/limits/[provider]/page.tsx b/src/app/(mobile-ui)/limits/[provider]/page.tsx index e6ac2c056..22a5492f9 100644 --- a/src/app/(mobile-ui)/limits/[provider]/page.tsx +++ b/src/app/(mobile-ui)/limits/[provider]/page.tsx @@ -1,7 +1,7 @@ import PageContainer from '@/components/0_Bruddle/PageContainer' -import BridgeLimitsPage from '@/components/Limits/BridgeLimitsPage.view' -import { LIMITS_PROVIDERS, type LimitsProvider } from '@/components/Limits/consts.limits' -import MantecaLimitsPage from '@/components/Limits/MantecaLimitsPage.view' +import BridgeLimitsView from '@/features/limits/views/BridgeLimitsView' +import MantecaLimitsView from '@/features/limits/views/MantecaLimitsView' +import { LIMITS_PROVIDERS, type LimitsProvider } from '@/features/limits/consts' import { notFound } from 'next/navigation' interface ProviderLimitsPageProps { @@ -18,8 +18,8 @@ export default async function ProviderLimitsPage({ params }: ProviderLimitsPageP return ( - {provider === 'bridge' && } - {provider === 'manteca' && } + {provider === 'bridge' && } + {provider === 'manteca' && } ) } diff --git a/src/app/(mobile-ui)/limits/page.tsx b/src/app/(mobile-ui)/limits/page.tsx index ceb16465b..7ffd92336 100644 --- a/src/app/(mobile-ui)/limits/page.tsx +++ b/src/app/(mobile-ui)/limits/page.tsx @@ -1,10 +1,10 @@ import PageContainer from '@/components/0_Bruddle/PageContainer' -import LimitsPage from '@/components/Limits/LimitsPage.view' +import LimitsPageView from '@/features/limits/views/LimitsPageView' export default function LimitsPageRoute() { return ( - + ) } diff --git a/src/components/Limits/components/CryptoLimitsSection.tsx b/src/features/limits/components/CryptoLimitsSection.tsx similarity index 100% rename from src/components/Limits/components/CryptoLimitsSection.tsx rename to src/features/limits/components/CryptoLimitsSection.tsx diff --git a/src/components/Limits/components/FiatLimitsLockedCard.tsx b/src/features/limits/components/FiatLimitsLockedCard.tsx similarity index 100% rename from src/components/Limits/components/FiatLimitsLockedCard.tsx rename to src/features/limits/components/FiatLimitsLockedCard.tsx diff --git a/src/components/Limits/components/IncreaseLimitsButton.tsx b/src/features/limits/components/IncreaseLimitsButton.tsx similarity index 100% rename from src/components/Limits/components/IncreaseLimitsButton.tsx rename to src/features/limits/components/IncreaseLimitsButton.tsx diff --git a/src/components/Limits/components/LimitsProgressBar.tsx b/src/features/limits/components/LimitsProgressBar.tsx similarity index 95% rename from src/components/Limits/components/LimitsProgressBar.tsx rename to src/features/limits/components/LimitsProgressBar.tsx index 2270248e0..e5f1273cd 100644 --- a/src/components/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' +import { getLimitColorClass } from '../utils/limits.utils' interface LimitsProgressBarProps { total: number diff --git a/src/components/Limits/components/PeriodToggle.tsx b/src/features/limits/components/PeriodToggle.tsx similarity index 100% rename from src/components/Limits/components/PeriodToggle.tsx rename to src/features/limits/components/PeriodToggle.tsx diff --git a/src/components/Limits/consts.limits.ts b/src/features/limits/consts.ts similarity index 100% rename from src/components/Limits/consts.limits.ts rename to src/features/limits/consts.ts diff --git a/src/components/Limits/utils.limits.ts b/src/features/limits/utils/limits.utils.ts similarity index 97% rename from src/components/Limits/utils.limits.ts rename to src/features/limits/utils/limits.utils.ts index 348194301..ef4b4542d 100644 --- a/src/components/Limits/utils.limits.ts +++ b/src/features/limits/utils/limits.utils.ts @@ -1,5 +1,5 @@ import type { MantecaLimit } from '@/interfaces' -import { BRIDGE_REGIONS, MANTECA_REGIONS, REGION_TO_BRIDGE_PARAM, type LimitsPeriod } from './consts.limits' +import { BRIDGE_REGIONS, MANTECA_REGIONS, REGION_TO_BRIDGE_PARAM, type LimitsPeriod } from '../consts' /** * determines which provider route to navigate to based on region path diff --git a/src/components/Limits/BridgeLimitsPage.view.tsx b/src/features/limits/views/BridgeLimitsView.tsx similarity index 98% rename from src/components/Limits/BridgeLimitsPage.view.tsx rename to src/features/limits/views/BridgeLimitsView.tsx index 0d141c6d3..988a9242e 100644 --- a/src/components/Limits/BridgeLimitsPage.view.tsx +++ b/src/features/limits/views/BridgeLimitsView.tsx @@ -11,15 +11,15 @@ import Image from 'next/image' import * as Accordion from '@radix-ui/react-accordion' import { useQueryState, parseAsStringEnum } from 'nuqs' import { useState } from 'react' -import PeanutLoading from '../Global/PeanutLoading' -import { BANK_TRANSFER_REGIONS, QR_COUNTRIES, type BridgeRegion, type QrCountryId } from './consts.limits' +import PeanutLoading from '@/components/Global/PeanutLoading' +import { BANK_TRANSFER_REGIONS, QR_COUNTRIES, type BridgeRegion, type QrCountryId } from '../consts' /** * 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 BridgeLimitsPage = () => { +const BridgeLimitsView = () => { const router = useRouter() const { bridgeLimits, isLoading, error } = useLimits() const { isUserMantecaKycApproved } = useKycStatus() @@ -156,4 +156,4 @@ const BridgeLimitsPage = () => { ) } -export default BridgeLimitsPage +export default BridgeLimitsView diff --git a/src/components/Limits/LimitsPage.view.tsx b/src/features/limits/views/LimitsPageView.tsx similarity index 96% rename from src/components/Limits/LimitsPage.view.tsx rename to src/features/limits/views/LimitsPageView.tsx index 9b8eec055..34f0edafd 100644 --- a/src/components/Limits/LimitsPage.view.tsx +++ b/src/features/limits/views/LimitsPageView.tsx @@ -10,13 +10,13 @@ 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 CryptoLimitsSection from '../components/CryptoLimitsSection' +import FiatLimitsLockedCard from '../components/FiatLimitsLockedCard' import { REST_OF_WORLD_GLOBE_ICON } from '@/assets' -import { getProviderRoute } from './utils.limits' -import InfoCard from '../Global/InfoCard' +import { getProviderRoute } from '../utils/limits.utils' +import InfoCard from '@/components/Global/InfoCard' -const LimitsPage = () => { +const LimitsPageView = () => { const router = useRouter() const { unlockedRegions, lockedRegions } = useIdentityVerification() const { isUserKycApproved, isUserBridgeKycUnderReview, isUserMantecaKycApproved } = useKycStatus() @@ -101,7 +101,7 @@ const LimitsPage = () => { ) } -export default LimitsPage +export default LimitsPageView interface UnlockedRegionsListProps { regions: Region[] diff --git a/src/components/Limits/MantecaLimitsPage.view.tsx b/src/features/limits/views/MantecaLimitsView.tsx similarity index 92% rename from src/components/Limits/MantecaLimitsPage.view.tsx rename to src/features/limits/views/MantecaLimitsView.tsx index 7b7c4ae95..c95dcf816 100644 --- a/src/components/Limits/MantecaLimitsPage.view.tsx +++ b/src/features/limits/views/MantecaLimitsView.tsx @@ -6,19 +6,19 @@ 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 PeriodToggle from '../components/PeriodToggle' +import LimitsProgressBar from '../components/LimitsProgressBar' import Image from 'next/image' -import PeanutLoading from '../Global/PeanutLoading' -import { LIMITS_CURRENCY_FLAGS, LIMITS_CURRENCY_SYMBOLS, type LimitsPeriod } from './consts.limits' -import { getLimitData, getLimitColorClass } from './utils.limits' -import IncreaseLimitsButton from './components/IncreaseLimitsButton' +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 IncreaseLimitsButton from '../components/IncreaseLimitsButton' /** * displays manteca limits for latam users * shows monthly/yearly limits per currency with remaining amounts */ -const MantecaLimitsPage = () => { +const MantecaLimitsView = () => { const router = useRouter() const { mantecaLimits, isLoading, error } = useLimits() const [period, setPeriod] = useState('monthly') @@ -120,4 +120,4 @@ const MantecaLimitsPage = () => { ) } -export default MantecaLimitsPage +export default MantecaLimitsView From c666f41841c1f11dc03a0267c3eedf12f06e74e2 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:20:53 +0530 Subject: [PATCH 10/19] fix: DRY --- .../limits/components/LimitsDocsLink.tsx | 13 +++++++ .../limits/components/LimitsError.tsx | 22 +++++++++++ src/features/limits/consts.ts | 2 +- .../limits/views/BridgeLimitsView.tsx | 30 +++++---------- .../limits/views/MantecaLimitsView.tsx | 37 +++++++------------ 5 files changed, 59 insertions(+), 45 deletions(-) create mode 100644 src/features/limits/components/LimitsDocsLink.tsx create mode 100644 src/features/limits/components/LimitsError.tsx 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/consts.ts b/src/features/limits/consts.ts index d8367bb1d..741375396 100644 --- a/src/features/limits/consts.ts +++ b/src/features/limits/consts.ts @@ -38,7 +38,7 @@ export const LIMITS_CURRENCY_FLAGS: Record = { // currency to symbol mapping export const LIMITS_CURRENCY_SYMBOLS: Record = { - ARS: '$', + ARS: 'ARS', BRL: 'R$', USD: '$', } diff --git a/src/features/limits/views/BridgeLimitsView.tsx b/src/features/limits/views/BridgeLimitsView.tsx index 988a9242e..185b7ca06 100644 --- a/src/features/limits/views/BridgeLimitsView.tsx +++ b/src/features/limits/views/BridgeLimitsView.tsx @@ -13,6 +13,10 @@ import { useQueryState, parseAsStringEnum } from 'nuqs' import { useState } 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 LimitsError from '../components/LimitsError' +import LimitsDocsLink from '../components/LimitsDocsLink' +import EmptyState from '@/components/Global/EmptyStates/EmptyState' /** * displays bridge limits for na/europe/mx users @@ -36,10 +40,10 @@ const BridgeLimitsView = () => { // determine what to show based on source region const showBankTransferLimits = BANK_TRANSFER_REGIONS.includes(region) - // format limit amount with currency symbol + // format limit amount with currency symbol using shared util const formatLimit = (amount: string, asset: string) => { const symbol = asset === 'USD' ? '$' : asset - return `${symbol}${Number(amount).toLocaleString()}` + return `${symbol}${formatExtendedNumber(amount)}` } return ( @@ -48,11 +52,7 @@ const BridgeLimitsView = () => { {isLoading && } - {error && ( - -

Failed to load limits. Please try again.

-
- )} + {error && } {!isLoading && !error && bridgeLimits && ( <> @@ -135,23 +135,11 @@ const BridgeLimitsView = () => {
)} - - See more about limits - + )} - {!isLoading && !error && !bridgeLimits && ( - -

No limits data available.

-
- )} + {!isLoading && !error && !bridgeLimits && }
) } diff --git a/src/features/limits/views/MantecaLimitsView.tsx b/src/features/limits/views/MantecaLimitsView.tsx index c95dcf816..1e9c7c3fb 100644 --- a/src/features/limits/views/MantecaLimitsView.tsx +++ b/src/features/limits/views/MantecaLimitsView.tsx @@ -13,6 +13,10 @@ 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 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' /** * displays manteca limits for latam users @@ -23,11 +27,12 @@ const MantecaLimitsView = () => { const { mantecaLimits, isLoading, error } = useLimits() const [period, setPeriod] = useState('monthly') - // format amount with currency symbol - const formatAmount = (amount: number, currency: string) => { + // format amount with currency symbol using shared util + const formatLimitAmount = (amount: number, currency: string) => { const symbol = LIMITS_CURRENCY_SYMBOLS[currency] || currency - // round to 2 decimal places for display - return `${symbol}${amount.toLocaleString(undefined, { maximumFractionDigits: 2 })}` + // 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 ( @@ -36,11 +41,7 @@ const MantecaLimitsView = () => { {isLoading && } - {error && ( - -

Failed to load limits. Please try again.

-
- )} + {error && } {!isLoading && !error && mantecaLimits && mantecaLimits.length > 0 && ( <> @@ -74,7 +75,7 @@ const MantecaLimitsView = () => {
- {formatAmount(limitData.limit, limit.asset)} + {formatLimitAmount(limitData.limit, limit.asset)}
@@ -84,7 +85,7 @@ const MantecaLimitsView = () => { Remaining this {period === 'monthly' ? 'month' : 'year'} - {formatAmount(limitData.remaining, limit.asset)} + {formatLimitAmount(limitData.remaining, limit.asset)}
@@ -99,22 +100,12 @@ const MantecaLimitsView = () => { - - See more about limits - + )} {!isLoading && !error && (!mantecaLimits || mantecaLimits.length === 0) && ( - -

No limits data available.

-
+ )}
) From 9bfaeab5dac9447ea3caa371e8c7b548849155d3 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:26:07 +0530 Subject: [PATCH 11/19] feat: LimitsWarningCard component for displaying limit warnings and errors --- src/components/Global/Icons/Icon.tsx | 3 + .../limits/components/LimitsWarningCard.tsx | 85 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/features/limits/components/LimitsWarningCard.tsx diff --git a/src/components/Global/Icons/Icon.tsx b/src/components/Global/Icons/Icon.tsx index 842ebb9cc..8760cf18a 100644 --- a/src/components/Global/Icons/Icon.tsx +++ b/src/components/Global/Icons/Icon.tsx @@ -63,6 +63,7 @@ import { CompareArrowsRounded, WarningRounded, SpeedRounded, + InfoRounded, } from '@mui/icons-material' import { DocsIcon } from './docs' import { PeanutSupportIcon } from './peanut-support' @@ -122,6 +123,7 @@ export type IconName = | 'error' | 'clip' | 'info' + | 'info-filled' | 'external-link' | 'plus' | 'switch' @@ -254,6 +256,7 @@ const iconComponents: Record>> = clip: (props) => , info: (props) => , 'external-link': (props) => , + 'info-filled': (props) => , plus: (props) => , alert: (props) => , switch: (props) => , diff --git a/src/features/limits/components/LimitsWarningCard.tsx b/src/features/limits/components/LimitsWarningCard.tsx new file mode 100644 index 000000000..c0004fa31 --- /dev/null +++ b/src/features/limits/components/LimitsWarningCard.tsx @@ -0,0 +1,85 @@ +'use client' + +import InfoCard from '@/components/Global/InfoCard' +import { Icon, type IconProps } from '@/components/Global/Icons/Icon' +import Link from 'next/link' +import { useModalsContext } from '@/context/ModalsContext' +import { twMerge } from 'tailwind-merge' + +export type LimitsWarningType = 'warning' | 'error' + +interface LimitsWarningItem { + text: string + isLink?: boolean + href?: string + icon?: IconProps['name'] +} + +interface LimitsWarningCardProps { + type: LimitsWarningType + title: string + items: LimitsWarningItem[] + showSupportLink?: boolean + className?: string +} + +const SUPPORT_MESSAGE = 'Hi, I would like to increase my payment limits.' + +/** + * 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 && ( + <> +
+ + + )} +
+ } + /> + ) +} From f35d994cb445781bc8127fb567257146585d6132 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:26:48 +0530 Subject: [PATCH 12/19] feat: add manteca withdraw limits and useLimitsValidation hook for txn limit validation --- src/constants/payment.consts.ts | 4 + .../limits/hooks/useLimitsValidation.ts | 279 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 src/features/limits/hooks/useLimitsValidation.ts 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/hooks/useLimitsValidation.ts b/src/features/limits/hooks/useLimitsValidation.ts new file mode 100644 index 000000000..7f16d1c9f --- /dev/null +++ b/src/features/limits/hooks/useLimitsValidation.ts @@ -0,0 +1,279 @@ +'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' + +// 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 +} + +export type LimitFlowType = 'onramp' | 'offramp' | 'qr-payment' +export type LimitCurrency = 'ARS' | 'BRL' | 'USD' + +interface UseLimitsValidationOptions { + flowType: LimitFlowType + amount: number | string | null | undefined + currency?: LimitCurrency + // for qr payments, whether user is local (arg/brazil) or foreign + isLocalUser?: boolean +} + +/** + * hook to validate amounts against user's transaction limits + * returns warning/blocking state based on remaining limits + */ +export function useLimitsValidation({ + flowType, + amount, + currency = 'USD', + isLocalUser = false, +}: UseLimitsValidationOptions) { + const { mantecaLimits, bridgeLimits, isLoading, hasMantecaLimits, hasBridgeLimits } = useLimits() + const { isUserMantecaKycApproved, isUserBridgeKycApproved } = useKycStatus() + + // parse amount to number + const numericAmount = useMemo(() => { + if (!amount) return 0 + const parsed = typeof amount === 'string' ? parseFloat(amount) : amount + 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, + } + } + + 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) + 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 + } + } + + // check if amount exceeds remaining monthly limit + if (numericAmount > availableMonthly) { + return { + isBlocking: true, + isWarning: false, + remainingLimit: availableMonthly, + totalLimit: monthlyLimit, + message: `Amount exceeds your remaining limit of ${formatExtendedNumber(availableMonthly)} ${currency}`, + daysUntilReset: daysUntilMonthlyReset, + } + } + + // 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, + } + } + + return { + isBlocking: false, + isWarning: false, + remainingLimit: effectiveLimit, + totalLimit: monthlyLimit, + message: null, + daysUntilReset: daysUntilMonthlyReset, + } + }, [isUserMantecaKycApproved, relevantMantecaLimit, numericAmount, currency, daysUntilMonthlyReset, flowType]) + + // validate for bridge users (us/europe/mexico) - per transaction limits + const bridgeValidation = useMemo(() => { + if (!isUserBridgeKycApproved || !bridgeLimits) { + return { + isBlocking: false, + isWarning: false, + remainingLimit: null, + totalLimit: null, + message: null, + daysUntilReset: 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, + } + } + + // 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, + } + } + + return { + isBlocking: false, + isWarning: false, + remainingLimit: perTxLimit, + totalLimit: perTxLimit, + message: null, + daysUntilReset: null, + } + }, [isUserBridgeKycApproved, bridgeLimits, flowType, numericAmount]) + + // qr payment validation for foreign users (non-manteca kyc) + const foreignQrValidation = useMemo(() => { + if (flowType !== 'qr-payment' || isLocalUser) { + return { + isBlocking: false, + isWarning: false, + remainingLimit: null, + totalLimit: null, + message: null, + daysUntilReset: 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, + } + } + + return { + isBlocking: false, + isWarning: false, + remainingLimit: MAX_QR_PAYMENT_AMOUNT_FOREIGN, + totalLimit: MAX_QR_PAYMENT_AMOUNT_FOREIGN, + message: null, + daysUntilReset: null, + } + }, [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 + if (isUserMantecaKycApproved && hasMantecaLimits) { + 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, + } + }, [ + flowType, + isLocalUser, + isUserMantecaKycApproved, + isUserBridgeKycApproved, + hasMantecaLimits, + hasBridgeLimits, + mantecaValidation, + bridgeValidation, + foreignQrValidation, + ]) + + return { + ...validation, + isLoading, + // convenience getters + hasLimits: hasMantecaLimits || hasBridgeLimits, + isMantecaUser: isUserMantecaKycApproved, + isBridgeUser: isUserBridgeKycApproved, + } +} From 3bf65176e67bf6ed89e5f60d180cf3b1ae848274 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:27:20 +0530 Subject: [PATCH 13/19] feat: implement limits validation on payment flows --- .../add-money/[country]/bank/page.tsx | 40 ++++++++++++- src/app/(mobile-ui)/qr-pay/page.tsx | 58 +++++++++++++++++- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 59 ++++++++++++++++--- src/app/(mobile-ui)/withdraw/page.tsx | 49 ++++++++++++++- .../AddMoney/components/InputAmountStep.tsx | 43 +++++++++++++- .../AddMoney/components/MantecaAddMoney.tsx | 24 ++++++-- 6 files changed, 251 insertions(+), 22 deletions(-) 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..0ce1adfe7 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,9 @@ 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 { formatExtendedNumber } from '@/utils/general.utils' // Step type for URL state type BridgeBankStep = 'inputAmount' | 'kyc' | 'collectUserDetails' | 'showDetails' @@ -97,6 +100,14 @@ export default function OnrampBankPage() { return getMinimumAmount(selectedCountry.id) }, [selectedCountry?.id]) + // validate against user's bridge limits + const limitsValidation = useLimitsValidation({ + flowType: 'onramp', + amount: rawTokenAmount, + currency: 'USD', + isLocalUser: false, // bridge is for non-local users + }) + // 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 +352,8 @@ export default function OnrampBankPage() { } if (urlState.step === 'inputAmount') { + const showLimitsCard = limitsValidation.isBlocking || limitsValidation.isWarning + return (
@@ -364,6 +377,25 @@ export default function OnrampBankPage() { hideBalance /> + {/* limits warning/error card */} + {showLimitsCard && ( + + )} + Continue - {error.showError && !!error.errorMessage && } + {/* only show error if limits card is not displayed */} + {error.showError && !!error.errorMessage && !showLimitsCard && ( + + )}
('none') @@ -383,6 +389,26 @@ export default function QRPayPage() { } }, [paymentProcessor, simpleFiPayment, paymentLock?.code, paymentLock?.paymentAgainstAmount, amount]) + // determine if user is local (manteca kyc) for limits validation + const isLocalUser = useMemo(() => { + return isUserMantecaKycApproved && paymentProcessor === 'MANTECA' + }, [isUserMantecaKycApproved, paymentProcessor]) + + // determine currency for limits validation based on qr type + const limitsCurrency = useMemo(() => { + if (qrType === EQrType.PIX) return 'BRL' as const + if (qrType === EQrType.MERCADO_PAGO || qrType === EQrType.ARGENTINA_QR3) return 'ARS' as const + return 'USD' as const + }, [qrType]) + + // validate payment against user's limits + const limitsValidation = useLimitsValidation({ + flowType: 'qr-payment', + amount: usdAmount, + currency: limitsCurrency, + isLocalUser, + }) + // 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 +1561,34 @@ export default function QRPayPage() { hideBalance /> )} - {balanceErrorMessage && } + {/* only show balance error if limits card is not displayed */} + {balanceErrorMessage && !limitsValidation.isBlocking && !limitsValidation.isWarning && ( + + )} + + {/* Limits Warning/Error Card */} + {(limitsValidation.isBlocking || limitsValidation.isWarning) && ( + + )} {/* Information Card */} @@ -1560,7 +1613,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..bd3b861bf 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, type LimitCurrency } from '@/features/limits/hooks/useLimitsValidation' +import { MIN_MANTECA_WITHDRAW_AMOUNT } from '@/constants/payment.consts' +import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' +import { formatExtendedNumber } from '@/utils/general.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) @@ -102,6 +103,21 @@ export default function MantecaWithdrawFlow() { // Initialize KYC flow hook const { isMantecaKycRequired } = useMantecaKycFlow({ country: selectedCountry }) + // determine currency for limits validation + const limitsCurrency = useMemo(() => { + const currency = selectedCountry?.currency?.toUpperCase() + if (currency === 'ARS' || currency === 'BRL') return currency as LimitCurrency + return 'USD' + }, [selectedCountry?.currency]) + + // validate against user's limits + const limitsValidation = useLimitsValidation({ + flowType: 'offramp', + amount: usdAmount, + currency: limitsCurrency, + isLocalUser: true, // manteca is for local users + }) + // WebSocket listener for KYC status updates useWebSocket({ username: user?.user.username ?? undefined, @@ -290,10 +306,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 +444,29 @@ export default function MantecaWithdrawFlow() { balance ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : undefined } /> + + {/* limits warning/error card */} + {(limitsValidation.isBlocking || limitsValidation.isWarning) && ( + + )} + - {balanceErrorMessage && } + {/* only show balance error if limits card is not displayed */} + {balanceErrorMessage && !limitsValidation.isBlocking && !limitsValidation.isWarning && ( + + )}
)} diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 19cc8feb8..01cde8224 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -9,11 +9,13 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { tokenSelectorContext } from '@/context/tokenSelector.context' -import { formatAmount } from '@/utils/general.utils' +import { formatAmount, formatExtendedNumber } from '@/utils/general.utils' 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' type WithdrawStep = 'inputAmount' | 'selectMethod' @@ -79,6 +81,15 @@ 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', + isLocalUser: selectedMethod?.type === 'manteca', + }) + // 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 && ( + + )} + - {error.showError && !!error.errorMessage && } + {/* only show error if limits card is not displayed */} + {error.showError && !!error.errorMessage && !showLimitsCard && ( + + )}
) diff --git a/src/components/AddMoney/components/InputAmountStep.tsx b/src/components/AddMoney/components/InputAmountStep.tsx index e58cec3bc..4a387ec7a 100644 --- a/src/components/AddMoney/components/InputAmountStep.tsx +++ b/src/components/AddMoney/components/InputAmountStep.tsx @@ -8,8 +8,18 @@ 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 { formatExtendedNumber } from '@/utils/general.utils' type ICurrency = ReturnType + +interface LimitsValidationProps { + isBlocking: boolean + isWarning: boolean + remainingLimit: number | null + daysUntilReset: number | null +} + interface InputAmountStepProps { onSubmit: () => void isLoading: boolean @@ -21,6 +31,8 @@ interface InputAmountStepProps { setCurrentDenomination?: (denomination: string) => void initialDenomination?: string setDisplayedAmount?: (value: string) => void + limitsValidation?: LimitsValidationProps + limitsCurrency?: string } const InputAmountStep = ({ @@ -34,6 +46,8 @@ const InputAmountStep = ({ setCurrentDenomination, initialDenomination, setDisplayedAmount, + limitsValidation, + limitsCurrency = 'USD', }: InputAmountStepProps) => { const router = useRouter() @@ -41,6 +55,8 @@ const InputAmountStep = ({ return } + const showLimitsCard = limitsValidation?.isBlocking || limitsValidation?.isWarning + return (
router.back()} /> @@ -66,6 +82,28 @@ const InputAmountStep = ({ setCurrentDenomination={setCurrentDenomination} hideBalance /> + + {/* limits warning/error card */} + {showLimitsCard && ( + + )} +
This must exactly match what you send from your bank @@ -74,13 +112,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 card is not displayed */} + {error && !showLimitsCard && }
) diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index a3bada947..7c03eb8ec 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, type LimitCurrency } from '@/features/limits/hooks/useLimitsValidation' // Step type for URL state type MantecaStep = 'inputAmount' | 'depositDetails' @@ -67,6 +68,21 @@ const MantecaAddMoney: FC = () => { const currencyData = useCurrency(selectedCountry?.currency ?? 'ARS') const { user, fetchUser } = useAuth() + // determine currency for limits validation + const limitsCurrency = useMemo(() => { + const currency = selectedCountry?.currency?.toUpperCase() + if (currency === 'ARS' || currency === 'BRL') return currency as LimitCurrency + return 'USD' + }, [selectedCountry?.currency]) + + // validate against user's limits + const limitsValidation = useLimitsValidation({ + flowType: 'onramp', + amount: usdAmount, + currency: limitsCurrency, + isLocalUser: true, // manteca is for local users + }) + useWebSocket({ username: user?.user.username ?? undefined, autoConnect: !!user?.user.username, @@ -79,7 +95,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 +104,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 +221,8 @@ const MantecaAddMoney: FC = () => { setCurrentDenomination={handleDenominationChange} initialDenomination={currentDenomination} setDisplayedAmount={handleDisplayedAmountChange} + limitsValidation={limitsValidation} + limitsCurrency={limitsCurrency} /> {isKycModalOpen && ( Date: Thu, 15 Jan 2026 16:56:48 +0530 Subject: [PATCH 14/19] fix: cr dry suggestion --- .../add-money/[country]/bank/page.tsx | 18 +++++++------ src/app/(mobile-ui)/qr-pay/page.tsx | 18 +++++++------ src/app/(mobile-ui)/withdraw/manteca/page.tsx | 26 +++++++++---------- src/app/(mobile-ui)/withdraw/page.tsx | 18 +++++++------ .../AddMoney/components/InputAmountStep.tsx | 13 ++++------ .../AddMoney/components/MantecaAddMoney.tsx | 9 +++---- .../limits/components/LimitsWarningCard.tsx | 5 ++-- src/features/limits/utils/limits.utils.ts | 19 ++++++++++++++ 8 files changed, 73 insertions(+), 53 deletions(-) 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 0ce1adfe7..d1e3f3ced 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -29,6 +29,7 @@ import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs' import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' import { formatExtendedNumber } from '@/utils/general.utils' +import { LIMITS_COPY } from '@/features/limits/utils/limits.utils' // Step type for URL state type BridgeBankStep = 'inputAmount' | 'kyc' | 'collectUserDetails' | 'showDetails' @@ -381,16 +382,17 @@ export default function OnrampBankPage() { {showLimitsCard && ( @@ -417,8 +419,8 @@ export default function OnrampBankPage() { > Continue - {/* only show error if limits card is not displayed */} - {error.showError && !!error.errorMessage && !showLimitsCard && ( + {/* only show error if limits blocking card is not displayed (warnings can coexist) */} + {error.showError && !!error.errorMessage && !limitsValidation.isBlocking && ( )} diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 14fc68683..41b4280af 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -71,6 +71,7 @@ import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' import useKycStatus from '@/hooks/useKycStatus' import { MAX_QR_PAYMENT_AMOUNT_FOREIGN } from '@/constants/payment.consts' import { formatExtendedNumber } from '@/utils/general.utils' +import { LIMITS_COPY } from '@/features/limits/utils/limits.utils' const MAX_QR_PAYMENT_AMOUNT = '2000' const MIN_QR_PAYMENT_AMOUNT = '0.1' @@ -1561,8 +1562,8 @@ export default function QRPayPage() { hideBalance /> )} - {/* only show balance error if limits card is not displayed */} - {balanceErrorMessage && !limitsValidation.isBlocking && !limitsValidation.isWarning && ( + {/* only show balance error if limits blocking card is not displayed (warnings can coexist) */} + {balanceErrorMessage && !limitsValidation.isBlocking && ( )} @@ -1570,11 +1571,7 @@ export default function QRPayPage() { {(limitsValidation.isBlocking || limitsValidation.isWarning) && ( diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index bd3b861bf..7759faddf 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -45,10 +45,11 @@ import { } from '@/constants/manteca.consts' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { TRANSACTIONS } from '@/constants/query.consts' -import { useLimitsValidation, type LimitCurrency } from '@/features/limits/hooks/useLimitsValidation' +import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' import { MIN_MANTECA_WITHDRAW_AMOUNT } from '@/constants/payment.consts' import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' import { formatExtendedNumber } from '@/utils/general.utils' +import { mapToLimitCurrency, LIMITS_COPY } from '@/features/limits/utils/limits.utils' type MantecaWithdrawStep = 'amountInput' | 'bankDetails' | 'review' | 'success' | 'failure' @@ -104,10 +105,8 @@ export default function MantecaWithdrawFlow() { const { isMantecaKycRequired } = useMantecaKycFlow({ country: selectedCountry }) // determine currency for limits validation - const limitsCurrency = useMemo(() => { - const currency = selectedCountry?.currency?.toUpperCase() - if (currency === 'ARS' || currency === 'BRL') return currency as LimitCurrency - return 'USD' + const limitsCurrency = useMemo(() => { + return mapToLimitCurrency(selectedCountry?.currency) }, [selectedCountry?.currency]) // validate against user's limits @@ -449,11 +448,7 @@ export default function MantecaWithdrawFlow() { {(limitsValidation.isBlocking || limitsValidation.isWarning) && ( @@ -485,8 +485,8 @@ export default function MantecaWithdrawFlow() { > Continue - {/* only show balance error if limits card is not displayed */} - {balanceErrorMessage && !limitsValidation.isBlocking && !limitsValidation.isWarning && ( + {/* 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 01cde8224..bb410b3a2 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useState, useRef, useContext } from 'r import { formatUnits } from 'viem' import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' +import { LIMITS_COPY } from '@/features/limits/utils/limits.utils' type WithdrawStep = 'inputAmount' | 'selectMethod' @@ -300,11 +301,7 @@ export default function WithdrawPage() { {showLimitsCard && ( @@ -329,8 +331,8 @@ export default function WithdrawPage() { > Continue - {/* only show error if limits card is not displayed */} - {error.showError && !!error.errorMessage && !showLimitsCard && ( + {/* 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 4a387ec7a..517546498 100644 --- a/src/components/AddMoney/components/InputAmountStep.tsx +++ b/src/components/AddMoney/components/InputAmountStep.tsx @@ -10,6 +10,7 @@ import { useCurrency } from '@/hooks/useCurrency' import PeanutLoading from '@/components/Global/PeanutLoading' import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' import { formatExtendedNumber } from '@/utils/general.utils' +import { LIMITS_COPY } from '@/features/limits/utils/limits.utils' type ICurrency = ReturnType @@ -87,11 +88,7 @@ const InputAmountStep = ({ {showLimitsCard && ( )} @@ -118,8 +115,8 @@ const InputAmountStep = ({ > Continue - {/* only show error if limits card is not displayed */} - {error && !showLimitsCard && } + {/* 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 7c03eb8ec..32f66d169 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -18,7 +18,8 @@ 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, type LimitCurrency } from '@/features/limits/hooks/useLimitsValidation' +import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' +import { mapToLimitCurrency } from '@/features/limits/utils/limits.utils' // Step type for URL state type MantecaStep = 'inputAmount' | 'depositDetails' @@ -69,10 +70,8 @@ const MantecaAddMoney: FC = () => { const { user, fetchUser } = useAuth() // determine currency for limits validation - const limitsCurrency = useMemo(() => { - const currency = selectedCountry?.currency?.toUpperCase() - if (currency === 'ARS' || currency === 'BRL') return currency as LimitCurrency - return 'USD' + const limitsCurrency = useMemo(() => { + return mapToLimitCurrency(selectedCountry?.currency) }, [selectedCountry?.currency]) // validate against user's limits diff --git a/src/features/limits/components/LimitsWarningCard.tsx b/src/features/limits/components/LimitsWarningCard.tsx index c0004fa31..8f317c52f 100644 --- a/src/features/limits/components/LimitsWarningCard.tsx +++ b/src/features/limits/components/LimitsWarningCard.tsx @@ -5,6 +5,7 @@ import { Icon, type IconProps } from '@/components/Global/Icons/Icon' import Link from 'next/link' import { useModalsContext } from '@/context/ModalsContext' import { twMerge } from 'tailwind-merge' +import { LIMITS_COPY } from '../utils/limits.utils' export type LimitsWarningType = 'warning' | 'error' @@ -23,8 +24,6 @@ interface LimitsWarningCardProps { className?: string } -const SUPPORT_MESSAGE = 'Hi, I would like to increase my payment limits.' - /** * reusable card for displaying limit warnings (yellow) or blocking errors (red) * used across qr payments, add money, and withdraw flows @@ -68,7 +67,7 @@ export default function LimitsWarningCard({ <>