feat(events): crowdfunding funding goals via zaps

CreateEventPage gains an optional 'Funding Goal (sats)' field that publishes a
'goal' tag. EventDetailPage shows a FundingGoalCard with live raised/target
progress, backer count and a Fund button — funded peer-to-peer via NIP-57 zaps
(no escrow).
This commit is contained in:
SkateSpots 2026-09-26 17:24:42 +07:00
parent 38c99c1777
commit b6c33c8a9b
3 changed files with 115 additions and 0 deletions

View file

@ -0,0 +1,79 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ZapDialog } from '@/components/ZapDialog';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { cn } from '@/lib/utils';
import { Zap } from 'lucide-react';
import type { Event } from 'nostr-tools';
/**
* Crowdfunding progress for an event that declares a `goal` tag (sats).
* "Raised" is the live total of NIP-57 zaps to the event, so backers fund it
* peer-to-peer — no escrow, no platform in the middle.
*/
export function FundingGoalCard({
event,
goalSats,
className,
}: {
event: Event;
goalSats: number;
className?: string;
}) {
const { webln, activeNWC } = useWallet();
const { totalSats, zapCount, isLoading } = useZaps(event, webln, activeNWC);
const pct = goalSats > 0 ? Math.min(100, Math.round((totalSats / goalSats) * 100)) : 0;
const remaining = Math.max(0, goalSats - totalSats);
const reached = remaining === 0;
return (
<Card className={cn('border-2 border-[#ccff00]/40 bg-black/90', className)}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[#ccff00]">
<Zap className="h-5 w-5" />
Funding Goal
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-baseline justify-between gap-2">
<span className="text-2xl font-black text-white">
{isLoading ? '…' : totalSats.toLocaleString()}
<span className="ml-1 text-sm font-bold text-gray-400">
/ {goalSats.toLocaleString()} sats
</span>
</span>
<span className="text-sm font-bold text-[#ccff00]">{pct}%</span>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-gradient-to-r from-[#ccff00] to-[#00ff88] transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-xs text-gray-400">
{reached ? (
<>🎉 Goal reached! Funded by {zapCount.toLocaleString()} backer{zapCount === 1 ? '' : 's'}.</>
) : (
<>
<span className="font-bold text-white">{remaining.toLocaleString()}</span> sats to go ·{' '}
{zapCount.toLocaleString()} backer{zapCount === 1 ? '' : 's'}
</>
)}
</p>
<ZapDialog target={event}>
<button
type="button"
className="flex w-full items-center justify-center gap-2 rounded-md bg-[#ccff00] py-2 text-sm font-bold uppercase tracking-wide text-black transition-colors hover:bg-[#ccff00]/90"
>
<Zap className="h-4 w-4" />
{reached ? 'Keep supporting' : 'Fund this event'}
</button>
</ZapDialog>
</CardContent>
</Card>
);
}

View file

@ -112,6 +112,8 @@ export default function CreateEventPage() {
// Paid event / entry fee
const [entryFee, setEntryFee] = useState('');
const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0';
// Optional crowdfunding goal in sats (NIP-57 zaps fund the event).
const [fundingGoal, setFundingGoal] = useState('');
// Optional sponsors (logo strip on the event page).
const [sponsors, setSponsors] = useState<EventSponsor[]>([]);
@ -366,6 +368,12 @@ export default function CreateEventPage() {
tags.push(['t', 'paid']);
}
// Optional crowdfunding goal (sats) — shown as a funding progress bar and
// funded peer-to-peer via zaps (no escrow).
if (fundingGoal.trim() && Number(fundingGoal) > 0) {
tags.push(['goal', String(Math.floor(Number(fundingGoal)))]);
}
// Featured listing (optional, paid)
if (isFeatured && featuredUntilSeconds) {
tags.push(['featured', String(featuredUntilSeconds)]);
@ -1016,6 +1024,25 @@ export default function CreateEventPage() {
/>
</div>
{/* Funding goal (optional) — community crowdfunding via zaps */}
<div className="space-y-2">
<Label htmlFor="fundingGoal" className="flex items-center gap-2">
<Zap className="h-4 w-4 text-[#ccff00]" />
Funding Goal
<span className="text-xs text-gray-500 font-normal">(optional — sats to crowdfund via zaps)</span>
</Label>
<Input
id="fundingGoal"
type="number"
min="0"
inputMode="numeric"
placeholder="e.g. 50000"
value={fundingGoal}
onChange={(e) => setFundingGoal(e.target.value)}
className="bg-black/50 border-white/20"
/>
</div>
{/* Sponsors (optional) */}
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.02] p-4">
<div className="flex items-center justify-between gap-2">

View file

@ -19,6 +19,7 @@ import { EventSponsors } from '@/components/EventSponsors';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { useSkateEvent } from '@/hooks/useSkateEvents';
import { FundingGoalCard } from '@/components/FundingGoalCard';
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { useEventUpdates } from '@/hooks/useEventUpdates';
import { useAuthor } from '@/hooks/useAuthor';
@ -147,6 +148,9 @@ export default function EventDetailPage() {
// NIP-52 RSVPs for this event (counts + attendee avatars).
const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined);
// Optional crowdfunding goal (sats) set by the host as a `goal` tag.
const goalSats = Number(event?.event.tags.find(([t]) => t === 'goal')?.[1] ?? 0) || 0;
// Host updates → detect if the host cancelled or rescheduled, so we can warn
// attendees loudly instead of letting them show up to a dead jam.
const { data: eventUpdates } = useEventUpdates(event ? eventCoord(event) : undefined);
@ -532,6 +536,11 @@ export default function EventDetailPage() {
</CardContent>
</Card>
{/* Crowdfunding goal (only when the host set one) */}
{goalSats > 0 && (
<FundingGoalCard event={event.event} goalSats={goalSats} />
)}
{/* RSVP / Attendance */}
<Card>
<CardHeader className="pb-3">