Step 3: Crews — map-anchored collective feature (kind 35006)

- New SkateCrew kind 35006 + type/parser + useCrews/useCrew hooks
- /crews directory, /create-crew, /edit-crew, /crew/:pubkey/:identifier pages
- Reuses the collective engine: NIP-32 membership attestation (confirmed/pending),
  CollectiveMembers + ConfirmMembershipCard, ClinkPayButton, SpotPicker
- Activity v1: home spots + crew member feed (no events, per plan)
- Map: teal/cyan Crews layer at home turf with crew markers + popups;
  wired through SpotMap props, layer toggles (mobile popover + filters sheet),
  and mobileLayerCount badge
- Nav: Crews added to navItemKeys (desktop header, mobile bottom bar, sidebar)
  with en/es i18n keys
- NIP.md: documents kind 35006, tag taxonomy, and shared membership mechanism

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 13:59:50 +07:00
parent 657c36d264
commit f10aa7971c
15 changed files with 1397 additions and 5 deletions

87
NIP.md
View file

@ -1628,10 +1628,95 @@ on the map.
}
```
## Skate Crews (kind 35006)
A **Crew** is a map-anchored collective identity — the crew you skate/roll with,
not a service you hire. It answers *"who skates here?"* rather than *"who can I
hire?"* (that's The Lab). Crews are addressable events identified by
`pubkey + kind + d-tag`.
A crew is structurally a **collective** (it reuses the exact NIP-32 membership
attestation mechanism defined in [The Lab § Collective Membership](#collective-membership-nip-32-attestations))
minus the service/rate fields, plus a **home turf** (map anchor) and optional
**home spots**. Members are always attested per-member with their own key — a
bare `p` tag on the crew is only a claim until the member self-labels.
### Event Format
```json
{
"kind": 35006,
"content": "Late-night ledge crew. We haunt the plaza. All welcome, no kooks.",
"tags": [
["d", "barcelona-night-crew"],
["title", "Barcelona Night Crew"],
["t", "skatecrew"],
["image", "https://blossom.example/crew-logo.png"],
["g", "sp3e"],
["location", "Barcelona, Spain"],
["founded", "2019"],
["t", "street"],
["t", "transition"],
["p", "<memberPubkey1>"],
["p", "<memberPubkey2>"],
["a", "30818:<spotFounder>:<spotDtag>"],
["website", "https://bcnnightcrew.example"],
["noffer", "noffer1qqs..."],
["alt", "Skate crew: Barcelona Night Crew"]
]
}
```
### Required Tags
| Tag | Description |
|-----|-------------|
| `d` | Unique crew identifier |
| `title` | Crew name |
| `t: skatecrew` | Self-tag for discovery/querying |
### Optional Tags
| Tag | Description |
|-----|-------------|
| `image` | Crew logo / photo |
| `g` | Geohash of the crew's **home turf** (map anchor) |
| `location` | Human-readable home location |
| `founded` | Year founded (cosmetic) |
| `t: <vibe>` | Style/focus tags: `street`, `transition`, `diy`, `oldschool`, `allterrain`, `longboard`, `bmx-friendly`, `bowl`, `vert`, `cruising`, `downhill`, etc. |
| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). |
| `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. |
| `website`, `instagram`, `social` | Links |
| `noffer` | Optional shared tip jar (CLINK Offer — see below) |
### Membership
Crews reuse the collective membership model verbatim. A member confirms
belonging by publishing a NIP-32 label (kind `1985`) referencing the crew's
`a`-coordinate, `["l", "member", "org.skatespots.membership"]`. Confirmed /
pending chips and removal semantics are identical to Lab collectives.
### Map Integration
Crews with a `g` (home turf) render on the SkateSpots map as teal pins. Clicking
a crew pin opens its profile. A crew's `a`-referenced home spots remain regular
spots on the map and cross-link back to the crew.
### Querying Crews
```json
{
"kinds": [35006],
"#t": ["skatecrew"],
"limit": 200
}
```
## CLINK Payments (`noffer` tag)
SkateSpots supports an **optional, opt-in** self-custodial payment code on Lab
provider profiles (kinds 35001–35004) and skate shop stalls (kind 30017). This
provider profiles (kinds 35001–35004), skate crews (kind 35006), and skate shop
stalls (kind 30017). This
is layered on top of — never a replacement for — the existing Lightning Address
(`lud16` from kind 0) and zap flows.

View file

