diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index a1a1726..c413c49 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -1,390 +1,578 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useRef, useEffect } from 'react'; import { useSeoMeta } from '@unhead/react'; import { Link } from 'react-router-dom'; import { Layout } from '@/components/Layout'; -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; -import { TrustFilter, type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter'; -import { SortDropdown, type SortOption } from '@/components/SortDropdown'; +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, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS } from '@/lib/types'; -import { GraduationCap, Search, Plus, MapPin, Filter, Video, Scissors, CreditCard } from 'lucide-react'; +import { + COACH_SKILL_LABELS, + FILMING_SERVICE_LABELS, + FILMING_SKILL_LABELS, + EDITOR_SERVICE_LABELS, + EDITOR_SKILL_LABELS, +} from '@/lib/types'; +import { + GraduationCap, + Search, + Plus, + MapPin, + SlidersHorizontal, + Video, + Scissors, + CreditCard, + X, + ChevronRight, + Star, + Clock, + Users, + CheckCircle2, + Shield, +} from 'lucide-react'; import { cn } from '@/lib/utils'; +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; + +// ─── 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); -export default function LabPage() { - const { t } = useTranslation(); - - useSeoMeta({ - title: 'The Lab - SkateSpots', - description: 'Find skateboarding coaches, filmers, and photographers.', - }); +type ServiceType = 'coaching' | 'filming' | 'editing'; - const { user } = useCurrentUser(); - const { data: profiles, isLoading } = useLabProfiles(100); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedSkills, setSelectedSkills] = useState([]); - const [serviceType, setServiceType] = useState<'coaching' | 'filming' | 'editing'>('coaching'); - const [trustFilter, setTrustFilter] = useState('all'); - const [sortBy, setSortBy] = useState('recent'); +// ─── Per-category accent colours ───────────────────────────────────────────── - // Fetch trust scores for all profiles - const profilePubkeys = useMemo(() => profiles?.map((p) => p.pubkey) || [], [profiles]); - const { data: trustScores } = useBatchTrustScores(profilePubkeys); +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', + }, +}; - const getTrustScore = (pubkey: string) => trustScores?.get(pubkey); +// ─── FAB — offer services ───────────────────────────────────────────────────── - const toggleSkill = (skill: string) => { - setSelectedSkills((prev) => - prev.includes(skill) ? prev.filter((s) => s !== skill) : [...prev, skill] - ); - }; +function OfferFAB() { + const [open, setOpen] = useState(false); + const ref = useRef(null); - const filteredProfiles = useMemo(() => { - if (!profiles) return []; + // 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]); - let filtered = profiles.filter((profile) => { - // Filter by service type - if (serviceType === 'coaching' && profile.profileType !== 'coach') return false; - if (serviceType === 'filming' && profile.profileType !== 'filmer') return false; - if (serviceType === 'editing' && profile.profileType !== 'editor') return false; - - if (searchQuery) { - const query = searchQuery.toLowerCase(); - const matchesTitle = profile.title.toLowerCase().includes(query); - const matchesLocation = profile.location?.toLowerCase().includes(query); - const matchesBio = profile.bio?.toLowerCase().includes(query); - if (!matchesTitle && !matchesLocation && !matchesBio) return false; - } - - if (selectedSkills.length > 0) { - const hasMatchingSkill = profile.skills.some((skill) => selectedSkills.includes(skill)); - if (!hasMatchingSkill) return false; - } - - return true; - }); - - // Apply trust filter - if (trustScores && trustFilter !== 'all') { - filtered = filterByTrustLevel(filtered, trustFilter, getTrustScore); - } - - // Apply sorting - filtered.sort((a, b) => { - switch (sortBy) { - case 'trust': { - const scoreA = getTrustScore(a.pubkey) || 0; - const scoreB = getTrustScore(b.pubkey) || 0; - if (scoreB !== scoreA) return scoreB - scoreA; // Highest trust first - return b.createdAt - a.createdAt; // Then newest - } - case 'recent': - default: - return b.createdAt - a.createdAt; // Newest first - } - }); - - return filtered; - }, [profiles, searchQuery, selectedSkills, serviceType, trustFilter, trustScores, sortBy]); + 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-editor', label: 'Offer Editing', color: 'bg-purple-600 hover:bg-purple-700', icon: Scissors }, + { to: '/card', label: 'Get Rider Card', color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard }, + ]; return ( - -
-
-
-

- THE LAB -

-

{t('labPage.subtitle')}

+
+ + + {/* Dropdown */} + {open && ( +
+
+ {items.map(({ to, label, color, icon: Icon }) => ( + 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 + )} + > + + {label} + + ))}
- {user ? ( -
- - - - - - - - - - - - -
- ) : ( - - 🔒 {t('labPage.loginToOffer')} - +
+ )} +
+ ); +} + +// ─── 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; + 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; + 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: 'editing', label: 'Editors', icon: Scissors }, + ]; + + 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 ( +
+ {/* Tab row */} +
+ {tabs.map(({ key, label, icon: Icon }) => { + const isActive = serviceType === key; + const a = ACCENT[key]; + return ( + + ); + })} +
+ + {/* Search + Location + Filter row */} +
+ {/* Search by name */} +
+ + 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 && ( + )}
- {/* Service Type Filter */} -
- - - + {/* Location quick-filter */} +
+ + 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 && ( + + )}
- {/* Filters */} - - -
-
- -

