diff --git a/src/components/crew/CrewHero.tsx b/src/components/crew/CrewHero.tsx new file mode 100644 index 0000000..2edd488 --- /dev/null +++ b/src/components/crew/CrewHero.tsx @@ -0,0 +1,252 @@ +import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { + Users, + MapPin, + Calendar, + ShieldCheck, + Sparkles, + HardHat, + Camera, + GraduationCap, + Palette, + Music, + Dumbbell, + BadgeCheck, +} from 'lucide-react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { CrewActivityPulse } from '@/components/CrewBadge'; +import { CrewNip05Badge } from '@/components/CrewNip05Badge'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import { crewAccent, crewBadge, type CrewActivity } from '@/lib/crewHelpers'; +import { CREW_FOCUS_LABELS, CREW_VIBE_LABELS, type CrewFocus, type SkateCrew } from '@/lib/types'; +import { cn } from '@/lib/utils'; + +const FOCUS_ICON: Record = { + mixed: Sparkles, + builder: HardHat, + photo: Camera, + coaching: GraduationCap, + art: Palette, + music: Music, + wellness: Dumbbell, +}; + +const REP_STYLES: Record = { + og: { chip: 'bg-amber-500/20 text-amber-200 border-amber-400/40', glow: 'shadow-amber-500/30' }, + verified: { chip: 'bg-teal-500/20 text-teal-200 border-teal-400/40', glow: 'shadow-teal-500/30' }, + established: { chip: 'bg-fuchsia-500/20 text-fuchsia-200 border-fuchsia-400/40', glow: 'shadow-fuchsia-500/30' }, + crew: { chip: 'bg-white/10 text-gray-200 border-white/20', glow: 'shadow-white/10' }, +}; + +/** + * Immersive, team-page-style hero for a crew. + * + * - Full-bleed banner from `crew.image` (or a generated accent gradient when + * none is set), overlaid with the crew's deterministic accent colour so no two + * crews look alike. + * - Big team name, reputation crown, live activity pulse, focus + verification. + * - The crew's logo sits over the banner like a real club crest. + * + * Actions (invite/edit/join) are rendered by the parent via `actions` so the + * hero stays purely presentational. + */ +export function CrewHero({ + crew, + activity, + confirmedCount, + actions, +}: { + crew: SkateCrew; + activity?: CrewActivity; + confirmedCount?: number; + actions?: React.ReactNode; +}) { + const accent = crewAccent(crew); + const founder = useAuthor(crew.pubkey); + const founderName = founder.data?.metadata?.name ?? genUserName(crew.pubkey); + const founderPic = founder.data?.metadata?.picture; + const npub = nip19.npubEncode(crew.pubkey); + + const rep = crewBadge(crew, confirmedCount); + const repStyle = REP_STYLES[rep.key] ?? REP_STYLES.crew; + const FocusIcon = FOCUS_ICON[crew.focus]; + + return ( +
+ {/* ── Banner ── */} +
+ {crew.image ? ( + {crew.name} + ) : ( +
+ )} + + {/* Accent wash + bottom fade so text is always legible */} +
+
+ + {/* Top-right floating rep + activity */} +
+ + {rep.emoji} {rep.label} + + {activity && activity.level !== 'dormant' && ( + + + + )} +
+
+ + {/* ── Identity row (logo overlaps the banner) ── */} +
+
+
+ + + + {crew.name.slice(0, 2).toUpperCase()} + + + {/* member count crest */} + + + {crew.members.length} + +
+ + {/* Focus tag floats to the right on desktop */} +
+ + + {CREW_FOCUS_LABELS[crew.focus]} + +
+
+ + {/* Name + meta */} +
+
+

+ {crew.name} +

