Add Skater profiles (kind 35000) + /skaters directory

- New app-native Skater Profile kind 35000 with `skateskater` marker tag,
  the anti-spam boundary that keeps generic kind-0 Nostr profiles out of the
  directory (queried only from the community relay).
- Skater type, stance/style/level constants, and parser in types.ts.
- useSkaters + useUserSkaterProfile hooks.
- /skaters directory page: immersive hero, live count, search, style filter
  chips, ranked skater grid with NIP-05 verification badges and lazy author
  metadata.
- /become-skater opt-in flow (bio, photo, styles, stance, level, years, home
  spot, location w/ geohash, socials).
- Wire "Skaters" into the mobile nav and a cross-link banner in The Lab so
  plain skaters finally have a home.
- Document kind 35000 in NIP.md.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-02 20:58:51 +07:00
parent 9599adaf0d
commit b42c234709
8 changed files with 941 additions and 0 deletions

47
NIP.md
View file

@ -1478,6 +1478,53 @@ The value of a badge is proportional to the reputation of the witness:
}
```
## Skater Profile (kind 35000)
The **Skater Profile** is the app-native identity that makes a Nostr pubkey a
*discoverable SkateSpots skater*. It is an addressable event identified by
`pubkey + kind + d-tag` (the `d` tag is always `skater-profile`, so a user has
exactly one).
**This is the anti-spam boundary for the `/skaters` directory.** A plain kind-0
metadata profile is shared by the entire Nostr network (every social client,
every bot) and is therefore never sufficient to appear in the directory. Only
events carrying kind `35000` **and** the `["t", "skateskater"]` marker tag —
published from within the app, and queried exclusively from the SkateSpots
community relay — are listed. This complements relay-level write restrictions:
the relay keeps junk out at the source, the marker defines who is a skater.
Unlike Lab provider profiles, a Skater Profile advertises no paid service — it
exists purely for community discovery.
```json
{
"kind": 35000,
"content": "<bio / skate story>",
"tags": [
["d", "skater-profile"],
["title", "<display name at publish time>"],
["t", "skateskater"],
["t", "skateboarding"],
["alt", "SkateSpots skater: <display name>"],
["stance", "regular | goofy | switch | mongo"],
["level", "grom | intermediate | advanced | ripper"],
["t", "<style>"],
["years", "<years skating>"],
["home_spot", "<freeform home spot>"],
["location", "<city / area>"],
["g", "<geohash, coarse>"],
["image", "<action shot url>"],
["instagram", "<@handle>"],
["website", "<url>"]
]
}
```
Recognized `style` values (repeated `t` tags): `street`, `park`, `vert`,
`transition`, `bowl`, `cruising`, `downhill`, `freestyle`, `ledges`, `stairs`,
`diy`. A valid profile requires the `d` tag, a `title`, and the `skateskater`
marker tag.
## The Lab — Service Profiles
The Lab connects skaters with service providers. Each provider type is an

View file

@ -37,6 +37,8 @@ const BecomeTattooPage = lazy(() => import("./pages/BecomeTattooPage"));
const BecomeTrainerPage = lazy(() => import("./pages/BecomeTrainerPage"));
const BecomeMusicianPage = lazy(() => import("./pages/BecomeMusicianPage"));
const BecomeJudgePage = lazy(() => import("./pages/BecomeJudgePage"));
const BecomeSkaterPage = lazy(() => import("./pages/BecomeSkaterPage"));
const SkatersPage = lazy(() => import("./pages/SkatersPage"));
const CrewsPage = lazy(() => import("./pages/CrewsPage"));
const CreateCrewPage = lazy(() => import("./pages/CreateCrewPage"));
const EditCrewPage = lazy(() => import("./pages/EditCrewPage"));
@ -103,6 +105,8 @@ export function AppRouter() {
<Route path="/become-trainer" element={<BecomeTrainerPage />} />
<Route path="/become-musician" element={<BecomeMusicianPage />} />
<Route path="/become-judge" element={<BecomeJudgePage />} />
<Route path="/skaters" element={<SkatersPage />} />
<Route path="/become-skater" element={<BecomeSkaterPage />} />
<Route path="/crews" element={<CrewsPage />} />
<Route path="/create-crew" element={<CreateCrewPage />} />
<Route path="/edit-crew/:pubkey/:identifier" element={<EditCrewPage />} />

View file

@ -51,6 +51,7 @@ import {
Users,
SlidersHorizontal,
BookOpen,
PersonStanding,
} from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -690,6 +691,13 @@ export function Layout({ children, noPadding }: LayoutProps) {
onClick={() => setMobileMenuOpen(false)}
/>
))}
<NavLink
href="/skaters"
label="Skaters"
icon={PersonStanding}
isActive={isActive('/skaters')}
onClick={() => setMobileMenuOpen(false)}
/>
{user && (
<NavLink
href={`/${nip19.npubEncode(user.pubkey)}`}

67
src/hooks/useSkaters.ts Normal file
View file

@ -0,0 +1,67 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_SKATER_KIND, SKATER_TAG, parseSkater, type Skater } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
const SKATER_D = 'skater-profile';
/**
* List all SkateSpots skater profiles (kind 35000, tagged skateskater).
* Queried from the skate relays only — this is the anti-spam boundary:
* plain kind-0 Nostr profiles never appear here, only opted-in app skaters.
* Deduplicated per author (newest wins), newest-first.
*/
export function useSkaters(limit = 200) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skaters', limit, getQueryKeySuffix()],
select: (skaters: Skater[]) => filterAdminHidden(skaters, SKATE_SKATER_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [SKATE_SKATER_KIND], '#t': [SKATER_TAG], limit }],
{ signal },
);
// Deduplicate: keep the newest profile per author.
const byAuthor = new Map<string, Skater>();
for (const event of events) {
const s = parseSkater(event);
if (!s) continue;
const existing = byAuthor.get(s.pubkey);
if (!existing || s.createdAt > existing.createdAt) byAuthor.set(s.pubkey, s);
}
return [...byAuthor.values()].sort((a, b) => b.createdAt - a.createdAt);
},
staleTime: 30000,
refetchOnMount: 'always',
});
}
/** Fetch a single user's skater profile (or null). */
export function useUserSkaterProfile(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['skater-profile', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
queryFn: async () => {
if (!pubkey) return null;
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [SKATE_SKATER_KIND], authors: [pubkey], '#d': [SKATER_D], limit: 1 }],
{ signal },
);
if (events.length === 0) return null;
return parseSkater(events[0]);
},
staleTime: 30000,
refetchOnMount: 'always',
});
}
export { SKATER_D };

View file

@ -45,6 +45,7 @@ function clampStr(s: string | undefined, max: number): string | undefined {
export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey+kind+d-tag
export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
export const CALENDAR_EVENT_KIND = 31923;
export const SKATE_SKATER_KIND = 35000; // Skater profile — the app-native "I skate here" identity
export const SKATE_COACH_KIND = 35001;
export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles
@ -288,6 +289,105 @@ export function isValidLightningAddress(value: string | undefined | null): value
return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value.trim());
}
// ─── Skater profile (kind 35000) ───────────────────────────────────────────
// The app-native identity that makes a Nostr pubkey a *discoverable SkateSpots
// skater*. Publishing this (opt-in) is what lets someone appear in the /skaters
// directory — a plain kind-0 profile is NOT enough (that's the anti-spam wall).
export type SkaterStance = 'regular' | 'goofy' | 'switch' | 'mongo';
export type SkaterStyle = 'street' | 'park' | 'vert' | 'transition' | 'bowl' | 'cruising' | 'downhill' | 'freestyle' | 'ledges' | 'stairs' | 'diy';
export type SkaterLevel = 'grom' | 'intermediate' | 'advanced' | 'ripper';
export const SKATER_STANCES: Record<SkaterStance, { label: string; emoji: string }> = {
regular: { label: 'Regular', emoji: '🦶' },
goofy: { label: 'Goofy', emoji: '🦶' },
switch: { label: 'Switch', emoji: '🔄' },
mongo: { label: 'Mongo', emoji: '😅' },
};
export const SKATER_STYLES: Record<SkaterStyle, { label: string; emoji: string }> = {
street: { label: 'Street', emoji: '🛹' },
park: { label: 'Park', emoji: '🏞️' },
vert: { label: 'Vert', emoji: '📐' },
transition: { label: 'Transition', emoji: '🌊' },
bowl: { label: 'Bowl', emoji: '🥣' },
cruising: { label: 'Cruising', emoji: '🛴' },
downhill: { label: 'Downhill', emoji: '⛰️' },
freestyle: { label: 'Freestyle', emoji: '🕺' },
ledges: { label: 'Ledges', emoji: '🧱' },
stairs: { label: 'Stairs', emoji: '🪜' },
diy: { label: 'DIY', emoji: '🔨' },
};
export const SKATER_LEVELS: Record<SkaterLevel, { label: string; emoji: string; color: string }> = {
grom: { label: 'Grom', emoji: '🌱', color: '#22c55e' },
intermediate: { label: 'Intermediate', emoji: '⚡', color: '#3b82f6' },
advanced: { label: 'Advanced', emoji: '🔥', color: '#f59e0b' },
ripper: { label: 'Ripper', emoji: '👑', color: '#ff0040' },
};
/** The `t` marker tag that identifies a SkateSpots skater profile. */
export const SKATER_TAG = 'skateskater';
export interface Skater {
id: string;
pubkey: string;
identifier: string;
title: string; // display name at publish time
bio: string;
location?: string;
geohash?: string;
image?: string;
stance?: SkaterStance;
styles: SkaterStyle[]; // preferred terrain/disciplines
level?: SkaterLevel;
yearsSkating?: string;
homeSpot?: string; // freeform, or a spot ref
instagram?: string;
website?: string;
createdAt: number;
event: NostrEvent;
}
export function parseSkater(event: NostrEvent): Skater | null {
if (event.kind !== SKATE_SKATER_KIND) return null;
// Must carry the skater marker tag — this is the discovery gate.
if (!event.tags.some(([n, v]) => n === 't' && v === SKATER_TAG)) return null;
const d = event.tags.find(([name]) => name === 'd')?.[1];
const title = event.tags.find(([name]) => name === 'title')?.[1];
if (!d || !title) return null;
const tag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
const knownStyles = Object.keys(SKATER_STYLES) as SkaterStyle[];
const styles = event.tags
.filter(([n, v]) => n === 't' && (knownStyles as string[]).includes(v))
.map(([, v]) => v as SkaterStyle);
const stance = tag('stance') as SkaterStance | undefined;
const level = tag('level') as SkaterLevel | undefined;
return {
id: event.id,
pubkey: event.pubkey,
identifier: d,
title: title.slice(0, 120),
bio: event.content.slice(0, 2000),
location: tag('location'),
geohash: tag('g'),
image: tag('image'),
stance: stance && SKATER_STANCES[stance] ? stance : undefined,
styles,
level: level && SKATER_LEVELS[level] ? level : undefined,
yearsSkating: tag('years'),
homeSpot: tag('home_spot'),
instagram: tag('instagram'),
website: tag('website'),
createdAt: event.created_at,
event,
};
}
export interface SkateSpot {
id: string;
pubkey: string;

View file

@ -0,0 +1,381 @@
import { useEffect, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { Layout } from '@/components/Layout';
import { Card, CardContent } 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, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { LoginArea } from '@/components/auth/LoginArea';
import { useUserSkaterProfile, SKATER_D } from '@/hooks/useSkaters';
import {
SKATE_SKATER_KIND,
SKATER_TAG,
SKATER_STANCES,
SKATER_STYLES,
SKATER_LEVELS,
type SkaterStance,
type SkaterStyle,
type SkaterLevel,
} from '@/lib/types';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { Upload, X, Loader2, ArrowLeft, MapPin, PersonStanding, Sparkles } from 'lucide-react';
export default function BecomeSkaterPage() {
useSeoMeta({
title: 'Join the Skater Directory - SkateSpots',
description: 'Create your skater profile so the community can find you.',
});
const navigate = useNavigate();
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const { data: existing, isLoading: loadingExisting } = useUserSkaterProfile(user?.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? (user ? genUserName(user.pubkey) : 'Skater');
const avatar = metadata?.picture;
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [stance, setStance] = useState<SkaterStance | undefined>();
const [styles, setStyles] = useState<SkaterStyle[]>([]);
const [level, setLevel] = useState<SkaterLevel | undefined>();
const [years, setYears] = useState('');
const [homeSpot, setHomeSpot] = useState('');
const [instagram, setInstagram] = useState('');
const [website, setWebsite] = useState('');
const [image, setImage] = useState<string | null>(null);
const [isGettingLocation, setIsGettingLocation] = useState(false);
// Prefill from an existing profile when editing.
useEffect(() => {
if (existing) {
setBio(existing.bio || '');
setLocation(existing.location || '');
setStance(existing.stance);
setStyles(existing.styles || []);
setLevel(existing.level);
setYears(existing.yearsSkating || '');
setHomeSpot(existing.homeSpot || '');
setInstagram(existing.instagram || '');
setWebsite(existing.website || '');
if (existing.image) setImage(existing.image);
}
}, [existing]);
const toggleStyle = (s: SkaterStyle) =>
setStyles((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' });
return;
}
setIsGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatitude(pos.coords.latitude.toFixed(6));
setLongitude(pos.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({ title: 'Location found' });
},
(err) => {
setIsGettingLocation(false);
toast({ title: 'Location error', description: err.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: 'Photo 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 (!user) return;
if (styles.length === 0) {
toast({ title: 'Pick your styles', description: 'Choose at least one thing you skate.', variant: 'destructive' });
return;
}
try {
const tags: string[][] = [
['d', SKATER_D],
['title', displayName],
['t', SKATER_TAG],
['t', 'skateboarding'],
['alt', `SkateSpots skater: ${displayName}`],
];
if (location.trim()) tags.push(['location', location.trim()]);
if (latitude && longitude) {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
}
if (stance) tags.push(['stance', stance]);
if (level) tags.push(['level', level]);
for (const s of styles) tags.push(['t', s]);
if (years.trim()) tags.push(['years', years.trim()]);
if (homeSpot.trim()) tags.push(['home_spot', homeSpot.trim()]);
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
if (website.trim()) tags.push(['website', website.trim()]);
if (image) tags.push(['image', image]);
await createEvent({ kind: SKATE_SKATER_KIND, content: bio.trim(), tags });
toast({
title: existing ? 'Profile updated!' : "You're on the map! 🛹",
description: 'Other skaters can now discover you.',
});
navigate('/skaters');
} catch (error) {
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
}
};
if (!user) {
return (
<Layout>
<div className="container px-4 py-12 max-w-xl">
<Card className="bg-black/80 border-2 border-[#ff0040]/40">
<CardContent className="py-14 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#ff0040] to-[#ff6600] mb-5">
<PersonStanding className="h-8 w-8 text-white" />
</div>
<h2 className="text-2xl font-black uppercase mb-2">Log in to join</h2>
<p className="text-gray-400 mb-6">Create your skater profile so the community can find you.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
const styleEntries = Object.entries(SKATER_STYLES) as [SkaterStyle, { label: string; emoji: string }][];
const stanceEntries = Object.entries(SKATER_STANCES) as [SkaterStance, { label: string; emoji: string }][];
const levelEntries = Object.entries(SKATER_LEVELS) as [SkaterLevel, { label: string; emoji: string; color: string }][];
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<Link to="/skaters" 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 Skaters
</Link>
{/* Hero */}
<div className="relative overflow-hidden rounded-2xl border-2 border-[#ff0040]/30 bg-gradient-to-br from-[#ff0040]/10 via-black to-[#ccff00]/5 p-6 mb-6">
<div className="absolute -top-10 -right-10 w-48 h-48 bg-[#ff0040] opacity-10 rounded-full blur-3xl" />
<div className="relative flex items-center gap-4">
<Avatar className="h-16 w-16 border-2 border-[#ff0040]/50">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] font-black">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<h1 className="text-2xl font-black uppercase leading-none">
{existing ? 'Edit your' : 'Join the'} <span className="text-[#ff0040]">Skater</span> Directory
</h1>
<p className="text-gray-400 text-sm mt-1.5 truncate">
Publishing as <span className="text-white font-bold">{displayName}</span>
</p>
</div>
</div>
</div>
{loadingExisting ? (
<div className="flex items-center justify-center py-16 text-gray-500">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading your profile…
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Bio */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-5">
<div className="space-y-2">
<Label htmlFor="bio" className="text-sm font-bold uppercase tracking-wide text-gray-400">Your skate story</Label>
<Textarea
id="bio"
placeholder="How long you've been skating, what you're into, what you're working on…"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={4}
className="resize-none"
/>
</div>
{/* Photo */}
<div className="space-y-2">
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Action shot (optional)</Label>
{image ? (
<div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800">
<img src={image} alt="Profile" className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/80 hover:bg-red-500 transition-colors">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<label className="w-28 h-28 rounded-2xl border-2 border-dashed border-white/15 flex flex-col items-center justify-center cursor-pointer hover:border-[#ff0040]/50 hover:bg-white/5 transition-colors">
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
{isUploading ? <Loader2 className="h-6 w-6 animate-spin text-gray-500" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-[10px] text-gray-500 uppercase font-bold">Upload</span></>}
</label>
)}
</div>
</CardContent>
</Card>
{/* Styles */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-3">
<div>
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">What you skate *</Label>
<p className="text-xs text-muted-foreground mt-0.5">Pick everything that fits your style.</p>
</div>
<div className="flex flex-wrap gap-2">
{styleEntries.map(([key, { label, emoji }]) => {
const active = styles.includes(key);
return (
<button
key={key}
type="button"
onClick={() => toggleStyle(key)}
className={cn(
'px-3 py-2 rounded-xl text-sm font-bold border transition-all flex items-center gap-1.5',
active
? 'bg-[#ff0040] border-[#ff0040] text-white shadow-[0_0_16px_rgba(255,0,64,0.35)] scale-105'
: 'bg-white/5 border-white/10 text-gray-300 hover:border-[#ff0040]/40 hover:text-white',
)}
>
<span>{emoji}</span>{label}
</button>
);
})}
</div>
</CardContent>
</Card>
{/* Stance + Level */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-5">
<div className="space-y-3">
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Stance</Label>
<div className="flex flex-wrap gap-2">
{stanceEntries.map(([key, { label, emoji }]) => (
<button
key={key}
type="button"
onClick={() => setStance(stance === key ? undefined : key)}
className={cn(
'px-4 py-2 rounded-xl text-sm font-bold border transition-all flex items-center gap-1.5',
stance === key ? 'bg-[#ccff00] border-[#ccff00] text-black' : 'bg-white/5 border-white/10 text-gray-300 hover:border-[#ccff00]/40',
)}
>
<span>{emoji}</span>{label}
</button>
))}
</div>
</div>
<div className="space-y-3">
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Level</Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{levelEntries.map(([key, { label, emoji, color }]) => (
<button
key={key}
type="button"
onClick={() => setLevel(level === key ? undefined : key)}
className={cn(
'px-3 py-2.5 rounded-xl text-sm font-black border transition-all text-center',
level === key ? 'text-black' : 'bg-white/5 border-white/10 text-gray-300 hover:border-white/30',
)}
style={level === key ? { backgroundColor: color, borderColor: color } : undefined}
>
<div className="text-lg leading-none mb-0.5">{emoji}</div>
{label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="years" className="text-sm font-bold uppercase tracking-wide text-gray-400">Years skating</Label>
<Input id="years" placeholder="e.g. 8" value={years} onChange={(e) => setYears(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="home" className="text-sm font-bold uppercase tracking-wide text-gray-400">Home spot</Label>
<Input id="home" placeholder="Local park / plaza" value={homeSpot} onChange={(e) => setHomeSpot(e.target.value)} />
</div>
</div>
</CardContent>
</Card>
{/* Location + socials */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-5">
<div className="space-y-2">
<Label htmlFor="location" className="text-sm font-bold uppercase tracking-wide text-gray-400">City / area</Label>
<Input id="location" placeholder="Barcelona, ES" value={location} onChange={(e) => setLocation(e.target.value)} />
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-1 border-white/15">
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting…</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
</Button>
<p className="text-xs text-muted-foreground">Helps others find skaters near them. Only a coarse area is shared.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ig" className="text-sm font-bold uppercase tracking-wide text-gray-400">Instagram</Label>
<Input id="ig" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="web" className="text-sm font-bold uppercase tracking-wide text-gray-400">Website</Label>
<Input id="web" placeholder="https://…" value={website} onChange={(e) => setWebsite(e.target.value)} />
</div>
</div>
</CardContent>
</Card>
<Button type="submit" className="w-full h-12 bg-[#ff0040] hover:bg-[#ff0040]/90 uppercase font-black text-base" disabled={isPublishing || isUploading}>
{isPublishing ? (
<><Loader2 className="h-5 w-5 mr-2 animate-spin" />Publishing…</>
) : (
<><Sparkles className="h-5 w-5 mr-2" />{existing ? 'Update my profile' : 'Put me on the map'}</>
)}
</Button>
</form>
)}
</div>
</Layout>
);
}

View file

@ -68,6 +68,8 @@ import {
Users,
CheckCircle2,
Shield,
PersonStanding,
ArrowRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@ -1293,6 +1295,25 @@ export default function LabPage() {
)}
</div>
{/* ── Skater directory cross-link ── */}
<Link
to="/skaters"
className="group flex items-center gap-3 rounded-2xl border border-white/10 bg-gradient-to-r from-[#ff0040]/10 via-black to-[#ccff00]/5 p-4 mb-5 hover:border-[#ff0040]/40 transition-all"
>
<div className="flex-shrink-0 w-11 h-11 rounded-xl bg-[#ff0040]/15 flex items-center justify-center group-hover:bg-[#ff0040]/25 transition-colors">
<PersonStanding className="h-6 w-6 text-[#ff0040]" />
</div>
<div className="min-w-0 flex-1">
<p className="font-black uppercase text-sm leading-tight">
Just a <span className="text-[#ff0040]">skater</span>? Not a pro?
</p>
<p className="text-xs text-gray-400 mt-0.5">
The Lab is for services. Browse & join the whole skater community →
</p>
</div>
<ArrowRight className="h-5 w-5 text-gray-500 group-hover:text-[#ff0040] group-hover:translate-x-0.5 transition-all flex-shrink-0" />
</Link>
{/* ── Sticky toolbar (tabs + search + location + filter button + active chips) ── */}
<LabToolbar
serviceType={serviceType}

313
src/pages/SkatersPage.tsx Normal file
View file

@ -0,0 +1,313 @@
import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useSkaters, useUserSkaterProfile } from '@/hooks/useSkaters';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import {
SKATER_STYLES,
SKATER_STANCES,
SKATER_LEVELS,
type Skater,
type SkaterStyle,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { PersonStanding, Search, MapPin, Check, Sparkles, Users, ShieldCheck, X } from 'lucide-react';
// ─── Skater card ────────────────────────────────────────────────────────────
function SkaterCard({ skater }: { skater: Skater }) {
const author = useAuthor(skater.pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? skater.title ?? genUserName(skater.pubkey);
const avatar = metadata?.picture ?? skater.image;
const nip05 = metadata?.nip05;
const npub = nip19.npubEncode(skater.pubkey);
const level = skater.level ? SKATER_LEVELS[skater.level] : undefined;
return (
<Link
to={`/${npub}`}
className="group relative rounded-2xl border-2 border-white/10 bg-black/60 overflow-hidden hover:border-[#ff0040]/50 transition-all hover:-translate-y-0.5"
>
{/* accent glow */}
<div className="absolute -top-16 -right-16 w-40 h-40 bg-[#ff0040] opacity-0 group-hover:opacity-10 blur-3xl transition-opacity" />
<div className="relative p-4">
<div className="flex items-start gap-3">
<div className="relative flex-shrink-0">
<Avatar className="h-14 w-14 border-2 border-[#ff0040]/30 group-hover:border-[#ff0040]/60 transition-colors">
<AvatarImage src={avatar} alt={name} className="object-cover" />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] font-black">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{level && (
<span
className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full text-black whitespace-nowrap"
style={{ backgroundColor: level.color }}
>
{level.emoji} {level.label}
</span>
)}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex items-center gap-1.5">
<h3 className="font-black text-white truncate group-hover:text-[#ff0040] transition-colors">{name}</h3>
{nip05 && <ShieldCheck className="h-3.5 w-3.5 text-[#ccff00] flex-shrink-0" />}
</div>
{skater.location && (
<p className="text-xs text-gray-400 flex items-center gap-1 mt-0.5 truncate">
<MapPin className="h-3 w-3 flex-shrink-0" />
{skater.location}
</p>
)}
{skater.stance && (
<p className="text-[11px] text-gray-500 mt-0.5">
{SKATER_STANCES[skater.stance].emoji} {SKATER_STANCES[skater.stance].label}
{skater.yearsSkating ? ` · ${skater.yearsSkating}y` : ''}
</p>
)}
</div>
</div>
{/* Styles */}
{skater.styles.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{skater.styles.slice(0, 4).map((s) => (
<span key={s} className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/8 text-gray-300 border border-white/10">
{SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label}
</span>
))}
{skater.styles.length > 4 && (
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/5 text-gray-500">
+{skater.styles.length - 4}
</span>
)}
</div>
)}
{skater.bio && (
<p className="text-xs text-gray-400 mt-3 line-clamp-2 leading-relaxed">{skater.bio}</p>
)}
</div>
</Link>
);
}
function SkaterCardSkeleton() {
return (
<div className="rounded-2xl border-2 border-white/10 bg-black/60 p-4">
<div className="flex items-start gap-3">
<Skeleton className="h-14 w-14 rounded-full" />
<div className="flex-1 space-y-2 pt-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
<div className="flex gap-1 mt-3">
<Skeleton className="h-4 w-14 rounded-full" />
<Skeleton className="h-4 w-14 rounded-full" />
</div>
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────
export default function SkatersPage() {
useSeoMeta({
title: 'Skaters - SkateSpots',
description: 'Discover skaters in the SkateSpots community. Browse by style, stance, level and location.',
});
const { data: skaters, isLoading } = useSkaters();
const { user } = useCurrentUser();
const { data: myProfile } = useUserSkaterProfile(user?.pubkey);
const [query, setQuery] = useState('');
const [styleFilter, setStyleFilter] = useState<SkaterStyle | 'all'>('all');
// Which styles actually appear (to avoid empty filter chips).
const availableStyles = useMemo(() => {
const set = new Set<SkaterStyle>();
for (const s of skaters ?? []) for (const st of s.styles) set.add(st);
return (Object.keys(SKATER_STYLES) as SkaterStyle[]).filter((s) => set.has(s));
}, [skaters]);
const filtered = useMemo(() => {
let list = skaters ?? [];
if (styleFilter !== 'all') list = list.filter((s) => s.styles.includes(styleFilter));
if (query.trim()) {
const q = query.trim().toLowerCase();
list = list.filter(
(s) =>
s.title.toLowerCase().includes(q) ||
s.location?.toLowerCase().includes(q) ||
s.bio.toLowerCase().includes(q) ||
nip19.npubEncode(s.pubkey).toLowerCase().includes(q),
);
}
return list;
}, [skaters, styleFilter, query]);
const total = skaters?.length ?? 0;
return (
<Layout>
<div className="min-h-screen">
{/* ── Hero header ── */}
<div className="relative overflow-hidden border-b border-white/10 bg-gradient-to-br from-[#ff0040]/15 via-black to-[#ccff00]/5">
<div className="absolute -top-24 -left-16 w-72 h-72 bg-[#ff0040] opacity-15 rounded-full blur-[100px]" />
<div className="absolute -bottom-24 -right-16 w-72 h-72 bg-[#ccff00] opacity-10 rounded-full blur-[100px]" />
<div className="container relative px-4 py-10 sm:py-14 max-w-6xl">
<div className="flex items-center gap-2 text-[#ff0040] mb-3">
<PersonStanding className="h-5 w-5" />
<span className="text-xs font-black uppercase tracking-[0.2em]">The Community</span>
</div>
<h1
className="text-4xl sm:text-6xl font-black uppercase tracking-tight leading-none"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
>
Skaters
</h1>
<p className="text-gray-300 mt-3 max-w-xl text-base sm:text-lg">
Find your people. Browse the crew of skaters repping SkateSpots — by style, stance, level and city.
</p>
<div className="flex flex-wrap items-center gap-3 mt-6">
{!myProfile ? (
<Link to="/become-skater">
<Button className="h-11 bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase gap-2">
<Sparkles className="h-4 w-4" />
Put me on the map
</Button>
</Link>
) : (
<Link to="/become-skater">
<Button variant="outline" className="h-11 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10 font-bold gap-2">
<Check className="h-4 w-4" />
Edit my skater profile
</Button>
</Link>
)}
{total > 0 && (
<span className="flex items-center gap-1.5 text-sm text-gray-400">
<Users className="h-4 w-4" />
<span className="font-black text-white">{total}</span> skater{total !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
</div>
{/* ── Search + filters ── */}
<div className="sticky top-0 z-20 bg-black/95 backdrop-blur-md border-b border-white/10">
<div className="container px-4 py-3 max-w-6xl space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<Input
placeholder="Search by name, city or npub…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 h-10 bg-white/5 border-white/10"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Style chips */}
{availableStyles.length > 0 && (
<div className="flex items-center gap-1.5 overflow-x-auto scrollbar-none pb-0.5">
<button
onClick={() => setStyleFilter('all')}
className={cn(
'flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-bold border transition-all whitespace-nowrap',
styleFilter === 'all' ? 'bg-white text-black border-white' : 'bg-white/5 border-white/10 text-gray-400 hover:text-white hover:border-white/25',
)}
>
All
</button>
{availableStyles.map((s) => (
<button
key={s}
onClick={() => setStyleFilter(styleFilter === s ? 'all' : s)}
className={cn(
'flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-bold border transition-all whitespace-nowrap flex items-center gap-1',
styleFilter === s ? 'bg-[#ff0040] border-[#ff0040] text-white' : 'bg-white/5 border-white/10 text-gray-400 hover:text-white hover:border-[#ff0040]/40',
)}
>
{SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label}
</button>
))}
</div>
)}
</div>
</div>
{/* ── Grid ── */}
<div className="container px-4 py-6 max-w-6xl">
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => <SkaterCardSkeleton key={i} />)}
</div>
) : (() => {
const shown = filtered;
return shown.length === 0 ? (
<Card className="border-dashed border-2 border-white/10 bg-black/40">
<CardContent className="py-16 px-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-white/5 mb-4">
<PersonStanding className="h-8 w-8 text-gray-600" />
</div>
{total === 0 ? (
<>
<p className="text-xl font-black uppercase text-white mb-2">No skaters yet</p>
<p className="text-gray-400 max-w-sm mx-auto mb-6">
Be the first to join the directory and put SkateSpots on the map.
</p>
<Link to="/become-skater">
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase gap-2">
<Sparkles className="h-4 w-4" />
Put me on the map
</Button>
</Link>
</>
) : (
<>
<p className="text-lg font-bold text-white mb-1">No matches</p>
<p className="text-gray-400 text-sm mb-4">Try a different search or clear your filters.</p>
<button
onClick={() => { setQuery(''); setStyleFilter('all'); }}
className="text-xs font-black uppercase tracking-wider text-[#ff0040] hover:underline"
>
Clear filters
</button>
</>
)}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{shown.map((s) => <SkaterCard key={s.pubkey} skater={s} />)}
</div>
);
})()}
</div>
</div>
</Layout>
);
}