SkateSpots/src/pages/SkatersPage.tsx
shakespeare.diy dbae3a1501 Add soft NIP-05 verification for skaters (open directory + "Verified" filter)
Keeps the skater directory fully open — NIP-05 is a trust signal, not a gate:
- Extract reusable NIP-05 verification into lib/nip05.ts (parse, fetch well-known, verify against expected pubkey); refactor useCrewNip05 to use it
- New useSkaterVerifications hook: batch-fetches kind-0 metadata for all skaters and verifies each nip05 resolves to its own pubkey, returning a Set of verified pubkeys
- SkatersPage: add a "Verified (N)" toggle (disabled when none verified) that filters to genuinely verified skaters; show ✓ badge only for truly-verified skaters; verified-aware empty state
- NIP19Page profile: nip05 line now reflects real verification (lime ShieldCheck + "Verified" pill when the handle resolves to the key, muted grey otherwise)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-02 21:51:11 +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>
);
}