feat(crews): expandable filter labels + context-aware empty states

- Add a More/Less toggle (remembered in localStorage) that expands the focus
  toggle group from short labels to full names, wrapping instead of scrolling.
- Empty states are now context-aware: a search miss offers 'Clear search'; an
  empty scene filter reads 'No <Focus> crews yet' with 'Show all crews' rather
  than wrongly claiming no crews exist; a genuinely empty board keeps the
  'Claim your local scene' recruiting loop and drops the no-op near-me CTA.
This commit is contained in:
SkateSpots 2026-09-27 10:04:21 +07:00
parent 6b925a4fc0
commit 19f9acd7ef

View file

@ -11,13 +11,14 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { CrewRepBadge, CrewActivityPulse } from '@/components/CrewBadge';
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
import { useCrews } from '@/hooks/useCrews';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
import { useGeolocation } from '@/hooks/useGeolocation';
import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/crewHelpers';
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
import type { CrewActivity } from '@/lib/crewHelpers';
import { cn } from '@/lib/utils';
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2, Music, Palette, Dumbbell, Film } from 'lucide-react';
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2, Music, Palette, Dumbbell, Film, ChevronDown } from 'lucide-react';
// `label` is the full name (used for the tooltip + screen readers); `short` is
// what the compact toggle shows, to keep the filter bar to a single row.
@ -159,6 +160,8 @@ export default function CrewsPage() {
const [query, setQuery] = useState('');
const [focusFilter, setFocusFilter] = useState<CrewFocus | 'all'>('all');
const [nearMe, setNearMe] = useState(false);
// Remembered preference: compact short labels vs. the full filter names.
const [filtersExpanded, setFiltersExpanded] = useLocalStorage('crews-filters-expanded', false);
const userLoc: LatLng | null = geo.coords;
@ -275,16 +278,19 @@ export default function CrewsPage() {
)}
</div>
{/* Filters: one compact focus toggle group (scrolls on mobile) */}
{/* Filters: compact focus toggle group; "More" expands to full names */}
<div className="mb-5 flex items-center gap-2">
<div className="scroll-container min-w-0 flex-1 overflow-x-auto">
<div className={cn('min-w-0 flex-1', !filtersExpanded && 'scroll-container overflow-x-auto')}>
<ToggleGroup
type="single"
value={focusFilter}
onValueChange={(v) => {
if (v) setFocusFilter(v as CrewFocus | 'all');
}}
className="inline-flex w-max items-center gap-0.5 rounded-xl border border-white/10 bg-white/[0.04] p-1"
className={cn(
'items-center justify-start gap-0.5 rounded-xl border border-white/10 bg-white/[0.04] p-1',
filtersExpanded ? 'flex flex-wrap' : 'inline-flex w-max',
)}
>
{FOCUS_FILTERS.map(({ key, label, short, icon: Icon }) => (
<ToggleGroupItem
@ -299,12 +305,24 @@ export default function CrewsPage() {
)}
>
<Icon className="h-3.5 w-3.5" />
{short}
{filtersExpanded ? label : short}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{/* Expand/collapse the full filter names (remembered across visits) */}
<button
type="button"
onClick={() => setFiltersExpanded((v) => !v)}
aria-expanded={filtersExpanded}
title={filtersExpanded ? 'Show fewer filters' : 'Show full filter names'}
className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-2.5 text-xs font-bold text-gray-300 transition-colors hover:border-teal-500/40 hover:text-teal-300"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', filtersExpanded && 'rotate-180')} />
<span className="hidden sm:inline">{filtersExpanded ? 'Less' : 'More'}</span>
</button>
{/* Near me — mobile only; on desktop it was just clutter in this bar. */}
<button
type="button"
@ -343,7 +361,12 @@ export default function CrewsPage() {
))}
</div>
) : filtered.length === 0 ? (
<EmptyState query={query} onNearMe={handleNearMe} hasLocation={!!userLoc} />
<EmptyState
query={query}
focus={focusFilter}
onClearFilter={() => setFocusFilter('all')}
onClearSearch={() => setQuery('')}
/>
) : (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{filtered.map((crew) => (
@ -364,24 +387,53 @@ export default function CrewsPage() {
function EmptyState({
query,
onNearMe,
hasLocation,
focus,
onClearFilter,
onClearSearch,
}: {
query: string;
onNearMe: () => void;
hasLocation: boolean;
focus: CrewFocus | 'all';
onClearFilter: () => void;
onClearSearch: () => void;
}) {
// 1) A search with no hits — offer a way straight back out.
if (query) {
return (
<SharedEmptyState
icon={Users}
title="No crews match"
hint="Try a different search."
icon={Search}
title="No crews match your search"
hint={`Nothing found for “${query}”. Try another name or city.`}
actions={[
{ label: 'Clear search', onClick: onClearSearch, icon: X, variant: 'secondary' },
]}
/>
);
}
// Rich empty state — never feels dead. Prompts to claim the local scene.
// 2) A scene filter with no crews in it — there *are* crews, just not here.
// Saying "no crews planted here yet" would be flatly wrong, so offer the way out.
if (focus !== 'all') {
const label = CREW_FOCUS_LABELS[focus];
return (
<SharedEmptyState
icon={Users}
title={`No ${label} crews yet`}
hint={`Nobody's flying the “${label}” flag here yet. Show every crew, or start this one yourself.`}
actions={[
{ label: 'Show all crews', onClick: onClearFilter, icon: Users, variant: 'secondary' },
{
label: 'Create Crew',
to: '/create-crew',
icon: Plus,
className: 'bg-teal-500 text-black hover:bg-teal-600',
},
]}
/>
);
}
// 3) Genuinely no crews at all — make it a recruiting loop, not a dead end.
// (No "near me" CTA: with zero crews, sorting by distance does nothing.)
return (
<Card className="border-2 border-dashed border-teal-500/30 bg-gradient-to-br from-teal-500/[0.06] to-transparent">
<CardContent className="p-0">
@ -396,18 +448,6 @@ function EmptyState({
icon: Plus,
className: 'bg-teal-500 text-black hover:bg-teal-600',
},
...(!hasLocation
? [
{
label: 'Find crews near me',
onClick: onNearMe,
icon: Navigation,
variant: 'secondary' as const,
className:
'border-orange-500/40 text-orange-300 hover:bg-orange-500/10',
},
]
: []),
]}
/>
</CardContent>