import { useState, useMemo, useRef, useEffect } from 'react'; import { useSeoMeta } from '@unhead/react'; import { Link, useSearchParams } from 'react-router-dom'; import { Layout } from '@/components/Layout'; import { Badge } from '@/components/ui/badge'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter'; import { type SortOption } from '@/components/SortDropdown'; import { useLabProfiles, type LabProfile } from '@/hooks/useLabProfiles'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useTranslation } from '@/contexts/LanguageContext'; import { genUserName } from '@/lib/genUserName'; import { COACH_SKILL_LABELS, FILMING_SERVICE_LABELS, FILMING_SKILL_LABELS, EDITOR_SERVICE_LABELS, EDITOR_SKILL_LABELS, PHOTO_SERVICE_LABELS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, ARTIST_SERVICE_LABELS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, GRAFFITI_STYLE_LABELS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, TATTOO_STYLE_LABELS, TATTOO_METHOD_LABELS, TRAINER_FOCUS_LABELS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS, JUDGE_FORMAT_LABELS, JUDGE_SPECIALTY_LABELS, JUDGE_LEVEL_LABELS, } from '@/lib/types'; import { GraduationCap, Search, Plus, MapPin, SlidersHorizontal, Video, Scissors, Camera, HardHat, Palette, SprayCan, Brush, Dumbbell, Music, Gavel, CreditCard, X, ChevronRight, Star, Clock, Users, CheckCircle2, Shield, PersonStanding, ArrowRight, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { ProBadge } from '@/components/ProBadge'; import { LabCrewsStrip } from '@/components/LabCrewsStrip'; import { isFeaturedActive } from '@/lib/types'; // ─── Constants ──────────────────────────────────────────────────────────────── const COACH_SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS); const FILMING_SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS); const EDITOR_SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS); const EDITOR_SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS); const PHOTO_SERVICE_ENTRIES = Object.entries(PHOTO_SERVICE_LABELS); const PHOTO_CAMERA_ENTRIES = Object.entries(PHOTO_CAMERA_LABELS); const PHOTO_LENS_ENTRIES = Object.entries(PHOTO_LENS_LABELS); const PHOTO_LIGHTING_ENTRIES = Object.entries(PHOTO_LIGHTING_LABELS); const PHOTO_SUPPORT_ENTRIES = Object.entries(PHOTO_SUPPORT_LABELS); const PHOTO_STYLE_ENTRIES = Object.entries(PHOTO_STYLE_LABELS); const BUILDER_SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS); const BUILDER_SKILL_ENTRIES = Object.entries(BUILDER_SKILL_LABELS); const BUILDER_SCALE_ENTRIES = Object.entries(BUILDER_SCALE_LABELS); const BUILDER_ALL_LABELS: Record = { ...BUILDER_SERVICE_LABELS, ...BUILDER_SKILL_LABELS, ...BUILDER_SCALE_LABELS, }; const ARTIST_SERVICE_ENTRIES = Object.entries(ARTIST_SERVICE_LABELS); const ARTIST_MEDIUM_ENTRIES = Object.entries(ARTIST_MEDIUM_LABELS); const ARTIST_TOOL_ENTRIES = Object.entries(ARTIST_TOOL_LABELS); const ARTIST_ALL_LABELS: Record = { ...ARTIST_SERVICE_LABELS, ...ARTIST_MEDIUM_LABELS, ...ARTIST_TOOL_LABELS, }; const GRAFFITI_STYLE_ENTRIES = Object.entries(GRAFFITI_STYLE_LABELS); const GRAFFITI_MEDIUM_ENTRIES = Object.entries(GRAFFITI_MEDIUM_LABELS); const GRAFFITI_CONTEXT_ENTRIES = Object.entries(GRAFFITI_CONTEXT_LABELS); const GRAFFITI_ALL_LABELS: Record = { ...GRAFFITI_STYLE_LABELS, ...GRAFFITI_MEDIUM_LABELS, ...GRAFFITI_CONTEXT_LABELS, }; const TATTOO_STYLE_ENTRIES = Object.entries(TATTOO_STYLE_LABELS); const TATTOO_METHOD_ENTRIES = Object.entries(TATTOO_METHOD_LABELS); const TATTOO_ALL_LABELS: Record = { ...TATTOO_STYLE_LABELS, ...TATTOO_METHOD_LABELS, }; const TRAINER_FOCUS_ENTRIES = Object.entries(TRAINER_FOCUS_LABELS); const TRAINER_INJURY_ENTRIES = Object.entries(TRAINER_INJURY_LABELS); const TRAINER_CREDENTIAL_ENTRIES = Object.entries(TRAINER_CREDENTIAL_LABELS); const TRAINER_ALL_LABELS: Record = { ...TRAINER_FOCUS_LABELS, ...TRAINER_INJURY_LABELS, ...TRAINER_CREDENTIAL_LABELS, }; const MUSICIAN_GENRE_ENTRIES = Object.entries(MUSICIAN_GENRE_LABELS); const MUSICIAN_INSTRUMENT_ENTRIES = Object.entries(MUSICIAN_INSTRUMENT_LABELS); const MUSICIAN_ALL_LABELS: Record = { ...MUSICIAN_GENRE_LABELS, ...MUSICIAN_INSTRUMENT_LABELS, }; const JUDGE_FORMAT_ENTRIES = Object.entries(JUDGE_FORMAT_LABELS); const JUDGE_SPECIALTY_ENTRIES = Object.entries(JUDGE_SPECIALTY_LABELS); const JUDGE_LEVEL_ENTRIES = Object.entries(JUDGE_LEVEL_LABELS); const JUDGE_ALL_LABELS: Record = { ...JUDGE_FORMAT_LABELS, ...JUDGE_SPECIALTY_LABELS, ...JUDGE_LEVEL_LABELS, }; // All photographer label maps merged — for resolving a chip label regardless of category const PHOTO_ALL_LABELS: Record = { ...PHOTO_SERVICE_LABELS, ...PHOTO_CAMERA_LABELS, ...PHOTO_LENS_LABELS, ...PHOTO_LIGHTING_LABELS, ...PHOTO_SUPPORT_LABELS, ...PHOTO_STYLE_LABELS, }; type ServiceType = | 'coaching' | 'filming' | 'editing' | 'photography' | 'building' | 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging'; /** Map a ServiceType to the LabProfile.profileType it filters. */ const SERVICE_TO_PROFILE_TYPE: Record = { coaching: 'coach', filming: 'filmer', editing: 'editor', photography: 'photographer', building: 'builder', art: 'artist', graffiti: 'graffiti', tattoo: 'tattoo', training: 'trainer', music: 'musician', judging: 'judge', }; // ─── Per-category accent colours ───────────────────────────────────────────── const ACCENT = { coaching: { active: 'bg-[#ff0040] hover:bg-[#ff0040]/90 text-white border-[#ff0040]', chip: 'bg-[#ff0040] text-white border-[#ff0040]', chipHover: 'hover:border-[#ff0040]/60 hover:text-[#ff0040]', border: 'border-[#ff0040]/40', glow: 'shadow-[#ff0040]/20', icon: 'text-[#ff0040]', badge: 'bg-[#ff0040]/15 text-[#ff0040] border-[#ff0040]/30', stripe: 'bg-[#ff0040]', badgeSkill: 'bg-red-900/40 border-red-700/50 text-red-300', }, filming: { active: 'bg-orange-500 hover:bg-orange-600 text-white border-orange-500', chip: 'bg-orange-500 text-white border-orange-500', chipHover: 'hover:border-orange-500/60 hover:text-orange-400', border: 'border-orange-500/40', glow: 'shadow-orange-500/20', icon: 'text-orange-400', badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30', stripe: 'bg-orange-500', badgeSkill: 'bg-orange-900/40 border-orange-700/50 text-orange-300', }, editing: { active: 'bg-purple-600 hover:bg-purple-700 text-white border-purple-600', chip: 'bg-purple-600 text-white border-purple-600', chipHover: 'hover:border-purple-500/60 hover:text-purple-400', border: 'border-purple-500/40', glow: 'shadow-purple-500/20', icon: 'text-purple-400', badge: 'bg-purple-500/15 text-purple-400 border-purple-500/30', stripe: 'bg-purple-600', badgeSkill: 'bg-purple-900/40 border-purple-700/50 text-purple-300', }, photography: { active: 'bg-indigo-600 hover:bg-indigo-700 text-white border-indigo-600', chip: 'bg-indigo-600 text-white border-indigo-600', chipHover: 'hover:border-indigo-500/60 hover:text-indigo-400', border: 'border-indigo-500/40', glow: 'shadow-indigo-500/20', icon: 'text-indigo-400', badge: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30', stripe: 'bg-indigo-600', badgeSkill: 'bg-indigo-900/40 border-indigo-700/50 text-indigo-300', }, building: { active: 'bg-amber-500 hover:bg-amber-600 text-black border-amber-500', chip: 'bg-amber-500 text-black border-amber-500', chipHover: 'hover:border-amber-500/60 hover:text-amber-400', border: 'border-amber-500/40', glow: 'shadow-amber-500/20', icon: 'text-amber-400', badge: 'bg-amber-500/15 text-amber-400 border-amber-500/30', stripe: 'bg-amber-500', badgeSkill: 'bg-amber-900/40 border-amber-700/50 text-amber-300', }, art: { active: 'bg-fuchsia-600 hover:bg-fuchsia-700 text-white border-fuchsia-600', chip: 'bg-fuchsia-600 text-white border-fuchsia-600', chipHover: 'hover:border-fuchsia-500/60 hover:text-fuchsia-400', border: 'border-fuchsia-500/40', glow: 'shadow-fuchsia-500/20', icon: 'text-fuchsia-400', badge: 'bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/30', stripe: 'bg-fuchsia-600', badgeSkill: 'bg-fuchsia-900/40 border-fuchsia-700/50 text-fuchsia-300', }, graffiti: { active: 'bg-lime-500 hover:bg-lime-600 text-black border-lime-500', chip: 'bg-lime-500 text-black border-lime-500', chipHover: 'hover:border-lime-500/60 hover:text-lime-400', border: 'border-lime-500/40', glow: 'shadow-lime-500/20', icon: 'text-lime-400', badge: 'bg-lime-500/15 text-lime-400 border-lime-500/30', stripe: 'bg-lime-500', badgeSkill: 'bg-lime-900/40 border-lime-700/50 text-lime-300', }, tattoo: { active: 'bg-rose-600 hover:bg-rose-700 text-white border-rose-600', chip: 'bg-rose-600 text-white border-rose-600', chipHover: 'hover:border-rose-500/60 hover:text-rose-400', border: 'border-rose-500/40', glow: 'shadow-rose-500/20', icon: 'text-rose-400', badge: 'bg-rose-500/15 text-rose-400 border-rose-500/30', stripe: 'bg-rose-600', badgeSkill: 'bg-rose-900/40 border-rose-700/50 text-rose-300', }, training: { active: 'bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600', chip: 'bg-emerald-600 text-white border-emerald-600', chipHover: 'hover:border-emerald-500/60 hover:text-emerald-400', border: 'border-emerald-500/40', glow: 'shadow-emerald-500/20', icon: 'text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', stripe: 'bg-emerald-600', badgeSkill: 'bg-emerald-900/40 border-emerald-700/50 text-emerald-300', }, music: { active: 'bg-sky-500 hover:bg-sky-600 text-white border-sky-500', chip: 'bg-sky-500 text-white border-sky-500', chipHover: 'hover:border-sky-500/60 hover:text-sky-400', border: 'border-sky-500/40', glow: 'shadow-sky-500/20', icon: 'text-sky-400', badge: 'bg-sky-500/15 text-sky-400 border-sky-500/30', stripe: 'bg-sky-500', badgeSkill: 'bg-sky-900/40 border-sky-700/50 text-sky-300', }, judging: { active: 'bg-yellow-500 hover:bg-yellow-600 text-black border-yellow-500', chip: 'bg-yellow-500 text-black border-yellow-500', chipHover: 'hover:border-yellow-500/60 hover:text-yellow-400', border: 'border-yellow-500/40', glow: 'shadow-yellow-500/20', icon: 'text-yellow-400', badge: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30', stripe: 'bg-yellow-500', badgeSkill: 'bg-yellow-900/40 border-yellow-700/50 text-yellow-300', }, }; // ─── FAB — offer services ───────────────────────────────────────────────────── function OfferFAB() { const [open, setOpen] = useState(false); const ref = useRef(null); // Close on outside click useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); const items = [ { to: '/become-coach', label: 'Offer Coaching', color: 'bg-[#ff0040] hover:bg-[#ff0040]/90', icon: GraduationCap }, { to: '/become-filmer', label: 'Offer Filming', color: 'bg-orange-500 hover:bg-orange-600', icon: Video }, { to: '/become-photographer', label: 'Offer Photography', color: 'bg-indigo-600 hover:bg-indigo-700', icon: Camera }, { to: '/become-editor', label: 'Offer Editing', color: 'bg-purple-600 hover:bg-purple-700', icon: Scissors }, { to: '/become-builder', label: 'Offer Building', color: 'bg-amber-500 hover:bg-amber-600 text-black', icon: HardHat }, { to: '/become-artist', label: 'Offer Design / Art', color: 'bg-fuchsia-600 hover:bg-fuchsia-700', icon: Palette }, { to: '/become-graffiti', label: 'Offer Graffiti', color: 'bg-lime-500 hover:bg-lime-600 text-black', icon: SprayCan }, { to: '/become-tattoo', label: 'Offer Tattoo', color: 'bg-rose-600 hover:bg-rose-700', icon: Brush }, { to: '/become-trainer', label: 'Offer Training / Physio', color: 'bg-emerald-600 hover:bg-emerald-700', icon: Dumbbell }, { to: '/become-musician', label: 'Add Music Profile', color: 'bg-sky-500 hover:bg-sky-600', icon: Music }, { to: '/become-judge', label: 'Judge Events', color: 'bg-yellow-500 hover:bg-yellow-600 text-black', icon: Gavel }, { to: '/card', label: 'Get Rider Card', color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard }, ]; return (
{/* Dropdown */} {open && (
{items.map(({ to, label, color, icon: Icon }) => ( setOpen(false)} className={cn( 'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-bold uppercase tracking-wide transition-all active:scale-95', color )} > {label} ))}
)}
); } // ─── Sticky toolbar ─────────────────────────────────────────────────────────── interface ToolbarProps { serviceType: ServiceType; onServiceChange: (t: ServiceType) => void; searchQuery: string; onSearchChange: (q: string) => void; locationQuery: string; onLocationChange: (q: string) => void; activeFilterCount: number; onOpenFilters: () => void; counts: Record; totalCount: number; isLoading: boolean; // Active filter chips data selectedSkills: string[]; onRemoveSkill: (s: string) => void; sortBy: SortOption; trustFilter: TrustFilterLevel; onClearAll: () => void; serviceType: ServiceType; } function getSkillLabelForType(skill: string, serviceType: ServiceType): string { if (serviceType === 'coaching') return COACH_SKILL_LABELS[skill] || skill; if (serviceType === 'filming') return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill; if (serviceType === 'photography') return PHOTO_ALL_LABELS[skill] || skill; if (serviceType === 'building') return BUILDER_ALL_LABELS[skill] || skill; if (serviceType === 'art') return ARTIST_ALL_LABELS[skill] || skill; if (serviceType === 'graffiti') return GRAFFITI_ALL_LABELS[skill] || skill; if (serviceType === 'tattoo') return TATTOO_ALL_LABELS[skill] || skill; if (serviceType === 'training') return TRAINER_ALL_LABELS[skill] || skill; if (serviceType === 'music') return MUSICIAN_ALL_LABELS[skill] || skill; if (serviceType === 'judging') return JUDGE_ALL_LABELS[skill] || skill; return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill; } function LabToolbar({ serviceType, onServiceChange, searchQuery, onSearchChange, locationQuery, onLocationChange, activeFilterCount, onOpenFilters, counts, totalCount, isLoading, selectedSkills, onRemoveSkill, sortBy, trustFilter, onClearAll, }: ToolbarProps) { const tabs: { key: ServiceType; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ { key: 'coaching', label: 'Coaches', icon: GraduationCap }, { key: 'filming', label: 'Filmers', icon: Video }, { key: 'photography', label: 'Photo', icon: Camera }, { key: 'editing', label: 'Editors', icon: Scissors }, { key: 'building', label: 'Builders', icon: HardHat }, { key: 'art', label: 'Artists', icon: Palette }, { key: 'graffiti', label: 'Graffiti', icon: SprayCan }, { key: 'tattoo', label: 'Tattoo', icon: Brush }, { key: 'training', label: 'Trainers', icon: Dumbbell }, { key: 'music', label: 'Music', icon: Music }, { key: 'judging', label: 'Judges', icon: Gavel }, ]; const accent = ACCENT[serviceType]; // Build active filter chip labels const activeChips: { key: string; label: string }[] = []; selectedSkills.forEach((s) => { activeChips.push({ key: s, label: getSkillLabelForType(s, serviceType) }); }); const hasNonSkillFilters = trustFilter !== 'all' || sortBy !== 'recent'; return (
{/* Tab row — a wrapping grid so every craft is visible at a glance with no horizontal scrolling. 10 crafts lay out as two tidy rows of five on phones, and flex to a single line on wide screens. Icon-over-label stack keeps each tab compact but tappable; a count badge floats in the corner. */}
{tabs.map(({ key, label, icon: Icon }) => { const isActive = serviceType === key; const a = ACCENT[key]; return ( ); })}
{/* Search + Location + Filter row */}
{/* Search by name */}
onSearchChange(e.target.value)} placeholder="Name, bio…" className="w-full h-9 pl-8 pr-3 rounded-xl bg-white/5 border border-white/10 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-white/30 transition-all" /> {searchQuery && ( )}
{/* Location quick-filter */}
onLocationChange(e.target.value)} placeholder="City…" className="w-full h-9 pl-8 pr-3 rounded-xl bg-white/5 border border-white/10 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-white/30 transition-all" /> {locationQuery && ( )}
{/* Filter button */}
{/* Active filter chips row — only shown when filters are active */} {(activeChips.length > 0 || hasNonSkillFilters) && (
{/* Skill chips */} {activeChips.map(({ key, label }) => ( ))} {/* Sort chip */} {sortBy !== 'recent' && ( Top Rated )} {/* Trust chip */} {trustFilter !== 'all' && ( {trustFilter === 'verified' ? 'Verified' : trustFilter === 'trusted' ? 'Trusted' : 'Following'} )} {/* Clear all */}
)} {/* Results count */} {!isLoading && (

{totalCount === 0 ? 'No results' : `${totalCount} result${totalCount !== 1 ? 's' : ''}`}

)}
); } // ─── Filter drawer — bottom sheet on mobile, right panel on desktop ─────────── interface FilterDrawerProps { open: boolean; onClose: () => void; serviceType: ServiceType; selectedSkills: string[]; onToggleSkill: (s: string) => void; sortBy: SortOption; onSortChange: (s: SortOption) => void; trustFilter: TrustFilterLevel; onTrustChange: (t: TrustFilterLevel) => void; onClearAll: () => void; activeCount: number; } function FilterDrawer({ open, onClose, serviceType, selectedSkills, onToggleSkill, sortBy, onSortChange, trustFilter, onTrustChange, onClearAll, activeCount, }: FilterDrawerProps) { const accent = ACCENT[serviceType]; const skillSections: { title: string; entries: [string, string][]; activeColor: string }[] = serviceType === 'coaching' ? [{ title: 'Skills to Learn', entries: COACH_SKILL_ENTRIES, activeColor: accent.chip }] : serviceType === 'filming' ? [ { title: 'Content Type', entries: FILMING_SERVICE_ENTRIES, activeColor: accent.chip }, { title: 'Equipment & Style', entries: Object.entries(FILMING_SKILL_LABELS), activeColor: 'bg-orange-400 text-white border-orange-400' }, ] : serviceType === 'photography' ? [ { title: 'Services', entries: PHOTO_SERVICE_ENTRIES, activeColor: accent.chip }, { title: 'Camera', entries: PHOTO_CAMERA_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' }, { title: 'Lenses', entries: PHOTO_LENS_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' }, { title: 'Lighting', entries: PHOTO_LIGHTING_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' }, { title: 'Support & Rig', entries: PHOTO_SUPPORT_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' }, { title: 'Style', entries: PHOTO_STYLE_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' }, ] : serviceType === 'building' ? [ { title: 'What They Build', entries: BUILDER_SERVICE_ENTRIES, activeColor: accent.chip }, { title: 'Materials & Trade', entries: BUILDER_SKILL_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' }, { title: 'Project Scale', entries: BUILDER_SCALE_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' }, ] : serviceType === 'art' ? [ { title: 'What They Make', entries: ARTIST_SERVICE_ENTRIES, activeColor: accent.chip }, { title: 'Medium', entries: ARTIST_MEDIUM_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' }, { title: 'Software & Tools', entries: ARTIST_TOOL_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' }, ] : serviceType === 'graffiti' ? [ { title: 'Style', entries: GRAFFITI_STYLE_ENTRIES, activeColor: accent.chip }, { title: 'Medium', entries: GRAFFITI_MEDIUM_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' }, { title: 'Work Context', entries: GRAFFITI_CONTEXT_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' }, ] : serviceType === 'tattoo' ? [ { title: 'Style', entries: TATTOO_STYLE_ENTRIES, activeColor: accent.chip }, { title: 'Method', entries: TATTOO_METHOD_ENTRIES, activeColor: 'bg-rose-400 text-white border-rose-400' }, ] : serviceType === 'training' ? [ { title: 'Focus', entries: TRAINER_FOCUS_ENTRIES, activeColor: accent.chip }, { title: 'Injuries Treated', entries: TRAINER_INJURY_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' }, { title: 'Credentials & Setting', entries: TRAINER_CREDENTIAL_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' }, ] : serviceType === 'music' ? [ { title: 'Genre', entries: MUSICIAN_GENRE_ENTRIES, activeColor: accent.chip }, { title: 'Instruments & Role', entries: MUSICIAN_INSTRUMENT_ENTRIES, activeColor: 'bg-sky-400 text-white border-sky-400' }, ] : serviceType === 'judging' ? [ { title: 'Event Formats', entries: JUDGE_FORMAT_ENTRIES, activeColor: accent.chip }, { title: 'Specialty', entries: JUDGE_SPECIALTY_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' }, { title: 'Level / Circuit', entries: JUDGE_LEVEL_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' }, ] : [ { title: 'Edit Type', entries: EDITOR_SERVICE_ENTRIES, activeColor: accent.chip }, { title: 'Software & Tools', entries: EDITOR_SKILL_ENTRIES, activeColor: 'bg-purple-400 text-white border-purple-400' }, ]; const trustOptions: { value: TrustFilterLevel; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ { value: 'all', label: 'All', icon: Users }, { value: 'verified', label: 'Verified', icon: CheckCircle2 }, { value: 'trusted', label: 'Trusted', icon: Shield }, { value: 'following', label: 'Following', icon: CheckCircle2 }, ]; const drawerContent = ( <> {/* Header */}
Filters {activeCount > 0 && ( {activeCount} active )}
{activeCount > 0 && ( )}
{/* Scrollable body */}
{/* Sort */}

