From 7f49df1503080b909acebc38bbb3e8acee1af9b4 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 7 Sep 2026 20:20:51 +0700 Subject: [PATCH] Wallet UX polish: elegant recovery-phrase reveal, separate amounts, loading states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continued the wallet audit with a focus on intuitive, minimal, elegant UX. • Recovery phrase — replaced the raw browser alert() (which dumped the seed as unstyled, uncopyable plaintext) with a proper RecoveryPhraseDialog: hidden/blurred by default with a deliberate "tap to reveal" gate (no shoulder-surfing on open), a numbered 12-word chip grid for accurate transcription, a copy button (enabled only after reveal), and an honest "paper is safest, clipboards leak" note. Re-hides on every close. This was the wallet's most sensitive surface and its worst blemish. • Fixed shared-state bug: "Create Cashu Token" and "Pay Lightning Address" both wrote to the same `sendAmount`, so switching between them carried a stale amount across two unrelated intents. The address form now owns its own amount state. • Added loading feedback + double-tap protection to every money action that lacked it — Generate Invoice ("Generating…"), Create Token ("Creating…"), Receive Token ("Receiving…"), Pay Lightning Address ("Sending…"). Each hook now exposes an in-flight flag and guards re-entry. WalletPage and the browser "app-only" notice were already clean/elegant — left as-is. Bumps to 1.2.22 (Android versionCode 10222 auto-derived). Co-authored-by: shakespeare.diy --- package.json | 2 +- src/components/wallet/CashuWalletCard.tsx | 67 ++++++--- .../wallet/RecoveryPhraseDialog.tsx | 141 ++++++++++++++++++ src/hooks/wallet/useLightningOperations.ts | 13 +- src/hooks/wallet/useTokenOperations.ts | 24 ++- 5 files changed, 215 insertions(+), 32 deletions(-) create mode 100644 src/components/wallet/RecoveryPhraseDialog.tsx diff --git a/package.json b/package.json index fb353cd..a133b29 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.2.21", + "version": "1.2.22", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/components/wallet/CashuWalletCard.tsx b/src/components/wallet/CashuWalletCard.tsx index 98e8b1e..934cd25 100644 --- a/src/components/wallet/CashuWalletCard.tsx +++ b/src/components/wallet/CashuWalletCard.tsx @@ -39,6 +39,7 @@ import { import { useToast } from '@/hooks/useToast'; import { QRScannerModal } from '@/components/wallet/QRScannerModal'; import { MintPrivacyManager } from '@/components/wallet/MintPrivacyManager'; +import { RecoveryPhraseDialog } from '@/components/wallet/RecoveryPhraseDialog'; import { isNativePlatform } from '@/lib/platform'; import { Smartphone } from 'lucide-react'; @@ -168,6 +169,7 @@ function CashuWalletCardNative() { setShowInvoiceModal, currentInvoice, handleLightningReceive, + isGeneratingInvoice, lightningInvoice, setLightningInvoice, isProcessingPayment, @@ -178,9 +180,11 @@ function CashuWalletCardNative() { tokenInput, setTokenInput, handleTokenReceive, + isReceivingToken, sendAmount, setSendAmount, handleTokenSend, + isCreatingToken, sentToken, setSentToken, sentTokenQRCode, @@ -194,9 +198,15 @@ function CashuWalletCardNative() { // QR Scanner state const [showQRScanner, setShowQRScanner] = useState(false); - - // Lightning address state + + // Recovery-phrase reveal dialog + const [showRecoveryPhrase, setShowRecoveryPhrase] = useState(false); + + // Lightning address state. Its amount is kept SEPARATE from the token-send + // amount (`sendAmount`) so switching between "Create Token" and "Pay Address" + // never carries a stale value between two unrelated intents. const [lightningAddress, setLightningAddress] = useState(''); + const [lightningAddressAmount, setLightningAddressAmount] = useState(''); // Set first mint as active if none selected useEffect(() => { @@ -242,34 +252,39 @@ function CashuWalletCardNative() { }, [coco, setTokenInput, setLightningInvoice, toast]); // Handle paying a Lightning address + const [isPayingAddress, setIsPayingAddress] = useState(false); const handlePayLightningAddress = useCallback(async () => { - if (!lightningAddress || !sendAmount || !coco || !activeMintUrl) return; - + if (!lightningAddress || !lightningAddressAmount || !coco || !activeMintUrl) return; + try { - const amount = parseInt(sendAmount); + const amount = parseInt(lightningAddressAmount); if (isNaN(amount) || amount <= 0) { toast({ variant: 'destructive', title: 'Invalid amount', description: 'Please enter a valid amount' }); return; } + setIsPayingAddress(true); + // First resolve the Lightning address to get an invoice const invoice = await getLightningInvoice({ lightningAddress, amountSats: amount }); - + // Then pay the invoice using Cashu const meltQuote = await coco.quotes.createMeltQuote(activeMintUrl, invoice); await coco.quotes.payMeltQuote(activeMintUrl, meltQuote.quote); - + toast({ title: 'Payment sent', description: `Sent ${amount} sats to ${lightningAddress}` }); setLightningAddress(''); - setSendAmount(''); + setLightningAddressAmount(''); } catch (err) { toast({ variant: 'destructive', title: 'Payment failed', description: err instanceof Error ? err.message : 'Unknown error' }); + } finally { + setIsPayingAddress(false); } - }, [lightningAddress, sendAmount, coco, activeMintUrl, getLightningInvoice, toast, setSendAmount]); + }, [lightningAddress, lightningAddressAmount, coco, activeMintUrl, getLightningInvoice, toast]); // Copy to clipboard helper const copyToClipboard = async (text: string, label: string) => { @@ -406,10 +421,10 @@ function CashuWalletCardNative() { /> @@ -446,10 +461,10 @@ function CashuWalletCardNative() { /> @@ -471,10 +486,10 @@ function CashuWalletCardNative() { /> @@ -543,16 +558,16 @@ function CashuWalletCardNative() { type="number" inputMode="numeric" placeholder="Amount in sats" - value={sendAmount} - onChange={(e) => setSendAmount(e.target.value)} + value={lightningAddressAmount} + onChange={(e) => setLightningAddressAmount(e.target.value)} className="h-11" /> @@ -668,9 +683,7 @@ function CashuWalletCardNative() { Your wallet is secured with a 12-word recovery phrase. Save it safely — you'll need it to restore your balance if you clear browser data.

+ )} + + + {/* Actions */} +
+ + +
+ +

+ Writing it on paper is safest — copying to your clipboard can leak to other apps. +

+ + + ); +} diff --git a/src/hooks/wallet/useLightningOperations.ts b/src/hooks/wallet/useLightningOperations.ts index aa8666c..b8480e7 100644 --- a/src/hooks/wallet/useLightningOperations.ts +++ b/src/hooks/wallet/useLightningOperations.ts @@ -27,6 +27,10 @@ export function useLightningOperations({ expiry: number; } | null>(null); + // Receive-generation in-flight flag (so the button can show progress and + // can't be double-tapped into two invoices). + const [isGeneratingInvoice, setIsGeneratingInvoice] = useState(false); + // Send state const [lightningInvoice, setLightningInvoice] = useState(''); const [isProcessingPayment, setIsProcessingPayment] = useState(false); @@ -35,7 +39,7 @@ export function useLightningOperations({ // Handle receive (create mint quote/invoice) const handleLightningReceive = useCallback(async () => { - if (!coco || !quoteAmount || !activeMintUrl) return; + if (isGeneratingInvoice || !coco || !quoteAmount || !activeMintUrl) return; try { const amount = parseInt(quoteAmount); @@ -47,6 +51,8 @@ export function useLightningOperations({ }); return; } + + setIsGeneratingInvoice(true); const quote = await coco.quotes.createMintQuote(activeMintUrl, amount); const qrCodeUrl = await generateQRCode(quote.request); @@ -103,8 +109,10 @@ export function useLightningOperations({ ? `${activeMintUrl?.replace(/^https?:\/\//, '')} can't be reached from your browser (no CORS). Switch to a web-friendly mint in wallet settings — or use the native app, where any mint works.` : raw, }); + } finally { + setIsGeneratingInvoice(false); } - }, [coco, quoteAmount, activeMintUrl, generateQRCode, toast]); + }, [coco, quoteAmount, activeMintUrl, generateQRCode, isGeneratingInvoice, toast]); // Handle send (pay Lightning invoice) const handleLightningSend = useCallback(async () => { @@ -159,6 +167,7 @@ export function useLightningOperations({ currentInvoice, setCurrentInvoice, handleLightningReceive, + isGeneratingInvoice, // Send lightningInvoice, diff --git a/src/hooks/wallet/useTokenOperations.ts b/src/hooks/wallet/useTokenOperations.ts index 9d2a37e..83fab37 100644 --- a/src/hooks/wallet/useTokenOperations.ts +++ b/src/hooks/wallet/useTokenOperations.ts @@ -21,10 +21,12 @@ export function useTokenOperations({ // Receive state const [tokenInput, setTokenInput] = useState(''); - + const [isReceivingToken, setIsReceivingToken] = useState(false); + // Send state const [sendAmount, setSendAmount] = useState(''); - + const [isCreatingToken, setIsCreatingToken] = useState(false); + // Transaction output state const [sentToken, setSentToken] = useState(null); const [sentTokenQRCode, setSentTokenQRCode] = useState(''); @@ -83,8 +85,9 @@ export function useTokenOperations({ // Handle receive (token) const handleTokenReceive = useCallback(async () => { - if (!coco || !tokenInput) return; + if (isReceivingToken || !coco || !tokenInput) return; + setIsReceivingToken(true); try { const tokenToProcess = tokenInput.trim(); @@ -113,12 +116,14 @@ export function useTokenOperations({ title: 'Receive failed', description: err instanceof Error ? err.message : 'Unknown error' }); + } finally { + setIsReceivingToken(false); } - }, [coco, tokenInput, handleUnknownMintError, toast]); + }, [coco, tokenInput, isReceivingToken, handleUnknownMintError, toast]); // Handle send (token) const handleTokenSend = useCallback(async () => { - if (!coco || !sendAmount || !activeMintUrl) return; + if (isCreatingToken || !coco || !sendAmount || !activeMintUrl) return; try { const amount = parseInt(sendAmount); @@ -130,7 +135,8 @@ export function useTokenOperations({ }); return; } - + + setIsCreatingToken(true); const token = await coco.wallet.send(activeMintUrl, amount); const encodedToken = getEncodedToken(token); setSentToken(encodedToken); @@ -147,18 +153,22 @@ export function useTokenOperations({ title: 'Send failed', description: err instanceof Error ? err.message : 'Unknown error' }); + } finally { + setIsCreatingToken(false); } - }, [coco, sendAmount, activeMintUrl, getEncodedToken, generateSentTokenQRCode, toast]); + }, [coco, sendAmount, activeMintUrl, isCreatingToken, getEncodedToken, generateSentTokenQRCode, toast]); return { // Receive state tokenInput, setTokenInput, handleTokenReceive, + isReceivingToken, // Send state sendAmount, setSendAmount, handleTokenSend, + isCreatingToken, // Transaction output state sentToken, setSentToken,