Refactor goal components: deduplicate GoalCard/GoalContent, fix staleness
- Unify GoalCard and GoalContent into a single component with variant prop - Extract useGoalDisplay hook for shared display logic (author, progress, community link, deadline, image) - Add useNow(60s) interval so deadline labels refresh automatically - Add generic parseATagCoordinate utility to nostrEvents.ts - Replace DOM-mutating image onError with React state - Remove dead isGoalFunded export and redundant created_at in publish - Delete GoalContent.tsx (-144 net lines)
This commit is contained in:
@@ -103,7 +103,6 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
|
||||
kind: ZAP_GOAL_KIND,
|
||||
content: title.trim(),
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
// Refresh the fundraising tab and the community activity feed
|
||||
|
||||
+215
-186
@@ -1,224 +1,253 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } 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 type { NostrEvent } 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 { ZapDialog } from '@/components/ZapDialog';
|
||||
import { useAddrEvent } from '@/hooks/useEvent';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useGoalProgress } from '@/hooks/useGoalProgress';
|
||||
import { useGoalDisplay } from '@/hooks/useGoalDisplay';
|
||||
import { useOpenPost } from '@/hooks/useOpenPost';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { formatSats, isGoalExpired, parseCommunityATag, type ParsedGoal } from '@/lib/goalUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { formatSats, parseGoalEvent, type ParsedGoal } from '@/lib/goalUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GoalCardProps {
|
||||
event: NostrEvent;
|
||||
goal: ParsedGoal;
|
||||
/** Pre-parsed goal. When omitted the component parses the event itself. */
|
||||
goal?: ParsedGoal;
|
||||
/**
|
||||
* `card` — standalone clickable card with border, stats row, and zap button
|
||||
* (community fundraising tab).
|
||||
* `compact` — inline content block for NoteCard / PostDetailPage.
|
||||
*/
|
||||
variant?: 'card' | 'compact';
|
||||
}
|
||||
|
||||
export function GoalCard({ event, goal }: GoalCardProps) {
|
||||
const expired = isGoalExpired(goal);
|
||||
const { currentSats, percentage, contributors, zapCount, isLoading: progressLoading } =
|
||||
useGoalProgress(event.id, goal.amountMsat, goal.closedAt);
|
||||
/**
|
||||
* Renders a NIP-75 zap goal.
|
||||
*
|
||||
* - `variant="card"` (default): standalone card used in the community
|
||||
* fundraising tab. Includes a clickable wrapper, stats row, and Contribute button.
|
||||
* - `variant="compact"`: inline renderer used inside NoteCard feeds and
|
||||
* PostDetailPage.
|
||||
*/
|
||||
export function GoalCard({ event, goal: goalProp, variant = 'card' }: GoalCardProps) {
|
||||
const goal = useMemo(() => goalProp ?? parseGoalEvent(event), [goalProp, event]);
|
||||
if (!goal) return null;
|
||||
return <GoalCardInner event={event} goal={goal} variant={variant} />;
|
||||
}
|
||||
|
||||
const funded = percentage >= 100;
|
||||
// ── Inner renderer (hooks are safe here) ──────────────────────────────────────
|
||||
|
||||
// Navigation to post detail
|
||||
function GoalCardInner({
|
||||
event,
|
||||
goal,
|
||||
variant,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
goal: ParsedGoal;
|
||||
variant: 'card' | 'compact';
|
||||
}) {
|
||||
const isCard = variant === 'card';
|
||||
const d = useGoalDisplay(event, goal);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
// Navigation (card variant only)
|
||||
const postPath = useMemo(
|
||||
() => `/${nip19.neventEncode({ id: event.id, author: event.pubkey })}`,
|
||||
[event.id, event.pubkey],
|
||||
);
|
||||
const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost(postPath);
|
||||
|
||||
// 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;
|
||||
// ── Shared sub-sections ───────────────────────────────────────────────────
|
||||
|
||||
// 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 imageSection = d.image && !imgError && (
|
||||
<div className={cn('overflow-hidden', isCard ? 'aspect-[21/9] w-full' : '-mx-4 aspect-[21/9]')}>
|
||||
<img
|
||||
src={d.image}
|
||||
alt={goal.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="rounded-xl border border-border overflow-hidden bg-card transition-all duration-300 hover:shadow-md hover:border-primary/20 cursor-pointer"
|
||||
onClick={openPost}
|
||||
onAuxClick={auxOpenPost}
|
||||
>
|
||||
{/* Goal image */}
|
||||
{image && (
|
||||
<div className="aspect-[21/9] w-full overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={goal.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Header: title + community link / status badge */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Target className="size-4 text-primary shrink-0" />
|
||||
<h3 className="font-semibold text-[15px] leading-tight truncate">{goal.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{communityUrl && communityName && (
|
||||
<Link
|
||||
to={communityUrl}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Users className="size-3" />
|
||||
{communityName}
|
||||
</Link>
|
||||
)}
|
||||
{funded ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
|
||||
Funded
|
||||
</Badge>
|
||||
) : expired ? (
|
||||
<Badge variant="secondary">Ended</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{goal.summary && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{goal.summary}</p>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-2">
|
||||
<Progress
|
||||
value={percentage}
|
||||
className={cn('h-3', funded && '[&>div]:bg-emerald-500')}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">
|
||||
{progressLoading ? (
|
||||
<Skeleton className="h-4 w-20 inline-block" />
|
||||
) : (
|
||||
<>{formatSats(currentSats)} sats</>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
of {formatSats(goal.amountSats)} sats ({percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{zapCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="size-3" />
|
||||
{zapCount} zap{zapCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{contributors.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="size-3" />
|
||||
{contributors.length} contributor{contributors.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{deadlineLabel && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{deadlineLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recipient info — who is receiving the zaps */}
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-3 py-2.5">
|
||||
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-9 ring-2 ring-background">
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
const headerSection = (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Target className="size-4 text-primary shrink-0" />
|
||||
<h3 className={cn('font-semibold text-[15px] leading-tight', isCard && 'truncate')}>
|
||||
{goal.title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{d.communityUrl && d.communityName && (
|
||||
<Link
|
||||
to={d.communityUrl}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Users className="size-3" />
|
||||
{d.communityName}
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-muted-foreground">Receiving zaps</p>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="text-sm font-medium truncate block hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{displayName}
|
||||
</Link>
|
||||
{lightningAddress && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={lightningAddress}>
|
||||
<Zap className="size-3 inline-block mr-0.5 -mt-0.5" />
|
||||
{lightningAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{d.funded ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
|
||||
Funded
|
||||
</Badge>
|
||||
) : d.expired ? (
|
||||
<Badge variant="secondary">Ended</Badge>
|
||||
) : !isCard && d.deadlineLabel ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
{d.deadlineLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Zap button */}
|
||||
{!expired && canZap(metadata) && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
)}
|
||||
>
|
||||
<Zap className="size-4" />
|
||||
Contribute
|
||||
</button>
|
||||
</ZapDialog>
|
||||
</div>
|
||||
const summarySection = goal.summary && (
|
||||
<p className={cn('text-sm text-muted-foreground leading-relaxed', !isCard && 'line-clamp-2')}>
|
||||
{goal.summary}
|
||||
</p>
|
||||
);
|
||||
|
||||
const progressSection = (
|
||||
<div className={cn('space-y-2', !isCard && 'space-y-1.5')}>
|
||||
<Progress
|
||||
value={d.percentage}
|
||||
className={cn(isCard ? 'h-3' : 'h-2.5', d.funded && '[&>div]:bg-emerald-500')}
|
||||
/>
|
||||
<div className={cn('flex items-center justify-between', isCard ? 'text-sm' : 'text-xs text-muted-foreground')}>
|
||||
<span className={cn('font-medium', !isCard && 'text-foreground')}>
|
||||
{d.progressLoading ? (
|
||||
<Skeleton className={cn('inline-block', isCard ? 'h-4 w-20' : 'h-3.5 w-16')} />
|
||||
) : (
|
||||
<>{formatSats(d.currentSats)} sats</>
|
||||
)}
|
||||
</span>
|
||||
<span className={isCard ? 'text-muted-foreground' : undefined}>
|
||||
of {formatSats(goal.amountSats)} sats ({d.percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const recipientSection = (
|
||||
<div className={cn(
|
||||
'flex items-center rounded-lg bg-muted/50 px-3',
|
||||
isCard ? 'gap-3 py-2.5' : 'gap-2.5 py-2',
|
||||
)}>
|
||||
<Link to={d.profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={d.avatarShape} className={cn('ring-2 ring-background', isCard ? 'size-9' : 'size-8')}>
|
||||
<AvatarImage src={d.metadata?.picture} />
|
||||
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
|
||||
{d.displayName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-muted-foreground">Receiving zaps</p>
|
||||
<Link
|
||||
to={d.profileUrl}
|
||||
className="text-sm font-medium truncate block hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{d.displayName}
|
||||
</Link>
|
||||
{d.lightningAddress && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={d.lightningAddress}>
|
||||
<Zap className="size-3 inline-block mr-0.5 -mt-0.5" />
|
||||
{d.lightningAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Card variant ──────────────────────────────────────────────────────────
|
||||
|
||||
if (isCard) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-border overflow-hidden bg-card transition-all duration-300 hover:shadow-md hover:border-primary/20 cursor-pointer"
|
||||
onClick={openPost}
|
||||
onAuxClick={auxOpenPost}
|
||||
>
|
||||
{imageSection}
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{headerSection}
|
||||
{summarySection}
|
||||
{progressSection}
|
||||
|
||||
{/* Stats row (card only) */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{d.zapCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="size-3" />
|
||||
{d.zapCount} zap{d.zapCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{d.contributors.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="size-3" />
|
||||
{d.contributors.length} contributor{d.contributors.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{d.deadlineLabel && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{d.deadlineLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recipientSection}
|
||||
|
||||
{/* Zap button (card only) */}
|
||||
{!d.expired && canZap(d.metadata) && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
)}
|
||||
>
|
||||
<Zap className="size-4" />
|
||||
Contribute
|
||||
</button>
|
||||
</ZapDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Compact variant ───────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
{imageSection}
|
||||
{headerSection}
|
||||
{summarySection}
|
||||
{progressSection}
|
||||
{recipientSection}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skeleton ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Skeleton placeholder for loading goal cards. */
|
||||
export function GoalCardSkeleton() {
|
||||
return (
|
||||
|
||||
@@ -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 <GoalContentInner event={event} goal={goal} />;
|
||||
}
|
||||
|
||||
function GoalContentInner({
|
||||
event,
|
||||
goal,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
goal: NonNullable<ReturnType<typeof parseGoalEvent>>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* Goal image */}
|
||||
{image && (
|
||||
<div className="-mx-4 aspect-[21/9] overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={goal.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title + community link / status */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Target className="size-4 text-primary shrink-0" />
|
||||
<h3 className="font-semibold text-[15px] leading-tight">{goal.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{communityUrl && communityName && (
|
||||
<Link
|
||||
to={communityUrl}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Users className="size-3" />
|
||||
{communityName}
|
||||
</Link>
|
||||
)}
|
||||
{funded ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
|
||||
Funded
|
||||
</Badge>
|
||||
) : expired ? (
|
||||
<Badge variant="secondary">Ended</Badge>
|
||||
) : deadlineLabel ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
{deadlineLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{goal.summary && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">{goal.summary}</p>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-1.5">
|
||||
<Progress
|
||||
value={percentage}
|
||||
className={cn('h-2.5', funded && '[&>div]:bg-emerald-500')}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{progressLoading ? (
|
||||
<Skeleton className="h-3.5 w-16 inline-block" />
|
||||
) : (
|
||||
<>{formatSats(currentSats)} sats</>
|
||||
)}
|
||||
</span>
|
||||
<span>of {formatSats(goal.amountSats)} sats ({percentage}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipient — who is receiving the zaps */}
|
||||
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2">
|
||||
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-8 ring-2 ring-background">
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-xs text-muted-foreground">Receiving zaps</p>
|
||||
</div>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="text-sm font-medium truncate block hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{displayName}
|
||||
</Link>
|
||||
{lightningAddress && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={lightningAddress}>
|
||||
<Zap className="size-3 inline-block mr-0.5 -mt-0.5" />
|
||||
{lightningAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<CommunityContent event={event} />
|
||||
) : isZapGoal ? (
|
||||
<GoalContent event={event} />
|
||||
<GoalCard event={event} />
|
||||
) : isVoiceMessage ? (
|
||||
<VoiceMessagePlayer event={event} />
|
||||
) : isCalendarEvent ? (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+6
-14
@@ -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:<pubkey>:<d-tag>`) 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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<EncryptedLetterContent event={event} />
|
||||
) : isZapGoal ? (
|
||||
<GoalContent event={event} />
|
||||
<GoalCard event={event} variant="compact" />
|
||||
) : isVine ||
|
||||
isPoll ||
|
||||
isGeocache ||
|
||||
|
||||
Reference in New Issue
Block a user