Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 44 additions & 21 deletions src/components/AddMoney/components/MantecaAddMoney.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use client'
import { type FC, useEffect, useMemo, useState, useCallback } from 'react'
import MantecaDepositShareDetails from '@/components/AddMoney/components/MantecaDepositShareDetails'
import MantecaPixQrDeposit from '@/components/AddMoney/components/MantecaPixQrDeposit'
import CyclingLoading from '@/components/Global/PeanutLoading/CyclingLoading'
import InputAmountStep from '@/components/AddMoney/components/InputAmountStep'
import { useParams, useSearchParams } from 'next/navigation'
import { addMoneyCountryUrl } from '@/utils/native-routes'
Expand All @@ -25,11 +27,9 @@ import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs'
import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import InfoCard from '@/components/Global/InfoCard'
import underMaintenanceConfig, { PIX_BRAZIL_ONRAMP_MAINTENANCE } from '@/config/underMaintenance.config'

// Step type for URL state
type MantecaStep = 'inputAmount' | 'depositDetails'
type MantecaStep = 'inputAmount' | 'depositDetails' | 'showQR'

// Currency denomination type for URL state
type CurrencyDenomination = 'USD' | 'ARS' | 'BRL' | 'MXN' | 'EUR'
Expand All @@ -48,7 +48,7 @@ const MantecaAddMoney: FC = () => {
// amounts of this same screen instead of leaving it. The URL stays shareable either
// way. Enforced by the no-restricted-syntax guard in eslint.config.js.
const [urlState, setUrlState] = useQueryStates({
step: parseAsStringEnum<MantecaStep>(['inputAmount', 'depositDetails']),
step: parseAsStringEnum<MantecaStep>(['inputAmount', 'depositDetails', 'showQR']),
amount: parseAsString,
currency: parseAsStringEnum<CurrencyDenomination>(['USD', 'ARS', 'BRL', 'MXN', 'EUR']),
})
Expand All @@ -57,7 +57,6 @@ const MantecaAddMoney: FC = () => {
const step: MantecaStep = urlState.step ?? 'inputAmount'
// Amount from URL - this is in the denomination specified by `currency`
const displayedAmount = urlState.amount ?? ''
const currentDenomination = urlState.currency ?? 'USD'

// Local UI state for tracking both amounts (needed for API call and validation)
const [usdAmount, setUsdAmount] = useState<string>('')
Expand All @@ -73,10 +72,11 @@ const MantecaAddMoney: FC = () => {
const selectedCountry = useMemo(() => {
return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath)
}, [selectedCountryPath])
// Default the input denomination to BRL for Brazil (PIX is in BRL); every other
// country keeps the USD default.
const currentDenomination: CurrencyDenomination =
urlState.currency ?? (selectedCountry?.currency === 'BRL' ? 'BRL' : 'USD')
const onBack = useSafeBack(addMoneyCountryUrl(selectedCountryPath))
// BRL-via-PIX onramp warn-only maintenance flag (see underMaintenance.config.ts).
// Brazil-scoped so the Argentina/ARS Manteca onramp is unaffected.
const showPixMaintenance = selectedCountry?.id === 'BR' && underMaintenanceConfig.pixBrazilOnrampMaintenance
// The pool→full upgrade gate asks "did the user clear ID verification?",
// not "do they have an enabled rail elsewhere?" — read the identity
// signal directly (Sumsub-cleared the human) instead of the old
Expand Down Expand Up @@ -194,14 +194,20 @@ const MantecaAddMoney: FC = () => {
setError(depositData.error)
return
}
setDepositDetails(depositData.data)
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_CONFIRMED, {
amount_usd: usdAmount,
method_type: 'manteca',
country: selectedCountryPath,
})
// Update URL state to show deposit details step
setUrlState({ step: 'depositDetails' })
// BRL deposits carry the dynamic PIX QR in the ramp-on synthetic's
// details → show the QR step. ARS/others show deposit details.
const data = depositData.data
setDepositDetails(data)
if (selectedCountry?.currency === 'BRL') {
setUrlState({ step: 'showQR' })
} else {
setUrlState({ step: 'depositDetails' })
}
} catch (error) {
console.log(error)
const errorMessage = error instanceof Error ? error.message : String(error)
Expand Down Expand Up @@ -229,10 +235,23 @@ const MantecaAddMoney: FC = () => {
if (step === 'depositDetails' && !depositDetails) {
setUrlState({ step: 'inputAmount' })
}
if (step === 'showQR' && !depositDetails) {
setUrlState({ step: 'inputAmount' })
}
}, [step, depositDetails, setUrlState])

if (!selectedCountry) return null

// While the BRL PIX deposit request is in flight (the QR is being generated),
// show the branded processing screen — same as when a PIX payment is processing.
if (isCreatingDeposit && selectedCountry.currency === 'BRL') {
return (
<div className="my-auto flex min-h-[inherit] flex-col justify-center">
<CyclingLoading />
</div>
)
}

if (step === 'inputAmount') {
return (
<>
Expand Down Expand Up @@ -287,16 +306,6 @@ const MantecaAddMoney: FC = () => {
limitsValidation={limitsValidation}
limitsCurrency={limitsValidation.currency}
onBack={onBack}
maintenanceBanner={
showPixMaintenance ? (
<InfoCard
variant="warning"
icon="alert"
title={PIX_BRAZIL_ONRAMP_MAINTENANCE.title}
description={PIX_BRAZIL_ONRAMP_MAINTENANCE.description}
/>
) : undefined
}
/>
</>
)
Expand All @@ -316,6 +325,20 @@ const MantecaAddMoney: FC = () => {
)
}

if (step === 'showQR') {
if (!depositDetails) {
return null
}
return (
<MantecaPixQrDeposit
depositDetails={depositDetails}
currencyAmount={localCurrencyAmount}
onBack={() => setUrlState({ step: 'inputAmount' })}
onComplete={() => queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })}
/>
)
}

return null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ const MantecaDepositShareDetails = ({
return MANTECA_COUNTRIES_CONFIG[currentCountryDetails.id]?.depositAddressLabel ?? 'Deposit Address'
}, [currentCountryDetails])

const depositAddress = depositDetails.details.depositAddress
// BRL synthetics no longer carry these (QR-only) — but BRL routes to the QR
// screen, never here; the fallback just keeps the ARS/static path total.
const depositAddress = depositDetails.details.depositAddress ?? ''
const shortenedAddress = depositAddress.length > 30 ? shortenStringLong(depositAddress, 10) : depositAddress
const depositAlias = depositDetails.details.depositAlias
const depositAmount = currencyAmount ?? depositDetails.stages['1'].thresholdAmount
Expand Down
119 changes: 119 additions & 0 deletions src/components/AddMoney/components/MantecaPixQrDeposit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
'use client'

import { type FC, useEffect, useMemo, useState } from 'react'
import NavHeader from '@/components/Global/NavHeader'
import QRCodeWrapper from '@/components/Global/QRCodeWrapper'
import CopyToClipboard from '@/components/Global/CopyToClipboard'
import { Icon } from '@/components/Global/Icons/Icon'
import { Button } from '@/components/0_Bruddle/Button'
import { type MantecaDepositResponseData } from '@/types/manteca.types'
import { useMantecaDepositPolling } from '@/components/AddMoney/hooks/useMantecaDepositPolling'
import CyclingLoading from '@/components/Global/PeanutLoading/CyclingLoading'

const MantecaPixQrDeposit: FC<{
depositDetails: MantecaDepositResponseData
currencyAmount?: string
// Parent owns step navigation — usually setUrlState({ step: 'inputAmount' }).
onBack: () => void
// Fired once when the deposit settles (parent refreshes balance/history).
onComplete: () => void
}> = ({ depositDetails, currencyAmount, onBack, onComplete }) => {
// The dynamic PIX QR (EMVCo copia-e-cola) rides in the ramp-on synthetic's
// details.depositAddresses.PIX (confirmed against prod 2026-07-02).
const qr = depositDetails.details.depositAddresses?.PIX?.code
// Poll by the real synthetic id (unchanged polling contract).
const { status } = useMantecaDepositPolling(depositDetails.id, onComplete)

// QR expiry countdown. `priceExpireAt` carries a tz offset, so Date parses it
// directly. We tick once a second and stop once the QR is paid or has lapsed
// (the effect re-runs when isExpired flips and clears the interval).
const expiresAtMs = useMemo(
() => new Date(depositDetails.details.priceExpireAt).getTime(),
[depositDetails.details.priceExpireAt]
)
const [nowMs, setNowMs] = useState(() => Date.now())

const remainingMs = expiresAtMs - nowMs
const isExpired = remainingMs <= 0
const minutes = Math.floor(remainingMs / 60000)
const seconds = Math.floor((remainingMs % 60000) / 1000)
const countdownLabel = isExpired ? null : `${minutes}:${String(seconds).padStart(2, '0')}`

useEffect(() => {
if (status === 'completed' || status === 'processing' || isExpired) return
const interval = setInterval(() => setNowMs(Date.now()), 1000)
return () => clearInterval(interval)
}, [status, isExpired])

if (status === 'completed') {
return (
<div className="flex min-h-[inherit] flex-col gap-8">
<NavHeader title="Add Money" onPrev={onBack} />
<div className="my-auto flex flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-1">
<Icon name="check" size={32} />
</div>
<h2 className="text-2xl font-bold text-n-1">Deposit received!</h2>
<p className="text-grey-1">Your balance has been updated.</p>
<Button variant="purple" shadowSize="4" className="w-full" onClick={onBack}>
Done
</Button>
</div>
</div>
)
}

// Payment detected, settling — show the branded processing screen (same as PIX payments).
if (status === 'processing') {
return (
<div className="flex min-h-[inherit] flex-col gap-8">
<NavHeader title="Add Money" onPrev={onBack} />
<div className="my-auto flex flex-col justify-center">
<CyclingLoading />
</div>
</div>
)
}

return (
<div className="flex min-h-[inherit] flex-col gap-8">
<NavHeader title="Add Money" onPrev={onBack} />
<div className="my-auto flex flex-col gap-6">
<div className="text-center">
<p className="text-sm text-grey-1">Pay with PIX</p>
{currencyAmount && <p className="text-2xl font-bold text-n-1">R$ {currencyAmount}</p>}
</div>

{!qr ? (
<CyclingLoading />
Comment on lines +87 to +88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File outline ==\n'
ast-grep outline src/components/AddMoney/components/MantecaPixQrDeposit.tsx --view expanded || true

printf '\n== Relevant search ==\n'
rg -n "depositAddresses\\.PIX|useMantecaDepositPolling|CyclingLoading|qrTimedOut|onBack" src/components/AddMoney -S || true

printf '\n== File excerpt ==\n'
sed -n '1,220p' src/components/AddMoney/components/MantecaPixQrDeposit.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 13305


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Test file ==\n'
sed -n '1,220p' src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx

printf '\n== Polling hook ==\n'
sed -n '1,220p' src/components/AddMoney/hooks/useMantecaDepositPolling.ts

printf '\n== Type definition search ==\n'
rg -n "type MantecaDepositResponseData|interface MantecaDepositResponseData|depositAddresses" src/types src/components/AddMoney -S

Repository: peanutprotocol/peanut-ui

Length of output: 7738


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Type file excerpt ==\n'
sed -n '1,180p' src/types/manteca.types.ts

printf '\n== Component usage search ==\n'
rg -n "MantecaPixQrDeposit|depositDetails" src/components/AddMoney -S

printf '\n== MantecaAddMoney excerpt ==\n'
sed -n '1,380p' src/components/AddMoney/components/MantecaAddMoney.tsx

Repository: peanutprotocol/peanut-ui

Length of output: 24260


Add a fallback when the PIX code is missing.
depositAddresses.PIX?.code is optional here, and when it’s absent this screen stays on CyclingLoading forever. The expiry state never renders because it’s gated behind qr, and useMantecaDepositPolling only tracks settlement status. Add a timeout/error + retry/back state, or make the API guarantee the PIX code at creation time.

) : (
<>
<QRCodeWrapper url={qr} isBlurred={isExpired} disabled={isExpired} className="max-w-[280px]" />

{countdownLabel && (
<p className="text-center text-sm text-grey-1">Expires in {countdownLabel}</p>
)}

{isExpired ? (
<div className="flex flex-col gap-3 text-center">
<p className="text-sm text-grey-1">This QR code has expired.</p>
<Button variant="stroke" className="w-full" onClick={onBack}>
Go back
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
<p className="text-center text-sm text-grey-1">
Scan with your bank app, or copy the PIX code.
</p>
<CopyToClipboard textToCopy={qr} type="button" className="w-full" />
</div>
)}
</>
)}
</div>
</div>
)
}

export default MantecaPixQrDeposit
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* MantecaPixQrDeposit — the BRL dynamic-PIX-QR screen.
*
* One `details.depositAddresses.PIX.code` string (EMVCo copia-e-cola) drives
* both the QR and the copy button; a live countdown is derived from
* `details.priceExpireAt`; polling flips the screen to a success state. Nested
* primitives are stubbed so only this component's own logic is under test.
*/
import React from 'react'
import { render, screen } from '@testing-library/react'

const mockUseMantecaDepositPolling = jest.fn()
jest.mock('@/components/AddMoney/hooks/useMantecaDepositPolling', () => ({
useMantecaDepositPolling: (...args: unknown[]) => mockUseMantecaDepositPolling(...args),
}))

jest.mock('@/components/Global/NavHeader', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/Global/QRCodeWrapper', () => ({
__esModule: true,
default: ({ url, disabled }: { url: string; disabled?: boolean }) => (
<div data-testid="qr" data-url={url} data-disabled={disabled ? 'true' : 'false'} />
),
}))
jest.mock('@/components/Global/CopyToClipboard', () => ({
__esModule: true,
default: ({ textToCopy }: { textToCopy: string }) => <div data-testid="copy" data-text={textToCopy} />,
}))
jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () => <div /> }))
jest.mock('@/components/0_Bruddle/Button', () => ({
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
<button onClick={onClick}>{children}</button>
),
}))

// eslint-disable-next-line import/first -- must come after jest.mock
import MantecaPixQrDeposit from '../MantecaPixQrDeposit'

const PIX_CODE = '00020126-COPIA-E-COLA'
const baseDeposit = {
id: 'syn-1',
type: 'RAMP_OPERATION' as const,
details: {
// prod shape confirmed 2026-07-02 — the QR rides in depositAddresses.PIX
depositAddresses: {
PIX: {
type: 'QR',
code: PIX_CODE,
url: `https://widget.manteca.dev/qr?code=${PIX_CODE}`,
expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString(), // ~3 days out
bankId: 'bank-1',
},
},
priceExpireAt: new Date(Date.now() + 5 * 60_000).toISOString(), // 5 min out
},
stages: {},
} as unknown as import('@/types/manteca.types').MantecaDepositResponseData

beforeEach(() => {
mockUseMantecaDepositPolling.mockReset()
mockUseMantecaDepositPolling.mockReturnValue({ status: 'pending' })
})

describe('MantecaPixQrDeposit', () => {
it('renders the QR + copy from the same PIX copia-e-cola code, the amount, and a live countdown', () => {
render(
<MantecaPixQrDeposit
depositDetails={baseDeposit}
currencyAmount="10"
onBack={jest.fn()}
onComplete={jest.fn()}
/>
)
expect(screen.getByTestId('qr')).toHaveAttribute('data-url', PIX_CODE)
expect(screen.getByTestId('copy')).toHaveAttribute('data-text', PIX_CODE)
expect(screen.getByText('R$ 10')).toBeInTheDocument()
expect(screen.getByText(/Expires in/)).toBeInTheDocument()
})

it('shows the expired state (QR disabled, no countdown) once priceExpireAt has passed', () => {
const expired = {
...baseDeposit,
details: { ...baseDeposit.details, priceExpireAt: new Date(Date.now() - 1000).toISOString() },
}
render(<MantecaPixQrDeposit depositDetails={expired} onBack={jest.fn()} onComplete={jest.fn()} />)

expect(screen.getByText(/expired/i)).toBeInTheDocument()
expect(screen.getByTestId('qr')).toHaveAttribute('data-disabled', 'true')
expect(screen.queryByText(/Expires in/)).not.toBeInTheDocument()
})

it('shows the success state when the deposit completes', () => {
mockUseMantecaDepositPolling.mockReturnValue({ status: 'completed' })
render(<MantecaPixQrDeposit depositDetails={baseDeposit} onBack={jest.fn()} onComplete={jest.fn()} />)

expect(screen.getByText('Deposit received!')).toBeInTheDocument()
expect(screen.queryByTestId('qr')).not.toBeInTheDocument()
})
})
Loading
Loading