SkateSpots/src/pages/CoachDetailPage.tsx
SkateSpots c794d5fd18 Give Lab profiles a per-craft URL: /lab/<craft>/<pubkey>/<id>
Every provider lived at /coach/<pubkey>/<id> regardless of craft (a barber got
a "coach" URL). New src/lib/labRoutes.ts maps craft kind → segment and builds
the canonical path; the router adds /lab/:craft/:pubkey/:identifier (the craft
segment is cosmetic — the page still resolves by pubkey + identifier) and keeps
/coach/... working for old links. All generated links updated: offer-form
redirects, Lab cards + "Your Lab profiles", cross-links, spot/event/admin links,
the CVM client URL, the share path, and the SPA clean-link shim.
2026-10-04 11:32:15 +07:00

999 lines
54 KiB
TypeScript

import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useParams, Link, useNavigate, useSearchParams } 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 {
COACH_SKILL_KEYS, COACH_SKILL_DESC_KEYS,
FILMING_SERVICE_KEYS, FILMING_SERVICE_DESC_KEYS, FILMING_SKILL_KEYS, FILMING_SKILL_DESC_KEYS,
EDITOR_SERVICE_KEYS, EDITOR_SERVICE_DESC_KEYS, EDITOR_SKILL_KEYS, EDITOR_SKILL_DESC_KEYS,
PHOTO_SERVICE_KEYS, PHOTO_SERVICE_DESC_KEYS, PHOTO_CAMERA_KEYS, PHOTO_LENS_KEYS, PHOTO_LIGHTING_KEYS, PHOTO_SUPPORT_KEYS, PHOTO_STYLE_KEYS,
BUILDER_SERVICE_KEYS, BUILDER_SERVICE_DESC_KEYS, BUILDER_SKILL_KEYS, BUILDER_SCALE_KEYS,
ARTIST_SERVICE_KEYS, ARTIST_SERVICE_DESC_KEYS, ARTIST_MEDIUM_KEYS, ARTIST_TOOL_KEYS,
GRAFFITI_STYLE_KEYS, GRAFFITI_STYLE_DESC_KEYS, GRAFFITI_MEDIUM_KEYS, GRAFFITI_CONTEXT_KEYS,
TATTOO_STYLE_KEYS, TATTOO_STYLE_DESC_KEYS, TATTOO_METHOD_KEYS,
TRAINER_FOCUS_KEYS, TRAINER_FOCUS_DESC_KEYS, TRAINER_INJURY_KEYS, TRAINER_CREDENTIAL_KEYS,
MUSICIAN_GENRE_KEYS, MUSICIAN_INSTRUMENT_KEYS,
JUDGE_FORMAT_KEYS, JUDGE_SPECIALTY_KEYS, JUDGE_LEVEL_KEYS,
ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS,
BARBER_SERVICE_KEYS, BARBER_SERVICE_DESC_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS,
} from '@/lib/labelKeys';
import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, coachOptionKey, coachRateCurrencyKey, coachRateUnitKey, type CoachOption } from '@/lib/coachOptions';
import { parseLabExtras } from '@/lib/labExtras';
import { CRAFT_DETAIL_FIELDS } from '@/lib/craftFields';
import { labProfilePath } from '@/lib/labRoutes';
import { LabExtrasSection } from '@/components/LabExtrasSection';
import { ShareProfileDialog } from '@/components/ShareProfileDialog';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
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,
ShieldCheck,
Share2,
Pencil,
PartyPopper,
X,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
function CoachDetailSkeleton() {
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">
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-4">
<Skeleton className="h-20 w-20 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Skeleton className="h-24 w-full mt-4" />
</CardContent>
</Card>
</div>
<div className="min-w-0">
<Skeleton className="h-64 w-full" />
</div>
</div>
</div>
</>
);
}
export default function CoachDetailPage() {
const { t } = useTranslation();
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();
const [shareOpen, setShareOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const justPublished = searchParams.get('published') === '1';
// 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 <CoachDetailSkeleton />;
}
if (error) {
return <PageError onRetry={() => refetch()} />;
}
if (!profile) {
return <NotFound />;
}
const npub = nip19.npubEncode(profile.pubkey);
// Per-type accent (text, border, bg, ring) — keeps the detail page on-brand
const ACCENTS: Record<string, { text: string; bg: string; border: string; hover: string }> = {
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<string, unknown>;
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;
/** Repeatable string-array field on the union profile (empty when absent). */
const parr = (key: string): string[] => {
const v = pf[key];
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
};
/** Translated label for a stored option value, falling back to the raw value. */
const optLabel = (options: CoachOption[], value: string): string => {
const key = coachOptionKey(options, value);
return key ? t(key) : value;
};
// Shared craft-agnostic extras (languages, availability, rate, payments,
// gallery, reel) — read straight off the event so every Lab craft gets them.
const extras = parseLabExtras(profile.event);
// Structured rate (`rate_amount`/`rate_currency`/`rate_unit`) with a fallback
// to the legacy freeform `rate` string.
const rateAmount = extras.rateAmount;
const rateCurrencyKey = coachRateCurrencyKey(extras.rateCurrency);
const rateUnitKey = coachRateUnitKey(extras.rateUnit);
const rateDisplay = rateAmount
? `${rateAmount} ${rateCurrencyKey ? t(rateCurrencyKey) : (extras.rateCurrency ?? '')}${rateUnitKey ? ` · ${t(rateUnitKey)}` : ''}`.trim()
: pget('rate');
const TYPE_ICONS: Record<string, typeof GraduationCap> = {
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<string, number> = {
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<string, Record<string, string>> = {
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<string, Record<string, string>> = {
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 LABEL_KEYS_BY_TYPE: Record<string, Record<string, string>> = {
coach: COACH_SKILL_KEYS,
filmer: { ...FILMING_SERVICE_KEYS, ...FILMING_SKILL_KEYS },
editor: { ...EDITOR_SERVICE_KEYS, ...EDITOR_SKILL_KEYS },
photographer: { ...PHOTO_SERVICE_KEYS, ...PHOTO_CAMERA_KEYS, ...PHOTO_LENS_KEYS, ...PHOTO_LIGHTING_KEYS, ...PHOTO_SUPPORT_KEYS, ...PHOTO_STYLE_KEYS },
builder: { ...BUILDER_SERVICE_KEYS, ...BUILDER_SKILL_KEYS, ...BUILDER_SCALE_KEYS },
artist: { ...ARTIST_SERVICE_KEYS, ...ARTIST_MEDIUM_KEYS, ...ARTIST_TOOL_KEYS },
graffiti: { ...GRAFFITI_STYLE_KEYS, ...GRAFFITI_MEDIUM_KEYS, ...GRAFFITI_CONTEXT_KEYS },
tattoo: { ...TATTOO_STYLE_KEYS, ...TATTOO_METHOD_KEYS },
trainer: { ...TRAINER_FOCUS_KEYS, ...TRAINER_INJURY_KEYS, ...TRAINER_CREDENTIAL_KEYS },
musician: { ...MUSICIAN_GENRE_KEYS, ...MUSICIAN_INSTRUMENT_KEYS },
judge: { ...JUDGE_FORMAT_KEYS, ...JUDGE_SPECIALTY_KEYS, ...JUDGE_LEVEL_KEYS },
organizer: { ...ORGANIZER_EVENT_KEYS, ...ORGANIZER_SERVICE_KEYS, ...ORGANIZER_SCALE_KEYS },
barber: { ...BARBER_SERVICE_KEYS, ...BARBER_STYLE_KEYS, ...BARBER_SETTING_KEYS },
};
const DESC_KEYS_BY_TYPE: Record<string, Record<string, string>> = {
coach: COACH_SKILL_DESC_KEYS,
filmer: { ...FILMING_SERVICE_DESC_KEYS, ...FILMING_SKILL_DESC_KEYS },
editor: { ...EDITOR_SERVICE_DESC_KEYS, ...EDITOR_SKILL_DESC_KEYS },
photographer: PHOTO_SERVICE_DESC_KEYS,
builder: BUILDER_SERVICE_DESC_KEYS,
artist: ARTIST_SERVICE_DESC_KEYS,
graffiti: GRAFFITI_STYLE_DESC_KEYS,
tattoo: TATTOO_STYLE_DESC_KEYS,
trainer: TRAINER_FOCUS_DESC_KEYS,
barber: BARBER_SERVICE_DESC_KEYS,
};
const getSkillLabel = (skill: string) => {
const key = LABEL_KEYS_BY_TYPE[profile.profileType]?.[skill];
return key ? t(key) : (LABELS_BY_TYPE[profile.profileType]?.[skill] || skill);
};
const getSkillDescription = (skill: string): string | undefined => {
const key = DESC_KEYS_BY_TYPE[profile.profileType]?.[skill];
return key ? t(key) : DESCRIPTIONS_BY_TYPE[profile.profileType]?.[skill];
};
const TYPE_NOUNS: Record<string, string> = {
coach: t('labUi.detail.noun.coach'), filmer: t('labUi.detail.noun.filmer'), editor: t('labUi.detail.noun.editor'), photographer: t('labUi.detail.noun.photographer'), builder: t('labUi.detail.noun.builder'),
artist: t('labUi.detail.noun.artist'), graffiti: t('labUi.detail.noun.graffiti'), tattoo: t('labUi.detail.noun.tattoo'), trainer: t('labUi.detail.noun.trainer'), musician: t('labUi.detail.noun.musician'), judge: t('labUi.detail.noun.judge'),
organizer: t('labUi.detail.noun.organizer'),
barber: t('labUi.detail.noun.barber'),
};
const typeNoun = TYPE_NOUNS[profile.profileType] ?? t('labUi.detail.noun.profile');
const SKILLS_TITLES: Record<string, string> = {
coach: t('labUi.detail.skillsTitle.skills'), photographer: t('labUi.detail.skillsTitle.servicesGear'), builder: t('labUi.detail.skillsTitle.whatWeBuild'),
artist: t('labUi.detail.skillsTitle.whatTheyMake'), graffiti: t('labUi.detail.skillsTitle.stylesWork'), tattoo: t('labUi.detail.skillsTitle.styles'),
trainer: t('labUi.detail.skillsTitle.focusSpecialties'), musician: t('labUi.detail.skillsTitle.genresInstruments'), judge: t('labUi.detail.skillsTitle.formatsSpecialties'),
barber: t('labUi.detail.skillsTitle.servicesStyles'), organizer: t('labUi.detail.skillsTitle.eventTypesServices'),
};
const skillsTitle = SKILLS_TITLES[profile.profileType] ?? t('labUi.detail.servicesSkills');
return (
<>
<div className="container px-4 py-8 max-w-4xl">
<Link
to="/lab"
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6"
>
<ArrowLeft className="h-4 w-4 mr-2" />
{t('labUi.become.backToLab')}
</Link>
{justPublished && user?.pubkey === profile.pubkey && (
<div className={`mb-6 flex items-center gap-3 rounded-xl border-2 p-4 ${accent.border} bg-white/[0.03]`}>
<PartyPopper className={`h-6 w-6 flex-shrink-0 ${accent.text}`} />
<div className="min-w-0 flex-1">
<p className="font-black uppercase">{t('labUi.detail.publishedTitle')}</p>
<p className="text-sm text-gray-400">{t('labUi.detail.publishedBody')}</p>
</div>
<Button onClick={() => setShareOpen(true)} className={`flex-shrink-0 gap-1.5 ${accent.bg} font-bold text-white hover:opacity-90`}>
<Share2 className="h-4 w-4" /> {t('common.share')}
</Button>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
const next = new URLSearchParams(searchParams);
next.delete('published');
setSearchParams(next, { replace: true });
}}
className="flex-shrink-0 rounded-full p-1 text-gray-500 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Main Content */}
<div className="md:col-span-2 space-y-6 min-w-0">
{/* Confirm-membership prompt (only for listed pending members) */}
{isCollective && (
<ConfirmMembershipCard
kind={profileKind}
founderPubkey={profile.pubkey}
identifier={profile.identifier}
collectiveName={profile.title}
claimedMembers={profile.members}
accentBg={`${accent.bg} hover:opacity-90`}
/>
)}
{/* Profile Card */}
<Card className={`bg-black/90 border-2 ${accent.border}`}>
<CardContent className="pt-6">
<div className="flex items-start gap-4 mb-6">
<Avatar className="h-20 w-20">
<AvatarImage src={profile.image || metadata?.picture} alt={profile.title} />
<AvatarFallback className={`${accent.bg} text-white text-2xl`}>
{profile.title.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h1 className={`text-3xl font-black uppercase ${accent.text}`} style={{
fontFamily: 'Impact, "Arial Black", sans-serif'
}}>
{profile.title}
</h1>
<TypeIcon className={`h-7 w-7 ${accent.text}`} />
{profile.entityType === 'collective' && (
<Badge variant="outline" className={`gap-1 ${accent.text} ${accent.border}`}>
<Users className="h-3 w-3" /> {t('labUi.collective')}
</Badge>
)}
<TrustBadge pubkey={profile.pubkey} size="md" />
<OfficialBadge pubkey={profile.pubkey} size="md" />
<ProBadge
featuredUntil={profile.featuredUntil}
entityCoord={`${profile.event.kind}:${profile.pubkey}:${profile.identifier}`}
/>
<Button
variant="outline"
size="sm"
onClick={() => setShareOpen(true)}
className={`ml-auto h-7 gap-1.5 px-2.5 ${accent.border} ${accent.text} hover:bg-white/5`}
>
<Share2 className="h-3.5 w-3.5" /> {t('common.share')}
</Button>
</div>
<p className="text-gray-400 flex items-center gap-2">
<Calendar className="h-4 w-4" />
{t('labUi.detail.joined')} {new Date(profile.createdAt * 1000).toLocaleDateString(dateLocaleCode())}
</p>
</div>
</div>
{/* Bio */}
{profile.bio && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2">{t('labUi.detail.about')}</h3>
<p className="text-gray-300 whitespace-pre-wrap">{profile.bio}</p>
</div>
)}
{/* Skills */}
{profile.skills.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3">
{skillsTitle}
</h3>
<div className="space-y-3">
{profile.skills.map((skill) => (
<div key={skill} className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<div className="flex items-start gap-3">
<Badge className={`${accent.bg} text-white font-bold`}>
{getSkillLabel(skill)}
</Badge>
{getSkillDescription(skill) && (
<p className="text-sm text-gray-400 flex-1">
{getSkillDescription(skill)}
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Coach — credentials, formats, levels, ages, languages, availability */}
{isCoach && (parr('credentials').length > 0 || pget('certNote') || parr('formats').length > 0 || parr('levels').length > 0 || parr('ageGroups').length > 0) && (
<div className="mb-6 space-y-5">
{(parr('credentials').length > 0 || pget('certNote')) && (
<div>
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
<ShieldCheck className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.credentialsTitle')}
</h3>
{parr('credentials').length > 0 && (
<div className="flex flex-wrap gap-2">
{parr('credentials').map((c) => (
<span key={c} className={`inline-flex items-center gap-1.5 text-xs font-bold px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>
<ShieldCheck className="h-3.5 w-3.5" />
{optLabel(COACH_CREDENTIALS, c)}
</span>
))}
</div>
)}
{pget('certNote') && (
<p className="mt-2 text-sm text-gray-300">{pget('certNote')}</p>
)}
<p className="text-xs text-gray-500 mt-1.5">{t('labUi.detail.selfAttested')}</p>
</div>
)}
{(parr('formats').length > 0 || parr('levels').length > 0 || parr('ageGroups').length > 0) && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{parr('formats').length > 0 && (
<div>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1.5">{t('labUi.detail.formats')}</h4>
<div className="flex flex-wrap gap-1.5">
{parr('formats').map((v) => (
<span key={v} className={`text-xs px-2.5 py-1 rounded-full border ${accent.border} text-gray-200`}>{optLabel(COACH_FORMATS, v)}</span>
))}
</div>
</div>
)}
{parr('levels').length > 0 && (
<div>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1.5">{t('labUi.detail.levels')}</h4>
<div className="flex flex-wrap gap-1.5">
{parr('levels').map((v) => (
<span key={v} className={`text-xs px-2.5 py-1 rounded-full border ${accent.border} text-gray-200`}>{optLabel(COACH_LEVELS, v)}</span>
))}
</div>
</div>
)}
{parr('ageGroups').length > 0 && (
<div>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1.5">{t('labUi.detail.ages')}</h4>
<div className="flex flex-wrap gap-1.5">
{parr('ageGroups').map((v) => (
<span key={v} className={`text-xs px-2.5 py-1 rounded-full border ${accent.border} text-gray-200`}>{optLabel(COACH_AGE_GROUPS, v)}</span>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
{/* Shared extras (all crafts): payments, languages, availability, portfolio */}
<LabExtrasSection extras={extras} accent={accent} profilePubkey={profile.pubkey} />
{/* Craft-specific freeform details (see lib/craftFields) */}
{(() => {
const fields = (CRAFT_DETAIL_FIELDS[profile.profileType] ?? []).filter((f) => pget(f.tag));
if (fields.length === 0) return null;
return (
<div className="mb-6 space-y-4">
{fields.map((f) => (
<div key={f.tag}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">{t(f.labelKey)}</h4>
<p className="text-sm text-gray-300 whitespace-pre-wrap">
{pget(f.tag) === 'true' ? t('common.yes') : pget(f.tag)}
</p>
</div>
))}
</div>
);
})()}
{/* In the Bag — photographer / filmer gear (freeform) */}
{(isPhotographer || isFilmer || isBuilder) && pget('gear') && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
{isBuilder ? <HardHat className={`h-4 w-4 ${accent.text}`} /> : <Aperture className={`h-4 w-4 ${accent.text}`} />}
{isBuilder ? t('labUi.detail.howWeWork') : t('labUi.detail.inTheBag')}
</h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{pget('gear')}
</p>
</div>
)}
{/* Editor — hardware & software */}
{isEditor && (pget('hardware') || pget('software')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{pget('hardware') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.hardware')}</h4>
<p className="text-sm text-gray-300">{pget('hardware')}</p>
</div>
)}
{pget('software') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.software')}</h4>
<p className="text-sm text-gray-300">{pget('software')}</p>
</div>
)}
</div>
)}
{/* Judge — events judged */}
{isJudge && pget('events_judged') && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
<Gavel className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.eventsJudged')}
</h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{pget('events_judged')}
</p>
</div>
)}
{/* Organizer — events organized */}
{isOrganizer && pget('events_hosted') && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
<Tent className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.eventsOrganized')}
</h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{pget('events_hosted')}
</p>
</div>
)}
{/* Spots — places this provider works / skates */}
{'spotRefs' in profile && profile.spotRefs && profile.spotRefs.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
<MapPin className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.spots')}
</h3>
<div className="flex flex-wrap gap-2">
{profile.spotRefs.map((ref) => {
const [kind, pk, ...rest] = ref.split(':');
if (kind !== '30818' || !pk || rest.length === 0) return null;
return (
<Link
key={ref}
to={`/spot/${pk}/${rest.join(':')}`}
className={`text-xs font-bold px-3 py-1.5 rounded-full border ${accent.border} text-gray-200 hover:bg-white/5`}
>
<MapPin className="inline h-3 w-3 mr-1" /> {t('labUi.detail.viewSpot')}
</Link>
);
})}
</div>
</div>
)}
{/* Builder — service area & insured */}
{isBuilder && (pget('serviceArea') || pbool('insured')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{pget('serviceArea') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<MapPin className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.serviceArea')}
</h4>
<p className="text-sm text-gray-300">{pget('serviceArea')}</p>
</div>
)}
{pbool('insured') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<HardHat className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-gray-200">{t('labUi.detail.licensedInsured')}</span>
</div>
)}
</div>
)}
{/* Tattoo — studio & books-open */}
{isTattoo && (pget('studio') || pbool('booksOpen')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{pget('studio') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<MapPin className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.studio')}
</h4>
<p className="text-sm text-gray-300">{pget('studio')}</p>
</div>
)}
{pbool('booksOpen') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<Brush className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-emerald-400">{t('labUi.detail.booksOpen')}</span>
</div>
)}
</div>
)}
{/* Barber — shop & books-open */}
{isBarber && (pget('studio') || pbool('booksOpen')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{pget('studio') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<MapPin className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.shopStudio')}
</h4>
<p className="text-sm text-gray-300">{pget('studio')}</p>
</div>
)}
{pbool('booksOpen') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<Sparkles className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-emerald-400">{t('labUi.detail.booksOpen')}</span>
</div>
)}
</div>
)}
{/* Trainer — credentials & remote */}
{isTrainer && (pget('credentials') || pbool('remote')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{pget('credentials') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<Dumbbell className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.credentials')}
</h4>
<p className="text-sm text-gray-300">{pget('credentials')}</p>
</div>
)}
{pbool('remote') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<Globe className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-gray-200">{t('labUi.detail.remoteOnline')}</span>
</div>
)}
</div>
)}
{/* Graffiti — crew name */}
{isGraffiti && pget('crew') && (
<div className="mb-6">
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<SprayCan className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.graffitiCrew')}
</h4>
<p className="text-sm text-gray-300">{pget('crew')}</p>
</div>
)}
{/* Musician — act type & listen links */}
{isMusician && (
<div className="mb-6 space-y-3">
{'actType' in profile && profile.actType && (
<div>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.act')}</h4>
<p className="text-sm text-gray-300 capitalize">{profile.actType}</p>
</div>
)}
{(('spotify' in profile && profile.spotify) || ('bandcamp' in profile && profile.bandcamp) || ('soundcloud' in profile && profile.soundcloud) || ('youtube' in profile && profile.youtube)) && (
<div>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-1">
<Music className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.listen')}
</h4>
<div className="flex flex-wrap gap-2">
{'spotify' in profile && sanitizeUrl(profile.spotify) && (
<a href={sanitizeUrl(profile.spotify)!} target="_blank" rel="noopener noreferrer" className={`px-3 py-1.5 rounded-full text-xs font-bold border ${accent.border} text-gray-200 hover:bg-white/5`}>Spotify</a>
)}
{'bandcamp' in profile && sanitizeUrl(profile.bandcamp) && (
<a href={sanitizeUrl(profile.bandcamp)!} target="_blank" rel="noopener noreferrer" className={`px-3 py-1.5 rounded-full text-xs font-bold border ${accent.border} text-gray-200 hover:bg-white/5`}>Bandcamp</a>
)}
{'soundcloud' in profile && sanitizeUrl(profile.soundcloud) && (
<a href={sanitizeUrl(profile.soundcloud)!} target="_blank" rel="noopener noreferrer" className={`px-3 py-1.5 rounded-full text-xs font-bold border ${accent.border} text-gray-200 hover:bg-white/5`}>SoundCloud</a>
)}
{'youtube' in profile && sanitizeUrl(profile.youtube) && (
<a href={sanitizeUrl(profile.youtube)!} target="_blank" rel="noopener noreferrer" className={`px-3 py-1.5 rounded-full text-xs font-bold border ${accent.border} text-gray-200 hover:bg-white/5`}>YouTube</a>
)}
</div>
</div>
)}
</div>
)}
{/* Upcoming gigs — events that feature this musician */}
{isMusician && gigs.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2">
<Calendar className={`h-4 w-4 ${accent.text}`} />
{t('labUi.detail.upcomingGigs')}
</h3>
<div className="space-y-2">
{gigs.slice(0, 6).map((gig) => {
const isUpcoming = gig.start >= Math.floor(Date.now() / 1000);
return (
<Link
key={gig.event.id}
to={`/event/${gig.pubkey}/${gig.identifier}`}
className={`flex items-center gap-3 rounded-lg border ${accent.border} bg-black/50 p-3 transition-colors hover:bg-white/5`}
>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${accent.bg}/20`}>
<Music className={`h-4 w-4 ${accent.text}`} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-white">{gig.title}</p>
<p className="text-xs text-gray-400">
{new Date(gig.start * 1000).toLocaleDateString(dateLocaleCode(), { month: 'short', day: 'numeric', year: 'numeric' })}
{gig.location ? ` · ${gig.location}` : ''}
</p>
</div>
{!isUpcoming && (
<Badge variant="secondary" className="shrink-0 text-[10px]">{t('labUi.detail.past')}</Badge>
)}
</Link>
);
})}
</div>
</div>
)}
{/* Experience & Rate */}
<div className="grid grid-cols-2 gap-4">
{profile.experience && (
<div>
<h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.experience')}</h4>
<p className="text-gray-300">{t('labUi.detail.yearsCount', { count: profile.experience })}</p>
</div>
)}
{rateDisplay && (
<div>
<h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.rate')}</h4>
<p className="text-gray-300">{rateDisplay}</p>
{rateAmount && pget('rate') && (
<p className="mt-0.5 text-xs text-gray-500">{pget('rate')}</p>
)}
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6 min-w-0">
{/* Contact Card */}
<Card className="bg-black/90 border-2 border-[#ccff00]/50">
<CardHeader>
<CardTitle className="text-lg text-[#ccff00] uppercase font-black flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
{t('labUi.detail.contact')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Direct Message */}
<Button
variant="outline"
className="w-full gap-2 border-[#ff0040] hover:bg-[#ff0040]/10 text-white"
onClick={() => navigate(`/messages/${npub}`)}
>
<MessageCircle className="h-4 w-4" />
{t('labUi.detail.sendMessage')}
</Button>
{/* Book this act for an event — deep-links to event creation
pre-tagged with this musician (Music → Events). */}
{isMusician && (
<Button
className={`w-full gap-2 ${accent.bg} text-white font-bold hover:opacity-90`}
onClick={() =>
navigate(
`/create-event?type=concert&musician=${profile.pubkey}:${profile.identifier}`,
)
}
>
<Calendar className="h-4 w-4" />
{t('labUi.detail.bookGig')}
</Button>
)}
{/* Contact Info from Profile */}
{sanitizeTel(profile.phone) && (
<a href={sanitizeTel(profile.phone)!} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
<Phone className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">{profile.phone}</span>
</a>
)}
{sanitizeEmail(profile.email) && (
<a href={sanitizeEmail(profile.email)!} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
<Mail className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">{profile.email}</span>
</a>
)}
{sanitizeUrl(profile.website) && (
<a href={sanitizeUrl(profile.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(profile.website!)}`}>
<Globe className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">{urlDomain(profile.website!)}</span>
</a>
)}
{isCoach && pget('instagram') && (
<a href={`https://instagram.com/${pget('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-[#ccff00]" />
<span className="truncate">@{pget('instagram')!.replace('@', '')}</span>
</a>
)}
{!isCoach && 'social' in profile && sanitizeUrl(profile.social) && (
<a href={sanitizeUrl(profile.social)!} 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(profile.social!)}`}>
<Globe className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">{urlDomain(profile.social!)}</span>
</a>
)}
{/* Portfolio / reel (all crafts except coach) */}
{!isCoach && 'portfolio' in profile && sanitizeUrl(profile.portfolio) && (
<a href={sanitizeUrl(profile.portfolio)!} 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(profile.portfolio!)}`}>
<Aperture className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">{t('labUi.detail.portfolioDomain', { domain: urlDomain(profile.portfolio!) })}</span>
</a>
)}
{/* Lightning Address from Nostr Profile */}
{metadata?.lud16 && (
<div className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded">
<Mail className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">{metadata.lud16}</span>
</div>
)}
{/* Zap Button */}
<div className="pt-3 border-t border-gray-700">
<ZapButton
target={profile.event}
className="w-full justify-center"
/>
</div>
{/* CLINK self-custodial payment (only when a valid noffer is present) */}
{'noffer' in profile && profile.noffer && (
<ClinkPayButton
noffer={profile.noffer}
recipientName={profile.title}
className="w-full justify-center"
/>
)}
</CardContent>
</Card>
{/* Scene vouches — community anti-fraud / trust signal */}
<VouchCard
coord={`${profileKind}:${profile.pubkey}:${profile.identifier}`}
providerPubkey={profile.pubkey}
providerName={profile.title}
accentText={accent.text}
accentBg={`${accent.bg} hover:opacity-90`}
/>
{/* 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)) && (
<Card className="bg-black/90 border-2 border-red-500/30">
<CardHeader>
<CardTitle className="text-lg text-red-400 uppercase font-black">
{user.pubkey === profile.pubkey ? t('labUi.detail.manage') : t('labUi.detail.admin')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-gray-400">
{user.pubkey === profile.pubkey
? t('labUi.detail.manageRemove', { noun: typeNoun })
: t('labUi.detail.adminTakeDown', { noun: typeNoun })}
</p>
{user.pubkey === profile.pubkey && (
<Link to={`/become/${profile.profileType}`}>
<Button variant="outline" className={`w-full justify-center gap-2 ${accent.border} ${accent.text} hover:bg-white/5`}>
<Pencil className="h-4 w-4" /> {t('labUi.detail.editProfile')}
</Button>
</Link>
)}
<DeleteEntityDialog
event={profile.event}
entityLabel={t('labUi.detail.entityLabel', { noun: typeNoun })}
entityName={profile.title}
redirectTo="/lab"
triggerText={t('labUi.detail.deleteProfile')}
className="w-full justify-center"
/>
</CardContent>
</Card>
)}
{/* Members Card (collectives only) */}
{isCollective && (
<CollectiveMembers
kind={profileKind}
founderPubkey={profile.pubkey}
identifier={profile.identifier}
claimedMembers={profile.members}
accentText={accent.text}
accentBorder={accent.border}
/>
)}
{/* Request to join (collectives only) — shown to non-owner,
non-member skaters; notifies the owner via their bell. */}
{isCollective && (
<RequestToJoinCollective
profile={profile}
accentText={accent.text}
accentBorder={accent.border}
/>
)}
{/* Linked crew (cross-link) — pass this profile's own coord so a
mutual link shows a "Verified" badge. */}
<LinkedCrewCard
crewRef={profile.crewRef}
selfCoord={`${profileKind}:${profile.pubkey}:${profile.identifier}`}
/>
{/* Location Card */}
{profile.location && (
<Card className="bg-black/90 border-2 border-gray-700">
<CardHeader>
<CardTitle className="text-lg text-white uppercase font-black flex items-center gap-2">
<MapPin className={`h-5 w-5 ${accent.text}`} />
{t('labUi.detail.location')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-300">{profile.location}</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
<ShareProfileDialog
open={shareOpen}
onOpenChange={setShareOpen}
npub={nip19.npubEncode(profile.pubkey)}
displayName={profile.title}
path={labProfilePath(profile.profileType, profile.pubkey, profile.identifier)}
/>
</>
);
}