Crews page
- "Near me" geolocation toggle with haversine distance sort + distance chips
- Crew count + cities social-proof stat header
- Deterministic per-crew vibe-colour accents (no more wall of teal)
- Activity pulse ("🔥 active this week") + OG/Verified/Established rep badges
- Rich empty state ("Claim your local scene") instead of dead "No crews yet"
Crew synergies
- Crew treasury: transparent, non-custodial NIP-75 zap goal (kind 9041) with
live progress bar tallied from zap receipts; funds settle to founder's own
Lightning wallet — no shared wallet or new wallet settings needed
- Crew-hosted events: host a jam/comp as the crew (event a-tag → crew coord),
"Hosted by [crew]" on event pages, Crew Events list on the crew page
- Crew ↔ Lab hiring pipeline: "Open Call / looking-for" flags (filmer, editor,
coach, builder, members) surfaced on the crew page
- Request to join: encrypted NIP-17 DM to founder + admins (no founder-push
bottleneck, no public join event)
- Reverse links: "Crews that skate here" on spot pages
Contacts (0xchat-style + Armada-style privacy)
- New /contacts page with Crew-mates / Follows / Recent DMs / Saved tabs
- Per-contact quick actions: message, zap, view profile, star
- Private encrypted contacts list (NIP-51 kind 30000, NIP-44 encrypted to self)
- Added to burger menu + desktop More menu (gated to logged-in users)
Docs: NIP.md updated with crew treasury, crew-hosted events, looking-for tags,
and the private encrypted contacts list.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
116 lines
4.4 KiB
TypeScript
116 lines
4.4 KiB
TypeScript
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { ZapButton } from '@/components/ZapButton';
|
|
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
|
import { cn } from '@/lib/utils';
|
|
import { Target, Zap, Users, CheckCircle2 } from 'lucide-react';
|
|
|
|
/**
|
|
* Crew treasury — a transparent, zap-funded NIP-75 goal. Contributions are
|
|
* ordinary zaps to the goal event that settle to the founder's own Lightning
|
|
* wallet; SkateSpots never custodies funds. Progress is tallied from public
|
|
* zap receipts, so anyone can audit it.
|
|
*/
|
|
export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
|
|
const { data: goal, isLoading } = useCrewGoal(goalId);
|
|
|
|
if (!goalId) return null;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Card className="bg-black/90 border-2 border-orange-500/30">
|
|
<CardHeader>
|
|
<Skeleton className="h-5 w-40" />
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<Skeleton className="h-3 w-full" />
|
|
<Skeleton className="h-3 w-2/3" />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (!goal) return null;
|
|
|
|
const pct = Math.min(100, goal.percent);
|
|
const reached = goal.percent >= 100;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const closed = goal.closedAt ? goal.closedAt < now : false;
|
|
const daysLeft = goal.closedAt ? Math.ceil((goal.closedAt - now) / 86400) : null;
|
|
|
|
return (
|
|
<Card
|
|
className={cn(
|
|
'overflow-hidden border-2 bg-black/90',
|
|
reached ? 'border-green-500/50' : 'border-orange-500/40',
|
|
)}
|
|
>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
|
<Target className={cn('h-5 w-5', reached ? 'text-green-400' : 'text-orange-400')} />
|
|
Crew Treasury
|
|
</CardTitle>
|
|
{goal.summary && <p className="text-xs text-gray-400">{goal.summary}</p>}
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{goal.description && (
|
|
<p className="whitespace-pre-wrap text-sm text-gray-300">{goal.description}</p>
|
|
)}
|
|
|
|
{/* Progress bar */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-end justify-between text-sm">
|
|
<span className={cn('font-black', reached ? 'text-green-400' : 'text-orange-400')}>
|
|
{goal.raisedSats.toLocaleString()} sats
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
of {goal.targetSats.toLocaleString()} goal
|
|
</span>
|
|
</div>
|
|
<div className="h-3 w-full overflow-hidden rounded-full bg-white/5">
|
|
<div
|
|
className={cn(
|
|
'h-full rounded-full transition-all duration-700',
|
|
reached
|
|
? 'bg-gradient-to-r from-green-500 to-emerald-400'
|
|
: 'bg-gradient-to-r from-orange-500 to-amber-400',
|
|
)}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between text-[11px] text-gray-500">
|
|
<span className="flex items-center gap-1">
|
|
{reached ? (
|
|
<span className="flex items-center gap-1 font-bold text-green-400">
|
|
<CheckCircle2 className="h-3 w-3" /> Goal reached!
|
|
</span>
|
|
) : (
|
|
<span className="font-bold text-orange-300">{goal.percent}% funded</span>
|
|
)}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Users className="h-3 w-3" />
|
|
{goal.contributions} contribution{goal.contributions === 1 ? '' : 's'}
|
|
{daysLeft !== null && daysLeft > 0 && ` · ${daysLeft}d left`}
|
|
{closed && ' · closed'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contribute — zaps the goal event; settles to the founder's wallet. */}
|
|
{!closed && (
|
|
<div className="space-y-2">
|
|
<ZapButton
|
|
target={goal.event}
|
|
className="w-full justify-center bg-orange-500 text-black hover:bg-orange-600"
|
|
/>
|
|
<p className="flex items-center justify-center gap-1 text-center text-[10px] text-gray-600">
|
|
<Zap className="h-2.5 w-2.5" />
|
|
Non-custodial · zaps go straight to the crew's Lightning wallet
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|