Redesign crew detail + crews page (Phase 2: visual)

- New CrewHero: immersive full-bleed banner with per-crew accent glow, club-crest
  logo overlapping the banner, member-count crest, big team name with neon shadow,
  reputation crown + live activity pulse, focus/verification chips, "Led by"
  team-leader inline, vibes and bio. Actions are passed in from the page.
- New CrewLeadership card: frames the founder as "Team Leader" and admins as
  "Captains" with role crests, verified ticks and quick-DM — establishing the
  crew hierarchy and laying groundwork for coordinator roles.
- Rewire CrewDetailPage to use CrewHero + CrewLeadership; wire live activity via
  useCrewsActivity and confirmed-member/admin sets. Removed the old flat hero
  card and now-unused imports.
- Upgrade CrewsPage header into a proper hero band with gradient, glow and
  stat pills.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-02 23:07:27 +07:00
parent e8c1fbc71a
commit 8a384c79d2
4 changed files with 518 additions and 126 deletions

View file

@ -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<CrewFocus, typeof Users> = {
mixed: Sparkles,
builder: HardHat,
photo: Camera,
coaching: GraduationCap,
art: Palette,
music: Music,
wellness: Dumbbell,
};
const REP_STYLES: Record<string, { chip: string; glow: string }> = {
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 (
<section className="relative overflow-hidden rounded-3xl border border-white/10 bg-black">
{/* ── Banner ── */}
<div className="relative h-40 w-full sm:h-56">
{crew.image ? (
<img
src={crew.image}
alt={crew.name}
className="h-full w-full object-cover"
style={{ filter: 'saturate(1.05)' }}
/>
) : (
<div
className="h-full w-full"
style={{
background: `radial-gradient(120% 140% at 20% 0%, ${accent.hex}55 0%, ${accent.hex}18 40%, #000 100%)`,
}}
/>
)}
{/* Accent wash + bottom fade so text is always legible */}
<div
className="absolute inset-0"
style={{
background: `linear-gradient(to top, #000 6%, ${accent.hex}14 55%, transparent 100%)`,
}}
/>
<div
aria-hidden
className="absolute -left-16 -top-16 h-56 w-56 rounded-full blur-3xl opacity-40"
style={{ background: accent.hex }}
/>
{/* Top-right floating rep + activity */}
<div className="absolute right-3 top-3 flex flex-wrap items-center justify-end gap-1.5">
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-black uppercase tracking-wide backdrop-blur-md shadow-lg',
repStyle.chip,
repStyle.glow,
)}
title={rep.label}
>
<span className="leading-none">{rep.emoji}</span> {rep.label}
</span>
{activity && activity.level !== 'dormant' && (
<span className="inline-flex items-center rounded-full border border-white/10 bg-black/50 px-2.5 py-1 backdrop-blur-md">
<CrewActivityPulse activity={activity} showDormant />
</span>
)}
</div>
</div>
{/* ── Identity row (logo overlaps the banner) ── */}
<div className="relative px-4 pb-5 sm:px-6">
<div className="-mt-12 flex items-end gap-4 sm:-mt-14">
<div
className="relative shrink-0 rounded-3xl p-[3px] shadow-2xl"
style={{ background: `linear-gradient(135deg, ${accent.hex}, ${accent.hex}44)` }}
>
<Avatar className="h-24 w-24 rounded-[calc(1.5rem-3px)] border-4 border-black sm:h-28 sm:w-28">
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
<AvatarFallback
className="rounded-[calc(1.5rem-3px)] text-3xl font-black"
style={{ color: accent.hex, background: `${accent.hex}1f` }}
>
{crew.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* member count crest */}
<span
className="absolute -bottom-2 -right-2 inline-flex items-center gap-1 rounded-full border-2 border-black px-2 py-0.5 text-xs font-black text-black shadow-lg"
style={{ background: accent.hex }}
>
<Users className="h-3 w-3" />
{crew.members.length}
</span>
</div>
{/* Focus tag floats to the right on desktop */}
<div className="hidden flex-1 items-center justify-end pb-2 sm:flex">
<span
className="inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-black uppercase tracking-wide"
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}14` }}
>
<FocusIcon className="h-3.5 w-3.5" />
{CREW_FOCUS_LABELS[crew.focus]}
</span>
</div>
</div>
{/* Name + meta */}
<div className="mt-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h1
className="text-3xl font-black uppercase leading-none text-white sm:text-4xl"
style={{
fontFamily: 'Impact, "Arial Black", sans-serif',
textShadow: `0 0 24px ${accent.hex}55`,
}}
>
{crew.name}
</h1>
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} />
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm text-gray-400">
{crew.location && (
<span className="flex min-w-0 items-center gap-1.5">
<MapPin className="h-4 w-4 shrink-0" style={{ color: accent.hex }} />
<span className="truncate">{crew.location}</span>
</span>
)}
{crew.founded && (
<span className="flex items-center gap-1.5">
<Calendar className="h-4 w-4 shrink-0" /> Est. {crew.founded}
</span>
)}
{/* Team leader inline */}
<Link
to={`/${npub}`}
className="group flex items-center gap-1.5 transition-colors hover:text-white"
title={`Team leader: ${founderName}`}
>
<Avatar className="h-5 w-5 border border-white/20">
<AvatarImage src={founderPic} alt={founderName} />
<AvatarFallback className="text-[8px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="inline-flex items-center gap-1">
<ShieldCheck className="h-3.5 w-3.5" style={{ color: accent.hex }} />
Led by <span className="font-bold text-gray-200 group-hover:text-white">{founderName}</span>
</span>
</Link>
</div>
{/* Mobile focus tag */}
<div className="mt-3 flex flex-wrap items-center gap-1.5 sm:hidden">
<span
className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-black uppercase tracking-wide"
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}14` }}
>
<FocusIcon className="h-3 w-3" />
{CREW_FOCUS_LABELS[crew.focus]}
</span>
</div>
{/* Vibes */}
{crew.vibes.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{crew.vibes.slice(0, 6).map((v) => (
<span
key={v}
className="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-0.5 text-xs font-bold text-gray-300"
>
{CREW_VIBE_LABELS[v] || v}
</span>
))}
</div>
)}
{/* Bio */}
{crew.bio && (
<p className="mt-4 max-w-2xl whitespace-pre-wrap break-words text-[15px] leading-relaxed text-gray-300">
{crew.bio}
</p>
)}
{/* Actions */}
{actions && <div className="mt-5 flex flex-wrap items-center gap-2">{actions}</div>}
</div>
</div>
</section>
);
}
/** Tiny verified tick used elsewhere; re-exported so callers don't re-import lucide. */
export { BadgeCheck as CrewVerifiedTick };

