SkateSpots/src/pages/LabPage.tsx
shakespeare.diy 46cb2266ea fix: restore accidentally-removed cn import in LabPage and EventsPage
The empty-state migration removed the `cn` import from both files on the
mistaken belief it was unused. In fact each file still uses cn() ~22 times
(ProfileCard, FilterDrawer, event cards, etc.), so /lab and /events crashed
at runtime with "cn is not defined" and hit the route error boundary.
Re-added `import { cn } from '@/lib/utils'` to both.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:07:01 +07:00

1387 lines
56 KiB
TypeScript

import { useState, useMemo, useRef, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link, useSearchParams } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { Badge } from '@/components/ui/badge';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
import { type SortOption } from '@/components/SortDropdown';
import { useLabProfiles, type LabProfile } from '@/hooks/useLabProfiles';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useTranslation } from '@/contexts/LanguageContext';
import { genUserName } from '@/lib/genUserName';
import {
COACH_SKILL_LABELS,
FILMING_SERVICE_LABELS,
FILMING_SKILL_LABELS,
EDITOR_SERVICE_LABELS,
EDITOR_SKILL_LABELS,
PHOTO_SERVICE_LABELS,
PHOTO_CAMERA_LABELS,
PHOTO_LENS_LABELS,
PHOTO_LIGHTING_LABELS,
PHOTO_SUPPORT_LABELS,
PHOTO_STYLE_LABELS,
BUILDER_SERVICE_LABELS,
BUILDER_SKILL_LABELS,
BUILDER_SCALE_LABELS,
ARTIST_SERVICE_LABELS,
ARTIST_MEDIUM_LABELS,
ARTIST_TOOL_LABELS,
GRAFFITI_STYLE_LABELS,
GRAFFITI_MEDIUM_LABELS,
GRAFFITI_CONTEXT_LABELS,
TATTOO_STYLE_LABELS,
TATTOO_METHOD_LABELS,
TRAINER_FOCUS_LABELS,
TRAINER_INJURY_LABELS,
TRAINER_CREDENTIAL_LABELS,
MUSICIAN_GENRE_LABELS,
MUSICIAN_INSTRUMENT_LABELS,
JUDGE_FORMAT_LABELS,
JUDGE_SPECIALTY_LABELS,
JUDGE_LEVEL_LABELS,
} from '@/lib/types';
import {
GraduationCap,
Search,
Plus,
MapPin,
SlidersHorizontal,
Video,
Scissors,
Camera,
HardHat,
Palette,
SprayCan,
Brush,
Dumbbell,
Music,
Gavel,
CreditCard,
X,
ChevronRight,
Star,
Clock,
Users,
CheckCircle2,
Shield,
PersonStanding,
ArrowRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { 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,
};
// 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';
/** 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',
};
// ─── Per-category accent colours ─────────────────────────────────────────────
const ACCENT = {
coaching: {
active: 'bg-[#ff0040] hover:bg-[#ff0040]/90 text-white border-[#ff0040]',
chip: 'bg-[#ff0040] text-white border-[#ff0040]',
chipHover: 'hover:border-[#ff0040]/60 hover:text-[#ff0040]',
border: 'border-[#ff0040]/40',
glow: 'shadow-[#ff0040]/20',
icon: 'text-[#ff0040]',
badge: 'bg-[#ff0040]/15 text-[#ff0040] border-[#ff0040]/30',
stripe: 'bg-[#ff0040]',
badgeSkill: 'bg-red-900/40 border-red-700/50 text-red-300',
},
filming: {
active: 'bg-orange-500 hover:bg-orange-600 text-white border-orange-500',
chip: 'bg-orange-500 text-white border-orange-500',
chipHover: 'hover:border-orange-500/60 hover:text-orange-400',
border: 'border-orange-500/40',
glow: 'shadow-orange-500/20',
icon: 'text-orange-400',
badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
stripe: 'bg-orange-500',
badgeSkill: 'bg-orange-900/40 border-orange-700/50 text-orange-300',
},
editing: {
active: 'bg-purple-600 hover:bg-purple-700 text-white border-purple-600',
chip: 'bg-purple-600 text-white border-purple-600',
chipHover: 'hover:border-purple-500/60 hover:text-purple-400',
border: 'border-purple-500/40',
glow: 'shadow-purple-500/20',
icon: 'text-purple-400',
badge: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
stripe: 'bg-purple-600',
badgeSkill: 'bg-purple-900/40 border-purple-700/50 text-purple-300',
},
photography: {
active: 'bg-indigo-600 hover:bg-indigo-700 text-white border-indigo-600',
chip: 'bg-indigo-600 text-white border-indigo-600',
chipHover: 'hover:border-indigo-500/60 hover:text-indigo-400',
border: 'border-indigo-500/40',
glow: 'shadow-indigo-500/20',
icon: 'text-indigo-400',
badge: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30',
stripe: 'bg-indigo-600',
badgeSkill: 'bg-indigo-900/40 border-indigo-700/50 text-indigo-300',
},
building: {
active: 'bg-amber-500 hover:bg-amber-600 text-black border-amber-500',
chip: 'bg-amber-500 text-black border-amber-500',
chipHover: 'hover:border-amber-500/60 hover:text-amber-400',
border: 'border-amber-500/40',
glow: 'shadow-amber-500/20',
icon: 'text-amber-400',
badge: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
stripe: 'bg-amber-500',
badgeSkill: 'bg-amber-900/40 border-amber-700/50 text-amber-300',
},
art: {
active: 'bg-fuchsia-600 hover:bg-fuchsia-700 text-white border-fuchsia-600',
chip: 'bg-fuchsia-600 text-white border-fuchsia-600',
chipHover: 'hover:border-fuchsia-500/60 hover:text-fuchsia-400',
border: 'border-fuchsia-500/40',
glow: 'shadow-fuchsia-500/20',
icon: 'text-fuchsia-400',
badge: 'bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/30',
stripe: 'bg-fuchsia-600',
badgeSkill: 'bg-fuchsia-900/40 border-fuchsia-700/50 text-fuchsia-300',
},
graffiti: {
active: 'bg-lime-500 hover:bg-lime-600 text-black border-lime-500',
chip: 'bg-lime-500 text-black border-lime-500',
chipHover: 'hover:border-lime-500/60 hover:text-lime-400',
border: 'border-lime-500/40',
glow: 'shadow-lime-500/20',
icon: 'text-lime-400',
badge: 'bg-lime-500/15 text-lime-400 border-lime-500/30',
stripe: 'bg-lime-500',
badgeSkill: 'bg-lime-900/40 border-lime-700/50 text-lime-300',
},
tattoo: {
active: 'bg-rose-600 hover:bg-rose-700 text-white border-rose-600',
chip: 'bg-rose-600 text-white border-rose-600',
chipHover: 'hover:border-rose-500/60 hover:text-rose-400',
border: 'border-rose-500/40',
glow: 'shadow-rose-500/20',
icon: 'text-rose-400',
badge: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
stripe: 'bg-rose-600',
badgeSkill: 'bg-rose-900/40 border-rose-700/50 text-rose-300',
},
training: {
active: 'bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600',
chip: 'bg-emerald-600 text-white border-emerald-600',
chipHover: 'hover:border-emerald-500/60 hover:text-emerald-400',
border: 'border-emerald-500/40',
glow: 'shadow-emerald-500/20',
icon: 'text-emerald-400',
badge: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
stripe: 'bg-emerald-600',
badgeSkill: 'bg-emerald-900/40 border-emerald-700/50 text-emerald-300',
},
music: {
active: 'bg-sky-500 hover:bg-sky-600 text-white border-sky-500',
chip: 'bg-sky-500 text-white border-sky-500',
chipHover: 'hover:border-sky-500/60 hover:text-sky-400',
border: 'border-sky-500/40',
glow: 'shadow-sky-500/20',
icon: 'text-sky-400',
badge: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
stripe: 'bg-sky-500',
badgeSkill: 'bg-sky-900/40 border-sky-700/50 text-sky-300',
},
judging: {
active: 'bg-yellow-500 hover:bg-yellow-600 text-black border-yellow-500',
chip: 'bg-yellow-500 text-black border-yellow-500',
chipHover: 'hover:border-yellow-500/60 hover:text-yellow-400',
border: 'border-yellow-500/40',
glow: 'shadow-yellow-500/20',
icon: 'text-yellow-400',
badge: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
stripe: 'bg-yellow-500',
badgeSkill: 'bg-yellow-900/40 border-yellow-700/50 text-yellow-300',
},
};
// ─── FAB — offer services ─────────────────────────────────────────────────────
function OfferFAB() {
const [open, setOpen] = useState(false);
const ref = useRef<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]);
const items = [
{ to: '/become/coach', label: 'Offer Coaching', color: 'bg-[#ff0040] hover:bg-[#ff0040]/90', icon: GraduationCap },
{ to: '/become/filmer', label: 'Offer Filming', color: 'bg-orange-500 hover:bg-orange-600', icon: Video },
{ to: '/become/photographer', label: 'Offer Photography', color: 'bg-indigo-600 hover:bg-indigo-700', icon: Camera },
{ to: '/become/editor', label: 'Offer Editing', color: 'bg-purple-600 hover:bg-purple-700', icon: Scissors },
{ to: '/become/builder', label: 'Offer Building', color: 'bg-amber-500 hover:bg-amber-600 text-black', icon: HardHat },
{ to: '/become/artist', label: 'Offer Design / Art', color: 'bg-fuchsia-600 hover:bg-fuchsia-700', icon: Palette },
{ to: '/become/graffiti', label: 'Offer Graffiti', color: 'bg-lime-500 hover:bg-lime-600 text-black', icon: SprayCan },
{ to: '/become/tattoo', label: 'Offer Tattoo', color: 'bg-rose-600 hover:bg-rose-700', icon: Brush },
{ to: '/become/trainer', label: 'Offer Training / Physio', color: 'bg-emerald-600 hover:bg-emerald-700', icon: Dumbbell },
{ to: '/become/musician', label: 'Add Music Profile', color: 'bg-sky-500 hover:bg-sky-600', icon: Music },
{ to: '/become/judge', label: 'Judge Events', color: 'bg-yellow-500 hover:bg-yellow-600 text-black', icon: Gavel },
{ to: '/card', label: 'Get Rider Card', color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard },
];
return (
<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">Offer Services</span>
<span className="sm:hidden">Offer</span>
</button>
{/* Dropdown */}
{open && (
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl border border-white/10 bg-black/95 shadow-2xl shadow-black/60 backdrop-blur z-50 overflow-hidden">
<div className="p-1.5 space-y-0.5">
{items.map(({ to, label, color, icon: Icon }) => (
<Link
key={to}
to={to}
onClick={() => setOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-bold uppercase tracking-wide transition-all active:scale-95',
color
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{label}
</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;
sortBy: SortOption;
trustFilter: TrustFilterLevel;
onClearAll: () => void;
}
function getSkillLabelForType(skill: string, serviceType: ServiceType): string {
if (serviceType === 'coaching') return COACH_SKILL_LABELS[skill] || skill;
if (serviceType === 'filming') return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (serviceType === 'photography') return PHOTO_ALL_LABELS[skill] || skill;
if (serviceType === 'building') return BUILDER_ALL_LABELS[skill] || skill;
if (serviceType === 'art') return ARTIST_ALL_LABELS[skill] || skill;
if (serviceType === 'graffiti') return GRAFFITI_ALL_LABELS[skill] || skill;
if (serviceType === 'tattoo') return TATTOO_ALL_LABELS[skill] || skill;
if (serviceType === 'training') return TRAINER_ALL_LABELS[skill] || skill;
if (serviceType === 'music') return MUSICIAN_ALL_LABELS[skill] || skill;
if (serviceType === 'judging') return JUDGE_ALL_LABELS[skill] || skill;
return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
}
function LabToolbar({
serviceType,
onServiceChange,
searchQuery,
onSearchChange,
locationQuery,
onLocationChange,
activeFilterCount,
onOpenFilters,
counts,
totalCount,
isLoading,
selectedSkills,
onRemoveSkill,
sortBy,
trustFilter,
onClearAll,
}: ToolbarProps) {
const tabs: { key: ServiceType; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ key: 'coaching', label: 'Coaches', icon: GraduationCap },
{ key: 'filming', label: 'Filmers', icon: Video },
{ key: 'photography', label: 'Photo', icon: Camera },
{ key: 'editing', label: 'Editors', icon: Scissors },
{ key: 'building', label: 'Builders', icon: HardHat },
{ key: 'art', label: 'Artists', icon: Palette },
{ key: 'graffiti', label: 'Graffiti', icon: SprayCan },
{ key: 'tattoo', label: 'Tattoo', icon: Brush },
{ key: 'training', label: 'Trainers', icon: Dumbbell },
{ key: 'music', label: 'Music', icon: Music },
{ key: 'judging', label: 'Judges', icon: Gavel },
];
const accent = ACCENT[serviceType];
// Build active filter chip labels
const activeChips: { key: string; label: string }[] = [];
selectedSkills.forEach((s) => {
activeChips.push({ key: s, label: getSkillLabelForType(s, serviceType) });
});
const hasNonSkillFilters = trustFilter !== 'all' || sortBy !== 'recent';
return (
<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. 10 crafts lay out as two tidy rows of five on
phones, and flex to a single line on wide screens. Icon-over-label
stack keeps each tab compact but tappable; a count badge floats in
the corner. */}
<div className="grid grid-cols-5 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="Name, bio…"
className="w-full h-9 pl-8 pr-3 rounded-xl bg-white/5 border border-white/10 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-white/30 transition-all"
/>
{searchQuery && (
<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="City…"
className="w-full h-9 pl-8 pr-3 rounded-xl bg-white/5 border border-white/10 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-white/30 transition-all"
/>
{locationQuery && (
<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">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 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" />
Top Rated
</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' ? 'Verified' : trustFilter === 'trusted' ? 'Trusted' : '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"
>
Clear all
</button>
</div>
)}
{/* Results count */}
{!isLoading && (
<p className="text-[11px] text-gray-500 font-semibold leading-none">
{totalCount === 0
? 'No results'
: `${totalCount} result${totalCount !== 1 ? 's' : ''}`}
</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;
sortBy: SortOption;
onSortChange: (s: SortOption) => void;
trustFilter: TrustFilterLevel;
onTrustChange: (t: TrustFilterLevel) => void;
onClearAll: () => void;
activeCount: number;
}
function FilterDrawer({
open,
onClose,
serviceType,
selectedSkills,
onToggleSkill,
sortBy,
onSortChange,
trustFilter,
onTrustChange,
onClearAll,
activeCount,
}: FilterDrawerProps) {
const accent = ACCENT[serviceType];
const skillSections: { title: string; entries: [string, string][]; activeColor: string }[] =
serviceType === 'coaching'
? [{ title: 'Skills to Learn', entries: COACH_SKILL_ENTRIES, activeColor: accent.chip }]
: serviceType === 'filming'
? [
{ title: 'Content Type', entries: FILMING_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Equipment & Style', entries: Object.entries(FILMING_SKILL_LABELS), activeColor: 'bg-orange-400 text-white border-orange-400' },
]
: serviceType === 'photography'
? [
{ title: 'Services', entries: PHOTO_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Camera', entries: PHOTO_CAMERA_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
{ title: 'Lenses', entries: PHOTO_LENS_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
{ title: 'Lighting', entries: PHOTO_LIGHTING_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
{ title: 'Support & Rig', entries: PHOTO_SUPPORT_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
{ title: 'Style', entries: PHOTO_STYLE_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
]
: serviceType === 'building'
? [
{ title: 'What They Build', entries: BUILDER_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Materials & Trade', entries: BUILDER_SKILL_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' },
{ title: 'Project Scale', entries: BUILDER_SCALE_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' },
]
: serviceType === 'art'
? [
{ title: 'What They Make', entries: ARTIST_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Medium', entries: ARTIST_MEDIUM_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' },
{ title: 'Software & Tools', entries: ARTIST_TOOL_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' },
]
: serviceType === 'graffiti'
? [
{ title: 'Style', entries: GRAFFITI_STYLE_ENTRIES, activeColor: accent.chip },
{ title: 'Medium', entries: GRAFFITI_MEDIUM_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' },
{ title: 'Work Context', entries: GRAFFITI_CONTEXT_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' },
]
: serviceType === 'tattoo'
? [
{ title: 'Style', entries: TATTOO_STYLE_ENTRIES, activeColor: accent.chip },
{ title: 'Method', entries: TATTOO_METHOD_ENTRIES, activeColor: 'bg-rose-400 text-white border-rose-400' },
]
: serviceType === 'training'
? [
{ title: 'Focus', entries: TRAINER_FOCUS_ENTRIES, activeColor: accent.chip },
{ title: 'Injuries Treated', entries: TRAINER_INJURY_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' },
{ title: 'Credentials & Setting', entries: TRAINER_CREDENTIAL_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' },
]
: serviceType === 'music'
? [
{ title: 'Genre', entries: MUSICIAN_GENRE_ENTRIES, activeColor: accent.chip },
{ title: 'Instruments & Role', entries: MUSICIAN_INSTRUMENT_ENTRIES, activeColor: 'bg-sky-400 text-white border-sky-400' },
]
: serviceType === 'judging'
? [
{ title: 'Event Formats', entries: JUDGE_FORMAT_ENTRIES, activeColor: accent.chip },
{ title: 'Specialty', entries: JUDGE_SPECIALTY_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' },
{ title: 'Level / Circuit', entries: JUDGE_LEVEL_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' },
]
: [
{ title: 'Edit Type', entries: EDITOR_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Software & Tools', entries: EDITOR_SKILL_ENTRIES, activeColor: 'bg-purple-400 text-white border-purple-400' },
];
const trustOptions: { value: TrustFilterLevel; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ value: 'all', label: 'All', icon: Users },
{ value: 'verified', label: 'Verified', icon: CheckCircle2 },
{ value: 'trusted', label: 'Trusted', icon: Shield },
{ value: 'following', label: 'Following', icon: CheckCircle2 },
];
const drawerContent = (
<>
{/* Header */}
<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">Filters</span>
{activeCount > 0 && (
<Badge className={cn('text-xs px-1.5 py-0', accent.badge)}>
{activeCount} active
</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"
>
Clear all
</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">Sort By</p>
<div className="flex gap-2">
{[
{ value: 'recent' as SortOption, label: 'Newest', icon: Clock },
{ value: 'trust' as SortOption, label: 'Top Rated', 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">Trust Level</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}`
)}
>
{label}
</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
)}
>
Show Results{activeCount > 0 ? ` · ${activeCount} active` : ''}
</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>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>Filters</SheetTitle>
</SheetHeader>
{drawerContent}
</SheetContent>
</Sheet>
</div>
</>
);
}
// ─── Profile Card ─────────────────────────────────────────────────────────────
function ProfileCard({ profile }: { profile: LabProfile }) {
const author = useAuthor(profile.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(profile.pubkey);
const avatar = profile.image || metadata?.picture;
const nip05 = metadata?.nip05;
const PROFILE_TO_SERVICE: Record<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',
};
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,
};
const TypeIcon = TYPE_ICONS[serviceType];
const getSkillLabel = (skill: string) => getSkillLabelForType(skill, serviceType);
return (
<Link to={`/coach/${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">
<span className="truncate">{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="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 */}
<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">{profile.experience}yr exp</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 configs = {
coaching: {
icon: GraduationCap,
title: 'No coaches yet',
body: 'Be the first to offer skateboarding coaching in your area.',
cta: 'Offer Coaching',
to: '/become/coach',
color: 'bg-[#ff0040] hover:bg-[#ff0040]/90',
},
filming: {
icon: Video,
title: 'No filmers yet',
body: 'Be the first to offer skate filming — clips, full parts, contest coverage and more.',
cta: 'Offer Filming',
to: '/become/filmer',
color: 'bg-orange-500 hover:bg-orange-600',
},
photography: {
icon: Camera,
title: 'No photographers yet',
body: 'Be the first to offer skate photography in your area — action shots, sequences, editorial and more.',
cta: 'Offer Photography',
to: '/become/photographer',
color: 'bg-indigo-600 hover:bg-indigo-700',
},
editing: {
icon: Scissors,
title: 'No video editors yet',
body: 'Be the first to offer skate video editing services.',
cta: 'Offer Editing',
to: '/become/editor',
color: 'bg-purple-600 hover:bg-purple-700',
},
building: {
icon: HardHat,
title: 'No builders yet',
body: 'Be the first to list your ramp, park or DIY building — concrete, wood, rails and more.',
cta: 'Offer Building',
to: '/become/builder',
color: 'bg-amber-500 hover:bg-amber-600 text-black',
},
art: {
icon: Palette,
title: 'No artists yet',
body: 'Be the first to offer board graphics, logos, posters and merch for the scene.',
cta: 'Offer Design / Art',
to: '/become/artist',
color: 'bg-fuchsia-600 hover:bg-fuchsia-700',
},
graffiti: {
icon: SprayCan,
title: 'No graffiti artists yet',
body: 'Be the first to offer pieces, murals and skatepark art in your area.',
cta: 'Offer Graffiti',
to: '/become/graffiti',
color: 'bg-lime-500 hover:bg-lime-600 text-black',
},
tattoo: {
icon: Brush,
title: 'No tattoo artists yet',
body: 'Be the first skate-culture tattoo artist to list — flash, custom, fine line and more.',
cta: 'Offer Tattoo',
to: '/become/tattoo',
color: 'bg-rose-600 hover:bg-rose-700',
},
training: {
icon: Dumbbell,
title: 'No trainers yet',
body: 'Be the first trainer or physio to help skaters get strong and stay healthy.',
cta: 'Offer Training / Physio',
to: '/become/trainer',
color: 'bg-emerald-600 hover:bg-emerald-700',
},
music: {
icon: Music,
title: 'No musicians yet',
body: 'Skate and make music? Add your band or solo project — genres, instruments and links.',
cta: 'Add Music Profile',
to: '/become/musician',
color: 'bg-sky-500 hover:bg-sky-600',
},
judging: {
icon: Gavel,
title: 'No judges yet',
body: 'Run a fair contest? List yourself to judge Game of S.K.A.T.E., best trick jams, contests and touring stops.',
cta: 'Judge Events',
to: '/become/judge',
color: 'bg-yellow-500 hover:bg-yellow-600 text-black',
},
};
const c = configs[serviceType];
const Icon = c.icon;
return (
<SharedEmptyState
icon={Icon}
title={c.title}
hint={
user ? (
c.body
) : (
<>
{c.body}{' '}
<Link to="/settings" className="text-[#ccff00] underline underline-offset-2">
Log in
</Link>{' '}
to create a profile.
</>
)
}
actions={
user
? [{ label: c.cta, to: c.to, icon: Plus, className: c.color }]
: []
}
footer={{ label: 'Have questions? Check the FAQ →', to: '/faq' }}
/>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function LabPage() {
const { t } = useTranslation();
useSeoMeta({
title: 'The Lab - SkateSpots',
description: 'Find skateboarding coaches, filmers, photographers, and video editors.',
});
const { user } = useCurrentUser();
const { data: profiles, isLoading } = useLabProfiles(100);
// Allow deep-linking to a specific craft, e.g. /lab?craft=filming — used by
// the community stats cards so tapping a number lands on that craft's list.
const [searchParams] = useSearchParams();
const initialCraft = (() => {
const c = searchParams.get('craft');
const valid: ServiceType[] = ['coaching', 'filming', 'editing', 'photography', 'building', 'art', 'graffiti', 'tattoo', 'training', 'music', 'judging'];
return c && (valid as string[]).includes(c) ? (c as ServiceType) : 'coaching';
})();
const [searchQuery, setSearchQuery] = useState('');
const [locationQuery, setLocationQuery] = useState('');
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
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 = (pubkey: string) => trustScores?.get(pubkey);
// Fetch all authors for search-by-name support
// We collect metadata from ProfileCard's useAuthor calls via a shared memo approach
// The search includes displayName by filtering against the author metadata in ProfileCard
// Since we can't call hooks in loops at the page level, we implement name search
// by passing the searchQuery down and letting ProfileCard self-filter via a parent store.
// Simpler: we filter by profile fields, and the rendered cards handle their own display.
// Name search: we add it as a secondary match against profile.title (already done) but
// also expose a `displayName` field via a lookup map we build from the query cache.
// For now, profile.title is the coach's self-described role (e.g. "Skate Coach Berlin")
// and the display name comes from kind 0. We add the genUserName fallback to search too.
const displayNameMap = useMemo(() => {
// Build a map of pubkey -> genUserName fallback for client-side name search
const map = new Map<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));
};
// 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,
};
if (!profiles) return empty;
const byType = (t: ServiceType) =>
profiles.filter((p) => p.profileType === SERVICE_TO_PROFILE_TYPE[t]).length;
return {
coaching: byType('coaching'),
filming: byType('filming'),
photography: byType('photography'),
editing: byType('editing'),
building: byType('building'),
art: byType('art'),
graffiti: byType('graffiti'),
tattoo: byType('tattoo'),
training: byType('training'),
music: byType('music'),
judging: byType('judging'),
};
}, [profiles]);
const filteredProfiles = useMemo(() => {
if (!profiles) return [];
let filtered = profiles.filter((profile) => {
if (profile.profileType !== SERVICE_TO_PROFILE_TYPE[serviceType]) return false;
// Text search: title, bio, and genUserName fallback
if (searchQuery) {
const q = searchQuery.toLowerCase();
const fallbackName = displayNameMap.get(profile.pubkey) ?? '';
const matchesTitle = profile.title.toLowerCase().includes(q);
const matchesBio = profile.bio?.toLowerCase().includes(q);
const matchesName = fallbackName.toLowerCase().includes(q);
if (!matchesTitle && !matchesBio && !matchesName) return false;
}
// Location filter (dedicated city search)
if (locationQuery) {
const lq = locationQuery.toLowerCase();
const matchesLocation = profile.location?.toLowerCase().includes(lq);
if (!matchesLocation) return false;
}
if (selectedSkills.length > 0) {
if (!profile.skills.some((s) => selectedSkills.includes(s))) return false;
}
return true;
});
if (trustScores && trustFilter !== 'all') {
filtered = filterByTrustLevel(filtered, trustFilter, getTrustScore);
}
filtered.sort((a, b) => {
if (sortBy === 'trust') {
const diff = (getTrustScore(b.pubkey) || 0) - (getTrustScore(a.pubkey) || 0);
if (diff !== 0) return diff;
}
return b.createdAt - a.createdAt;
});
return filtered;
}, [profiles, searchQuery, locationQuery, selectedSkills, serviceType, trustFilter, trustScores, sortBy, displayNameMap]);
// Active filter count (skills + trust + sort) — location and search are separate quick filters
const activeFilterCount =
selectedSkills.length +
(trustFilter !== 'all' ? 1 : 0) +
(sortBy !== 'recent' ? 1 : 0);
const handleServiceChange = (type: ServiceType) => {
setServiceType(type);
setSelectedSkills([]);
};
const handleClearAll = () => {
setSelectedSkills([]);
setTrustFilter('all');
setSortBy('recent');
};
return (
<Layout>
<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">
🔒 Login to offer services
</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" />
The Crew
</p>
<p className="font-black uppercase text-sm leading-tight">
Just a <span className="text-[#ff0040]" style={{ textShadow: '0 0 12px rgba(255,0,64,0.6)' }}>skater</span>?
</p>
<p className="text-xs text-gray-400 mt-0.5 leading-snug">
The Lab's for services. Browse &amp; join the whole skater community
</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}
sortBy={sortBy}
trustFilter={trustFilter}
onClearAll={handleClearAll}
/>
{/* ── Results grid ── */}
<div className="mt-5">
{isLoading ? (
<div className="grid sm:grid-cols-2 gap-3">
{Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
) : filteredProfiles.length > 0 ? (
<div className="grid sm:grid-cols-2 gap-3">
{filteredProfiles.map((profile) => (
<ProfileCard key={profile.id} profile={profile} />
))}
</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}
sortBy={sortBy}
onSortChange={setSortBy}
trustFilter={trustFilter}
onTrustChange={setTrustFilter}
onClearAll={handleClearAll}
activeCount={activeFilterCount}
/>
</Layout>
);
}