Removed/renamed the remaining 39 unused locals/dead code, file-by-file: - unused destructured bindings/params (remove the binding, keep the call) - unused single-line pure consts and dead functions/interfaces removed - deleted the dead _FeedItem component and its now-unused imports - amber _readChunks self-call fixed; SpotMap popup call site updated Warnings: 182 -> 7 (only react-refresh/only-export-components remain). no-explicit-any=0, exhaustive-deps=0, no-unused-vars=0. Gate: 101 tests, 0 errors, boot smoke green. AGENTS.md baseline + guidance updated.
837 lines
34 KiB
TypeScript
837 lines
34 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import { useCashu } from '@/hooks/wallet/useCashu';
|
||
import { useMintManager } from '@/hooks/wallet/useMintManager';
|
||
import { useLightningOperations } from '@/hooks/wallet/useLightningOperations';
|
||
import { useTokenOperations } from '@/hooks/wallet/useTokenOperations';
|
||
import { useHistoryManager } from '@/hooks/wallet/useHistoryManager';
|
||
import { useSatsToUsd } from '@/hooks/useExchangeRate';
|
||
import { useLightningAddress } from '@/hooks/useLightningAddress';
|
||
import { generateQRCode } from '@/lib/wallet/qrCodeGenerator';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select';
|
||
import {
|
||
Wallet,
|
||
ArrowDownCircle,
|
||
ArrowUpCircle,
|
||
History,
|
||
Copy,
|
||
ExternalLink,
|
||
AlertCircle,
|
||
Shield,
|
||
ScanLine,
|
||
Zap,
|
||
Nut,
|
||
} from 'lucide-react';
|
||
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';
|
||
|
||
/**
|
||
* The built-in Cashu (ecash) mint wallet only works where there's no browser
|
||
* CORS jail — i.e. in the native app. In a plain web browser most mints block
|
||
* requests (no Access-Control-Allow-Origin), so minting/melting fails. Rather
|
||
* than ship a browser feature that unreliably errors, we gate the full wallet
|
||
* to native and, in the browser, explain how to transact (zaps via a connected
|
||
* Lightning wallet, or use the native app).
|
||
*
|
||
* Zaps/tips — the app's actual money flow — keep working in the browser via
|
||
* NWC / WebLN / Lightning address, none of which touch a Cashu mint.
|
||
*/
|
||
export function CashuWalletCard() {
|
||
if (!isNativePlatform()) {
|
||
return <CashuWalletBrowserNotice />;
|
||
}
|
||
return <CashuWalletCardNative />;
|
||
}
|
||
|
||
function CashuWalletBrowserNotice() {
|
||
return (
|
||
<Card className="relative overflow-hidden border-orange-500/30 bg-gradient-to-br from-orange-500/[0.07] via-background to-yellow-500/[0.05]">
|
||
{/* Ambient glow */}
|
||
<div className="pointer-events-none absolute -top-16 -right-16 h-40 w-40 rounded-full bg-orange-500/10 blur-3xl" />
|
||
<div className="pointer-events-none absolute -bottom-20 -left-10 h-40 w-40 rounded-full bg-yellow-500/[0.07] blur-3xl" />
|
||
|
||
<CardHeader className="relative p-5 pb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="relative flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-orange-500 to-amber-500 shadow-lg shadow-orange-500/25">
|
||
<Nut className="h-5 w-5 text-black" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<CardTitle className="text-base font-black tracking-tight flex items-center gap-2">
|
||
Built-in Ecash Wallet
|
||
<Badge className="border-0 bg-orange-500/15 text-[10px] font-bold uppercase tracking-wider text-orange-400">
|
||
App only
|
||
</Badge>
|
||
</CardTitle>
|
||
<CardDescription className="text-xs mt-0.5">
|
||
Self-custodial Cashu ecash, powered by Bitcoin Lightning
|
||
</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent className="relative space-y-4 p-5 pt-1">
|
||
{/* Native-only explainer — framed as a premium capability, not a disabled feature */}
|
||
<div className="rounded-2xl border border-orange-500/20 bg-gradient-to-br from-orange-500/[0.08] to-transparent p-4">
|
||
<div className="flex items-start gap-3">
|
||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-orange-500/15">
|
||
<Smartphone className="h-4 w-4 text-orange-400" />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<p className="text-sm font-bold leading-tight">Unlock the full wallet in the SkateSpots app</p>
|
||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||
The built-in ecash wallet lets you mint, send and redeem Cashu tokens right at the
|
||
spot. It lives in the native app, where it can talk to mints directly — browsers
|
||
sandbox those connections for your security.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* What works right here — the actual money flow */}
|
||
<div className="rounded-2xl border bg-muted/30 p-4">
|
||
<p className="text-sm font-bold flex items-center gap-2">
|
||
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-amber-500/15">
|
||
<Zap className="h-3.5 w-3.5 text-amber-500" />
|
||
</span>
|
||
Zap & tip — works in your browser now
|
||
</p>
|
||
<p className="mt-2 text-xs text-muted-foreground leading-relaxed">
|
||
Sending sats to spots, events, coaches and crews works right here. Just connect a
|
||
Lightning wallet below — Nostr Wallet Connect or a browser extension like Alby. No
|
||
Cashu mint needed.
|
||
</p>
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
{['Nostr Wallet Connect', 'Alby / WebLN', 'Lightning address'].map((label) => (
|
||
<span
|
||
key={label}
|
||
className="inline-flex items-center gap-1 rounded-full border border-amber-500/25 bg-amber-500/10 px-2.5 py-1 text-[11px] font-semibold text-amber-500/90"
|
||
>
|
||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||
{label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function CashuWalletCardNative() {
|
||
const {
|
||
coco,
|
||
repositories,
|
||
balances,
|
||
mints,
|
||
totalBalance,
|
||
isLoading: walletLoading,
|
||
error: walletError,
|
||
mnemonic,
|
||
needsBackup,
|
||
confirmBackup,
|
||
getEncodedToken,
|
||
refreshMints,
|
||
} = useCashu();
|
||
|
||
const usdValue = useSatsToUsd(totalBalance);
|
||
const { toast } = useToast();
|
||
const { getInvoice: getLightningInvoice, isLoading: isResolvingAddress } = useLightningAddress();
|
||
|
||
const {
|
||
activeMintUrl,
|
||
setActiveMintUrl,
|
||
handleAddMint,
|
||
handleRemoveMintDirect,
|
||
getCleanMintLabel,
|
||
} = useMintManager({ coco, mints, repositories, refreshMints });
|
||
|
||
const {
|
||
quoteAmount,
|
||
setQuoteAmount,
|
||
showInvoiceModal,
|
||
setShowInvoiceModal,
|
||
currentInvoice,
|
||
handleLightningReceive,
|
||
isGeneratingInvoice,
|
||
lightningInvoice,
|
||
setLightningInvoice,
|
||
isProcessingPayment,
|
||
handleLightningSend,
|
||
} = useLightningOperations({ coco, activeMintUrl, generateQRCode });
|
||
|
||
const {
|
||
tokenInput,
|
||
setTokenInput,
|
||
handleTokenReceive,
|
||
isReceivingToken,
|
||
sendAmount,
|
||
setSendAmount,
|
||
handleTokenSend,
|
||
isCreatingToken,
|
||
sentToken,
|
||
setSentToken,
|
||
sentTokenQRCode,
|
||
} = useTokenOperations({ coco, activeMintUrl, generateQRCode, getEncodedToken });
|
||
|
||
const { history } = useHistoryManager({
|
||
coco,
|
||
repositories,
|
||
mints,
|
||
});
|
||
|
||
// QR Scanner state
|
||
const [showQRScanner, setShowQRScanner] = useState(false);
|
||
|
||
// 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(() => {
|
||
if (mints.length > 0 && !activeMintUrl) {
|
||
setActiveMintUrl(mints[0].url);
|
||
}
|
||
}, [mints, activeMintUrl, setActiveMintUrl]);
|
||
|
||
// Handle QR scan result
|
||
const handleQRScanResult = useCallback(async (result: { type: string; value: string }) => {
|
||
setShowQRScanner(false);
|
||
|
||
switch (result.type) {
|
||
case 'cashu_token':
|
||
setTokenInput(result.value);
|
||
// Auto-receive
|
||
if (coco) {
|
||
try {
|
||
await coco.wallet.receive(result.value);
|
||
toast({ title: 'Token received', description: 'Cashu token redeemed successfully!' });
|
||
} catch (err) {
|
||
toast({
|
||
variant: 'destructive',
|
||
title: 'Receive failed',
|
||
description: err instanceof Error ? err.message : 'Unknown error'
|
||
});
|
||
}
|
||
}
|
||
break;
|
||
case 'lightning_invoice':
|
||
setLightningInvoice(result.value);
|
||
break;
|
||
case 'lightning_address':
|
||
setLightningAddress(result.value);
|
||
break;
|
||
case 'bitcoin_address':
|
||
// Recognized, but this is an ecash/Lightning wallet — no on-chain send.
|
||
toast({
|
||
variant: 'destructive',
|
||
title: 'On-chain not supported here',
|
||
description: 'That’s a Bitcoin address. This wallet only sends Cashu tokens and Lightning payments.',
|
||
});
|
||
break;
|
||
case 'npub':
|
||
toast({
|
||
variant: 'destructive',
|
||
title: 'That’s a Nostr profile',
|
||
description: 'Scan a Cashu token, Lightning invoice, or Lightning address to pay.',
|
||
});
|
||
break;
|
||
case 'clink_offer':
|
||
case 'clink_debit':
|
||
toast({
|
||
variant: 'destructive',
|
||
title: 'Payment code not supported yet',
|
||
description: 'This wallet can’t pay CLINK codes yet. Use a Lightning invoice or address instead.',
|
||
});
|
||
break;
|
||
default:
|
||
toast({
|
||
variant: 'destructive',
|
||
title: 'Unrecognized QR code',
|
||
description: 'Scan a Cashu token, Lightning invoice, or Lightning address.',
|
||
});
|
||
}
|
||
}, [coco, setTokenInput, setLightningInvoice, toast]);
|
||
|
||
// Handle paying a Lightning address
|
||
const [isPayingAddress, setIsPayingAddress] = useState(false);
|
||
const handlePayLightningAddress = useCallback(async () => {
|
||
if (!lightningAddress || !lightningAddressAmount || !coco || !activeMintUrl) return;
|
||
|
||
try {
|
||
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('');
|
||
setLightningAddressAmount('');
|
||
} catch (err) {
|
||
toast({
|
||
variant: 'destructive',
|
||
title: 'Payment failed',
|
||
description: err instanceof Error ? err.message : 'Unknown error'
|
||
});
|
||
} finally {
|
||
setIsPayingAddress(false);
|
||
}
|
||
}, [lightningAddress, lightningAddressAmount, coco, activeMintUrl, getLightningInvoice, toast]);
|
||
|
||
// Copy to clipboard helper
|
||
const copyToClipboard = async (text: string, label: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
toast({ title: 'Copied', description: `${label} copied to clipboard` });
|
||
} catch (err) {
|
||
console.error('Failed to copy:', err);
|
||
}
|
||
};
|
||
|
||
// Show wallet loading state
|
||
if (walletLoading) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Wallet className="h-5 w-5 text-orange-500" />
|
||
Cashu Wallet
|
||
</CardTitle>
|
||
<CardDescription>Initializing wallet...</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<Skeleton className="h-20 w-full" />
|
||
<Skeleton className="h-10 w-full" />
|
||
<Skeleton className="h-10 w-full" />
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// Show wallet error state
|
||
if (walletError) {
|
||
return (
|
||
<Card className="border-destructive/50">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||
<AlertCircle className="h-5 w-5" />
|
||
Wallet Error
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Alert variant="destructive">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>{walletError.message}</AlertDescription>
|
||
</Alert>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="space-y-4">
|
||
{/* Main Wallet Card */}
|
||
<Card className="border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-yellow-500/5">
|
||
<CardHeader className="p-4">
|
||
<CardTitle className="text-base font-bold flex items-center gap-2">
|
||
<Wallet className="h-5 w-5 text-orange-500" />
|
||
Cashu Wallet
|
||
</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
Built-in ecash wallet — no external setup required
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4 p-4 pt-0">
|
||
{/* Balance Display — compact on mobile since BalanceHero already shows it */}
|
||
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-orange-500/10 border border-orange-500/20">
|
||
<div>
|
||
<div className="text-[10px] text-orange-400/80 uppercase tracking-widest font-bold mb-0.5">Balance</div>
|
||
<div
|
||
className="text-2xl font-black tabular-nums leading-none"
|
||
aria-live="polite"
|
||
aria-label={`Wallet balance ${totalBalance.toLocaleString()} sats`}
|
||
>
|
||
{totalBalance.toLocaleString()}
|
||
<span className="text-sm font-semibold text-muted-foreground ml-1.5">sats</span>
|
||
</div>
|
||
{usdValue !== null && (
|
||
<div className="text-xs text-muted-foreground mt-0.5">
|
||
≈ ${usdValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Nut className="h-8 w-8 text-orange-500/30 flex-shrink-0" />
|
||
</div>
|
||
|
||
{/* Active Mint Selector */}
|
||
{mints.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Active Mint</Label>
|
||
<Select value={activeMintUrl || undefined} onValueChange={setActiveMintUrl}>
|
||
<SelectTrigger className="h-11">
|
||
<SelectValue placeholder="Select a mint" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{mints.map((mint) => (
|
||
<SelectItem key={mint.url} value={mint.url}>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span>{getCleanMintLabel(mint)}</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{balances[mint.url] || 0} sats
|
||
</span>
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Main Actions */}
|
||
<Tabs defaultValue="receive" className="w-full">
|
||
<TabsList className="grid w-full grid-cols-2 h-11 rounded-xl">
|
||
<TabsTrigger value="receive" className="gap-2 rounded-lg h-full font-bold text-sm data-[state=active]:bg-green-600 data-[state=active]:text-white">
|
||
<ArrowDownCircle className="h-4 w-4" />
|
||
Receive
|
||
</TabsTrigger>
|
||
<TabsTrigger value="send" className="gap-2 rounded-lg h-full font-bold text-sm data-[state=active]:bg-red-600 data-[state=active]:text-white">
|
||
<ArrowUpCircle className="h-4 w-4" />
|
||
Send
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* Receive Tab */}
|
||
<TabsContent value="receive" className="space-y-4 mt-4">
|
||
{/* Lightning Invoice */}
|
||
<div className="space-y-3">
|
||
<Label className="text-sm font-semibold">⚡ Receive via Lightning</Label>
|
||
<div className="flex flex-col gap-2">
|
||
<Input
|
||
type="number"
|
||
inputMode="numeric"
|
||
placeholder="Amount in sats"
|
||
value={quoteAmount}
|
||
onChange={(e) => setQuoteAmount(e.target.value)}
|
||
className="h-11 min-w-0"
|
||
/>
|
||
<Button
|
||
onClick={handleLightningReceive}
|
||
disabled={!activeMintUrl || !quoteAmount || isGeneratingInvoice}
|
||
className="h-11 bg-orange-500 hover:bg-orange-600 font-bold"
|
||
>
|
||
{isGeneratingInvoice ? 'Generating…' : 'Generate Invoice'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<div className="absolute inset-0 flex items-center">
|
||
<span className="w-full border-t" />
|
||
</div>
|
||
<div className="relative flex justify-center text-xs uppercase">
|
||
<span className="bg-background px-2 text-muted-foreground">Or</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cashu Token */}
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-sm font-semibold">🥜 Receive Cashu Token</Label>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-9 gap-1.5 px-3"
|
||
onClick={() => setShowQRScanner(true)}
|
||
>
|
||
<ScanLine className="h-4 w-4" />
|
||
Scan QR
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Input
|
||
placeholder="Paste Cashu token (cashu...)"
|
||
value={tokenInput}
|
||
onChange={(e) => setTokenInput(e.target.value)}
|
||
className="font-mono text-xs h-11"
|
||
/>
|
||
<Button
|
||
onClick={handleTokenReceive}
|
||
disabled={!tokenInput || isReceivingToken}
|
||
className="w-full h-11 bg-green-600 hover:bg-green-700 font-bold"
|
||
>
|
||
{isReceivingToken ? 'Receiving…' : 'Receive Token'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* Send Tab */}
|
||
<TabsContent value="send" className="space-y-4 mt-4">
|
||
{/* Send Token */}
|
||
<div className="space-y-3">
|
||
<Label className="text-sm font-semibold">🥜 Create Cashu Token</Label>
|
||
<div className="flex flex-col gap-2">
|
||
<Input
|
||
type="number"
|
||
inputMode="numeric"
|
||
placeholder="Amount in sats"
|
||
value={sendAmount}
|
||
onChange={(e) => setSendAmount(e.target.value)}
|
||
className="min-w-0 h-11"
|
||
/>
|
||
<Button
|
||
onClick={handleTokenSend}
|
||
disabled={!activeMintUrl || !sendAmount || isCreatingToken}
|
||
className="h-11 bg-orange-500 hover:bg-orange-600 font-bold"
|
||
>
|
||
{isCreatingToken ? 'Creating…' : 'Create Token'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<div className="absolute inset-0 flex items-center">
|
||
<span className="w-full border-t" />
|
||
</div>
|
||
<div className="relative flex justify-center text-xs uppercase">
|
||
<span className="bg-background px-2 text-muted-foreground">Or</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pay Lightning Invoice */}
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-sm font-semibold">⚡ Pay Lightning Invoice</Label>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-9 gap-1.5 px-3"
|
||
onClick={() => setShowQRScanner(true)}
|
||
>
|
||
<ScanLine className="h-4 w-4" />
|
||
Scan QR
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Input
|
||
placeholder="Paste Lightning invoice (lnbc...)"
|
||
value={lightningInvoice}
|
||
onChange={(e) => setLightningInvoice(e.target.value)}
|
||
className="font-mono text-xs h-11"
|
||
/>
|
||
<Button
|
||
onClick={handleLightningSend}
|
||
disabled={!lightningInvoice || isProcessingPayment}
|
||
className="w-full h-11 bg-blue-600 hover:bg-blue-700 font-bold"
|
||
>
|
||
<Zap className="h-4 w-4 mr-2" />
|
||
{isProcessingPayment ? 'Processing...' : 'Pay Invoice'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<div className="absolute inset-0 flex items-center">
|
||
<span className="w-full border-t" />
|
||
</div>
|
||
<div className="relative flex justify-center text-xs uppercase">
|
||
<span className="bg-background px-2 text-muted-foreground">Or</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pay Lightning Address */}
|
||
<div className="space-y-3">
|
||
<Label className="text-sm font-semibold">📧 Pay Lightning Address</Label>
|
||
<div className="space-y-2">
|
||
<Input
|
||
placeholder="user@domain.com"
|
||
value={lightningAddress}
|
||
onChange={(e) => setLightningAddress(e.target.value)}
|
||
className="h-11"
|
||
/>
|
||
<Input
|
||
type="number"
|
||
inputMode="numeric"
|
||
placeholder="Amount in sats"
|
||
value={lightningAddressAmount}
|
||
onChange={(e) => setLightningAddressAmount(e.target.value)}
|
||
className="h-11"
|
||
/>
|
||
<Button
|
||
onClick={handlePayLightningAddress}
|
||
disabled={!lightningAddress || !lightningAddressAmount || isResolvingAddress || isPayingAddress}
|
||
className="w-full h-11 bg-purple-600 hover:bg-purple-700 font-bold"
|
||
>
|
||
{isResolvingAddress || isPayingAddress ? 'Sending…' : 'Send to Address'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* History */}
|
||
{history.length > 0 && (
|
||
<div className="space-y-3">
|
||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||
<History className="h-3.5 w-3.5" />
|
||
Recent Activity
|
||
</Label>
|
||
<div className="space-y-2 max-h-52 overflow-y-auto scroll-container">
|
||
{history.slice(0, 5).map((entry) => (
|
||
<div
|
||
key={entry.id}
|
||
className="flex items-center justify-between p-3 rounded-xl bg-muted/50 border min-h-[52px]"
|
||
>
|
||
<div className="flex items-center gap-2.5 min-w-0">
|
||
{entry.type === 'mint' || entry.type === 'receive' ? (
|
||
<ArrowDownCircle className="h-5 w-5 text-green-500 flex-shrink-0" />
|
||
) : (
|
||
<ArrowUpCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
|
||
)}
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold leading-tight">
|
||
{entry.type === 'mint' && 'Received ⚡ Lightning'}
|
||
{entry.type === 'melt' && 'Sent ⚡ Lightning'}
|
||
{entry.type === 'send' && 'Sent 🥜 Token'}
|
||
{entry.type === 'receive' && 'Received 🥜 Token'}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
{new Date(entry.timestamp).toLocaleDateString()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-right flex-shrink-0 ml-2">
|
||
<div className={`text-sm font-mono font-bold ${entry.type === 'mint' || entry.type === 'receive' ? 'text-green-400' : 'text-red-400'}`}>
|
||
{entry.type === 'mint' || entry.type === 'receive' ? '+' : '-'}
|
||
{entry.amount.toLocaleString()}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">sats</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* What is Cashu Info Card */}
|
||
<Card className="border-gray-700">
|
||
<CardHeader className="px-4 pt-4 pb-3">
|
||
<CardTitle className="flex items-center gap-2 text-base">
|
||
<Zap className="h-5 w-5 text-amber-500" />
|
||
What is Cashu?
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 space-y-3">
|
||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||
Cashu is digital cash backed by Bitcoin Lightning. Think of it as Bitcoin tokens you hold in your browser.
|
||
</p>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||
{[
|
||
'Works offline at skate spots',
|
||
'Instant peer-to-peer payments',
|
||
"Privacy-focused (mints can't track you)",
|
||
'Always redeemable for Lightning sats',
|
||
].map((item) => (
|
||
<div key={item} className="flex items-start gap-2 p-2.5 bg-muted/40 rounded-lg">
|
||
<span className="text-green-500 flex-shrink-0 mt-0.5">✅</span>
|
||
<span className="text-muted-foreground text-xs leading-snug">{item}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<a
|
||
href="https://mintradar.pedani.eu/"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline font-medium"
|
||
>
|
||
<ExternalLink className="h-4 w-4" />
|
||
Find mints at mintradar.pedani.eu
|
||
</a>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Mint Privacy Management */}
|
||
<MintPrivacyManager
|
||
mints={mints}
|
||
balances={balances}
|
||
activeMintUrl={activeMintUrl}
|
||
onSetActiveMint={setActiveMintUrl}
|
||
onAddMint={handleAddMint}
|
||
onRemoveMint={handleRemoveMintDirect}
|
||
totalBalance={totalBalance}
|
||
/>
|
||
</div>
|
||
|
||
{/* Backup Warning */}
|
||
{mnemonic && (
|
||
<Card className="border-amber-500/50 bg-amber-500/5">
|
||
<CardHeader className="px-4 pt-4 pb-3">
|
||
<CardTitle className="flex items-center gap-2 text-amber-600 text-base">
|
||
<Shield className="h-5 w-5" />
|
||
Backup Your Wallet
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 space-y-3">
|
||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||
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={() => setShowRecoveryPhrase(true)}
|
||
variant="outline"
|
||
className="w-full h-11 border-amber-500/50 text-amber-600 hover:bg-amber-500/10 font-bold"
|
||
>
|
||
View Recovery Phrase
|
||
</Button>
|
||
<p className="text-xs text-muted-foreground italic">
|
||
This is like your Nostr nsec — keep it secret and backed up!
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Invoice Modal */}
|
||
<Dialog open={showInvoiceModal} onOpenChange={setShowInvoiceModal}>
|
||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-4 sm:p-6">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-base sm:text-lg">Receive {currentInvoice?.amount} sats</DialogTitle>
|
||
<DialogDescription className="text-xs sm:text-sm">
|
||
Pay this invoice from any Lightning wallet
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{currentInvoice && (
|
||
<div className="space-y-3 sm:space-y-4">
|
||
<div className="flex justify-center p-2 sm:p-4 bg-white rounded-lg">
|
||
<img src={currentInvoice.qrCodeUrl} alt="Invoice QR Code" className="max-w-full max-h-[40vh] h-auto object-contain" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-muted-foreground">Lightning Invoice</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
value={currentInvoice.invoice}
|
||
readOnly
|
||
className="font-mono text-[10px] sm:text-xs"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => copyToClipboard(currentInvoice.invoice, 'Invoice')}
|
||
>
|
||
<Copy className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs sm:text-sm text-amber-600">
|
||
⏳ Waiting for payment...
|
||
</p>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Send Token Modal */}
|
||
<Dialog open={!!sentToken} onOpenChange={(open) => !open && setSentToken(null)}>
|
||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-4 sm:p-6">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-base sm:text-lg">Token Ready to Send</DialogTitle>
|
||
<DialogDescription className="text-xs sm:text-sm">
|
||
Share this token with the recipient
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{sentToken && (
|
||
<div className="space-y-3 sm:space-y-4">
|
||
{sentTokenQRCode && (
|
||
<div className="flex justify-center p-2 sm:p-4 bg-white rounded-lg">
|
||
<img src={sentTokenQRCode} alt="Token QR Code" className="max-w-full max-h-[40vh] h-auto object-contain" />
|
||
</div>
|
||
)}
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-muted-foreground">Cashu Token</Label>
|
||
<div className="space-y-2">
|
||
<Input
|
||
value={sentToken}
|
||
readOnly
|
||
className="font-mono text-[10px] sm:text-xs"
|
||
/>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => copyToClipboard(sentToken, 'Token')}
|
||
className="flex-1"
|
||
>
|
||
<Copy className="h-4 w-4 mr-2" />
|
||
Copy Token
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Alert className="border-amber-500/50 bg-amber-500/5">
|
||
<AlertCircle className="h-4 w-4 text-amber-600" />
|
||
<AlertDescription className="text-xs sm:text-sm">
|
||
Anyone with this token can claim it - treat it like cash!
|
||
</AlertDescription>
|
||
</Alert>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Recovery Phrase Dialog — replaces the old raw alert() with an elegant,
|
||
hidden-by-default reveal.
|
||
When needsBackup is set (a freshly auto-generated seed), it becomes a
|
||
MANDATORY, non-dismissable backup step: the user must reveal the words
|
||
and confirm before they can start using the wallet. */}
|
||
{mnemonic && (
|
||
<RecoveryPhraseDialog
|
||
open={showRecoveryPhrase || needsBackup}
|
||
onOpenChange={setShowRecoveryPhrase}
|
||
mnemonic={mnemonic}
|
||
requireConfirm={needsBackup}
|
||
onConfirm={confirmBackup}
|
||
/>
|
||
)}
|
||
|
||
{/* QR Scanner Modal */}
|
||
<QRScannerModal
|
||
isOpen={showQRScanner}
|
||
onClose={() => setShowQRScanner(false)}
|
||
onResult={handleQRScanResult}
|
||
/>
|
||
</>
|
||
);
|
||
}
|