-
Notifications
You must be signed in to change notification settings - Fork 14
feat(manteca): BRL PIX deposit QR screen (ramp-on details.qr) [draft, depends on api#1093] #2315
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
Closed
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
a9fe6b0
feat(manteca): BRL PIX dynamic-QR deposit screen (FE)
abalinda 911fb36
refactor(manteca): repoint BRL PIX QR screen to the ramp-on synthetic…
abalinda bff0028
feat(manteca): default to local currency for BRL/ARS + branded waitin…
abalinda 4f8b08e
feat(manteca): narrow local-currency default to Brazil + branded sett…
abalinda 0ddfb3b
chore(add-money): hide redundant "From Bank" card for Brazil
abalinda 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
118 changes: 118 additions & 0 deletions
118
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,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 ( | ||
| <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 |
90 changes: 90 additions & 0 deletions
90
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,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: () => <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' | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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( | ||
| <MantecaPixQrDeposit | ||
| depositDetails={baseDeposit} | ||
| currencyAmount="10" | ||
| onBack={jest.fn()} | ||
| onComplete={jest.fn()} | ||
| /> | ||
| ) | ||
| 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(<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() | ||
| }) | ||
| }) | ||
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
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.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Gate
showQRon the returned QR payload, not just the selected currency.Line 202 sends every BRL deposit to the QR step, but
details.qris optional andMantecaPixQrDepositnever refreshesdepositDetails. If the create-deposit response comes back without a QR payload, the user lands on a permanent “Preparing your PIX QR…” screen instead of falling back to deposit details.🛠️ Suggested fix
const data = depositData.data setDepositDetails(data) - if (selectedCountry?.currency === 'BRL') { + if (selectedCountry?.currency === 'BRL' && data?.details.qr) { setUrlState({ step: 'showQR' }) } else { setUrlState({ step: 'depositDetails' }) }📝 Committable suggestion
🤖 Prompt for AI Agents