- {serviceType === 'coaching' && t('labPage.whatToLearn')} - {serviceType === 'filming' && t('labPage.whatContent')} - {serviceType === 'editing' && 'Edit Style & Tools'} -

-
-

- {serviceType === 'coaching' && t('labPage.selectSkillsDesc')} - {serviceType === 'filming' && t('labPage.selectFilmingDesc')} - {serviceType === 'editing' && 'Filter by edit type or software/hardware the editor uses.'} -

- -
- {/* Show coach skills when coaching */} - {serviceType === 'coaching' && COACH_SKILL_ENTRIES.map(([skill, label]) => ( - - ))} - - {/* Show filming services when filming */} - {serviceType === 'filming' && FILMING_SERVICE_ENTRIES.map(([service, label]) => ( - - ))} - - {/* Show editor services when editing */} - {serviceType === 'editing' && EDITOR_SERVICE_ENTRIES.map(([service, label]) => ( - - ))} - - {/* Show editor tool skills when editing (second row) */} - {serviceType === 'editing' && EDITOR_SKILL_ENTRIES.map(([skill, label]) => ( - - ))} -
- -
-
- - setSearchQuery(e.target.value)} - className="pl-9 bg-gray-800 border-gray-700" - /> -
-
- - -
-
-
-
-
- - {/* Coaches Grid */} - {isLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - - {/* Name skeleton */} -
-
-
-
-
-
- {/* Avatar and info */} -
-
-
-
-
-
-
- {/* Bio skeleton */} -
-
-
-
- {/* Skills skeleton */} -
-
-
-
-
- - - ))} -
- ) : filteredProfiles.length > 0 ? ( -
- {filteredProfiles.map((profile) => ( - - ))} -
- ) : ( - - - -

{t('labPage.noServicesYet')}

-

- {t('labPage.beFirstToOffer')} -

- {user && ( -
- - - - - - -
- )} -
-
- )} + {/* Filter button */} +
- + + {/* Active filter chips row — only shown when filters are active */} + {(activeChips.length > 0 || hasNonSkillFilters) && ( +
+ {/* Skill chips */} + {activeChips.map(({ key, label }) => ( + + ))} + + {/* Sort chip */} + {sortBy !== 'recent' && ( + + + Top Rated + + )} + + {/* Trust chip */} + {trustFilter !== 'all' && ( + + + {trustFilter === 'verified' ? 'Verified' : trustFilter === 'trusted' ? 'Trusted' : 'Following'} + + )} + + {/* Clear all */} + +
+ )} + + {/* Results count */} + {!isLoading && ( +

+ {totalCount === 0 + ? 'No results' + : `${totalCount} result${totalCount !== 1 ? 's' : ''}`} +

+ )} +
); } +// ─── 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' }, + ] + : [ + { 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 */} +
+
+ + Filters + {activeCount > 0 && ( + + {activeCount} active + + )} +
+
+ {activeCount > 0 && ( + + )} + +
+
+ + {/* Scrollable body */} +
+ {/* Sort */} +
+

Sort By

+
+ {[ + { value: 'recent' as SortOption, label: 'Newest', icon: Clock }, + { value: 'trust' as SortOption, label: 'Top Rated', icon: Star }, + ].map(({ value, label, icon: Icon }) => ( + + ))} +
+
+ + {/* Trust — inline pill buttons, no dropdown */} +
+

Trust Level

+
+ {trustOptions.map(({ value, label, icon: Icon }) => ( + + ))} +
+
+ + {/* Skill chips per section */} + {skillSections.map(({ title, entries, activeColor }) => ( +
+

{title}

+
+ {entries.map(([key, label]) => { + const isActive = selectedSkills.includes(key); + return ( + + ); + })} +
+
+ ))} +
+ + {/* Apply */} +
+ +
+ + ); + + return ( + <> + {/* Mobile: bottom sheet */} +
+ !v && onClose()}> + + + Filters + + {drawerContent} + + +
+ + {/* Desktop: right side panel */} +
+ !v && onClose()}> + + + Filters + + {drawerContent} + + +
+ + ); +} + +// ─── Profile Card ───────────────────────────────────────────────────────────── + function ProfileCard({ profile }: { profile: LabProfile }) { const author = useAuthor(profile.pubkey); const metadata = author.data?.metadata; @@ -396,95 +584,383 @@ function ProfileCard({ profile }: { profile: LabProfile }) { const isFilmer = profile.profileType === 'filmer'; const isEditor = profile.profileType === 'editor'; - // Get skill labels based on profile type + const serviceType: ServiceType = isCoach ? 'coaching' : isFilmer ? 'filming' : 'editing'; + const accent = ACCENT[serviceType]; + + const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : 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 (isEditor) { - return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill; - } + if (isFilmer) return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill; + if (isEditor) return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill; return skill; }; return ( - - - {/* Name Display at Top */} -
-
-

{displayName}

- {isCoach && } - {isFilmer &&