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
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue