1039 lines
56 KiB
TypeScript
1039 lines
56 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 { TrustFlaggedNotice } from '@/components/TrustFlaggedNotice';
|
|
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, SKATE_TRANSPORT_KIND, TRANSPORT_VEHICLE_LABELS, TRANSPORT_SERVICE_LABELS, TRANSPORT_FEATURE_LABELS } 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,
|
|
TRANSPORT_VEHICLE_KEYS, TRANSPORT_SERVICE_KEYS, TRANSPORT_FEATURE_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,
|
|
Truck,
|
|
ShieldCheck,
|
|
ShieldAlert,
|
|
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` : t('legacyMiscUi.seoProfileTitle'),
|
|
description: profile?.bio || t('legacyMiscUi.seoViewProfile'),
|
|
});
|
|
|
|
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' },
|
|
transport: { text: 'text-amber-400', bg: 'bg-amber-500', border: 'border-amber-500/30', hover: 'hover:bg-amber-500/10 border-amber-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,
|
|
transport: Truck,
|
|
};
|
|
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,
|
|
transport: SKATE_TRANSPORT_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 },
|
|
transport: { ...TRANSPORT_VEHICLE_LABELS, ...TRANSPORT_SERVICE_LABELS, ...TRANSPORT_FEATURE_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,
|
|
transport: {},
|
|
};
|
|
|
|
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 },
|
|
transport: { ...TRANSPORT_VEHICLE_KEYS, ...TRANSPORT_SERVICE_KEYS, ...TRANSPORT_FEATURE_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,
|
|
transport: {},
|
|
};
|
|
|
|
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'),
|
|
transport: t('labUi.detail.noun.transport'),
|
|
};
|
|
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'),
|
|
transport: t('labUi.detail.skillsTitle.transport'),
|
|
};
|
|
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>
|
|
|
|
{profile.profileType === 'transport' && (
|
|
<div className="mb-6 flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-amber-100">
|
|
<ShieldAlert className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-400" />
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-black uppercase tracking-wide text-amber-300">
|
|
{t('labUi.detail.transportSelfAttestedTitle')}
|
|
</p>
|
|
<p className="mt-1 text-xs leading-relaxed text-amber-100/80">
|
|
{t('labUi.detail.transportSelfAttested')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{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}`}
|
|
/>
|
|
{user?.pubkey === profile.pubkey && (
|
|
<Link to={`/become/${profile.profileType}`} className="ml-auto">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className={`h-7 gap-1.5 px-2.5 ${accent.border} ${accent.text} hover:bg-white/5`}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" /> {t('labUi.detail.editProfile')}
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShareOpen(true)}
|
|
className={`h-7 gap-1.5 px-2.5 ${user?.pubkey === profile.pubkey ? '' : 'ml-auto'} ${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>
|
|
|
|
<TrustFlaggedNotice pubkey={profile.pubkey} targetTitle={profile.title} className="mb-6" />
|
|
|
|
{/* 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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|