481 lines
20 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|