diff --git a/src/components/BeneficiaryDonateDialog.tsx b/src/components/BeneficiaryDonateDialog.tsx index 827a1605..99d829aa 100644 --- a/src/components/BeneficiaryDonateDialog.tsx +++ b/src/components/BeneficiaryDonateDialog.tsx @@ -18,27 +18,32 @@ import { nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin'; import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; -interface BeneficiaryDonateDialogProps { +interface BeneficiaryDonatePanelProps { /** Hex pubkey of the beneficiary. */ pubkey: string; - open: boolean; - onOpenChange: (open: boolean) => void; + /** + * If true, the profile preview row (avatar + display name) is hidden. + * Use when the surrounding UI already identifies the beneficiary — + * e.g. the campaign detail page, which shows the recipient as the + * campaign organizer above the panel. + */ + hideProfile?: boolean; } /** - * Per-beneficiary donate dialog. Renders the recipient's Taproot Bitcoin - * address (derived from their Nostr pubkey) as a scannable BIP-21 QR code - * and a copyable string, plus an "Open in wallet" affordance. + * Inline panel rendering a beneficiary's Taproot address as a scannable + * BIP-21 QR code, a copyable string, and an "Open in wallet" button. + * + * Used both by `BeneficiaryDonateDialog` (modal context) and embedded + * directly into the campaign page when there's a single beneficiary. * * Intentionally minimal: no amount input, no PSBT/in-app wallet flow — - * that's `DonateDialog`'s job. This is for donating directly to one - * individual. + * that's `DonateDialog`'s job. */ -export function BeneficiaryDonateDialog({ +export function BeneficiaryDonatePanel({ pubkey, - open, - onOpenChange, -}: BeneficiaryDonateDialogProps) { + hideProfile = false, +}: BeneficiaryDonatePanelProps) { const { toast } = useToast(); const [copied, setCopied] = useState(false); @@ -52,7 +57,6 @@ export function BeneficiaryDonateDialog({ () => nostrPubkeyToBitcoinAddress(pubkey), [pubkey], ); - // BIP-21 URI: most wallets recognize the `bitcoin:` scheme when scanning. // No amount field — donor picks one in their wallet. const bip21 = address ? `bitcoin:${address}` : ''; @@ -73,17 +77,18 @@ export function BeneficiaryDonateDialog({ } }; - return ( - - - - Donate to {displayName} - - Scan the QR code or copy the Bitcoin address below to donate. - - + if (!address) { + return ( +
+ + We couldn't derive a Bitcoin address for this beneficiary. +
+ ); + } - {/* Profile preview */} + return ( +
+ {!hideProfile && (
{picture && } @@ -95,54 +100,89 @@ export function BeneficiaryDonateDialog({
{displayName}
+ )} - {address ? ( -
- {/* QR code */} -
-
- -
-
+ {/* QR code */} +
+
+ +
+
- {/* Copyable address */} -
- - -
+ {/* Copyable address */} +
+ + +
+
+ ); +} - {/* Open in wallet — relies on the `bitcoin:` URI handler. */} - - +interface BeneficiaryDonateDialogProps { + /** Hex pubkey of the beneficiary. */ + pubkey: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * Modal wrapper around `BeneficiaryDonatePanel` for places that still want + * the dialog UX (e.g. multi-beneficiary campaigns, where each row's + * "Donate" button opens this dialog). + */ +export function BeneficiaryDonateDialog({ + pubkey, + open, + onOpenChange, +}: BeneficiaryDonateDialogProps) { + const author = useAuthor(pubkey); + const metadata = author.data?.metadata; + const displayName = + metadata?.display_name || metadata?.name || genUserName(pubkey); + const address = useMemo( + () => nostrPubkeyToBitcoinAddress(pubkey), + [pubkey], + ); + const bip21 = address ? `bitcoin:${address}` : ''; + + return ( + + + + Donate to {displayName} + + Scan the QR code or copy the Bitcoin address below to donate. + + + + + + {bip21 ? ( + // Open in wallet — relies on the `bitcoin:` URI handler. + ) : ( -
-
- - We couldn't derive a Bitcoin address for this beneficiary. -
- -
+ )}
diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index 81df48b8..320b706b 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -9,6 +9,7 @@ import { Archive, ArchiveRestore, ChevronLeft, + ExternalLink, HandHeart, MapPin, Pencil, @@ -19,7 +20,10 @@ import { import { ArticleContent } from '@/components/ArticleContent'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; -import { BeneficiaryDonateDialog } from '@/components/BeneficiaryDonateDialog'; +import { + BeneficiaryDonateDialog, + BeneficiaryDonatePanel, +} from '@/components/BeneficiaryDonateDialog'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; @@ -59,7 +63,7 @@ import { getCampaignPrimaryTagLabel, type ParsedCampaign, } from '@/lib/campaign'; -import { satsToUSDWhole } from '@/lib/bitcoin'; +import { nostrPubkeyToBitcoinAddress, satsToUSDWhole } from '@/lib/bitcoin'; import { formatNumber } from '@/lib/formatNumber'; import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; @@ -114,7 +118,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { const queryClient = useQueryClient(); const [donateOpen, setDonateOpen] = useState(false); - const [beneficiaryDonateOpen, setBeneficiaryDonateOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [interactionsOpen, setInteractionsOpen] = useState(false); @@ -235,6 +238,18 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { const tagLabel = getCampaignPrimaryTagLabel(campaign); const raisedSats = stats?.totalSats ?? 0; + // Single-beneficiary campaigns inline the recipient's BIP-21 QR + address + // directly into the page, and turn the big "Donate" button into an + // "Open in wallet" anchor. There's no split to coordinate, so the full + // PSBT flow in DonateDialog would be friction. + const singleBeneficiary = + campaign.recipients.length === 1 ? campaign.recipients[0] : null; + const singleBip21 = useMemo(() => { + if (!singleBeneficiary) return ''; + const address = nostrPubkeyToBitcoinAddress(singleBeneficiary.pubkey); + return address ? `bitcoin:${address}` : ''; + }, [singleBeneficiary]); + const isCreator = user?.pubkey === campaign.pubkey; const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]); const storyEvent = useMemo( @@ -449,29 +464,44 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { )}
- + : singleBeneficiary + ? 'Open in wallet' + : 'Donate'; + const Icon = singleBeneficiary && !disabled ? ExternalLink : HandHeart; + + // Single-beneficiary, active campaign: the inline QR + // panel is already on the page, so the button becomes + // an "Open in wallet" anchor pointing at the same + // bitcoin: URI. + if (singleBeneficiary && !disabled && singleBip21) { + return ( + + ); + } + + return ( + + ); + })()}