View file

@ -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 (
<div
className={cn(
'group relative flex items-center gap-3 overflow-hidden rounded-2xl border p-3 transition-all',
isLeader ? 'border-transparent' : 'border-white/10 bg-white/[0.03] hover:bg-white/[0.06]',
)}
style={
isLeader
? {
background: `linear-gradient(135deg, ${accentHex}1f, transparent 70%)`,
borderColor: `${accentHex}55`,
}
: undefined
}
>
<Link to={`/${npub}`} className="relative shrink-0">
<div
className="rounded-2xl p-[2px]"
style={{ background: isLeader ? `linear-gradient(135deg, ${accentHex}, ${accentHex}55)` : 'transparent' }}
>
<Avatar className={cn('rounded-[calc(1rem-2px)]', isLeader ? 'h-14 w-14' : 'h-11 w-11')}>
<AvatarImage src={pic} alt={name} className="object-cover" />
<AvatarFallback
className="rounded-[calc(1rem-2px)] font-black"
style={{ color: accentHex, background: `${accentHex}1f` }}
>
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
</Link>
<div className="min-w-0 flex-1">
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-black uppercase tracking-wider"
style={{ color: accentHex, background: `${accentHex}1f` }}
>
<RoleIcon className="h-3 w-3" />
{roleLabel}
</span>
<Link to={`/${npub}`} className="mt-1 block">
<p className={cn('truncate font-black text-white', isLeader ? 'text-lg' : 'text-sm')}>
{name}
</p>
</Link>
{confirmed && !isLeader && (
<p className="flex items-center gap-1 text-[11px] font-bold text-green-500">
<Check className="h-3 w-3" /> Verified member
</p>
)}
</div>
{/* Quick DM (not for yourself) */}
{!isSelf && (
<Link
to={`/messages/${npub}`}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 text-gray-400 transition-colors hover:border-white/20 hover:text-white"
title={`Message ${name}`}
>
<MessageSquare className="h-4 w-4" />
</Link>
)}
</div>
);
}
/**
* "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<string>;
}) {
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 (
<Card className="border-2 bg-black/90" style={{ borderColor: `${accent.hex}40` }}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Crown className="h-5 w-5" style={{ color: accent.hex }} />
Leadership
</CardTitle>
</CardHeader>
<CardContent className="space-y-2.5">
<LeaderCard pubkey={crew.pubkey} role="leader" accentHex={accent.hex} />
{captains.length > 0 && (
<>
<p className="px-1 pt-1 text-[11px] font-black uppercase tracking-widest text-gray-500">
{captains.length} Captain{captains.length === 1 ? '' : 's'}
</p>
{captains.map((pk) => (
<LeaderCard
key={pk}
pubkey={pk}
role="captain"
accentHex={accent.hex}
confirmed={confirmedAdmins?.has(pk)}
/>
))}
</>
)}
<p className="pt-1 text-[11px] leading-relaxed text-gray-500">
The Team Leader runs the roster; Captains help invite, host jams and keep the crew tidy.
</p>
</CardContent>
</Card>
);
}

View file

@ -12,13 +12,14 @@ import { ZapButton } from '@/components/ZapButton';
import { ZapCrewDialog } from '@/components/ZapCrewDialog'; import { ZapCrewDialog } from '@/components/ZapCrewDialog';
import { ClinkPayButton } from '@/components/ClinkPayButton'; import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers'; 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 { CrewChat } from '@/components/CrewChat';
import { CrewAgentPanel } from '@/components/CrewAgentPanel'; import { CrewAgentPanel } from '@/components/CrewAgentPanel';
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard'; import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
import { CrewLookingForCard } from '@/components/CrewLookingFor'; import { CrewLookingForCard } from '@/components/CrewLookingFor';
import { CrewRequestToJoin } from '@/components/CrewRequestToJoin'; import { CrewRequestToJoin } from '@/components/CrewRequestToJoin';
import { CrewRepBadge } from '@/components/CrewBadge';
import { useCrewEvents } from '@/hooks/useCrewEvents'; import { useCrewEvents } from '@/hooks/useCrewEvents';
import { EventCard } from '@/components/EventCard'; import { EventCard } from '@/components/EventCard';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard'; import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
@ -48,7 +49,7 @@ import { useCrewFeed } from '@/hooks/useCrewFeed';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName'; 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 { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import NotFound from './NotFound'; 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) { if (isLoading) {
return ( return (
<Layout> <Layout>
@ -236,107 +248,52 @@ export default function CrewDetailPage() {
/> />
)} )}
{/* Hero */} {/* Immersive team-page hero */}
<Card className="bg-black/90 border-2 border-teal-500/40 overflow-hidden"> <CrewHero
<CardContent className="pt-6"> crew={crew}
<div className="mb-4"> activity={activity}
{/* Top: avatar + title/meta. Actions move below on mobile so the confirmedCount={confirmedCount}
title never gets squeezed by the button column. */} actions={
<div className="flex items-start gap-3 sm:gap-4"> (canManage || isFounder || (isAdmin && !isFounder) || role === 'member' || role === 'none') ? (
<Avatar className="h-14 w-14 sm:h-20 sm:w-20 rounded-2xl border-2 border-teal-500/40 flex-shrink-0"> <>
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" /> {canManage && <CrewInviteDialog crew={crew} isFounder={isFounder} />}
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black text-lg sm:text-xl"> {isFounder && (
{crew.name.slice(0, 2).toUpperCase()} <>
</AvatarFallback> <Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
</Avatar> <Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<div className="flex-1 min-w-0"> <Edit className="h-3.5 w-3.5" /> Edit
<div className="flex items-center gap-2 mb-2 flex-wrap"> </Button>
<h1 className="text-2xl sm:text-3xl font-black uppercase text-teal-400 break-words" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}> </Link>
{crew.name} <DeleteEntityDialog
</h1> event={crew.event}
<Badge variant="outline" className="gap-1 text-teal-400 border-teal-500/40"> entityLabel="crew"
<Users className="h-3 w-3" /> Crew entityName={crew.name}
</Badge> redirectTo="/crews"
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} /> triggerText="Disband"
<CrewRepBadge crew={crew} />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-400">
{crew.location && (
<span className="flex items-center gap-1 min-w-0">
<MapPin className="h-4 w-4 text-teal-400 flex-shrink-0" />
<span className="truncate">{crew.location}</span>
</span>
)}
{crew.founded && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4 flex-shrink-0" /> Since {crew.founded}
</span>
)}
</div>
</div>
</div>
{/* Actions: full-width wrap under the header, comfortable tap targets */}
{(canManage || isFounder || (isAdmin && !isFounder) || role === 'member' || role === 'none') && (
<div className="mt-4 flex flex-wrap items-center gap-2">
{canManage && (
<CrewInviteDialog
crew={crew}
isFounder={isFounder}
/> />
)} </>
{isFounder && ( )}
<> {isAdmin && !isFounder && (
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}> <Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10"> <ShieldCheck className="h-3 w-3" /> You're a captain
<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 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> </Badge>
))} )}
</div> {role === 'member' && (
)} <Badge variant="outline" className="gap-1 border-teal-500/30 text-teal-400">
</CardContent> <Users className="h-3 w-3" /> Member
</Card> </Badge>
)}
{role === 'none' && (
<CrewRequestToJoin
crewName={crew.name}
founderPubkey={crew.pubkey}
admins={crew.admins}
/>
)}
</>
) : null
}
/>
{/* Open call — looking for filmers/editors/members (Lab pipeline) */} {/* Open call — looking for filmers/editors/members (Lab pipeline) */}
<CrewLookingForCard lookingFor={crew.lookingFor} /> <CrewLookingForCard lookingFor={crew.lookingFor} />
@ -418,8 +375,11 @@ export default function CrewDetailPage() {
</Tabs> </Tabs>
</div> </div>
{/* Sidebar — ordered: who → support → band → connections → founder */} {/* Sidebar — ordered: leadership → who → support → band → connections → founder */}
<div className="space-y-6"> <div className="space-y-6">
{/* Leadership — team leader (founder) + captains (admins) */}
<CrewLeadership crew={crew} confirmedAdmins={confirmedAdmins} />
{/* Members (with per-member visibility respected) */} {/* Members (with per-member visibility respected) */}
{hasMembers && ( {hasMembers && (
<CollectiveMembers <CollectiveMembers

View file

@ -197,31 +197,48 @@ export default function CrewsPage() {
return ( return (
<Layout> <Layout>
<div className="container max-w-3xl px-4 py-6"> <div className="container max-w-3xl px-4 py-6">
{/* Header */} {/* Hero header band */}
<div className="mb-4 flex items-start justify-between gap-3"> <div className="relative mb-5 overflow-hidden rounded-3xl border border-teal-500/20 bg-gradient-to-br from-teal-500/[0.12] via-black to-black p-5 sm:p-6">
<div className="min-w-0"> <div
<h1 aria-hidden
className="flex items-center gap-2 text-2xl font-black uppercase text-teal-400 sm:text-3xl" className="pointer-events-none absolute -right-16 -top-20 h-56 w-56 rounded-full bg-teal-500/25 blur-3xl"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }} />
> <div className="relative flex items-start justify-between gap-3">
<Users className="h-6 w-6 shrink-0 sm:h-7 sm:w-7" /> Crews <div className="min-w-0">
</h1> <h1
<p className="mt-1 text-sm text-gray-400">Your map-anchored skate family — the people you skate <span className="text-teal-400/80 font-semibold">with</span>, not who you hire (that's The Lab).</p> className="flex items-center gap-2 text-3xl font-black uppercase leading-none text-white sm:text-4xl"
{/* Social-proof stat line */} style={{ fontFamily: 'Impact, "Arial Black", sans-serif', textShadow: '0 0 24px rgba(20,184,166,0.4)' }}
{!isLoading && crews && crews.length > 0 && ( >
<p className="mt-1 text-xs font-bold text-teal-500/80"> <Users className="h-7 w-7 shrink-0 text-teal-400 sm:h-8 sm:w-8" /> Crews
{crews.length} crew{crews.length === 1 ? '' : 's'} </h1>
{cityCount > 0 && ` · ${cityCount} ${cityCount === 1 ? 'city' : 'cities'}`} <p className="mt-2 max-w-md text-sm leading-relaxed text-gray-400">
Your map-anchored skate family — the people you skate{' '}
<span className="font-semibold text-teal-400/90">with</span>, not who you hire (that's The Lab).
</p> </p>
)} {/* Social-proof stat line */}
{!isLoading && crews && crews.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-teal-500/30 bg-teal-500/10 px-3 py-1 text-xs font-black text-teal-300">
<Users className="h-3.5 w-3.5" />
{crews.length} crew{crews.length === 1 ? '' : 's'}
</span>
{cityCount > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-bold text-gray-300">
<MapPin className="h-3.5 w-3.5 text-teal-400" />
{cityCount} {cityCount === 1 ? 'city' : 'cities'}
</span>
)}
</div>
)}
</div>
<Link to="/create-crew" className="shrink-0">
<Button className="gap-2 bg-teal-500 text-sm font-black uppercase text-black shadow-lg shadow-teal-500/30 hover:bg-teal-600">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create Crew</span>
<span className="sm:hidden">Create</span>
</Button>
</Link>
</div> </div>
<Link to="/create-crew" className="shrink-0">
<Button className="gap-2 bg-teal-500 text-sm font-black uppercase text-black hover:bg-teal-600">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create Crew</span>
<span className="sm:hidden">Create</span>
</Button>
</Link>
</div> </div>
{/* Search */} {/* Search */}