mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 18:31:13 -05:00
Add contributor leaderboard
Add types, hook, UI components, and integration for leaderboard showing top users with badges
This commit is contained in:
173
src/components/contributors/AchievementBadge.tsx
Normal file
173
src/components/contributors/AchievementBadge.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
Award,
|
||||||
|
Camera,
|
||||||
|
Edit,
|
||||||
|
MapPin,
|
||||||
|
MessageSquare,
|
||||||
|
Sparkles,
|
||||||
|
Trophy,
|
||||||
|
Crown,
|
||||||
|
Shield
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { AchievementLevel, SpecialBadge } from '@/types/contributor';
|
||||||
|
|
||||||
|
interface AchievementBadgeProps {
|
||||||
|
level: AchievementLevel;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpecialBadgeProps {
|
||||||
|
badge: SpecialBadge;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
const achievementConfig: Record<AchievementLevel, {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
description: string;
|
||||||
|
}> = {
|
||||||
|
legend: {
|
||||||
|
label: 'Legend',
|
||||||
|
color: 'bg-gradient-to-r from-purple-500 to-pink-500 text-white border-0',
|
||||||
|
icon: <Crown className="w-3 h-3" />,
|
||||||
|
description: '5000+ contribution points - An absolute legend!',
|
||||||
|
},
|
||||||
|
platinum: {
|
||||||
|
label: 'Platinum',
|
||||||
|
color: 'bg-gradient-to-r from-slate-300 to-slate-400 text-slate-900 border-0',
|
||||||
|
icon: <Trophy className="w-3 h-3" />,
|
||||||
|
description: '1000+ contribution points - Elite contributor',
|
||||||
|
},
|
||||||
|
gold: {
|
||||||
|
label: 'Gold',
|
||||||
|
color: 'bg-gradient-to-r from-yellow-400 to-yellow-500 text-yellow-900 border-0',
|
||||||
|
icon: <Award className="w-3 h-3" />,
|
||||||
|
description: '500+ contribution points - Outstanding work!',
|
||||||
|
},
|
||||||
|
silver: {
|
||||||
|
label: 'Silver',
|
||||||
|
color: 'bg-gradient-to-r from-gray-300 to-gray-400 text-gray-800 border-0',
|
||||||
|
icon: <Award className="w-3 h-3" />,
|
||||||
|
description: '100+ contribution points - Great contributor',
|
||||||
|
},
|
||||||
|
bronze: {
|
||||||
|
label: 'Bronze',
|
||||||
|
color: 'bg-gradient-to-r from-orange-400 to-orange-500 text-orange-900 border-0',
|
||||||
|
icon: <Award className="w-3 h-3" />,
|
||||||
|
description: '10+ contribution points - Getting started!',
|
||||||
|
},
|
||||||
|
newcomer: {
|
||||||
|
label: 'Newcomer',
|
||||||
|
color: 'bg-muted text-muted-foreground',
|
||||||
|
icon: <Sparkles className="w-3 h-3" />,
|
||||||
|
description: 'Just getting started',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const specialBadgeConfig: Record<SpecialBadge, {
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
}> = {
|
||||||
|
park_explorer: {
|
||||||
|
label: 'Park Explorer',
|
||||||
|
icon: <MapPin className="w-3 h-3" />,
|
||||||
|
description: 'Added 100+ parks to the database',
|
||||||
|
color: 'bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20',
|
||||||
|
},
|
||||||
|
ride_master: {
|
||||||
|
label: 'Ride Master',
|
||||||
|
icon: <Sparkles className="w-3 h-3" />,
|
||||||
|
description: 'Added 200+ rides to the database',
|
||||||
|
color: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
|
||||||
|
},
|
||||||
|
photographer: {
|
||||||
|
label: 'Photographer',
|
||||||
|
icon: <Camera className="w-3 h-3" />,
|
||||||
|
description: 'Uploaded 500+ photos',
|
||||||
|
color: 'bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/20',
|
||||||
|
},
|
||||||
|
critic: {
|
||||||
|
label: 'Critic',
|
||||||
|
icon: <MessageSquare className="w-3 h-3" />,
|
||||||
|
description: 'Wrote 100+ reviews',
|
||||||
|
color: 'bg-orange-500/10 text-orange-700 dark:text-orange-400 border-orange-500/20',
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
label: 'Editor',
|
||||||
|
icon: <Edit className="w-3 h-3" />,
|
||||||
|
description: 'Made 500+ edits to existing entries',
|
||||||
|
color: 'bg-cyan-500/10 text-cyan-700 dark:text-cyan-400 border-cyan-500/20',
|
||||||
|
},
|
||||||
|
completionist: {
|
||||||
|
label: 'Completionist',
|
||||||
|
icon: <Shield className="w-3 h-3" />,
|
||||||
|
description: 'Contributed across all content types',
|
||||||
|
color: 'bg-indigo-500/10 text-indigo-700 dark:text-indigo-400 border-indigo-500/20',
|
||||||
|
},
|
||||||
|
veteran: {
|
||||||
|
label: 'Veteran',
|
||||||
|
icon: <Award className="w-3 h-3" />,
|
||||||
|
description: 'Member for over 1 year',
|
||||||
|
color: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20',
|
||||||
|
},
|
||||||
|
top_contributor: {
|
||||||
|
label: 'Top Contributor',
|
||||||
|
icon: <Crown className="w-3 h-3" />,
|
||||||
|
description: 'Ranked #1 contributor',
|
||||||
|
color: 'bg-pink-500/10 text-pink-700 dark:text-pink-400 border-pink-500/20',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AchievementBadge({ level, size = 'md' }: AchievementBadgeProps) {
|
||||||
|
const config = achievementConfig[level];
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'text-xs px-2 py-0.5',
|
||||||
|
md: 'text-sm px-2.5 py-0.5',
|
||||||
|
lg: 'text-base px-3 py-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge className={`${config.color} ${sizeClasses[size]} gap-1`}>
|
||||||
|
{config.icon}
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{config.description}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecialBadge({ badge, size = 'sm' }: SpecialBadgeProps) {
|
||||||
|
const config = specialBadgeConfig[badge];
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'text-xs px-2 py-0.5',
|
||||||
|
md: 'text-sm px-2.5 py-0.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant="outline" className={`${config.color} ${sizeClasses[size]} gap-1`}>
|
||||||
|
{config.icon}
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{config.description}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/components/contributors/ContributorLeaderboard.tsx
Normal file
172
src/components/contributors/ContributorLeaderboard.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { useContributorLeaderboard } from '@/hooks/useContributorLeaderboard';
|
||||||
|
import { LeaderboardEntry } from './LeaderboardEntry';
|
||||||
|
import { TimePeriod } from '@/types/contributor';
|
||||||
|
import { Trophy, TrendingUp, Users, AlertCircle } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
export function ContributorLeaderboard() {
|
||||||
|
const [timePeriod, setTimePeriod] = useState<TimePeriod>('all_time');
|
||||||
|
const [limit, setLimit] = useState(50);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useContributorLeaderboard(limit, timePeriod);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Failed to load contributor leaderboard. Please try again later.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-2xl">
|
||||||
|
<Trophy className="w-6 h-6 text-yellow-500" />
|
||||||
|
Contributor Leaderboard
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Celebrating our amazing contributors who make ThrillWiki possible
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-lg px-4 py-2">
|
||||||
|
<Users className="w-4 h-4 mr-2" />
|
||||||
|
{data?.total_contributors.toLocaleString() || 0} Contributors
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
{/* Time Period Filter */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm font-medium mb-2 block">Time Period</label>
|
||||||
|
<Select value={timePeriod} onValueChange={(value) => setTimePeriod(value as TimePeriod)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all_time">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trophy className="w-4 h-4" />
|
||||||
|
All Time
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="month">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
This Month
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="week">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
This Week
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Limit Filter */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm font-medium mb-2 block">Show Top</label>
|
||||||
|
<Select value={limit.toString()} onValueChange={(value) => setLimit(parseInt(value))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="10">Top 10</SelectItem>
|
||||||
|
<SelectItem value="25">Top 25</SelectItem>
|
||||||
|
<SelectItem value="50">Top 50</SelectItem>
|
||||||
|
<SelectItem value="100">Top 100</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Achievement Legend */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Achievement Levels</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Contribution points are calculated based on approved submissions: Parks (10 pts), Rides (8 pts), Companies (5 pts), Models (5 pts), Reviews (3 pts), Photos (2 pts), Edits (1 pt)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
|
<AchievementInfo level="Legend" points="5000+" color="bg-gradient-to-r from-purple-500 to-pink-500" />
|
||||||
|
<AchievementInfo level="Platinum" points="1000+" color="bg-gradient-to-r from-slate-300 to-slate-400" />
|
||||||
|
<AchievementInfo level="Gold" points="500+" color="bg-gradient-to-r from-yellow-400 to-yellow-500" />
|
||||||
|
<AchievementInfo level="Silver" points="100+" color="bg-gradient-to-r from-gray-300 to-gray-400" />
|
||||||
|
<AchievementInfo level="Bronze" points="10+" color="bg-gradient-to-r from-orange-400 to-orange-500" />
|
||||||
|
<AchievementInfo level="Newcomer" points="0-9" color="bg-muted" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Leaderboard */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(10)].map((_, i) => (
|
||||||
|
<Card key={i} className="p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Skeleton className="w-[60px] h-[60px] rounded-lg" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<Skeleton className="h-4 w-1/4" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : data?.contributors && data.contributors.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.contributors.map((contributor) => (
|
||||||
|
<LeaderboardEntry
|
||||||
|
key={contributor.user_id}
|
||||||
|
contributor={contributor}
|
||||||
|
showPeriodStats={timePeriod !== 'all_time'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<Trophy className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No Contributors Yet</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Be the first to contribute to ThrillWiki!
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AchievementInfo({ level, points, color }: { level: string; points: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`${color} rounded-lg p-3 mb-2`}>
|
||||||
|
<Trophy className="w-6 h-6 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold">{level}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{points} pts</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/contributors/LeaderboardEntry.tsx
Normal file
146
src/components/contributors/LeaderboardEntry.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { LeaderboardContributor } from '@/types/contributor';
|
||||||
|
import { AchievementBadge, SpecialBadge } from './AchievementBadge';
|
||||||
|
import { Trophy, TrendingUp, Calendar } from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
interface LeaderboardEntryProps {
|
||||||
|
contributor: LeaderboardContributor;
|
||||||
|
showPeriodStats?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaderboardEntry({ contributor, showPeriodStats = false }: LeaderboardEntryProps) {
|
||||||
|
const periodStats = contributor.stats;
|
||||||
|
const allTimeStats = contributor.total_stats;
|
||||||
|
const totalContributions = showPeriodStats
|
||||||
|
? contributor.contribution_score
|
||||||
|
: contributor.total_score;
|
||||||
|
|
||||||
|
const getRankColor = (rank: number) => {
|
||||||
|
if (rank === 1) return 'text-yellow-500';
|
||||||
|
if (rank === 2) return 'text-gray-400';
|
||||||
|
if (rank === 3) return 'text-orange-600';
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRankIcon = (rank: number) => {
|
||||||
|
if (rank <= 3) {
|
||||||
|
return <Trophy className={`w-6 h-6 ${getRankColor(rank)}`} />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-4 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Rank */}
|
||||||
|
<div className="flex flex-col items-center justify-center min-w-[60px]">
|
||||||
|
{getRankIcon(contributor.rank)}
|
||||||
|
<span className={`text-2xl font-bold ${getRankColor(contributor.rank)}`}>
|
||||||
|
#{contributor.rank}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar & Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start gap-3 mb-3">
|
||||||
|
<Avatar className="w-12 h-12">
|
||||||
|
<AvatarImage src={contributor.avatar_url || undefined} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{(contributor.display_name || contributor.username).slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-lg truncate">
|
||||||
|
{contributor.display_name || contributor.username}
|
||||||
|
</h3>
|
||||||
|
<AchievementBadge level={contributor.achievement_level} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 text-sm text-muted-foreground mb-2">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
Joined {formatDistanceToNow(new Date(contributor.join_date), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Special Badges */}
|
||||||
|
{contributor.special_badges.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
|
{contributor.special_badges.map((badge) => (
|
||||||
|
<SpecialBadge key={badge} badge={badge} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{showPeriodStats ? (
|
||||||
|
<>
|
||||||
|
{periodStats.parks_added > 0 && (
|
||||||
|
<StatCard label="Parks" value={periodStats.parks_added} />
|
||||||
|
)}
|
||||||
|
{periodStats.rides_added > 0 && (
|
||||||
|
<StatCard label="Rides" value={periodStats.rides_added} />
|
||||||
|
)}
|
||||||
|
{periodStats.photos_added > 0 && (
|
||||||
|
<StatCard label="Photos" value={periodStats.photos_added} />
|
||||||
|
)}
|
||||||
|
{periodStats.reviews_added > 0 && (
|
||||||
|
<StatCard label="Reviews" value={periodStats.reviews_added} />
|
||||||
|
)}
|
||||||
|
{periodStats.edits_made > 0 && (
|
||||||
|
<StatCard label="Edits" value={periodStats.edits_made} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{allTimeStats.total_parks > 0 && (
|
||||||
|
<StatCard label="Parks" value={allTimeStats.total_parks} />
|
||||||
|
)}
|
||||||
|
{allTimeStats.total_rides > 0 && (
|
||||||
|
<StatCard label="Rides" value={allTimeStats.total_rides} />
|
||||||
|
)}
|
||||||
|
{allTimeStats.total_photos > 0 && (
|
||||||
|
<StatCard label="Photos" value={allTimeStats.total_photos} />
|
||||||
|
)}
|
||||||
|
{allTimeStats.total_reviews > 0 && (
|
||||||
|
<StatCard label="Reviews" value={allTimeStats.total_reviews} />
|
||||||
|
)}
|
||||||
|
{allTimeStats.total_edits > 0 && (
|
||||||
|
<StatCard label="Edits" value={allTimeStats.total_edits} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Score */}
|
||||||
|
<div className="mt-3 pt-3 border-t flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
<span>Contribution Score</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-base font-bold">
|
||||||
|
{totalContributions.toLocaleString()} pts
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted/50 rounded-lg p-2 text-center">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">{label}</div>
|
||||||
|
<div className="text-lg font-bold">{value.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/hooks/useContributorLeaderboard.ts
Normal file
25
src/hooks/useContributorLeaderboard.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/lib/supabaseClient';
|
||||||
|
import { LeaderboardData, TimePeriod } from '@/types/contributor';
|
||||||
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
|
||||||
|
export function useContributorLeaderboard(
|
||||||
|
limit: number = 50,
|
||||||
|
timePeriod: TimePeriod = 'all_time'
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.analytics.contributorLeaderboard(limit, timePeriod),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await supabase.rpc('get_contributor_leaderboard', {
|
||||||
|
limit_count: limit,
|
||||||
|
time_period: timePeriod,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data as unknown as LeaderboardData;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
refetchInterval: 5 * 60 * 1000, // Refresh every 5 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6829,6 +6829,10 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
generate_ticket_number: { Args: never; Returns: string }
|
generate_ticket_number: { Args: never; Returns: string }
|
||||||
get_auth0_sub_from_jwt: { Args: never; Returns: string }
|
get_auth0_sub_from_jwt: { Args: never; Returns: string }
|
||||||
|
get_contributor_leaderboard: {
|
||||||
|
Args: { limit_count?: number; time_period?: string }
|
||||||
|
Returns: Json
|
||||||
|
}
|
||||||
get_current_user_id: { Args: never; Returns: string }
|
get_current_user_id: { Args: never; Returns: string }
|
||||||
get_database_statistics: { Args: never; Returns: Json }
|
get_database_statistics: { Args: never; Returns: Json }
|
||||||
get_email_change_status: { Args: never; Returns: Json }
|
get_email_change_status: { Args: never; Returns: Json }
|
||||||
|
|||||||
@@ -111,5 +111,7 @@ export const queryKeys = {
|
|||||||
growthTrends: (days: number, granularity: string) => ['analytics', 'growth-trends', days, granularity] as const,
|
growthTrends: (days: number, granularity: string) => ['analytics', 'growth-trends', days, granularity] as const,
|
||||||
entityComparisons: () => ['analytics', 'entity-comparisons'] as const,
|
entityComparisons: () => ['analytics', 'entity-comparisons'] as const,
|
||||||
databaseHealth: () => ['analytics', 'database-health'] as const,
|
databaseHealth: () => ['analytics', 'database-health'] as const,
|
||||||
|
contributorLeaderboard: (limit: number, timePeriod: string) =>
|
||||||
|
['analytics', 'contributor-leaderboard', limit, timePeriod] as const,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DataQualityOverview } from '@/components/admin/database-stats/DataQuali
|
|||||||
import { GrowthTrendsChart } from '@/components/admin/database-stats/GrowthTrendsChart';
|
import { GrowthTrendsChart } from '@/components/admin/database-stats/GrowthTrendsChart';
|
||||||
import { EntityComparisonDashboard } from '@/components/admin/database-stats/EntityComparisonDashboard';
|
import { EntityComparisonDashboard } from '@/components/admin/database-stats/EntityComparisonDashboard';
|
||||||
import { DatabaseHealthDashboard } from '@/components/admin/database-stats/DatabaseHealthDashboard';
|
import { DatabaseHealthDashboard } from '@/components/admin/database-stats/DatabaseHealthDashboard';
|
||||||
|
import { ContributorLeaderboard } from '@/components/contributors/ContributorLeaderboard';
|
||||||
import { useAdminDatabaseStats } from '@/hooks/useAdminDatabaseStats';
|
import { useAdminDatabaseStats } from '@/hooks/useAdminDatabaseStats';
|
||||||
import { useRecentAdditions } from '@/hooks/useRecentAdditions';
|
import { useRecentAdditions } from '@/hooks/useRecentAdditions';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
@@ -68,26 +69,30 @@ export default function AdminDatabaseStats() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="space-y-6">
|
<Tabs defaultValue="overview" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-5">
|
<TabsList className="grid w-full grid-cols-6">
|
||||||
<TabsTrigger value="overview" className="flex items-center gap-2">
|
<TabsTrigger value="overview" className="flex items-center gap-2">
|
||||||
<Box className="h-4 w-4" />
|
<Box className="h-4 w-4" />
|
||||||
Overview
|
Overview
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="growth" className="flex items-center gap-2">
|
<TabsTrigger value="growth" className="flex items-center gap-2">
|
||||||
<TrendingUp className="h-4 w-4" />
|
<TrendingUp className="h-4 w-4" />
|
||||||
Growth Trends
|
Growth
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="comparisons" className="flex items-center gap-2">
|
<TabsTrigger value="comparisons" className="flex items-center gap-2">
|
||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Comparisons
|
Comparisons
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="contributors" className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Contributors
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="quality" className="flex items-center gap-2">
|
<TabsTrigger value="quality" className="flex items-center gap-2">
|
||||||
<Activity className="h-4 w-4" />
|
<Activity className="h-4 w-4" />
|
||||||
Data Quality
|
Quality
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="health" className="flex items-center gap-2">
|
<TabsTrigger value="health" className="flex items-center gap-2">
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
Health Checks
|
Health
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -201,6 +206,11 @@ export default function AdminDatabaseStats() {
|
|||||||
<EntityComparisonDashboard />
|
<EntityComparisonDashboard />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Contributors Tab */}
|
||||||
|
<TabsContent value="contributors" className="space-y-6">
|
||||||
|
<ContributorLeaderboard />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Data Quality Tab */}
|
{/* Data Quality Tab */}
|
||||||
<TabsContent value="quality" className="space-y-6">
|
<TabsContent value="quality" className="space-y-6">
|
||||||
<DataQualityOverview />
|
<DataQualityOverview />
|
||||||
|
|||||||
53
src/types/contributor.ts
Normal file
53
src/types/contributor.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Contributor leaderboard types
|
||||||
|
export type AchievementLevel = 'newcomer' | 'bronze' | 'silver' | 'gold' | 'platinum' | 'legend';
|
||||||
|
|
||||||
|
export type SpecialBadge =
|
||||||
|
| 'park_explorer'
|
||||||
|
| 'ride_master'
|
||||||
|
| 'photographer'
|
||||||
|
| 'critic'
|
||||||
|
| 'editor'
|
||||||
|
| 'completionist'
|
||||||
|
| 'veteran'
|
||||||
|
| 'top_contributor';
|
||||||
|
|
||||||
|
export interface ContributorStats {
|
||||||
|
parks_added: number;
|
||||||
|
rides_added: number;
|
||||||
|
companies_added: number;
|
||||||
|
models_added: number;
|
||||||
|
photos_added: number;
|
||||||
|
reviews_added: number;
|
||||||
|
edits_made: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TotalContributorStats {
|
||||||
|
total_parks: number;
|
||||||
|
total_rides: number;
|
||||||
|
total_photos: number;
|
||||||
|
total_reviews: number;
|
||||||
|
total_edits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardContributor {
|
||||||
|
rank: number;
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
join_date: string;
|
||||||
|
contribution_score: number;
|
||||||
|
total_score: number;
|
||||||
|
achievement_level: AchievementLevel;
|
||||||
|
special_badges: SpecialBadge[];
|
||||||
|
stats: ContributorStats;
|
||||||
|
total_stats: TotalContributorStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardData {
|
||||||
|
contributors: LeaderboardContributor[];
|
||||||
|
total_contributors: number;
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimePeriod = 'all_time' | 'month' | 'week';
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
-- Create function to get contributor leaderboard
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_contributor_leaderboard(
|
||||||
|
limit_count integer DEFAULT 50,
|
||||||
|
time_period text DEFAULT 'all_time' -- 'all_time', 'month', 'week'
|
||||||
|
)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
result jsonb;
|
||||||
|
date_filter timestamp;
|
||||||
|
BEGIN
|
||||||
|
-- Determine date filter based on time period
|
||||||
|
CASE time_period
|
||||||
|
WHEN 'week' THEN date_filter := NOW() - INTERVAL '7 days';
|
||||||
|
WHEN 'month' THEN date_filter := NOW() - INTERVAL '30 days';
|
||||||
|
ELSE date_filter := '1970-01-01'::timestamp;
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
WITH contributor_stats AS (
|
||||||
|
SELECT
|
||||||
|
p.user_id,
|
||||||
|
p.username,
|
||||||
|
p.display_name,
|
||||||
|
p.avatar_url,
|
||||||
|
p.created_at as join_date,
|
||||||
|
|
||||||
|
-- Count approved submissions by type
|
||||||
|
COALESCE(SUM(CASE WHEN es.entity_type = 'park' AND es.status = 'approved' AND es.created_at >= date_filter THEN 1 ELSE 0 END), 0) as parks_added,
|
||||||
|
COALESCE(SUM(CASE WHEN es.entity_type = 'ride' AND es.status = 'approved' AND es.created_at >= date_filter THEN 1 ELSE 0 END), 0) as rides_added,
|
||||||
|
COALESCE(SUM(CASE WHEN es.entity_type = 'company' AND es.status = 'approved' AND es.created_at >= date_filter THEN 1 ELSE 0 END), 0) as companies_added,
|
||||||
|
COALESCE(SUM(CASE WHEN es.entity_type = 'ride_model' AND es.status = 'approved' AND es.created_at >= date_filter THEN 1 ELSE 0 END), 0) as models_added,
|
||||||
|
|
||||||
|
-- Count photos
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM entity_photos ep
|
||||||
|
WHERE ep.uploaded_by = p.user_id
|
||||||
|
AND ep.status = 'approved'
|
||||||
|
AND ep.created_at >= date_filter
|
||||||
|
), 0) as photos_added,
|
||||||
|
|
||||||
|
-- Count reviews
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM reviews r
|
||||||
|
WHERE r.user_id = p.user_id
|
||||||
|
AND r.status = 'approved'
|
||||||
|
AND r.created_at >= date_filter
|
||||||
|
), 0) as reviews_added,
|
||||||
|
|
||||||
|
-- Count edits (from entity_history)
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM entity_history eh
|
||||||
|
WHERE eh.changed_by = p.user_id
|
||||||
|
AND eh.created_at >= date_filter
|
||||||
|
), 0) as edits_made,
|
||||||
|
|
||||||
|
-- All-time stats for achievements
|
||||||
|
COALESCE((SELECT COUNT(*) FROM entity_submissions WHERE user_id = p.user_id AND status = 'approved' AND entity_type = 'park'), 0) as total_parks,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM entity_submissions WHERE user_id = p.user_id AND status = 'approved' AND entity_type = 'ride'), 0) as total_rides,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM entity_photos WHERE uploaded_by = p.user_id AND status = 'approved'), 0) as total_photos,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM reviews WHERE user_id = p.user_id AND status = 'approved'), 0) as total_reviews,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM entity_history WHERE changed_by = p.user_id), 0) as total_edits
|
||||||
|
|
||||||
|
FROM profiles p
|
||||||
|
LEFT JOIN entity_submissions es ON es.user_id = p.user_id
|
||||||
|
WHERE p.user_id IS NOT NULL
|
||||||
|
GROUP BY p.user_id, p.username, p.display_name, p.avatar_url, p.created_at
|
||||||
|
),
|
||||||
|
|
||||||
|
scored_contributors AS (
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
-- Calculate contribution score (weighted)
|
||||||
|
(parks_added * 10) +
|
||||||
|
(rides_added * 8) +
|
||||||
|
(companies_added * 5) +
|
||||||
|
(models_added * 5) +
|
||||||
|
(photos_added * 2) +
|
||||||
|
(reviews_added * 3) +
|
||||||
|
(edits_made * 1) as contribution_score,
|
||||||
|
|
||||||
|
(total_parks * 10) +
|
||||||
|
(total_rides * 8) +
|
||||||
|
(total_photos * 2) +
|
||||||
|
(total_reviews * 3) +
|
||||||
|
(total_edits * 1) as total_score
|
||||||
|
|
||||||
|
FROM contributor_stats
|
||||||
|
),
|
||||||
|
|
||||||
|
ranked_contributors AS (
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY contribution_score DESC, total_score DESC, join_date ASC) as rank,
|
||||||
|
|
||||||
|
-- Determine achievement level
|
||||||
|
CASE
|
||||||
|
WHEN total_score >= 5000 THEN 'legend'
|
||||||
|
WHEN total_score >= 1000 THEN 'platinum'
|
||||||
|
WHEN total_score >= 500 THEN 'gold'
|
||||||
|
WHEN total_score >= 100 THEN 'silver'
|
||||||
|
WHEN total_score >= 10 THEN 'bronze'
|
||||||
|
ELSE 'newcomer'
|
||||||
|
END as achievement_level,
|
||||||
|
|
||||||
|
-- Calculate special badges
|
||||||
|
jsonb_build_array(
|
||||||
|
CASE WHEN total_parks >= 100 THEN 'park_explorer' END,
|
||||||
|
CASE WHEN total_rides >= 200 THEN 'ride_master' END,
|
||||||
|
CASE WHEN total_photos >= 500 THEN 'photographer' END,
|
||||||
|
CASE WHEN total_reviews >= 100 THEN 'critic' END,
|
||||||
|
CASE WHEN total_edits >= 500 THEN 'editor' END,
|
||||||
|
CASE WHEN total_parks >= 10 AND total_rides >= 50 AND total_photos >= 100 THEN 'completionist' END,
|
||||||
|
CASE WHEN EXTRACT(days FROM (NOW() - join_date)) >= 365 THEN 'veteran' END,
|
||||||
|
CASE WHEN rank = 1 THEN 'top_contributor' END
|
||||||
|
) - 'null'::jsonb as special_badges
|
||||||
|
|
||||||
|
FROM scored_contributors
|
||||||
|
WHERE contribution_score > 0 OR total_score > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT jsonb_build_object(
|
||||||
|
'contributors', (
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'rank', rank,
|
||||||
|
'user_id', user_id,
|
||||||
|
'username', username,
|
||||||
|
'display_name', display_name,
|
||||||
|
'avatar_url', avatar_url,
|
||||||
|
'join_date', join_date,
|
||||||
|
'contribution_score', contribution_score,
|
||||||
|
'total_score', total_score,
|
||||||
|
'achievement_level', achievement_level,
|
||||||
|
'special_badges', special_badges,
|
||||||
|
'stats', jsonb_build_object(
|
||||||
|
'parks_added', parks_added,
|
||||||
|
'rides_added', rides_added,
|
||||||
|
'companies_added', companies_added,
|
||||||
|
'models_added', models_added,
|
||||||
|
'photos_added', photos_added,
|
||||||
|
'reviews_added', reviews_added,
|
||||||
|
'edits_made', edits_made
|
||||||
|
),
|
||||||
|
'total_stats', jsonb_build_object(
|
||||||
|
'total_parks', total_parks,
|
||||||
|
'total_rides', total_rides,
|
||||||
|
'total_photos', total_photos,
|
||||||
|
'total_reviews', total_reviews,
|
||||||
|
'total_edits', total_edits
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY rank
|
||||||
|
)
|
||||||
|
FROM ranked_contributors
|
||||||
|
LIMIT limit_count
|
||||||
|
),
|
||||||
|
'total_contributors', (SELECT COUNT(*) FROM ranked_contributors),
|
||||||
|
'generated_at', NOW()
|
||||||
|
) INTO result;
|
||||||
|
|
||||||
|
RETURN result;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Grant execute permission to authenticated users
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_contributor_leaderboard(integer, text) TO authenticated;
|
||||||
Reference in New Issue
Block a user