Inline single-beneficiary QR panel into campaign page

For campaigns with exactly one recipient, the QR + Bitcoin address +
copyable string that BeneficiaryDonateDialog used to host in a modal
is now embedded directly in the 'Beneficiary' section of the campaign
page. The big primary button becomes 'Open in wallet' and links to
the same BIP-21 URI as the inline QR.

Extracts BeneficiaryDonatePanel as the reusable body; the dialog
keeps wrapping it for the multi-beneficiary case where each row's
Donate button still opens a modal.

Regression-of: 69929fc0
This commit is contained in:
Alex Gleason
2026-05-18 19:34:50 -05:00
parent 69929fc00d
commit 6488a0ed63
2 changed files with 176 additions and 106 deletions
+107 -67
View File
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Donate to {displayName}</DialogTitle>
<DialogDescription className="sr-only">
Scan the QR code or copy the Bitcoin address below to donate.
</DialogDescription>
</DialogHeader>
if (!address) {
return (
<div className="flex items-center gap-2 text-sm">
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
<span>We couldn't derive a Bitcoin address for this beneficiary.</span>
</div>
);
}
{/* Profile preview */}
return (
<div className="space-y-4">
{!hideProfile && (
<div className="flex items-center gap-3">
<Avatar className="size-10 ring-1 ring-border">
{picture && <AvatarImage src={picture} alt="" />}
@@ -95,54 +100,89 @@ export function BeneficiaryDonateDialog({
<div className="font-medium truncate">{displayName}</div>
</div>
</div>
)}
{address ? (
<div className="space-y-4">
{/* QR code */}
<div className="flex justify-center">
<div className="rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas value={bip21} size={200} level="M" />
</div>
</div>
{/* QR code */}
<div className="flex justify-center">
<div className="rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas value={bip21} size={200} level="M" />
</div>
</div>
{/* Copyable address */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Bitcoin address
</Label>
<button
type="button"
onClick={copyAddress}
className="w-full flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs break-all text-left hover:bg-muted/60 motion-safe:transition-colors"
aria-label="Copy Bitcoin address"
>
<span className="break-all">{address}</span>
{copied ? (
<Check className="size-4 text-green-500 shrink-0" />
) : (
<Copy className="size-4 text-muted-foreground shrink-0" />
)}
</button>
</div>
{/* Copyable address */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Bitcoin address
</Label>
<button
type="button"
onClick={copyAddress}
className="w-full flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs break-all text-left hover:bg-muted/60 motion-safe:transition-colors"
aria-label="Copy Bitcoin address"
>
<span className="break-all">{address}</span>
{copied ? (
<Check className="size-4 text-green-500 shrink-0" />
) : (
<Copy className="size-4 text-muted-foreground shrink-0" />
)}
</button>
</div>
</div>
);
}
{/* Open in wallet — relies on the `bitcoin:` URI handler. */}
<Button asChild className="w-full">
<a href={bip21}>
<ExternalLink className="size-4 mr-1.5" />
Open in wallet
</a>
</Button>
</div>
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Donate to {displayName}</DialogTitle>
<DialogDescription className="sr-only">
Scan the QR code or copy the Bitcoin address below to donate.
</DialogDescription>
</DialogHeader>
<BeneficiaryDonatePanel pubkey={pubkey} />
{bip21 ? (
// Open in wallet — relies on the `bitcoin:` URI handler.
<Button asChild className="w-full">
<a href={bip21}>
<ExternalLink className="size-4 mr-1.5" />
Open in wallet
</a>
</Button>
) : (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm">
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
<span>We couldn't derive a Bitcoin address for this beneficiary.</span>
</div>
<Button onClick={() => onOpenChange(false)} className="w-full">
Close
</Button>
</div>
<Button onClick={() => onOpenChange(false)} className="w-full">
Close
</Button>
)}
</DialogContent>
</Dialog>
+69 -39
View File
@@ -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 }) {
)}
<div className="grid grid-cols-4 gap-2">
<Button
size="lg"
className="w-full col-span-3"
onClick={() => {
// Logged-out donors on a single-beneficiary campaign get
// the simpler per-beneficiary BIP-21 dialog directly —
// there's no split to coordinate, so the multi-step
// PSBT/login flow would be pure friction.
if (!user && campaign.recipients.length === 1) {
setBeneficiaryDonateOpen(true);
} else {
setDonateOpen(true);
}
}}
disabled={deadline?.isPast || campaign.archived}
>
<HandHeart className="size-5 mr-2" />
{campaign.archived
{(() => {
const disabled = deadline?.isPast || campaign.archived;
const label = campaign.archived
? 'Campaign archived'
: deadline?.isPast
? 'Campaign ended'
: 'Donate'}
</Button>
: 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 (
<Button asChild size="lg" className="w-full col-span-3">
<a href={singleBip21}>
<Icon className="size-5 mr-2" />
{label}
</a>
</Button>
);
}
return (
<Button
size="lg"
className="w-full col-span-3"
onClick={() => setDonateOpen(true)}
disabled={disabled}
>
<Icon className="size-5 mr-2" />
{label}
</Button>
);
})()}
<Button variant="outline" size="lg" className="w-full" onClick={handleShare}>
<Share2 className="size-4 mr-2" />
@@ -481,13 +511,23 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
<div className="space-y-2 border-t border-border/60 pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Beneficiaries
</div>
<div className="divide-y divide-border/60">
{campaign.recipients.map((r) => (
<RecipientRow key={r.pubkey} pubkey={r.pubkey} weight={r.weight} />
))}
{singleBeneficiary ? 'Beneficiary' : 'Beneficiaries'}
</div>
{singleBeneficiary ? (
// One recipient: inline the BIP-21 QR + copyable address
// panel. The campaign organizer is identified at the top
// of the page, so hide the panel's profile row.
<BeneficiaryDonatePanel
pubkey={singleBeneficiary.pubkey}
hideProfile
/>
) : (
<div className="divide-y divide-border/60">
{campaign.recipients.map((r) => (
<RecipientRow key={r.pubkey} pubkey={r.pubkey} weight={r.weight} />
))}
</div>
)}
</div>
<div className="space-y-2 border-t border-border/60 pt-4">
@@ -618,16 +658,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
btcPrice={btcPrice}
/>
{/* Single-beneficiary shortcut for logged-out donors. Mounted only when
there's exactly one recipient so the pubkey is unambiguous. */}
{campaign.recipients.length === 1 && (
<BeneficiaryDonateDialog
pubkey={campaign.recipients[0].pubkey}
open={beneficiaryDonateOpen}
onOpenChange={setBeneficiaryDonateOpen}
/>
)}
<ReplyComposeModal
event={campaign.event}
open={replyOpen}