@ -31,6 +31,10 @@ const BecomeFilmerPage = lazy(() => import("./pages/BecomeFilmerPage"));
const BecomeEditorPage = lazy(() => import("./pages/BecomeEditorPage"));
const BecomePhotographerPage = lazy(() => import("./pages/BecomePhotographerPage"));
const BecomeBuilderPage = lazy(() => import("./pages/BecomeBuilderPage"));
const CrewsPage = lazy(() => import("./pages/CrewsPage"));
const CreateCrewPage = lazy(() => import("./pages/CreateCrewPage"));
const EditCrewPage = lazy(() => import("./pages/EditCrewPage"));
const CrewDetailPage = lazy(() => import("./pages/CrewDetailPage"));
const CoachDetailPage = lazy(() => import("./pages/CoachDetailPage"));
const TourDetailPage = lazy(() => import("./pages/TourDetailPage"));
const AboutPage = lazy(() => import("./pages/AboutPage"));
@ -84,6 +88,10 @@ export function AppRouter() {
<Route path="/become-editor" element={<BecomeEditorPage />} />
<Route path="/become-photographer" element={<BecomePhotographerPage />} />
<Route path="/become-builder" element={<BecomeBuilderPage />} />
<Route path="/crews" element={<CrewsPage />} />
<Route path="/create-crew" element={<CreateCrewPage />} />
<Route path="/edit-crew/:pubkey/:identifier" element={<EditCrewPage />} />
<Route path="/crew/:pubkey/:identifier" element={<CrewDetailPage />} />
<Route path="/coach/:pubkey/:identifier" element={<CoachDetailPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/pyramid" element={<PyramidPage />} />

367
src/components/CrewForm.tsx Normal file
View file

@ -0,0 +1,367 @@
import { useState } from 'react';
import { nip19 } from 'nostr-tools';
import ngeohash from 'ngeohash';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import {
SKATE_CREW_KIND,
SKATE_SPOT_KIND,
CREW_VIBE_LABELS,
isValidNoffer,
type SkateCrew,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus } from 'lucide-react';
const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS);
const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black';
function MemberRow({ pubkey, onRemove }: { pubkey: string; onRemove: () => void }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(pubkey);
const npub = nip19.npubEncode(pubkey);
return (
<div className="flex items-center gap-3 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
<Avatar className="h-8 w-8">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{name}</p>
<p className="truncate text-xs text-gray-500">{npub.slice(0, 16)}…</p>
</div>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label="Remove member">
<X className="h-4 w-4" />
</Button>
</div>
);
}
interface CrewFormProps {
/** Existing crew when editing. */
existing?: SkateCrew | null;
}
export function CrewForm({ existing }: CrewFormProps) {
const navigate = useNavigate();
const { user } = useCurrentUser();
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const [name, setName] = useState(existing?.name ?? '');
const [bio, setBio] = useState(existing?.bio ?? '');
const [location, setLocation] = useState(existing?.location ?? '');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [vibes, setVibes] = useState<string[]>(existing?.vibes ?? []);
const [founded, setFounded] = useState(existing?.founded ?? '');
const [image, setImage] = useState<string | null>(existing?.image ?? null);
const [website, setWebsite] = useState(existing?.website ?? '');
const [instagram, setInstagram] = useState(existing?.instagram ?? '');
const [social, setSocial] = useState(existing?.social ?? '');
const [noffer, setNoffer] = useState(existing?.noffer ?? '');
const [members, setMembers] = useState<string[]>(existing?.members ?? []);
const [memberInput, setMemberInput] = useState('');
const [memberError, setMemberError] = useState<string | null>(null);
const [selectedSpots, setSelectedSpots] = useState<string[]>(
(existing?.spotRefs ?? []).map((ref) => {
const parts = ref.split(':');
return `${parts[1]}:${parts[2]}`;
})
);
const [isGettingLocation, setIsGettingLocation] = useState(false);
const toggleVibe = (v: string) =>
setVibes((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]));
const addMember = () => {
const raw = memberInput.trim();
if (!raw) return;
let hex: string | null = null;
try {
if (raw.startsWith('npub1') || raw.startsWith('nostr:npub1')) {
const decoded = nip19.decode(raw.replace(/^nostr:/, ''));
if (decoded.type === 'npub') hex = decoded.data;
} else if (raw.startsWith('nprofile1')) {
const decoded = nip19.decode(raw);
if (decoded.type === 'nprofile') hex = decoded.data.pubkey;
} else if (/^[0-9a-f]{64}$/i.test(raw)) {
hex = raw.toLowerCase();
}
} catch {
hex = null;
}
if (!hex) {
setMemberError('Enter a valid npub, nprofile, or hex pubkey.');
return;
}
if (members.includes(hex)) {
setMemberError('That member is already added.');
return;
}
setMembers([...members, hex]);
setMemberInput('');
setMemberError(null);
};
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' });
return;
}
setIsGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({ title: 'Location found' });
},
(error) => {
setIsGettingLocation(false);
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
}
);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
try {
const tags = await uploadFile(files[0]);
const url = tags[0]?.[1];
if (url) {
setImage(url);
toast({ title: 'Logo uploaded' });
}
} catch (error) {
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
}
e.target.value = '';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
toast({ title: 'Name required', description: 'Give your crew a name.', variant: 'destructive' });
return;
}
try {
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
const tags: string[][] = [
['d', identifier],
['title', name.trim()],
['t', 'skatecrew'],
['alt', `Skate crew: ${name.trim()}`],
];
if (location.trim()) tags.push(['location', location.trim()]);
// Home turf geohash — prefer freshly captured coords, else keep existing
if (latitude && longitude) {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
} else if (existing?.geohash) {
tags.push(['g', existing.geohash]);
}
for (const v of vibes) tags.push(['t', v]);
if (founded.trim()) tags.push(['founded', founded.trim()]);
if (image) tags.push(['image', image]);
if (website.trim()) tags.push(['website', website.trim()]);
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
if (social.trim()) tags.push(['social', social.trim()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
for (const pk of members) tags.push(['p', pk]);
for (const spotId of selectedSpots) {
const [spotPubkey, spotIdentifier] = spotId.split(':');
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
}
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
navigate(`/crew/${user?.pubkey}/${identifier}`);
} catch (error) {
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
}
};
return (
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-teal-400" /> {existing ? 'Edit Crew' : 'Crew Details'}
</CardTitle>
<CardDescription className="text-gray-400">Your crew's identity, home turf and members</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="crew-name">Crew Name *</Label>
<Input id="crew-name" placeholder="e.g. Backyard Bandits" value={name} onChange={(e) => setName(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="crew-bio">About / Manifesto</Label>
<Textarea id="crew-bio" placeholder="Who you are, what you skate, your story..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
</div>
{/* Vibe tags */}
<div className="space-y-3">
<Label>Vibe / Focus</Label>
<div className="flex flex-wrap gap-2">
{VIBE_ENTRIES.map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => toggleVibe(key)}
className={cn(
'px-3 py-2 rounded-xl text-xs font-bold border transition-all active:scale-95',
vibes.includes(key)
? 'bg-teal-500 text-black border-teal-500'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
)}
>
{label}
</button>
))}
</div>
</div>
{/* Members */}
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div>
<Label className="flex items-center gap-2 text-teal-400"><Users className="h-4 w-4" /> Members</Label>
<p className="mt-1 text-xs text-gray-500">
Add your crew's Nostr accounts. Each member confirms from their own key — until then they show as “pending”.
</p>
</div>
<div className="flex gap-2">
<Input
placeholder="npub1… / nprofile1… / hex pubkey"
value={memberInput}
onChange={(e) => { setMemberInput(e.target.value); setMemberError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addMember(); } }}
spellCheck={false}
autoCapitalize="none"
className="bg-gray-800 border-gray-700 font-mono text-sm"
/>
<Button type="button" onClick={addMember} className={cn('shrink-0 gap-1', TEAL)}>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{memberError && <p className="text-xs text-red-400">{memberError}</p>}
{members.length > 0 && (
<div className="space-y-2">
{members.map((pk) => (
<MemberRow key={pk} pubkey={pk} onRemove={() => setMembers(members.filter((m) => m !== pk))} />
))}
</div>
)}
</div>
{/* Home spots */}
<SpotPicker
selectedSpots={selectedSpots}
onSpotsChange={setSelectedSpots}
maxSpots={10}
label="Home Spots"
description="Link the spots your crew haunts — they'll connect to your crew on the map"
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="crew-location">Home Turf (City / Area)</Label>
<Input id="crew-location" placeholder="Bristol, UK" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="crew-founded">Founded (Year)</Label>
<Input id="crew-founded" type="number" placeholder="2015" value={founded} onChange={(e) => setFounded(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
<div className="space-y-2">
<Label>Home Turf Coordinates (map anchor)</Label>
<div className="grid grid-cols-2 gap-4">
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
</Button>
{existing?.geohash && !latitude && (
<p className="text-xs text-gray-500">Leave blank to keep the current home turf location.</p>
)}
</div>
{/* Links */}
<div className="space-y-3">
<Label className="text-base font-bold">Links</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="crew-instagram">Instagram</Label>
<Input id="crew-instagram" placeholder="@crew" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="crew-website">Website</Label>
<Input id="crew-website" type="url" placeholder="https://crew.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="crew-social">Other Social</Label>
<Input id="crew-social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
<NofferField value={noffer} onChange={setNoffer} />
</div>
{/* Logo */}
<div className="space-y-2">
<Label>Crew Logo / Photo</Label>
{image ? (
<div className="relative w-32 h-32 rounded-2xl overflow-hidden bg-gray-800">
<img src={image} alt="Crew" className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
<X className="h-4 w-4" />
</button>
</div>
) : (
<label className="w-32 h-32 rounded-2xl border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50">
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">Upload</span></>}
</label>
)}
</div>
<Button type="submit" className={cn('w-full uppercase font-black', TEAL)} disabled={isPublishing || isUploading}>
{isPublishing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
) : (
<><Users className="h-4 w-4 mr-2" />{existing ? 'Save Crew' : 'Create Crew'}</>
)}
</Button>
</CardContent>
</Card>
</form>
);
}

