diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index a6435d34e..baa9fea45 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -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' @@ -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' @@ -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(['inputAmount', 'depositDetails']), + step: parseAsStringEnum(['inputAmount', 'depositDetails', 'showQR']), amount: parseAsString, currency: parseAsStringEnum(['USD', 'ARS', 'BRL', 'MXN', 'EUR']), }) @@ -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('') @@ -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 @@ -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) @@ -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 ( +
+ +
+ ) + } + if (step === 'inputAmount') { return ( <> @@ -287,16 +306,6 @@ const MantecaAddMoney: FC = () => { limitsValidation={limitsValidation} limitsCurrency={limitsValidation.currency} onBack={onBack} - maintenanceBanner={ - showPixMaintenance ? ( - - ) : undefined - } /> ) @@ -316,6 +325,20 @@ const MantecaAddMoney: FC = () => { ) } + if (step === 'showQR') { + if (!depositDetails) { + return null + } + return ( + setUrlState({ step: 'inputAmount' })} + onComplete={() => queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })} + /> + ) + } + return null } diff --git a/src/components/AddMoney/components/MantecaPixQrDeposit.tsx b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx new file mode 100644 index 000000000..726571e87 --- /dev/null +++ b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx @@ -0,0 +1,118 @@ +'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. + const qr = depositDetails.details.qr + // 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 ( +
+ +
+
+ +
+

Deposit received!

+

Your balance has been updated.

