import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { useSeoMeta } from '@unhead/react'; import { Link, useSearchParams } from 'react-router-dom'; import { LazyMount } from '@/components/LazyMount'; import { Badge } from '@/components/ui/badge'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter'; import { COACH_FACETS, coachOptionKey, emptyCoachFilters, type CoachFacetKey } from '@/lib/coachOptions'; import { parseLabExtras, type LabExtras } from '@/lib/labExtras'; import { labProfilePath } from '@/lib/labRoutes'; 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/language'; 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, ORGANIZER_EVENT_LABELS, ORGANIZER_SERVICE_LABELS, ORGANIZER_SCALE_LABELS, BARBER_SERVICE_LABELS, BARBER_STYLE_LABELS, BARBER_SETTING_LABELS, TRANSPORT_VEHICLE_LABELS, TRANSPORT_SERVICE_LABELS, TRANSPORT_FEATURE_LABELS, TRANSPORT_CAPACITY_LABELS, TRANSPORT_COVERAGE_LABELS, DESIGNER_SERVICE_LABELS, DESIGNER_METHOD_LABELS, DESIGNER_MATERIAL_LABELS, } from '@/lib/types'; import { COACH_SKILL_KEYS, FILMING_SERVICE_KEYS, FILMING_SKILL_KEYS, EDITOR_SERVICE_KEYS, EDITOR_SKILL_KEYS, PHOTO_SERVICE_KEYS, PHOTO_CAMERA_KEYS, PHOTO_LENS_KEYS, PHOTO_LIGHTING_KEYS, PHOTO_SUPPORT_KEYS, PHOTO_STYLE_KEYS, BUILDER_SERVICE_KEYS, BUILDER_SKILL_KEYS, BUILDER_SCALE_KEYS, ARTIST_SERVICE_KEYS, ARTIST_MEDIUM_KEYS, ARTIST_TOOL_KEYS, GRAFFITI_STYLE_KEYS, GRAFFITI_MEDIUM_KEYS, GRAFFITI_CONTEXT_KEYS, TATTOO_STYLE_KEYS, TATTOO_METHOD_KEYS, TRAINER_FOCUS_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_STYLE_KEYS, BARBER_SETTING_KEYS, TRANSPORT_VEHICLE_KEYS, TRANSPORT_SERVICE_KEYS, TRANSPORT_FEATURE_KEYS, TRANSPORT_CAPACITY_KEYS, TRANSPORT_COVERAGE_KEYS, DESIGNER_SERVICE_KEYS, DESIGNER_METHOD_KEYS, DESIGNER_MATERIAL_KEYS, } from '@/lib/labelKeys'; 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, Tent, Sparkles, Pencil, Shirt, Truck, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { EmptyState as SharedEmptyState } from '@/components/EmptyState'; 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, }; const ORGANIZER_EVENT_ENTRIES = Object.entries(ORGANIZER_EVENT_LABELS); const ORGANIZER_SERVICE_ENTRIES = Object.entries(ORGANIZER_SERVICE_LABELS); const ORGANIZER_SCALE_ENTRIES = Object.entries(ORGANIZER_SCALE_LABELS); const ORGANIZER_ALL_LABELS: Record = { ...ORGANIZER_EVENT_LABELS, ...ORGANIZER_SERVICE_LABELS, ...ORGANIZER_SCALE_LABELS, }; const BARBER_SERVICE_ENTRIES = Object.entries(BARBER_SERVICE_LABELS); const BARBER_STYLE_ENTRIES = Object.entries(BARBER_STYLE_LABELS); const BARBER_SETTING_ENTRIES = Object.entries(BARBER_SETTING_LABELS); const BARBER_ALL_LABELS: Record = { ...BARBER_SERVICE_LABELS, ...BARBER_STYLE_LABELS, ...BARBER_SETTING_LABELS, }; const TRANSPORT_VEHICLE_ENTRIES = Object.entries(TRANSPORT_VEHICLE_LABELS); const TRANSPORT_SERVICE_ENTRIES = Object.entries(TRANSPORT_SERVICE_LABELS); const TRANSPORT_FEATURE_ENTRIES = Object.entries(TRANSPORT_FEATURE_LABELS); const TRANSPORT_CAPACITY_ENTRIES = Object.entries(TRANSPORT_CAPACITY_LABELS); const TRANSPORT_COVERAGE_ENTRIES = Object.entries(TRANSPORT_COVERAGE_LABELS); const TRANSPORT_ALL_LABELS: Record = { ...TRANSPORT_VEHICLE_LABELS, ...TRANSPORT_SERVICE_LABELS, ...TRANSPORT_FEATURE_LABELS, ...TRANSPORT_CAPACITY_LABELS, ...TRANSPORT_COVERAGE_LABELS, }; const DESIGNER_SERVICE_ENTRIES = Object.entries(DESIGNER_SERVICE_LABELS); const DESIGNER_METHOD_ENTRIES = Object.entries(DESIGNER_METHOD_LABELS); const DESIGNER_MATERIAL_ENTRIES = Object.entries(DESIGNER_MATERIAL_LABELS); const DESIGNER_ALL_LABELS: Record = { ...DESIGNER_SERVICE_LABELS, ...DESIGNER_METHOD_LABELS, ...DESIGNER_MATERIAL_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' | 'organizing' | 'barbering' | 'clothing' | 'transport'; /** 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', organizing: 'organizer', barbering: 'barber', clothing: 'designer', transport: 'transport', }; /** Lab option translation keys per service type (merged across each craft's maps). */ const KEYS_BY_TYPE: Record> = { coaching: COACH_SKILL_KEYS, filming: { ...FILMING_SERVICE_KEYS, ...FILMING_SKILL_KEYS }, editing: { ...EDITOR_SERVICE_KEYS, ...EDITOR_SKILL_KEYS }, photography: { ...PHOTO_SERVICE_KEYS, ...PHOTO_CAMERA_KEYS, ...PHOTO_LENS_KEYS, ...PHOTO_LIGHTING_KEYS, ...PHOTO_SUPPORT_KEYS, ...PHOTO_STYLE_KEYS }, building: { ...BUILDER_SERVICE_KEYS, ...BUILDER_SKILL_KEYS, ...BUILDER_SCALE_KEYS }, art: { ...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 }, training: { ...TRAINER_FOCUS_KEYS, ...TRAINER_INJURY_KEYS, ...TRAINER_CREDENTIAL_KEYS }, music: { ...MUSICIAN_GENRE_KEYS, ...MUSICIAN_INSTRUMENT_KEYS }, judging: { ...JUDGE_FORMAT_KEYS, ...JUDGE_SPECIALTY_KEYS, ...JUDGE_LEVEL_KEYS }, organizing: { ...ORGANIZER_EVENT_KEYS, ...ORGANIZER_SERVICE_KEYS, ...ORGANIZER_SCALE_KEYS }, barbering: { ...BARBER_SERVICE_KEYS, ...BARBER_STYLE_KEYS, ...BARBER_SETTING_KEYS }, clothing: { ...DESIGNER_SERVICE_KEYS, ...DESIGNER_METHOD_KEYS, ...DESIGNER_MATERIAL_KEYS }, transport: { ...TRANSPORT_VEHICLE_KEYS, ...TRANSPORT_SERVICE_KEYS, ...TRANSPORT_FEATURE_KEYS, ...TRANSPORT_CAPACITY_KEYS, ...TRANSPORT_COVERAGE_KEYS }, }; // ─── 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', }, organizing: { active: 'bg-teal-600 hover:bg-teal-700 text-white border-teal-600', chip: 'bg-teal-600 text-white border-teal-600', chipHover: 'hover:border-teal-500/60 hover:text-teal-400', border: 'border-teal-500/40', glow: 'shadow-teal-500/20', icon: 'text-teal-400', badge: 'bg-teal-500/15 text-teal-400 border-teal-500/30', stripe: 'bg-teal-600', badgeSkill: 'bg-teal-900/40 border-teal-700/50 text-teal-300', }, barbering: { active: 'bg-cyan-600 hover:bg-cyan-700 text-white border-cyan-600', chip: 'bg-cyan-600 text-white border-cyan-600', chipHover: 'hover:border-cyan-500/60 hover:text-cyan-400', border: 'border-cyan-500/40', glow: 'shadow-cyan-500/20', icon: 'text-cyan-400', badge: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30', stripe: 'bg-cyan-600', badgeSkill: 'bg-cyan-900/40 border-cyan-700/50 text-cyan-300', }, clothing: { active: 'bg-violet-600 hover:bg-violet-700 text-white border-violet-600', chip: 'bg-violet-600 text-white border-violet-600', chipHover: 'hover:border-violet-500/60 hover:text-violet-400', border: 'border-violet-500/40', glow: 'shadow-violet-500/20', icon: 'text-violet-400', badge: 'bg-violet-500/15 text-violet-400 border-violet-500/30', stripe: 'bg-violet-600', badgeSkill: 'bg-violet-900/40 border-violet-700/50 text-violet-300', }, transport: { 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', }, }; // ─── FAB — offer services ───────────────────────────────────────────────────── function OfferFAB() { const { t } = useTranslation(); 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]); // Service offers — the colour is kept as a small icon chip, not a full row, // so the menu reads as one list instead of a wall of buttons. const serviceItems = [ { to: '/become/coach', label: t('labUi.offerCoaching'), color: 'bg-[#ff0040] hover:bg-[#ff0040]/90', icon: GraduationCap }, { to: '/become/filmer', label: t('labUi.offerFilming'), color: 'bg-orange-500 hover:bg-orange-600', icon: Video }, { to: '/become/photographer', label: t('labUi.offerPhotography'), color: 'bg-indigo-600 hover:bg-indigo-700', icon: Camera }, { to: '/become/editor', label: t('labUi.offerEditing'), color: 'bg-purple-600 hover:bg-purple-700', icon: Scissors }, { to: '/become/builder', label: t('labUi.offerBuilding'), color: 'bg-amber-500 hover:bg-amber-600 text-black', icon: HardHat }, { to: '/become/artist', label: t('labUi.offerDesignArt'), color: 'bg-fuchsia-600 hover:bg-fuchsia-700', icon: Palette }, { to: '/become/graffiti', label: t('labUi.offerGraffiti'), color: 'bg-lime-500 hover:bg-lime-600 text-black', icon: SprayCan }, { to: '/become/tattoo', label: t('labUi.offerTattoo'), color: 'bg-rose-600 hover:bg-rose-700', icon: Brush }, { to: '/become/trainer', label: t('labUi.offerTraining'), color: 'bg-emerald-600 hover:bg-emerald-700', icon: Dumbbell }, { to: '/become/musician', label: t('labUi.addMusicProfile'), color: 'bg-sky-500 hover:bg-sky-600', icon: Music }, { to: '/become/judge', label: t('labUi.judgeEvents'), color: 'bg-yellow-500 hover:bg-yellow-600 text-black', icon: Gavel }, { to: '/become/organizer', label: t('labUi.organizeEvents'), color: 'bg-teal-600 hover:bg-teal-700', icon: Tent }, { to: '/become/barber', label: t('labUi.offerCuts'), color: 'bg-cyan-600 hover:bg-cyan-700', icon: Sparkles }, { to: '/become/clothing', label: t('labUi.offerClothing'), color: 'bg-violet-600 hover:bg-violet-700', icon: Shirt }, ]; return (
{/* Dropdown — one clean list; the role colour is a small icon chip, and the Rider Card action stays pinned below the scrolling list. */} {open && (

{t('labUi.offerServices')}

{serviceItems.map(({ to, label, color, icon: Icon }) => ( setOpen(false)} className="group flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm font-semibold text-gray-200 transition-colors hover:bg-white/[0.07]" > {label} ))}
setOpen(false)} className="flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm font-semibold text-[#ccff00] transition-colors hover:bg-[#ccff00]/10" > {t('labUi.getRiderCard')}
)}
); } // ─── 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; coachChips: { key: string; label: string; facet: CoachFacetKey; value: string }[]; onRemoveCoachFacet: (facet: CoachFacetKey, value: string) => void; sortBy: SortOption; trustFilter: TrustFilterLevel; onClearAll: () => void; } function getSkillLabelForType(skill: string, serviceType: ServiceType, t: (key: string) => string): string { const key = KEYS_BY_TYPE[serviceType]?.[skill]; if (key) return t(key); 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; if (serviceType === 'organizing') return ORGANIZER_ALL_LABELS[skill] || skill; if (serviceType === 'barbering') return BARBER_ALL_LABELS[skill] || skill; if (serviceType === 'clothing') return DESIGNER_ALL_LABELS[skill] || skill; if (serviceType === 'transport') return TRANSPORT_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, coachChips, onRemoveCoachFacet, sortBy, trustFilter, onClearAll, }: ToolbarProps) { const { t } = useTranslation(); const tabs: { key: ServiceType; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ { key: 'coaching', label: t('labUi.tabCoaches'), icon: GraduationCap }, { key: 'filming', label: t('labUi.tabFilmers'), icon: Video }, { key: 'photography', label: t('labUi.tabPhoto'), icon: Camera }, { key: 'editing', label: t('labUi.tabEditors'), icon: Scissors }, { key: 'building', label: t('labUi.tabBuilders'), icon: HardHat }, { key: 'art', label: t('labUi.tabArtists'), icon: Palette }, { key: 'graffiti', label: t('labUi.tabGraffiti'), icon: SprayCan }, { key: 'tattoo', label: t('labUi.tabTattoo'), icon: Brush }, { key: 'training', label: t('labUi.tabTrainers'), icon: Dumbbell }, { key: 'music', label: t('labUi.tabMusic'), icon: Music }, { key: 'judging', label: t('labUi.tabJudges'), icon: Gavel }, { key: 'organizing', label: t('labUi.tabOrganizers'), icon: Tent }, { key: 'barbering', label: t('labUi.tabBarbers'), icon: Sparkles }, { key: 'clothing', label: t('labUi.tabClothing'), icon: Shirt }, { key: 'transport', label: t('labUi.tabTransport'), icon: Truck }, ]; 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, t) }); }); const hasNonSkillFilters = trustFilter !== 'all' || sortBy !== 'recent' || coachChips.length > 0; return (
{/* Tab row — a wrapping grid so every craft is visible at a glance with no horizontal scrolling. 11 crafts lay out 4-4-3 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={t('labUi.searchNamePlaceholder')} 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={t('labUi.searchCityPlaceholder')} 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 }) => ( ))} {/* Coaching facet chips */} {coachChips.map(({ key, label, facet, value }) => ( ))} {/* Sort chip */} {sortBy !== 'recent' && ( {t('labUi.topRated')} )} {/* Trust chip */} {trustFilter !== 'all' && ( {trustFilter === 'verified' ? t('common.verified') : trustFilter === 'trusted' ? t('labUi.trusted') : t('labUi.following')} )} {/* Clear all */}
)} {/* Results count */} {!isLoading && (

{totalCount === 0 ? t('labUi.noResults') : t(totalCount === 1 ? 'labUi.resultsOne' : 'labUi.resultsMany', { count: totalCount })}

)}
); } // ─── Filter drawer — bottom sheet on mobile, right panel on desktop ─────────── interface FilterDrawerProps { open: boolean; onClose: () => void; serviceType: ServiceType; selectedSkills: string[]; onToggleSkill: (s: string) => void; coachFacets: Record; coachFilters: Record; onToggleCoachFacet: (facet: CoachFacetKey, value: 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, coachFacets, coachFilters, onToggleCoachFacet, sortBy, onSortChange, trustFilter, onTrustChange, onClearAll, activeCount, }: FilterDrawerProps) { const { t } = useTranslation(); const accent = ACCENT[serviceType]; const skillSections: { title: string; entries: [string, string][]; activeColor: string }[] = serviceType === 'coaching' ? [{ title: t('labUi.filterSkillsToLearn'), entries: COACH_SKILL_ENTRIES, activeColor: accent.chip }] : serviceType === 'filming' ? [ { title: t('labUi.filterContentType'), entries: FILMING_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterEquipmentStyle'), entries: Object.entries(FILMING_SKILL_LABELS), activeColor: 'bg-orange-400 text-white border-orange-400' }, ] : serviceType === 'photography' ? [ { title: t('labUi.filterServices'), entries: PHOTO_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterCamera'), entries: PHOTO_CAMERA_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' }, { title: t('labUi.filterLenses'), entries: PHOTO_LENS_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' }, { title: t('labUi.filterLighting'), entries: PHOTO_LIGHTING_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' }, { title: t('labUi.filterSupportRig'), entries: PHOTO_SUPPORT_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' }, { title: t('labUi.filterStyle'), entries: PHOTO_STYLE_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' }, ] : serviceType === 'building' ? [ { title: t('labUi.filterWhatTheyBuild'), entries: BUILDER_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterMaterialsTrade'), entries: BUILDER_SKILL_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' }, { title: t('labUi.filterProjectScale'), entries: BUILDER_SCALE_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' }, ] : serviceType === 'art' ? [ { title: t('labUi.filterWhatTheyMake'), entries: ARTIST_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterMedium'), entries: ARTIST_MEDIUM_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' }, { title: t('labUi.filterSoftwareTools'), entries: ARTIST_TOOL_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' }, ] : serviceType === 'graffiti' ? [ { title: t('labUi.filterStyle'), entries: GRAFFITI_STYLE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterMedium'), entries: GRAFFITI_MEDIUM_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' }, { title: t('labUi.filterWorkContext'), entries: GRAFFITI_CONTEXT_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' }, ] : serviceType === 'tattoo' ? [ { title: t('labUi.filterStyle'), entries: TATTOO_STYLE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterMethod'), entries: TATTOO_METHOD_ENTRIES, activeColor: 'bg-rose-400 text-white border-rose-400' }, ] : serviceType === 'training' ? [ { title: t('labUi.filterFocus'), entries: TRAINER_FOCUS_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterInjuriesTreated'), entries: TRAINER_INJURY_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' }, { title: t('labUi.filterCredentialsSetting'), entries: TRAINER_CREDENTIAL_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' }, ] : serviceType === 'music' ? [ { title: t('labUi.filterGenre'), entries: MUSICIAN_GENRE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterInstrumentsRole'), entries: MUSICIAN_INSTRUMENT_ENTRIES, activeColor: 'bg-sky-400 text-white border-sky-400' }, ] : serviceType === 'judging' ? [ { title: t('labUi.filterEventFormats'), entries: JUDGE_FORMAT_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterSpecialty'), entries: JUDGE_SPECIALTY_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' }, { title: t('labUi.filterLevelCircuit'), entries: JUDGE_LEVEL_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' }, ] : serviceType === 'organizing' ? [ { title: t('labUi.filterEventTypes'), entries: ORGANIZER_EVENT_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterWhatYouHandle'), entries: ORGANIZER_SERVICE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' }, { title: t('labUi.filterScale'), entries: ORGANIZER_SCALE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' }, ] : serviceType === 'barbering' ? [ { title: t('labUi.filterWhatTheyOffer'), entries: BARBER_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterSignatureStyles'), entries: BARBER_STYLE_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' }, { title: t('labUi.filterSetting'), entries: BARBER_SETTING_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' }, ] : serviceType === 'clothing' ? [ { title: t('labUi.filterWhatTheyMake'), entries: DESIGNER_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterMethod'), entries: DESIGNER_METHOD_ENTRIES, activeColor: 'bg-violet-400 text-white border-violet-400' }, { title: t('labUi.filterMaterialsTrade'), entries: DESIGNER_MATERIAL_ENTRIES, activeColor: 'bg-violet-400 text-white border-violet-400' }, ] : serviceType === 'transport' ? [ { title: t('labUi.filterVehicles'), entries: TRANSPORT_VEHICLE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterTransportServices'), entries: TRANSPORT_SERVICE_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' }, { title: t('labUi.filterCapabilities'), entries: TRANSPORT_FEATURE_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' }, { title: t('labUi.filterCapacity'), entries: TRANSPORT_CAPACITY_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' }, { title: t('labUi.filterCoverage'), entries: TRANSPORT_COVERAGE_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' }, ] : [ { title: t('labUi.filterEditType'), entries: EDITOR_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterSoftwareTools'), 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: t('labUi.all'), icon: Users }, { value: 'verified', label: t('common.verified'), icon: CheckCircle2 }, { value: 'trusted', label: t('labUi.trusted'), icon: Shield }, { value: 'following', label: t('labUi.following'), icon: CheckCircle2 }, ]; const drawerContent = ( <> {/* Header */}
{t('labUi.filters')} {activeCount > 0 && ( {t('labUi.activeCount', { count: activeCount })} )}
{activeCount > 0 && ( )}
{/* Scrollable body */}
{/* Sort */}

{t('labUi.sortBy')}

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

{t('labUi.trustLevel')}

{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 ( ); })}
))} {/* Coaching refinements — only for coaches, and only facets present in the loaded data (so an empty filter never appears). */} {COACH_FACETS.map(({ key, titleKey, options }) => { const values = coachFacets[key]; if (!values || values.length === 0) return null; return (

{t(titleKey)}

{values.map((value) => { const active = coachFilters[key].includes(value); const optKey = coachOptionKey(options, value); return ( ); })}
); })}
{/* Apply */}
); return ( <> {/* Mobile: bottom sheet */}
!v && onClose()}> {t('labUi.filters')} {drawerContent}
{/* Desktop: right side panel */}
!v && onClose()}> {t('labUi.filters')} {drawerContent}
); } // ─── Profile Card ───────────────────────────────────────────────────────────── function ProfileCard({ profile }: { profile: LabProfile }) { const { t } = useTranslation(); 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', organizer: 'organizing', barber: 'barbering', designer: 'clothing', transport: 'transport', }; 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, organizing: Tent, barbering: Sparkles, clothing: Shirt, transport: Truck, }; const TypeIcon = TYPE_ICONS[serviceType]; const getSkillLabel = (skill: string) => getSkillLabelForType(skill, serviceType, t); 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 */} {/* Title / headline — only when it adds something beyond the name */} {profile.title && profile.title !== displayName && (