View file

@ -45,6 +45,7 @@ import {
Heart,
CreditCard,
Radio,
Users,
} from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -67,6 +68,7 @@ const navItemKeys = [
{ href: '/', labelKey: 'nav.map', icon: MapPin },
{ href: '/lab', labelKey: 'nav.lab', icon: GraduationCap },
{ href: '/events', labelKey: 'nav.events', icon: Calendar },
{ href: '/crews', labelKey: 'nav.crews', icon: Users },
];
// Format sats for display (e.g., 1234 -> "1.2k", 12345 -> "12k")

View file

@ -6,7 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { MapPin } from 'lucide-react';
import type { SkateSpot, MarketplaceItem, SkateTour, SkateEvent } from '@/lib/types';
import type { SkateSpot, MarketplaceItem, SkateTour, SkateEvent, SkateCrew } from '@/lib/types';
import { SPOT_TYPE_LABELS, formatPrice } from '@/lib/types';
import type { SpotWeatherData } from '@/hooks/useSpotWeather';
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
@ -235,6 +235,45 @@ const createEventIcon = () => L.divIcon({
popupAnchor: [0, -38],
});
// Custom crew marker icon (teal/cyan — crew home turf)
const createCrewIcon = (logo?: string) => L.divIcon({
className: 'crew-marker',
html: `
<div style="
width: 42px;
height: 42px;
background: linear-gradient(135deg, #14b8a6, #06b6d4);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 14px rgba(20,184,166,0.55);
border: 3px solid #fff;
overflow: hidden;
">
${logo ? `
<img src="${logo}" style="
width: 100%;
height: 100%;
object-fit: cover;
transform: rotate(45deg) scale(1.5);
" onerror="this.style.display='none'" />
` : `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
`}
</div>
`,
iconSize: [42, 42],
iconAnchor: [21, 42],
popupAnchor: [0, -42],
});
// Custom BTC Map marker icon (for Bitcoin-accepting places)
const createBtcMapIcon = (isBoosted?: boolean) => L.divIcon({
className: 'btcmap-marker',
@ -332,6 +371,7 @@ interface SpotMapProps {
marketplaceItems?: MarketplaceItem[];
tours?: SkateTour[];
events?: SkateEvent[];
crews?: SkateCrew[];
btcMapPlaces?: BtcMapPlace[];
center?: [number, number];
zoom?: number;
@ -348,6 +388,7 @@ interface SpotMapProps {
showGear?: boolean;
showTours?: boolean;
showEvents?: boolean;
showCrews?: boolean;
showBtcMap?: boolean;
flyTo?: { lat: number; lng: number; zoom?: number } | null;
}
@ -576,6 +617,7 @@ export function SpotMap({
marketplaceItems = [],
tours = [],
events = [],
crews = [],
btcMapPlaces = [],
center = [40.7128, -74.006], // Default to NYC
zoom = 3,
@ -592,6 +634,7 @@ export function SpotMap({
showGear = true,
showTours = true,
showEvents = false,
showCrews = false,
showBtcMap = false,
flyTo = null,
}: SpotMapProps) {
@ -1125,7 +1168,87 @@ export function SpotMap({
markersRef.current.push(eventMarker);
});
}
}, [locationGroups, tours, showTours, events, showEvents, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate]);
// Add crew markers (crew home turf — teal pins)
if (showCrews && crews.length > 0) {
crews.forEach((crew) => {
if (!crew.geohash) return; // Only plot crews with a home turf
const decoded = ngeohash.decode(crew.geohash);
const crewMarker = L.marker([decoded.latitude, decoded.longitude], {
icon: createCrewIcon(crew.image),
});
const container = document.createElement('div');
container.className = 'p-3 min-w-[240px] max-w-[300px]';
// Header
const header = document.createElement('div');
header.className = 'flex items-start gap-2 mb-3';
header.innerHTML = `
<div class="w-10 h-10 rounded-lg bg-teal-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
${crew.image
? `<img src="${crew.image}" class="w-full h-full object-cover" onerror="this.parentElement.innerHTML='<span class=\\'text-lg\\'>🤘</span>'" />`
: `<span class="text-lg">🤘</span>`}
</div>
<div class="flex-1 min-w-0">
<h3 class="font-bold text-base text-white leading-tight">${crew.name}</h3>
<p class="text-xs text-teal-400 font-medium">SKATE CREW</p>
</div>
`;
container.appendChild(header);
// Details
const details = document.createElement('div');
details.className = 'space-y-1.5 mb-3 text-xs text-gray-300';
if (crew.location) {
details.innerHTML += `<div class="flex items-center gap-2">📍 ${crew.location}</div>`;
}
if (crew.members.length > 0) {
details.innerHTML += `<div class="flex items-center gap-2">👥 ${crew.members.length} member${crew.members.length === 1 ? '' : 's'}</div>`;
}
if (crew.founded) {
details.innerHTML += `<div class="flex items-center gap-2">📅 Since ${crew.founded}</div>`;
}
container.appendChild(details);
// Vibe tags
if (crew.vibes.length > 0) {
const badges = document.createElement('div');
badges.className = 'flex flex-wrap gap-1 mb-3';
crew.vibes.slice(0, 4).forEach((vibe) => {
const badge = document.createElement('span');
badge.className = 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-teal-500/20 text-teal-300 border border-teal-500/30';
badge.textContent = vibe;
badges.appendChild(badge);
});
container.appendChild(badges);
}
// View button
const button = document.createElement('button');
button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-bold bg-teal-500 text-white hover:bg-teal-600 h-8 px-3 transition-colors';
button.textContent = 'View Crew →';
button.onclick = (e) => {
e.preventDefault();
navigate(`/crew/${crew.pubkey}/${crew.identifier}`);
};
container.appendChild(button);
crewMarker.bindPopup(container, {
maxWidth: 320,
className: 'custom-popup crew-popup',
});
if (clusterGroupRef.current) {
clusterGroupRef.current.addLayer(crewMarker);
} else {
crewMarker.addTo(map);
}
markersRef.current.push(crewMarker);
});
}
}, [locationGroups, tours, showTours, events, showEvents, crews, showCrews, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate]);
// Update BTC Map markers
useEffect(() => {

39
src/hooks/useCrewFeed.ts Normal file
View file

@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Recent kind-1 notes from a crew's members — the "crew feed".
* Keeps the crew page alive without any new event kinds.
*/
export function useCrewFeed(memberPubkeys: string[], limit = 30) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['crew-feed', memberPubkeys.slice().sort().join(','), getQueryKeySuffix()],
queryFn: async (): Promise<NostrEvent[]> => {
if (memberPubkeys.length === 0) return [];
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[
{
kinds: [1],
authors: memberPubkeys,
limit,
},
],
{ signal }
);
// Exclude replies — a crew feed reads better as top-level posts.
const topLevel = events.filter(
(e) => !e.tags.some(([name]) => name === 'e')
);
return topLevel.sort((a, b) => b.created_at - a.created_at).slice(0, limit);
},
enabled: memberPubkeys.length > 0,
staleTime: 60 * 1000,
});
}

