SkateSpots/src/pages/CrewDetailPage.tsx
SkateSpots 087f490acc refactor(layout): hoist Layout into the router so the chrome mounts once
- AppRouter wraps every route (except /amber-callback and the 404) in ONE
  <Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
  navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
  a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
  fragments instead of <Layout> (structure-preserving), imports dropped.

Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
2026-09-27 13:41:42 +07:00

571 lines
27 KiB
TypeScript

import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { PageError } from '@/components/ErrorState';
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 { CrewHero } from '@/components/crew/CrewHero';
import { CrewLeadership } from '@/components/crew/CrewLeadership';
import { CrewSponsorBanner } from '@/components/crew/CrewSponsorBanner';
import { CrewCoordinatorCard } from '@/components/crew/CrewCoordinatorCard';
import { CrewVideoSection } from '@/components/crew/CrewVideoSection';
import { crewAccent } from '@/lib/crewHelpers';
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
import { useCrewChatUnread } from '@/hooks/useCrewChatUnread';
import { CrewChat } from '@/components/CrewChat';
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
import { CREW_AGENT_AVATAR } from '@/lib/crewAgent';
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
import { CrewLookingForCard } from '@/components/CrewLookingFor';
import { CrewRequestToJoin } from '@/components/CrewRequestToJoin';
import { useCrewEvents } from '@/hooks/useCrewEvents';
import { EventCard } from '@/components/EventCard';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { MemberVisibilityControl } from '@/components/MemberVisibilityControl';
import { LinkedLabCard } from '@/components/CrossLinkCards';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { useCrewRole } from '@/hooks/useCrewRole';
import { useHideCrewPost } from '@/hooks/useCrewModeration';
import { CrewInviteDialog } from '@/components/CrewInviteDialog';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { useToast } from '@/hooks/useToast';
import { useCrew } from '@/hooks/useCrews';
import { useCrewFeed } from '@/hooks/useCrewFeed';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, SHOW_CREW_TREASURY, CREW_LOOKING_FOR_LABELS, type CrewRole } from '@/lib/types';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import NotFound from './NotFound';
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, Music } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
export default function CrewDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: crew, isLoading, error, refetch } = useCrew(identifier || '', pubkey);
const author = useAuthor(crew?.pubkey);
const { user } = useCurrentUser();
const { isFounder, isAdmin, role, canManage } = useCrewRole(crew);
const { toast } = useToast();
const [agentOpen, setAgentOpen] = useState(false);
const [activeTab, setActiveTab] = useState('events');
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 } = 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 { mutateAsync: hidePost } = useHideCrewPost(crewCoord);
// Unread dot on the Chat tab — cheap, member-only, no background decryption.
const isCrewMember = !!user && !!crew &&
(user.pubkey === crew.pubkey || crew.members.includes(user.pubkey));
const { hasUnread: chatHasUnread } = useCrewChatUnread(crew ?? undefined, isCrewMember);
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),
]))
: [];
// Confirmed count + confirmed-admin set (fuel the hero's reputation crown and
// the leadership "verified" ticks).
const confirmedCount = (memberStatuses ?? []).filter((m) => m.status === 'confirmed').length;
const confirmedAdmins = new Set(
(memberStatuses ?? []).filter((m) => m.status === 'confirmed').map((m) => m.pubkey),
);
// Live activity for the hero pulse (reuse the batched hook with a single crew).
const { data: activityMap } = useCrewsActivity(crew ? [crew] : undefined);
const activity = crew ? activityMap?.get(crewCoordinate(crew)) : undefined;
// Skate roles (filmer / photographer / …) grouped by member pubkey.
const rolesByPubkey = (() => {
const map = new Map<string, CrewRole[]>();
for (const { pubkey: pk, role } of crew?.crewRoles ?? []) {
const list = map.get(pk) ?? [];
list.push(role);
map.set(pk, list);
}
return map;
})();
if (isLoading) {
return (
<>
<div className="container px-4 py-8 max-w-4xl">
<Skeleton className="h-6 w-32 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-6 min-w-0">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-64 w-full" />
</div>
<Skeleton className="h-64 w-full" />
</div>
</div>
</>
);
}
if (error) return <PageError onRetry={() => refetch()} />;
if (!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 (
<>
<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 grid-cols-1 md:grid-cols-3 gap-6">
{/* Main */}
<div className="md:col-span-2 space-y-6 min-w-0">
{/* 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"
/>
)}
{/* Immersive team-page hero */}
<CrewHero
crew={crew}
activity={activity}
confirmedCount={confirmedCount}
actions={
(canManage || isFounder || (isAdmin && !isFounder) || role === 'member' || role === 'none') ? (
<>
{canManage && <CrewInviteDialog crew={crew} isFounder={isFounder} />}
{isFounder && (
<>
<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"
/>
</>
)}
{isAdmin && !isFounder && (
<Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">
<ShieldCheck className="h-3 w-3" /> You're a captain
</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 crew={crew} />}
</>
) : null
}
/>
{/* Sponsor — a local shop backing the crew */}
{crew.sponsorRef && <CrewSponsorBanner sponsorRef={crew.sponsorRef} />}
{/* The Part — crew's headline video / montage (YouTube or Instagram) */}
{crew.videoUrl && (
<CrewVideoSection
videoUrl={crew.videoUrl}
crewName={crew.name}
accentHex={crewAccent(crew).hex}
/>
)}
{/* Open call — looking for filmers/editors/members (Lab pipeline) */}
<CrewLookingForCard lookingFor={crew.lookingFor} />
{/* Crew treasury — hidden for now (kept in code, re-enable by flipping SHOW_CREW_TREASURY) */}
{SHOW_CREW_TREASURY && crew.goalRef && <CrewTreasuryCard goalId={crew.goalRef} />}
{/* 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 overflow-hidden rounded-2xl bg-teal-500/15">
<Avatar className="h-12 w-12 rounded-2xl">
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" className="object-cover" />
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black">SQ</AvatarFallback>
</Avatar>
</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>
)}
{/* Activity — one clean tabbed surface instead of three stacked cards */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 bg-black/60 border border-white/10">
<TabsTrigger value="events" className="gap-1.5 data-[state=active]:bg-[#ccff00]/20 data-[state=active]:text-[#ccff00]">
<Calendar className="h-4 w-4" /> Events
{crewEvents && crewEvents.length > 0 && (
<span className="ml-0.5 rounded-full bg-white/10 px-1.5 text-[10px]">{crewEvents.length}</span>
)}
</TabsTrigger>
<TabsTrigger value="chat" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<MessageSquare className="h-4 w-4" /> Chat
{chatHasUnread && activeTab !== 'chat' && (
<span
className="ml-0.5 h-2 w-2 rounded-full bg-teal-400 shadow-[0_0_6px_rgba(45,212,191,0.9)]"
aria-label="New messages"
title="New messages"
/>
)}
</TabsTrigger>
</TabsList>
{/* Events */}
<TabsContent value="events" className="mt-4 space-y-3">
{canManage && (
<Link to={`/create-event?crew=${crew.pubkey}:${crew.identifier}`} className="block">
<Button size="sm" className="w-full 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>
)}
{crewEvents && crewEvents.length > 0 ? (
crewEvents.slice(0, 8).map((ev) => <EventCard key={ev.id} event={ev} compact />)
) : (
<Card className="border-dashed border-white/10 bg-black/40">
<CardContent className="py-10 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.
</CardContent>
</Card>
)}
</TabsContent>
{/* Chat */}
<TabsContent value="chat" className="mt-4">
<CrewChat
crew={crew}
crewName={crew.name}
founderPubkey={crew.pubkey}
members={crew.members}
admins={crew.admins}
crewCoord={crewCoord}
canModerate={canManage}
/>
</TabsContent>
</Tabs>
</div>
{/* Sidebar — ordered: leadership → who → support → band → connections → founder */}
<div className="space-y-6 min-w-0">
{/* Leadership — team leader (founder) + captains (admins) */}
<CrewLeadership crew={crew} confirmedAdmins={confirmedAdmins} rolesByPubkey={rolesByPubkey} />
{/* Private encrypted chat coordinator (Cordn / MLS) */}
<CrewCoordinatorCard crew={crew} isFounder={isFounder} />
{/* Members (with per-member visibility respected) */}
{hasMembers && (
<CollectiveMembers
kind={SKATE_CREW_KIND}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
claimedMembers={crew.members}
admins={crew.admins}
rolesByPubkey={rolesByPubkey}
accentText="text-teal-400"
accentBorder="border-teal-500/40"
/>
)}
{/* A confirmed member can change their own visibility here */}
{hasMembers && (
<MemberVisibilityControl
kind={SKATE_CREW_KIND}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
collectiveName={crew.name}
claimedMembers={crew.members}
accentBg="bg-teal-500 hover:bg-teal-600"
/>
)}
{/* Support the crew */}
<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">
<Zap className="h-5 w-5" /> Support
</CardTitle>
</CardHeader>
<CardContent className="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" />
)}
</CardContent>
</Card>
{/* Crew Band — many crews double as a local band. Shown as its own card,
with a "Book for a gig" action that deep-links to create a Concert
event hosted by this crew (connects bands → the Events calendar). */}
{crew.bandName && (
<Card className="bg-gradient-to-br from-purple-500/10 to-black/90 border-2 border-purple-500/40">
<CardHeader>
<CardTitle className="text-lg text-purple-300 uppercase font-black flex items-center gap-2">
<Music className="h-5 w-5 text-purple-400" /> Crew Band
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl bg-purple-500/20 text-2xl">
🎸
</div>
<div className="min-w-0">
<p className="truncate text-base font-black text-white">{crew.bandName}</p>
<p className="text-xs text-purple-300/70">Live music · skate & sound</p>
</div>
</div>
{crew.bandUrl && sanitizeUrl(crew.bandUrl) && (
<a
href={sanitizeUrl(crew.bandUrl)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 rounded-lg border border-purple-500/30 bg-purple-500/10 px-3 py-2 text-sm font-bold text-purple-200 transition-colors hover:bg-purple-500/20"
title={`Listen: ${urlDomain(crew.bandUrl)}`}
>
<Music className="h-4 w-4" /> Listen to the tunes
</a>
)}
<Link
to={`/create-event?crew=${crew.pubkey}:${crew.identifier}&type=concert&title=${encodeURIComponent(`${crew.bandName} live`)}`}
className="flex items-center justify-center gap-2 rounded-lg bg-purple-500 px-3 py-2 text-sm font-black uppercase text-white transition-colors hover:bg-purple-600"
>
<Calendar className="h-4 w-4" /> Set up a gig
</Link>
{user && user.pubkey !== crew.pubkey && (
<Link
to={`/messages/${npub}?message=${encodeURIComponent(`Hey ${crew.name} — interested in booking ${crew.bandName} for a gig. Are you around to talk dates?`)}`}
className="flex items-center justify-center gap-2 rounded-lg border border-purple-500/30 bg-purple-500/10 px-3 py-2 text-sm font-bold text-purple-200 transition-colors hover:bg-purple-500/20"
>
<MessageSquare className="h-4 w-4" /> Message about a gig
</Link>
)}
<p className="text-center text-[11px] leading-snug text-gray-500">
<strong className="text-gray-400">Set up a gig</strong> adds a music event to the
calendar hosted by this crew. <strong className="text-gray-400">Message</strong> opens
an encrypted DM to the crew to sort dates &amp; details.
</p>
</CardContent>
</Card>
)}
{/* Linked Lab profiles — hire this crew. Pass the crew's own coord
so each row shows a "Verified" badge when the Lab profile links back. */}
<LinkedLabCard labRefs={crew.labRefs} selfCoord={crewCoord} />
{/* 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>
)}
{/* Founder & links (bottom — provenance / external) */}
<Card className="bg-black/90 border border-white/10">
<CardHeader>
<CardTitle className="text-sm text-gray-400 uppercase font-black tracking-widest">
Founder & Links
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<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>
);
})}
</CardContent>
</Card>
</div>
</div>
</div>
{/* Crew Agent chat panel — seeded with the crew's OWN data so Sasquatch
can answer crew-specific questions (its events, roster, open call,
vibe, band, home spots) directly, not just re-skin the global bot. */}
{crew.agentEnabled && (
<CrewAgentPanel
open={agentOpen}
onOpenChange={setAgentOpen}
ctx={{
crewName: crew.name,
location: crew.location,
city: crew.location,
bio: crew.bio,
vibes: crew.vibes,
founderName,
memberCount: 1 + crew.members.length,
lookingFor: crew.lookingFor.map((s) => CREW_LOOKING_FOR_LABELS[s]?.label ?? s),
bandName: crew.bandName,
homeSpotCount: crew.spotRefs.length,
website: crew.website,
instagram: crew.instagram,
events: (crewEvents ?? []).map((e) => ({
title: e.title,
start: e.start,
location: e.location,
path: `/event/${e.pubkey}/${e.identifier}`,
})),
recentPosts: (feed ?? []).slice(0, 5).map((ev) => ({
author: genUserName(ev.pubkey),
text: ev.content ?? '',
createdAt: ev.created_at,
})),
}}
/>
)}
</>
);
}