{profile.title}

)}
{profile.location && ( {profile.location} )} {profile.experience && ( {t('labUi.yearsExp', { count: profile.experience })} )}
{/* 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 { t } = useTranslation(); const configs = { coaching: { icon: GraduationCap, title: t('labUi.empty.coachesTitle'), body: t('labUi.empty.coachesBody'), cta: t('labUi.offerCoaching'), to: '/become/coach', color: 'bg-[#ff0040] hover:bg-[#ff0040]/90', }, filming: { icon: Video, title: t('labUi.empty.filmersTitle'), body: t('labUi.empty.filmersBody'), cta: t('labUi.offerFilming'), to: '/become/filmer', color: 'bg-orange-500 hover:bg-orange-600', }, photography: { icon: Camera, title: t('labUi.empty.photographersTitle'), body: t('labUi.empty.photographersBody'), cta: t('labUi.offerPhotography'), to: '/become/photographer', color: 'bg-indigo-600 hover:bg-indigo-700', }, editing: { icon: Scissors, title: t('labUi.empty.editorsTitle'), body: t('labUi.empty.editorsBody'), cta: t('labUi.offerEditing'), to: '/become/editor', color: 'bg-purple-600 hover:bg-purple-700', }, building: { icon: HardHat, title: t('labUi.empty.buildersTitle'), body: t('labUi.empty.buildersBody'), cta: t('labUi.offerBuilding'), to: '/become/builder', color: 'bg-amber-500 hover:bg-amber-600 text-black', }, art: { icon: Palette, title: t('labUi.empty.artistsTitle'), body: t('labUi.empty.artistsBody'), cta: t('labUi.offerDesignArt'), to: '/become/artist', color: 'bg-fuchsia-600 hover:bg-fuchsia-700', }, graffiti: { icon: SprayCan, title: t('labUi.empty.graffitiTitle'), body: t('labUi.empty.graffitiBody'), cta: t('labUi.offerGraffiti'), to: '/become/graffiti', color: 'bg-lime-500 hover:bg-lime-600 text-black', }, tattoo: { icon: Brush, title: t('labUi.empty.tattooTitle'), body: t('labUi.empty.tattooBody'), cta: t('labUi.offerTattoo'), to: '/become/tattoo', color: 'bg-rose-600 hover:bg-rose-700', }, training: { icon: Dumbbell, title: t('labUi.empty.trainersTitle'), body: t('labUi.empty.trainersBody'), cta: t('labUi.offerTraining'), to: '/become/trainer', color: 'bg-emerald-600 hover:bg-emerald-700', }, music: { icon: Music, title: t('labUi.empty.musiciansTitle'), body: t('labUi.empty.musiciansBody'), cta: t('labUi.addMusicProfile'), to: '/become/musician', color: 'bg-sky-500 hover:bg-sky-600', }, judging: { icon: Gavel, title: t('labUi.empty.judgesTitle'), body: t('labUi.empty.judgesBody'), cta: t('labUi.judgeEvents'), to: '/become/judge', color: 'bg-yellow-500 hover:bg-yellow-600 text-black', }, organizing: { icon: Tent, title: t('labUi.empty.organizersTitle'), body: t('labUi.empty.organizersBody'), cta: t('labUi.organizeEvents'), to: '/become/organizer', color: 'bg-teal-600 hover:bg-teal-700', }, barbering: { icon: Sparkles, title: t('labUi.empty.barbersTitle'), body: t('labUi.empty.barbersBody'), cta: t('labUi.offerCuts'), to: '/become/barber', color: 'bg-cyan-600 hover:bg-cyan-700', }, clothing: { icon: Shirt, title: t('labUi.empty.clothingTitle'), body: t('labUi.empty.clothingBody'), cta: t('labUi.offerClothing'), to: '/become/clothing', color: 'bg-violet-600 hover:bg-violet-700', }, transport: { icon: Truck, title: t('labUi.empty.transportTitle'), body: t('labUi.empty.transportBody'), cta: t('labUi.offerTransport'), to: '/become/transport', color: 'bg-amber-500 hover:bg-amber-600 text-black', }, }; const c = configs[serviceType]; const Icon = c.icon; return ( {c.body}{' '} {t('auth.login')} {' '} {t('labUi.empty.loginSuffix')} ) } actions={ user ? [{ label: c.cta, to: c.to, icon: Plus, className: c.color }] : [] } footer={{ label: t('labUi.empty.footer'), to: '/faq' }} /> ); } // ─── Page ───────────────────────────────────────────────────────────────────── export default function LabPage() { const { t } = useTranslation(); useSeoMeta({ title: t('seoUi.labTitle'), description: t('seoUi.labDesc'), }); const { user } = useCurrentUser(); const { data: profiles, isLoading } = useLabProfiles(100); const ownProfiles = useMemo( () => (profiles ?? []).filter((p) => p.pubkey === user?.pubkey), [profiles, user], ); // 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', 'organizing', 'barbering', 'clothing', 'transport']; return c && (valid as string[]).includes(c) ? (c as ServiceType) : 'coaching'; })(); const [searchQuery, setSearchQuery] = useState(''); const [locationQuery, setLocationQuery] = useState(''); const [selectedSkills, setSelectedSkills] = useState([]); const [coachFilters, setCoachFilters] = useState(emptyCoachFilters); 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 = useCallback((pubkey: string) => trustScores?.get(pubkey), [trustScores]); // 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)); }; // Shared enrichment values for a profile (craft-agnostic, read off the event). const extraValues = useCallback((p: LabProfile, key: keyof LabExtras): string[] => { const v = parseLabExtras(p.event)[key]; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; }, []); const toggleCoachFacet = (facet: CoachFacetKey, value: string) => { setCoachFilters((prev) => ({ ...prev, [facet]: prev[facet].includes(value) ? prev[facet].filter((v) => v !== value) : [...prev[facet], value], })); }; // Only offer facets that actually exist in the loaded profiles (any craft). const coachFacets = useMemo(() => { const collect = (key: CoachFacetKey) => { const set = new Set(); for (const p of profiles ?? []) for (const v of extraValues(p, key)) set.add(v); return [...set].sort(); }; return { formats: collect('formats'), levels: collect('levels'), ageGroups: collect('ageGroups'), credentials: collect('credentials'), availability: collect('availability'), languages: collect('languages'), } as Record; }, [profiles, extraValues]); const coachChips = useMemo(() => { const chips: { key: string; label: string; facet: CoachFacetKey; value: string }[] = []; for (const { key, options } of COACH_FACETS) { for (const value of coachFilters[key]) { const optKey = coachOptionKey(options, value); chips.push({ key: `${key}:${value}`, label: optKey ? t(optKey) : value, facet: key, value }); } } return chips; }, [coachFilters, t]); // 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, organizing: 0, barbering: 0, clothing: 0, transport: 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'), organizing: byType('organizing'), barbering: byType('barbering'), clothing: byType('clothing'), transport: byType('transport'), }; }, [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; } // Shared facet filters — AND across facets, OR within one. for (const { key } of COACH_FACETS) { const selected = coachFilters[key]; if (selected.length > 0 && !extraValues(profile, key).some((v) => selected.includes(v))) { 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, getTrustScore, trustScores, sortBy, displayNameMap, coachFilters, extraValues]); // Active filter count (skills + coaching facets + trust + sort) — location and search are separate quick filters const activeFilterCount = selectedSkills.length + coachChips.length + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0); const handleServiceChange = (type: ServiceType) => { setServiceType(type); setSelectedSkills([]); setCoachFilters(emptyCoachFilters()); }; const handleClearAll = () => { setSelectedSkills([]); setCoachFilters(emptyCoachFilters()); setTrustFilter('all'); setSortBy('recent'); }; return ( <>
{/* ── Page header ── */}

