SkateSpots/src/pages/CrewsPage.tsx
shakespeare.diy 6848a72869 Crews: proximity discovery, activity/rep signals, treasury, hiring pipeline, crew-hosted events + reverse links; add Contacts page
Crews page
- "Near me" geolocation toggle with haversine distance sort + distance chips
- Crew count + cities social-proof stat header
- Deterministic per-crew vibe-colour accents (no more wall of teal)
- Activity pulse ("🔥 active this week") + OG/Verified/Established rep badges
- Rich empty state ("Claim your local scene") instead of dead "No crews yet"

Crew synergies
- Crew treasury: transparent, non-custodial NIP-75 zap goal (kind 9041) with
  live progress bar tallied from zap receipts; funds settle to founder's own
  Lightning wallet — no shared wallet or new wallet settings needed
- Crew-hosted events: host a jam/comp as the crew (event a-tag → crew coord),
  "Hosted by [crew]" on event pages, Crew Events list on the crew page
- Crew ↔ Lab hiring pipeline: "Open Call / looking-for" flags (filmer, editor,
  coach, builder, members) surfaced on the crew page
- Request to join: encrypted NIP-17 DM to founder + admins (no founder-push
  bottleneck, no public join event)
- Reverse links: "Crews that skate here" on spot pages

Contacts (0xchat-style + Armada-style privacy)
- New /contacts page with Crew-mates / Follows / Recent DMs / Saved tabs
- Per-contact quick actions: message, zap, view profile, star
- Private encrypted contacts list (NIP-51 kind 30000, NIP-44 encrypted to self)
- Added to burger menu + desktop More menu (gated to logged-in users)

Docs: NIP.md updated with crew treasury, crew-hosted events, looking-for tags,
and the private encrypted contacts list.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-25 12:38:45 +07:00

360 lines
14 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 } 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: 'mixed', label: CREW_FOCUS_LABELS.mixed, icon: Sparkles },
];
const FOCUS_BADGE_ICONS: Record<CrewFocus, typeof Users> = {
mixed: Sparkles,
builder: HardHat,
photo: Camera,
coaching: GraduationCap,
};
function CrewCard({
crew,
activity,
distanceKm,
}: {
crew: SkateCrew;
activity?: CrewActivity;
distanceKm: number | null;
}) {
const accent = crewAccent(crew);
return (
<Link
to={`/crew/${crew.pubkey}/${crew.identifier}`}
className={cn(
'group flex items-center gap-3 rounded-2xl border p-3 transition-all active:scale-[0.99]',
accent.border,
accent.bg,
accent.hoverBorder,
'hover:bg-white/[0.06]',
)}
>
<Avatar className="h-14 w-14 rounded-2xl border-2" style={{ borderColor: `${accent.hex}55` }}>
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
<AvatarFallback className="rounded-2xl 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">
<h3 className="flex items-center gap-1.5 truncate font-black leading-tight text-white">
<span className="truncate">{crew.name}</span>
<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>
</h3>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<CrewRepBadge crew={crew} />
<CrewActivityPulse activity={activity} />
{crew.focus !== 'mixed' && (() => {
const FocusIcon = FOCUS_BADGE_ICONS[crew.focus];
return (
<span className="inline-flex items-center gap-1 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" />
{CREW_FOCUS_LABELS[crew.focus]}
</span>
);
})()}
</div>
{(crew.location || distanceKm !== null) && (
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-gray-400">
<MapPin className="h-3 w-3 flex-shrink-0" style={{ color: accent.hex }} />
{crew.location}
{distanceKm !== null && (
<span className="ml-1 font-bold" style={{ color: accent.hex }}>
· {formatDistance(distanceKm)}
</span>
)}
</p>
)}
{crew.vibes.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{crew.vibes.slice(0, 3).map((v) => (
<span
key={v}
className="inline-flex items-center 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-4">
<div>
<h1
className="flex items-center gap-2 text-3xl font-black uppercase text-teal-400"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
>
<Users className="h-7 w-7" /> Crews
</h1>
<p className="mt-1 text-sm text-gray-400">Find your people — local crews, collectives &amp; communities</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">
<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">Start a Crew</span>
<span className="sm:hidden">Start</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 sm: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" /> Start a 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>
);
}