SkateSpots/src/pages/BountyLeaderboardPage.tsx

481 lines
20 KiB
TypeScript

import { useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { ErrorState } from '@/components/ErrorState';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language';
import { useAuthor } from '@/hooks/useAuthor';
import { useSkateShops } from '@/hooks/useSkateShops';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { genUserName } from '@/lib/genUserName';
import { nip19 } from 'nostr-tools';
import { Trophy, Medal, Crown, Store, MapPin, Zap, Camera, FileText, Users, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
// Leaderboard entry component
function LeaderboardEntry({
rank,
pubkey,
count,
type,
isCurrentUser
}: {
rank: number;
pubkey: string;
count: number;
type: 'shops' | 'spots' | 'total';
isCurrentUser: boolean;
}) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
const avatar = metadata?.picture;
// Rank styling
const getRankIcon = () => {
if (rank === 1) return <Crown className="h-5 w-5 text-yellow-400" />;
if (rank === 2) return <Medal className="h-5 w-5 text-gray-300" />;
if (rank === 3) return <Medal className="h-5 w-5 text-amber-600" />;
return <span className="text-sm font-bold text-gray-500">#{rank}</span>;
};
const getRankBg = () => {
if (rank === 1) return 'bg-gradient-to-r from-yellow-950/50 to-orange-950/30 border-yellow-500/50';
if (rank === 2) return 'bg-gradient-to-r from-gray-900/50 to-gray-800/30 border-gray-500/50';
if (rank === 3) return 'bg-gradient-to-r from-amber-950/50 to-orange-950/30 border-amber-600/50';
return 'bg-black/30 border-gray-800';
};
const getTypeLabel = () => {
if (type === 'shops') return t('walletUi.leaderboard.shopsLower');
if (type === 'spots') return t('walletUi.leaderboard.spotsLower');
return t('walletUi.leaderboard.contributions');
};
return (
<Link to={`/${nip19.npubEncode(pubkey)}`}>
<div className={cn(
'flex items-center gap-3 p-3 sm:p-4 rounded-xl border-2 transition-all hover:scale-[1.01]',
getRankBg(),
isCurrentUser && 'ring-2 ring-[#ff0040] ring-offset-2 ring-offset-black'
)}>
{/* Rank */}
<div className="w-8 h-8 flex items-center justify-center flex-shrink-0">
{getRankIcon()}
</div>
{/* Avatar */}
<Avatar className="h-10 w-10 sm:h-12 sm:w-12 border-2 border-gray-700 flex-shrink-0">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-xs sm:text-sm font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-bold text-white truncate text-sm sm:text-base">
{displayName}
</p>
{isCurrentUser && (
<Badge className="bg-[#ff0040] text-white text-[10px] px-1.5 py-0">{t('walletUi.leaderboard.you')}</Badge>
)}
</div>
<p className="text-xs text-gray-500 capitalize">
{getTypeLabel()}
</p>
</div>
{/* Count */}
<div className="text-right flex-shrink-0">
<p className="text-lg sm:text-xl font-black text-[#ccff00]">{count}</p>
</div>
</div>
</Link>
);
}
function LeaderboardSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-4 rounded-xl bg-black/30 border border-gray-800">
<Skeleton className="w-8 h-8 rounded-full" />
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-6 w-12" />
</div>
))}
</div>
);
}
export default function BountyLeaderboardPage() {
const { t } = useTranslation();
useSeoMeta({
title: t('seoUi.bountyLeaderboardTitle'),
description: t('seoUi.bountyLeaderboardDesc'),
});
const { user } = useCurrentUser();
const { data: shops, isLoading: shopsLoading, isError: shopsError, refetch: refetchShops } = useSkateShops(500);
const { data: spots, isLoading: spotsLoading, isError: spotsError, refetch: refetchSpots } = useSkateSpots(500);
// Calculate shop leaderboard
const shopLeaderboard = useMemo(() => {
if (!shops) return [];
const countByPubkey: Record<string, number> = {};
shops.forEach(shop => {
countByPubkey[shop.pubkey] = (countByPubkey[shop.pubkey] || 0) + 1;
});
return Object.entries(countByPubkey)
.map(([pubkey, count]) => ({ pubkey, count }))
.sort((a, b) => b.count - a.count);
}, [shops]);
// Calculate spot leaderboard
const spotLeaderboard = useMemo(() => {
if (!spots) return [];
const countByPubkey: Record<string, number> = {};
spots.forEach(spot => {
countByPubkey[spot.pubkey] = (countByPubkey[spot.pubkey] || 0) + 1;
});
return Object.entries(countByPubkey)
.map(([pubkey, count]) => ({ pubkey, count }))
.sort((a, b) => b.count - a.count);
}, [spots]);
// Combined leaderboard (total contributions)
const combinedLeaderboard = useMemo(() => {
const combined: Record<string, { shops: number; spots: number; total: number }> = {};
shops?.forEach(shop => {
if (!combined[shop.pubkey]) combined[shop.pubkey] = { shops: 0, spots: 0, total: 0 };
combined[shop.pubkey].shops++;
combined[shop.pubkey].total++;
});
spots?.forEach(spot => {
if (!combined[spot.pubkey]) combined[spot.pubkey] = { shops: 0, spots: 0, total: 0 };
combined[spot.pubkey].spots++;
combined[spot.pubkey].total++;
});
return Object.entries(combined)
.map(([pubkey, data]) => ({ pubkey, ...data }))
.sort((a, b) => b.total - a.total);
}, [shops, spots]);
// Find user's rank
const userCombinedRank = combinedLeaderboard.findIndex(e => e.pubkey === user?.pubkey) + 1;
const userContributions = combinedLeaderboard.find(e => e.pubkey === user?.pubkey);
// Stats
const totalShops = shops?.length || 0;
const totalSpots = spots?.length || 0;
const totalMappers = combinedLeaderboard.length;
if (shopsError || spotsError) {
return (
<>
<div className="container mx-auto max-w-3xl px-4 py-8">
<ErrorState onRetry={() => { refetchShops(); refetchSpots(); }} />
</div>
</>
);
}
return (
<>
<div className="container px-4 py-6 sm:py-8 max-w-4xl">
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ccff00]/10 border border-[#ccff00]/30 mb-4">
<Zap className="h-5 w-5 text-[#ccff00]" />
<span className="text-[#ccff00] font-bold uppercase text-sm">{t('walletUi.leaderboard.mapEarn')}</span>
</div>
<h1 className="text-3xl sm:text-4xl font-black uppercase mb-3">
<span className="neon-lime">{t('walletUi.leaderboard.helpMap')}</span>
</h1>
<p className="text-gray-400 max-w-lg mx-auto">
{t('walletUi.leaderboard.intro')}
</p>
</div>
{/* How It Works */}
<Card className="mb-8 bg-gradient-to-br from-[#ccff00]/5 to-[#ff0040]/5 border-2 border-[#ccff00]/20">
<CardContent className="p-6">
<h2 className="text-lg font-black uppercase mb-4 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-[#ccff00]" />
{t('walletUi.leaderboard.howItWorks')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-black/40 border border-gray-800">
<div className="w-10 h-10 rounded-lg bg-[#ff0040]/20 flex items-center justify-center mb-3">
<MapPin className="h-5 w-5 text-[#ff0040]" />
</div>
<h3 className="font-bold text-white mb-1">{t('walletUi.leaderboard.step1Title')}</h3>
<p className="text-xs text-gray-400">
{t('walletUi.leaderboard.step1Desc')}
</p>
</div>
<div className="p-4 rounded-xl bg-black/40 border border-gray-800">
<div className="w-10 h-10 rounded-lg bg-[#ccff00]/20 flex items-center justify-center mb-3">
<Zap className="h-5 w-5 text-[#ccff00]" />
</div>
<h3 className="font-bold text-white mb-1">{t('walletUi.leaderboard.step2Title')}</h3>
<p className="text-xs text-gray-400">
{t('walletUi.leaderboard.step2Desc')}
</p>
</div>
<div className="p-4 rounded-xl bg-black/40 border border-gray-800">
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center mb-3">
<Trophy className="h-5 w-5 text-orange-400" />
</div>
<h3 className="font-bold text-white mb-1">{t('walletUi.leaderboard.step3Title')}</h3>
<p className="text-xs text-gray-400">
{t('walletUi.leaderboard.step3Desc')}
</p>
</div>
</div>
{/* Quality Tips */}
<div className="mt-4 p-4 rounded-xl bg-black/40 border border-[#ccff00]/20">
<p className="text-sm font-bold text-[#ccff00] mb-2">{t('walletUi.leaderboard.proTips')}</p>
<div className="flex flex-wrap gap-3 text-xs text-gray-400">
<span className="flex items-center gap-1">
<Camera className="h-3 w-3" />
{t('walletUi.leaderboard.tipPhotos')}
</span>
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{t('walletUi.leaderboard.tipDescriptions')}
</span>
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{t('walletUi.leaderboard.tipLocation')}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Quick Actions */}
<div className="grid grid-cols-2 gap-3 mb-8">
<Link to="/create-shop">
<Card className="h-full bg-[#ccff00]/10 border-2 border-[#ccff00]/30 hover:border-[#ccff00]/60 transition-all hover:scale-[1.02] cursor-pointer">
<CardContent className="p-4 sm:p-6 text-center">
<Store className="h-8 w-8 mx-auto mb-2 text-[#ccff00]" />
<h3 className="font-black uppercase text-sm sm:text-base mb-1">{t('walletUi.leaderboard.mapShop')}</h3>
<p className="text-xs text-gray-400 hidden sm:block">{t('walletUi.leaderboard.mapShopDesc')}</p>
</CardContent>
</Card>
</Link>
<Link to="/create-spot">
<Card className="h-full bg-[#ff0040]/10 border-2 border-[#ff0040]/30 hover:border-[#ff0040]/60 transition-all hover:scale-[1.02] cursor-pointer">
<CardContent className="p-4 sm:p-6 text-center">
<MapPin className="h-8 w-8 mx-auto mb-2 text-[#ff0040]" />
<h3 className="font-black uppercase text-sm sm:text-base mb-1">{t('walletUi.leaderboard.mapSpot')}</h3>
<p className="text-xs text-gray-400 hidden sm:block">{t('walletUi.leaderboard.mapSpotDesc')}</p>
</CardContent>
</Card>
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3 mb-6">
<Card className="bg-[#ccff00]/10 border-[#ccff00]/30">
<CardContent className="p-3 sm:p-4 text-center">
<Store className="h-5 w-5 mx-auto mb-1 text-[#ccff00]" />
<p className="text-xl sm:text-2xl font-black text-white">{totalShops}</p>
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">{t('walletUi.leaderboard.shops')}</p>
</CardContent>
</Card>
<Card className="bg-[#ff0040]/10 border-[#ff0040]/30">
<CardContent className="p-3 sm:p-4 text-center">
<MapPin className="h-5 w-5 mx-auto mb-1 text-[#ff0040]" />
<p className="text-xl sm:text-2xl font-black text-white">{totalSpots}</p>
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">{t('walletUi.stats.spots')}</p>
</CardContent>
</Card>
<Card className="bg-orange-500/10 border-orange-500/30">
<CardContent className="p-3 sm:p-4 text-center">
<Users className="h-5 w-5 mx-auto mb-1 text-orange-400" />
<p className="text-xl sm:text-2xl font-black text-white">{totalMappers}</p>
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">{t('walletUi.stats.mappers')}</p>
</CardContent>
</Card>
</div>
{/* User's position (if logged in and has contributions) */}
{user && userCombinedRank > 0 && userContributions && (
<Card className="mb-6 bg-gradient-to-r from-[#ff0040]/20 to-[#ccff00]/10 border-2 border-[#ff0040]/50">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-[#ff0040]/20 flex items-center justify-center">
<span className="text-lg font-black text-[#ff0040]">#{userCombinedRank}</span>
</div>
<div>
<p className="font-bold text-white">{t('walletUi.leaderboard.yourRanking')}</p>
<p className="text-sm text-gray-400">
{t('walletUi.leaderboard.spotsShops', { spots: userContributions.spots, shops: userContributions.shops })}
</p>
</div>
</div>
<div className="text-right">
<p className="text-2xl font-black text-[#ccff00]">{userContributions.total}</p>
<p className="text-xs text-gray-500">{t('walletUi.leaderboard.totalLower')}</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Leaderboard */}
<div className="mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-400" />
{t('walletUi.stats.topMappers')}
</h2>
</div>
<Tabs defaultValue="combined" className="space-y-4">
<TabsList className="grid w-full grid-cols-3 bg-black/50 border border-gray-800">
<TabsTrigger value="combined" className="gap-1.5 text-xs sm:text-sm">
<Trophy className="h-4 w-4" />
<span className="hidden sm:inline">{t('walletUi.leaderboard.overall')}</span>
</TabsTrigger>
<TabsTrigger value="shops" className="gap-1.5 text-xs sm:text-sm">
<Store className="h-4 w-4" />
{t('walletUi.leaderboard.shops')}
</TabsTrigger>
<TabsTrigger value="spots" className="gap-1.5 text-xs sm:text-sm">
<MapPin className="h-4 w-4" />
{t('walletUi.stats.spots')}
</TabsTrigger>
</TabsList>
<TabsContent value="combined">
{shopsLoading || spotsLoading ? (
<LeaderboardSkeleton />
) : combinedLeaderboard.length > 0 ? (
<div className="space-y-2">
{combinedLeaderboard.slice(0, 20).map((entry, i) => (
<LeaderboardEntry
key={entry.pubkey}
rank={i + 1}
pubkey={entry.pubkey}
count={entry.total}
type="total"
isCurrentUser={entry.pubkey === user?.pubkey}
/>
))}
</div>
) : (
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-12 text-center">
<Trophy className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<p className="text-gray-400 mb-4">{t('walletUi.leaderboard.noContributions')}</p>
<Link to="/create-spot">
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold">
{t('walletUi.leaderboard.addFirstSpot')}
</Button>
</Link>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="shops">
{shopsLoading ? (
<LeaderboardSkeleton />
) : shopLeaderboard.length > 0 ? (
<div className="space-y-2">
{shopLeaderboard.slice(0, 20).map((entry, i) => (
<LeaderboardEntry
key={entry.pubkey}
rank={i + 1}
pubkey={entry.pubkey}
count={entry.count}
type="shops"
isCurrentUser={entry.pubkey === user?.pubkey}
/>
))}
</div>
) : (
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-12 text-center">
<Store className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<p className="text-gray-400 mb-4">{t('walletUi.leaderboard.noShops')}</p>
<Link to="/create-shop">
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold">
{t('walletUi.leaderboard.addFirstShop')}
</Button>
</Link>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="spots">
{spotsLoading ? (
<LeaderboardSkeleton />
) : spotLeaderboard.length > 0 ? (
<div className="space-y-2">
{spotLeaderboard.slice(0, 20).map((entry, i) => (
<LeaderboardEntry
key={entry.pubkey}
rank={i + 1}
pubkey={entry.pubkey}
count={entry.count}
type="spots"
isCurrentUser={entry.pubkey === user?.pubkey}
/>
))}
</div>
) : (
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-12 text-center">
<MapPin className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<p className="text-gray-400 mb-4">{t('walletUi.leaderboard.noSpots')}</p>
<Link to="/create-spot">
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold">
{t('walletUi.leaderboard.addFirstSpot')}
</Button>
</Link>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
{/* Community Note */}
<Card className="mt-8 bg-gray-900/50 border-gray-800">
<CardContent className="p-4 sm:p-6 text-center">
<Zap className="h-8 w-8 mx-auto mb-3 text-yellow-400" />
<h3 className="font-bold text-white mb-2">{t('walletUi.leaderboard.poweredBy')}</h3>
<p className="text-sm text-gray-400 max-w-md mx-auto">
{t('walletUi.leaderboard.poweredByDesc')}
</p>
</CardContent>
</Card>
</div>
</>
);
}