SkateSpots/src/pages/LabPage.tsx
shakespeare.diy cc2e35f569 Lab tabs: fixed 5-across grid instead of horizontal scroll
The Lab category tabs (Coaches/Filmers/Photo/Editors/Builders) overflowed
the viewport and scrolled sideways on phones, hiding Builders. Replace the
overflow-x row with a 5-column grid of compact icon-over-label tabs so all
five are visible at once without lateral scrolling, with the count badge
floated to the corner so it never widens a tab.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 18:56:06 +07:00

1094 lines
44 KiB
TypeScript

import { useState, useMemo, useRef, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link } 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,
} from '@/lib/types';
import {
GraduationCap,
Search,
Plus,
MapPin,
SlidersHorizontal,
Video,
Scissors,
Camera,
HardHat,
CreditCard,
X,
ChevronRight,
Star,
Clock,
Users,
CheckCircle2,
Shield,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ProBadge } from '@/components/ProBadge';
import { LabCrewsStrip } from '@/components/LabCrewsStrip';
import { isFeaturedActive } from '@/lib/types';
// ─── Constants ────────────────────────────────────────────────────────────────
const COACH_SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
const FILMING_SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
const EDITOR_SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
const EDITOR_SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
const PHOTO_SERVICE_ENTRIES = Object.entries(PHOTO_SERVICE_LABELS);
const PHOTO_CAMERA_ENTRIES = Object.entries(PHOTO_CAMERA_LABELS);
const PHOTO_LENS_ENTRIES = Object.entries(PHOTO_LENS_LABELS);
const PHOTO_LIGHTING_ENTRIES = Object.entries(PHOTO_LIGHTING_LABELS);
const PHOTO_SUPPORT_ENTRIES = Object.entries(PHOTO_SUPPORT_LABELS);
const PHOTO_STYLE_ENTRIES = Object.entries(PHOTO_STYLE_LABELS);
const BUILDER_SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS);
const BUILDER_SKILL_ENTRIES = Object.entries(BUILDER_SKILL_LABELS);
const BUILDER_SCALE_ENTRIES = Object.entries(BUILDER_SCALE_LABELS);
const BUILDER_ALL_LABELS: Record<string, string> = {
...BUILDER_SERVICE_LABELS,
...BUILDER_SKILL_LABELS,
...BUILDER_SCALE_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';
// ─── 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',
},
};
// ─── 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: '/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;
serviceType: ServiceType;
}
function getSkillLabelForType(skill: string, serviceType: ServiceType): string {
if (serviceType === 'coaching') return COACH_SKILL_LABELS[skill] || skill;
if (serviceType === 'filming') return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (serviceType === 'photography') return PHOTO_ALL_LABELS[skill] || skill;
if (serviceType === 'building') return BUILDER_ALL_LABELS[skill] || skill;
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 },
];
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 — fixed 5-across grid so every category is visible without
horizontal scrolling. Icon-over-label stack keeps each tab compact
but tappable on small phones; a count badge floats in the corner. */}
<div className="grid grid-cols-5 gap-1.5 sm:gap-2">
{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 py-2 rounded-xl font-black text-[10px] sm:text-xs uppercase tracking-tight sm:tracking-wide transition-all duration-150 active:scale-95',
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' },
]
: [
{ 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 isCoach = profile.profileType === 'coach';
const isFilmer = profile.profileType === 'filmer';
const isEditor = profile.profileType === 'editor';
const isPhotographer = profile.profileType === 'photographer';
const isBuilder = profile.profileType === 'builder';
const serviceType: ServiceType = isCoach ? 'coaching' : isFilmer ? 'filming' : isPhotographer ? 'photography' : isBuilder ? 'building' : 'editing';
const accent = ACCENT[serviceType];
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : isBuilder ? HardHat : Scissors;
const getSkillLabel = (skill: string) => {
if (isCoach) return COACH_SKILL_LABELS[skill] || skill;
if (isFilmer) return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (isPhotographer) return PHOTO_ALL_LABELS[skill] || skill;
if (isBuilder) return BUILDER_ALL_LABELS[skill] || skill;
if (isEditor) return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
return skill;
};
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',
},
};
const c = configs[serviceType];
const Icon = c.icon;
return (
<div className="flex flex-col items-center justify-center py-20 px-4 text-center">
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
<Icon className="h-8 w-8 text-gray-600" />
</div>
<h3 className="text-lg font-black mb-2 text-white">{c.title}</h3>
<p className="text-gray-500 text-sm max-w-xs mb-4">{c.body}</p>
{user ? (
<Link to={c.to}>
<button className={cn('flex items-center gap-2 px-5 py-2.5 rounded-xl font-black text-sm uppercase tracking-wide text-white transition-all active:scale-95', c.color)}>
<Plus className="h-4 w-4" />
{c.cta}
</button>
</Link>
) : (
<p className="text-xs text-gray-600 mb-4">
<Link to="/settings" className="text-[#ccff00] underline underline-offset-2">Log in</Link> to create a profile
</p>
)}
<Link to="/faq" className="mt-4 text-xs text-gray-600 hover:text-gray-400 underline underline-offset-2 transition-colors">
Have questions? Check the FAQ →
</Link>
</div>
);
}
// ─── 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);
const [searchQuery, setSearchQuery] = useState('');
const [locationQuery, setLocationQuery] = useState('');
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [serviceType, setServiceType] = useState<ServiceType>('coaching');
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>>(() => {
if (!profiles) return { coaching: 0, filming: 0, editing: 0, photography: 0, building: 0 };
return {
coaching: profiles.filter((p) => p.profileType === 'coach').length,
filming: profiles.filter((p) => p.profileType === 'filmer').length,
photography: profiles.filter((p) => p.profileType === 'photographer').length,
editing: profiles.filter((p) => p.profileType === 'editor').length,
building: profiles.filter((p) => p.profileType === 'builder').length,
};
}, [profiles]);
const filteredProfiles = useMemo(() => {
if (!profiles) return [];
let filtered = profiles.filter((profile) => {
if (serviceType === 'coaching' && profile.profileType !== 'coach') return false;
if (serviceType === 'filming' && profile.profileType !== 'filmer') return false;
if (serviceType === 'photography' && profile.profileType !== 'photographer') return false;
if (serviceType === 'editing' && profile.profileType !== 'editor') return false;
if (serviceType === 'building' && profile.profileType !== 'builder') 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>
{/* ── 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>
);
}