diff --git a/frontend/src/components/leaderboard/GamificationBadges.tsx b/frontend/src/components/leaderboard/GamificationBadges.tsx new file mode 100644 index 000000000..9e46bdb15 --- /dev/null +++ b/frontend/src/components/leaderboard/GamificationBadges.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Flame, Star, Shield, Zap, Award } from 'lucide-react'; +import type { ContributorTier, BadgeType } from '../../lib/gamification'; + +interface TierIndicatorProps { + tier: string; + className?: string; +} + +export function TierIndicator({ tier, className = '' }: TierIndicatorProps) { + // Mapping tiers to specific styling and icons + let Icon = Shield; + let styleClass = 'bg-forge-800 text-text-muted border-border'; + + switch (tier.toLowerCase()) { + case 'novice': + Icon = Shield; + styleClass = 'bg-forge-800 text-text-muted border-border'; + break; + case 'adept': + Icon = Zap; + styleClass = 'bg-emerald/10 text-emerald border-emerald/20'; + break; + case 'master': + Icon = Star; + styleClass = 'bg-purple/10 text-purple border-purple/20'; + break; + case 'grandmaster': + Icon = Award; + styleClass = 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20 shadow-[0_0_10px_rgba(234,179,8,0.2)]'; + break; + } + + return ( + + + {tier} + + ); +} + +interface ContributorBadgeProps { + badge: string; + className?: string; +} + +export function ContributorBadge({ badge, className = '' }: ContributorBadgeProps) { + let styleClass = ''; + + switch (badge.toLowerCase()) { + case 'gold': + styleClass = 'text-yellow-400 drop-shadow-[0_0_4px_rgba(250,204,21,0.5)]'; + break; + case 'silver': + styleClass = 'text-zinc-300 drop-shadow-[0_0_3px_rgba(212,212,216,0.4)]'; + break; + case 'bronze': + styleClass = 'text-amber-600 drop-shadow-[0_0_2px_rgba(217,119,6,0.4)]'; + break; + } + + return ( +
+ + + +
+ ); +} + +interface AnimatedStreakProps { + streak: number; + className?: string; +} + +export function AnimatedStreak({ streak, className = '' }: AnimatedStreakProps) { + if (streak <= 0) return ; + + const isHot = streak >= 3; + const isOnFire = streak >= 7; + + // More intense animation for higher streaks + const pulseAnimation = isOnFire + ? { scale: [1, 1.2, 1], opacity: [0.8, 1, 0.8] } + : isHot + ? { scale: [1, 1.1, 1] } + : {}; + + const colorClass = isOnFire + ? 'text-status-error drop-shadow-[0_0_6px_rgba(255,82,82,0.8)]' + : isHot + ? 'text-orange-500 drop-shadow-[0_0_4px_rgba(249,115,22,0.6)]' + : 'text-status-warning'; + + return ( + + + + + {streak} + + ); +} diff --git a/frontend/src/components/leaderboard/LeaderboardTable.tsx b/frontend/src/components/leaderboard/LeaderboardTable.tsx index e37db6579..b72202bb0 100644 --- a/frontend/src/components/leaderboard/LeaderboardTable.tsx +++ b/frontend/src/components/leaderboard/LeaderboardTable.tsx @@ -4,6 +4,8 @@ import { Flame } from 'lucide-react'; import type { LeaderboardEntry } from '../../types/leaderboard'; import { LANG_COLORS } from '../../lib/utils'; import { fadeIn } from '../../lib/animations'; +import { enrichLeaderboardEntry } from '../../lib/gamification'; +import { TierIndicator, ContributorBadge, AnimatedStreak } from './GamificationBadges'; interface LeaderboardTableProps { entries: LeaderboardEntry[]; @@ -47,54 +49,57 @@ export function LeaderboardTable({ entries }: LeaderboardTableProps) {
Streak
- {tableEntries.map((entry) => ( - -
- #{entry.rank} -
-
- {entry.avatarUrl ? ( - - ) : ( -
- {entry.username[0]?.toUpperCase()} -
- )} -
- - {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} - ) : ( -
- {entry.username[0]?.toUpperCase()} -
- )} +
+ {e.avatarUrl ? ( + {e.username} + ) : ( +
+ {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} +
+
+ Streak + +
+
+ +
+ + ${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 {