- {/* Header: title + community link / status badge */}
-
-
-
-
{goal.title}
-
-
- {communityUrl && communityName && (
- e.stopPropagation()}
- >
-
- {communityName}
-
- )}
- {funded ? (
-
- Funded
-
- ) : expired ? (
- Ended
- ) : null}
-
-
-
- {/* Summary */}
- {goal.summary && (
-
{goal.summary}
- )}
-
- {/* Progress bar */}
-
-
-
- {/* Stats row */}
-
- {zapCount > 0 && (
-
-
- {zapCount} zap{zapCount !== 1 ? 's' : ''}
-
- )}
- {contributors.length > 0 && (
-
-
- {contributors.length} contributor{contributors.length !== 1 ? 's' : ''}
-
- )}
- {deadlineLabel && (
-
-
- {deadlineLabel}
-
- )}
-
-
- {/* Recipient info — who is receiving the zaps */}
-
-
e.stopPropagation()}>
-
-
-
- {displayName.charAt(0).toUpperCase()}
-
-
+ const headerSection = (
+
+
+
+
+ {goal.title}
+
+
+
+ {d.communityUrl && d.communityName && (
+
e.stopPropagation()}
+ >
+
+ {d.communityName}
-
-
Receiving zaps
-
e.stopPropagation()}
- >
- {displayName}
-
- {lightningAddress && (
-
-
- {lightningAddress}
-
- )}
-
-
+ )}
+ {d.funded ? (
+
+ Funded
+
+ ) : d.expired ? (
+
Ended
+ ) : !isCard && d.deadlineLabel ? (
+
+
+ {d.deadlineLabel}
+
+ ) : null}
+
+
+ );
- {/* Zap button */}
- {!expired && canZap(metadata) && (
-
e.stopPropagation()}>
-
-
-
-
+ const summarySection = goal.summary && (
+
+ {goal.summary}
+
+ );
+
+ const progressSection = (
+
+
+ );
+
+ const recipientSection = (
+
+
e.stopPropagation()}>
+
+
+
+ {d.displayName.charAt(0).toUpperCase()}
+
+
+
+
+
Receiving zaps
+
e.stopPropagation()}
+ >
+ {d.displayName}
+
+ {d.lightningAddress && (
+
+
+ {d.lightningAddress}
+
)}
);
+
+ // ── Card variant ──────────────────────────────────────────────────────────
+
+ if (isCard) {
+ return (
+
+ {imageSection}
+
+
+ {headerSection}
+ {summarySection}
+ {progressSection}
+
+ {/* Stats row (card only) */}
+
+ {d.zapCount > 0 && (
+
+
+ {d.zapCount} zap{d.zapCount !== 1 ? 's' : ''}
+
+ )}
+ {d.contributors.length > 0 && (
+
+
+ {d.contributors.length} contributor{d.contributors.length !== 1 ? 's' : ''}
+
+ )}
+ {d.deadlineLabel && (
+
+
+ {d.deadlineLabel}
+
+ )}
+
+
+ {recipientSection}
+
+ {/* Zap button (card only) */}
+ {!d.expired && canZap(d.metadata) && (
+
e.stopPropagation()}>
+
+
+
+
+ )}
+
+
+ );
+ }
+
+ // ── Compact variant ───────────────────────────────────────────────────────
+
+ return (
+
+ {imageSection}
+ {headerSection}
+ {summarySection}
+ {progressSection}
+ {recipientSection}
+
+ );
}
+// ── Skeleton ──────────────────────────────────────────────────────────────────
+
/** Skeleton placeholder for loading goal cards. */
export function GoalCardSkeleton() {
return (
diff --git a/src/components/GoalContent.tsx b/src/components/GoalContent.tsx
deleted file mode 100644
index 3bc7e86e..00000000
--- a/src/components/GoalContent.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import { useMemo } from 'react';
-import { Link } from 'react-router-dom';
-import { Clock, Target, Users, Zap } from 'lucide-react';
-import { nip19 } from 'nostr-tools';
-import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
-
-import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { getAvatarShape } from '@/lib/avatarShape';
-import { Badge } from '@/components/ui/badge';
-import { Progress } from '@/components/ui/progress';
-import { Skeleton } from '@/components/ui/skeleton';
-import { useAddrEvent } from '@/hooks/useEvent';
-import { useAuthor } from '@/hooks/useAuthor';
-import { useGoalProgress } from '@/hooks/useGoalProgress';
-import { useProfileUrl } from '@/hooks/useProfileUrl';
-import { formatSats, isGoalExpired, parseGoalEvent, parseCommunityATag } from '@/lib/goalUtils';
-import { genUserName } from '@/lib/genUserName';
-import { sanitizeUrl } from '@/lib/sanitizeUrl';
-import { cn } from '@/lib/utils';
-
-/**
- * Compact goal renderer for NoteCard feed views.
- * Shows goal title, progress bar, recipient profile + lightning address, and deadline.
- */
-export function GoalContent({ event }: { event: NostrEvent }) {
- const goal = useMemo(() => parseGoalEvent(event), [event]);
- if (!goal) return null;
-
- return
;
-}
-
-function GoalContentInner({
- event,
- goal,
-}: {
- event: NostrEvent;
- goal: NonNullable
>;
-}) {
- const expired = isGoalExpired(goal);
- const { currentSats, percentage, isLoading: progressLoading } =
- useGoalProgress(event.id, goal.amountMsat, goal.closedAt);
- const funded = percentage >= 100;
-
- // Recipient info
- const author = useAuthor(goal.beneficiary);
- const metadata: NostrMetadata | undefined = author.data?.metadata;
- const displayName = metadata?.display_name || metadata?.name || genUserName(goal.beneficiary);
- const avatarShape = getAvatarShape(metadata);
- const profileUrl = useProfileUrl(goal.beneficiary, metadata);
- const lightningAddress = metadata?.lud16 || metadata?.lud06 || undefined;
-
- // Deadline display
- const deadlineLabel = useMemo(() => {
- if (!goal.closedAt) return null;
- const now = Math.floor(Date.now() / 1000);
- const diff = goal.closedAt - now;
- if (diff <= 0) return 'Ended';
- const days = Math.floor(diff / 86400);
- const hours = Math.floor((diff % 86400) / 3600);
- if (days > 0) return `${days}d ${hours}h left`;
- const mins = Math.floor((diff % 3600) / 60);
- return hours > 0 ? `${hours}h ${mins}m left` : `${mins}m left`;
- }, [goal.closedAt]);
-
- const image = goal.image ? sanitizeUrl(goal.image) : undefined;
-
- // Community link
- const communityAddr = useMemo(() => goal.communityATag ? parseCommunityATag(goal.communityATag) : undefined, [goal.communityATag]);
- const { data: communityEvent } = useAddrEvent(communityAddr);
- const communityName = communityEvent?.tags.find(([n]) => n === 'name')?.[1]
- || communityEvent?.tags.find(([n]) => n === 'd')?.[1];
- const communityUrl = useMemo(() => {
- if (!communityAddr) return undefined;
- try {
- return `/${nip19.naddrEncode({ kind: communityAddr.kind, pubkey: communityAddr.pubkey, identifier: communityAddr.identifier })}`;
- } catch {
- return undefined;
- }
- }, [communityAddr]);
-
- return (
-
- {/* Goal image */}
- {image && (
-
-

{ (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; }}
- />
-
- )}
-
- {/* Title + community link / status */}
-
-
-
-
{goal.title}
-
-
- {communityUrl && communityName && (
- e.stopPropagation()}
- >
-
- {communityName}
-
- )}
- {funded ? (
-
- Funded
-
- ) : expired ? (
- Ended
- ) : deadlineLabel ? (
-
-
- {deadlineLabel}
-
- ) : null}
-
-
-
- {/* Summary */}
- {goal.summary && (
-
{goal.summary}
- )}
-
- {/* Progress bar */}
-
-
-
- {/* Recipient — who is receiving the zaps */}
-
-
e.stopPropagation()}>
-
-
-
- {displayName.charAt(0).toUpperCase()}
-
-
-
-
-
-
e.stopPropagation()}
- >
- {displayName}
-
- {lightningAddress && (
-
-
- {lightningAddress}
-
- )}
-
-
-
- );
-}
diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx
index 1339fd61..1229f926 100644
--- a/src/components/NoteCard.tsx
+++ b/src/components/NoteCard.tsx
@@ -49,7 +49,7 @@ import { FollowPackContent } from "@/components/FollowPackContent";
import { FoundLogContent } from "@/components/FoundLogContent";
import { GeocacheContent } from "@/components/GeocacheContent";
import { GitRepoCard } from "@/components/GitRepoCard";
-import { GoalContent } from "@/components/GoalContent";
+import { GoalCard } from "@/components/GoalCard";
import { NsiteCard } from "@/components/NsiteCard";
import { ImageGallery } from "@/components/ImageGallery";
import { CardsIcon } from "@/components/icons/CardsIcon";
@@ -606,7 +606,7 @@ export const NoteCard = memo(function NoteCard({
) : isCommunity ? (
) : isZapGoal ? (
-
+
) : isVoiceMessage ? (
) : isCalendarEvent ? (
diff --git a/src/hooks/useGoalDisplay.ts b/src/hooks/useGoalDisplay.ts
new file mode 100644
index 00000000..35ab2c98
--- /dev/null
+++ b/src/hooks/useGoalDisplay.ts
@@ -0,0 +1,124 @@
+import { useEffect, useMemo, useState } from 'react';
+import { nip19 } from 'nostr-tools';
+import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
+
+import { getAvatarShape } from '@/lib/avatarShape';
+import { isGoalExpired, parseCommunityATag, type ParsedGoal } from '@/lib/goalUtils';
+import { genUserName } from '@/lib/genUserName';
+import { useAddrEvent } from '@/hooks/useEvent';
+import { useAuthor } from '@/hooks/useAuthor';
+import { useGoalProgress } from '@/hooks/useGoalProgress';
+import { useProfileUrl } from '@/hooks/useProfileUrl';
+
+/** Re-renders every `intervalMs` so time-dependent values stay fresh. */
+function useNow(intervalMs: number): number {
+ const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
+ useEffect(() => {
+ const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), intervalMs);
+ return () => clearInterval(id);
+ }, [intervalMs]);
+ return now;
+}
+
+export interface GoalDisplayData {
+ // Status
+ expired: boolean;
+ funded: boolean;
+
+ // Progress
+ currentSats: number;
+ percentage: number;
+ contributors: string[];
+ zapCount: number;
+ progressLoading: boolean;
+
+ // Recipient
+ metadata: NostrMetadata | undefined;
+ displayName: string;
+ avatarShape: string | undefined;
+ profileUrl: string;
+ lightningAddress: string | undefined;
+
+ // Deadline
+ deadlineLabel: string | null;
+
+ // Community link
+ communityName: string | undefined;
+ communityUrl: string | undefined;
+
+ // Image (already sanitized at parse time)
+ image: string | undefined;
+}
+
+/**
+ * Consolidates all display-related hooks and derived state for a goal event.
+ * Used by both the standalone GoalCard and the compact NoteCard/detail renderers.
+ */
+export function useGoalDisplay(event: NostrEvent, goal: ParsedGoal): GoalDisplayData {
+ const now = useNow(60_000);
+ const expired = isGoalExpired(goal);
+ const { currentSats, percentage, contributors, zapCount, isLoading: progressLoading } =
+ useGoalProgress(event.id, goal.amountMsat, goal.closedAt);
+ const funded = percentage >= 100;
+
+ // Recipient info
+ const author = useAuthor(goal.beneficiary);
+ const metadata: NostrMetadata | undefined = author.data?.metadata;
+ const displayName = metadata?.display_name || metadata?.name || genUserName(goal.beneficiary);
+ const avatarShape = getAvatarShape(metadata);
+ const profileUrl = useProfileUrl(goal.beneficiary, metadata);
+ const lightningAddress = metadata?.lud16 || metadata?.lud06 || undefined;
+
+ // Deadline label — `now` dependency ensures it refreshes every minute
+ const deadlineLabel = useMemo(() => {
+ if (!goal.closedAt) return null;
+ const diff = goal.closedAt - now;
+ if (diff <= 0) return 'Ended';
+ const days = Math.floor(diff / 86400);
+ const hours = Math.floor((diff % 86400) / 3600);
+ if (days > 0) return `${days}d ${hours}h left`;
+ const mins = Math.floor((diff % 3600) / 60);
+ return hours > 0 ? `${hours}h ${mins}m left` : `${mins}m left`;
+ }, [goal.closedAt, now]);
+
+ // Community link
+ const communityAddr = useMemo(
+ () => (goal.communityATag ? parseCommunityATag(goal.communityATag) : undefined),
+ [goal.communityATag],
+ );
+ const { data: communityEvent } = useAddrEvent(communityAddr);
+ const communityName =
+ communityEvent?.tags.find(([n]) => n === 'name')?.[1] ||
+ communityEvent?.tags.find(([n]) => n === 'd')?.[1];
+ const communityUrl = useMemo(() => {
+ if (!communityAddr) return undefined;
+ try {
+ return `/${nip19.naddrEncode({
+ kind: communityAddr.kind,
+ pubkey: communityAddr.pubkey,
+ identifier: communityAddr.identifier,
+ })}`;
+ } catch {
+ return undefined;
+ }
+ }, [communityAddr]);
+
+ return {
+ expired,
+ funded,
+ currentSats,
+ percentage,
+ contributors,
+ zapCount,
+ progressLoading,
+ metadata,
+ displayName,
+ avatarShape,
+ profileUrl,
+ lightningAddress,
+ deadlineLabel,
+ communityName,
+ communityUrl,
+ image: goal.image,
+ };
+}
diff --git a/src/lib/goalUtils.ts b/src/lib/goalUtils.ts
index f9aafa30..46ecd202 100644
--- a/src/lib/goalUtils.ts
+++ b/src/lib/goalUtils.ts
@@ -1,5 +1,6 @@
import type { NostrEvent } from '@nostrify/nostrify';
+import { parseATagCoordinate } from '@/lib/nostrEvents';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ── Kind constant ─────────────────────────────────────────────────────────────
@@ -83,11 +84,6 @@ export function isGoalExpired(goal: ParsedGoal): boolean {
return Math.floor(Date.now() / 1000) > goal.closedAt;
}
-/** Check whether a goal has been fully funded (current >= target). */
-export function isGoalFunded(currentMsat: number, goal: ParsedGoal): boolean {
- return currentMsat >= goal.amountMsat;
-}
-
/** Format satoshis with locale-aware separators. */
export function formatSats(sats: number): string {
return sats.toLocaleString();
@@ -95,15 +91,11 @@ export function formatSats(sats: number): string {
/**
* Parse a community `a` tag coordinate (`34550::`) into its
- * constituent parts. Returns `undefined` if the format is invalid.
+ * constituent parts. Returns `undefined` if the format is invalid or the kind
+ * is not 34550.
*/
export function parseCommunityATag(aTag: string): { kind: number; pubkey: string; identifier: string } | undefined {
- const parts = aTag.split(':');
- if (parts.length < 3) return undefined;
- const kind = parseInt(parts[0], 10);
- if (kind !== 34550) return undefined;
- const pubkey = parts[1];
- if (!pubkey) return undefined;
- const identifier = parts.slice(2).join(':');
- return { kind, pubkey, identifier };
+ const addr = parseATagCoordinate(aTag);
+ if (!addr || addr.kind !== 34550) return undefined;
+ return addr;
}
diff --git a/src/lib/nostrEvents.ts b/src/lib/nostrEvents.ts
index 64604fc4..b3c97ec3 100644
--- a/src/lib/nostrEvents.ts
+++ b/src/lib/nostrEvents.ts
@@ -78,3 +78,21 @@ function getParentEventTag(event: NostrEvent): string[] | undefined {
// Deprecated positional scheme: last non-mention e-tag is the reply target
return eTags[eTags.length - 1];
}
+
+/**
+ * Parse a Nostr `a`-tag coordinate string (`kind:pubkey:identifier`) into its
+ * constituent parts. Returns `undefined` if the format is invalid.
+ *
+ * Handles d-tags that contain colons by joining everything after the second
+ * colon back together.
+ */
+export function parseATagCoordinate(aTag: string): { kind: number; pubkey: string; identifier: string } | undefined {
+ const parts = aTag.split(':');
+ if (parts.length < 3) return undefined;
+ const kind = parseInt(parts[0], 10);
+ if (isNaN(kind) || kind < 0) return undefined;
+ const pubkey = parts[1];
+ if (!pubkey) return undefined;
+ const identifier = parts.slice(2).join(':');
+ return { kind, pubkey, identifier };
+}
diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx
index 79cc0e09..c6222daa 100644
--- a/src/pages/PostDetailPage.tsx
+++ b/src/pages/PostDetailPage.tsx
@@ -39,7 +39,7 @@ import {
const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m => ({ default: m.CustomNipCard })));
import { FileMetadataContent } from "@/components/FileMetadataContent";
import { FollowPackContent } from "@/components/FollowPackContent";
-import { GoalContent } from "@/components/GoalContent";
+import { GoalCard } from "@/components/GoalCard";
import { FollowPackDetailContent } from "@/components/FollowPackDetailContent";
import { FoundLogContent } from "@/components/FoundLogContent";
import { GeocacheContent } from "@/components/GeocacheContent";
@@ -421,7 +421,6 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) {
);
}
-
/** NoteCard + NIP-22 comments section for kind 10008/30008 profile badges detail page. */
function ProfileBadgesDetailView({ event }: { event: NostrEvent }) {
const { muteItems } = useMuteList();
@@ -2143,7 +2142,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
) : isLetter ? (
) : isZapGoal ? (
-
+
) : isVine ||
isPoll ||
isGeocache ||