-
Notifications
You must be signed in to change notification settings - Fork 14
feat(manteca): BRL PIX deposit QR screen (details.depositAddresses.PIX) #2335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
6d31267
feat(manteca): BRL PIX dynamic-QR deposit screen (FE)
abalinda d0ff7d2
refactor(manteca): repoint BRL PIX QR screen to the ramp-on synthetic…
abalinda dfdd893
feat(manteca): default to local currency for BRL/ARS + branded waitin…
abalinda 3ab0668
feat(manteca): narrow local-currency default to Brazil + branded sett…
abalinda 9d506bb
feat(manteca): wire PIX QR screen to the prod-confirmed contract; ret…
jjramirezn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
119 changes: 119 additions & 0 deletions
119
src/components/AddMoney/components/MantecaPixQrDeposit.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
98 changes: 98 additions & 0 deletions
98
src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
Repository: peanutprotocol/peanut-ui
Length of output: 13305
🏁 Script executed:
Repository: peanutprotocol/peanut-ui
Length of output: 7738
🏁 Script executed:
Repository: peanutprotocol/peanut-ui
Length of output: 24260
Add a fallback when the PIX code is missing.
depositAddresses.PIX?.codeis optional here, and when it’s absent this screen stays onCyclingLoadingforever. The expiry state never renders because it’s gated behindqr, anduseMantecaDepositPollingonly tracks settlement status. Add a timeout/error + retry/back state, or make the API guarantee the PIX code at creation time.