diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index 8d76a224..ff76e319 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -2,15 +2,18 @@ import { useMemo, useCallback, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { + CalendarClock, CalendarDays, ChevronLeft, Crown, HandHeart, Info, + MapPin, Megaphone, MoreVertical, Pencil, Shield, + Target, Share2, UserCheck, UserMinus, @@ -25,8 +28,10 @@ import { PostActionBar } from '@/components/PostActionBar'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Progress } from '@/components/ui/progress'; import { Skeleton } from '@/components/ui/skeleton'; import { DonateDialog } from '@/components/DonateDialog'; import { NoteContent } from '@/components/NoteContent'; @@ -51,13 +56,17 @@ import { } from '@/hooks/useOrganizationActivity'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useToast } from '@/hooks/useToast'; +import { useSubmissionZapTotals } from '@/hooks/useSubmissionZapTotals'; import { CommunityModerationContext } from '@/contexts/CommunityModerationContext'; import { useLayoutOptions } from '@/contexts/LayoutContext'; import { applyCommunityModerationToEvents, parseCommunityEvent } from '@/lib/communityUtils'; import type { ParsedCampaign } from '@/lib/campaign'; import type { Action } from '@/hooks/useActions'; +import { formatSats, satsToUSDWhole } from '@/lib/bitcoin'; +import { getGeoDisplayName } from '@/lib/countries'; +import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers'; import { formatNumber } from '@/lib/formatNumber'; -import { genUserName } from '@/lib/genUserName'; +import { genUserName, getDisplayName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; @@ -185,8 +194,57 @@ function formatShelfEventDate(event: NostrEvent): string { return shelfDateFormatter.format(new Date(ts * 1000)); } +function formatPledgeAmount(sats: number, btcPrice: number | undefined): string { + if (btcPrice) return satsToUSDWhole(sats, btcPrice); + return `${formatSats(sats)} sats`; +} + +function formatPledgeDeadline(unixSeconds: number): { label: string; isPast: boolean } { + const now = Math.floor(Date.now() / 1000); + const diff = unixSeconds - now; + if (diff <= 0) return { label: 'Ended', isPast: true }; + const days = Math.ceil(diff / 86_400); + if (days <= 1) return { label: 'Ends today', isPast: false }; + if (days < 30) return { label: `${days} days left`, isPast: false }; + const months = Math.round(days / 30); + return { label: `${months} mo left`, isPast: false }; +} + +function PledgeShelfProgress({ pledgedSats, fundedSats, btcPrice }: { pledgedSats: number; fundedSats: number; btcPrice: number | undefined }) { + const pct = pledgedSats > 0 ? Math.min(100, Math.round((fundedSats / pledgedSats) * 100)) : 0; + return ( +
+ +
+ + {formatPledgeAmount(fundedSats, btcPrice)} + funded + + of {formatPledgeAmount(pledgedSats, btcPrice)} pledged +
+
+ ); +} + function PledgeShelfCard({ pledge }: { pledge: Action }) { - const cover = sanitizeUrl(pledge.image); + const { btcPrice } = useBitcoinWallet(); + const author = useAuthor(pledge.pubkey); + const metadata = author.data?.metadata; + const displayName = getDisplayName(metadata, pledge.pubkey); + const [imageLoadFailed, setImageLoadFailed] = useState(false); + const { data: commentsData } = useComments(pledge.event, 100); + const topLevel = useMemo(() => commentsData?.topLevelComments ?? [], [commentsData?.topLevelComments]); + const submissionIds = useMemo(() => topLevel.map((comment) => comment.id), [topLevel]); + const { data: zapTotals } = useSubmissionZapTotals(submissionIds); + const fundedSats = useMemo(() => { + const totals = zapTotals ?? new Map(); + return topLevel.reduce((sum, submission) => sum + (totals.get(submission.id) ?? 0), 0); + }, [topLevel, zapTotals]); + + const sanitizedCover = sanitizeUrl(pledge.image); + const coverImage = sanitizedCover && !imageLoadFailed ? sanitizedCover : DEFAULT_COVER_IMAGE; + const deadline = pledge.deadline ? formatPledgeDeadline(pledge.deadline) : null; + const countryLabel = pledge.countryCode ? getGeoDisplayName(pledge.countryCode) : undefined; const naddr = nip19.naddrEncode({ kind: pledge.event.kind, pubkey: pledge.pubkey, @@ -195,23 +253,66 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) { return ( -
- {cover ? ( - - ) : ( -
- + +
+ setImageLoadFailed(true)} + loading="lazy" + /> + {deadline?.isPast && ( +
+ + Ended + +
+ )} +
+ +
+
+

+ {pledge.title} +

+ {pledge.description.trim() && ( +

+ {pledge.description} +

+ )}
- )} -
-
-

{pledge.title}

- {pledge.description && ( -

{pledge.description}

- )} -
+ +
+ + + +
+ + + {topLevel.length} {topLevel.length === 1 ? 'submission' : 'submissions'} + + {countryLabel && ( + + + {countryLabel} + + )} + {deadline && ( + + + {deadline.label} + + )} +
+ +
+ by {displayName} +
+
+
); }