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:
parent
38c99c1777
commit
b6c33c8a9b
3 changed files with 115 additions and 0 deletions
79
src/components/FundingGoalCard.tsx
Normal file
79
src/components/FundingGoalCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -112,6 +112,8 @@ export default function CreateEventPage() {
|
||||||
// Paid event / entry fee
|
// Paid event / entry fee
|
||||||
const [entryFee, setEntryFee] = useState('');
|
const [entryFee, setEntryFee] = useState('');
|
||||||
const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0';
|
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).
|
// Optional sponsors (logo strip on the event page).
|
||||||
const [sponsors, setSponsors] = useState<EventSponsor[]>([]);
|
const [sponsors, setSponsors] = useState<EventSponsor[]>([]);
|
||||||
|
|
@ -366,6 +368,12 @@ export default function CreateEventPage() {
|
||||||
tags.push(['t', 'paid']);
|
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)
|
// Featured listing (optional, paid)
|
||||||
if (isFeatured && featuredUntilSeconds) {
|
if (isFeatured && featuredUntilSeconds) {
|
||||||
tags.push(['featured', String(featuredUntilSeconds)]);
|
tags.push(['featured', String(featuredUntilSeconds)]);
|
||||||
|
|
@ -1016,6 +1024,25 @@ export default function CreateEventPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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) */}
|
{/* Sponsors (optional) */}
|
||||||
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.02] p-4">
|
<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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { EventSponsors } from '@/components/EventSponsors';
|
||||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
||||||
|
import { FundingGoalCard } from '@/components/FundingGoalCard';
|
||||||
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||||
import { useEventUpdates } from '@/hooks/useEventUpdates';
|
import { useEventUpdates } from '@/hooks/useEventUpdates';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
|
@ -147,6 +148,9 @@ export default function EventDetailPage() {
|
||||||
// NIP-52 RSVPs for this event (counts + attendee avatars).
|
// NIP-52 RSVPs for this event (counts + attendee avatars).
|
||||||
const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined);
|
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
|
// 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.
|
// attendees loudly instead of letting them show up to a dead jam.
|
||||||
const { data: eventUpdates } = useEventUpdates(event ? eventCoord(event) : undefined);
|
const { data: eventUpdates } = useEventUpdates(event ? eventCoord(event) : undefined);
|
||||||
|
|
@ -532,6 +536,11 @@ export default function EventDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Crowdfunding goal (only when the host set one) */}
|
||||||
|
{goalSats > 0 && (
|
||||||
|
<FundingGoalCard event={event.event} goalSats={goalSats} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* RSVP / Attendance */}
|
{/* RSVP / Attendance */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue