Wallet UX polish: elegant recovery-phrase reveal, separate amounts, loading states
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 <assistant@shakespeare.diy>
This commit is contained in:
parent
39ac517610
commit
7f49df1503
5 changed files with 215 additions and 32 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
/>
|
||||
<Button
|
||||
onClick={handleLightningReceive}
|
||||
disabled={!activeMintUrl || !quoteAmount}
|
||||
disabled={!activeMintUrl || !quoteAmount || isGeneratingInvoice}
|
||||
className="h-11 bg-orange-500 hover:bg-orange-600 font-bold"
|
||||
>
|
||||
Generate Invoice
|
||||
{isGeneratingInvoice ? 'Generating…' : 'Generate Invoice'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -446,10 +461,10 @@ function CashuWalletCardNative() {
|
|||
/>
|
||||
<Button
|
||||
onClick={handleTokenReceive}
|
||||
disabled={!tokenInput}
|
||||
disabled={!tokenInput || isReceivingToken}
|
||||
className="w-full h-11 bg-green-600 hover:bg-green-700 font-bold"
|
||||
>
|
||||
Receive Token
|
||||
{isReceivingToken ? 'Receiving…' : 'Receive Token'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -471,10 +486,10 @@ function CashuWalletCardNative() {
|
|||
/>
|
||||
<Button
|
||||
onClick={handleTokenSend}
|
||||
disabled={!activeMintUrl || !sendAmount}
|
||||
disabled={!activeMintUrl || !sendAmount || isCreatingToken}
|
||||
className="h-11 bg-orange-500 hover:bg-orange-600 font-bold"
|
||||
>
|
||||
Create Token
|
||||
{isCreatingToken ? 'Creating…' : 'Create Token'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
<Button
|
||||
onClick={handlePayLightningAddress}
|
||||
disabled={!lightningAddress || !sendAmount || isResolvingAddress || isProcessingPayment}
|
||||
disabled={!lightningAddress || !lightningAddressAmount || isResolvingAddress || isPayingAddress}
|
||||
className="w-full h-11 bg-purple-600 hover:bg-purple-700 font-bold"
|
||||
>
|
||||
{isResolvingAddress ? 'Resolving...' : 'Send to Address'}
|
||||
{isResolvingAddress || isPayingAddress ? 'Sending…' : 'Send to Address'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
alert(`Your backup phrase:\n\n${mnemonic}\n\nWrite these words down and store them safely!`);
|
||||
}}
|
||||
onClick={() => setShowRecoveryPhrase(true)}
|
||||
variant="outline"
|
||||
className="w-full h-11 border-amber-500/50 text-amber-600 hover:bg-amber-500/10 font-bold"
|
||||
>
|
||||
|
|
@ -770,6 +783,16 @@ function CashuWalletCardNative() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Recovery Phrase Dialog — replaces the old raw alert() with an elegant,
|
||||
hidden-by-default reveal */}
|
||||
{mnemonic && (
|
||||
<RecoveryPhraseDialog
|
||||
open={showRecoveryPhrase}
|
||||
onOpenChange={setShowRecoveryPhrase}
|
||||
mnemonic={mnemonic}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* QR Scanner Modal */}
|
||||
<QRScannerModal
|
||||
isOpen={showQRScanner}
|
||||
|
|
|
|||
141
src/components/wallet/RecoveryPhraseDialog.tsx
Normal file
141
src/components/wallet/RecoveryPhraseDialog.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Shield, Eye, EyeOff, Copy, Check } from 'lucide-react';
|
||||
|
||||
interface RecoveryPhraseDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Space-separated BIP-39 mnemonic. */
|
||||
mnemonic: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An elegant, security-conscious recovery-phrase reveal — the wallet's single
|
||||
* most sensitive surface, so it deserves better than a raw `alert()`.
|
||||
*
|
||||
* Principles:
|
||||
* • Hidden by default (blurred word grid) so the seed can't be shoulder-surfed
|
||||
* just by opening the dialog; the user must deliberately tap "Reveal".
|
||||
* • Numbered word chips make it easy to transcribe accurately onto paper.
|
||||
* • Copy is available but explicitly discouraged in the warning (clipboards
|
||||
* leak); paper is the safe path.
|
||||
* • The seed is cleared from the reveal state whenever the dialog closes.
|
||||
*/
|
||||
export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryPhraseDialogProps) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const words = mnemonic.trim().split(/\s+/);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
// Re-hide on every close so re-opening always starts concealed.
|
||||
if (!next) {
|
||||
setRevealed(false);
|
||||
setCopied(false);
|
||||
}
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(mnemonic);
|
||||
setCopied(true);
|
||||
toast({ title: 'Copied', description: 'Recovery phrase copied — paste it somewhere safe, then clear your clipboard.' });
|
||||
setTimeout(() => setCopied(false), 2500);
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Copy failed', description: 'Please write the words down instead.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-5 sm:p-6">
|
||||
<DialogHeader>
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500/15">
|
||||
<Shield className="h-6 w-6 text-amber-500" />
|
||||
</div>
|
||||
<DialogTitle className="text-center text-lg font-black">Recovery Phrase</DialogTitle>
|
||||
<DialogDescription className="text-center text-xs leading-relaxed">
|
||||
These 12 words are the only way to restore your ecash balance. Write them down in order
|
||||
and store them offline — never share them with anyone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Word grid */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-2 gap-2 rounded-2xl border border-amber-500/20 bg-amber-500/[0.04] p-3 transition-all sm:grid-cols-3',
|
||||
!revealed && 'select-none blur-sm',
|
||||
)}
|
||||
aria-hidden={!revealed}
|
||||
>
|
||||
{words.map((word, i) => (
|
||||
<div
|
||||
key={`${i}-${word}`}
|
||||
className="flex items-center gap-2 rounded-lg bg-background/60 px-2.5 py-2"
|
||||
>
|
||||
<span className="w-4 text-right text-[10px] font-bold tabular-nums text-muted-foreground">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold">{word}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reveal overlay */}
|
||||
{!revealed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed(true)}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 rounded-2xl bg-background/40 backdrop-blur-[2px] transition-colors hover:bg-background/30 active:scale-[0.99]"
|
||||
>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/20">
|
||||
<Eye className="h-5 w-5 text-amber-500" />
|
||||
</span>
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-amber-400">
|
||||
Tap to reveal
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">Make sure nobody's watching</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 gap-2"
|
||||
onClick={() => setRevealed((v) => !v)}
|
||||
>
|
||||
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{revealed ? 'Hide' : 'Reveal'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 gap-2"
|
||||
onClick={handleCopy}
|
||||
disabled={!revealed}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
|
||||
Writing it on paper is safest — copying to your clipboard can leak to other apps.
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [sentTokenQRCode, setSentTokenQRCode] = useState<string>('');
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue