From 2bce20ba03d081c4ccd313a0403e1b3147dcf24b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 18 May 2026 19:58:40 -0500 Subject: [PATCH] Restructure campaign page into 2-column GoFundMe-style layout Splits CampaignDetailContent into a main article column (story + comments + threaded donation receipts) and a sticky right donate column that holds raised stats / progress / primary CTA / share / beneficiaries / recent donors. On lg+ the column sticks beside the article; on mobile it collapses inline below the hero so the donate CTA stays above the fold. Extracts CampaignHero, CampaignStory, DonateColumn, SingleBeneficiaryActions, MultiBeneficiaryActions, DonorPreviewList as named subcomponents so the two recipient variants stay in one file but each has a self-contained code path. The donor list ('Recent donations') shows up to 5 aggregated kind 8333 receipts (amount + relative time, no avatar) with a 'See all' button that scrolls to the inline activity feed below where every receipt is already rendered alongside comments. Drops CampaignProgress in favor of a raw Progress bar so the column's raised/of-goal text isn't duplicated by the helper's inline label. --- src/pages/CampaignDetailPage.tsx | 991 ++++++++++++++++++++----------- 1 file changed, 630 insertions(+), 361 deletions(-) diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index 0711f145..57de25af 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -36,11 +36,11 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { CampaignProgress } from '@/components/CampaignCard'; import { DonateDialog } from '@/components/DonateDialog'; import { PostActionBar } from '@/components/PostActionBar'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; +import { Progress } from '@/components/ui/progress'; import { InteractionsModal, type InteractionTab, @@ -66,6 +66,7 @@ import { satsToUSDWhole } from '@/lib/bitcoin'; import { formatNumber } from '@/lib/formatNumber'; import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import NotFound from './NotFound'; @@ -97,6 +98,13 @@ function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } } export function CampaignDetailPage({ pubkey, identifier, relays }: CampaignDetailPageProps) { + // Drop the default 600px column cap and the default right widget sidebar + // — this page renders its own GoFundMe-style 2-column layout (article on + // the left, sticky donate card on the right). We don't pass a custom + // rightSidebar through MainLayout because the column needs to scroll + // with the article on mobile (where the sidebar slot is invisible + // anyway). Keeping everything in one Outlet lets us inline the donate + // column below the hero on small screens. useLayoutOptions({ noMaxWidth: true, rightSidebar: null }); const { data: campaign, isLoading, isError } = useCampaign({ pubkey, identifier, relays }); @@ -131,36 +139,21 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { setInteractionsOpen(true); }; - // Engagement stats (replies / reposts / reactions / zaps) for the campaign - // event itself — drives the counters above the action bar. const { data: engagementStats } = useEventStats(campaign.event.id, campaign.event); - // Fetch NIP-22 comments for this addressable campaign. useComments resolves - // the `#A` filter automatically when given an addressable NostrEvent. const { data: commentsData, isLoading: commentsLoading } = useComments( campaign.event, 500, ); - // Build a recursive reply tree from the flat comment list, then interleave - // kind 8333 on-chain donation receipts as top-level nodes sorted by - // created_at. ThreadedReplyList renders each via NoteCard, which has a - // dedicated zap-receipt layout that already handles kind 8333. - // - // New donations produce one kind 8333 event for the whole tx; legacy - // donations produced one event per beneficiary sharing the same txid and - // donor. To render either as a single donation card, we group by - // `(txid, donor)` and sum each group's `amount` tags into the canonical - // (newest) event. New-schema donations are a singleton group whose sum - // already equals the event's own `amount`; legacy donations collapse - // their N events into one card showing the donation total. + // Aggregate kind 8333 donation receipts by `(txid, donor)` so each + // donation surfaces as a single event in the donor list and the inline + // reply tree. Legacy donations (one receipt per beneficiary sharing the + // same txid + donor) collapse into one card showing the donation total. const donationReceipts = useMemo((): NostrEvent[] => { if (!stats?.receipts || stats.receipts.length === 0) return []; - type Aggregate = { - canonical: NostrEvent; - totalSats: number; - }; + type Aggregate = { canonical: NostrEvent; totalSats: number }; const byDonation = new Map(); for (const receipt of stats.receipts) { @@ -172,15 +165,12 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { const key = `${txid}:${receipt.pubkey}`; const prev = byDonation.get(key); const totalSats = (prev?.totalSats ?? 0) + amount; - // Use the newest receipt as the canonical event so created_at reflects - // the latest activity for the donation. const canonical = prev && prev.canonical.created_at >= receipt.created_at ? prev.canonical : receipt; byDonation.set(key, { canonical, totalSats }); } - // Materialise display events with the summed amount tag. return Array.from(byDonation.values()).map(({ canonical, totalSats }) => ({ ...canonical, tags: [ @@ -210,7 +200,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { }; const commentNodes = topLevelComments.map((c) => buildCommentNode(c)); - // Donations have no replies of their own in this view. const donationNodes: ReplyNode[] = donationReceipts.map((ev) => ({ event: ev, children: [] })); return [...commentNodes, ...donationNodes].sort( @@ -219,9 +208,8 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { }, [commentsData, donationReceipts]); // Engagement counters above the action bar. Zaps are intentionally excluded - // for campaigns — Lightning zaps are not how campaigns are funded (on-chain - // donations via kind 8333 are), so showing a zap count here would suggest - // the wrong CTA. + // for campaigns — donations are on-chain (kind 8333), so showing a zap + // count here would suggest the wrong CTA. const hasStats = !!engagementStats?.replies || !!engagementStats?.reposts || @@ -237,11 +225,13 @@ 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 - // + "Open in wallet" button directly into the page (replacing the - // beneficiary list). The top primary donate button is dropped — the - // inline panel covers it — so there's no PSBT/multi-recipient flow to - // coordinate. + // The donate column has two visual variants: single-beneficiary + // campaigns inline the recipient's BIP-21 QR + address + "Open in + // wallet" (no in-app PSBT flow needed for a single recipient), and + // multi-beneficiary campaigns show the "Donate" button that opens + // DonateDialog plus a per-recipient list with their own donate + // buttons. The rest of the column (raised stats, share, donor list) + // is shared between both. const singleBeneficiary = campaign.recipients.length === 1 ? campaign.recipients[0] : null; @@ -301,326 +291,146 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ); }; + /** Smooth-scroll the comments+donations list (which already includes + * every donation receipt as a top-level node) into view. Used by the + * donate column's "See all" affordance. */ + const scrollToActivity = () => { + const el = document.getElementById('campaign-activity'); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + // ── Donate column ── + // Rendered twice in the JSX tree below: once inline under the hero on + // mobile (`lg:hidden`), once as the sticky right column on desktop + // (`hidden lg:block`). Building it as a const here keeps both call + // sites in sync — single source of truth for the donate UX. + const donateColumn = ( + setDonateOpen(true)} + onShare={handleShare} + onSeeAll={scrollToActivity} + /> + ); + return (
-
- {/* Hero */} -
- {/* Cover */} -
- {cover ? ( - - ) : ( -
- + {/* Cover hero stretches edge-to-edge on every breakpoint. */} + navigate(-1)} + onArchive={() => setArchiveConfirmOpen(true)} + onReopen={handleToggleArchive} + /> + + {/* Two-column body. On mobile the right column collapses inline + immediately below the hero so the donate CTA stays above the + fold. On lg+ the right column sticks to the viewport edge of + the main content while the article scrolls. */} +
+
+ {/* Mobile-only inline donate card */} +
{donateColumn}
+ + {/* Main article column */} +
+ 0} + expanded={storyExpanded} + onToggle={() => setStoryExpanded((v) => !v)} + /> + + {/* Engagement: stats counters, action bar, threaded replies + + donation receipts interleaved. */} +
+ {hasStats && ( +
+ {engagementStats?.reposts ? ( + + ) : null} + {engagementStats?.quotes ? ( + + ) : null} + {engagementStats?.reactions ? ( + + ) : null}
)} -
-
- - {isCreator && ( -
- - {campaign.archived ? ( - - ) : ( - - )} + setReplyOpen(true)} + onMore={() => setMoreMenuOpen(true)} + /> + +
+ {commentsLoading && statsLoading && replyTree.length === 0 ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : replyTree.length > 0 ? ( +
+ +
+ ) : ( +
+ No comments yet. Be the first to comment!
- )} -
- -
- {campaign.archived && ( - - - Archived - - )} -
-

- {campaign.title} -

- e.stopPropagation()} - className="text-xs sm:text-sm text-white/85 hover:text-white motion-safe:transition-colors" - > - by {creatorName} - -
-
- {tagLabel && ( - - - {tagLabel} - - )} - {countryLabel && ( - - - {countryLabel} - - )} - {deadline && ( - - - {deadline.label} - - )} - - - {campaign.recipients.length}{' '} - {campaign.recipients.length === 1 ? 'recipient' : 'recipients'} - -
- {campaign.summary && ( -

- {campaign.summary} -

)}
+
- {/* Support */} - - -
-