Sort By

{[ { value: 'recent' as SortOption, label: 'Newest', icon: Clock }, { value: 'trust' as SortOption, label: 'Top Rated', icon: Star }, ].map(({ value, label, icon: Icon }) => ( ))}
{/* Trust — inline pill buttons, no dropdown */}

Trust Level

{trustOptions.map(({ value, label, icon: Icon }) => ( ))}
{/* Skill chips per section */} {skillSections.map(({ title, entries, activeColor }) => (

{title}

{entries.map(([key, label]) => { const isActive = selectedSkills.includes(key); return ( ); })}
))}
{/* Apply */}
); return ( <> {/* Mobile: bottom sheet */}
!v && onClose()}> Filters {drawerContent}
{/* Desktop: right side panel */}
!v && onClose()}> Filters {drawerContent}
); } // ─── Profile Card ───────────────────────────────────────────────────────────── function ProfileCard({ profile }: { profile: LabProfile }) { const author = useAuthor(profile.pubkey); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(profile.pubkey); const avatar = profile.image || metadata?.picture; const nip05 = metadata?.nip05; const PROFILE_TO_SERVICE: Record = { coach: 'coaching', filmer: 'filming', editor: 'editing', photographer: 'photography', builder: 'building', artist: 'art', graffiti: 'graffiti', tattoo: 'tattoo', trainer: 'training', musician: 'music', judge: 'judging', }; const serviceType: ServiceType = PROFILE_TO_SERVICE[profile.profileType]; const accent = ACCENT[serviceType]; const TYPE_ICONS: Record> = { coaching: GraduationCap, filming: Video, editing: Scissors, photography: Camera, building: HardHat, art: Palette, graffiti: SprayCan, tattoo: Brush, training: Dumbbell, music: Music, judging: Gavel, }; const TypeIcon = TYPE_ICONS[serviceType]; const getSkillLabel = (skill: string) => getSkillLabelForType(skill, serviceType); return (
{/* Left color stripe */}
{/* Avatar */}
{displayName.slice(0, 2).toUpperCase()} {/* Type badge on avatar */}
{/* Main content */}
{/* Name row */}

{displayName} {profile.entityType === 'collective' && ( {profile.members.length > 0 ? profile.members.length : ''} )}

{nip05 && (

✓ {nip05}

)}
{/* Title + location */}

{profile.title}

{profile.location && ( {profile.location} )} {profile.experience && ( {profile.experience}yr exp )}
{/* Skill chips */} {profile.skills.length > 0 && (
{profile.skills.slice(0, 3).map((skill: string) => ( {getSkillLabel(skill)} ))} {profile.skills.length > 3 && ( +{profile.skills.length - 3} )}
)} {/* Rate */} {profile.rate && (

💰 {profile.rate}

)}
); } // ─── Skeleton card ──────────────────────────────────────────────────────────── function SkeletonCard() { return (
); } // ─── Empty state per-category ───────────────────────────────────────────────── function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boolean }) { const configs = { coaching: { icon: GraduationCap, title: 'No coaches yet', body: 'Be the first to offer skateboarding coaching in your area.', cta: 'Offer Coaching', to: '/become-coach', color: 'bg-[#ff0040] hover:bg-[#ff0040]/90', }, filming: { icon: Video, title: 'No filmers yet', body: 'Be the first to offer skate filming — clips, full parts, contest coverage and more.', cta: 'Offer Filming', to: '/become-filmer', color: 'bg-orange-500 hover:bg-orange-600', }, photography: { icon: Camera, title: 'No photographers yet', body: 'Be the first to offer skate photography in your area — action shots, sequences, editorial and more.', cta: 'Offer Photography', to: '/become-photographer', color: 'bg-indigo-600 hover:bg-indigo-700', }, editing: { icon: Scissors, title: 'No video editors yet', body: 'Be the first to offer skate video editing services.', cta: 'Offer Editing', to: '/become-editor', color: 'bg-purple-600 hover:bg-purple-700', }, building: { icon: HardHat, title: 'No builders yet', body: 'Be the first to list your ramp, park or DIY building — concrete, wood, rails and more.', cta: 'Offer Building', to: '/become-builder', color: 'bg-amber-500 hover:bg-amber-600 text-black', }, art: { icon: Palette, title: 'No artists yet', body: 'Be the first to offer board graphics, logos, posters and merch for the scene.', cta: 'Offer Design / Art', to: '/become-artist', color: 'bg-fuchsia-600 hover:bg-fuchsia-700', }, graffiti: { icon: SprayCan, title: 'No graffiti artists yet', body: 'Be the first to offer pieces, murals and skatepark art in your area.', cta: 'Offer Graffiti', to: '/become-graffiti', color: 'bg-lime-500 hover:bg-lime-600 text-black', }, tattoo: { icon: Brush, title: 'No tattoo artists yet', body: 'Be the first skate-culture tattoo artist to list — flash, custom, fine line and more.', cta: 'Offer Tattoo', to: '/become-tattoo', color: 'bg-rose-600 hover:bg-rose-700', }, training: { icon: Dumbbell, title: 'No trainers yet', body: 'Be the first trainer or physio to help skaters get strong and stay healthy.', cta: 'Offer Training / Physio', to: '/become-trainer', color: 'bg-emerald-600 hover:bg-emerald-700', }, music: { icon: Music, title: 'No musicians yet', body: 'Skate and make music? Add your band or solo project — genres, instruments and links.', cta: 'Add Music Profile', to: '/become-musician', color: 'bg-sky-500 hover:bg-sky-600', }, judging: { icon: Gavel, title: 'No judges yet', body: 'Run a fair contest? List yourself to judge Game of S.K.A.T.E., best trick jams, contests and touring stops.', cta: 'Judge Events', to: '/become-judge', color: 'bg-yellow-500 hover:bg-yellow-600 text-black', }, }; const c = configs[serviceType]; const Icon = c.icon; return (

