- CrewCard: rebuilt to fit any screen width — clip overflow, truncate name/location, single wrapping badge row, consistent avatar sizing; responsive header - Amber web login: after opening Amber, drop user on manual paste step after 2.5s so the spinner never hangs when the callback opens in a new tab or clipboard is blocked; keep polling on the paste step so a late callback still auto-completes - Stash: hasNip44/hasNip04 now fall back to the active login when pubkey matching fails, so logged-in nsec/amber users are no longer wrongly told "Encryption Required" - Signup: added an explicit welcome step that explains account creation and only generates the keypair when the user taps "Create my account" (no more silent auto-creation on dialog open) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
380 lines
15 KiB
TypeScript
380 lines
15 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { Link } from 'react-router-dom';
|
|
import { Layout } from '@/components/Layout';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { CrewRepBadge, CrewActivityPulse } from '@/components/CrewBadge';
|
|
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 { 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 } from 'lucide-react';
|
|
|
|
const FOCUS_FILTERS: { key: CrewFocus | 'all'; label: string; icon: typeof Users }[] = [
|
|
{ key: 'all', label: 'All', icon: Users },
|
|
{ key: 'builder', label: CREW_FOCUS_LABELS.builder, icon: HardHat },
|
|
{ key: 'photo', label: CREW_FOCUS_LABELS.photo, icon: Camera },
|
|
{ key: 'coaching', label: CREW_FOCUS_LABELS.coaching, icon: GraduationCap },
|
|
{ key: 'art', label: CREW_FOCUS_LABELS.art, icon: Palette },
|
|
{ key: 'music', label: CREW_FOCUS_LABELS.music, icon: Music },
|
|
{ key: 'wellness', label: CREW_FOCUS_LABELS.wellness, icon: Dumbbell },
|
|
{ key: 'mixed', label: CREW_FOCUS_LABELS.mixed, icon: Sparkles },
|
|
];
|
|
|
|
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 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_LABELS[crew.focus]}
|
|
</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={`Crew band: ${crew.bandName}`}
|
|
>
|
|
<Music className="h-2.5 w-2.5" />
|
|
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_LABELS[v] || v}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export default function CrewsPage() {
|
|
useSeoMeta({
|
|
title: 'Crews - SkateSpots',
|
|
description: 'Find your people. Discover local skate crews, collectives and communities near you.',
|
|
});
|
|
|
|
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 [nearMe, setNearMe] = useState(false);
|
|
|
|
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 (
|
|
<Layout>
|
|
<div className="container max-w-3xl px-4 py-6">
|
|
{/* Header */}
|
|
<div className="mb-4 flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h1
|
|
className="flex items-center gap-2 text-2xl font-black uppercase text-teal-400 sm:text-3xl"
|
|
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
|
|
>
|
|
<Users className="h-6 w-6 shrink-0 sm:h-7 sm:w-7" /> Crews
|
|
</h1>
|
|
<p className="mt-1 text-sm text-gray-400">Your map-anchored skate family — the people you skate <span className="text-teal-400/80 font-semibold">with</span>, not who you hire (that's The Lab).</p>
|
|
{/* Social-proof stat line */}
|
|
{!isLoading && crews && crews.length > 0 && (
|
|
<p className="mt-1 text-xs font-bold text-teal-500/80">
|
|
{crews.length} crew{crews.length === 1 ? '' : 's'}
|
|
{cityCount > 0 && ` · ${cityCount} ${cityCount === 1 ? 'city' : 'cities'}`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Link to="/create-crew" className="shrink-0">
|
|
<Button className="gap-2 bg-teal-500 text-sm font-black uppercase text-black hover:bg-teal-600">
|
|
<Plus className="h-4 w-4" />
|
|
<span className="hidden sm:inline">Create Crew</span>
|
|
<span className="sm:hidden">Create</span>
|
|
</Button>
|
|
</Link>
|
|
</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="Search crews by name or city…"
|
|
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 row: focus chips + Near me */}
|
|
<div className="mb-5 flex flex-wrap items-center gap-2">
|
|
{FOCUS_FILTERS.map(({ key, label, icon: Icon }) => {
|
|
const active = focusFilter === key;
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => setFocusFilter(key)}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-bold transition-all active:scale-95',
|
|
active
|
|
? 'border-teal-500 bg-teal-500 text-black'
|
|
: 'border-white/10 bg-white/5 text-gray-300 hover:border-teal-500/40 hover:text-teal-300',
|
|
)}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
{label}
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleNearMe}
|
|
className={cn(
|
|
'ml-auto inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-bold transition-all active:scale-95',
|
|
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" />
|
|
)}
|
|
Near me
|
|
</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} onNearMe={handleNearMe} hasLocation={!!userLoc} />
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
|
{filtered.map((crew) => (
|
|
<CrewCard
|
|
key={`${crew.pubkey}:${crew.identifier}`}
|
|
crew={crew}
|
|
activity={activityMap?.get(crewCoordinate(crew))}
|
|
distanceKm={nearMe && userLoc ? crewDistanceKm(crew, userLoc) : null}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
function EmptyState({
|
|
query,
|
|
onNearMe,
|
|
hasLocation,
|
|
}: {
|
|
query: string;
|
|
onNearMe: () => void;
|
|
hasLocation: boolean;
|
|
}) {
|
|
if (query) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center px-4 py-20 text-center">
|
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
|
|
<Users className="h-8 w-8 text-gray-600" />
|
|
</div>
|
|
<h3 className="mb-2 text-lg font-black text-white">No crews match</h3>
|
|
<p className="max-w-xs text-sm text-gray-500">Try a different search.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Rich empty state — never feels dead. Prompts to claim the local scene.
|
|
return (
|
|
<Card className="border-2 border-dashed border-teal-500/30 bg-gradient-to-br from-teal-500/[0.06] to-transparent">
|
|
<CardContent className="px-6 py-12 text-center">
|
|
<div className="mx-auto max-w-sm space-y-5">
|
|
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-3xl bg-teal-500/10 text-4xl">
|
|
🛹
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<h3 className="text-xl font-black uppercase text-white">Claim your local scene</h3>
|
|
<p className="text-sm text-gray-400">
|
|
No crews planted here yet. Be the first — start your crew, link your home spots,
|
|
and put your scene on the map for everyone to find.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-center">
|
|
<Link to="/create-crew">
|
|
<Button className="w-full gap-2 bg-teal-500 font-black uppercase text-black hover:bg-teal-600 sm:w-auto">
|
|
<Plus className="h-4 w-4" /> Create Crew
|
|
</Button>
|
|
</Link>
|
|
{!hasLocation && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={onNearMe}
|
|
className="w-full gap-2 border-orange-500/40 font-bold text-orange-300 hover:bg-orange-500/10 sm:w-auto"
|
|
>
|
|
<Navigation className="h-4 w-4" /> Find crews near me
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|