-
- {entry.username}
-
- {entry.topSkills?.length > 0 && (
-
- {entry.topSkills.slice(0, 4).map((s) => (
-
- ))}
+ {tableEntries.map((rawEntry) => {
+ const entry = enrichLeaderboardEntry(rawEntry);
+ return (
+
+
+ #{entry.rank}
+
+
+ {entry.avatarUrl ? (
+

+ ) : (
+
+ {entry.username[0]?.toUpperCase()}
)}
+
+
+
+ {entry.username}
+
+ {entry.tier && }
+ {entry.badges?.slice(0, 3).map((badge) => (
+
+ ))}
+
+ {entry.topSkills?.length > 0 && (
+
+ {entry.topSkills.slice(0, 4).map((s) => (
+
+ ))}
+
+ )}
+
+
+
+ {entry.bountiesCompleted}
+
+
+ ${entry.earningsFndry.toLocaleString()}
+
+
-
-
- {entry.bountiesCompleted}
-
-
- ${entry.earningsFndry.toLocaleString()}
-
-
- {entry.streak && entry.streak > 0 ? (
-
- {entry.streak}
-
- ) : (
- —
- )}
-
-
- ))}
+
+ );
+ })}
);
}
diff --git a/frontend/src/components/leaderboard/PodiumCards.tsx b/frontend/src/components/leaderboard/PodiumCards.tsx
index f36d233cc..1d59b9470 100644
--- a/frontend/src/components/leaderboard/PodiumCards.tsx
+++ b/frontend/src/components/leaderboard/PodiumCards.tsx
@@ -3,6 +3,8 @@ import { motion } from 'framer-motion';
import { Crown } from 'lucide-react';
import type { LeaderboardEntry } from '../../types/leaderboard';
import { staggerContainer, staggerItem } from '../../lib/animations';
+import { enrichLeaderboardEntry } from '../../lib/gamification';
+import { TierIndicator, ContributorBadge, AnimatedStreak } from './GamificationBadges';
interface PodiumCardsProps {
entries: LeaderboardEntry[];
@@ -29,37 +31,64 @@ function PodiumCard({ entry, rank }: { entry: LeaderboardEntry; rank: number })
const avatarSize = isGold ? 'w-14 h-14' : 'w-12 h-12';
const padding = isGold ? 'py-8 px-6' : 'py-6 px-6';
+ const e = enrichLeaderboardEntry(entry);
+
return (
{/* Crown for #1 */}
{isGold && (
-
+
)}
-
+
#{rank}
- {entry.avatarUrl ? (
-
- ) : (
-
- {entry.username[0]?.toUpperCase()}
-
- )}
+
+ {e.avatarUrl ? (
+

+ ) : (
+
+ {e.username[0]?.toUpperCase()}
+
+ )}
+
+ {/* Absolute top badge if they have one */}
+ {e.badges && e.badges.length > 0 && (
+
+
+
+ )}
+
- {entry.username}
- {entry.bountiesCompleted} bounties
-
- ${entry.earningsFndry.toLocaleString()}
-
+
+ {e.username}
+ {e.tier && }
+
+
+
+
+ Bounties
+ {e.bountiesCompleted}
+
+
+
+
+
+
+ ${e.earningsFndry.toLocaleString()}
+
+
);
}
diff --git a/frontend/src/lib/gamification.ts b/frontend/src/lib/gamification.ts
new file mode 100644
index 000000000..e3f6c6c9a
--- /dev/null
+++ b/frontend/src/lib/gamification.ts
@@ -0,0 +1,31 @@
+import type { LeaderboardEntry } from '../types/leaderboard';
+
+export type ContributorTier = 'Novice' | 'Adept' | 'Master' | 'Grandmaster';
+export type BadgeType = 'Gold' | 'Silver' | 'Bronze';
+
+export function computeTier(reputation: number): ContributorTier {
+ if (reputation >= 90) return 'Grandmaster';
+ if (reputation >= 70) return 'Master';
+ if (reputation >= 30) return 'Adept';
+ return 'Novice';
+}
+
+export function computeBadges(bountiesCompleted: number): BadgeType[] {
+ const badges: BadgeType[] = [];
+ if (bountiesCompleted >= 1) badges.push('Bronze');
+ if (bountiesCompleted >= 5) badges.push('Silver');
+ if (bountiesCompleted >= 15) badges.push('Gold');
+ // Sort badges high-to-low so highest is displayed first
+ return badges.reverse();
+}
+
+/**
+ * Ensures the entry has computed gamification fields if missing from the API.
+ */
+export function enrichLeaderboardEntry(entry: LeaderboardEntry): LeaderboardEntry {
+ return {
+ ...entry,
+ tier: entry.tier ?? computeTier(entry.reputation),
+ badges: entry.badges ?? computeBadges(entry.bountiesCompleted),
+ };
+}
diff --git a/frontend/src/types/leaderboard.ts b/frontend/src/types/leaderboard.ts
index 35405007b..78827fab2 100644
--- a/frontend/src/types/leaderboard.ts
+++ b/frontend/src/types/leaderboard.ts
@@ -11,6 +11,8 @@ export interface LeaderboardEntry {
reputation: number;
stakedFndry: number;
reputationBoost: number;
+ tier?: string;
+ badges?: string[];
}
export interface PlatformStats {