- AppRouter wraps every route (except /amber-callback and the 404) in ONE
<Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
fragments instead of <Layout> (structure-preserving), imports dropped.
Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
1477 lines
60 KiB
TypeScript
1477 lines
60 KiB
TypeScript
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { Link, useSearchParams } from 'react-router-dom';
|
|
import { LazyMount } from '@/components/LazyMount';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
|
|
import { type SortOption } from '@/components/SortDropdown';
|
|
import { useLabProfiles, type LabProfile } from '@/hooks/useLabProfiles';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import {
|
|
COACH_SKILL_LABELS,
|
|
FILMING_SERVICE_LABELS,
|
|
FILMING_SKILL_LABELS,
|
|
EDITOR_SERVICE_LABELS,
|
|
EDITOR_SKILL_LABELS,
|
|
PHOTO_SERVICE_LABELS,
|
|
PHOTO_CAMERA_LABELS,
|
|
PHOTO_LENS_LABELS,
|
|
PHOTO_LIGHTING_LABELS,
|
|
PHOTO_SUPPORT_LABELS,
|
|
PHOTO_STYLE_LABELS,
|
|
BUILDER_SERVICE_LABELS,
|
|
BUILDER_SKILL_LABELS,
|
|
BUILDER_SCALE_LABELS,
|
|
ARTIST_SERVICE_LABELS,
|
|
ARTIST_MEDIUM_LABELS,
|
|
ARTIST_TOOL_LABELS,
|
|
GRAFFITI_STYLE_LABELS,
|
|
GRAFFITI_MEDIUM_LABELS,
|
|
GRAFFITI_CONTEXT_LABELS,
|
|
TATTOO_STYLE_LABELS,
|
|
TATTOO_METHOD_LABELS,
|
|
TRAINER_FOCUS_LABELS,
|
|
TRAINER_INJURY_LABELS,
|
|
TRAINER_CREDENTIAL_LABELS,
|
|
MUSICIAN_GENRE_LABELS,
|
|
MUSICIAN_INSTRUMENT_LABELS,
|
|
JUDGE_FORMAT_LABELS,
|
|
JUDGE_SPECIALTY_LABELS,
|
|
JUDGE_LEVEL_LABELS,
|
|
ORGANIZER_EVENT_LABELS,
|
|
ORGANIZER_SERVICE_LABELS,
|
|
ORGANIZER_SCALE_LABELS,
|
|
BARBER_SERVICE_LABELS,
|
|
BARBER_STYLE_LABELS,
|
|
BARBER_SETTING_LABELS,
|
|
} 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,
|
|
Tent,
|
|
Sparkles,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
|
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|
import { ProBadge } from '@/components/ProBadge';
|
|
import { LabCrewsStrip } from '@/components/LabCrewsStrip';
|
|
import { isFeaturedActive } from '@/lib/types';
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
const COACH_SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
|
|
const FILMING_SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
|
|
const EDITOR_SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
|
|
const EDITOR_SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
|
|
const PHOTO_SERVICE_ENTRIES = Object.entries(PHOTO_SERVICE_LABELS);
|
|
const PHOTO_CAMERA_ENTRIES = Object.entries(PHOTO_CAMERA_LABELS);
|
|
const PHOTO_LENS_ENTRIES = Object.entries(PHOTO_LENS_LABELS);
|
|
const PHOTO_LIGHTING_ENTRIES = Object.entries(PHOTO_LIGHTING_LABELS);
|
|
const PHOTO_SUPPORT_ENTRIES = Object.entries(PHOTO_SUPPORT_LABELS);
|
|
const PHOTO_STYLE_ENTRIES = Object.entries(PHOTO_STYLE_LABELS);
|
|
const BUILDER_SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS);
|
|
const BUILDER_SKILL_ENTRIES = Object.entries(BUILDER_SKILL_LABELS);
|
|
const BUILDER_SCALE_ENTRIES = Object.entries(BUILDER_SCALE_LABELS);
|
|
|
|
const BUILDER_ALL_LABELS: Record<string, string> = {
|
|
...BUILDER_SERVICE_LABELS,
|
|
...BUILDER_SKILL_LABELS,
|
|
...BUILDER_SCALE_LABELS,
|
|
};
|
|
|
|
const ARTIST_SERVICE_ENTRIES = Object.entries(ARTIST_SERVICE_LABELS);
|
|
const ARTIST_MEDIUM_ENTRIES = Object.entries(ARTIST_MEDIUM_LABELS);
|
|
const ARTIST_TOOL_ENTRIES = Object.entries(ARTIST_TOOL_LABELS);
|
|
const ARTIST_ALL_LABELS: Record<string, string> = {
|
|
...ARTIST_SERVICE_LABELS, ...ARTIST_MEDIUM_LABELS, ...ARTIST_TOOL_LABELS,
|
|
};
|
|
|
|
const GRAFFITI_STYLE_ENTRIES = Object.entries(GRAFFITI_STYLE_LABELS);
|
|
const GRAFFITI_MEDIUM_ENTRIES = Object.entries(GRAFFITI_MEDIUM_LABELS);
|
|
const GRAFFITI_CONTEXT_ENTRIES = Object.entries(GRAFFITI_CONTEXT_LABELS);
|
|
const GRAFFITI_ALL_LABELS: Record<string, string> = {
|
|
...GRAFFITI_STYLE_LABELS, ...GRAFFITI_MEDIUM_LABELS, ...GRAFFITI_CONTEXT_LABELS,
|
|
};
|
|
|
|
const TATTOO_STYLE_ENTRIES = Object.entries(TATTOO_STYLE_LABELS);
|
|
const TATTOO_METHOD_ENTRIES = Object.entries(TATTOO_METHOD_LABELS);
|
|
const TATTOO_ALL_LABELS: Record<string, string> = {
|
|
...TATTOO_STYLE_LABELS, ...TATTOO_METHOD_LABELS,
|
|
};
|
|
|
|
const TRAINER_FOCUS_ENTRIES = Object.entries(TRAINER_FOCUS_LABELS);
|
|
const TRAINER_INJURY_ENTRIES = Object.entries(TRAINER_INJURY_LABELS);
|
|
const TRAINER_CREDENTIAL_ENTRIES = Object.entries(TRAINER_CREDENTIAL_LABELS);
|
|
const TRAINER_ALL_LABELS: Record<string, string> = {
|
|
...TRAINER_FOCUS_LABELS, ...TRAINER_INJURY_LABELS, ...TRAINER_CREDENTIAL_LABELS,
|
|
};
|
|
|
|
const MUSICIAN_GENRE_ENTRIES = Object.entries(MUSICIAN_GENRE_LABELS);
|
|
const MUSICIAN_INSTRUMENT_ENTRIES = Object.entries(MUSICIAN_INSTRUMENT_LABELS);
|
|
const MUSICIAN_ALL_LABELS: Record<string, string> = {
|
|
...MUSICIAN_GENRE_LABELS, ...MUSICIAN_INSTRUMENT_LABELS,
|
|
};
|
|
|
|
const JUDGE_FORMAT_ENTRIES = Object.entries(JUDGE_FORMAT_LABELS);
|
|
const JUDGE_SPECIALTY_ENTRIES = Object.entries(JUDGE_SPECIALTY_LABELS);
|
|
const JUDGE_LEVEL_ENTRIES = Object.entries(JUDGE_LEVEL_LABELS);
|
|
const JUDGE_ALL_LABELS: Record<string, string> = {
|
|
...JUDGE_FORMAT_LABELS, ...JUDGE_SPECIALTY_LABELS, ...JUDGE_LEVEL_LABELS,
|
|
};
|
|
|
|
const ORGANIZER_EVENT_ENTRIES = Object.entries(ORGANIZER_EVENT_LABELS);
|
|
const ORGANIZER_SERVICE_ENTRIES = Object.entries(ORGANIZER_SERVICE_LABELS);
|
|
const ORGANIZER_SCALE_ENTRIES = Object.entries(ORGANIZER_SCALE_LABELS);
|
|
const ORGANIZER_ALL_LABELS: Record<string, string> = {
|
|
...ORGANIZER_EVENT_LABELS, ...ORGANIZER_SERVICE_LABELS, ...ORGANIZER_SCALE_LABELS,
|
|
};
|
|
|
|
const BARBER_SERVICE_ENTRIES = Object.entries(BARBER_SERVICE_LABELS);
|
|
const BARBER_STYLE_ENTRIES = Object.entries(BARBER_STYLE_LABELS);
|
|
const BARBER_SETTING_ENTRIES = Object.entries(BARBER_SETTING_LABELS);
|
|
const BARBER_ALL_LABELS: Record<string, string> = {
|
|
...BARBER_SERVICE_LABELS, ...BARBER_STYLE_LABELS, ...BARBER_SETTING_LABELS,
|
|
};
|
|
|
|
// All photographer label maps merged — for resolving a chip label regardless of category
|
|
const PHOTO_ALL_LABELS: Record<string, string> = {
|
|
...PHOTO_SERVICE_LABELS,
|
|
...PHOTO_CAMERA_LABELS,
|
|
...PHOTO_LENS_LABELS,
|
|
...PHOTO_LIGHTING_LABELS,
|
|
...PHOTO_SUPPORT_LABELS,
|
|
...PHOTO_STYLE_LABELS,
|
|
};
|
|
|
|
type ServiceType =
|
|
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
|
|
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing' | 'barbering';
|
|
|
|
/** Map a ServiceType to the LabProfile.profileType it filters. */
|
|
const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> = {
|
|
coaching: 'coach',
|
|
filming: 'filmer',
|
|
editing: 'editor',
|
|
photography: 'photographer',
|
|
building: 'builder',
|
|
art: 'artist',
|
|
graffiti: 'graffiti',
|
|
tattoo: 'tattoo',
|
|
training: 'trainer',
|
|
music: 'musician',
|
|
judging: 'judge',
|
|
organizing: 'organizer',
|
|
barbering: 'barber',
|
|
};
|
|
|
|
// ─── Per-category accent colours ─────────────────────────────────────────────
|
|
|
|
const ACCENT = {
|
|
coaching: {
|
|
active: 'bg-[#ff0040] hover:bg-[#ff0040]/90 text-white border-[#ff0040]',
|
|
chip: 'bg-[#ff0040] text-white border-[#ff0040]',
|
|
chipHover: 'hover:border-[#ff0040]/60 hover:text-[#ff0040]',
|
|
border: 'border-[#ff0040]/40',
|
|
glow: 'shadow-[#ff0040]/20',
|
|
icon: 'text-[#ff0040]',
|
|
badge: 'bg-[#ff0040]/15 text-[#ff0040] border-[#ff0040]/30',
|
|
stripe: 'bg-[#ff0040]',
|
|
badgeSkill: 'bg-red-900/40 border-red-700/50 text-red-300',
|
|
},
|
|
filming: {
|
|
active: 'bg-orange-500 hover:bg-orange-600 text-white border-orange-500',
|
|
chip: 'bg-orange-500 text-white border-orange-500',
|
|
chipHover: 'hover:border-orange-500/60 hover:text-orange-400',
|
|
border: 'border-orange-500/40',
|
|
glow: 'shadow-orange-500/20',
|
|
icon: 'text-orange-400',
|
|
badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
|
stripe: 'bg-orange-500',
|
|
badgeSkill: 'bg-orange-900/40 border-orange-700/50 text-orange-300',
|
|
},
|
|
editing: {
|
|
active: 'bg-purple-600 hover:bg-purple-700 text-white border-purple-600',
|
|
chip: 'bg-purple-600 text-white border-purple-600',
|
|
chipHover: 'hover:border-purple-500/60 hover:text-purple-400',
|
|
border: 'border-purple-500/40',
|
|
glow: 'shadow-purple-500/20',
|
|
icon: 'text-purple-400',
|
|
badge: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
|
|
stripe: 'bg-purple-600',
|
|
badgeSkill: 'bg-purple-900/40 border-purple-700/50 text-purple-300',
|
|
},
|
|
photography: {
|
|
active: 'bg-indigo-600 hover:bg-indigo-700 text-white border-indigo-600',
|
|
chip: 'bg-indigo-600 text-white border-indigo-600',
|
|
chipHover: 'hover:border-indigo-500/60 hover:text-indigo-400',
|
|
border: 'border-indigo-500/40',
|
|
glow: 'shadow-indigo-500/20',
|
|
icon: 'text-indigo-400',
|
|
badge: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30',
|
|
stripe: 'bg-indigo-600',
|
|
badgeSkill: 'bg-indigo-900/40 border-indigo-700/50 text-indigo-300',
|
|
},
|
|
building: {
|
|
active: 'bg-amber-500 hover:bg-amber-600 text-black border-amber-500',
|
|
chip: 'bg-amber-500 text-black border-amber-500',
|
|
chipHover: 'hover:border-amber-500/60 hover:text-amber-400',
|
|
border: 'border-amber-500/40',
|
|
glow: 'shadow-amber-500/20',
|
|
icon: 'text-amber-400',
|
|
badge: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
|
stripe: 'bg-amber-500',
|
|
badgeSkill: 'bg-amber-900/40 border-amber-700/50 text-amber-300',
|
|
},
|
|
art: {
|
|
active: 'bg-fuchsia-600 hover:bg-fuchsia-700 text-white border-fuchsia-600',
|
|
chip: 'bg-fuchsia-600 text-white border-fuchsia-600',
|
|
chipHover: 'hover:border-fuchsia-500/60 hover:text-fuchsia-400',
|
|
border: 'border-fuchsia-500/40',
|
|
glow: 'shadow-fuchsia-500/20',
|
|
icon: 'text-fuchsia-400',
|
|
badge: 'bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/30',
|
|
stripe: 'bg-fuchsia-600',
|
|
badgeSkill: 'bg-fuchsia-900/40 border-fuchsia-700/50 text-fuchsia-300',
|
|
},
|
|
graffiti: {
|
|
active: 'bg-lime-500 hover:bg-lime-600 text-black border-lime-500',
|
|
chip: 'bg-lime-500 text-black border-lime-500',
|
|
chipHover: 'hover:border-lime-500/60 hover:text-lime-400',
|
|
border: 'border-lime-500/40',
|
|
glow: 'shadow-lime-500/20',
|
|
icon: 'text-lime-400',
|
|
badge: 'bg-lime-500/15 text-lime-400 border-lime-500/30',
|
|
stripe: 'bg-lime-500',
|
|
badgeSkill: 'bg-lime-900/40 border-lime-700/50 text-lime-300',
|
|
},
|
|
tattoo: {
|
|
active: 'bg-rose-600 hover:bg-rose-700 text-white border-rose-600',
|
|
chip: 'bg-rose-600 text-white border-rose-600',
|
|
chipHover: 'hover:border-rose-500/60 hover:text-rose-400',
|
|
border: 'border-rose-500/40',
|
|
glow: 'shadow-rose-500/20',
|
|
icon: 'text-rose-400',
|
|
badge: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
|
|
stripe: 'bg-rose-600',
|
|
badgeSkill: 'bg-rose-900/40 border-rose-700/50 text-rose-300',
|
|
},
|
|
training: {
|
|
active: 'bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600',
|
|
chip: 'bg-emerald-600 text-white border-emerald-600',
|
|
chipHover: 'hover:border-emerald-500/60 hover:text-emerald-400',
|
|
border: 'border-emerald-500/40',
|
|
glow: 'shadow-emerald-500/20',
|
|
icon: 'text-emerald-400',
|
|
badge: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
|
stripe: 'bg-emerald-600',
|
|
badgeSkill: 'bg-emerald-900/40 border-emerald-700/50 text-emerald-300',
|
|
},
|
|
music: {
|
|
active: 'bg-sky-500 hover:bg-sky-600 text-white border-sky-500',
|
|
chip: 'bg-sky-500 text-white border-sky-500',
|
|
chipHover: 'hover:border-sky-500/60 hover:text-sky-400',
|
|
border: 'border-sky-500/40',
|
|
glow: 'shadow-sky-500/20',
|
|
icon: 'text-sky-400',
|
|
badge: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
|
|
stripe: 'bg-sky-500',
|
|
badgeSkill: 'bg-sky-900/40 border-sky-700/50 text-sky-300',
|
|
},
|
|
judging: {
|
|
active: 'bg-yellow-500 hover:bg-yellow-600 text-black border-yellow-500',
|
|
chip: 'bg-yellow-500 text-black border-yellow-500',
|
|
chipHover: 'hover:border-yellow-500/60 hover:text-yellow-400',
|
|
border: 'border-yellow-500/40',
|
|
glow: 'shadow-yellow-500/20',
|
|
icon: 'text-yellow-400',
|
|
badge: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
|
|
stripe: 'bg-yellow-500',
|
|
badgeSkill: 'bg-yellow-900/40 border-yellow-700/50 text-yellow-300',
|
|
},
|
|
organizing: {
|
|
active: 'bg-teal-600 hover:bg-teal-700 text-white border-teal-600',
|
|
chip: 'bg-teal-600 text-white border-teal-600',
|
|
chipHover: 'hover:border-teal-500/60 hover:text-teal-400',
|
|
border: 'border-teal-500/40',
|
|
glow: 'shadow-teal-500/20',
|
|
icon: 'text-teal-400',
|
|
badge: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
|
|
stripe: 'bg-teal-600',
|
|
badgeSkill: 'bg-teal-900/40 border-teal-700/50 text-teal-300',
|
|
},
|
|
barbering: {
|
|
active: 'bg-cyan-600 hover:bg-cyan-700 text-white border-cyan-600',
|
|
chip: 'bg-cyan-600 text-white border-cyan-600',
|
|
chipHover: 'hover:border-cyan-500/60 hover:text-cyan-400',
|
|
border: 'border-cyan-500/40',
|
|
glow: 'shadow-cyan-500/20',
|
|
icon: 'text-cyan-400',
|
|
badge: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
|
|
stripe: 'bg-cyan-600',
|
|
badgeSkill: 'bg-cyan-900/40 border-cyan-700/50 text-cyan-300',
|
|
},
|
|
};
|
|
|
|
// ─── 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: '/become/organizer', label: 'Organize Events', color: 'bg-teal-600 hover:bg-teal-700', icon: Tent },
|
|
{ to: '/become/barber', label: 'Offer Cuts', color: 'bg-cyan-600 hover:bg-cyan-700', icon: Sparkles },
|
|
{ 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 max-h-[70vh] overflow-y-auto">
|
|
{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;
|
|
if (serviceType === 'organizing') return ORGANIZER_ALL_LABELS[skill] || skill;
|
|
if (serviceType === 'barbering') return BARBER_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 },
|
|
{ key: 'organizing', label: 'Organizers', icon: Tent },
|
|
{ key: 'barbering', label: 'Barbers', icon: Sparkles },
|
|
];
|
|
|
|
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. 11 crafts lay out 4-4-3 on phones, and flex
|
|
to a single line on wide screens. Icon-over-label stack keeps each tab
|
|
compact but tappable; a count badge floats in the corner. */}
|
|
<div className="grid grid-cols-4 gap-1.5 sm:gap-2 md:flex md:flex-wrap -mx-1 px-1">
|
|
{tabs.map(({ key, label, icon: Icon }) => {
|
|
const isActive = serviceType === key;
|
|
const a = ACCENT[key];
|
|
return (
|
|
<button
|
|
key={key}
|
|
onClick={() => onServiceChange(key)}
|
|
className={cn(
|
|
'relative flex flex-col items-center justify-center gap-1 px-1.5 sm:px-2.5 py-2 rounded-xl font-black text-[10px] sm:text-xs uppercase tracking-tight sm:tracking-wide transition-all duration-150 active:scale-95 md:min-w-[60px]',
|
|
isActive
|
|
? a.active
|
|
: 'text-gray-500 bg-white/5 hover:bg-white/10 hover:text-white border border-transparent'
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4 flex-shrink-0" />
|
|
<span className="leading-none">{label}</span>
|
|
{/* Count badge — floats top-right so it never widens the tab */}
|
|
{!isLoading && counts[key] > 0 && (
|
|
<span className={cn(
|
|
'absolute -top-1 -right-1 min-w-[16px] h-[16px] px-1 text-[9px] font-black rounded-full flex items-center justify-center leading-none border border-black/40',
|
|
isActive ? 'bg-white text-black' : 'bg-white/15 text-gray-300'
|
|
)}>
|
|
{counts[key]}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Search + Location + Filter row */}
|
|
<div className="flex items-center gap-2">
|
|
{/* Search by name */}
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
placeholder="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' },
|
|
]
|
|
: serviceType === 'organizing'
|
|
? [
|
|
{ title: 'Event Types', entries: ORGANIZER_EVENT_ENTRIES, activeColor: accent.chip },
|
|
{ title: 'What You Handle', entries: ORGANIZER_SERVICE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' },
|
|
{ title: 'Scale', entries: ORGANIZER_SCALE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' },
|
|
]
|
|
: serviceType === 'barbering'
|
|
? [
|
|
{ title: 'What They Offer', entries: BARBER_SERVICE_ENTRIES, activeColor: accent.chip },
|
|
{ title: 'Signature Styles', entries: BARBER_STYLE_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' },
|
|
{ title: 'Setting', entries: BARBER_SETTING_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-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',
|
|
organizer: 'organizing',
|
|
barber: 'barbering',
|
|
};
|
|
const serviceType: ServiceType = PROFILE_TO_SERVICE[profile.profileType];
|
|
const accent = ACCENT[serviceType];
|
|
|
|
const TYPE_ICONS: Record<ServiceType, React.ComponentType<{ className?: string }>> = {
|
|
coaching: GraduationCap, filming: Video, editing: Scissors, photography: Camera, building: HardHat,
|
|
art: Palette, graffiti: SprayCan, tattoo: Brush, training: Dumbbell, music: Music, judging: Gavel,
|
|
organizing: Tent,
|
|
barbering: Sparkles,
|
|
};
|
|
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 */}
|
|
{/* Title / headline — only when it adds something beyond the name */}
|
|
{profile.title && profile.title !== displayName && (
|
|
<p className="text-xs text-gray-400 truncate mb-1.5 leading-snug">{profile.title}</p>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 text-[11px] text-gray-500 mb-2">
|
|
{profile.location && (
|
|
<span className="flex items-center gap-1 truncate">
|
|
<MapPin className="h-3 w-3 text-[#ff0040] flex-shrink-0" />
|
|
{profile.location}
|
|
</span>
|
|
)}
|
|
{profile.experience && (
|
|
<span className="flex-shrink-0">{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',
|
|
},
|
|
organizing: {
|
|
icon: Tent,
|
|
title: 'No organizers yet',
|
|
body: 'Put on jams, comps, meetups or demos? List yourself so skaters can find and book you.',
|
|
cta: 'Organize Events',
|
|
to: '/become/organizer',
|
|
color: 'bg-teal-600 hover:bg-teal-700',
|
|
},
|
|
barbering: {
|
|
icon: Sparkles,
|
|
title: 'No barbers yet',
|
|
body: 'Cut hair for skaters? List your chair or mobile service so the scene can get a fresh trim.',
|
|
cta: 'Offer Cuts',
|
|
to: '/become/barber',
|
|
color: 'bg-cyan-600 hover:bg-cyan-700',
|
|
},
|
|
};
|
|
|
|
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', 'organizing', 'barbering'];
|
|
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 = useCallback((pubkey: string) => trustScores?.get(pubkey), [trustScores]);
|
|
|
|
// Fetch all authors for search-by-name support
|
|
// We collect metadata from ProfileCard's useAuthor calls via a shared memo approach
|
|
// The search includes displayName by filtering against the author metadata in ProfileCard
|
|
// Since we can't call hooks in loops at the page level, we implement name search
|
|
// by passing the searchQuery down and letting ProfileCard self-filter via a parent store.
|
|
// Simpler: we filter by profile fields, and the rendered cards handle their own display.
|
|
// Name search: we add it as a secondary match against profile.title (already done) but
|
|
// also expose a `displayName` field via a lookup map we build from the query cache.
|
|
// For now, profile.title is the coach's self-described role (e.g. "Skate Coach Berlin")
|
|
// and the display name comes from kind 0. We add the genUserName fallback to search too.
|
|
const displayNameMap = useMemo(() => {
|
|
// Build a map of pubkey -> genUserName fallback for client-side name search
|
|
const map = new Map<string, string>();
|
|
profiles?.forEach((p) => {
|
|
map.set(p.pubkey, genUserName(p.pubkey));
|
|
});
|
|
return map;
|
|
}, [profiles]);
|
|
|
|
const toggleSkill = (skill: string) => {
|
|
setSelectedSkills((prev) =>
|
|
prev.includes(skill) ? prev.filter((s) => s !== skill) : [...prev, skill]
|
|
);
|
|
};
|
|
|
|
const removeSkill = (skill: string) => {
|
|
setSelectedSkills((prev) => prev.filter((s) => s !== skill));
|
|
};
|
|
|
|
// Counts per tab (unfiltered by skills/search/location, just by type)
|
|
const counts = useMemo<Record<ServiceType, number>>(() => {
|
|
const empty: Record<ServiceType, number> = {
|
|
coaching: 0, filming: 0, editing: 0, photography: 0, building: 0,
|
|
art: 0, graffiti: 0, tattoo: 0, training: 0, music: 0, judging: 0, organizing: 0, barbering: 0,
|
|
};
|
|
if (!profiles) return empty;
|
|
const byType = (t: ServiceType) =>
|
|
profiles.filter((p) => p.profileType === SERVICE_TO_PROFILE_TYPE[t]).length;
|
|
return {
|
|
coaching: byType('coaching'),
|
|
filming: byType('filming'),
|
|
photography: byType('photography'),
|
|
editing: byType('editing'),
|
|
building: byType('building'),
|
|
art: byType('art'),
|
|
graffiti: byType('graffiti'),
|
|
tattoo: byType('tattoo'),
|
|
training: byType('training'),
|
|
music: byType('music'),
|
|
judging: byType('judging'),
|
|
organizing: byType('organizing'),
|
|
barbering: byType('barbering'),
|
|
};
|
|
}, [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, getTrustScore, 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 (
|
|
<>
|
|
<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 & 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) => (
|
|
<LazyMount key={profile.id} className="h-full">
|
|
<ProfileCard profile={profile} />
|
|
</LazyMount>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyState serviceType={serviceType} user={!!user} />
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Crews in this scene ── */}
|
|
<LabCrewsStrip serviceType={serviceType} />
|
|
</div>
|
|
|
|
{/* ── Filter drawer ── */}
|
|
<FilterDrawer
|
|
open={filtersOpen}
|
|
onClose={() => setFiltersOpen(false)}
|
|
serviceType={serviceType}
|
|
selectedSkills={selectedSkills}
|
|
onToggleSkill={toggleSkill}
|
|
sortBy={sortBy}
|
|
onSortChange={setSortBy}
|
|
trustFilter={trustFilter}
|
|
onTrustChange={setTrustFilter}
|
|
onClearAll={handleClearAll}
|
|
activeCount={activeFilterCount}
|
|
/>
|
|
</>
|
|
);
|
|
}
|