SkateSpots/src/pages/SkatersPage.tsx
shakespeare.diy 16e943d4ef refactor: consolidate become-* routes + migrate empty states to shared component
Empty states (P4 follow-up):
- CrewsPage and EventsPage now render the shared <EmptyState> instead of
  their own local copies, keeping spacing/actions/footer consistent.

Become-role routes (P1):
- Add src/lib/becomeRoles.tsx: single registry mapping each role slug to
  either a CraftProfileForm config (artist/graffiti/musician/judge/tattoo/
  trainer) or a lazy bespoke page (coach/filmer/editor/photographer/
  builder/skater). The 6 craft roles were already thin config wrappers, so
  their configs move into the registry and the wrapper files are deleted.
- Replace 12 hardcoded /become-* routes + 12 imports with one
  /become/:role route (BecomeRolePage) that renders from the registry.
- Legacy /become-<slug> URLs now redirect to /become/<slug> so shared/
  bookmarked links keep working.
- Update all internal links (LabPage, Index, SkatersPage, SpotDetailPage,
  NIP19Page, SkaterDirectoryCard) to the canonical /become/<slug> paths.

Note: the 5 large bespoke forms were intentionally NOT merged into one
config — they carry distinct machinery (SpotPicker, wallet/Pro, geohashing)
and forcing them together would increase complexity, not reduce it.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 18:44:53 +07:00

357 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<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>
) : skater.customLevel ? (
<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 bg-[#ccff00] max-w-[90px] truncate">
✏️ {skater.customLevel}
</span>
) : null}
</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>
{verified && (
<span title="NIP-05 verified" className="flex-shrink-0">
<ShieldCheck className="h-3.5 w-3.5 text-[#ccff00]" />
</span>
)}
</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');
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<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 (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 (
<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 + verified toggle */}
{(availableStyles.length > 0 || verifiedCount > 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>
{/* Verified only — soft trust filter, not a gate */}
<button
onClick={() => setVerifiedOnly((v) => !v)}
disabled={verifiedCount === 0 && !verifiedOnly}
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 disabled:opacity-40 disabled:cursor-not-allowed',
verifiedOnly
? 'bg-[#ccff00] border-[#ccff00] text-black'
: 'bg-white/5 border-white/10 text-gray-400 hover:text-white hover:border-[#ccff00]/40',
)}
title="Show only skaters with a verified NIP-05 identity"
>
<ShieldCheck className="h-3.5 w-3.5" />
Verified
{verifying ? (
<span className="opacity-60">…</span>
) : (
verifiedCount > 0 && <span className="opacity-70">({verifiedCount})</span>
)}
</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">
{verifiedOnly ? 'No verified skaters match' : 'No matches'}
</p>
<p className="text-gray-400 text-sm mb-4">
{verifiedOnly
? 'Try turning off the “Verified” filter to see everyone.'
: 'Try a different search or clear your filters.'}
</p>
<button
onClick={() => { setQuery(''); setStyleFilter('all'); setVerifiedOnly(false); }}
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} verified={verifiedSet?.has(s.pubkey)} />)}
</div>
);
})()}
</div>
</div>
</Layout>
);
}