- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event types, gear categories/conditions, price/listings, tours, crews, member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS copies now import it). spotStatus.statusTimeAgo() uses the locale date shim. - formatPrice() now takes an optional t so FREE/Trade/Make Offer translate. - Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge translated (routeUi.*), consumers updated. - Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas translated (sasquatchUi.*). - Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
472 lines
20 KiB
TypeScript
472 lines
20 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { Link } from 'react-router-dom';
|
|
import { LazyMount } from '@/components/LazyMount';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { CrewRepBadge, CrewActivityPulse } from '@/components/CrewBadge';
|
|
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { useCrews } from '@/hooks/useCrews';
|
|
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
|
|
import { useGeolocation } from '@/hooks/useGeolocation';
|
|
import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/crewHelpers';
|
|
import type { SkateCrew, CrewFocus } from '@/lib/types';
|
|
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS } from '@/lib/labelKeys';
|
|
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, ChevronDown, Check } from 'lucide-react';
|
|
|
|
const FOCUS_BADGE_ICONS: Record<CrewFocus, typeof Users> = {
|
|
mixed: Sparkles,
|
|
builder: HardHat,
|
|
photo: Camera,
|
|
coaching: GraduationCap,
|
|
art: Palette,
|
|
music: Music,
|
|
wellness: Dumbbell,
|
|
};
|
|
|
|
function CrewCard({
|
|
crew,
|
|
activity,
|
|
distanceKm,
|
|
}: {
|
|
crew: SkateCrew;
|
|
activity?: CrewActivity;
|
|
distanceKm: number | null;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const accent = crewAccent(crew);
|
|
const FocusIcon = crew.focus !== 'mixed' ? FOCUS_BADGE_ICONS[crew.focus] : null;
|
|
return (
|
|
<Link
|
|
to={`/crew/${crew.pubkey}/${crew.identifier}`}
|
|
className={cn(
|
|
'group flex w-full items-start gap-3 overflow-hidden rounded-2xl border p-3 text-left transition-colors active:scale-[0.99]',
|
|
accent.border,
|
|
accent.bg,
|
|
accent.hoverBorder,
|
|
)}
|
|
>
|
|
<Avatar
|
|
className="h-12 w-12 flex-shrink-0 rounded-xl border-2"
|
|
style={{ borderColor: `${accent.hex}55` }}
|
|
>
|
|
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
|
|
<AvatarFallback
|
|
className="rounded-xl text-sm font-black"
|
|
style={{ color: accent.hex, background: `${accent.hex}22` }}
|
|
>
|
|
{crew.name.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
{/* Name + member count */}
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<h3 className="min-w-0 flex-1 truncate text-sm font-black leading-tight text-white">
|
|
{crew.name}
|
|
</h3>
|
|
<span
|
|
className="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-bold"
|
|
style={{ color: accent.hex, background: `${accent.hex}22` }}
|
|
>
|
|
<Users className="h-2.5 w-2.5" />
|
|
{crew.members.length}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Location line */}
|
|
{(crew.location || distanceKm !== null) && (
|
|
<p className="flex min-w-0 items-center gap-1 text-xs text-gray-400">
|
|
<MapPin className="h-3 w-3 flex-shrink-0" style={{ color: accent.hex }} />
|
|
<span className="min-w-0 truncate">{crew.location}</span>
|
|
{distanceKm !== null && (
|
|
<span className="shrink-0 font-bold" style={{ color: accent.hex }}>
|
|
· {formatDistance(distanceKm)}
|
|
</span>
|
|
)}
|
|
</p>
|
|
)}
|
|
|
|
{/* Badges — all on one wrapping row, clipped so nothing spills */}
|
|
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
|
<CrewRepBadge crew={crew} />
|
|
<CrewActivityPulse activity={activity} />
|
|
{FocusIcon && (
|
|
<span className="inline-flex max-w-full items-center gap-1 truncate rounded-full border border-white/10 bg-white/[0.06] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-gray-300">
|
|
<FocusIcon className="h-2.5 w-2.5 shrink-0" />
|
|
{CREW_FOCUS_KEYS[crew.focus] ? t(CREW_FOCUS_KEYS[crew.focus]) : crew.focus}
|
|
</span>
|
|
)}
|
|
{crew.videoUrl && (
|
|
<span
|
|
className="inline-flex items-center gap-1 rounded-full border border-white/15 bg-white/[0.08] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white"
|
|
title={t('socialUi.crews.videoPartTooltip')}
|
|
>
|
|
<Film className="h-2.5 w-2.5" />
|
|
{t('socialUi.crews.part')}
|
|
</span>
|
|
)}
|
|
{crew.bandName && (
|
|
<span
|
|
className="inline-flex items-center gap-1 rounded-full border border-purple-500/30 bg-purple-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-purple-300"
|
|
title={t('socialUi.crews.bandTooltip', { name: crew.bandName })}
|
|
>
|
|
<Music className="h-2.5 w-2.5" />
|
|
{t('socialUi.crews.band')}
|
|
</span>
|
|
)}
|
|
{crew.vibes.slice(0, 2).map((v) => (
|
|
<span
|
|
key={v}
|
|
className="inline-flex max-w-full items-center truncate rounded-full border px-2 py-0.5 text-[10px] font-bold"
|
|
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}18` }}
|
|
>
|
|
{CREW_VIBE_KEYS[v] ? t(CREW_VIBE_KEYS[v]) : v}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export default function CrewsPage() {
|
|
const { t } = useTranslation();
|
|
useSeoMeta({
|
|
title: t('socialUi.crews.seoTitle'),
|
|
description: t('socialUi.crews.seoDescription'),
|
|
});
|
|
|
|
const { data: crews, isLoading } = useCrews(200);
|
|
const { data: activityMap } = useCrewsActivity(crews);
|
|
const geo = useGeolocation();
|
|
const [query, setQuery] = useState('');
|
|
const [focusFilter, setFocusFilter] = useState<CrewFocus | 'all'>('all');
|
|
const [focusOpen, setFocusOpen] = useState(false);
|
|
const [nearMe, setNearMe] = useState(false);
|
|
// Remembered preference: compact short labels vs. the full filter names.
|
|
const FOCUS_FILTERS: { key: CrewFocus | 'all'; label: string; short: string; icon: typeof Users }[] = [
|
|
{ key: 'all', label: t('socialUi.crews.focusAll'), short: t('socialUi.crews.focusAllShort'), icon: Users },
|
|
{ key: 'builder', label: CREW_FOCUS_KEYS.builder ? t(CREW_FOCUS_KEYS.builder) : 'builder', short: t('socialUi.crews.focusDiy'), icon: HardHat },
|
|
{ key: 'photo', label: CREW_FOCUS_KEYS.photo ? t(CREW_FOCUS_KEYS.photo) : 'photo', short: t('socialUi.crews.focusPhoto'), icon: Camera },
|
|
{ key: 'coaching', label: CREW_FOCUS_KEYS.coaching ? t(CREW_FOCUS_KEYS.coaching) : 'coaching', short: t('socialUi.crews.focusCoaching'), icon: GraduationCap },
|
|
{ key: 'art', label: CREW_FOCUS_KEYS.art ? t(CREW_FOCUS_KEYS.art) : 'art', short: t('socialUi.crews.focusArt'), icon: Palette },
|
|
{ key: 'music', label: CREW_FOCUS_KEYS.music ? t(CREW_FOCUS_KEYS.music) : 'music', short: t('socialUi.crews.focusMusic'), icon: Music },
|
|
{ key: 'wellness', label: CREW_FOCUS_KEYS.wellness ? t(CREW_FOCUS_KEYS.wellness) : 'wellness', short: t('socialUi.crews.focusPhysio'), icon: Dumbbell },
|
|
{ key: 'mixed', label: CREW_FOCUS_KEYS.mixed ? t(CREW_FOCUS_KEYS.mixed) : 'mixed', short: t('socialUi.crews.focusMixed'), icon: Sparkles },
|
|
];
|
|
const currentFocus = FOCUS_FILTERS.find((f) => f.key === focusFilter) ?? FOCUS_FILTERS[0];
|
|
const FocusIcon = currentFocus.icon;
|
|
|
|
const userLoc: LatLng | null = geo.coords;
|
|
|
|
// Stats: crew count + distinct cities (social proof header).
|
|
const { cityCount } = useMemo(() => {
|
|
const cities = new Set(
|
|
(crews ?? [])
|
|
.map((c) => c.location?.trim().toLowerCase())
|
|
.filter((c): c is string => !!c),
|
|
);
|
|
return { cityCount: cities.size };
|
|
}, [crews]);
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!crews) return [];
|
|
const q = query.trim().toLowerCase();
|
|
let list = crews.filter((c) => {
|
|
if (focusFilter !== 'all' && c.focus !== focusFilter) return false;
|
|
if (!q) return true;
|
|
return (
|
|
c.name.toLowerCase().includes(q) ||
|
|
c.location?.toLowerCase().includes(q) ||
|
|
c.bio?.toLowerCase().includes(q)
|
|
);
|
|
});
|
|
|
|
// Near-me: sort by distance ascending, crews without geo sink to the bottom.
|
|
if (nearMe && userLoc) {
|
|
list = [...list].sort((a, b) => {
|
|
const da = crewDistanceKm(a, userLoc);
|
|
const db = crewDistanceKm(b, userLoc);
|
|
if (da === null && db === null) return 0;
|
|
if (da === null) return 1;
|
|
if (db === null) return -1;
|
|
return da - db;
|
|
});
|
|
}
|
|
return list;
|
|
}, [crews, query, focusFilter, nearMe, userLoc]);
|
|
|
|
const handleNearMe = () => {
|
|
if (nearMe) {
|
|
setNearMe(false);
|
|
return;
|
|
}
|
|
if (!userLoc) geo.request();
|
|
setNearMe(true);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="container max-w-3xl px-4 py-6">
|
|
{/* Hero header band */}
|
|
<div className="relative mb-5 overflow-hidden rounded-3xl border border-teal-500/20 bg-gradient-to-br from-teal-500/[0.12] via-black to-black p-5 sm:p-6">
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute -right-16 -top-20 h-56 w-56 rounded-full bg-teal-500/25 blur-3xl"
|
|
/>
|
|
<div className="relative flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h1
|
|
className="flex items-center gap-2 text-3xl font-black uppercase leading-none text-white sm:text-4xl"
|
|
style={{ fontFamily: 'Impact, "Arial Black", sans-serif', textShadow: '0 0 24px rgba(20,184,166,0.4)' }}
|
|
>
|
|
<Users className="h-7 w-7 shrink-0 text-teal-400 sm:h-8 sm:w-8" /> {t('socialUi.crews.title')}
|
|
</h1>
|
|
<p className="mt-2 max-w-md text-sm leading-relaxed text-gray-400">
|
|
{t('socialUi.crews.heroLine')}{' '}
|
|
<span className="font-semibold text-teal-400/90">{t('socialUi.crews.heroWith')}</span>
|
|
{t('socialUi.crews.heroRest')}
|
|
</p>
|
|
{/* Social-proof stat line */}
|
|
{!isLoading && crews && crews.length > 0 && (
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-teal-500/30 bg-teal-500/10 px-3 py-1 text-xs font-black text-teal-300">
|
|
<Users className="h-3.5 w-3.5" />
|
|
{crews.length === 1
|
|
? t('socialUi.crews.crewCountOne', { count: crews.length })
|
|
: t('socialUi.crews.crewCountMany', { count: crews.length })}
|
|
</span>
|
|
{cityCount > 0 && (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-bold text-gray-300">
|
|
<MapPin className="h-3.5 w-3.5 text-teal-400" />
|
|
{cityCount === 1
|
|
? t('socialUi.crews.cityCountOne', { count: cityCount })
|
|
: t('socialUi.crews.cityCountMany', { count: cityCount })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Link to="/create-crew" className="shrink-0">
|
|
<Button className="gap-2 bg-teal-500 text-sm font-black uppercase text-black shadow-lg shadow-teal-500/30 hover:bg-teal-600">
|
|
<Plus className="h-4 w-4" />
|
|
<span className="hidden sm:inline">{t('socialUi.crews.createCrew')}</span>
|
|
<span className="sm:hidden">{t('socialUi.crews.create')}</span>
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="relative mb-3">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t('socialUi.crews.searchPlaceholder')}
|
|
className="h-10 w-full rounded-xl border border-white/10 bg-white/5 pl-9 pr-9 text-sm text-white placeholder:text-gray-600 transition-all focus:border-teal-500/50 focus:outline-none"
|
|
/>
|
|
{query && (
|
|
<button
|
|
onClick={() => setQuery('')}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filters: ONE compact control instead of a horizontally-scrolling chip
|
|
row (on a phone the chips were clipped and had to be swiped, and the
|
|
expand toggle read as just another category). */}
|
|
<div className="mb-5 flex items-center gap-2">
|
|
<Sheet open={focusOpen} onOpenChange={setFocusOpen}>
|
|
<SheetTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'inline-flex h-9 min-w-0 items-center gap-1.5 rounded-xl border px-3 text-sm font-bold transition-colors',
|
|
focusFilter === 'all'
|
|
? 'border-white/10 bg-white/[0.04] text-gray-300 hover:text-teal-300'
|
|
: 'border-teal-500/50 bg-teal-500/15 text-teal-300',
|
|
)}
|
|
>
|
|
<FocusIcon className="h-4 w-4 shrink-0" />
|
|
<span className="truncate">{currentFocus.label}</span>
|
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
|
</button>
|
|
</SheetTrigger>
|
|
<SheetContent side="bottom" className="rounded-t-2xl px-4 pb-8">
|
|
<SheetHeader className="pb-3 text-left">
|
|
<SheetTitle className="text-base">{t('socialUi.crews.filterTitle')}</SheetTitle>
|
|
<SheetDescription className="text-xs">
|
|
{t('socialUi.crews.filterDesc')}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="space-y-1">
|
|
{FOCUS_FILTERS.map(({ key, label, icon: Icon }) => {
|
|
const active = focusFilter === key;
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => {
|
|
setFocusFilter(key);
|
|
setFocusOpen(false);
|
|
}}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-bold transition-colors',
|
|
active ? 'bg-teal-500 text-black' : 'text-gray-200 hover:bg-white/5',
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0" />
|
|
<span className="flex-1">{label}</span>
|
|
{active && <Check className="h-4 w-4 shrink-0" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* Near me — mobile only; on desktop it was just clutter in this bar. */}
|
|
<button
|
|
type="button"
|
|
onClick={handleNearMe}
|
|
className={cn(
|
|
'inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-bold transition-all active:scale-95 sm:hidden',
|
|
nearMe
|
|
? 'border-orange-500 bg-orange-500 text-black'
|
|
: 'border-white/10 bg-white/5 text-gray-300 hover:border-orange-500/40 hover:text-orange-300',
|
|
)}
|
|
>
|
|
{geo.loading ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Navigation className="h-3.5 w-3.5" />
|
|
)}
|
|
{t('socialUi.crews.nearMe')}
|
|
</button>
|
|
</div>
|
|
|
|
{geo.error && nearMe && (
|
|
<p className="mb-4 text-xs text-orange-400">{geo.error}</p>
|
|
)}
|
|
|
|
{/* List */}
|
|
{isLoading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="flex items-center gap-3 rounded-2xl border border-white/5 p-3">
|
|
<Skeleton className="h-14 w-14 rounded-2xl" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-3 w-24" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<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) => (
|
|
<LazyMount key={`${crew.pubkey}:${crew.identifier}`} className="h-full">
|
|
<CrewCard
|
|
crew={crew}
|
|
activity={activityMap?.get(crewCoordinate(crew))}
|
|
distanceKm={nearMe && userLoc ? crewDistanceKm(crew, userLoc) : null}
|
|
/>
|
|
</LazyMount>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function EmptyState({
|
|
query,
|
|
focus,
|
|
onClearFilter,
|
|
onClearSearch,
|
|
}: {
|
|
query: string;
|
|
focus: CrewFocus | 'all';
|
|
onClearFilter: () => void;
|
|
onClearSearch: () => void;
|
|
}) {
|
|
// 1) A search with no hits — offer a way straight back out.
|
|
const { t } = useTranslation();
|
|
if (query) {
|
|
return (
|
|
<SharedEmptyState
|
|
icon={Search}
|
|
title={t('socialUi.crews.emptySearchTitle')}
|
|
hint={t('socialUi.crews.emptySearchHint', { query })}
|
|
actions={[
|
|
{ label: t('socialUi.crews.clearSearch'), onClick: onClearSearch, icon: X, variant: 'secondary' },
|
|
]}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 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_KEYS[focus] ? t(CREW_FOCUS_KEYS[focus]) : focus;
|
|
return (
|
|
<SharedEmptyState
|
|
icon={Users}
|
|
title={t('socialUi.crews.emptyFocusTitle', { label })}
|
|
hint={t('socialUi.crews.emptyFocusHint', { label })}
|
|
actions={[
|
|
{ label: t('socialUi.crews.showAll'), onClick: onClearFilter, icon: Users, variant: 'secondary' },
|
|
{
|
|
label: t('socialUi.crews.createCrew'),
|
|
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">
|
|
<SharedEmptyState
|
|
emoji="🛹"
|
|
title={t('socialUi.crews.claimTitle')}
|
|
hint={t('socialUi.crews.claimHint')}
|
|
actions={[
|
|
{
|
|
label: t('socialUi.crews.createCrew'),
|
|
to: '/create-crew',
|
|
icon: Plus,
|
|
className: 'bg-teal-500 text-black hover:bg-teal-600',
|
|
},
|
|
]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|