diff --git a/src/components/contributors/AchievementBadge.tsx b/src/components/contributors/AchievementBadge.tsx new file mode 100644 index 00000000..df9fbf9e --- /dev/null +++ b/src/components/contributors/AchievementBadge.tsx @@ -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 = { + legend: { + label: 'Legend', + color: 'bg-gradient-to-r from-purple-500 to-pink-500 text-white border-0', + icon: , + 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: , + 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: , + 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: , + 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: , + description: '10+ contribution points - Getting started!', + }, + newcomer: { + label: 'Newcomer', + color: 'bg-muted text-muted-foreground', + icon: , + description: 'Just getting started', + }, +}; + +const specialBadgeConfig: Record = { + park_explorer: { + label: 'Park Explorer', + icon: , + 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: , + 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: , + description: 'Uploaded 500+ photos', + color: 'bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/20', + }, + critic: { + label: 'Critic', + icon: , + description: 'Wrote 100+ reviews', + color: 'bg-orange-500/10 text-orange-700 dark:text-orange-400 border-orange-500/20', + }, + editor: { + label: 'Editor', + icon: , + 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: , + 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: , + 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: , + 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 ( + + + + + {config.icon} + {config.label} + + + +

{config.description}

+
+
+
+ ); +} + +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 ( + + + + + {config.icon} + {config.label} + + + +

{config.description}

+
+
+
+ ); +} diff --git a/src/components/contributors/ContributorLeaderboard.tsx b/src/components/contributors/ContributorLeaderboard.tsx new file mode 100644 index 00000000..ef259dd2 --- /dev/null +++ b/src/components/contributors/ContributorLeaderboard.tsx @@ -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('all_time'); + const [limit, setLimit] = useState(50); + + const { data, isLoading, error } = useContributorLeaderboard(limit, timePeriod); + + if (error) { + return ( + + + + Failed to load contributor leaderboard. Please try again later. + + + ); + } + + return ( +
+ {/* Header */} + + +
+
+ + + Contributor Leaderboard + + + Celebrating our amazing contributors who make ThrillWiki possible + +
+ + + {data?.total_contributors.toLocaleString() || 0} Contributors + +
+
+ +
+ {/* Time Period Filter */} +
+ + +
+ + {/* Limit Filter */} +
+ + +
+
+
+
+ + {/* Achievement Legend */} + + + Achievement Levels + + 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) + + + +
+ + + + + + +
+
+
+ + {/* Leaderboard */} + {isLoading ? ( +
+ {[...Array(10)].map((_, i) => ( + +
+ +
+ + + +
+
+
+ ))} +
+ ) : data?.contributors && data.contributors.length > 0 ? ( +
+ {data.contributors.map((contributor) => ( + + ))} +
+ ) : ( + + + +

No Contributors Yet

+

+ Be the first to contribute to ThrillWiki! +

+
+
+ )} +
+ ); +} + +function AchievementInfo({ level, points, color }: { level: string; points: string; color: string }) { + return ( +
+
+ +
+
{level}
+
{points} pts
+
+ ); +} diff --git a/src/components/contributors/LeaderboardEntry.tsx b/src/components/contributors/LeaderboardEntry.tsx new file mode 100644 index 00000000..39695a79 --- /dev/null +++ b/src/components/contributors/LeaderboardEntry.tsx @@ -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 ; + } + return null; + }; + + return ( + +
+ {/* Rank */} +
+ {getRankIcon(contributor.rank)} + + #{contributor.rank} + +
+ + {/* Avatar & Info */} +
+
+ + + + {(contributor.display_name || contributor.username).slice(0, 2).toUpperCase()} + + + +
+
+

+ {contributor.display_name || contributor.username} +

+ +
+ +
+ + + Joined {formatDistanceToNow(new Date(contributor.join_date), { addSuffix: true })} + +
+ + {/* Special Badges */} + {contributor.special_badges.length > 0 && ( +
+ {contributor.special_badges.map((badge) => ( + + ))} +
+ )} +
+
+ + {/* Stats Grid */} +
+ {showPeriodStats ? ( + <> + {periodStats.parks_added > 0 && ( + + )} + {periodStats.rides_added > 0 && ( + + )} + {periodStats.photos_added > 0 && ( + + )} + {periodStats.reviews_added > 0 && ( + + )} + {periodStats.edits_made > 0 && ( + + )} + + ) : ( + <> + {allTimeStats.total_parks > 0 && ( + + )} + {allTimeStats.total_rides > 0 && ( + + )} + {allTimeStats.total_photos > 0 && ( + + )} + {allTimeStats.total_reviews > 0 && ( + + )} + {allTimeStats.total_edits > 0 && ( + + )} + + )} +
+ + {/* Total Score */} +
+
+ + Contribution Score +
+ + {totalContributions.toLocaleString()} pts + +
+
+
+
+ ); +} + +function StatCard({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value.toLocaleString()}
+
+ ); +} diff --git a/src/hooks/useContributorLeaderboard.ts b/src/hooks/useContributorLeaderboard.ts new file mode 100644 index 00000000..847af302 --- /dev/null +++ b/src/hooks/useContributorLeaderboard.ts @@ -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 + }); +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index a7a7afbb..2eaba647 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -6829,6 +6829,10 @@ export type Database = { } generate_ticket_number: { 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_database_statistics: { Args: never; Returns: Json } get_email_change_status: { Args: never; Returns: Json } diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index a0e9526e..4b6183b7 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -111,5 +111,7 @@ export const queryKeys = { growthTrends: (days: number, granularity: string) => ['analytics', 'growth-trends', days, granularity] as const, entityComparisons: () => ['analytics', 'entity-comparisons'] as const, databaseHealth: () => ['analytics', 'database-health'] as const, + contributorLeaderboard: (limit: number, timePeriod: string) => + ['analytics', 'contributor-leaderboard', limit, timePeriod] as const, }, } as const; diff --git a/src/pages/AdminDatabaseStats.tsx b/src/pages/AdminDatabaseStats.tsx index b2165aa6..7c5b0342 100644 --- a/src/pages/AdminDatabaseStats.tsx +++ b/src/pages/AdminDatabaseStats.tsx @@ -7,6 +7,7 @@ import { DataQualityOverview } from '@/components/admin/database-stats/DataQuali import { GrowthTrendsChart } from '@/components/admin/database-stats/GrowthTrendsChart'; import { EntityComparisonDashboard } from '@/components/admin/database-stats/EntityComparisonDashboard'; import { DatabaseHealthDashboard } from '@/components/admin/database-stats/DatabaseHealthDashboard'; +import { ContributorLeaderboard } from '@/components/contributors/ContributorLeaderboard'; import { useAdminDatabaseStats } from '@/hooks/useAdminDatabaseStats'; import { useRecentAdditions } from '@/hooks/useRecentAdditions'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -68,26 +69,30 @@ export default function AdminDatabaseStats() { - + Overview - Growth Trends + Growth Comparisons + + + Contributors + - Data Quality + Quality - Health Checks + Health @@ -201,6 +206,11 @@ export default function AdminDatabaseStats() { + {/* Contributors Tab */} + + + + {/* Data Quality Tab */} diff --git a/src/types/contributor.ts b/src/types/contributor.ts new file mode 100644 index 00000000..98684c3f --- /dev/null +++ b/src/types/contributor.ts @@ -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'; diff --git a/supabase/migrations/20251111174845_5fa6aecb-2b86-4e69-b886-1f2d55bf5105.sql b/supabase/migrations/20251111174845_5fa6aecb-2b86-4e69-b886-1f2d55bf5105.sql new file mode 100644 index 00000000..3c34ac9d --- /dev/null +++ b/supabase/migrations/20251111174845_5fa6aecb-2b86-4e69-b886-1f2d55bf5105.sql @@ -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; \ No newline at end of file