Add coaching facet filters to the Lab

The filter drawer now offers per-facet coaching filters (session formats,
levels, age groups, credentials, availability, languages) alongside the
existing skill chips. Options are derived from the loaded coach profiles, so
empty facets never appear; active selections show as removable chips in the
toolbar and count toward the filter badge. Filtering is client-side, matching
the fact that these tags are not relay-indexed.

Also preserve language case in parseSkateCoach (proper nouns, not slugs).
This commit is contained in:
SkateSpots 2026-10-04 09:58:41 +07:00
parent 3e534c5ffc
commit 468175a0fa
5 changed files with 157 additions and 6 deletions

View file

@ -71,8 +71,14 @@ session with **no other memory of this project**. So:
**`ImageLightbox`**; the reel reuses **`parseCrewVideo`** **`ImageLightbox`**; the reel reuses **`parseCrewVideo`**
(`src/lib/videoEmbed.ts`). `parseSkateCoach` exposes `formats`/`levels`/ (`src/lib/videoEmbed.ts`). `parseSkateCoach` exposes `formats`/`levels`/
`ageGroups`/`credentials`/`languages`/`availability`/`booking`/`photos`/`video` `ageGroups`/`credentials`/`languages`/`availability`/`booking`/`photos`/`video`
on `SkateCoach`. Documented in NIP.md → "Coach (kind 35001)". Not yet done: on `SkateCoach`. Documented in NIP.md → "Coach (kind 35001)".
coach testimonials (31873-style) and `/lab` discovery filters. **`/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 - **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared
image input — **drag & drop + multi-select**, emits `File[]` to its caller image input — **drag & drop + multi-select**, emits `File[]` to its caller
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and (which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and

5
NIP.md
View file

@ -2070,6 +2070,11 @@ client-side.
| `photo` | URL | Repeatable — portfolio gallery image (max 12) | | `photo` | URL | Repeatable — portfolio gallery image (max 12) |
| `video` | URL | Optional highlight-reel link (YouTube / Instagram) | | `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 > **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 > 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. > signal. Clients must label credentials as self-attested, never as verified.

View file

@ -55,3 +55,20 @@ export const COACH_AVAILABILITY: CoachOption[] = [
export function coachOptionKey(options: CoachOption[], value: string): string | undefined { export function coachOptionKey(options: CoachOption[], value: string): string | undefined {
return options.find((o) => o.value === value)?.key; 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<CoachFacetKey, string[]> {
return { formats: [], levels: [], ageGroups: [], credentials: [], availability: [], languages: [] };
}

View file

@ -2676,7 +2676,11 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
const levels = values('level'); const levels = values('level');
const ageGroups = values('age'); const ageGroups = values('age');
const credentials = values('cert'); 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 availability = values('availability');
const booking = event.tags.find(([name]) => name === 'booking')?.[1]; const booking = event.tags.find(([name]) => name === 'booking')?.[1];
// Portfolio gallery (`photo` tags) — URLs are case-sensitive, so no lowercasing. // Portfolio gallery (`photo` tags) — URLs are case-sensitive, so no lowercasing.

View file

@ -5,6 +5,7 @@ import { LazyMount } from '@/components/LazyMount';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter'; import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
import { COACH_FACETS, coachOptionKey, emptyCoachFilters, type CoachFacetKey } from '@/lib/coachOptions';
import { type SortOption } from '@/components/SortDropdown'; import { type SortOption } from '@/components/SortDropdown';
import { useLabProfiles, type LabProfile } from '@/hooks/useLabProfiles'; import { useLabProfiles, type LabProfile } from '@/hooks/useLabProfiles';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
@ -504,6 +505,8 @@ interface ToolbarProps {
// Active filter chips data // Active filter chips data
selectedSkills: string[]; selectedSkills: string[];
onRemoveSkill: (s: string) => void; onRemoveSkill: (s: string) => void;
coachChips: { key: string; label: string; facet: CoachFacetKey; value: string }[];
onRemoveCoachFacet: (facet: CoachFacetKey, value: string) => void;
sortBy: SortOption; sortBy: SortOption;
trustFilter: TrustFilterLevel; trustFilter: TrustFilterLevel;
onClearAll: () => void; onClearAll: () => void;
@ -541,6 +544,8 @@ function LabToolbar({
isLoading, isLoading,
selectedSkills, selectedSkills,
onRemoveSkill, onRemoveSkill,
coachChips,
onRemoveCoachFacet,
sortBy, sortBy,
trustFilter, trustFilter,
onClearAll, onClearAll,
@ -570,7 +575,7 @@ function LabToolbar({
activeChips.push({ key: s, label: getSkillLabelForType(s, serviceType, t) }); 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 ( 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"> <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">
@ -689,6 +694,21 @@ function LabToolbar({
</button> </button>
))} ))}
{/* Coaching facet chips */}
{coachChips.map(({ key, label, facet, value }) => (
<button
key={key}
onClick={() => onRemoveCoachFacet(facet, value)}
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 */} {/* Sort chip */}
{sortBy !== 'recent' && ( {sortBy !== 'recent' && (
<span className={cn('flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border', accent.badge)}> <span className={cn('flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border', accent.badge)}>
@ -735,6 +755,9 @@ interface FilterDrawerProps {
serviceType: ServiceType; serviceType: ServiceType;
selectedSkills: string[]; selectedSkills: string[];
onToggleSkill: (s: string) => void; onToggleSkill: (s: string) => void;
coachFacets: Record<CoachFacetKey, string[]>;
coachFilters: Record<CoachFacetKey, string[]>;
onToggleCoachFacet: (facet: CoachFacetKey, value: string) => void;
sortBy: SortOption; sortBy: SortOption;
onSortChange: (s: SortOption) => void; onSortChange: (s: SortOption) => void;
trustFilter: TrustFilterLevel; trustFilter: TrustFilterLevel;
@ -749,6 +772,9 @@ function FilterDrawer({
serviceType, serviceType,
selectedSkills, selectedSkills,
onToggleSkill, onToggleSkill,
coachFacets,
coachFilters,
onToggleCoachFacet,
sortBy, sortBy,
onSortChange, onSortChange,
trustFilter, trustFilter,
@ -945,6 +971,38 @@ function FilterDrawer({
</div> </div>
</div> </div>
))} ))}
{/* 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 (
<div key={key}>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2.5">{t(titleKey)}</p>
<div className="flex flex-wrap gap-1.5">
{values.map((value) => {
const active = coachFilters[key].includes(value);
const optKey = coachOptionKey(options, value);
return (
<button
key={value}
onClick={() => onToggleCoachFacet(key, value)}
className={cn(
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
active
? accent.chip
: `bg-white/5 border-white/10 text-gray-400 ${accent.chipHover}`
)}
>
{optKey ? t(optKey) : value}
</button>
);
})}
</div>
</div>
);
})}
</div> </div>
{/* Apply */} {/* Apply */}
@ -1312,6 +1370,7 @@ export default function LabPage() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [locationQuery, setLocationQuery] = useState(''); const [locationQuery, setLocationQuery] = useState('');
const [selectedSkills, setSelectedSkills] = useState<string[]>([]); const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [coachFilters, setCoachFilters] = useState(emptyCoachFilters);
const [serviceType, setServiceType] = useState<ServiceType>(initialCraft); const [serviceType, setServiceType] = useState<ServiceType>(initialCraft);
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>('all'); const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>('all');
const [sortBy, setSortBy] = useState<SortOption>('recent'); const [sortBy, setSortBy] = useState<SortOption>('recent');
@ -1350,6 +1409,50 @@ export default function LabPage() {
setSelectedSkills((prev) => prev.filter((s) => s !== skill)); 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<string, unknown>)[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<string>();
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<CoachFacetKey, string[]>;
}, [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) // Counts per tab (unfiltered by skills/search/location, just by type)
const counts = useMemo<Record<ServiceType, number>>(() => { const counts = useMemo<Record<ServiceType, number>>(() => {
const empty: Record<ServiceType, number> = { const empty: Record<ServiceType, number> = {
@ -1403,6 +1506,14 @@ export default function LabPage() {
if (!profile.skills.some((s) => selectedSkills.includes(s))) return false; 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; return true;
}); });
@ -1419,21 +1530,24 @@ export default function LabPage() {
}); });
return filtered; 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 = const activeFilterCount =
selectedSkills.length + selectedSkills.length +
coachChips.length +
(trustFilter !== 'all' ? 1 : 0) + (trustFilter !== 'all' ? 1 : 0) +
(sortBy !== 'recent' ? 1 : 0); (sortBy !== 'recent' ? 1 : 0);
const handleServiceChange = (type: ServiceType) => { const handleServiceChange = (type: ServiceType) => {
setServiceType(type); setServiceType(type);
setSelectedSkills([]); setSelectedSkills([]);
setCoachFilters(emptyCoachFilters());
}; };
const handleClearAll = () => { const handleClearAll = () => {
setSelectedSkills([]); setSelectedSkills([]);
setCoachFilters(emptyCoachFilters());
setTrustFilter('all'); setTrustFilter('all');
setSortBy('recent'); setSortBy('recent');
}; };
@ -1508,6 +1622,8 @@ export default function LabPage() {
isLoading={isLoading} isLoading={isLoading}
selectedSkills={selectedSkills} selectedSkills={selectedSkills}
onRemoveSkill={removeSkill} onRemoveSkill={removeSkill}
coachChips={coachChips}
onRemoveCoachFacet={toggleCoachFacet}
sortBy={sortBy} sortBy={sortBy}
trustFilter={trustFilter} trustFilter={trustFilter}
onClearAll={handleClearAll} onClearAll={handleClearAll}
@ -1543,6 +1659,9 @@ export default function LabPage() {
serviceType={serviceType} serviceType={serviceType}
selectedSkills={selectedSkills} selectedSkills={selectedSkills}
onToggleSkill={toggleSkill} onToggleSkill={toggleSkill}
coachFacets={coachFacets}
coachFilters={coachFilters}
onToggleCoachFacet={toggleCoachFacet}
sortBy={sortBy} sortBy={sortBy}
onSortChange={setSortBy} onSortChange={setSortBy}
trustFilter={trustFilter} trustFilter={trustFilter}