Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8c8c32f
Merge pull request #2326 from peanutprotocol/hotfix/add-money-rate-nu…
Hugo0 Jul 2, 2026
a5f1a36
feat(badges): wire Offramp User badge (art + campaign maps)
0xkkonrad Jul 2, 2026
09d2d88
chore(badges): use teary coin variant for Offramp User art
0xkkonrad Jul 2, 2026
456a52a
Merge pull request #2331 from peanutprotocol/hotfix/offramp-user-badge
kushagrasarathe Jul 2, 2026
7fd3fb7
fix(card): key card flows off findActiveCard, never cards[0] + un-tra…
Hugo0 Jul 2, 2026
c9c4da5
fix: key the stuck-after-success signal to the card identity (CodeRab…
Hugo0 Jul 2, 2026
6d31267
feat(manteca): BRL PIX dynamic-QR deposit screen (FE)
abalinda Jun 30, 2026
d0ff7d2
refactor(manteca): repoint BRL PIX QR screen to the ramp-on synthetic…
abalinda Jun 30, 2026
dfdd893
feat(manteca): default to local currency for BRL/ARS + branded waitin…
abalinda Jul 1, 2026
3ab0668
feat(manteca): narrow local-currency default to Brazil + branded sett…
abalinda Jul 1, 2026
9d506bb
feat(manteca): wire PIX QR screen to the prod-confirmed contract; ret…
jjramirezn Jul 2, 2026
8dd1f94
fix: address /code-review findings on the setup-modal escape
Hugo0 Jul 2, 2026
c2fa8e5
fix: key lastError to the attempted card + named Sentry import (/code…
Hugo0 Jul 2, 2026
126d1aa
Merge pull request #2335 from peanutprotocol/feat/pix-qr-deposit-main
Hugo0 Jul 2, 2026
1a4b994
Merge pull request #2334 from peanutprotocol/fix/rain-active-card-sel…
Hugo0 Jul 2, 2026
6f027c8
fix(manteca): PIX QR no longer vanishes — settling gates on stage, no…
jjramirezn Jul 2, 2026
1b75fbb
Merge pull request #2337 from peanutprotocol/fix/pix-qr-processing-state
Hugo0 Jul 2, 2026
df27ff3
feat: advertise supported networks, tokens and bank rails in landing FAQ
Hugo0 Jul 2, 2026
02da547
refactor: single-source the FAQ rail facts per code review
Hugo0 Jul 2, 2026
fb1ac5d
feat: position supported-rails FAQ right after 'What is Peanut?' (Hug…
Hugo0 Jul 2, 2026
8d4d799
fix: derive non-EVM chain names in FAQ copy from OTHER_SUPPORTED_CHAI…
Hugo0 Jul 2, 2026
9752628
Merge pull request #2338 from peanutprotocol/feat/faq-supported-rails
Hugo0 Jul 2, 2026
8e5f036
Merge remote-tracking branch 'origin/main' into chore/backmerge-main-…
Hugo0 Jul 2, 2026
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
Binary file added public/badges/offramp_user.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion src/app/(mobile-ui)/dev/card-session-approve/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { useState } from 'react'
import { findActiveCard } from '@/components/Card/cardState.utils'
import { useRainCardOverview } from '@/hooks/useRainCardOverview'
import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey'
import { Button } from '@/components/0_Bruddle/Button'
Expand All @@ -18,7 +19,7 @@ export default function CardSessionApprovePage() {
const { grant, isGranting } = useGrantSessionKey()
const [status, setStatus] = useState<string>('')

const card = overview?.cards?.[0]
const card = findActiveCard(overview)

const handleClick = async () => {
setStatus('Waiting for passkey tap…')
Expand Down
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 />
) : (
<>
<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