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 { useSkaterVerifications } from '@/hooks/useSkaterVerifications'; 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, verified }: { skater: Skater; verified?: boolean }) { 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 npub = nip19.npubEncode(skater.pubkey); const level = skater.level ? SKATER_LEVELS[skater.level] : undefined; return ( {/* accent glow */}
{name.slice(0, 2).toUpperCase()} {level ? ( {level.emoji} {level.label} ) : skater.customLevel ? ( ✏️ {skater.customLevel} ) : null}

{name}

{verified && ( )}
{skater.location && (

{skater.location}

)} {skater.stance && (

{SKATER_STANCES[skater.stance].emoji} {SKATER_STANCES[skater.stance].label} {skater.yearsSkating ? ` · ${skater.yearsSkating}y` : ''}

)}
{/* Styles */} {skater.styles.length > 0 && (
{skater.styles.slice(0, 4).map((s) => ( {SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label} ))} {skater.styles.length > 4 && ( +{skater.styles.length - 4} )}
)} {skater.bio && (

{skater.bio}

)}
); } function SkaterCardSkeleton() { return (
); } // ─── 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('all'); const [verifiedOnly, setVerifiedOnly] = useState(false); // Soft NIP-05 verification for the whole list (never a gate — powers the // "Verified only" toggle and the ✓ badge, so trust is opt-in, not enforced). const skaterPubkeys = useMemo(() => (skaters ?? []).map((s) => s.pubkey), [skaters]); const { data: verifiedSet, isLoading: verifying } = useSkaterVerifications(skaterPubkeys); const verifiedCount = verifiedSet?.size ?? 0; // Which styles actually appear (to avoid empty filter chips). const availableStyles = useMemo(() => { const set = new Set(); 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 (verifiedOnly) list = list.filter((s) => verifiedSet?.has(s.pubkey)); 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, verifiedOnly, verifiedSet]); const total = skaters?.length ?? 0; return (
{/* ── Hero header ── */}
The Community

Skaters

Find your people. Browse the crew of skaters repping SkateSpots — by style, stance, level and city.

{!myProfile ? ( ) : ( )} {total > 0 && ( {total} skater{total !== 1 ? 's' : ''} )}
{/* ── Search + filters ── */}
setQuery(e.target.value)} className="pl-9 h-10 bg-white/5 border-white/10" /> {query && ( )}
{/* Style chips + verified toggle */} {(availableStyles.length > 0 || verifiedCount > 0) && (
{/* Verified only — soft trust filter, not a gate */} {availableStyles.map((s) => ( ))}
)}
{/* ── Grid ── */}
{isLoading ? (
{Array.from({ length: 6 }).map((_, i) => )}
) : (() => { const shown = filtered; return shown.length === 0 ? (
{total === 0 ? ( <>

No skaters yet

Be the first to join the directory and put SkateSpots on the map.

) : ( <>

{verifiedOnly ? 'No verified skaters match' : 'No matches'}

{verifiedOnly ? 'Try turning off the “Verified” filter to see everyone.' : 'Try a different search or clear your filters.'}

)}
) : (
{shown.map((s) => )}
); })()}
); }