{c.title}

{c.body}

{user ? ( ) : (

Log in to create a profile

)} Have questions? Check the FAQ →
); } // ─── Page ───────────────────────────────────────────────────────────────────── export default function LabPage() { const { t } = useTranslation(); useSeoMeta({ title: 'The Lab - SkateSpots', description: 'Find skateboarding coaches, filmers, photographers, and video editors.', }); const { user } = useCurrentUser(); const { data: profiles, isLoading } = useLabProfiles(100); // Allow deep-linking to a specific craft, e.g. /lab?craft=filming — used by // the community stats cards so tapping a number lands on that craft's list. const [searchParams] = useSearchParams(); const initialCraft = (() => { const c = searchParams.get('craft'); const valid: ServiceType[] = ['coaching', 'filming', 'editing', 'photography', 'building', 'art', 'graffiti', 'tattoo', 'training', 'music', 'judging']; return c && (valid as string[]).includes(c) ? (c as ServiceType) : 'coaching'; })(); const [searchQuery, setSearchQuery] = useState(''); const [locationQuery, setLocationQuery] = useState(''); const [selectedSkills, setSelectedSkills] = useState([]); const [serviceType, setServiceType] = useState(initialCraft); const [trustFilter, setTrustFilter] = useState('all'); const [sortBy, setSortBy] = useState('recent'); const [filtersOpen, setFiltersOpen] = useState(false); const profilePubkeys = useMemo(() => profiles?.map((p) => p.pubkey) || [], [profiles]); const { data: trustScores } = useBatchTrustScores(profilePubkeys); const getTrustScore = (pubkey: string) => trustScores?.get(pubkey); // Fetch all authors for search-by-name support // We collect metadata from ProfileCard's useAuthor calls via a shared memo approach // The search includes displayName by filtering against the author metadata in ProfileCard // Since we can't call hooks in loops at the page level, we implement name search // by passing the searchQuery down and letting ProfileCard self-filter via a parent store. // Simpler: we filter by profile fields, and the rendered cards handle their own display. // Name search: we add it as a secondary match against profile.title (already done) but // also expose a `displayName` field via a lookup map we build from the query cache. // For now, profile.title is the coach's self-described role (e.g. "Skate Coach Berlin") // and the display name comes from kind 0. We add the genUserName fallback to search too. const displayNameMap = useMemo(() => { // Build a map of pubkey -> genUserName fallback for client-side name search const map = new Map(); profiles?.forEach((p) => { map.set(p.pubkey, genUserName(p.pubkey)); }); return map; }, [profiles]); const toggleSkill = (skill: string) => { setSelectedSkills((prev) => prev.includes(skill) ? prev.filter((s) => s !== skill) : [...prev, skill] ); }; const removeSkill = (skill: string) => { setSelectedSkills((prev) => prev.filter((s) => s !== skill)); }; // Counts per tab (unfiltered by skills/search/location, just by type) const counts = useMemo>(() => { const empty: Record = { coaching: 0, filming: 0, editing: 0, photography: 0, building: 0, art: 0, graffiti: 0, tattoo: 0, training: 0, music: 0, judging: 0, }; if (!profiles) return empty; const byType = (t: ServiceType) => profiles.filter((p) => p.profileType === SERVICE_TO_PROFILE_TYPE[t]).length; return { coaching: byType('coaching'), filming: byType('filming'), photography: byType('photography'), editing: byType('editing'), building: byType('building'), art: byType('art'), graffiti: byType('graffiti'), tattoo: byType('tattoo'), training: byType('training'), music: byType('music'), judging: byType('judging'), }; }, [profiles]); const filteredProfiles = useMemo(() => { if (!profiles) return []; let filtered = profiles.filter((profile) => { if (profile.profileType !== SERVICE_TO_PROFILE_TYPE[serviceType]) return false; // Text search: title, bio, and genUserName fallback if (searchQuery) { const q = searchQuery.toLowerCase(); const fallbackName = displayNameMap.get(profile.pubkey) ?? ''; const matchesTitle = profile.title.toLowerCase().includes(q); const matchesBio = profile.bio?.toLowerCase().includes(q); const matchesName = fallbackName.toLowerCase().includes(q); if (!matchesTitle && !matchesBio && !matchesName) return false; } // Location filter (dedicated city search) if (locationQuery) { const lq = locationQuery.toLowerCase(); const matchesLocation = profile.location?.toLowerCase().includes(lq); if (!matchesLocation) return false; } if (selectedSkills.length > 0) { if (!profile.skills.some((s) => selectedSkills.includes(s))) return false; } return true; }); if (trustScores && trustFilter !== 'all') { filtered = filterByTrustLevel(filtered, trustFilter, getTrustScore); } filtered.sort((a, b) => { if (sortBy === 'trust') { const diff = (getTrustScore(b.pubkey) || 0) - (getTrustScore(a.pubkey) || 0); if (diff !== 0) return diff; } return b.createdAt - a.createdAt; }); return filtered; }, [profiles, searchQuery, locationQuery, selectedSkills, serviceType, trustFilter, trustScores, sortBy, displayNameMap]); // Active filter count (skills + trust + sort) — location and search are separate quick filters const activeFilterCount = selectedSkills.length + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0); const handleServiceChange = (type: ServiceType) => { setServiceType(type); setSelectedSkills([]); }; const handleClearAll = () => { setSelectedSkills([]); setTrustFilter('all'); setSortBy('recent'); }; return (
{/* ── Page header ── */}

