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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user