import { useSeoMeta } from '@unhead/react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { PageError } from '@/components/ErrorState';
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 { 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, SKATE_ORGANIZER_KIND, ORGANIZER_EVENT_LABELS, ORGANIZER_SERVICE_LABELS, ORGANIZER_SCALE_LABELS, BARBER_SERVICE_LABELS, BARBER_SERVICE_DESCRIPTIONS, BARBER_STYLE_LABELS, BARBER_SETTING_LABELS, SKATE_BARBER_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,
Tent,
Sparkles,
} 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, refetch } = 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 isPhotographer = profile?.profileType === 'photographer';
const isBuilder = profile?.profileType === 'builder';
const isGraffiti = profile?.profileType === 'graffiti';
const isTattoo = profile?.profileType === 'tattoo';
const isTrainer = profile?.profileType === 'trainer';
const isMusician = profile?.profileType === 'musician';
const isEditor = profile?.profileType === 'editor';
const isJudge = profile?.profileType === 'judge';
const isOrganizer = profile?.profileType === 'organizer';
const isBarber = profile?.profileType === 'barber';
useSeoMeta({
title: profile ? `${profile.title} - SkateSpots` : 'Profile - SkateSpots',
description: profile?.bio || 'View profile.',
});
const metadata = author.data?.metadata;
if (isLoading) {
return ;
}
if (error) {
return refetch()} />;
}
if (!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' },
organizer: { text: 'text-teal-400', bg: 'bg-teal-600', border: 'border-teal-500/30', hover: 'hover:bg-teal-500/10 border-teal-500' },
barber: { text: 'text-cyan-400', bg: 'bg-cyan-600', border: 'border-cyan-500/30', hover: 'hover:bg-cyan-500/10 border-cyan-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,
organizer: Tent,
barber: Sparkles,
};
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,
organizer: SKATE_ORGANIZER_KIND,
barber: SKATE_BARBER_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 },
organizer: { ...ORGANIZER_EVENT_LABELS, ...ORGANIZER_SERVICE_LABELS, ...ORGANIZER_SCALE_LABELS },
barber: { ...BARBER_SERVICE_LABELS, ...BARBER_STYLE_LABELS, ...BARBER_SETTING_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: {},
organizer: {},
barber: BARBER_SERVICE_DESCRIPTIONS,
};
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',
organizer: 'event organizer',
barber: 'barber',
};
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',
barber: 'Services & Styles', organizer: 'Event Types & Services',
};
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 && (
)}
{/* 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')}
)}
{/* Editor — hardware & software */}
{isEditor && (pget('hardware') || pget('software')) && (
{pget('hardware') && (
Hardware
{pget('hardware')}
)}
{pget('software') && (
Software
{pget('software')}
)}
)}
{/* Judge — events judged */}
{isJudge && pget('events_judged') && (
Events Judged
{pget('events_judged')}
)}
{/* Organizer — events organized */}
{isOrganizer && pget('events_hosted') && (
Events Organized
{pget('events_hosted')}
)}
{/* Spots — places this provider works / skates */}
{'spotRefs' in profile && profile.spotRefs && profile.spotRefs.length > 0 && (
Spots
{profile.spotRefs.map((ref) => {
const [kind, pk, ...rest] = ref.split(':');
if (kind !== '30818' || !pk || rest.length === 0) return null;
return (
View spot
);
})}
)}
{/* 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') && (
)}
{pbool('booksOpen') && (
Books Open
)}
)}
{/* Barber — shop & books-open */}
{isBarber && (pget('studio') || pbool('booksOpen')) && (
{pget('studio') && (
Shop / 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 && (
)}
{(('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 && (
)}
{/* Sidebar */}
{/* Contact Card */}
Contact
{/* Direct Message */}
navigate(`/messages/${npub}`)}
>
Send Message
{/* Book this act for an event — deep-links to event creation
pre-tagged with this musician (Music → Events). */}
{isMusician && (
navigate(
`/create-event?type=concert&musician=${profile.pubkey}:${profile.identifier}`,
)
}
>
Book for a Gig
)}
{/* 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}
)}
>
);
}