THE{' '} LAB

{t('labPage.subtitle')}

{/* CTA — logged in: FAB dropdown. Logged out: small badge */} {user ? ( ) : ( 🔒 Login to offer services )}
{/* ── Skater directory cross-link ── */}

Just a skater?

The Lab is for services. Browse & join the whole skater community

{/* ── Sticky toolbar (tabs + search + location + filter button + active chips) ── */} setFiltersOpen(true)} counts={counts} totalCount={filteredProfiles.length} isLoading={isLoading} selectedSkills={selectedSkills} onRemoveSkill={removeSkill} sortBy={sortBy} trustFilter={trustFilter} onClearAll={handleClearAll} /> {/* ── Results grid ── */}
{isLoading ? (
{Array.from({ length: 6 }).map((_, i) => )}
) : filteredProfiles.length > 0 ? (
{filteredProfiles.map((profile) => ( ))}
) : ( )}
{/* ── Crews in this scene ── */}
{/* ── Filter drawer ── */} setFiltersOpen(false)} serviceType={serviceType} selectedSkills={selectedSkills} onToggleSkill={toggleSkill} sortBy={sortBy} onSortChange={setSortBy} trustFilter={trustFilter} onTrustChange={setTrustFilter} onClearAll={handleClearAll} activeCount={activeFilterCount} />
); }