72
src/hooks/useCrews.ts Normal file
View file

@ -0,0 +1,72 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_CREW_KIND, parseSkateCrew, type SkateCrew } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
export function useCrews(limit = 200) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['skate-crews', limit, getQueryKeySuffix()],
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[
{
kinds: [SKATE_CREW_KIND],
'#t': ['skatecrew'],
limit,
},
],
{ signal }
);
const crews: SkateCrew[] = [];
for (const event of events) {
const crew = parseSkateCrew(event);
if (crew) crews.push(crew);
}
return crews.sort((a, b) => b.createdAt - a.createdAt);
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: true,
});
}
export function useCrew(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['skate-crew', identifier, pubkey, getQueryKeySuffix()],
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
const filter: {
kinds: number[];
'#d': string[];
authors?: string[];
limit: number;
} = {
kinds: [SKATE_CREW_KIND],
'#d': [identifier],
limit: 1,
};
if (pubkey) filter.authors = [pubkey];
const events = await querySkateContent([filter], { signal });
if (events.length === 0) return null;
const mostRecent = events.reduce((latest, current) =>
current.created_at > latest.created_at ? current : latest
);
return parseSkateCrew(mostRecent);
},
enabled: !!identifier,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: true,
});
}

View file

@ -52,6 +52,7 @@ const en = {
map: 'Shops & Spots',
lab: 'The Lab',
events: 'Events',
crews: 'Crews',
wallet: 'Wallet',
settings: 'Settings',
more: 'More',

View file

@ -53,6 +53,7 @@ const es = {
map: 'Tiendas y Spots',
lab: 'El Lab',
events: 'Eventos',
crews: 'Crews',
wallet: 'Billetera',
settings: 'Ajustes',
more: 'Más',

View file

@ -50,6 +50,7 @@ export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles
export const SKATE_PHOTOGRAPHER_KIND = 35004; // Skate photographer profiles
export const SKATE_BUILDER_KIND = 35005; // Skate ramp / park builder profiles
export const SKATE_CREW_KIND = 35006; // Skate crews (community identity, map-anchored)
export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings
export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections
export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots
@ -749,6 +750,45 @@ export const BUILDER_SCALE_LABELS: Record<string, string> = {
event: 'Event / Contest Course',
};
// ----- Crews (community identity, map-anchored) -----
// Crew vibe / focus tags
export const CREW_VIBE_LABELS: Record<string, string> = {
street: 'Street',
transition: 'Transition / Vert',
park: 'Park',
diy: 'DIY',
cruising: 'Cruising / Filming',
oldschool: 'Old School',
allterrain: 'All Terrain',
longboard: 'Longboard',
girls: 'Girls / Womxn',
youth: 'Youth / Groms',
lgbtq: 'LGBTQ+',
adaptive: 'Adaptive',
};
export interface SkateCrew {
id: string;
pubkey: string;
identifier: string;
name: string;
bio: string; // Freeform crew manifesto / about
geohash?: string; // Home turf (map anchor)
location?: string;
image?: string; // Crew logo / photo
vibes: string[]; // Style/focus tags
founded?: string; // Year founded (cosmetic)
website?: string;
instagram?: string;
social?: string;
noffer?: string; // Optional shared tip jar (CLINK)
members: string[]; // Member pubkeys (hex) via `p` tags
spotRefs: string[]; // Home spots (kind 30818 refs)
createdAt: number;
event: NostrEvent;
}
export interface SkateBuilder {
id: string;
pubkey: string;
@ -1221,6 +1261,55 @@ export function parseSkateBuilder(event: NostrEvent): SkateBuilder | null {
};
}
export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
if (event.kind !== SKATE_CREW_KIND) return null;
const hasCrewTag = event.tags.some(([name, value]) => name === 't' && value === 'skatecrew');
if (!hasCrewTag) return null;
const d = event.tags.find(([name]) => name === 'd')?.[1];
const name = event.tags.find(([name]) => name === 'title')?.[1] || event.tags.find(([name]) => name === 'name')?.[1];
if (!d || !name) return null;
if (name.trim().length < MIN_TITLE_LEN || name.length > MAX_TITLE_LEN) return null;
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
const image = event.tags.find(([name]) => name === 'image')?.[1];
const vibes = event.tags.filter(([name, value]) => name === 't' && value !== 'skatecrew').map(([, value]) => value);
const founded = event.tags.find(([name]) => name === 'founded')?.[1];
const website = event.tags.find(([name]) => name === 'website')?.[1];
const instagram = event.tags.find(([name]) => name === 'instagram')?.[1];
const social = event.tags.find(([name]) => name === 'social')?.[1];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
const members = parseMemberPubkeys(event);
const spotRefs = event.tags
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
.map(([, value]) => value);
return {
id: event.id,
pubkey: event.pubkey,
identifier: d,
name: name.slice(0, MAX_TITLE_LEN),
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
location,
geohash,
image,
vibes,
founded,
website,
instagram,
social,
noffer,
members,
spotRefs,
createdAt: event.created_at,
event,
};
}
export function parseSkateTour(event: NostrEvent): SkateTour | null {
if (event.kind !== SKATE_TOUR_KIND) return null;

View file

@ -0,0 +1,52 @@
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { Card, CardContent } from '@/components/ui/card';
import { LoginArea } from '@/components/auth/LoginArea';
import { CrewForm } from '@/components/CrewForm';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { Users, ArrowLeft } from 'lucide-react';
export default function CreateCrewPage() {
useSeoMeta({
title: 'Start a Crew - SkateSpots',
description: 'Create your skate crew — home turf, members and spots.',
});
const { user } = useCurrentUser();
if (!user) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center">
<Users className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<p className="text-gray-400 mb-6">You need to log in to start a crew.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<div className="mb-6">
<Link to="/crews" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Crews
</Link>
<h1 className="text-3xl font-black uppercase text-teal-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
Start a Crew
</h1>
<p className="text-gray-400 mt-1">Rally your people — plant your flag on the map</p>
</div>
<CrewForm />
</div>
</Layout>
);
}

View file

@ -0,0 +1,294 @@
import { useSeoMeta } from '@unhead/react';
import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ZapButton } from '@/components/ZapButton';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { NoteContent } from '@/components/NoteContent';
import { useCrew } from '@/hooks/useCrews';
import { useCrewFeed } from '@/hooks/useCrewFeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } from '@/lib/types';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import NotFound from './NotFound';
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
function FeedItem({ event }: { event: NostrEvent }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(event.pubkey);
const npub = nip19.npubEncode(event.pubkey);
return (
<div className="rounded-xl border border-white/5 bg-black/40 p-3">
<div className="mb-1.5 flex items-center gap-2">
<Link to={`/${npub}`}>
<Avatar className="h-7 w-7">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
<Link to={`/${npub}`} className="text-sm font-bold text-gray-200 hover:text-white truncate">
{name}
</Link>
<span className="ml-auto text-[11px] text-gray-500">
{new Date(event.created_at * 1000).toLocaleDateString()}
</span>
</div>
<div className="whitespace-pre-wrap break-words text-sm text-gray-300">
<NoteContent event={event} className="text-sm" />
</div>
</div>
);
}
export default function CrewDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: crew, isLoading, error } = useCrew(identifier || '', pubkey);
const { user } = useCurrentUser();
const author = useAuthor(crew?.pubkey);
useSeoMeta({
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
description: crew?.bio || 'A skate crew on SkateSpots.',
});
// Feed = founder + listed members
const feedAuthors = crew ? [crew.pubkey, ...crew.members] : [];
const { data: feed, isLoading: feedLoading } = useCrewFeed(feedAuthors);
if (isLoading) {
return (
<Layout>
<div className="container px-4 py-8 max-w-4xl">
<Skeleton className="h-6 w-32 mb-6" />
<div className="grid md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-64 w-full" />
</div>
<Skeleton className="h-64 w-full" />
</div>
</div>
</Layout>
);
}
if (error || !crew) return <NotFound />;
const npub = nip19.npubEncode(crew.pubkey);
const founderName = author.data?.metadata?.name ?? genUserName(crew.pubkey);
const isFounder = user?.pubkey === crew.pubkey;
const hasMembers = crew.members.length > 0;
return (
<Layout>
<div className="container px-4 py-8 max-w-4xl">
<Link to="/crews" className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Crews
</Link>
<div className="grid md:grid-cols-3 gap-6">
{/* Main */}
<div className="md:col-span-2 space-y-6">
{/* Confirm membership prompt (listed pending members only) */}
{hasMembers && (
<ConfirmMembershipCard
kind={SKATE_CREW_KIND}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
collectiveName={crew.name}
claimedMembers={crew.members}
accentBg="bg-teal-500 hover:bg-teal-600 text-black"
/>
)}
{/* Hero */}
<Card className="bg-black/90 border-2 border-teal-500/40 overflow-hidden">
<CardContent className="pt-6">
<div className="flex items-start gap-4 mb-4">
<Avatar className="h-20 w-20 rounded-2xl border-2 border-teal-500/40">
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black text-xl">
{crew.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h1 className="text-3xl font-black uppercase text-teal-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
{crew.name}
</h1>
<Badge variant="outline" className="gap-1 text-teal-400 border-teal-500/40">
<Users className="h-3 w-3" /> Crew
</Badge>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
{crew.location && (
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4 text-teal-400" /> {crew.location}
</span>
)}
{crew.founded && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> Since {crew.founded}
</span>
)}
</div>
</div>
{isFounder && (
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<Edit className="h-3.5 w-3.5" /> Edit
</Button>
</Link>
)}
</div>
{crew.bio && (
<p className="text-gray-300 whitespace-pre-wrap mb-4">{crew.bio}</p>
)}
{crew.vibes.length > 0 && (
<div className="flex flex-wrap gap-2">
{crew.vibes.map((v) => (
<Badge key={v} className="bg-teal-500/15 text-teal-300 border-teal-500/30">
{CREW_VIBE_LABELS[v] || v}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Crew feed */}
<Card className="bg-black/90 border-2 border-white/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<MessageSquare className="h-5 w-5 text-teal-400" /> Crew Feed
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{feedLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border border-white/5 bg-black/40 p-3 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
))
) : feed && feed.length > 0 ? (
feed.map((ev) => <FeedItem key={ev.id} event={ev} />)
) : (
<p className="text-sm text-gray-500 py-4 text-center">
No recent posts from the crew yet.
</p>
)}
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Founder / links / support */}
<Card className="bg-black/90 border-2 border-teal-500/40">
<CardHeader>
<CardTitle className="text-lg text-teal-400 uppercase font-black flex items-center gap-2">
<Users className="h-5 w-5" /> Crew
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Link to={`/${npub}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
<Avatar className="h-6 w-6">
<AvatarImage src={author.data?.metadata?.picture} alt={founderName} />
<AvatarFallback className="text-[10px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="truncate">Started by {founderName}</span>
</Link>
{sanitizeUrl(crew.website) && (
<a href={sanitizeUrl(crew.website)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(crew.website!)}`}>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{urlDomain(crew.website!)}</span>
</a>
)}
{crew.instagram && (
<a href={`https://instagram.com/${crew.instagram.replace('@', '')}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
<Instagram className="h-4 w-4 text-teal-400" />
<span className="truncate">@{crew.instagram.replace('@', '')}</span>
</a>
)}
{sanitizeUrl(crew.social) && (
<a href={sanitizeUrl(crew.social)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(crew.social!)}`}>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{urlDomain(crew.social!)}</span>
</a>
)}
<div className="pt-3 border-t border-gray-700 space-y-2">
<ZapButton target={crew.event} className="w-full justify-center" />
{crew.noffer && (
<ClinkPayButton noffer={crew.noffer} recipientName={crew.name} className="w-full justify-center" />
)}
</div>
</CardContent>
</Card>
{/* Members */}
{hasMembers && (
<CollectiveMembers
kind={SKATE_CREW_KIND}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
claimedMembers={crew.members}
accentText="text-teal-400"
accentBorder="border-teal-500/40"
/>
)}
{/* Home spots */}
{crew.spotRefs.length > 0 && (
<Card className="bg-black/90 border-2 border-white/5">
<CardHeader>
<CardTitle className="text-lg text-white uppercase font-black flex items-center gap-2">
<MapPin className="h-5 w-5 text-teal-400" /> Home Spots
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{crew.spotRefs.map((ref) => {
const [, spotPubkey, spotD] = ref.split(':');
return (
<Link
key={ref}
to={`/spot/${spotPubkey}/${spotD}`}
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors"
>
<span className="text-base">🛹</span>
<span className="truncate">View spot</span>
</Link>
);
})}
<Link to="/" className="block text-center text-xs text-teal-400 hover:text-teal-300 pt-1">
See on the map →
</Link>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</Layout>
);
}

