From e0dd45c4cd6efb12094aa901ea0f9209c8c7e652 Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:51:55 +0000 Subject: [PATCH 01/13] feat(api): add type definitions for StreamForge API endpoints --- src/types/api.ts | 150 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 src/types/api.ts diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..29cbd36 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,150 @@ +/** + * StreamForge API Type Definitions + * + * This file defines the TypeScript interfaces for the StreamForge API. + * These types ensure type safety when communicating with the backend. + */ + +/** + * Represents a contributor in the StreamForge system + */ +export interface Contributor { + /** GitHub username */ + username: string; + /** GitHub avatar URL */ + avatar: string; + /** Total contribution score */ + score: number; + /** Number of contributions (PRs, commits, reviews) */ + contributionsCount: number; + /** Total rewards earned in USDC (cents) */ + totalRewardsCents: number; + /** Number of unclaimed rewards in USDC (cents) */ + unclaimedRewardsCents: number; +} + +/** + * Represents project summary statistics + */ +export interface ProjectSummary { + /** Project name */ + name: string; + /** Total number of contributors */ + totalContributors: number; + /** Total rewards paid in USDC (cents) */ + totalRewardsPaidCents: number; + /** Total number of contributions */ + totalContributions: number; +} + +/** + * Type of activity event + */ +export type ActivityEventType = 'pr_merged' | 'commit' | 'review' | 'reward_claimed'; + +/** + * Represents a recent activity event + */ +export interface ActivityEvent { + /** Unique event identifier */ + id: string; + /** Type of event */ + type: ActivityEventType; + /** Contributor who performed the action */ + contributor: { + username: string; + avatar: string; + }; + /** Event timestamp (ISO 8601) */ + timestamp: string; + /** Event description/message */ + description: string; + /** Related metadata (PR number, commit sha, etc.) */ + metadata?: { + prNumber?: number; + commitSha?: string; + rewardAmount?: number; + }; +} + +/** + * Response from the leaderboard API endpoint + */ +export interface LeaderboardResponse { + /** List of contributors ranked by score */ + contributors: Contributor[]; + /** Current page number */ + page: number; + /** Total number of pages */ + totalPages: number; + /** Total number of contributors */ + total: number; +} + +/** + * Response from the project summary API endpoint + */ +export type ProjectSummaryResponse = ProjectSummary; + +/** + * Response from the activity feed API endpoint + */ +export interface ActivityFeedResponse { + /** List of recent activity events */ + events: ActivityEvent[]; + /** Current page number */ + page: number; + /** Total number of pages */ + totalPages: number; +} + +/** + * Response from the pending rewards API endpoint + */ +export interface PendingRewardsResponse { + /** Contributors with pending rewards */ + contributors: Array<{ + username: string; + avatar: string; + pendingRewardsCents: number; + }>; + /** Total pending rewards in USDC (cents) */ + totalPendingCents: number; +} + +/** + * API error response + */ +export interface ApiError { + /** Error message */ + message: string; + /** HTTP status code */ + code: number; + /** Additional error details */ + details?: unknown; +} + +/** + * Helper to format USDC from cents to dollars + */ +export function formatUSDC(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +/** + * Helper to format relative time (e.g., "2 hours ago") + */ +export function formatRelativeTime(timestamp: string): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + return date.toLocaleDateString(); +} From 2cc34f4de2f1c97be350a1056805502cbc99da71 Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:52:28 +0000 Subject: [PATCH 02/13] feat(api): implement StreamForge API client with fetch wrapper --- src/lib/api-client.ts | 141 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/lib/api-client.ts diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..cdebd54 --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,141 @@ +/** + * StreamForge API Client + * + * Provides typed methods for communicating with the StreamForge backend API. + * Configure via NEXT_PUBLIC_API_URL environment variable. + */ + +import type { + Contributor, + ProjectSummaryResponse, + LeaderboardResponse, + ActivityFeedResponse, + PendingRewardsResponse, + ApiError, +} from '@/types/api'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +/** + * Create an API error from a failed response + */ +async function createApiError(response: Response): Promise { + let message = 'Failed to fetch data from StreamForge API'; + try { + const data = await response.json(); + message = data.message || message; + } catch { + // JSON parse failed, use default message + } + return { + message, + code: response.status, + }; +} + +/** + * Handle API response with proper error handling + */ +async function handleResponse(response: Response): Promise { + if (!response.ok) { + throw await createApiError(response); + } + return response.json() as Promise; +} + +/** + * StreamForge API Client + * + * All methods return typed responses or throw ApiError on failure. + * Components should handle loading, empty, and error states appropriately. + */ +export const apiClient = { + /** + * Fetch project summary statistics + * + * GET /api/project/summary + */ + async getProjectSummary(): Promise { + const response = await fetch(`${API_URL}/api/project/summary`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + }); + return handleResponse(response); + }, + + /** + * Fetch contributor leaderboard + * + * GET /api/contributors?page=1&limit=10 + */ + async getLeaderboard( + page: number = 1, + limit: number = 10 + ): Promise { + const params = new URLSearchParams({ + page: String(page), + limit: String(limit), + }); + const response = await fetch(`${API_URL}/api/contributors?${params}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + }); + return handleResponse(response); + }, + + /** + * Fetch recent activity feed + * + * GET /api/activity?page=1&limit=20 + */ + async getActivityFeed( + page: number = 1, + limit: number = 20 + ): Promise { + const params = new URLSearchParams({ + page: String(page), + limit: String(limit), + }); + const response = await fetch(`${API_URL}/api/activity?${params}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + }); + return handleResponse(response); + }, + + /** + * Fetch contributors with pending rewards + * + * GET /api/rewards/pending + */ + async getPendingRewards(): Promise { + const response = await fetch(`${API_URL}/api/rewards/pending`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + }); + return handleResponse(response); + }, + + /** + * Fetch top contributors by score + * + * GET /api/contributors/top?limit=5 + */ + async getTopContributors(limit: number = 5): Promise { + const params = new URLSearchParams({ + limit: String(limit), + }); + const response = await fetch(`${API_URL}/api/contributors/top?${params}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + }); + return handleResponse(response); + }, +}; + +export default apiClient; From 8a26f7fb878771ef4dbfe6f1005de90f28a70bb5 Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:52:48 +0000 Subject: [PATCH 03/13] feat(ui): add Card component with title and footer support --- src/components/ui/card.tsx | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/components/ui/card.tsx diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..321b7c6 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,33 @@ +import { type ReactNode } from 'react'; + +export interface CardProps { + children: ReactNode; + title?: string; + footer?: ReactNode; + className?: string; +} + +export function Card({ + children, + title, + footer, + className = '', +}: CardProps) { + return ( +
+ {title && ( +
+

{title}

+
+ )} +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+ ); +} From 127e4656fac84b26317cc12cc0d0fe3c97e43126 Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:53:00 +0000 Subject: [PATCH 04/13] feat(ui): add Button component with variants and loading state --- src/components/ui/button.tsx | 69 ++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/components/ui/button.tsx diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..21a626a --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,69 @@ +import { type ButtonHTMLAttributes, type ReactNode } from 'react'; + +export interface ButtonProps extends ButtonHTMLAttributes { + children: ReactNode; + variant?: 'primary' | 'secondary' | 'outline'; + size?: 'sm' | 'md' | 'lg'; + isLoading?: boolean; + className?: string; +} + +export function Button({ + children, + variant = 'primary', + size = 'md', + isLoading = false, + disabled, + className = '', + ...rest +}: ButtonProps) { + const baseClasses = + 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'; + + const variantClasses = { + primary: + 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600', + secondary: + 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600', + outline: + 'bg-transparent text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-gray-500 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-800', + }; + + const sizeClasses = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-lg', + }; + + return ( + + ); +} From 404a9cca54749ae26d97a082ba7a291f0502bc0e Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:53:15 +0000 Subject: [PATCH 05/13] feat(ui): add Badge component with rank variants --- src/components/ui/badge.tsx | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/components/ui/badge.tsx diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..ed27a8d --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,45 @@ +import { type ReactNode } from 'react'; + +export interface BadgeProps { + children: ReactNode; + variant?: 'default' | 'success' | 'warning' | 'gold' | 'silver' | 'bronze'; + size?: 'sm' | 'md'; + className?: string; +} + +export function Badge({ + children, + variant = 'default', + size = 'md', + className = '', +}: BadgeProps) { + const baseClasses = 'inline-flex items-center justify-center rounded-full font-semibold'; + + const variantClasses = { + default: + 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200', + success: + 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + warning: + 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + gold: + 'bg-yellow-400 text-yellow-900 dark:bg-yellow-500 dark:text-yellow-100', + silver: + 'bg-gray-300 text-gray-700 dark:bg-gray-600 dark:text-gray-200', + bronze: + 'bg-orange-300 text-orange-900 dark:bg-orange-700 dark:text-orange-100', + }; + + const sizeClasses = { + sm: 'px-2 py-0.5 text-xs', + md: 'px-3 py-1 text-sm', + }; + + return ( + + {children} + + ); +} From 2eb3cc24adc3fdb9df352518aadb7be724d1968a Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:53:22 +0000 Subject: [PATCH 06/13] feat(ui): add Avatar component with fallback initials --- src/components/ui/avatar.tsx | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/components/ui/avatar.tsx diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..4a3a998 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,65 @@ +import React, { type HTMLAttributes } from 'react'; + +export interface AvatarProps extends HTMLAttributes { + src: string; + alt: string; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + className?: string; +} + +function getInitials(name: string): string { + return name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2); +} + +export function getGitHubAvatarUrl(username: string): string { + return `https://github.com/${username}.png`; +} + +export function Avatar({ + src, + alt, + size = 'md', + className = '', + ...rest +}: AvatarProps) { + const sizeClasses = { + xs: 'h-6 w-6 text-xs', + sm: 'h-8 w-8 text-sm', + md: 'h-10 w-10 text-base', + lg: 'h-12 w-12 text-lg', + xl: 'h-16 w-16 text-xl', + }; + + const [imageError, setImageError] = React.useState(false); + + React.useEffect(() => { + setImageError(false); + }, [src]); + + return ( +
+ {imageError ? ( +
+ {getInitials(alt)} +
+ ) : ( + /* eslint-disable-next-line @next/next/no-img-element */ + {alt} setImageError(true)} + {...rest} + /> + )} +
+ ); +} From 9123f0cda0403c8f13b0bcae74fe6c5305fb4f0b Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:53:35 +0000 Subject: [PATCH 07/13] feat(dashboard): add Project Summary Card component with metrics --- .../dashboard/project-summary-card.tsx | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/components/dashboard/project-summary-card.tsx diff --git a/src/components/dashboard/project-summary-card.tsx b/src/components/dashboard/project-summary-card.tsx new file mode 100644 index 0000000..af2e868 --- /dev/null +++ b/src/components/dashboard/project-summary-card.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card } from '@/components/ui/card'; +import { apiClient } from '@/lib/api-client'; +import type { ProjectSummaryResponse } from '@/types/api'; + +export function ProjectSummaryCard() { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchData() { + try { + setIsLoading(true); + setError(null); + const summary = await apiClient.getProjectSummary(); + setData(summary); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load project summary'); + console.error('Error fetching project summary:', err); + } finally { + setIsLoading(false); + } + } + + fetchData(); + }, []); + + if (isLoading) { + return ( + +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+
+ ); + } + + if (error) { + return ( + +
+

Error loading data

+

{error}

+ +
+
+ ); + } + + if (!data) { + return ( + +
+

No project data available

+
+
+ ); + } + + return ( + +
+

{data.name}

+

Project Overview

+
+ +
+ + + + +
+
+ ); +} + +interface MetricCardProps { + label: string; + value: string | number; + icon: string; +} + +function MetricCard({ label, value, icon }: MetricCardProps) { + return ( +
+
+ {icon} +
+

+ {value} +

+

+ {label} +

+
+ ); +} From f0a7a47a0d5d7bc3868fc493f06bb5d12f18b9a3 Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:53:43 +0000 Subject: [PATCH 08/13] feat(dashboard): add Activity Feed component with event types --- src/components/dashboard/activity-feed.tsx | 139 +++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/components/dashboard/activity-feed.tsx diff --git a/src/components/dashboard/activity-feed.tsx b/src/components/dashboard/activity-feed.tsx new file mode 100644 index 0000000..e7b2dca --- /dev/null +++ b/src/components/dashboard/activity-feed.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card } from '@/components/ui/card'; +import { Avatar } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { apiClient } from '@/lib/api-client'; +import { formatRelativeTime } from '@/types/api'; +import type { ActivityEvent, ActivityFeedResponse } from '@/types/api'; + +export function ActivityFeed() { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchData() { + try { + setIsLoading(true); + setError(null); + const feed = await apiClient.getActivityFeed(1, 10); + setData(feed); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load activity feed'); + console.error('Error fetching activity feed:', err); + } finally { + setIsLoading(false); + } + } + + fetchData(); + }, []); + + if (isLoading) { + return ( + +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + if (error) { + return ( + +
+

{error}

+
+
+ ); + } + + if (!data || data.events.length === 0) { + return ( + +
+

No recent activity

+
+
+ ); + } + + return ( + 1 && ( + + ) + } + > +
+ {data.events.map((event) => ( + + ))} +
+
+ ); +} + +interface ActivityItemProps { + event: ActivityEvent; +} + +function ActivityItem({ event }: ActivityItemProps) { + const eventConfig = getEventConfig(event.type); + + return ( +
+ +
+
+ + {event.contributor.username} + + + {eventConfig.label} + +
+

+ {event.description} +

+

+ {formatRelativeTime(event.timestamp)} +

+
+
+ ); +} + +function getEventConfig(type: ActivityEvent['type']) { + switch (type) { + case 'pr_merged': + return { label: 'PR Merged', badgeVariant: 'success' as const }; + case 'commit': + return { label: 'Commit', badgeVariant: 'default' as const }; + case 'review': + return { label: 'Review', badgeVariant: 'default' as const }; + case 'reward_claimed': + return { label: 'Reward', badgeVariant: 'gold' as const }; + default: + return { label: 'Activity', badgeVariant: 'default' as const }; + } +} From 2067ff846df22a83c1bc592c2c2df5110d7cca36 Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:53:47 +0000 Subject: [PATCH 09/13] feat(dashboard): add Top Contributors list with medals --- src/components/dashboard/top-contributors.tsx | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/components/dashboard/top-contributors.tsx diff --git a/src/components/dashboard/top-contributors.tsx b/src/components/dashboard/top-contributors.tsx new file mode 100644 index 0000000..fd7593e --- /dev/null +++ b/src/components/dashboard/top-contributors.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card } from '@/components/ui/card'; +import { Avatar } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { apiClient } from '@/lib/api-client'; +import type { Contributor } from '@/types/api'; + +export function TopContributors() { + const [contributors, setContributors] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchData() { + try { + setIsLoading(true); + setError(null); + const data = await apiClient.getTopContributors(5); + setContributors(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load top contributors'); + console.error('Error fetching top contributors:', err); + } finally { + setIsLoading(false); + } + } + + fetchData(); + }, []); + + if (isLoading) { + return ( + +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + if (error) { + return ( + +
+

{error}

+
+
+ ); + } + + if (contributors.length === 0) { + return ( + +
+

No contributors yet

+
+
+ ); + } + + return ( + +
+ {contributors.map((contributor, index) => ( + + ))} +
+
+ ); +} + +interface ContributorRowProps { + contributor: Contributor; + rank: number; +} + +function ContributorRow({ contributor, rank }: ContributorRowProps) { + const badgeVariant = getRankBadgeVariant(rank); + const medal = getRankMedal(rank); + + return ( +
+
+ {medal || ( + + {rank} + + )} +
+ +
+

+ {contributor.username} +

+

+ {contributor.contributionsCount} contributions +

+
+
+

+ {contributor.score.toLocaleString()} +

+

points

+
+
+ ); +} + +function getRankBadgeVariant(rank: number): 'gold' | 'silver' | 'bronze' | 'default' { + if (rank === 1) return 'gold'; + if (rank === 2) return 'silver'; + if (rank === 3) return 'bronze'; + return 'default'; +} + +function getRankMedal(rank: number): string | null { + if (rank === 1) return '🥇'; + if (rank === 2) return '🥈'; + if (rank === 3) return '🥉'; + return null; +} From 9225384b815b279c6d3f1cb6b4c30b686b410147 Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:53:52 +0000 Subject: [PATCH 10/13] feat(dashboard): add Pending Rewards panel with claim action --- src/components/dashboard/pending-rewards.tsx | 147 +++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/components/dashboard/pending-rewards.tsx diff --git a/src/components/dashboard/pending-rewards.tsx b/src/components/dashboard/pending-rewards.tsx new file mode 100644 index 0000000..85ed4a1 --- /dev/null +++ b/src/components/dashboard/pending-rewards.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card } from '@/components/ui/card'; +import { Avatar } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { apiClient } from '@/lib/api-client'; +import type { PendingRewardsResponse } from '@/types/api'; + +export function PendingRewards() { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [claimingUser, setClaimingUser] = useState(null); + + useEffect(() => { + async function fetchData() { + try { + setIsLoading(true); + setError(null); + const rewards = await apiClient.getPendingRewards(); + setData(rewards); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load pending rewards'); + console.error('Error fetching pending rewards:', err); + } finally { + setIsLoading(false); + } + } + + fetchData(); + }, []); + + const handleClaim = async (username: string) => { + setClaimingUser(username); + // TODO: Implement claim logic when API endpoint is available + setTimeout(() => { + setClaimingUser(null); + }, 1000); + }; + + if (isLoading) { + return ( + +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + if (error) { + return ( + +
+

{error}

+
+
+ ); + } + + if (!data || data.contributors.length === 0) { + return ( + +
+

No pending rewards

+
+
+ ); + } + + return ( + + + Total pending: ${(data.totalPendingCents / 100).toFixed(2)} + + {data.contributors.length} pending + + } + > +
+ {data.contributors.map((contributor) => ( + handleClaim(contributor.username)} + /> + ))} +
+
+ ); +} + +interface RewardRowProps { + contributor: { + username: string; + avatar: string; + pendingRewardsCents: number; + }; + isClaiming: boolean; + onClaim: () => void; +} + +function RewardRow({ contributor, isClaiming, onClaim }: RewardRowProps) { + return ( +
+ +
+

+ {contributor.username} +

+

+ ${(contributor.pendingRewardsCents / 100).toFixed(2)} USDC +

+
+ + Pending + + +
+ ); +} From 87b6a1de8c7593cc214ce21e0d1fb82ac30a9ef7 Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:53:56 +0000 Subject: [PATCH 11/13] feat(dashboard): add Contributor Leaderboard with pagination --- .../dashboard/contributor-leaderboard.tsx | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 src/components/dashboard/contributor-leaderboard.tsx diff --git a/src/components/dashboard/contributor-leaderboard.tsx b/src/components/dashboard/contributor-leaderboard.tsx new file mode 100644 index 0000000..2736e16 --- /dev/null +++ b/src/components/dashboard/contributor-leaderboard.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card } from '@/components/ui/card'; +import { Avatar } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { apiClient } from '@/lib/api-client'; +import type { Contributor, LeaderboardResponse } from '@/types/api'; + +export function ContributorLeaderboard({ + initialData, + pageSize = 10, +}: { + initialData?: LeaderboardResponse; + pageSize?: number; +} = {}) { + const [data, setData] = useState(initialData || null); + const [isLoading, setIsLoading] = useState(!initialData); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(initialData ? 1 : 0); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + useEffect(() => { + if (initialData) return; + + async function fetchData() { + try { + setIsLoading(true); + setError(null); + const leaderboard = await apiClient.getLeaderboard(1, pageSize); + setData(leaderboard); + setCurrentPage(1); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load leaderboard'); + console.error('Error fetching leaderboard:', err); + } finally { + setIsLoading(false); + } + } + + fetchData(); + }, [initialData, pageSize]); + + const handleLoadMore = async () => { + if (!data || isLoadingMore) return; + + try { + setIsLoadingMore(true); + const nextPage = currentPage + 1; + const moreData = await apiClient.getLeaderboard(nextPage, pageSize); + + setData({ + ...moreData, + contributors: [...data.contributors, ...moreData.contributors], + }); + setCurrentPage(nextPage); + } catch (err) { + console.error('Error loading more contributors:', err); + } finally { + setIsLoadingMore(false); + } + }; + + if (isLoading) { + return ( + +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + if (error) { + return ( + +
+

Error loading data

+

{error}

+ +
+
+ ); + } + + if (!data || data.contributors.length === 0) { + return ( + +
+

No contributors yet

+
+
+ ); + } + + const hasMore = currentPage < data.totalPages; + + return ( + +
+ {/* Desktop table view */} +
+ + + + + + + + + + + + {data.contributors.map((contributor, index) => ( + + ))} + +
RankContributorScoreContributionsRewards
+
+ + {/* Mobile card view */} +
+ {data.contributors.map((contributor, index) => ( + + ))} +
+ + {/* Load more button */} + {hasMore && ( +
+ +
+ )} +
+
+ ); +} + +interface ContributorRowProps { + contributor: Contributor; + rank: number; +} + +function ContributorRow({ contributor, rank }: ContributorRowProps) { + const badgeVariant = getRankBadgeVariant(rank); + + return ( + + + + {rank} + + + +
+ + + {contributor.username} + +
+ + + {contributor.score.toLocaleString()} + + + {contributor.contributionsCount} + + + ${(contributor.totalRewardsCents / 100).toFixed(2)} + + + ); +} + +function ContributorCard({ contributor, rank }: ContributorRowProps) { + const badgeVariant = getRankBadgeVariant(rank); + + return ( +
+
+ +
+
+ + {contributor.username} + + + #{rank} + +
+
+
+
+
+

{contributor.score.toLocaleString()}

+

Score

+
+
+

{contributor.contributionsCount}

+

Contrib

+
+
+

${(contributor.totalRewardsCents / 100).toFixed(2)}

+

Rewards

+
+
+
+ ); +} + +function getRankBadgeVariant(rank: number): 'gold' | 'silver' | 'bronze' | 'default' { + if (rank === 1) return 'gold'; + if (rank === 2) return 'silver'; + if (rank === 3) return 'bronze'; + return 'default'; +} From f0819c26305296e519f646abe90bb43124b10946 Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 11:54:09 +0000 Subject: [PATCH 12/13] feat(dashboard): create dashboard route with all sections --- src/app/dashboard/page.tsx | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/app/dashboard/page.tsx diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..6978629 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,62 @@ +import { ProjectSummaryCard } from '@/components/dashboard/project-summary-card'; +import { ActivityFeed } from '@/components/dashboard/activity-feed'; +import { TopContributors } from '@/components/dashboard/top-contributors'; +import { PendingRewards } from '@/components/dashboard/pending-rewards'; +import { ContributorLeaderboard } from '@/components/dashboard/contributor-leaderboard'; + +export const metadata = { + title: 'Dashboard - StreamForge', + description: 'Project overview and contributor leaderboard', +}; + +export default function DashboardPage() { + return ( +
+ {/* Header */} +
+
+
+
+

+ Dashboard +

+

+ Project overview and contributor activity +

+
+
+
+
+ + {/* Main Content */} +
+
+ {/* Project Summary */} +
+ +
+ + {/* Two Column Layout */} +
+
+ +
+
+ +
+
+ + {/* Pending Rewards */} +
+ +
+ + {/* Full Leaderboard */} +
+ +
+
+
+
+ ); +} From 79bd908b065468e5e932d95121600822a722d92f Mon Sep 17 00:00:00 2001 From: Manak Date: Sun, 15 Mar 2026 12:00:33 +0000 Subject: [PATCH 13/13] chore: remove unused CSS variables and use system fonts - Remove unused CSS variables for background/foreground colors - Use Tailwind's native dark mode with system fonts - Clean up globals.css to only include essential styles - Add missing newline to layout.tsx --- src/app/globals.css | 23 +---------------------- src/app/layout.tsx | 2 +- 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..8824bf1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,5 @@ @import "tailwindcss"; -:root { - --background: #ffffff; - --foreground: #171717; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 09763d9..5cfb2b3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,4 +15,4 @@ export default function RootLayout({ {children} ); -} \ No newline at end of file +}