+ +
+ +
+ {crew.location && ( + + + {crew.location} + + )} + {crew.founded && ( + + Est. {crew.founded} + + )} + {/* Team leader inline */} + + + + {founderName.slice(0, 2).toUpperCase()} + + + + Led by {founderName} + + +
+ + {/* Mobile focus tag */} +
+ + + {CREW_FOCUS_LABELS[crew.focus]} + +
+ + {/* Vibes */} + {crew.vibes.length > 0 && ( +
+ {crew.vibes.slice(0, 6).map((v) => ( + + {CREW_VIBE_LABELS[v] || v} + + ))} +
+ )} + + {/* Bio */} + {crew.bio && ( +

+ {crew.bio} +

+ )} + + {/* Actions */} + {actions &&
{actions}
} +
+
+
+ ); +} + +/** Tiny verified tick used elsewhere; re-exported so callers don't re-import lucide. */ +export { BadgeCheck as CrewVerifiedTick }; diff --git a/src/components/crew/CrewLeadership.tsx b/src/components/crew/CrewLeadership.tsx new file mode 100644 index 0000000..f76d8bf --- /dev/null +++ b/src/components/crew/CrewLeadership.tsx @@ -0,0 +1,163 @@ +import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { Crown, ShieldCheck, MessageSquare, Check } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { genUserName } from '@/lib/genUserName'; +import { crewAccent } from '@/lib/crewHelpers'; +import type { SkateCrew } from '@/lib/types'; +import { cn } from '@/lib/utils'; + +/** + * A single leader ("Team Leader" for the founder, "Captain" for admins). + * Big, personable, links to the profile + a quick message action. + */ +function LeaderCard({ + pubkey, + role, + accentHex, + confirmed, +}: { + pubkey: string; + role: 'leader' | 'captain'; + accentHex: string; + confirmed?: boolean; +}) { + const author = useAuthor(pubkey); + const meta = author.data?.metadata; + const name = meta?.name ?? genUserName(pubkey); + const pic = meta?.picture; + const npub = nip19.npubEncode(pubkey); + const { user } = useCurrentUser(); + const isSelf = user?.pubkey === pubkey; + + const isLeader = role === 'leader'; + const RoleIcon = isLeader ? Crown : ShieldCheck; + const roleLabel = isLeader ? 'Team Leader' : 'Captain'; + + return ( +
+ +
+ + + + {name.slice(0, 2).toUpperCase()} + + +
+ + +
+ + + {roleLabel} + + +

+ {name} +

+ + {confirmed && !isLeader && ( +

+ Verified member +

+ )} +
+ + {/* Quick DM (not for yourself) */} + {!isSelf && ( + + + + )} +
+ ); +} + +/** + * "The Leadership" — the human hierarchy of a crew. + * + * - **Team Leader**: the founder (author of the crew event). The one who + * ultimately controls the roster on Nostr. + * - **Captains**: delegated admins who help run the crew (invite, moderate). + * + * This makes the crew read like a real team, and lays the groundwork for + * dedicated coordinator roles (MLS / local coordinator) that will sync with + * Lab profiles. + */ +export function CrewLeadership({ + crew, + confirmedAdmins, +}: { + crew: SkateCrew; + /** Admin pubkeys whose membership is confirmed (for the verified tick). */ + confirmedAdmins?: Set; +}) { + const accent = crewAccent(crew); + // Admins are always a subset of members; founder is never listed as an admin card. + const captains = crew.admins.filter((pk) => pk !== crew.pubkey); + + return ( + + + + + Leadership + + + + + + {captains.length > 0 && ( + <> +

+ {captains.length} Captain{captains.length === 1 ? '' : 's'} +

+ {captains.map((pk) => ( + + ))} + + )} + +

+ The Team Leader runs the roster; Captains help invite, host jams and keep the crew tidy. +

+
+
+ ); +} diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 25b1b75..af71a19 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -12,13 +12,14 @@ 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 { CrewHero } from '@/components/crew/CrewHero'; +import { CrewLeadership } from '@/components/crew/CrewLeadership'; +import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity'; 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'; @@ -48,7 +49,7 @@ import { useCrewFeed } from '@/hooks/useCrewFeed'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { genUserName } from '@/lib/genUserName'; -import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS, SHOW_CREW_TREASURY } from '@/lib/types'; +import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SHOW_CREW_TREASURY } from '@/lib/types'; import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; import NotFound from './NotFound'; @@ -190,6 +191,17 @@ export default function CrewDetailPage() { ])) : []; + // 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; + if (isLoading) { return ( @@ -236,107 +248,52 @@ export default function CrewDetailPage() { /> )} - {/* Hero */} - - -
- {/* Top: avatar + title/meta. Actions move below on mobile so the - title never gets squeezed by the button column. */} -
- - - - {crew.name.slice(0, 2).toUpperCase()} - - -
-
-

- {crew.name} -

- - Crew - - - -
-
- {crew.location && ( - - - {crew.location} - - )} - {crew.founded && ( - - Since {crew.founded} - - )} -
-
-
- - {/* Actions: full-width wrap under the header, comfortable tap targets */} - {(canManage || isFounder || (isAdmin && !isFounder) || role === 'member' || role === 'none') && ( -
- {canManage && ( - + {canManage && } + {isFounder && ( + <> + + + + - )} - {isFounder && ( - <> - - - - - - )} - {isAdmin && !isFounder && ( - - You're an admin - - )} - {role === 'member' && ( - - Member - - )} - {role === 'none' && ( - - )} -
- )} -
- - {crew.bio && ( -

{crew.bio}

- )} - - {crew.vibes.length > 0 && ( -
- {crew.vibes.map((v) => ( - - {CREW_VIBE_LABELS[v] || v} + + )} + {isAdmin && !isFounder && ( + + You're a captain - ))} -
- )} -
-
+ )} + {role === 'member' && ( + + Member + + )} + {role === 'none' && ( + + )} + + ) : null + } + /> {/* Open call — looking for filmers/editors/members (Lab pipeline) */} @@ -418,8 +375,11 @@ export default function CrewDetailPage() { - {/* Sidebar — ordered: who → support → band → connections → founder */} + {/* Sidebar — ordered: leadership → who → support → band → connections → founder */}
+ {/* Leadership — team leader (founder) + captains (admins) */} + + {/* Members (with per-member visibility respected) */} {hasMembers && (
- {/* Header */} -
-
-

- Crews -

-

Your map-anchored skate family — the people you skate with, not who you hire (that's The Lab).

- {/* Social-proof stat line */} - {!isLoading && crews && crews.length > 0 && ( -

- {crews.length} crew{crews.length === 1 ? '' : 's'} - {cityCount > 0 && ` · ${cityCount} ${cityCount === 1 ? 'city' : 'cities'}`} + {/* Hero header band */} +

+
+
+
+

+ Crews +

+

+ Your map-anchored skate family — the people you skate{' '} + with, not who you hire (that's The Lab).

- )} + {/* Social-proof stat line */} + {!isLoading && crews && crews.length > 0 && ( +
+ + + {crews.length} crew{crews.length === 1 ? '' : 's'} + + {cityCount > 0 && ( + + + {cityCount} {cityCount === 1 ? 'city' : 'cities'} + + )} +
+ )} +
+ + +
- - -
{/* Search */}