diff --git a/AGENTS.md b/AGENTS.md index b8a4de9..ac45005 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,8 +71,14 @@ session with **no other memory of this project**. So: **`ImageLightbox`**; the reel reuses **`parseCrewVideo`** (`src/lib/videoEmbed.ts`). `parseSkateCoach` exposes `formats`/`levels`/ `ageGroups`/`credentials`/`languages`/`availability`/`booking`/`photos`/`video` - on `SkateCoach`. Documented in NIP.md → "Coach (kind 35001)". Not yet done: - coach testimonials (31873-style) and `/lab` discovery filters. + on `SkateCoach`. Documented in NIP.md → "Coach (kind 35001)". + **`/lab` coaching filters (2026-10-04):** the filter drawer gains a + per-coaching facet section (formats/levels/ages/credentials/availability/ + languages) built from **`COACH_FACETS`** — only values actually present in the + loaded profiles are offered; selections show as removable chips in the + toolbar and count toward `activeFilterCount`. Client-side only (these tags are + not relay-indexed). Testimonials are already covered by the existing Lab + scene-vouch system (`VouchCard`, kind 1985) — no new kind needed. - **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared image input — **drag & drop + multi-select**, emits `File[]` to its caller (which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and diff --git a/NIP.md b/NIP.md index 97c9cc5..8a96a72 100644 --- a/NIP.md +++ b/NIP.md @@ -2070,6 +2070,11 @@ client-side. | `photo` | URL | Repeatable — portfolio gallery image (max 12) | | `video` | URL | Optional highlight-reel link (YouTube / Instagram) | +The Lab's coaching filter drawer offers these as facet filters (formats, levels, +ages, credentials, availability, languages), populated only from values present +in the loaded profiles. Filtering is client-side, consistent with the tags not +being relay-indexed. + > **Trust note:** `cert` tags are **self-attested**. SkateSpots does not verify > them — only a verified NIP-05 domain (or a NIP-32 moderation verdict) is a real > signal. Clients must label credentials as self-attested, never as verified. diff --git a/src/lib/coachOptions.ts b/src/lib/coachOptions.ts index 5acd204..d19357d 100644 --- a/src/lib/coachOptions.ts +++ b/src/lib/coachOptions.ts @@ -55,3 +55,20 @@ export const COACH_AVAILABILITY: CoachOption[] = [ export function coachOptionKey(options: CoachOption[], value: string): string | undefined { return options.find((o) => o.value === value)?.key; } + +/** Filterable coaching facets, in the order they appear in the Lab filter drawer. */ +export type CoachFacetKey = 'formats' | 'levels' | 'ageGroups' | 'credentials' | 'availability' | 'languages'; + +export const COACH_FACETS: { key: CoachFacetKey; titleKey: string; options: CoachOption[] }[] = [ + { key: 'formats', titleKey: 'labUi.detail.formats', options: COACH_FORMATS }, + { key: 'levels', titleKey: 'labUi.detail.levels', options: COACH_LEVELS }, + { key: 'ageGroups', titleKey: 'labUi.detail.ages', options: COACH_AGE_GROUPS }, + { key: 'credentials', titleKey: 'labUi.detail.credentialsTitle', options: COACH_CREDENTIALS }, + { key: 'availability', titleKey: 'labUi.detail.availability', options: COACH_AVAILABILITY }, + { key: 'languages', titleKey: 'labUi.detail.languages', options: [] }, +]; + +/** A fresh, empty set of coaching facet selections. */ +export function emptyCoachFilters(): Record { + return { formats: [], levels: [], ageGroups: [], credentials: [], availability: [], languages: [] }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 3dbfff3..3d0a776 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2676,7 +2676,11 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null { const levels = values('level'); const ageGroups = values('age'); const credentials = values('cert'); - const languages = values('language'); + // Languages are proper nouns — preserve case (no lowercasing). + const languages = event.tags + .filter(([n, v]) => n === 'language' && !!v?.trim()) + .map(([, v]) => v.trim()) + .filter((v, i, arr) => arr.indexOf(v) === i); const availability = values('availability'); const booking = event.tags.find(([name]) => name === 'booking')?.[1]; // Portfolio gallery (`photo` tags) — URLs are case-sensitive, so no lowercasing. diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index c4f857c..2f76b6a 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -5,6 +5,7 @@ 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 { COACH_FACETS, coachOptionKey, emptyCoachFilters, type CoachFacetKey } from '@/lib/coachOptions'; import { type SortOption } from '@/components/SortDropdown'; import { useLabProfiles, type LabProfile } from '@/hooks/useLabProfiles'; import { useAuthor } from '@/hooks/useAuthor'; @@ -504,6 +505,8 @@ interface ToolbarProps { // Active filter chips data selectedSkills: string[]; onRemoveSkill: (s: string) => void; + coachChips: { key: string; label: string; facet: CoachFacetKey; value: string }[]; + onRemoveCoachFacet: (facet: CoachFacetKey, value: string) => void; sortBy: SortOption; trustFilter: TrustFilterLevel; onClearAll: () => void; @@ -541,6 +544,8 @@ function LabToolbar({ isLoading, selectedSkills, onRemoveSkill, + coachChips, + onRemoveCoachFacet, sortBy, trustFilter, onClearAll, @@ -570,7 +575,7 @@ function LabToolbar({ activeChips.push({ key: s, label: getSkillLabelForType(s, serviceType, t) }); }); - const hasNonSkillFilters = trustFilter !== 'all' || sortBy !== 'recent'; + const hasNonSkillFilters = trustFilter !== 'all' || sortBy !== 'recent' || coachChips.length > 0; return (
@@ -689,6 +694,21 @@ function LabToolbar({ ))} + {/* Coaching facet chips */} + {coachChips.map(({ key, label, facet, value }) => ( + + ))} + {/* Sort chip */} {sortBy !== 'recent' && ( @@ -735,6 +755,9 @@ interface FilterDrawerProps { serviceType: ServiceType; selectedSkills: string[]; onToggleSkill: (s: string) => void; + coachFacets: Record; + coachFilters: Record; + onToggleCoachFacet: (facet: CoachFacetKey, value: string) => void; sortBy: SortOption; onSortChange: (s: SortOption) => void; trustFilter: TrustFilterLevel; @@ -749,6 +772,9 @@ function FilterDrawer({ serviceType, selectedSkills, onToggleSkill, + coachFacets, + coachFilters, + onToggleCoachFacet, sortBy, onSortChange, trustFilter, @@ -945,6 +971,38 @@ function FilterDrawer({
))} + + {/* Coaching refinements — only for coaches, and only facets present in + the loaded data (so an empty filter never appears). */} + {serviceType === 'coaching' && COACH_FACETS.map(({ key, titleKey, options }) => { + const values = coachFacets[key]; + if (!values || values.length === 0) return null; + return ( +
+

{t(titleKey)}

+
+ {values.map((value) => { + const active = coachFilters[key].includes(value); + const optKey = coachOptionKey(options, value); + return ( + + ); + })} +
+
+ ); + })} {/* Apply */} @@ -1312,6 +1370,7 @@ export default function LabPage() { const [searchQuery, setSearchQuery] = useState(''); const [locationQuery, setLocationQuery] = useState(''); const [selectedSkills, setSelectedSkills] = useState([]); + const [coachFilters, setCoachFilters] = useState(emptyCoachFilters); const [serviceType, setServiceType] = useState(initialCraft); const [trustFilter, setTrustFilter] = useState('all'); const [sortBy, setSortBy] = useState('recent'); @@ -1350,6 +1409,50 @@ export default function LabPage() { setSelectedSkills((prev) => prev.filter((s) => s !== skill)); }; + // Read a repeatable array field off the union profile (empty when absent). + const profileField = useCallback((p: LabProfile, key: string): string[] => { + const v = (p as unknown as Record)[key]; + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; + }, []); + + const toggleCoachFacet = (facet: CoachFacetKey, value: string) => { + setCoachFilters((prev) => ({ + ...prev, + [facet]: prev[facet].includes(value) + ? prev[facet].filter((v) => v !== value) + : [...prev[facet], value], + })); + }; + + // Only offer coaching facets that actually exist in the loaded profiles. + const coachFacets = useMemo(() => { + const coaching = (profiles ?? []).filter((p) => p.profileType === 'coach'); + const collect = (key: string) => { + const set = new Set(); + for (const p of coaching) for (const v of profileField(p, key)) set.add(v); + return [...set].sort(); + }; + return { + formats: collect('formats'), + levels: collect('levels'), + ageGroups: collect('ageGroups'), + credentials: collect('credentials'), + availability: collect('availability'), + languages: collect('languages'), + } as Record; + }, [profiles, profileField]); + + const coachChips = useMemo(() => { + const chips: { key: string; label: string; facet: CoachFacetKey; value: string }[] = []; + for (const { key, options } of COACH_FACETS) { + for (const value of coachFilters[key]) { + const optKey = coachOptionKey(options, value); + chips.push({ key: `${key}:${value}`, label: optKey ? t(optKey) : value, facet: key, value }); + } + } + return chips; + }, [coachFilters, t]); + // Counts per tab (unfiltered by skills/search/location, just by type) const counts = useMemo>(() => { const empty: Record = { @@ -1403,6 +1506,14 @@ export default function LabPage() { if (!profile.skills.some((s) => selectedSkills.includes(s))) return false; } + // Coaching facets — AND across facets, OR within one. + for (const { key } of COACH_FACETS) { + const selected = coachFilters[key]; + if (selected.length > 0 && !profileField(profile, key).some((v) => selected.includes(v))) { + return false; + } + } + return true; }); @@ -1419,21 +1530,24 @@ export default function LabPage() { }); return filtered; - }, [profiles, searchQuery, locationQuery, selectedSkills, serviceType, trustFilter, getTrustScore, trustScores, sortBy, displayNameMap]); + }, [profiles, searchQuery, locationQuery, selectedSkills, serviceType, trustFilter, getTrustScore, trustScores, sortBy, displayNameMap, coachFilters, profileField]); - // Active filter count (skills + trust + sort) — location and search are separate quick filters + // Active filter count (skills + coaching facets + trust + sort) — location and search are separate quick filters const activeFilterCount = selectedSkills.length + + coachChips.length + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0); const handleServiceChange = (type: ServiceType) => { setServiceType(type); setSelectedSkills([]); + setCoachFilters(emptyCoachFilters()); }; const handleClearAll = () => { setSelectedSkills([]); + setCoachFilters(emptyCoachFilters()); setTrustFilter('all'); setSortBy('recent'); }; @@ -1508,6 +1622,8 @@ export default function LabPage() { isLoading={isLoading} selectedSkills={selectedSkills} onRemoveSkill={removeSkill} + coachChips={coachChips} + onRemoveCoachFacet={toggleCoachFacet} sortBy={sortBy} trustFilter={trustFilter} onClearAll={handleClearAll} @@ -1543,6 +1659,9 @@ export default function LabPage() { serviceType={serviceType} selectedSkills={selectedSkills} onToggleSkill={toggleSkill} + coachFacets={coachFacets} + coachFilters={coachFilters} + onToggleCoachFacet={toggleCoachFacet} sortBy={sortBy} onSortChange={setSortBy} trustFilter={trustFilter}