diff --git a/src/features/receive/ReceivePaymentDialog.test.tsx b/src/features/receive/ReceivePaymentDialog.test.tsx index 2f40ce89..0b128c18 100644 --- a/src/features/receive/ReceivePaymentDialog.test.tsx +++ b/src/features/receive/ReceivePaymentDialog.test.tsx @@ -39,3 +39,24 @@ describe('a created invoice', () => { expect(screen.queryByText('Scan to pay this Lightning invoice')).toBeNull(); }); }); + +describe('receive tabs', () => { + it('takes the tabs away once an invoice exists, and gives a way back', async () => { + await openDialog(); + + expect(await screen.findByTestId('btc-tab')).toBeInTheDocument(); + fireEvent.click(await screen.findByTestId('show-amount-panel-button')); + fireEvent.change(await screen.findByTestId('invoice-amount-input'), { target: { value: '5000' } }); + fireEvent.click(screen.getByTestId('generate-invoice-button')); + + await screen.findByTestId('lightning-invoice-text'); + expect(screen.queryByTestId('btc-tab')).toBeNull(); + expect(screen.queryByTestId('usd-tab')).toBeNull(); + + // `useSheetBack` registers through an effect, so the arrow lands a render late. + fireEvent.click(await screen.findByRole('button', { name: 'Back' })); + + expect(await screen.findByTestId('show-amount-panel-button')).toBeInTheDocument(); + expect(screen.getByTestId('btc-tab')).toBeInTheDocument(); + }); +}); diff --git a/src/features/receive/ReceivePaymentDialog.tsx b/src/features/receive/ReceivePaymentDialog.tsx index bb2daf51..e9960751 100644 --- a/src/features/receive/ReceivePaymentDialog.tsx +++ b/src/features/receive/ReceivePaymentDialog.tsx @@ -100,6 +100,11 @@ const QRCodeDisplay: React.FC = ({ paymentData, feeSats, amo const ReceivePaymentDialog: React.FC = ({ isOpen, onClose }) => { const receive = useReceivePayment(); const [showChangeConfirm, setShowChangeConfirm] = useState(false); + // The tabs belong to choosing, not to what you have made with the choice: + // past the USD amount form there is a quote to lose, and on a created + // invoice there is an invoice. The header arrow carries the way back from + // both instead. + const [usdResting, setUsdResting] = useState(true); // First-paint deferral. On a fresh post-install launch the main // thread is still contending with WASM compile + SDK connect @@ -284,6 +289,9 @@ const ReceivePaymentDialog: React.FC = ({ isOpen, onC // Cleared, so a QR replacing its placeholder does not turn in again. later(320, () => setTurning(null)); }; + // Tabs while the user is choosing what to receive, gone once there is + // something made: a USD request past its amount form, or a created invoice. + const tabsVisible = receive.activeTab === 'usd' ? usdResting : receive.currentStep === 'input'; // Under 400px the code shrinks so the switch and label have room beside its corners. const qrSize = window.innerWidth < 400 ? 184 : 200; const qrCardClassName = `${turning === 'out' ? 'animate-qr-turn-out' : turning === 'close' ? 'qr-turn-close' : turning === 'in' ? 'animate-qr-turn-in' : pendingMode ? 'qr-turn-wait' : ''} motion-reduce:animate-none`; @@ -325,12 +333,16 @@ const ReceivePaymentDialog: React.FC = ({ isOpen, onC } /> {isContentReady ? ( - + {tabsVisible && ( + { if (!isBtcTab) { handleTabChange('lightning'); setShownMode('lightning'); } }} data-testid="btc-tab"> ₿ BTC @@ -339,14 +351,22 @@ const ReceivePaymentDialog: React.FC = ({ isOpen, onC $ USD - + + )} {/* The USD tab sits outside StepContainer: its steps size to their own content (matching the cross-chain send flow), so the 280px floor would pad the short ones out with dead space. */} - {receive.activeTab === 'usd' ? ( - - ) : ( + {/* Stays mounted across tab switches and renders nothing while it + is off screen: a quote, its network and the typed amount are + too much to lose to a stray tap on BTC. Unkeyed for the same + reason, since `resetCount` is bumped by closing the amount + panel, which has nothing to do with this flow. */} + + {receive.activeTab !== 'usd' && ( <> {receive.currentStep === 'input' && ( diff --git a/src/features/receive/hooks/useReceivePayment.ts b/src/features/receive/hooks/useReceivePayment.ts index fe00c66e..e5ff1055 100644 --- a/src/features/receive/hooks/useReceivePayment.ts +++ b/src/features/receive/hooks/useReceivePayment.ts @@ -59,6 +59,8 @@ export interface UseReceivePaymentReturn { // available as an escape hatch for the SDK-error-recovery path // where the amount must survive the panel closing and reopening. closeAmountPanel: () => void; + /** Leaves a created invoice for the address view; the invoice stays payable. */ + dismissInvoice: () => void; handleTabChange: (tab: PaymentMethod, loadLightningAddress: () => void) => void; generateBitcoinAddress: () => Promise; generateBolt11Invoice: () => Promise; @@ -232,6 +234,20 @@ export function useReceivePayment(): UseReceivePaymentReturn { } }, [wallet, amountSats, amountDisplay, description, showAmountPanel]); + // Back from a created invoice to the address view. The invoice itself stays + // valid and payable: nothing here revokes it, it just leaves the screen. + // Clears the amount so the next trip through the panel starts empty. + const dismissInvoice = useCallback(() => { + setCurrentStep('input'); + setPaymentData(''); + setFeeSats(0); + setError(null); + setAmountSats(null); + setDescription(''); + setInvoiceAmount(null); + setResetCount((c) => c + 1); + }, []); + const handleTabChange = useCallback((tab: PaymentMethod, loadLightningAddress: () => void) => { setActiveTab(tab); setCurrentStep('input'); @@ -271,6 +287,7 @@ export function useReceivePayment(): UseReceivePaymentReturn { setAmountDisplay, setShowAmountPanel, closeAmountPanel, + dismissInvoice, handleTabChange, generateBitcoinAddress, generateBolt11Invoice, diff --git a/src/features/receive/workflows/CrossChainReceiveWorkflow.tsx b/src/features/receive/workflows/CrossChainReceiveWorkflow.tsx index bbcb85fc..53a9c02a 100644 --- a/src/features/receive/workflows/CrossChainReceiveWorkflow.tsx +++ b/src/features/receive/workflows/CrossChainReceiveWorkflow.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import type { CrossChainReceiveInfo, CrossChainRoutePair, @@ -40,7 +40,16 @@ type WorkflowStep = 'amount' | 'loading' | 'asset' | 'chain' | 'provider' | 'gen const QUICK_USD_AMOUNTS = [10, 50, 200]; -const CrossChainReceiveWorkflow: React.FC = () => { +interface CrossChainReceiveWorkflowProps { + /** Whether the USD tab is the one on screen. Kept mounted while it is not, + * so a trip to BTC and back does not discard a typed amount or a quote. */ + active: boolean; + /** True while the flow is still on its amount form, which is where the tabs + * still belong. */ + onRestingChange?: (resting: boolean) => void; +} + +const CrossChainReceiveWorkflow: React.FC = ({ active, onRestingChange }) => { const wallet = useWallet(); const stableBalance = useStableBalance(); const { showToast } = useToast(); @@ -62,7 +71,9 @@ const CrossChainReceiveWorkflow: React.FC = () => { // The provider and result steps cap and scroll themselves, same as the // asset / chain steps do from inside their own components. - useSheetOwnsScroll(step === 'provider' || step === 'result'); + useSheetOwnsScroll(active && (step === 'provider' || step === 'result')); + + useEffect(() => { onRestingChange?.(step === 'amount'); }, [step, onRestingChange]); const { uniqueAssets, chainGroupKey, getChainsForAsset } = useCrossChainRouteGroups(routes); const chainsForAsset = selectedAsset ? getChainsForAsset(selectedAsset) : []; @@ -259,6 +270,8 @@ const CrossChainReceiveWorkflow: React.FC = () => { // short step is not padded out to the tallest one. `pt-6` is the step padding // the other receive tabs use. The selection lists cap themselves against the // viewport (see CrossChainAssetStep) rather than against this container. + if (!active) return null; + return (
{/* Step 1: Amount */}