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:
parent
6b925a4fc0
commit
19f9acd7ef
1 changed files with 66 additions and 26 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue