import type { FeeEstimates } from './shared'; import { useState } from 'react'; import { Link } from 'react-router'; import { Bitcoin, Coins, Loader2, Zap } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { walletSectionPath } from './shared'; import { formatSats } from './format'; import { Amount } from './Amount'; import { CopyField } from './CopyField'; import { EmptyWallet, UnsupportedSection } from './EmptyWallet'; import { UnlockPrompt } from './LockBadge'; import { useSelectedWallet } from './useSelectedWallet'; import { useCoinSelection } from './useCoinSelection'; import { useLockCountdown } from './useLockCountdown'; import { useBalances, usePermissions, useFees, useUtxos, useWalletOperations } from './useWalletData'; import { PayInvoiceDialog } from './dialogs/PayInvoiceDialog'; // Sending — the only part of the app that genuinely needs an unlocked wallet. // // The form is fully usable while locked: you can compose the whole spend, see the fee, review the coins. // Only the submit is blocked, with an inline unlock prompt above it rather than a modal wall across the // screen. That ordering is deliberate — you should be able to read what you are about to sign before you // are asked for the passphrase. // // The selected coins come from `?coins=` (useCoinSelection), so the Coins section and this one agree // through the URL rather than a shared store. const FEE_PRESETS: { key: keyof FeeEstimates; label: string; hint: string }[] = [ { key: 'fastestFee', label: 'Fastest', hint: 'next block' }, { key: 'halfHourFee', label: 'Fast', hint: '~30 min' }, { key: 'hourFee', label: 'Normal', hint: '~1 hour' }, { key: 'economyFee', label: 'Economy', hint: 'hours' }, { key: 'minimumFee', label: 'Minimum', hint: 'whenever' }, ]; export const SendView = () => { const { wallet, walletId, isLoading } = useSelectedWallet(); const { capabilities } = usePermissions(walletId); const canOnchain = capabilities.includes('onchainSend'); const canLightning = capabilities.includes('lightningSend'); if (!walletId) return ; if (!canOnchain && !canLightning) return ; return (
{canOnchain && canLightning && ( On-chain Lightning )} {canOnchain && ( )} {canLightning && ( )}
); }; type FormProps = { walletId: number; walletName: string }; const OnchainSendForm = ({ walletId, walletName }: FormProps) => { const { balances } = useBalances(walletId); const { fees } = useFees(walletId); const { capabilities } = usePermissions(walletId); const { selected, clear } = useCoinSelection(); const { utxos } = useUtxos(walletId, capabilities.includes('coinControl')); const { send } = useWalletOperations(walletId); const { hasSeed, unlocked } = useLockCountdown(walletId); const [address, setAddress] = useState(''); const [amount, setAmount] = useState(''); const [sendAll, setSendAll] = useState(false); const [satPerVbyte, setSatPerVbyte] = useState(''); const [label, setLabel] = useState(''); const [broadcast, setBroadcast] = useState<{ txid: string; feeSats: number } | null>(null); // A seeded wallet must be unlocked to sign. A wallet with no seed of its own (a remote node) signs // upstream, so there is nothing to unlock and the form is always live. const needsUnlock = hasSeed === true && !unlocked; const selectedUtxos = utxos.filter((u) => selected.includes(`${u.txid}:${u.vout}`)); const selectedTotal = selectedUtxos.reduce((sum, u) => sum + u.amountSats, 0); const feeRate = Number(satPerVbyte); const amountSats = Number(amount); const canSubmit = !!address.trim() && (sendAll || (Number.isFinite(amountSats) && amountSats > 0)) && Number.isFinite(feeRate) && feeRate >= 1 && !needsUnlock && !send.isPending; const submit = async (ev: React.FormEvent) => { ev.preventDefault(); if (!canSubmit) return; const result = await send.mutateAsync({ address: address.trim(), amountSats: sendAll ? undefined : amountSats, sendAll: sendAll || undefined, satPerVbyte: feeRate, outpoints: selected.length > 0 ? selected : undefined, label: label.trim() || undefined, }); setBroadcast({ txid: result.txid, feeSats: result.feeSats }); setAddress(''); setAmount(''); setLabel(''); setSendAll(false); clear(); }; if (broadcast) { return (

Broadcast

Paid a fee of {formatSats(broadcast.feeSats)} sats. It will confirm when a miner includes it.

); } return (

Spendable

{/* Frozen coins are confirmed and are the wallet's, but coin selection will not touch them. This header said "Spendable" over a figure that included them, so a max-value send was rejected as having no spendable UTXOs while the number above it said otherwise. */}
{!!balances?.onchainFrozen && (

{formatSats(balances.onchainFrozen)} sats frozen and excluded

)}
setAddress(ev.target.value)} className="font-mono text-xs" />
setAmount(ev.target.value)} className="w-48" />
{fees && FEE_PRESETS.map(({ key, label: presetLabel, hint }) => ( ))}
setSatPerVbyte(ev.target.value)} className="w-32" />
{capabilities.includes('coinControl') && (
{selected.length === 0 ? ( Automatic coin selection.{' '} Pick coins ) : ( <> {selected.length} coin{selected.length === 1 ? '' : 's'} selected · )}
)}
setLabel(ev.target.value)} />
{needsUnlock && } ); }; const LightningSendPanel = ({ walletId, walletName }: FormProps) => { const { balances } = useBalances(walletId); const { hasSeed, unlocked } = useLockCountdown(walletId); const [payOpen, setPayOpen] = useState(false); const needsUnlock = hasSeed === true && !unlocked; return (

Lightning balance

{needsUnlock && } {payOpen && }
); };