THE{' '} LAB

{t('labPage.subtitle')}

{/* CTA — logged in: FAB dropdown. Logged out: small badge */} {user ? ( ) : ( 🔒 {t('labUi.loginToOffer')} )}
{/* ── Skater directory cross-link ── */} {/* Neon skater glyph — vector icon, single-color, always renders on any device */}
{/* Copy */}

{t('labUi.theCrew')}

{t('labUi.justASkater')} {t('labUi.skaterWord')}?

{t('labUi.labForServices')}

{/* ── Sticky toolbar (tabs + search + location + filter button + active chips) ── */} setFiltersOpen(true)} counts={counts} totalCount={filteredProfiles.length} isLoading={isLoading} selectedSkills={selectedSkills} onRemoveSkill={removeSkill} coachChips={coachChips} onRemoveCoachFacet={toggleCoachFacet} sortBy={sortBy} trustFilter={trustFilter} onClearAll={handleClearAll} /> {/* ── Your Lab profiles (owner quick access) ── */} {user && ownProfiles.length > 0 && (

{t('labUi.yourProfiles')}

{ownProfiles.map((p) => ( {p.title} ))}
)} {/* ── 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} coachFacets={coachFacets} coachFilters={coachFilters} onToggleCoachFacet={toggleCoachFacet} sortBy={sortBy} onSortChange={setSortBy} trustFilter={trustFilter} onTrustChange={setTrustFilter} onClearAll={handleClearAll} activeCount={activeFilterCount} /> ); }