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 (
-
-
- -
+
+ +
+
+
+
-
- - - -
+ +
-
- - -
- - -
+
+