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:
parent
3e534c5ffc
commit
468175a0fa
5 changed files with 157 additions and 6 deletions
10
AGENTS.md
10
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
|
||||
|
|
|
|||
5
NIP.md
5
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.
|
||||
|
|
|
|||
|
|
@ -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<CoachFacetKey, string[]> {
|
||||
return { formats: [], levels: [], ageGroups: [], credentials: [], availability: [], languages: [] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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>
|
||||
))}
|
||||
|
||||
{/* 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 */}
|
||||
{sortBy !== 'recent' && (
|
||||
<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;
|
||||
selectedSkills: string[];
|
||||
onToggleSkill: (s: string) => void;
|
||||
coachFacets: Record<CoachFacetKey, string[]>;
|
||||
coachFilters: Record<CoachFacetKey, string[]>;
|
||||
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({
|
|||
</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>
|
||||
|
||||
{/* Apply */}
|
||||
|
|
@ -1312,6 +1370,7 @@ export default function LabPage() {
|
|||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [locationQuery, setLocationQuery] = useState('');
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
const [coachFilters, setCoachFilters] = useState(emptyCoachFilters);
|
||||
const [serviceType, setServiceType] = useState<ServiceType>(initialCraft);
|
||||
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>('all');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('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<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)
|
||||
const counts = useMemo<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;
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue