~3,470 new keys (5,770 total, EN=ES), ~115 more files wired. - nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets (sort/trust/weather), spot-type label helper. - placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications, collectives, crew chat/coordinator. settingsUi: settings, auth/signup, onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints, app shop, newsletter, community stats, leaderboards, profile. - Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
268 lines
9.7 KiB
TypeScript
268 lines
9.7 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { ErrorState } from '@/components/ErrorState';
|
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { useZapLeaderboard } from '@/hooks/useZapLeaderboard';
|
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
|
|
import { cn } from '@/lib/utils';
|
|
import { Crown, Medal, MapPin, Store, Trophy, Users, Zap } from 'lucide-react';
|
|
import type { ZapLeaderboardSender, ZapLeaderboardTarget } from '@/hooks/useZapLeaderboard';
|
|
|
|
const RANGES = {
|
|
week: 7 * 24 * 60 * 60,
|
|
month: 30 * 24 * 60 * 60,
|
|
all: null,
|
|
} as const;
|
|
|
|
type RangeKey = keyof typeof RANGES;
|
|
|
|
function RankBadge({ rank }: { rank: number }) {
|
|
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>;
|
|
}
|
|
|
|
function rowClasses(rank: number) {
|
|
return cn(
|
|
'flex items-center gap-3 rounded-xl border-2 bg-black/60 px-3 py-2.5 transition-colors',
|
|
rank === 1
|
|
? 'border-yellow-500/40 hover:border-yellow-500/70'
|
|
: rank === 2
|
|
? 'border-gray-400/30 hover:border-gray-400/60'
|
|
: rank === 3
|
|
? 'border-amber-600/30 hover:border-amber-600/60'
|
|
: 'border-white/10 hover:border-[#ff0040]/50',
|
|
);
|
|
}
|
|
|
|
function Sats({ sats, count }: { sats: number; count: number }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="text-right">
|
|
<div className="flex items-center gap-1 font-bold text-[#ccff00]">
|
|
<Zap className="h-4 w-4" />
|
|
{sats.toLocaleString()}
|
|
</div>
|
|
<div className="text-[11px] text-gray-500">
|
|
{t('walletUi.zap.count', { count })}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TargetRow({
|
|
rank,
|
|
target,
|
|
spot,
|
|
}: {
|
|
rank: number;
|
|
target: ZapLeaderboardTarget;
|
|
spot?: SkateSpot;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
// `a` coordinate = kind:pubkey:identifier (identifier may itself contain ':').
|
|
const [kindStr, pubkey, ...rest] = target.key.split(':');
|
|
const identifier = rest.join(':');
|
|
const kind = Number(kindStr);
|
|
const isSpotCoord = kind === SKATE_SPOT_KIND && !!pubkey && identifier !== '';
|
|
const isShop = spot?.types.includes('shop') ?? false;
|
|
|
|
const to = spot
|
|
? isShop
|
|
? `/shop/${spot.pubkey}/${spot.identifier}`
|
|
: `/spot/${spot.pubkey}/${spot.identifier}`
|
|
: isSpotCoord
|
|
? `/${nip19.naddrEncode({ kind, pubkey, identifier })}`
|
|
: undefined;
|
|
|
|
const label = spot?.title ?? (isShop ? t('walletUi.leaderboard.skateShop') : t('walletUi.leaderboard.skateSpot'));
|
|
|
|
const inner = (
|
|
<div className={rowClasses(rank)}>
|
|
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center">
|
|
<RankBadge rank={rank} />
|
|
</div>
|
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-[#ff0040]/15 text-[#ff0040]">
|
|
{isShop ? <Store className="h-4 w-4" /> : <MapPin className="h-4 w-4" />}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-bold uppercase tracking-wide text-white">{label}</p>
|
|
{spot?.location && (
|
|
<p className="truncate text-xs text-gray-500">{spot.location}</p>
|
|
)}
|
|
</div>
|
|
<Sats sats={target.sats} count={target.count} />
|
|
</div>
|
|
);
|
|
|
|
return to ? <Link to={to} className="block">{inner}</Link> : inner;
|
|
}
|
|
|
|
function SupporterRow({ rank, entry }: { rank: number; entry: ZapLeaderboardSender }) {
|
|
const { t } = useTranslation();
|
|
const author = useAuthor(entry.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name ?? genUserName(entry.pubkey);
|
|
|
|
return (
|
|
<Link to={`/${nip19.npubEncode(entry.pubkey)}`} className="block">
|
|
<div className={rowClasses(rank)}>
|
|
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center">
|
|
<RankBadge rank={rank} />
|
|
</div>
|
|
<Avatar className="h-9 w-9 flex-shrink-0 border border-[#ccff00]/30">
|
|
<AvatarImage src={metadata?.picture} alt={displayName} />
|
|
<AvatarFallback className="bg-black text-[#ccff00] text-xs">
|
|
{displayName.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-bold text-white">{displayName}</p>
|
|
<p className="truncate text-xs text-gray-500">{t('walletUi.leaderboard.supporter')}</p>
|
|
</div>
|
|
<Sats sats={entry.sats} count={entry.count} />
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function RowsSkeleton() {
|
|
return (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-[62px] w-full rounded-xl" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ZapLeaderboardPage() {
|
|
const { t } = useTranslation();
|
|
useSeoMeta({
|
|
title: 'Zap Leaderboards - SkateSpots',
|
|
description: 'Top zapped skate spots and the community members supporting them.',
|
|
});
|
|
|
|
const [range, setRange] = useState<RangeKey>('month');
|
|
|
|
const since = useMemo(() => {
|
|
const seconds = RANGES[range];
|
|
return seconds ? Math.floor(Date.now() / 1000) - seconds : undefined;
|
|
}, [range]);
|
|
|
|
const { data, isLoading, isError, refetch } = useZapLeaderboard(since);
|
|
const { data: spots } = useSkateSpots(200);
|
|
|
|
const spotByCoord = useMemo(() => {
|
|
const map = new Map<string, SkateSpot>();
|
|
for (const s of spots ?? []) {
|
|
map.set(`${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`, s);
|
|
}
|
|
return map;
|
|
}, [spots]);
|
|
|
|
const topTargets = useMemo(
|
|
() => (data?.targets ?? []).filter((t) => t.key.startsWith(`${SKATE_SPOT_KIND}:`)).slice(0, 10),
|
|
[data],
|
|
);
|
|
const topSenders = useMemo(() => (data?.senders ?? []).slice(0, 10), [data]);
|
|
|
|
const totalZapped = useMemo(
|
|
() => topSenders.reduce((sum, s) => sum + s.sats, 0),
|
|
[topSenders],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="container mx-auto max-w-3xl px-4 py-8">
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<h1 className="flex items-center gap-2 text-2xl font-black uppercase tracking-wide text-white sm:text-3xl">
|
|
<Trophy className="h-7 w-7 text-[#ccff00]" />
|
|
{t('walletUi.leaderboard.zapTitle')}
|
|
</h1>
|
|
<p className="mt-1 text-sm text-gray-400">
|
|
{t('walletUi.leaderboard.zapSubtitle')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Time range */}
|
|
<Tabs value={range} onValueChange={(v) => setRange(v as RangeKey)} className="mb-6">
|
|
<TabsList className="grid w-full grid-cols-3">
|
|
<TabsTrigger value="week">{t('walletUi.leaderboard.thisWeek')}</TabsTrigger>
|
|
<TabsTrigger value="month">{t('walletUi.leaderboard.thisMonth')}</TabsTrigger>
|
|
<TabsTrigger value="all">{t('walletUi.leaderboard.allTime')}</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{/* Top spots */}
|
|
<Card className="mb-6 border-2 border-[#ff0040]/25 bg-black/80">
|
|
<CardContent className="p-4">
|
|
<h2 className="mb-3 flex items-center gap-2 text-sm font-black uppercase tracking-[0.15em] text-[#ff0040]">
|
|
<MapPin className="h-4 w-4" />
|
|
{t('walletUi.leaderboard.topZappedSpots')}
|
|
</h2>
|
|
{isError ? (
|
|
<ErrorState onRetry={() => refetch()} />
|
|
) : isLoading ? (
|
|
<RowsSkeleton />
|
|
) : topTargets.length === 0 ? (
|
|
<p className="py-8 text-center text-sm text-gray-500">
|
|
{t('walletUi.leaderboard.noZaps')}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{topTargets.map((t, i) => (
|
|
<TargetRow key={t.key} rank={i + 1} target={t} spot={spotByCoord.get(t.key)} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Top supporters */}
|
|
<Card className="border-2 border-[#ccff00]/25 bg-black/80">
|
|
<CardContent className="p-4">
|
|
<h2 className="mb-3 flex items-center gap-2 text-sm font-black uppercase tracking-[0.15em] text-[#ccff00]">
|
|
<Users className="h-4 w-4" />
|
|
{t('walletUi.leaderboard.topSupporters')}
|
|
</h2>
|
|
{isError ? (
|
|
<ErrorState onRetry={() => refetch()} />
|
|
) : isLoading ? (
|
|
<RowsSkeleton />
|
|
) : topSenders.length === 0 ? (
|
|
<p className="py-8 text-center text-sm text-gray-500">
|
|
{t('walletUi.leaderboard.noZapsSent')}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div className="space-y-2">
|
|
{topSenders.map((s, i) => (
|
|
<SupporterRow key={s.pubkey} rank={i + 1} entry={s} />
|
|
))}
|
|
</div>
|
|
{totalZapped > 0 && (
|
|
<Badge variant="secondary" className="mt-4 font-mono text-[11px]">
|
|
{t('walletUi.leaderboard.fromTop10', { amount: totalZapped.toLocaleString() })}
|
|
</Badge>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|