154
src/pages/CrewsPage.tsx Normal file
View file

@ -0,0 +1,154 @@
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 { useCrews } from '@/hooks/useCrews';
import { CREW_VIBE_LABELS, type SkateCrew } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Users, Plus, Search, MapPin, X } from 'lucide-react';
function CrewCard({ crew }: { crew: SkateCrew }) {
return (
<Link
to={`/crew/${crew.pubkey}/${crew.identifier}`}
className="group flex items-center gap-3 rounded-2xl border border-teal-500/20 bg-white/[0.03] p-3 transition-all hover:border-teal-500/50 hover:bg-white/[0.06] active:scale-[0.99]"
>
<Avatar className="h-14 w-14 rounded-2xl border-2 border-teal-500/30">
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black">
{crew.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h3 className="truncate font-black text-white leading-tight flex items-center gap-1.5">
{crew.name}
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold text-teal-400">
<Users className="h-2.5 w-2.5" /> {crew.members.length}
</span>
</h3>
{crew.location && (
<p className="mt-0.5 flex items-center gap-1 text-xs text-gray-400 truncate">
<MapPin className="h-3 w-3 text-teal-400 flex-shrink-0" />
{crew.location}
</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 border-teal-700/50 bg-teal-900/30 px-2 py-0.5 text-[10px] font-bold text-teal-300">
{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 [query, setQuery] = useState('');
const filtered = useMemo(() => {
if (!crews) return [];
if (!query.trim()) return crews;
const q = query.toLowerCase();
return crews.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.location?.toLowerCase().includes(q) ||
c.bio?.toLowerCase().includes(q)
);
}, [crews, query]);
return (
<Layout>
<div className="container px-4 py-6 max-w-3xl">
{/* Header */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-black uppercase text-teal-400 flex items-center gap-2" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
<Users className="h-7 w-7" /> Crews
</h1>
<p className="text-gray-400 mt-1 text-sm">Find your people — local crews, collectives & communities</p>
</div>
<Link to="/create-crew">
<Button className="gap-2 bg-teal-500 hover:bg-teal-600 text-black font-black uppercase text-sm">
<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-5">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search crews by name or city…"
className="w-full h-10 pl-9 pr-9 rounded-xl bg-white/5 border border-white/10 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-teal-500/50 transition-all"
/>
{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>
{/* 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 ? (
<div className="flex flex-col items-center justify-center py-20 px-4 text-center">
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
<Users className="h-8 w-8 text-gray-600" />
</div>
<h3 className="text-lg font-black mb-2 text-white">
{query ? 'No crews match' : 'No crews yet'}
</h3>
<p className="text-gray-500 text-sm max-w-xs mb-4">
{query ? 'Try a different search.' : 'Be the first to plant your crew on the map.'}
</p>
{!query && (
<Link to="/create-crew">
<button className={cn('flex items-center gap-2 px-5 py-2.5 rounded-xl font-black text-sm uppercase tracking-wide bg-teal-500 hover:bg-teal-600 text-black transition-all active:scale-95')}>
<Plus className="h-4 w-4" />
Start a Crew
</button>
</Link>
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{filtered.map((crew) => (
<CrewCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
))}
</div>
)}
</div>
</Layout>
);
}

View file

@ -0,0 +1,85 @@
import { useSeoMeta } from '@unhead/react';
import { Link, useParams } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { LoginArea } from '@/components/auth/LoginArea';
import { CrewForm } from '@/components/CrewForm';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCrew } from '@/hooks/useCrews';
import NotFound from './NotFound';
import { Users, ArrowLeft } from 'lucide-react';
export default function EditCrewPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { user } = useCurrentUser();
const { data: crew, isLoading } = useCrew(identifier || '', pubkey);
useSeoMeta({
title: crew ? `Edit ${crew.name} - SkateSpots` : 'Edit Crew - SkateSpots',
description: 'Edit your skate crew.',
});
if (!user) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center">
<Users className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
if (isLoading) {
return (
<Layout>
<div className="container px-4 py-8 max-w-2xl">
<Skeleton className="h-6 w-32 mb-6" />
<Skeleton className="h-[600px] w-full" />
</div>
</Layout>
);
}
if (!crew) return <NotFound />;
// Only the crew's founder can edit it
if (crew.pubkey !== user.pubkey) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center">
<Users className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Not your crew</h2>
<p className="text-gray-400">Only the crew founder can edit this crew.</p>
</CardContent>
</Card>
</div>
</Layout>
);
}
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<div className="mb-6">
<Link to={`/crew/${crew.pubkey}/${crew.identifier}`} className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Crew
</Link>
<h1 className="text-3xl font-black uppercase text-teal-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
Edit Crew
</h1>
</div>
<CrewForm existing={crew} />
</div>
</Layout>
);
}

View file

@ -26,6 +26,7 @@ import { useShops } from '@/hooks/useShops';
import { useMarketplaceItems } from '@/hooks/useMarketplace';
import { useTours } from '@/hooks/useTours';
import { useUpcomingSkateEvents } from '@/hooks/useSkateEvents';
import { useCrews } from '@/hooks/useCrews';
import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowing } from '@/hooks/useFollowing';
@ -547,6 +548,8 @@ export default function MapPage() {
const { data: tours, isLoading: toursLoading } = useTours(100);
// Upcoming events for the (opt-in) Events map layer
const { data: upcomingEvents } = useUpcomingSkateEvents(100);
// Crews for the (opt-in) Crews map layer
const { data: crews } = useCrews(200);
// Check if coming with ?view=gear to focus on marketplace
const viewParam = searchParams.get('view');
@ -574,6 +577,8 @@ export default function MapPage() {
const [showTours, setShowTours] = useState(!focusGear);
// Events layer — off by default; users opt in via the Layers control
const [showEvents, setShowEvents] = useState(false);
// Crews layer — off by default; users opt in via the Layers control
const [showCrews, setShowCrews] = useState(false);
const [showBtcMap, setShowBtcMap] = useState(false);
// Mobile filters sheet
@ -904,8 +909,8 @@ export default function MapPage() {
// Count active layer filters for mobile badge
const activeLayerCount = [showSpots, showShops, showGear, showTours, showBtcMap].filter(Boolean).length;
// Count of the primary layers active — shown as a badge on the Layers button
const mobileLayerCount = [showSpots, showShops, showGear, showTours, showEvents].filter(Boolean).length;
const totalPrimaryLayers = 5;
const mobileLayerCount = [showSpots, showShops, showGear, showTours, showEvents, showCrews].filter(Boolean).length;
const totalPrimaryLayers = 6;
const activeCategoryCount = selectedCategories.length;
const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0);
@ -1055,6 +1060,17 @@ export default function MapPage() {
>
📅 Events
</Button>
<Button
variant={showCrews ? 'default' : 'outline'}
size="sm"
className={cn(
'justify-center gap-1 h-9 text-xs px-2',
showCrews ? 'bg-teal-500 hover:bg-teal-600' : ''
)}
onClick={() => setShowCrews(!showCrews)}
>
🤘 Crews
</Button>
<Button
variant={showBtcMap ? 'default' : 'outline'}
size="sm"
@ -1209,6 +1225,7 @@ export default function MapPage() {
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
{ key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
{ key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
].map(({ key, label, dot, active, toggle }) => (
<button
key={key}
@ -1435,11 +1452,13 @@ export default function MapPage() {
weatherEnabled={weatherEnabled}
weatherData={weatherData}
events={upcomingEvents || []}
crews={crews || []}
showSpots={showSpots}
showShops={showShops}
showGear={showGear}
showTours={showTours}
showEvents={showEvents}
showCrews={showCrews}
showBtcMap={showBtcMap}
flyTo={flyTo}
/>
@ -1578,6 +1597,7 @@ export default function MapPage() {
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
{ key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
{ key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
].map(({ key, label, dot, active, toggle }) => (
<button
key={key}