import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { AlertTriangle, ArrowUpRight, Check, ChevronLeft, HandHeart, Heart, Loader2, LogIn, Sparkle, Sparkles, Star, } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer'; import { Button } from '@/components/ui/button'; import { CampaignWalletDonatePanel } from '@/components/CampaignWalletDonatePanel'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; import { Textarea } from '@/components/ui/textarea'; import AuthDialog from '@/components/auth/AuthDialog'; import { useAppContext } from '@/hooks/useAppContext'; import { useBitcoinSigner } from '@/hooks/useBitcoinSigner'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useDonateCampaign, type DonateCampaignResult, type DonationFeeSpeed } from '@/hooks/useDonateCampaign'; import { useToast } from '@/hooks/useToast'; import { BITCOIN_DUST_LIMIT, estimateFee, fetchUTXOs, formatSats, getFeeRates, nostrPubkeyToBitcoinAddress, satsToUSD, usdToSats, type FeeRates, } from '@/lib/bitcoin'; import { type ParsedCampaign, } from '@/lib/campaign'; import { cn } from '@/lib/utils'; /** * Donation presets in USD. The signed event and Bitcoin transaction still use * sats; USD is only the user-facing input currency. */ const PRESET_AMOUNTS: readonly { amountUsd: number; icon: React.ComponentType<{ className?: string }>; label: string }[] = [ { amountUsd: 10, icon: Sparkle, label: '$10' }, { amountUsd: 25, icon: Sparkles, label: '$25' }, { amountUsd: 100, icon: Star, label: '$100' }, { amountUsd: 500, icon: Heart, label: '$500' }, { amountUsd: 1_000, icon: HandHeart, label: '$1K' }, ]; function parseUsdInput(input: string): number { const cleaned = input.replace(/[, $]/g, '').trim(); if (!cleaned) return 0; const n = Number(cleaned); return Number.isFinite(n) && n > 0 ? n : 0; } function feeRateForSpeed(rates: FeeRates, speed: DonationFeeSpeed): number { return { fastest: rates.fastestFee, halfHour: rates.halfHourFee, hour: rates.hourFee, economy: rates.economyFee, }[speed]; } function estimateDonationFee({ feeRate, utxoCount, }: { feeRate: number; utxoCount: number; }): number { // Single recipient + change output. return estimateFee(utxoCount, 2, feeRate); } interface DonateDialogProps { campaign: ParsedCampaign; open: boolean; onOpenChange: (open: boolean) => void; /** Spot price of BTC in USD, used for inline USD previews. Optional. */ btcPrice?: number; } type Step = 'form' | 'confirm' | 'success'; /** * Donate dialog for **on-chain** (`bc1q…` / `bc1p…`) campaigns. The * campaign's `w` wallet endpoint is the single output destination — * there are no recipient splits, no per-recipient previews, and no * dust math beyond the one-output PSBT. * * Silent-payment campaigns (`sp1…`) never open this dialog; their * detail-page donate column points directly at the SP code via the * `CampaignWalletDonatePanel` so donors can scan/copy and pay from a * BIP-352-aware external wallet. */ export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateDialogProps) { const { user } = useCurrentUser(); const { canSignPsbt } = useBitcoinSigner(); const { donateToCampaign } = useDonateCampaign(); const { toast } = useToast(); const [step, setStep] = useState('form'); const [amountUsd, setAmountUsd] = useState(PRESET_AMOUNTS[1].amountUsd); const [customUsd, setCustomUsd] = useState(''); const [comment, setComment] = useState(''); const [feeSpeed, setFeeSpeed] = useState('fastest'); const [result, setResult] = useState(null); // Reset when the dialog reopens for a fresh donation. useEffect(() => { if (open) { setStep('form'); setResult(null); } }, [open]); const effectiveUsd = customUsd.trim() ? parseUsdInput(customUsd) : amountUsd; const effectiveAmount = usdToSats(effectiveUsd, btcPrice); const belowDust = Number.isFinite(effectiveAmount) && effectiveAmount > 0 && effectiveAmount < BITCOIN_DUST_LIMIT; const donateMutation = useMutation({ mutationFn: async () => donateToCampaign({ campaign, amountSats: effectiveAmount, comment, feeSpeed, }), onSuccess: (r) => { setResult(r); setStep('success'); if (!r.receiptPublished) { toast({ title: 'Donation sent, but the receipt failed', description: `On-chain tx ${r.txid.slice(0, 12)}… broadcast; the kind 8333 receipt didn't publish${r.receiptPublishError ? ` (${r.receiptPublishError})` : ''}.`, variant: 'destructive', }); } else { toast({ title: 'Donation sent', description: `Thanks for supporting ${campaign.title}.`, }); } }, onError: (error: unknown) => { const msg = error instanceof Error ? error.message : String(error); toast({ title: 'Donation failed', description: msg, variant: 'destructive', }); }, }); const handleClose = () => { if (donateMutation.isPending) return; onOpenChange(false); }; // ── Logged-out flow ── if (open && !user) { return ( ); } // Logged-in but the signer can't build a PSBT (e.g. NIP-07 extension // without signPsbt). Direct the donor at the external-wallet panel on // the page — the in-app flow simply isn't possible without a PSBT // signer. if (open && !canSignPsbt) { return ( ); } return ( {step === 'form' && ( { setAmountUsd(usd); setCustomUsd(''); }} onCustomChange={setCustomUsd} onCommentChange={setComment} onFeeSpeedChange={setFeeSpeed} onContinue={() => setStep('confirm')} onClose={handleClose} /> )} {step === 'confirm' && ( setStep('form')} onSubmit={() => donateMutation.mutate()} /> )} {step === 'success' && result && ( )} ); } // ───────────────────────────────────────────────────────────────────── // Form step // ───────────────────────────────────────────────────────────────────── interface FormViewProps { campaign: ParsedCampaign; amountUsd: number; customUsd: string; comment: string; feeSpeed: DonationFeeSpeed; effectiveAmount: number; effectiveUsd: number; belowDust: boolean; btcPrice: number | undefined; isPending: boolean; onAmountChange: (usd: number) => void; onCustomChange: (value: string) => void; onCommentChange: (value: string) => void; onFeeSpeedChange: (speed: DonationFeeSpeed) => void; onContinue: () => void; onClose: () => void; } function FormView({ campaign, amountUsd, customUsd, comment, feeSpeed, effectiveAmount, effectiveUsd, belowDust, btcPrice, isPending, onAmountChange, onCustomChange, onCommentChange, onFeeSpeedChange, onContinue, }: FormViewProps) { const usingCustom = customUsd.trim().length > 0; const canContinue = effectiveAmount > 0 && !belowDust; return ( <> Donate to {campaign.title} Send Bitcoin to the campaign's wallet from your in-app balance.
{/* Preset amounts */}
{PRESET_AMOUNTS.map(({ amountUsd: usd, icon: Icon, label }) => { const selected = !usingCustom && amountUsd === usd; return ( ); })}
{/* Custom amount */}
$ onCustomChange(e.target.value)} className="pl-7" />
{effectiveAmount > 0 && (
≈ {formatSats(effectiveAmount)} sats {btcPrice && effectiveUsd > 0 && ( <> · ${effectiveUsd.toLocaleString()} at current price )}
)}
{/* Comment */}