- First-visit language auto-detect (Spanish devices land in es; stored choice wins). - Sasquatch AI replies in the app language (skate trick names kept English). - i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t() key missing from en/es or any en/es key drift. - ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace, trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters. - Policy: only skate TRICK names stay English; user-generated content untouched.
364 lines
16 KiB
TypeScript
364 lines
16 KiB
TypeScript
import { useMemo, useState } from 'react';
|
||
import { useSeoMeta } from '@unhead/react';
|
||
import { Link } from 'react-router-dom';
|
||
import { nip19 } from 'nostr-tools';
|
||
import { LazyMount } from '@/components/LazyMount';
|
||
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 { useTranslation } from '@/contexts/language';
|
||
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 { t } = useTranslation();
|
||
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={t('labUi.skaters.nip05Verified')} 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() {
|
||
const { t } = useTranslation();
|
||
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 (
|
||
<>
|
||
<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]">{t('labUi.skaters.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">
|
||
{t('labUi.skaters.heroDesc')}
|
||
</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" />
|
||
{t('labUi.skaterForm.putOnMap')}
|
||
</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" />
|
||
{t('labUi.skaters.editProfile')}
|
||
</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> {t(total === 1 ? 'labUi.skaters.countOne' : 'labUi.skaters.countMany', { count: total })}
|
||
</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={t('labUi.skaters.searchPlaceholder')}
|
||
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={t('labUi.skaters.clearSearch')}
|
||
>
|
||
<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',
|
||
)}
|
||
>
|
||
{t('labUi.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={t('labUi.skaters.verifiedFilterTitle')}
|
||
>
|
||
<ShieldCheck className="h-3.5 w-3.5" />
|
||
{t('common.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">{t('labUi.skaters.noneTitle')}</p>
|
||
<p className="text-gray-400 max-w-sm mx-auto mb-6">
|
||
{t('labUi.skaters.noneDesc')}
|
||
</p>
|
||
<Link to="/become/skater">
|
||
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase gap-2">
|
||
<Sparkles className="h-4 w-4" />
|
||
{t('labUi.skaterForm.putOnMap')}
|
||
</Button>
|
||
</Link>
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="text-lg font-bold text-white mb-1">
|
||
{verifiedOnly ? t('labUi.skaters.noVerifiedMatch') : t('labUi.skaters.noMatches')}
|
||
</p>
|
||
<p className="text-gray-400 text-sm mb-4">
|
||
{verifiedOnly
|
||
? t('labUi.skaters.tryTurnOffVerified')
|
||
: t('labUi.skaters.tryDifferentSearch')}
|
||
</p>
|
||
<button
|
||
onClick={() => { setQuery(''); setStyleFilter('all'); setVerifiedOnly(false); }}
|
||
className="text-xs font-black uppercase tracking-wider text-[#ff0040] hover:underline"
|
||
>
|
||
{t('common.clearFilters')}
|
||
</button>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{shown.map((s) => (
|
||
<LazyMount key={s.pubkey} className="h-full">
|
||
<SkaterCard skater={s} verified={verifiedSet?.has(s.pubkey)} />
|
||
</LazyMount>
|
||
))}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|