Support Campaign

-

- Donations are sent with Bitcoin and split across the beneficiaries. -

-
- - {statsLoading ? ( - - ) : ( - <> -
-
- {formatSatsFull(raisedSats, btcPrice)} -
- {campaign.goalSats ? ( -
- raised of {formatSatsFull(campaign.goalSats, btcPrice)} goal -
- ) : ( -
raised
- )} -
- {campaign.goalSats && ( - - )} - - )} - - {singleBeneficiary ? ( - // The inline BeneficiaryDonatePanel below already shows the - // "Open in wallet" button right under the address, so there's - // no primary donate button up here. Share spans the row. - - ) : ( -
- - - -
- )} - -
-
- {singleBeneficiary ? 'Beneficiary' : 'Beneficiaries'} -
- {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. - - ) : ( -
- {campaign.recipients.map((r) => ( - - ))} -
- )} -
- -
-
- Story -
- {campaign.story.trim().length > 0 ? ( -
-
-
- -
- {!storyExpanded && ( - // Fade overlay hints at clipped content. Pointer-events - // disabled so the Read more button below is clickable. -
- )} -
- -
- ) : ( -
-

- The organizer hasn't written a story for this campaign yet. -

-
- )} -
- - {/* Engagement: stats row, action bar, threaded replies. - No top border here — PostActionBar carries its own. */} -
- {hasStats && ( -
- {engagementStats?.reposts ? ( - - ) : null} - {engagementStats?.quotes ? ( - - ) : null} - {engagementStats?.reactions ? ( - - ) : null} -
- )} - - setReplyOpen(true)} - onMore={() => setMoreMenuOpen(true)} - /> - - {/* Threaded comments + on-chain donation receipts */} -
- {commentsLoading && statsLoading && replyTree.length === 0 ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) : replyTree.length > 0 ? ( -
- -
- ) : ( -
- No comments yet. Be the first to comment! -
- )} -
-
- - + {/* Desktop-only sticky donate column. max-h + overflow lets the + column scroll internally on shorter viewports (e.g. laptops) + so a tall donate card with QR + donor list never traps its + bottom rows offscreen. */} +
@@ -630,8 +440,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { onOpenChange={(open) => { setDonateOpen(open); if (!open) { - // Refresh stats after the dialog closes so a successful donation - // shows up promptly even if relay propagation lagged. queryClient.invalidateQueries({ queryKey: ['campaign-donations', campaign.aTag] }); } }} @@ -683,6 +491,469 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { ); } +// ───────────────────────────────────────────────────────────────────── +// Hero +// ───────────────────────────────────────────────────────────────────── + +interface CampaignHeroProps { + campaign: ParsedCampaign; + cover: string | undefined; + creatorName: string; + deadline: { label: string; isPast: boolean } | null; + countryLabel: string | undefined; + tagLabel: string | undefined; + isCreator: boolean; + naddr: string; + archiveDisabled: boolean; + onBack: () => void; + onArchive: () => void; + onReopen: () => void; +} + +function CampaignHero({ + campaign, + cover, + creatorName, + deadline, + countryLabel, + tagLabel, + isCreator, + naddr, + archiveDisabled, + onBack, + onArchive, + onReopen, +}: CampaignHeroProps) { + return ( +
+
+ {cover ? ( + + ) : ( +
+ +
+ )} +
+ +
+ + {isCreator && ( +
+ + {campaign.archived ? ( + + ) : ( + + )} +
+ )} +
+ +
+ {campaign.archived && ( + + + Archived + + )} +
+

+ {campaign.title} +

+ e.stopPropagation()} + className="text-xs sm:text-sm text-white/85 hover:text-white motion-safe:transition-colors" + > + by {creatorName} + +
+
+ {tagLabel && ( + + + {tagLabel} + + )} + {countryLabel && ( + + + {countryLabel} + + )} + {deadline && ( + + + {deadline.label} + + )} + + + {campaign.recipients.length}{' '} + {campaign.recipients.length === 1 ? 'recipient' : 'recipients'} + +
+ {campaign.summary && ( +

+ {campaign.summary} +

+ )} +
+
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────── +// Story +// ───────────────────────────────────────────────────────────────────── + +function CampaignStory({ + storyEvent, + hasContent, + expanded, + onToggle, +}: { + storyEvent: NostrEvent; + hasContent: boolean; + expanded: boolean; + onToggle: () => void; +}) { + if (!hasContent) { + return ( +
+

+ The organizer hasn't written a story for this campaign yet. +

+
+ ); + } + + return ( +
+
+
+ +
+ {!expanded && ( +
+ )} +
+ +
+ ); +} + +// ───────────────────────────────────────────────────────────────────── +// Donate column +// ───────────────────────────────────────────────────────────────────── + +interface DonateColumnProps { + campaign: ParsedCampaign; + /** The lone recipient when there's exactly one beneficiary; null otherwise. */ + singleBeneficiary: ParsedCampaign['recipients'][number] | null; + raisedSats: number; + statsLoading: boolean; + btcPrice: number | undefined; + /** Aggregated kind 8333 donation events, newest first. */ + donations: NostrEvent[]; + deadline: { label: string; isPast: boolean } | null; + onDonateClick: () => void; + onShare: () => void; + /** Scroll the inline activity list into view (donations + comments). */ + onSeeAll: () => void; +} + +function DonateColumn({ + campaign, + singleBeneficiary, + raisedSats, + statsLoading, + btcPrice, + donations, + deadline, + onDonateClick, + onShare, + onSeeAll, +}: DonateColumnProps) { + const ended = deadline?.isPast || campaign.archived; + const endedLabel = campaign.archived + ? 'Campaign archived' + : deadline?.isPast + ? 'Campaign ended' + : null; + + return ( + + + {/* Raised stats + progress */} + {statsLoading ? ( + + ) : ( +
+
+
+ {formatSatsFull(raisedSats, btcPrice)} + + raised + +
+ {campaign.goalSats ? ( +
+ of {formatSatsFull(campaign.goalSats, btcPrice)} goal + {donations.length > 0 && ( + <> + {' · '} + {formatNumber(donations.length)}{' '} + {donations.length === 1 ? 'donation' : 'donations'} + + )} +
+ ) : donations.length > 0 ? ( +
+ {formatNumber(donations.length)}{' '} + {donations.length === 1 ? 'donation' : 'donations'} +
+ ) : null} +
+ {campaign.goalSats && ( + + )} +
+ )} + + {/* Primary actions — variant fork is here */} + {singleBeneficiary ? ( + + ) : ( + + )} + + {/* Beneficiaries — only shown for multi-beneficiary campaigns. + The single-beneficiary variant already inlines the recipient + via the BIP-21 panel above. */} + {!singleBeneficiary && ( +
+
+ Beneficiaries +
+
+ {campaign.recipients.map((r) => ( + + ))} +
+
+ )} + + {/* Latest donors preview */} + {donations.length > 0 && ( +
+
+ Recent donations +
+ + +
+ )} +
+
+ ); +} + +/** Donate / Share pair for multi-beneficiary campaigns. */ +function MultiBeneficiaryActions({ + ended, + endedLabel, + onDonateClick, + onShare, +}: { + ended: boolean; + endedLabel: string | null; + onDonateClick: () => void; + onShare: () => void; +}) { + return ( +
+ + +
+ ); +} + +/** BIP-21 QR + address + open-in-wallet panel for single-beneficiary + * campaigns. The panel's "Open in wallet" button is the primary CTA, + * so we don't render a separate Donate button above it — just Share + * beneath. When the campaign has ended, the panel is suppressed and a + * disabled "Campaign ended/archived" button takes its place so the + * page still communicates state. */ +function SingleBeneficiaryActions({ + pubkey, + ended, + endedLabel, + onShare, +}: { + pubkey: string; + ended: boolean; + endedLabel: string | null; + onShare: () => void; +}) { + if (ended) { + return ( +
+ + +
+ ); + } + + return ( +
+ + +
+ ); +} + +/** Compact donor list: monogram, amount, relative time. Shows up to the + * first 5 entries; the parent surfaces the rest via "See all". */ +function DonorPreviewList({ + donations, + btcPrice, +}: { + donations: NostrEvent[]; + btcPrice: number | undefined; +}) { + const preview = donations.slice(0, 5); + return ( +
    + {preview.map((ev) => { + const amountTag = ev.tags.find(([n]) => n === 'amount')?.[1]; + const sats = amountTag ? Number(amountTag) : 0; + return ( +
  • +
    + +
    +
    +
    + {formatSatsFull(sats, btcPrice)} +
    +
    + {timeAgo(ev.created_at)} +
    +
    +
  • + ); + })} +
+ ); +} + +// ───────────────────────────────────────────────────────────────────── +// Beneficiary row (multi-beneficiary campaigns) +// ───────────────────────────────────────────────────────────────────── + function RecipientRow({ pubkey, weight }: { pubkey: string; weight: number }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; @@ -731,6 +1002,10 @@ function RecipientRow({ pubkey, weight }: { pubkey: string; weight: number }) { ); } +// ───────────────────────────────────────────────────────────────────── +// Skeletons +// ───────────────────────────────────────────────────────────────────── + function CampaignReplySkeleton() { return (
@@ -749,26 +1024,20 @@ function CampaignReplySkeleton() { function CampaignDetailSkeleton() { return (
-
-
- -
+
+ +
+
+
+
-
- - - -
+ +
-
- - -
- - -
+
+