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 { CrewRepBadge, CrewActivityPulse } from '@/components/CrewBadge';
|
||||||
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
|
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
|
||||||
import { useCrews } from '@/hooks/useCrews';
|
import { useCrews } from '@/hooks/useCrews';
|
||||||
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
|
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
|
||||||
import { useGeolocation } from '@/hooks/useGeolocation';
|
import { useGeolocation } from '@/hooks/useGeolocation';
|
||||||
import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/crewHelpers';
|
import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/crewHelpers';
|
||||||
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
|
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
|
||||||
import type { CrewActivity } from '@/lib/crewHelpers';
|
import type { CrewActivity } from '@/lib/crewHelpers';
|
||||||
import { cn } from '@/lib/utils';
|
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
|
// `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.
|
// 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 [query, setQuery] = useState('');
|
||||||
const [focusFilter, setFocusFilter] = useState<CrewFocus | 'all'>('all');
|
const [focusFilter, setFocusFilter] = useState<CrewFocus | 'all'>('all');
|
||||||
const [nearMe, setNearMe] = useState(false);
|
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;
|
const userLoc: LatLng | null = geo.coords;
|
||||||
|
|
||||||
|
|
@ -275,16 +278,19 @@ export default function CrewsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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="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
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
value={focusFilter}
|
value={focusFilter}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v) setFocusFilter(v as CrewFocus | 'all');
|
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 }) => (
|
{FOCUS_FILTERS.map(({ key, label, short, icon: Icon }) => (
|
||||||
<ToggleGroupItem
|
<ToggleGroupItem
|
||||||
|
|
@ -299,12 +305,24 @@ export default function CrewsPage() {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-3.5 w-3.5" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
{short}
|
{filtersExpanded ? label : short}
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
</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. */}
|
{/* Near me — mobile only; on desktop it was just clutter in this bar. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -343,7 +361,12 @@ export default function CrewsPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : 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">
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
{filtered.map((crew) => (
|
{filtered.map((crew) => (
|
||||||
|
|
@ -364,24 +387,53 @@ export default function CrewsPage() {
|
||||||
|
|
||||||
function EmptyState({
|
function EmptyState({
|
||||||
query,
|
query,
|
||||||
onNearMe,
|
focus,
|
||||||
hasLocation,
|
onClearFilter,
|
||||||
|
onClearSearch,
|
||||||
}: {
|
}: {
|
||||||
query: string;
|
query: string;
|
||||||
onNearMe: () => void;
|
focus: CrewFocus | 'all';
|
||||||
hasLocation: boolean;
|
onClearFilter: () => void;
|
||||||
|
onClearSearch: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
// 1) A search with no hits — offer a way straight back out.
|
||||||
if (query) {
|
if (query) {
|
||||||
return (
|
return (
|
||||||
<SharedEmptyState
|
<SharedEmptyState
|
||||||
icon={Users}
|
icon={Search}
|
||||||
title="No crews match"
|
title="No crews match your search"
|
||||||
hint="Try a different 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 (
|
return (
|
||||||
<Card className="border-2 border-dashed border-teal-500/30 bg-gradient-to-br from-teal-500/[0.06] to-transparent">
|
<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">
|
<CardContent className="p-0">
|
||||||
|
|
@ -396,18 +448,6 @@ function EmptyState({
|
||||||
icon: Plus,
|
icon: Plus,
|
||||||
className: 'bg-teal-500 text-black hover:bg-teal-600',
|
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>
|
</CardContent>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue