diff --git a/app/api/bounties/[id]/milestones/advance/route.ts b/app/api/bounties/[id]/milestones/advance/route.ts index e9229b3..d37a0fb 100644 --- a/app/api/bounties/[id]/milestones/advance/route.ts +++ b/app/api/bounties/[id]/milestones/advance/route.ts @@ -33,7 +33,7 @@ export async function POST( return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); } - let updates: Partial = { + const updates: Partial = { lastUpdatedAt: new Date().toISOString() }; diff --git a/app/api/leaderboard/route.ts b/app/api/leaderboard/route.ts new file mode 100644 index 0000000..9b47e64 --- /dev/null +++ b/app/api/leaderboard/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { getMockLeaderboard } from '@/lib/mock-leaderboard'; +import { LeaderboardResponse, ReputationTier } from '@/types/leaderboard'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '10'); + const tier = searchParams.get('tier') as ReputationTier | null; + + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 300)); + + const { data, total } = getMockLeaderboard(page, limit, tier || undefined); + + const response: LeaderboardResponse = { + entries: data.map((contributor, index) => ({ + rank: (page - 1) * limit + index + 1, + previousRank: null, // Mock data doesn't track history yet + rankChange: 0, + contributor, + })), + totalCount: total, + currentUserRank: null, // Only relevant if user context is provided + lastUpdatedAt: new Date().toISOString(), + }; + + return NextResponse.json(response); +} diff --git a/app/api/leaderboard/top/route.ts b/app/api/leaderboard/top/route.ts new file mode 100644 index 0000000..5aa70b1 --- /dev/null +++ b/app/api/leaderboard/top/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { getMockLeaderboard } from '@/lib/mock-leaderboard'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const count = parseInt(searchParams.get('count') || '5'); + + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 200)); + + // Get top N contributors + const { data } = getMockLeaderboard(1, count); + + return NextResponse.json(data); +} diff --git a/app/api/leaderboard/user/[userId]/route.ts b/app/api/leaderboard/user/[userId]/route.ts new file mode 100644 index 0000000..1386335 --- /dev/null +++ b/app/api/leaderboard/user/[userId]/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { getMockUserRank } from '@/lib/mock-leaderboard'; + +interface Params { + params: Promise<{ userId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + // Await params as per Next.js 15+ requirements if applicable, or good practice for future + const { userId } = await params; + + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 200)); + + const result = getMockUserRank(userId); + + if (!result) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + return NextResponse.json({ + rank: result.rank, + contributor: result.contributor, + paramsUserId: userId + }); +} diff --git a/hooks/use-leaderboard.ts b/hooks/use-leaderboard.ts new file mode 100644 index 0000000..2532507 --- /dev/null +++ b/hooks/use-leaderboard.ts @@ -0,0 +1,64 @@ +import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { leaderboardApi } from '@/lib/api/leaderboard'; +import { LeaderboardFilters } from '@/types/leaderboard'; + +export const LEADERBOARD_KEYS = { + all: ['leaderboard'] as const, + lists: () => [...LEADERBOARD_KEYS.all, 'list'] as const, + list: (filters: LeaderboardFilters) => [...LEADERBOARD_KEYS.lists(), filters] as const, + user: (userId: string) => [...LEADERBOARD_KEYS.all, 'user', userId] as const, + top: (count: number) => [...LEADERBOARD_KEYS.all, 'top', count] as const, +}; + +export const useLeaderboard = (filters: LeaderboardFilters, limit: number = 20) => { + return useInfiniteQuery({ + queryKey: LEADERBOARD_KEYS.list(filters), + queryFn: ({ pageParam = 1 }) => + leaderboardApi.fetchLeaderboard(filters, { page: pageParam, limit }), + getNextPageParam: (lastPage, allPages) => { + const loadedCount = allPages.flatMap(p => p.entries).length; + if (loadedCount < lastPage.totalCount) { + return allPages.length + 1; + } + return undefined; + }, + initialPageParam: 1, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; + +export const useUserRank = (userId?: string) => { + return useQuery({ + queryKey: LEADERBOARD_KEYS.user(userId || ''), + queryFn: () => leaderboardApi.fetchUserRank(userId!), + enabled: !!userId, + }); +}; + +export const useTopContributors = (count: number = 5) => { + return useQuery({ + queryKey: LEADERBOARD_KEYS.top(count), + queryFn: () => leaderboardApi.fetchTopContributors(count), + staleTime: 1000 * 60 * 15, // 15 minutes + }); +}; + +export const usePrefetchLeaderboardPage = () => { + const queryClient = useQueryClient(); + + return (filters: LeaderboardFilters, page: number, limit: number) => { + queryClient.prefetchInfiniteQuery({ + queryKey: LEADERBOARD_KEYS.list(filters), + queryFn: ({ pageParam }) => leaderboardApi.fetchLeaderboard(filters, { page: pageParam as number, limit }), + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + const loadedCount = allPages.flatMap(p => p.entries).length; + if (loadedCount < lastPage.totalCount) { + return allPages.length + 1; + } + return undefined; + }, + pages: page // Prefetch up to this many pages if needed + }); + }; +}; diff --git a/lib/api/leaderboard.ts b/lib/api/leaderboard.ts new file mode 100644 index 0000000..51e17df --- /dev/null +++ b/lib/api/leaderboard.ts @@ -0,0 +1,34 @@ +import { get } from './client'; +import { + LeaderboardResponse, + LeaderboardFilters, + LeaderboardPagination, + LeaderboardContributor +} from '@/types/leaderboard'; + +const LEADERBOARD_ENDPOINT = '/api/leaderboard'; + +export const leaderboardApi = { + fetchLeaderboard: async ( + filters: LeaderboardFilters, + pagination: LeaderboardPagination + ): Promise => { + const params: Record = { + page: pagination.page, + limit: pagination.limit, + }; + + if (filters.tier) params.tier = filters.tier; + if (filters.tags?.length) params.tags = filters.tags.join(','); + + return get(LEADERBOARD_ENDPOINT, { params }); + }, + + fetchUserRank: async (userId: string): Promise<{ rank: number, contributor: LeaderboardContributor }> => { + return get<{ rank: number, contributor: LeaderboardContributor }>(`${LEADERBOARD_ENDPOINT}/user/${userId}`); + }, + + fetchTopContributors: async (count: number = 5): Promise => { + return get(`${LEADERBOARD_ENDPOINT}/top`, { params: { count } }); + } +}; diff --git a/lib/mock-leaderboard.ts b/lib/mock-leaderboard.ts new file mode 100644 index 0000000..7c93149 --- /dev/null +++ b/lib/mock-leaderboard.ts @@ -0,0 +1,74 @@ +import { LeaderboardContributor, ReputationTier } from "@/types/leaderboard"; + +const generateMockContributor = ( + id: string, + rank: number, + tier: ReputationTier, + score: number +): LeaderboardContributor => ({ + id: `contributor-${id}`, + userId: `user-${id}`, + walletAddress: `0x${Math.random().toString(16).substr(2, 40)}`, + displayName: `Contributor ${id}`, + avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${id}`, + totalScore: score, + tier, + stats: { + totalCompleted: Math.floor(Math.random() * 50) + 1, + totalEarnings: Math.floor(Math.random() * 10000) + 100, + earningsCurrency: "USDC", + completionRate: 0.85 + Math.random() * 0.15, + averageCompletionTime: Math.floor(Math.random() * 72) + 24, + currentStreak: Math.floor(Math.random() * 5), + longestStreak: Math.floor(Math.random() * 10) + 5, + }, + topTags: ["DeFi", "Smart Contracts", "Frontend", "Auditing"].sort(() => 0.5 - Math.random()).slice(0, 3), + lastActiveAt: new Date(Date.now() - Math.floor(Math.random() * 7) * 24 * 60 * 60 * 1000).toISOString(), +}); + +export const mockLeaderboardData: LeaderboardContributor[] = [ + generateMockContributor("1", 1, "LEGEND", 15000), + generateMockContributor("2", 2, "LEGEND", 14500), + generateMockContributor("3", 3, "EXPERT", 12000), + generateMockContributor("4", 4, "EXPERT", 11500), + generateMockContributor("5", 5, "ESTABLISHED", 9000), + generateMockContributor("6", 6, "ESTABLISHED", 8500), + generateMockContributor("7", 7, "CONTRIBUTOR", 5000), + generateMockContributor("8", 8, "CONTRIBUTOR", 4500), + generateMockContributor("9", 9, "NEWCOMER", 1000), + generateMockContributor("10", 10, "NEWCOMER", 800), + // Generate some more for pagination testing + ...Array.from({ length: 40 }).map((_, i) => + generateMockContributor(`${i + 11}`, i + 11, "NEWCOMER", 500 - i * 10) + ) +]; + +export const getMockLeaderboard = ( + page: number = 1, + limit: number = 10, + filterTier?: ReputationTier +) => { + let filtered = [...mockLeaderboardData]; + + if (filterTier) { + filtered = filtered.filter(c => c.tier === filterTier); + } + + const sorted = filtered.sort((a, b) => b.totalScore - a.totalScore); + const start = (page - 1) * limit; + const paginated = sorted.slice(start, start + limit); + + return { + data: paginated, + total: filtered.length + }; +}; + +export const getMockUserRank = (userId: string) => { + const index = mockLeaderboardData.findIndex(u => u.userId === userId); + if (index === -1) return null; + return { + rank: index + 1, + contributor: mockLeaderboardData[index] + }; +}; diff --git a/types/bounty.ts b/types/bounty.ts index 23b70ac..9e367ee 100644 --- a/types/bounty.ts +++ b/types/bounty.ts @@ -52,7 +52,7 @@ export interface Bounty { // Let's add them as optional to be safe and backward compatible with existing components. requirements?: string[] scope?: string - milestones?: any[] // Optional milestone definition + milestones?: unknown[] // Optional milestone definition } export type BountyStatus = Bounty['status'] diff --git a/types/leaderboard.ts b/types/leaderboard.ts new file mode 100644 index 0000000..0a4118c --- /dev/null +++ b/types/leaderboard.ts @@ -0,0 +1,59 @@ +export type ReputationTier = + | 'NEWCOMER' + | 'CONTRIBUTOR' + | 'ESTABLISHED' + | 'EXPERT' + | 'LEGEND'; + +export type LeaderboardTimeframe = + | 'ALL_TIME' + | 'THIS_MONTH' + | 'THIS_WEEK'; + +export interface ContributorStats { + totalCompleted: number; + totalEarnings: number; + earningsCurrency: string; + completionRate: number; + averageCompletionTime: number; // in hours + currentStreak: number; // consecutive days/weeks depending on logic, usually completion streak + longestStreak: number; +} + +export interface LeaderboardContributor { + id: string; + userId: string; + walletAddress: string | null; + displayName: string; + avatarUrl: string | null; + totalScore: number; + tier: ReputationTier; + stats: ContributorStats; + topTags: string[]; + lastActiveAt: string; // ISO Date string +} + +export interface LeaderboardEntry { + rank: number; + previousRank: number | null; + rankChange: number | null; + contributor: LeaderboardContributor; +} + +export interface LeaderboardResponse { + entries: LeaderboardEntry[]; + totalCount: number; + currentUserRank: number | null; + lastUpdatedAt: string; // ISO Date string +} + +export interface LeaderboardFilters { + timeframe: LeaderboardTimeframe; + tier?: ReputationTier; + tags?: string[]; +} + +export interface LeaderboardPagination { + page: number; + limit: number; +}