SkateSpots/src/pages/PyramidPage.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

206 lines
8.8 KiB
TypeScript

import { useSeoMeta } from '@unhead/react';
import { useMemo } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useSkateEvents } from '@/hooks/useSkateEvents';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTrustScore } from '@/hooks/useTrustScore';
import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
import { Trophy, Medal } from 'lucide-react';
import { cn } from '@/lib/utils';
function ContributorRow({ pubkey, spots, events, rank }: { pubkey: string; spots: number; events: number; rank: number }) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const { data: trustScore } = useTrustScore(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
const total = spots + events;
const rankColor = rank === 1 ? 'text-yellow-400' : rank === 2 ? 'text-gray-300' : rank === 3 ? 'text-orange-400' : 'text-gray-500';
const rankEmoji = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
// Trust score color coding
const trustScoreColor = trustScore === undefined ? 'text-gray-500' :
trustScore >= 80 ? 'text-green-500' :
trustScore >= 50 ? 'text-yellow-500' :
'text-gray-500';
return (
<div className="flex items-center gap-4 p-4 bg-black/50 rounded-lg border border-gray-700 hover:border-[#ff0040]/50 transition-all">
<div className={`text-3xl font-black ${rankColor} w-12 text-center`}>
{rankEmoji || `#${rank}`}
</div>
<Avatar className="h-12 w-12 border-2 border-[#ff0040]/30">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-black text-[#ff0040] font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="font-bold text-white">{displayName}</div>
{metadata?.nip05 && (
<div className="flex items-center gap-1 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-[#ccff00]" />
<span className="text-[#ccff00]">{metadata.nip05}</span>
</div>
)}
</div>
<div className="flex gap-4 text-sm">
<div className="text-center">
<div className="text-[#ff0040] font-black">{spots}</div>
<div className="text-gray-500 text-xs uppercase">{t('labUi.pyramid.spots')}</div>
</div>
<div className="text-center">
<div className="text-[#ccff00] font-black">{events}</div>
<div className="text-gray-500 text-xs uppercase">{t('labUi.pyramid.events')}</div>
</div>
<div className="text-center">
<div className="text-[#ff6600] font-black">{total}</div>
<div className="text-gray-500 text-xs uppercase">{t('labUi.pyramid.total')}</div>
</div>
<div className="text-center min-w-[48px]">
<div className={cn("font-black", trustScoreColor)}>
{trustScore ?? '—'}
</div>
<div className="text-gray-500 text-xs uppercase">{t('labUi.pyramid.trust')}</div>
</div>
</div>
</div>
);
}
export default function PyramidPage() {
const { t } = useTranslation();
useSeoMeta({
title: 'Pyramid Rankings - SkateSpots',
description: 'Top contributors and community leaders.',
});
const { user } = useCurrentUser();
const { data: allSpots } = useSkateSpots(500);
const { data: allEvents } = useSkateEvents(500);
const { data: userTrustScore } = useTrustScore(user?.pubkey);
// Calculate contributor rankings
const rankings = useMemo(() => {
if (!allSpots && !allEvents) return [];
const contributorMap = new Map<string, { spots: number; events: number }>();
// Count spots per contributor
allSpots?.forEach(spot => {
const current = contributorMap.get(spot.pubkey) || { spots: 0, events: 0 };
contributorMap.set(spot.pubkey, { ...current, spots: current.spots + 1 });
});
// Count events per contributor
allEvents?.forEach(event => {
const current = contributorMap.get(event.pubkey) || { spots: 0, events: 0 };
contributorMap.set(event.pubkey, { ...current, events: current.events + 1 });
});
// Convert to array and sort by total contributions
return Array.from(contributorMap.entries())
.map(([pubkey, stats]) => ({
pubkey,
spots: stats.spots,
events: stats.events,
total: stats.spots + stats.events,
}))
.sort((a, b) => b.total - a.total);
}, [allSpots, allEvents]);
const userRank = rankings.findIndex(r => r.pubkey === user?.pubkey) + 1;
const userStats = rankings.find(r => r.pubkey === user?.pubkey);
return (
<>
<div className="container px-4 sm:px-6 py-8 sm:py-12">
<div className="text-center mb-12">
<Trophy className="h-16 w-16 mx-auto mb-4 text-orange-500" />
<h1 className="text-4xl md:text-6xl font-black uppercase mb-4 neon-header">
<span className="neon-red">{t('labUi.pyramid.pyramidWord')}</span> <span className="neon-lime">{t('labUi.pyramid.rankingsWord')}</span>
</h1>
<p className="text-gray-400 text-xl">{t('labUi.pyramid.subtitle')}</p>
</div>
{/* Your Stats */}
{user && userStats && (
<Card className="bg-gradient-to-br from-[#ff0040]/20 to-[#ccff00]/20 border-2 border-[#ff0040]/50 max-w-4xl mx-auto mb-8">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-4xl font-black text-[#ff0040]">#{userRank}</div>
<div>
<div className="font-black text-xl text-white uppercase">{t('labUi.pyramid.yourRank')}</div>
<div className="text-sm text-gray-400">{t('labUi.pyramid.keepContributing')}</div>
</div>
</div>
<div className="flex gap-4">
<div className="text-center">
<div className="text-3xl font-black text-[#ff0040]">{userStats.spots}</div>
<div className="text-xs text-gray-400 uppercase">{t('labUi.pyramid.spots')}</div>
</div>
<div className="text-center">
<div className="text-3xl font-black text-[#ccff00]">{userStats.events}</div>
<div className="text-xs text-gray-400 uppercase">{t('labUi.pyramid.events')}</div>
</div>
<div className="text-center">
<div className="text-3xl font-black text-[#ff6600]">{userStats.total}</div>
<div className="text-xs text-gray-400 uppercase">{t('labUi.pyramid.total')}</div>
</div>
<div className="text-center">
<div className={cn(
"text-3xl font-black",
userTrustScore === undefined ? "text-gray-500" :
userTrustScore >= 80 ? "text-green-500" :
userTrustScore >= 50 ? "text-yellow-500" :
"text-gray-500"
)}>
{userTrustScore ?? '—'}
</div>
<div className="text-xs text-gray-400 uppercase">{t('labUi.pyramid.trust')}</div>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Leaderboard */}
<Card className="bg-black/90 border-2 border-gray-700 max-w-4xl mx-auto">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-6">
<Medal className="h-8 w-8 text-[#ff0040]" />
<h2 className="text-2xl font-black uppercase">{t('labUi.pyramid.topContributors')}</h2>
</div>
{rankings.length > 0 ? (
<div className="space-y-2">
{rankings.slice(0, 20).map((contributor, idx) => (
<ContributorRow
key={contributor.pubkey}
pubkey={contributor.pubkey}
spots={contributor.spots}
events={contributor.events}
rank={idx + 1}
/>
))}
</div>
) : (
<div className="text-center py-12">
<Trophy className="h-16 w-16 mx-auto mb-4 text-gray-600" />
<p className="text-gray-400">{t('labUi.pyramid.noContributors')}</p>
</div>
)}
</CardContent>
</Card>
</div>
</>
);
}