SkateSpots/src/pages/BountyLeaderboardPage.tsx
SkateSpots 087f490acc refactor(layout): hoist Layout into the router so the chrome mounts once
- AppRouter wraps every route (except /amber-callback and the 404) in ONE
  <Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
  navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
  a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
  fragments instead of <Layout> (structure-preserving), imports dropped.

Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
2026-09-27 13:41:42 +07:00

478 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 { 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 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 'shops';
if (type === 'spots') return 'spots';
return '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">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() {
useSeoMeta({
title: 'Map & Earn - SkateSpots',
description: 'Map skate spots and shops, earn zaps from the community. See the top contributors.',
});
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">Map & Earn</span>
</div>
<h1 className="text-3xl sm:text-4xl font-black uppercase mb-3">
<span className="neon-lime">HELP MAP THE WORLD</span>
</h1>
<p className="text-gray-400 max-w-lg mx-auto">
Add spots and shops, get zapped by grateful skaters. Top contributors may receive bonus rewards!
</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]" />
How It Works
</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">1. Map</h3>
<p className="text-xs text-gray-400">
Add spots & shops with photos and details
</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">2. Earn Zaps</h3>
<p className="text-xs text-gray-400">
Skaters who find your spots useful will tip you
</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">3. Bonus Rewards</h3>
<p className="text-xs text-gray-400">
Top mappers may get bonus rewards from our fund
</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">💡 Pro Tips for More Zaps:</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" />
Add 3+ photos
</span>
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
Write detailed descriptions
</span>
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
Accurate location
</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">Map a Shop</h3>
<p className="text-xs text-gray-400 hidden sm:block">Skate shops, board stores</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">Map a Spot</h3>
<p className="text-xs text-gray-400 hidden sm:block">Parks, street spots, DIY</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">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">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">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">Your Ranking</p>
<p className="text-sm text-gray-400">
{userContributions.spots} spots • {userContributions.shops} 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">total</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" />
Top Mappers
</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">Overall</span>
</TabsTrigger>
<TabsTrigger value="shops" className="gap-1.5 text-xs sm:text-sm">
<Store className="h-4 w-4" />
Shops
</TabsTrigger>
<TabsTrigger value="spots" className="gap-1.5 text-xs sm:text-sm">
<MapPin className="h-4 w-4" />
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">No contributions yet. Be the first!</p>
<Link to="/create-spot">
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold">
Add First Spot
</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">No shops mapped yet</p>
<Link to="/create-shop">
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold">
Add First Shop
</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">No spots mapped yet</p>
<Link to="/create-spot">
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold">
Add First Spot
</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">Powered by Community Zaps</h3>
<p className="text-sm text-gray-400 max-w-md mx-auto">
When you find a spot helpful, zap the mapper! It's the best way to say thanks and encourage more contributions.
</p>
</CardContent>
</Card>
</div>
</>
);
}