SkateSpots/src/pages/SkatersPage.tsx
SkateSpots 09735375c2 feat(i18n): Spanish (Castellano) — infra + Map/Spots, Marketplace, Lab (batch 1)
- 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.
2026-10-01 23:57:20 +07:00

364 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 { 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>
</>
);
}