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>
1094 lines
44 KiB
TypeScript
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>
|
|
);
|
|
}
|