SkateSpots/src/pages/CrewDetailPage.tsx
shakespeare.diy 6848a72869 Crews: proximity discovery, activity/rep signals, treasury, hiring pipeline, crew-hosted events + reverse links; add Contacts page
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>
2026-07-25 12:38:45 +07:00

584 lines
26 KiB
TypeScript

import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ZapButton } from '@/components/ZapButton';
import { ZapCrewDialog } from '@/components/ZapCrewDialog';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers';
import { CrewNip05Badge } from '@/components/CrewNip05Badge';
import { CrewChat } from '@/components/CrewChat';
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
import { CrewLookingForCard } from '@/components/CrewLookingFor';
import { CrewRequestToJoin } from '@/components/CrewRequestToJoin';
import { CrewRepBadge } from '@/components/CrewBadge';
import { useCrewEvents } from '@/hooks/useCrewEvents';
import { EventCard } from '@/components/EventCard';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { LinkedLabCard } from '@/components/CrossLinkCards';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { useCrewRole } from '@/hooks/useCrewRole';
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
import { CrewInviteDialog } from '@/components/CrewInviteDialog';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { useToast } from '@/hooks/useToast';
import { NoteContent } from '@/components/NoteContent';
import { useCrew } from '@/hooks/useCrews';
import { useCrewFeed } from '@/hooks/useCrewFeed';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } from '@/lib/types';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import NotFound from './NotFound';
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, EyeOff } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
function FeedItem({
event,
canModerate,
onHide,
isHiding,
}: {
event: NostrEvent;
canModerate?: boolean;
onHide?: (event: NostrEvent) => void;
isHiding?: boolean;
}) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(event.pubkey);
const npub = nip19.npubEncode(event.pubkey);
return (
<div className="rounded-xl border border-white/5 bg-black/40 p-3">
<div className="mb-1.5 flex items-center gap-2">
<Link to={`/${npub}`}>
<Avatar className="h-7 w-7">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
<Link to={`/${npub}`} className="text-sm font-bold text-gray-200 hover:text-white truncate">
{name}
</Link>
<span className="ml-auto text-[11px] text-gray-500">
{new Date(event.created_at * 1000).toLocaleDateString()}
</span>
{canModerate && onHide && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-gray-500 hover:text-red-400"
disabled={isHiding}
aria-label="Hide this post"
title="Hide from crew feed (admin)"
>
<EyeOff className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hide this post?</AlertDialogTitle>
<AlertDialogDescription>
As a crew moderator you can hide this post from the crew feed for everyone.
The original note stays on Nostr — this only removes it from this crew's feed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onHide(event)}
className="bg-red-600 hover:bg-red-700"
>
Hide post
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<div className="whitespace-pre-wrap break-words text-sm text-gray-300">
<NoteContent event={event} className="text-sm" />
</div>
</div>
);
}
export default function CrewDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: crew, isLoading, error } = useCrew(identifier || '', pubkey);
const author = useAuthor(crew?.pubkey);
const { isFounder, isAdmin, role, canManage } = useCrewRole(crew);
const { toast } = useToast();
const [agentOpen, setAgentOpen] = useState(false);
useSeoMeta({
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
description: crew?.bio || 'A skate crew on SkateSpots.',
});
// Feed = founder + listed members
const feedAuthors = crew ? [crew.pubkey, ...crew.members] : [];
const { data: feed, isLoading: feedLoading } = useCrewFeed(feedAuthors);
// Crew-hosted events (jams, comps, meetups).
const { data: crewEvents } = useCrewEvents(crew?.pubkey, crew?.identifier);
// Moderation — founder + admins can hide feed posts (NIP-32 labels).
const crewCoord = crew ? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}` : '';
const moderators = crew ? Array.from(new Set([crew.pubkey, ...crew.admins])) : [];
const { data: hiddenIds } = useCrewModeration({
crewCoord,
moderators,
enabled: !!crew,
});
const { mutateAsync: hidePost, isPending: isHiding } = useHideCrewPost(crewCoord);
const handleHidePost = async (ev: NostrEvent) => {
try {
await hidePost({ postId: ev.id, postAuthor: ev.pubkey });
toast({ title: 'Post hidden', description: 'Removed from the crew feed for everyone.' });
} catch (err) {
toast({
title: 'Failed to hide post',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
// Confirmed members (for "Zap the Crew" — only pay people who vouched in)
const { data: memberStatuses } = useCollectiveMembers({
kind: SKATE_CREW_KIND,
founderPubkey: crew?.pubkey ?? '',
identifier: crew?.identifier ?? '',
claimedMembers: crew?.members ?? [],
enabled: !!crew && (crew.members.length ?? 0) > 0,
});
// Recipients: founder + confirmed members (dedup, founder always included)
const crewZapRecipients = crew
? Array.from(new Set([
crew.pubkey,
...(memberStatuses ?? []).filter((m) => m.status === 'confirmed').map((m) => m.pubkey),
]))
: [];
if (isLoading) {
return (
<Layout>
<div className="container px-4 py-8 max-w-4xl">
<Skeleton className="h-6 w-32 mb-6" />
<div className="grid md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-64 w-full" />
</div>
<Skeleton className="h-64 w-full" />
</div>
</div>
</Layout>
);
}
if (error || !crew) return <NotFound />;
const npub = nip19.npubEncode(crew.pubkey);
const founderName = author.data?.metadata?.name ?? genUserName(crew.pubkey);
const hasMembers = crew.members.length > 0;
return (
<Layout>
<div className="container px-4 py-8 max-w-4xl">
<Link to="/crews" className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Crews
</Link>
<div className="grid md:grid-cols-3 gap-6">
{/* Main */}
<div className="md:col-span-2 space-y-6">
{/* Confirm membership prompt (listed pending members only) */}
{hasMembers && (
<ConfirmMembershipCard
kind={SKATE_CREW_KIND}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
collectiveName={crew.name}
claimedMembers={crew.members}
accentBg="bg-teal-500 hover:bg-teal-600 text-black"
/>
)}
{/* Hero */}
<Card className="bg-black/90 border-2 border-teal-500/40 overflow-hidden">
<CardContent className="pt-6">
<div className="flex items-start gap-4 mb-4">
<Avatar className="h-20 w-20 rounded-2xl border-2 border-teal-500/40">
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black text-xl">
{crew.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h1 className="text-3xl font-black uppercase text-teal-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
{crew.name}
</h1>
<Badge variant="outline" className="gap-1 text-teal-400 border-teal-500/40">
<Users className="h-3 w-3" /> Crew
</Badge>
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} />
<CrewRepBadge crew={crew} />
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
{crew.location && (
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4 text-teal-400" /> {crew.location}
</span>
)}
{crew.founded && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> Since {crew.founded}
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-2">
{canManage && (
<CrewInviteDialog
crewName={crew.name}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
isFounder={isFounder}
/>
)}
{isFounder && (
<div className="flex items-center gap-2">
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<Edit className="h-3.5 w-3.5" /> Edit
</Button>
</Link>
<DeleteEntityDialog
event={crew.event}
entityLabel="crew"
entityName={crew.name}
redirectTo="/crews"
triggerText="Disband"
/>
</div>
)}
{isAdmin && !isFounder && (
<Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">
<ShieldCheck className="h-3 w-3" /> You're an admin
</Badge>
)}
{role === 'member' && (
<Badge variant="outline" className="gap-1 border-teal-500/30 text-teal-400">
<Users className="h-3 w-3" /> Member
</Badge>
)}
{role === 'none' && (
<CrewRequestToJoin
crewName={crew.name}
founderPubkey={crew.pubkey}
admins={crew.admins}
/>
)}
</div>
</div>
{crew.bio && (
<p className="text-gray-300 whitespace-pre-wrap mb-4">{crew.bio}</p>
)}
{crew.vibes.length > 0 && (
<div className="flex flex-wrap gap-2">
{crew.vibes.map((v) => (
<Badge key={v} className="bg-teal-500/15 text-teal-300 border-teal-500/30">
{CREW_VIBE_LABELS[v] || v}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Open call — looking for filmers/editors/members (Lab pipeline) */}
<CrewLookingForCard lookingFor={crew.lookingFor} />
{/* Crew treasury — transparent, zap-funded NIP-75 goal */}
{crew.goalRef && <CrewTreasuryCard goalId={crew.goalRef} />}
{/* Crew-hosted events (jams / comps / meetups) */}
{((crewEvents && crewEvents.length > 0) || canManage) && (
<Card className="bg-black/90 border-2 border-[#ccff00]/20">
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Calendar className="h-5 w-5 text-[#ccff00]" /> Crew Events
</CardTitle>
{canManage && (
<Link to={`/create-event?crew=${crew.pubkey}:${crew.identifier}`}>
<Button size="sm" className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
<Calendar className="h-3.5 w-3.5" /> Host a jam
</Button>
</Link>
)}
</CardHeader>
<CardContent className="space-y-3">
{crewEvents && crewEvents.length > 0 ? (
crewEvents.slice(0, 5).map((ev) => (
<EventCard key={ev.id} event={ev} compact />
))
) : (
<p className="py-4 text-center text-sm text-gray-500">
No crew events yet. Host a jam, comp or meetup as the crew — it connects your
crew to the events calendar and lets people zap it.
</p>
)}
</CardContent>
</Card>
)}
{/* Crew feed */}
<Card className="bg-black/90 border-2 border-white/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<MessageSquare className="h-5 w-5 text-teal-400" /> Crew Feed
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{feedLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border border-white/5 bg-black/40 p-3 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
))
) : (() => {
// Moderators still see hidden posts (greyed via the badge on hide),
// but for everyone the hidden set is filtered out.
const visible = (feed ?? []).filter(
(ev) => canManage || !hiddenIds?.has(ev.id),
);
if (visible.length === 0) {
return (
<p className="text-sm text-gray-500 py-4 text-center">
No recent posts from the crew yet.
</p>
);
}
return visible.map((ev) => (
<div key={ev.id} className={cn(hiddenIds?.has(ev.id) && 'opacity-50')}>
{hiddenIds?.has(ev.id) && (
<p className="mb-1 flex items-center gap-1 text-[11px] font-bold uppercase text-red-400">
<EyeOff className="h-3 w-3" /> Hidden from feed
</p>
)}
<FeedItem
event={ev}
canModerate={canManage && !hiddenIds?.has(ev.id)}
onHide={handleHidePost}
isHiding={isHiding}
/>
</div>
));
})()}
</CardContent>
</Card>
{/* Crew Agent — AI guide, shown when the crew enabled it */}
{crew.agentEnabled && (
<button
type="button"
onClick={() => setAgentOpen(true)}
className="group flex w-full items-center gap-3 rounded-2xl border-2 border-teal-500/30 bg-gradient-to-br from-teal-500/10 to-transparent p-4 text-left transition-all hover:border-teal-500/60 hover:from-teal-500/15 active:scale-[0.99]"
>
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-teal-500/15 text-2xl">
🐝
</div>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 font-black text-white">
Ask Sasquatch
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
Crew Agent
</span>
</p>
<p className="text-xs text-gray-400">
Your crew's AI guide — nearby spots, events, coaches & gear, with live data.
</p>
</div>
<MessageSquare className="h-5 w-5 flex-shrink-0 text-teal-400/60 group-hover:text-teal-400" />
</button>
)}
{/* Crew Chat — private E2E-encrypted group chat for members */}
<CrewChat
crewName={crew.name}
founderPubkey={crew.pubkey}
members={crew.members}
admins={crew.admins}
crewCoord={crewCoord}
canModerate={canManage}
/>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Founder / links / support */}
<Card className="bg-black/90 border-2 border-teal-500/40">
<CardHeader>
<CardTitle className="text-lg text-teal-400 uppercase font-black flex items-center gap-2">
<Users className="h-5 w-5" /> Crew
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Link to={`/${npub}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
<Avatar className="h-6 w-6">
<AvatarImage src={author.data?.metadata?.picture} alt={founderName} />
<AvatarFallback className="text-[10px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="truncate">Started by {founderName}</span>
</Link>
{sanitizeUrl(crew.website) && (
<a href={sanitizeUrl(crew.website)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(crew.website!)}`}>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{urlDomain(crew.website!)}</span>
</a>
)}
{crew.instagram && (
<a href={`https://instagram.com/${crew.instagram.replace('@', '')}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
<Instagram className="h-4 w-4 text-teal-400" />
<span className="truncate">@{crew.instagram.replace('@', '')}</span>
</a>
)}
{crew.socials.map((s, i) => {
const url = sanitizeUrl(s);
return url ? (
<a
key={`${s}-${i}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors"
title={`Opens: ${urlDomain(s)}`}
>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{urlDomain(s)}</span>
</a>
) : (
<div
key={`${s}-${i}`}
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded"
>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{s}</span>
</div>
);
})}
<div className="pt-3 border-t border-gray-700 space-y-2">
{/* Zap the whole crew — split across founder + confirmed members */}
{crewZapRecipients.length > 1 && (
<ZapCrewDialog crewName={crew.name} recipients={crewZapRecipients}>
<Button className="w-full justify-center gap-2 bg-teal-500 hover:bg-teal-600 font-bold">
<Users className="h-4 w-4" />
<Zap className="h-4 w-4" />
Zap the Crew
</Button>
</ZapCrewDialog>
)}
<ZapButton target={crew.event} className="w-full justify-center" />
{crew.noffer && (
<ClinkPayButton noffer={crew.noffer} recipientName={crew.name} className="w-full justify-center" />
)}
</div>
</CardContent>
</Card>
{/* Members */}
{hasMembers && (
<CollectiveMembers
kind={SKATE_CREW_KIND}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
claimedMembers={crew.members}
admins={crew.admins}
accentText="text-teal-400"
accentBorder="border-teal-500/40"
/>
)}
{/* Linked Lab profiles — hire this crew */}
<LinkedLabCard labRefs={crew.labRefs} />
{/* Home spots */}
{crew.spotRefs.length > 0 && (
<Card className="bg-black/90 border-2 border-white/5">
<CardHeader>
<CardTitle className="text-lg text-white uppercase font-black flex items-center gap-2">
<MapPin className="h-5 w-5 text-teal-400" /> Home Spots
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{crew.spotRefs.map((ref) => {
const [, spotPubkey, spotD] = ref.split(':');
return (
<Link
key={ref}
to={`/spot/${spotPubkey}/${spotD}`}
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors"
>
<span className="text-base">🛹</span>
<span className="truncate">View spot</span>
</Link>
);
})}
<Link to="/" className="block text-center text-xs text-teal-400 hover:text-teal-300 pt-1">
See on the map →
</Link>
</CardContent>
</Card>
)}
</div>
</div>
</div>
{/* Crew Agent chat panel */}
{crew.agentEnabled && (
<CrewAgentPanel
open={agentOpen}
onOpenChange={setAgentOpen}
ctx={{ crewName: crew.name, location: crew.location, city: crew.location }}
/>
)}
</Layout>
);
}