- 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.
206 lines
8.8 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|