+ +
+
+ ) + } + + // Payment detected, settling — show the branded processing screen (same as PIX payments). + if (status === 'processing') { + return ( +
+ +
+ +
+
+ ) + } + + return ( +
+ +
+
+

Pay with PIX

+ {currencyAmount &&

R$ {currencyAmount}

} +
+ + {!qr ? ( + + ) : ( + <> + + + {countdownLabel && ( +

Expires in {countdownLabel}

+ )} + + {isExpired ? ( +
+

This QR code has expired.

+ +
+ ) : ( +
+

+ Scan with your bank app, or copy the PIX code. +

+ +
+ )} + + )} +
+
+ ) +} + +export default MantecaPixQrDeposit diff --git a/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx new file mode 100644 index 000000000..0658d8864 --- /dev/null +++ b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx @@ -0,0 +1,90 @@ +/** + * MantecaPixQrDeposit — the BRL dynamic-PIX-QR screen. + * + * One `details.qr` string 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: () =>
})) +jest.mock('@/components/Global/QRCodeWrapper', () => ({ + __esModule: true, + default: ({ url, disabled }: { url: string; disabled?: boolean }) => ( +
+ ), +})) +jest.mock('@/components/Global/CopyToClipboard', () => ({ + __esModule: true, + default: ({ textToCopy }: { textToCopy: string }) =>
, +})) +jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () =>
})) +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), +})) + +// eslint-disable-next-line import/first -- must come after jest.mock +import MantecaPixQrDeposit from '../MantecaPixQrDeposit' + +const baseDeposit = { + id: 'syn-1', + type: 'RAMP_OPERATION' as const, + details: { + qr: '00020126-COPIA-E-COLA', + priceExpireAt: new Date(Date.now() + 5 * 60_000).toISOString(), // 5 min out + depositAddress: '', + depositAlias: '', + }, + 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 `details.qr`, the amount, and a live countdown', () => { + render( + + ) + expect(screen.getByTestId('qr')).toHaveAttribute('data-url', baseDeposit.details.qr) + expect(screen.getByTestId('copy')).toHaveAttribute('data-text', baseDeposit.details.qr) + 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() + + 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() + + expect(screen.getByText('Deposit received!')).toBeInTheDocument() + expect(screen.queryByTestId('qr')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/AddMoney/consts/index.ts b/src/components/AddMoney/consts/index.ts index 6b8ad3925..3ac4a3841 100644 --- a/src/components/AddMoney/consts/index.ts +++ b/src/components/AddMoney/consts/index.ts @@ -2755,6 +2755,12 @@ countryData.forEach((country) => { if (method.id === 'pix-add') { return countryCode === 'BR' } + // 'From Bank' is redundant for Brazil — it routes to the same Manteca flow + // as 'Pix' (/add-money/brazil/manteca), so hide it and show Pix only. + // Reversible: delete this block to restore the 'From Bank' card for Brazil. + if (method.id === 'bank-transfer-add' && countryCode === 'BR') { + return false + } return true }).map((m) => { const newMethod = { ...m } diff --git a/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx b/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx new file mode 100644 index 000000000..fdf4c6108 --- /dev/null +++ b/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx @@ -0,0 +1,69 @@ +/** + * useMantecaDepositPolling — maps the BE intent status to a poll status and + * fires onComplete exactly once on COMPLETED. Read-only: it never moves money, + * it mirrors GET /manteca/deposit/:id/status so the QR screen can advance. + */ +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' + +const mockGetDepositStatus = jest.fn() +jest.mock('@/services/manteca', () => ({ + mantecaApi: { getDepositStatus: mockGetDepositStatus }, +})) + +// eslint-disable-next-line import/first -- must come after jest.mock +import { useMantecaDepositPolling } from '../useMantecaDepositPolling' + +describe('useMantecaDepositPolling', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + mockGetDepositStatus.mockReset() + }) + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + it('reports "pending" for a non-terminal status', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + const { result } = renderHook(() => useMantecaDepositPolling('dep-1', jest.fn()), { wrapper }) + + await waitFor(() => expect(mockGetDepositStatus).toHaveBeenCalledWith('dep-1')) + expect(result.current.status).toBe('pending') + }) + + it('reports "completed" and fires onComplete exactly once on COMPLETED', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-2', status: 'COMPLETED' } }) + const onComplete = jest.fn() + const { result, rerender } = renderHook(() => useMantecaDepositPolling('dep-2', onComplete), { wrapper }) + + await waitFor(() => expect(result.current.status).toBe('completed')) + rerender() + rerender() + expect(onComplete).toHaveBeenCalledTimes(1) + }) + + it('reports "failed" for a terminal failure status', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-3', status: 'CANCELLED' } }) + const { result } = renderHook(() => useMantecaDepositPolling('dep-3', jest.fn()), { wrapper }) + + await waitFor(() => expect(result.current.status).toBe('failed')) + }) + + it('reports "processing" for an intermediate settling status', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-4', status: 'PROCESSING' } }) + const { result } = renderHook(() => useMantecaDepositPolling('dep-4', jest.fn()), { wrapper }) + + await waitFor(() => expect(result.current.status).toBe('processing')) + }) + + it('does not query when depositId is undefined', () => { + const { result } = renderHook(() => useMantecaDepositPolling(undefined, jest.fn()), { wrapper }) + + expect(result.current.status).toBe('pending') + expect(mockGetDepositStatus).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/AddMoney/hooks/useMantecaDepositPolling.ts b/src/components/AddMoney/hooks/useMantecaDepositPolling.ts new file mode 100644 index 000000000..eee0b3ff4 --- /dev/null +++ b/src/components/AddMoney/hooks/useMantecaDepositPolling.ts @@ -0,0 +1,51 @@ +'use client' + +import { mantecaApi } from '@/services/manteca' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useRef } from 'react' + +const POLLING_INTERVAL = 5000 + +// Terminal TransactionIntentStatus values returned by GET /manteca/deposit/:id/status. +const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELLED', 'REFUNDED'] +// Non-terminal states that mean "payment detected, settling" — show a processing screen. +const PROCESSING_STATUSES = ['PROCESSING', 'AWAITING_SETTLEMENT'] + +type MantecaDepositPollStatus = 'pending' | 'processing' | 'completed' | 'failed' + +/** + * Poll a BRL PIX deposit intent until it settles. Read-only: the webhook/poller + * post the actual credit — this just mirrors `intent.status` so the QR screen can + * advance to a success state. Fires `onComplete` exactly once on COMPLETED. + */ +export function useMantecaDepositPolling(depositId: string | undefined, onComplete: () => void) { + const hasCompleted = useRef(false) + + const { data } = useQuery({ + queryKey: ['manteca-deposit-status', depositId], + queryFn: () => mantecaApi.getDepositStatus(depositId!), + enabled: !!depositId, + gcTime: 0, // don't carry a settled status across navigations + refetchInterval: (query) => { + const status = (query.state.data as { data?: { status?: string } } | undefined)?.data?.status + return status && TERMINAL_STATUSES.includes(status) ? false : POLLING_INTERVAL + }, + }) + + const status: MantecaDepositPollStatus = useMemo(() => { + const s = data?.data?.status + if (s === 'COMPLETED') return 'completed' + if (s && TERMINAL_STATUSES.includes(s)) return 'failed' + if (s && PROCESSING_STATUSES.includes(s)) return 'processing' + return 'pending' + }, [data]) + + useEffect(() => { + if (status === 'completed' && !hasCompleted.current) { + hasCompleted.current = true + onComplete() + } + }, [status, onComplete]) + + return { status } +} diff --git a/src/components/Global/QRCodeWrapper/index.tsx b/src/components/Global/QRCodeWrapper/index.tsx index ed9d7de39..3cdcaa892 100644 --- a/src/components/Global/QRCodeWrapper/index.tsx +++ b/src/components/Global/QRCodeWrapper/index.tsx @@ -11,6 +11,8 @@ interface QRCodeWrapperProps { disabled?: boolean isBlurred?: boolean centerImage?: string + /** Merged onto the root — pass a `max-w-*` to override the default 160px width. */ + className?: string } const QRCodeWrapper = ({ @@ -19,6 +21,7 @@ const QRCodeWrapper = ({ disabled = false, isBlurred = false, centerImage, + className, }: QRCodeWrapperProps) => { const [qrRendered, setQrRendered] = useState(false) @@ -39,7 +42,7 @@ const QRCodeWrapper = ({ const showLoading = isLoading || !qrRendered || !url return ( -
+
{/* Container with black border and rounded corners */}
=> { + try { + const response = await serverFetch(`/manteca/deposit/${depositId}/status`) + const data = await response.json() + if (!response.ok) { + return { error: data.error || 'Failed to fetch deposit status.' } + } + return { data } + } catch (error) { + return { error: error instanceof Error ? error.message : 'An unexpected error occurred.' } + } + }, + cancelDeposit: async (depositId: string): Promise<{ data?: MantecaDepositResponseData; error?: string }> => { try { const response = await serverFetch(`/manteca/deposit/${depositId}/cancel`, { diff --git a/src/types/manteca.types.ts b/src/types/manteca.types.ts index a25c54834..47458a776 100644 --- a/src/types/manteca.types.ts +++ b/src/types/manteca.types.ts @@ -19,6 +19,8 @@ export interface MantecaDepositResponseData { withdrawCostInAsset: string price: string priceExpireAt: string + // STUB: dynamic PIX QR (EMVCo copia-e-cola) Manteca will add to BRL ramp-on details — exact field name TBC when they ship it (Slack 2026-06-30). + qr?: string } currentStage: number stages: {