SkateSpots/src/pages/LabPage.tsx

1802 lines
75 KiB
TypeScript

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<string, string> = {
...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<string, string> = {
...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<string, string> = {
...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<string, string> = {
...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<string, string> = {
...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<string, string> = {
...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<string, string> = {
...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<string, string> = {
...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<string, string> = {
...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<string, string> = {
...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<string, string> = {
...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<string, string> = {
...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<ServiceType, LabProfile['profileType']> = {
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<ServiceType, Record<string, string>> = {
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<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={cn(
'flex items-center gap-2 px-4 py-2.5 rounded-xl font-black text-sm uppercase tracking-wide transition-all active:scale-95',
open
? 'bg-white text-black'
: 'bg-[#ff0040] hover:bg-[#ff0040]/90 text-white'
)}
>
{open ? <X className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
<span className="hidden sm:inline">{t('labUi.offerServices')}</span>
<span className="sm:hidden">{t('labUi.offer')}</span>
</button>
{/* Dropdown — one clean list; the role colour is a small icon chip, and
the Rider Card action stays pinned below the scrolling list. */}
{open && (
<div className="absolute right-0 top-full z-50 mt-2 w-64 overflow-hidden rounded-2xl border border-white/10 bg-black/95 shadow-2xl shadow-black/60 backdrop-blur">
<p className="px-3 pt-2.5 pb-1 text-[10px] font-black uppercase tracking-widest text-gray-500">
{t('labUi.offerServices')}
</p>
<div className="max-h-[min(60vh,440px)] space-y-0.5 overflow-y-auto p-1.5 pt-0.5">
{serviceItems.map(({ to, label, color, icon: Icon }) => (
<Link
key={to}
to={to}
onClick={() => 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]"
>
<span className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg', color)}>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
</Link>
))}
</div>
<div className="border-t border-white/10 p-1.5">
<Link
to="/card"
onClick={() => 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"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-[#ccff00]/40 bg-[#ccff00]/10">
<CreditCard className="h-4 w-4" />
</span>
{t('labUi.getRiderCard')}
</Link>
</div>
</div>
)}
</div>
);
}
// ─── 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<ServiceType, number>;
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 (
<div className="sticky top-14 md:top-16 z-30 bg-black/95 backdrop-blur border-b border-white/5 -mx-4 px-4 py-3 space-y-2.5">
{/* 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. */}
<div className="grid grid-cols-4 gap-1.5 sm:gap-2 md:flex md:flex-wrap -mx-1 px-1">
{tabs.map(({ key, label, icon: Icon }) => {
const isActive = serviceType === key;
const a = ACCENT[key];
return (
<button
key={key}
onClick={() => onServiceChange(key)}
className={cn(
'relative flex flex-col items-center justify-center gap-1 px-1.5 sm:px-2.5 py-2 rounded-xl font-black text-[10px] sm:text-xs uppercase tracking-tight sm:tracking-wide transition-all duration-150 active:scale-95 md:min-w-[60px]',
isActive
? a.active
: 'text-gray-500 bg-white/5 hover:bg-white/10 hover:text-white border border-transparent'
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<span className="leading-none">{label}</span>
{/* Count badge — floats top-right so it never widens the tab */}
{!isLoading && counts[key] > 0 && (
<span className={cn(
'absolute -top-1 -right-1 min-w-[16px] h-[16px] px-1 text-[9px] font-black rounded-full flex items-center justify-center leading-none border border-black/40',
isActive ? 'bg-white text-black' : 'bg-white/15 text-gray-300'
)}>
{counts[key]}
</span>
)}
</button>
);
})}
</div>
{/* Search + Location + Filter row */}
<div className="flex items-center gap-2">
{/* Search by name */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<button
onClick={() => onSearchChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Location quick-filter */}
<div className="relative flex-1">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
<input
type="text"
value={locationQuery}
onChange={(e) => 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 && (
<button
onClick={() => onLocationChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Filter button */}
<button
onClick={onOpenFilters}
className={cn(
'relative flex items-center gap-1.5 h-9 px-3 rounded-xl font-bold text-xs uppercase tracking-wide transition-all active:scale-95 border whitespace-nowrap flex-shrink-0',
activeFilterCount > 0
? `${accent.badge} border-current`
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-white'
)}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('labUi.filters')}</span>
{activeFilterCount > 0 && (
<span className="flex items-center justify-center w-4 h-4 text-[9px] font-black rounded-full bg-current/30 leading-none">
{activeFilterCount}
</span>
)}
</button>
</div>
{/* Active filter chips row — only shown when filters are active */}
{(activeChips.length > 0 || hasNonSkillFilters) && (
<div className="flex items-center gap-1.5 flex-wrap">
{/* Skill chips */}
{activeChips.map(({ key, label }) => (
<button
key={key}
onClick={() => onRemoveSkill(key)}
className={cn(
'flex items-center gap-1 min-h-0 min-w-0 px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all hover:opacity-80 active:scale-95',
accent.chip
)}
>
{label}
<X className="h-2.5 w-2.5" />
</button>
))}
{/* Coaching facet chips */}
{coachChips.map(({ key, label, facet, value }) => (
<button
key={key}
onClick={() => onRemoveCoachFacet(facet, value)}
className={cn(
'flex items-center gap-1 min-h-0 min-w-0 px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all hover:opacity-80 active:scale-95',
accent.chip
)}
>
{label}
<X className="h-2.5 w-2.5" />
</button>
))}
{/* Sort chip */}
{sortBy !== 'recent' && (
<span className={cn('flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border', accent.badge)}>
<Star className="h-2.5 w-2.5" />
{t('labUi.topRated')}
</span>
)}
{/* Trust chip */}
{trustFilter !== 'all' && (
<span className={cn('flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border', accent.badge)}>
<Shield className="h-2.5 w-2.5" />
{trustFilter === 'verified' ? t('common.verified') : trustFilter === 'trusted' ? t('labUi.trusted') : t('labUi.following')}
</span>
)}
{/* Clear all */}
<button
onClick={onClearAll}
className="px-2.5 py-1 rounded-full text-[10px] font-bold text-gray-500 hover:text-red-400 border border-white/10 hover:border-red-500/30 transition-all"
>
{t('labUi.clearAll')}
</button>
</div>
)}
{/* Results count */}
{!isLoading && (
<p className="text-[11px] text-gray-500 font-semibold leading-none">
{totalCount === 0
? t('labUi.noResults')
: t(totalCount === 1 ? 'labUi.resultsOne' : 'labUi.resultsMany', { count: totalCount })}
</p>
)}
</div>
);
}
// ─── 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<CoachFacetKey, string[]>;
coachFilters: Record<CoachFacetKey, string[]>;
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 */}
<div className="flex items-center justify-between px-4 pt-4 pb-3 flex-shrink-0 border-b border-white/5">
<div className="flex items-center gap-2">
<SlidersHorizontal className={cn('h-4 w-4', accent.icon)} />
<span className="font-black text-base text-white">{t('labUi.filters')}</span>
{activeCount > 0 && (
<Badge className={cn('text-xs px-1.5 py-0', accent.badge)}>
{t('labUi.activeCount', { count: activeCount })}
</Badge>
)}
</div>
<div className="flex items-center gap-3">
{activeCount > 0 && (
<button
onClick={onClearAll}
className="text-xs text-gray-400 hover:text-red-400 transition-colors font-semibold"
>
{t('labUi.clearAll')}
</button>
)}
<button
onClick={onClose}
className="w-7 h-7 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 transition-colors"
>
<X className="h-3.5 w-3.5 text-gray-400" />
</button>
</div>
</div>
{/* Scrollable body */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 space-y-5 py-4 scroll-container">
{/* Sort */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">{t('labUi.sortBy')}</p>
<div className="flex gap-2">
{[
{ 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 }) => (
<button
key={value}
onClick={() => onSortChange(value)}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold border transition-all flex-1 justify-center',
sortBy === value
? accent.chip
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10'
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
{/* Trust — inline pill buttons, no dropdown */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">{t('labUi.trustLevel')}</p>
<div className="flex flex-wrap gap-2">
{trustOptions.map(({ value, label, icon: Icon }) => (
<button
key={value}
onClick={() => onTrustChange(value)}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold border transition-all',
trustFilter === value
? accent.chip
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-white'
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
{/* Skill chips per section */}
{skillSections.map(({ title, entries, activeColor }) => (
<div key={title}>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2.5">{title}</p>
<div className="flex flex-wrap gap-1.5">
{entries.map(([key, label]) => {
const isActive = selectedSkills.includes(key);
return (
<button
key={key}
onClick={() => onToggleSkill(key)}
className={cn(
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
isActive
? activeColor
: `bg-white/5 border-white/10 text-gray-400 ${accent.chipHover}`
)}
>
{getSkillLabelForType(key, serviceType, t) || label}
</button>
);
})}
</div>
</div>
))}
{/* 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 (
<div key={key}>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2.5">{t(titleKey)}</p>
<div className="flex flex-wrap gap-1.5">
{values.map((value) => {
const active = coachFilters[key].includes(value);
const optKey = coachOptionKey(options, value);
return (
<button
key={value}
onClick={() => onToggleCoachFacet(key, value)}
className={cn(
'min-h-0 min-w-0 px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
active
? accent.chip
: `bg-white/5 border-white/10 text-gray-400 ${accent.chipHover}`
)}
>
{optKey ? t(optKey) : value}
</button>
);
})}
</div>
</div>
);
})}
</div>
{/* Apply */}
<div className="flex-shrink-0 px-4 pt-3 pb-4 border-t border-white/5">
<button
onClick={onClose}
className={cn(
'w-full h-12 rounded-xl font-black text-base uppercase tracking-wide transition-all active:scale-[0.98]',
accent.active
)}
>
{activeCount > 0 ? t('labUi.showResultsActive', { count: activeCount }) : t('labUi.showResults')}
</button>
</div>
</>
);
return (
<>
{/* Mobile: bottom sheet */}
<div className="md:hidden">
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent side="bottom" className="h-[82vh] max-h-[700px] rounded-t-2xl p-0 flex flex-col bg-[#0a0a0a]">
<SheetHeader className="sr-only">
<SheetTitle>{t('labUi.filters')}</SheetTitle>
</SheetHeader>
{drawerContent}
</SheetContent>
</Sheet>
</div>
{/* Desktop: right side panel */}
<div className="hidden md:block">
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent side="right" className="w-80 p-0 flex flex-col bg-[#0a0a0a] border-l border-white/8">
<SheetHeader className="sr-only">
<SheetTitle>{t('labUi.filters')}</SheetTitle>
</SheetHeader>
{drawerContent}
</SheetContent>
</Sheet>
</div>
</>
);
}
// ─── 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<LabProfile['profileType'], ServiceType> = {
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<ServiceType, React.ComponentType<{ className?: string }>> = {
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 (
<Link to={labProfilePath(profile.profileType, profile.pubkey, profile.identifier)}>
<div className={cn(
'group relative flex gap-3 p-4 rounded-2xl bg-white/3 border border-white/8',
'hover:bg-white/5 hover:border-white/15 hover:shadow-xl',
'transition-all duration-200 active:scale-[0.99]',
'overflow-hidden',
isFeaturedActive(profile.featuredUntil) &&
'border-amber-500/40 bg-gradient-to-br from-amber-500/[0.07] to-transparent',
)}>
{/* Left color stripe */}
<div className={cn('absolute left-0 top-0 bottom-0 w-[3px] rounded-l-2xl', accent.stripe)} />
{/* Avatar */}
<div className="relative flex-shrink-0">
<Avatar className={cn('h-14 w-14 border-2', accent.border)}>
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className={cn('text-sm font-black', accent.badge)}>
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Type badge on avatar */}
<div className={cn(
'absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center border-2 border-black',
accent.stripe
)}>
<TypeIcon className="h-2.5 w-2.5 text-white" />
</div>
</div>
{/* Main content */}
<div className="flex-1 min-w-0">
{/* Name row */}
<div className="flex items-start justify-between gap-2 mb-1">
<div className="min-w-0">
<h3 className="font-black text-sm text-white truncate leading-tight flex items-center gap-1.5 min-w-0">
<span className="truncate min-w-0">{displayName}</span>
<ProBadge featuredUntil={profile.featuredUntil} compact className="flex-shrink-0" />
{profile.entityType === 'collective' && (
<span
className={cn('inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold flex-shrink-0', accent.badgeSkill)}
title={t('labUi.collective')}
>
<Users className="h-2.5 w-2.5" />
{profile.members.length > 0 ? profile.members.length : ''}
</span>
)}
</h3>
{nip05 && (
<p className={cn('text-[10px] font-bold truncate leading-tight', accent.icon)}>
✓ {nip05}
</p>
)}
</div>
<ChevronRight className="h-4 w-4 text-gray-600 group-hover:text-gray-400 flex-shrink-0 mt-0.5 transition-colors" />
</div>
{/* Title + location */}
{/* Title / headline — only when it adds something beyond the name */}
{profile.title && profile.title !== displayName && (
<p className="text-xs text-gray-400 truncate mb-1.5 leading-snug">{profile.title}</p>
)}
<div className="flex items-center gap-3 text-[11px] text-gray-500 mb-2">
{profile.location && (
<span className="flex items-center gap-1 truncate">
<MapPin className="h-3 w-3 text-[#ff0040] flex-shrink-0" />
{profile.location}
</span>
)}
{profile.experience && (
<span className="flex-shrink-0">{t('labUi.yearsExp', { count: profile.experience })}</span>
)}
</div>
{/* Skill chips */}
{profile.skills.length > 0 && (
<div className="flex flex-wrap gap-1">
{profile.skills.slice(0, 3).map((skill: string) => (
<span
key={skill}
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border',
accent.badgeSkill
)}
>
{getSkillLabel(skill)}
</span>
))}
{profile.skills.length > 3 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border bg-white/5 border-white/10 text-gray-500">
+{profile.skills.length - 3}
</span>
)}
</div>
)}
{/* Rate */}
{profile.rate && (
<p className="text-[11px] text-orange-400 font-bold mt-1.5">💰 {profile.rate}</p>
)}
</div>
</div>
</Link>
);
}
// ─── Skeleton card ────────────────────────────────────────────────────────────
function SkeletonCard() {
return (
<div className="flex gap-3 p-4 rounded-2xl bg-white/3 border border-white/8">
<div className="w-14 h-14 rounded-full bg-white/8 animate-pulse flex-shrink-0" />
<div className="flex-1 space-y-2 min-w-0">
<div className="h-3.5 w-28 bg-white/8 animate-pulse rounded-full" />
<div className="h-3 w-36 bg-white/8 animate-pulse rounded-full" />
<div className="h-3 w-24 bg-white/8 animate-pulse rounded-full" />
<div className="flex gap-1.5 mt-1">
<div className="h-4 w-14 bg-white/8 animate-pulse rounded-full" />
<div className="h-4 w-18 bg-white/8 animate-pulse rounded-full" />
</div>
</div>
</div>
);
}
// ─── 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 (
<SharedEmptyState
icon={Icon}
title={c.title}
hint={
user ? (
c.body
) : (
<>
{c.body}{' '}
<Link to="/settings" className="text-[#ccff00] underline underline-offset-2">
{t('auth.login')}
</Link>{' '}
{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<string[]>([]);
const [coachFilters, setCoachFilters] = useState(emptyCoachFilters);
const [serviceType, setServiceType] = useState<ServiceType>(initialCraft);
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>('all');
const [sortBy, setSortBy] = useState<SortOption>('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<string, string>();
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<string>();
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<CoachFacetKey, string[]>;
}, [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<Record<ServiceType, number>>(() => {
const empty: Record<ServiceType, number> = {
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 (
<>
<div className="container px-4 py-6 md:py-10 max-w-4xl mx-auto">
{/* ── Page header ── */}
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<h1 className="text-4xl md:text-5xl font-black uppercase leading-none mb-1">
<span className="neon-red">THE</span>{' '}
<span className="neon-orange">LAB</span>
</h1>
<p className="text-gray-500 text-sm">{t('labPage.subtitle')}</p>
</div>
{/* CTA — logged in: FAB dropdown. Logged out: small badge */}
{user ? (
<OfferFAB />
) : (
<Badge variant="outline" className="border-yellow-500/40 text-yellow-500/80 text-xs px-2.5 py-1 flex-shrink-0">
🔒 {t('labUi.loginToOffer')}
</Badge>
)}
</div>
{/* ── Skater directory cross-link ── */}
<Link
to="/skaters"
className="group relative flex items-center gap-3 rounded-2xl border border-white/10 bg-gradient-to-r from-[#ff0040]/10 via-black to-[#ccff00]/5 p-4 mb-5 hover:border-[#ff0040]/50 transition-all duration-300 shadow-lg shadow-black/40 hover:shadow-[#ff0040]/10"
>
{/* Neon skater glyph — vector icon, single-color, always renders on any device */}
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-[#ff0040]/10 border border-[#ff0040]/20 flex items-center justify-center group-hover:bg-[#ff0040]/20 transition-colors">
<PersonStanding
className="h-7 w-7 text-[#ff0040] transition-transform duration-300 group-hover:scale-110"
style={{ filter: 'drop-shadow(0 0 6px rgba(255,0,64,0.5))' }}
/>
</div>
{/* Copy */}
<div className="min-w-0 flex-1">
<p className="inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-[#ccff00] mb-0.5">
<PersonStanding className="h-3.5 w-3.5" />
{t('labUi.theCrew')}
</p>
<p className="font-black uppercase text-sm leading-tight">
{t('labUi.justASkater')} <span className="text-[#ff0040]" style={{ textShadow: '0 0 12px rgba(255,0,64,0.6)' }}>{t('labUi.skaterWord')}</span>?
</p>
<p className="text-xs text-gray-400 mt-0.5 leading-snug">
{t('labUi.labForServices')}
</p>
</div>
<div className="flex-shrink-0 w-9 h-9 rounded-full bg-[#ff0040]/15 flex items-center justify-center group-hover:bg-[#ff0040]/30 transition-colors">
<ArrowRight className="h-5 w-5 text-[#ff0040] group-hover:translate-x-0.5 transition-transform" />
</div>
</Link>
{/* ── Sticky toolbar (tabs + search + location + filter button + active chips) ── */}
<LabToolbar
serviceType={serviceType}
onServiceChange={handleServiceChange}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
locationQuery={locationQuery}
onLocationChange={setLocationQuery}
activeFilterCount={activeFilterCount}
onOpenFilters={() => 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 && (
<div className="mt-4 rounded-2xl border border-white/10 bg-white/[0.03] p-3">
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-500">{t('labUi.yourProfiles')}</p>
<div className="flex flex-wrap gap-2">
{ownProfiles.map((p) => (
<Link
key={p.id}
to={labProfilePath(p.profileType, p.pubkey, p.identifier)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-bold text-gray-200 transition-colors hover:bg-white/10"
>
<Pencil className="h-3 w-3 text-[#ccff00]" />
{p.title}
</Link>
))}
</div>
</div>
)}
{/* ── Results grid ── */}
<div className="mt-5">
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
) : filteredProfiles.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{filteredProfiles.map((profile) => (
<LazyMount key={profile.id} className="h-full">
<ProfileCard profile={profile} />
</LazyMount>
))}
</div>
) : (
<EmptyState serviceType={serviceType} user={!!user} />
)}
</div>
{/* ── Crews in this scene ── */}
<LabCrewsStrip serviceType={serviceType} />
</div>
{/* ── Filter drawer ── */}
<FilterDrawer
open={filtersOpen}
onClose={() => 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}
/>
</>
);
}