import { useSeoMeta } from '@unhead/react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import { Layout } from '@/components/Layout'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Skeleton } from '@/components/ui/skeleton'; import { ZapButton } from '@/components/ZapButton'; import { ClinkPayButton } from '@/components/ClinkPayButton'; import { CollectiveMembers } from '@/components/CollectiveMembers'; import { RequestToJoinCollective } from '@/components/RequestToJoinCollective'; import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard'; import { VouchCard } from '@/components/VouchCard'; import { LinkedCrewCard } from '@/components/CrossLinkCards'; import { TrustBadge } from '@/components/TrustBadge'; import { useLabProfile } from '@/hooks/useLabProfiles'; import { useGigs } from '@/hooks/useGigs'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { isAdminPubkey } from '@/lib/admins'; import { genUserName } from '@/lib/genUserName'; import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, ARTIST_SERVICE_LABELS, ARTIST_SERVICE_DESCRIPTIONS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, GRAFFITI_STYLE_LABELS, GRAFFITI_STYLE_DESCRIPTIONS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, TATTOO_STYLE_LABELS, TATTOO_STYLE_DESCRIPTIONS, TATTOO_METHOD_LABELS, TRAINER_FOCUS_LABELS, TRAINER_FOCUS_DESCRIPTIONS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS, JUDGE_FORMAT_LABELS, JUDGE_SPECIALTY_LABELS, JUDGE_LEVEL_LABELS, SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND } from '@/lib/types'; import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl'; import { OfficialBadge } from '@/components/OfficialBadge'; import { ProBadge } from '@/components/ProBadge'; import NotFound from './NotFound'; import { GraduationCap, MapPin, ArrowLeft, MessageCircle, Mail, Phone, Globe, Instagram, Calendar, Video, Camera, Scissors, Aperture, Users, HardHat, Palette, SprayCan, Brush, Dumbbell, Music, Gavel, } from 'lucide-react'; import { nip19 } from 'nostr-tools'; function CoachDetailSkeleton() { return (
); } export default function CoachDetailPage() { const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { data: profile, isLoading, error } = useLabProfile(identifier || '', pubkey); const author = useAuthor(profile?.pubkey); const { user } = useCurrentUser(); const navigate = useNavigate(); // Gigs — only relevant for musicians. The hook is disabled for other types. const { data: gigs = [] } = useGigs( profile?.profileType === 'musician' ? profile.pubkey : undefined, profile?.profileType === 'musician' ? profile.identifier : undefined, ); const isCoach = profile?.profileType === 'coach'; const isFilmer = profile?.profileType === 'filmer'; const isEditor = profile?.profileType === 'editor'; const isPhotographer = profile?.profileType === 'photographer'; const isBuilder = profile?.profileType === 'builder'; const isArtist = profile?.profileType === 'artist'; const isGraffiti = profile?.profileType === 'graffiti'; const isTattoo = profile?.profileType === 'tattoo'; const isTrainer = profile?.profileType === 'trainer'; const isMusician = profile?.profileType === 'musician'; useSeoMeta({ title: profile ? `${profile.title} - SkateSpots` : 'Profile - SkateSpots', description: profile?.bio || 'View profile.', }); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(profile?.pubkey || ''); if (isLoading) { return ; } if (error || !profile) { return ; } const npub = nip19.npubEncode(profile.pubkey); // Per-type accent (text, border, bg, ring) — keeps the detail page on-brand const ACCENTS: Record = { coach: { text: 'text-[#ff0040]', bg: 'bg-[#ff0040]', border: 'border-[#ff0040]/30', hover: 'hover:bg-[#ff0040]/10 border-[#ff0040]' }, filmer: { text: 'text-orange-500', bg: 'bg-orange-500', border: 'border-orange-500/30', hover: 'hover:bg-orange-500/10 border-orange-500' }, photographer: { text: 'text-indigo-400', bg: 'bg-indigo-600', border: 'border-indigo-500/30', hover: 'hover:bg-indigo-500/10 border-indigo-500' }, builder: { text: 'text-amber-400', bg: 'bg-amber-500', border: 'border-amber-500/30', hover: 'hover:bg-amber-500/10 border-amber-500' }, editor: { text: 'text-purple-400', bg: 'bg-purple-600', border: 'border-purple-500/30', hover: 'hover:bg-purple-500/10 border-purple-500' }, artist: { text: 'text-fuchsia-400', bg: 'bg-fuchsia-600', border: 'border-fuchsia-500/30', hover: 'hover:bg-fuchsia-500/10 border-fuchsia-500' }, graffiti: { text: 'text-lime-400', bg: 'bg-lime-500', border: 'border-lime-500/30', hover: 'hover:bg-lime-500/10 border-lime-500' }, tattoo: { text: 'text-rose-400', bg: 'bg-rose-600', border: 'border-rose-500/30', hover: 'hover:bg-rose-500/10 border-rose-500' }, trainer: { text: 'text-emerald-400', bg: 'bg-emerald-600', border: 'border-emerald-500/30', hover: 'hover:bg-emerald-500/10 border-emerald-500' }, musician: { text: 'text-sky-400', bg: 'bg-sky-500', border: 'border-sky-500/30', hover: 'hover:bg-sky-500/10 border-sky-500' }, judge: { text: 'text-yellow-400', bg: 'bg-yellow-500', border: 'border-yellow-500/30', hover: 'hover:bg-yellow-500/10 border-yellow-500' }, }; const accent = ACCENTS[profile.profileType] ?? ACCENTS.coach; // The Lab profile is a union of craft-specific shapes. Craft-specific fields // (gear, studio, credentials, instagram, …) only exist on some members, so we // read them through a single typed accessor that returns an optional string // instead of scattering casts across the JSX. Presence is still gated by the // isCoach/isBuilder/… flags before these are shown. const pf = profile as unknown as Record; const pget = (key: string): string | undefined => { const v = pf[key]; return typeof v === 'string' && v.length > 0 ? v : undefined; }; const pbool = (key: string): boolean => pf[key] === true; const TYPE_ICONS: Record = { coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors, artist: Palette, graffiti: SprayCan, tattoo: Brush, trainer: Dumbbell, musician: Music, judge: Gavel, }; const TypeIcon = TYPE_ICONS[profile.profileType] ?? GraduationCap; // Kind for the collective coordinate (for NIP-32 membership attestations) const KINDS: Record = { coach: SKATE_COACH_KIND, filmer: SKATE_FILMER_KIND, editor: SKATE_EDITOR_KIND, photographer: SKATE_PHOTOGRAPHER_KIND, builder: SKATE_BUILDER_KIND, artist: SKATE_ARTIST_KIND, graffiti: SKATE_GRAFFITI_KIND, tattoo: SKATE_TATTOO_KIND, trainer: SKATE_TRAINER_KIND, musician: SKATE_MUSICIAN_KIND, judge: SKATE_JUDGE_KIND, }; const profileKind = KINDS[profile.profileType] ?? SKATE_COACH_KIND; const isCollective = profile.entityType === 'collective' && profile.members.length > 0; // Merged label maps per craft for chip resolution const LABELS_BY_TYPE: Record> = { coach: COACH_SKILL_LABELS, filmer: { ...FILMING_SERVICE_LABELS, ...FILMING_SKILL_LABELS }, editor: { ...EDITOR_SERVICE_LABELS, ...EDITOR_SKILL_LABELS }, photographer: { ...PHOTO_SERVICE_LABELS, ...PHOTO_CAMERA_LABELS, ...PHOTO_LENS_LABELS, ...PHOTO_LIGHTING_LABELS, ...PHOTO_SUPPORT_LABELS, ...PHOTO_STYLE_LABELS }, builder: { ...BUILDER_SERVICE_LABELS, ...BUILDER_SKILL_LABELS, ...BUILDER_SCALE_LABELS }, artist: { ...ARTIST_SERVICE_LABELS, ...ARTIST_MEDIUM_LABELS, ...ARTIST_TOOL_LABELS }, graffiti: { ...GRAFFITI_STYLE_LABELS, ...GRAFFITI_MEDIUM_LABELS, ...GRAFFITI_CONTEXT_LABELS }, tattoo: { ...TATTOO_STYLE_LABELS, ...TATTOO_METHOD_LABELS }, trainer: { ...TRAINER_FOCUS_LABELS, ...TRAINER_INJURY_LABELS, ...TRAINER_CREDENTIAL_LABELS }, musician: { ...MUSICIAN_GENRE_LABELS, ...MUSICIAN_INSTRUMENT_LABELS }, judge: { ...JUDGE_FORMAT_LABELS, ...JUDGE_SPECIALTY_LABELS, ...JUDGE_LEVEL_LABELS }, }; const DESCRIPTIONS_BY_TYPE: Record> = { coach: COACH_SKILL_DESCRIPTIONS, filmer: { ...FILMING_SERVICE_DESCRIPTIONS, ...FILMING_SKILL_DESCRIPTIONS }, editor: { ...EDITOR_SERVICE_DESCRIPTIONS, ...EDITOR_SKILL_DESCRIPTIONS }, photographer: PHOTO_SERVICE_DESCRIPTIONS, builder: BUILDER_SERVICE_DESCRIPTIONS, artist: ARTIST_SERVICE_DESCRIPTIONS, graffiti: GRAFFITI_STYLE_DESCRIPTIONS, tattoo: TATTOO_STYLE_DESCRIPTIONS, trainer: TRAINER_FOCUS_DESCRIPTIONS, musician: {}, judge: {}, }; const getSkillLabel = (skill: string) => LABELS_BY_TYPE[profile.profileType]?.[skill] || skill; const getSkillDescription = (skill: string): string | undefined => DESCRIPTIONS_BY_TYPE[profile.profileType]?.[skill]; const TYPE_NOUNS: Record = { coach: 'coach', filmer: 'filmer', editor: 'video editor', photographer: 'photographer', builder: 'builder', artist: 'artist', graffiti: 'graffiti artist', tattoo: 'tattoo artist', trainer: 'trainer', musician: 'musician', judge: 'event judge', }; const typeNoun = TYPE_NOUNS[profile.profileType] ?? 'profile'; const SKILLS_TITLES: Record = { coach: 'Skills', photographer: 'Services & Gear', builder: 'What We Build', artist: 'What They Make', graffiti: 'Styles & Work', tattoo: 'Styles', trainer: 'Focus & Specialties', musician: 'Genres & Instruments', judge: 'Formats & Specialties', }; const skillsTitle = SKILLS_TITLES[profile.profileType] ?? 'Services & Skills'; return (
Back to The Lab
{/* Main Content */}
{/* Confirm-membership prompt (only for listed pending members) */} {isCollective && ( )} {/* Profile Card */}
{profile.title.slice(0, 2).toUpperCase()}

{profile.title}

{profile.entityType === 'collective' && ( Collective )}

Joined {new Date(profile.createdAt * 1000).toLocaleDateString()}

{/* Bio */} {profile.bio && (

About

{profile.bio}

)} {/* Skills */} {profile.skills.length > 0 && (

{skillsTitle}

{profile.skills.map((skill) => (
{getSkillLabel(skill)} {getSkillDescription(skill) && (

{getSkillDescription(skill)}

)}
))}
)} {/* In the Bag — photographer / filmer gear (freeform) */} {(isPhotographer || isFilmer || isBuilder) && pget('gear') && (

{isBuilder ? : } {isBuilder ? 'How We Work' : 'In the Bag'}

{pget('gear')}

)} {/* Builder — service area & insured */} {isBuilder && (pget('serviceArea') || pbool('insured')) && (
{pget('serviceArea') && (

Service Area

{pget('serviceArea')}

)} {pbool('insured') && (
Licensed / Insured
)}
)} {/* Tattoo — studio & books-open */} {isTattoo && (pget('studio') || pbool('booksOpen')) && (
{pget('studio') && (

Studio

{pget('studio')}

)} {pbool('booksOpen') && (
Books Open
)}
)} {/* Trainer — credentials & remote */} {isTrainer && (pget('credentials') || pbool('remote')) && (
{pget('credentials') && (

Credentials

{pget('credentials')}

)} {pbool('remote') && (
Remote / Online available
)}
)} {/* Graffiti — crew name */} {isGraffiti && pget('crew') && (

Graffiti Crew

{pget('crew')}

)} {/* Musician — act type & listen links */} {isMusician && (
{'actType' in profile && profile.actType && (

Act

{profile.actType}

)} {(('spotify' in profile && profile.spotify) || ('bandcamp' in profile && profile.bandcamp) || ('soundcloud' in profile && profile.soundcloud) || ('youtube' in profile && profile.youtube)) && (

Listen

{'spotify' in profile && sanitizeUrl(profile.spotify) && ( Spotify )} {'bandcamp' in profile && sanitizeUrl(profile.bandcamp) && ( Bandcamp )} {'soundcloud' in profile && sanitizeUrl(profile.soundcloud) && ( SoundCloud )} {'youtube' in profile && sanitizeUrl(profile.youtube) && ( YouTube )}
)}
)} {/* Upcoming gigs — events that feature this musician */} {isMusician && gigs.length > 0 && (

Upcoming Gigs

{gigs.slice(0, 6).map((gig) => { const isUpcoming = gig.start >= Math.floor(Date.now() / 1000); return (

{gig.title}

{new Date(gig.start * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} {gig.location ? ` · ${gig.location}` : ''}

{!isUpcoming && ( Past )} ); })}
)} {/* Experience & Rate */}
{profile.experience && (

Experience

{profile.experience} years

)} {profile.rate && (

Rate

{profile.rate}

)}
{/* Sidebar */}
{/* Contact Card */} Contact {/* Direct Message */} {/* Book this act for an event — deep-links to event creation pre-tagged with this musician (Music → Events). */} {isMusician && ( )} {/* Contact Info from Profile */} {sanitizeTel(profile.phone) && ( {profile.phone} )} {sanitizeEmail(profile.email) && ( {profile.email} )} {sanitizeUrl(profile.website) && ( {urlDomain(profile.website!)} )} {isCoach && pget('instagram') && ( @{pget('instagram')!.replace('@', '')} )} {!isCoach && 'social' in profile && sanitizeUrl(profile.social) && ( {urlDomain(profile.social!)} )} {/* Portfolio / reel (all crafts except coach) */} {!isCoach && 'portfolio' in profile && sanitizeUrl(profile.portfolio) && ( Portfolio · {urlDomain(profile.portfolio!)} )} {/* Lightning Address from Nostr Profile */} {metadata?.lud16 && (
{metadata.lud16}
)} {/* Zap Button */}
{/* CLINK self-custodial payment (only when a valid noffer is present) */} {'noffer' in profile && profile.noffer && ( )}
{/* Scene vouches — community anti-fraud / trust signal */} {/* Owner controls — delete your own Lab profile; admins can take down any profile (DeleteEntityDialog gates owner-or-admin). */} {user && (user.pubkey === profile.pubkey || isAdminPubkey(user.pubkey)) && ( {user.pubkey === profile.pubkey ? 'Manage' : 'Admin'}

{user.pubkey === profile.pubkey ? `Remove your ${typeNoun} profile from The Lab.` : `Take down this ${typeNoun} profile. It will be hidden across SkateSpots.`}

)} {/* Members Card (collectives only) */} {isCollective && ( )} {/* Request to join (collectives only) — shown to non-owner, non-member skaters; notifies the owner via their bell. */} {isCollective && ( )} {/* Linked crew (cross-link) — pass this profile's own coord so a mutual link shows a "Verified" badge. */} {/* Location Card */} {profile.location && ( Location

{profile.location}

)}
); }