Add badge system based on user impact scores

- Created badge utility with 5 levels (Beginner, Intermediate, Advanced, Expert, Master)
- Badge thresholds: 0, 50, 100, 150, 200 points
- Implemented BadgeDisplay component with progress tracking
- Enhanced user profile statistics boxes with centered layout, gradients, and icons
- Added badge progress section showing advancement to next level
- Badge images reference existing assets in /public folder
This commit is contained in:
2025-11-18 17:51:30 +00:00
parent 0e05769581
commit 8448d001d2
5 changed files with 367 additions and 12 deletions

View File

@@ -0,0 +1,119 @@
# Badge System Implementation
## Overview
Implemented a badge achievement system that rewards users based on their impact score. Users earn badges as they contribute to the platform through reports and event participation.
## Badge Levels
| Badge | Min Score | Max Score | Image | Description |
|-------|-----------|-----------|-------|-------------|
| Beginner | 0 | 49 | `/Beginner.png` | Starting your environmental journey |
| Intermediate | 50 | 99 | `/Intermediate.png` | Making a real difference |
| Advanced | 100 | 149 | `/Advanced.png` | Dedicated environmental champion |
| Expert | 150 | 199 | `/Expert.png` | Leading the cleanup movement |
| Master | 200+ | ∞ | `/Master.png` | Elite environmental guardian |
## Impact Score Calculation
The impact score is calculated based on user activities:
- **Reports submitted**: 10 points each
- **Events attended**: 20 points each
- **Events organized (completed)**: 50 points each
Formula: `(reports × 10) + (events attended × 20) + (events organized × 50)`
## Implementation Details
### Files Created
1. **`src/utils/badges.ts`**
- Badge type definitions and constants
- `getBadgeForScore()` - Returns badge for a given score
- `getNextBadgeProgress()` - Calculates progress to next badge level
2. **`src/components/auth/BadgeDisplay.tsx`**
- Reusable badge display component
- Shows current badge with image and description
- Optional progress bar to next badge level
- Three size variants: small, medium, large
### Files Modified
1. **`src/pages/UserProfilePage.tsx`**
- Added badge display in statistics section (small size)
- Added dedicated badge progress section (large size with progress bar)
- Shows current badge, description, and progress to next level
2. **`src/components/auth/index.ts`**
- Exported `BadgeDisplay` component
## Features
### Badge Display Component
```typescript
<BadgeDisplay
impactScore={score}
showProgress={true}
size="large"
/>
```
**Props:**
- `impactScore` (number, required) - User's current impact score
- `showProgress` (boolean, optional) - Show progress bar to next badge
- `size` ('small' | 'medium' | 'large', optional) - Badge image size
### Progress Tracking
- Visual progress bar showing advancement to next badge
- Points remaining to next level
- Congratulatory message for Master level achievers
### Visual Design
- Color-coded badges with gradient backgrounds
- Responsive layout that works on all screen sizes
- Smooth animations for progress bars
- Badge images from `/public` folder
## User Profile Integration
The badge system is integrated into the user profile page in two locations:
1. **Statistics Grid** - Small badge display alongside key metrics
2. **Achievement Section** - Large badge with full progress details
## Badge Images
Badge images are stored in the `/public` folder:
- `Beginner.png`
- `Intermediate.png`
- `Advanced.png`
- `Expert.png`
- `Master.png`
These images are already present in the project and are referenced by the badge system.
## Future Enhancements
Potential improvements for the badge system:
- Badge unlock notifications
- Badge showcase on user profile
- Special badges for milestones (e.g., "First Report", "Event Organizer")
- Leaderboard showing top badge holders
- Social sharing of badge achievements
- Badge history timeline
## Testing
To test the badge system:
1. Navigate to user profile page
2. View current badge based on impact score
3. Check progress bar showing advancement to next level
4. Verify badge images load correctly
5. Test responsive design on different screen sizes
## Notes
- Badge levels are automatically calculated based on impact score
- Progress is shown as a percentage and points remaining
- Master badge holders see a special "Maximum Level Achieved" message
- The system is fully type-safe with TypeScript

View File

@@ -0,0 +1,69 @@
import { getBadgeForScore, getNextBadgeProgress } from '@/utils/badges';
interface BadgeDisplayProps {
impactScore: number;
showProgress?: boolean;
size?: 'small' | 'medium' | 'large';
}
/**
* Display user's badge based on impact score
*/
export function BadgeDisplay({ impactScore, showProgress = false, size = 'medium' }: BadgeDisplayProps) {
const { currentBadge, nextBadge, progress, pointsToNext } = getNextBadgeProgress(impactScore);
const sizeClasses = {
small: 'h-12 w-12',
medium: 'h-20 w-20',
large: 'h-32 w-32',
};
return (
<div className="flex flex-col items-center space-y-3">
{/* Badge Image */}
<div className="relative">
<img
src={currentBadge.image}
alt={`${currentBadge.level} Badge`}
className={`${sizeClasses[size]} object-contain`}
/>
</div>
{/* Badge Info */}
<div className="text-center">
<span className={`inline-block px-3 py-1 rounded-full text-sm font-semibold ${currentBadge.color}`}>
{currentBadge.level}
</span>
<p className="text-xs text-gray-600 mt-1">{currentBadge.description}</p>
</div>
{/* Progress to Next Badge */}
{showProgress && nextBadge && (
<div className="w-full max-w-xs">
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span>Progress to {nextBadge.level}</span>
<span>{pointsToNext} points to go</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-gradient-to-r from-blue-500 to-purple-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>{currentBadge.minScore}</span>
<span>{nextBadge.minScore}</span>
</div>
</div>
)}
{/* Master Badge - No Progress */}
{showProgress && !nextBadge && (
<div className="text-center">
<p className="text-sm font-semibold text-yellow-600">🏆 Maximum Level Achieved!</p>
<p className="text-xs text-gray-600">You're an environmental legend</p>
</div>
)}
</div>
);
}

View File

@@ -3,4 +3,5 @@ export { UserProfile } from './UserProfile';
export { SignInPage } from './SignInPage';
export { SignUpPage } from './SignUpPage';
export { NotificationSettings } from './NotificationSettings';
export { BadgeDisplay } from './BadgeDisplay';
export type { NotificationPreferences, AreaOfInterest } from './NotificationSettings';

View File

@@ -5,7 +5,7 @@ import { useClerk } from '@clerk/clerk-react';
import { userService, type UserProfile, type UserActivity } from '@/services/userService';
import type { Report, CleanupEvent } from '@/types';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import { NotificationSettings, type NotificationPreferences } from '@/components/auth';
import { NotificationSettings, type NotificationPreferences, BadgeDisplay } from '@/components/auth';
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
@@ -288,20 +288,69 @@ export function UserProfilePage() {
)}
</div>
{/* Statistics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
<div className="bg-blue-50 rounded-lg p-4">
<p className="text-sm text-gray-600">Reports Submitted</p>
<p className="text-3xl font-bold text-blue-600">{profile.reportCount}</p>
{/* Statistics and Badge */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6">
{/* Reports Submitted */}
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-blue-200">
<div className="flex flex-col items-center text-center space-y-3">
<div className="bg-blue-200 rounded-full p-3">
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-blue-700 mb-1">Reports Submitted</p>
<p className="text-4xl font-bold text-blue-600">{profile.reportCount}</p>
</div>
</div>
</div>
<div className="bg-green-50 rounded-lg p-4">
<p className="text-sm text-gray-600">Events Attended</p>
<p className="text-3xl font-bold text-green-600">{profile.eventCount}</p>
{/* Events Attended */}
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-green-200">
<div className="flex flex-col items-center text-center space-y-3">
<div className="bg-green-200 rounded-full p-3">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-green-700 mb-1">Events Attended</p>
<p className="text-4xl font-bold text-green-600">{profile.eventCount}</p>
</div>
</div>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<p className="text-sm text-gray-600">Impact Score</p>
<p className="text-3xl font-bold text-purple-600">{calculateImpactScore()}</p>
{/* Impact Score */}
<div className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-purple-200">
<div className="flex flex-col items-center text-center space-y-3">
<div className="bg-purple-200 rounded-full p-3">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-purple-700 mb-1">Impact Score</p>
<p className="text-4xl font-bold text-purple-600">{calculateImpactScore()}</p>
</div>
</div>
</div>
{/* Current Badge */}
<div className="bg-gradient-to-br from-yellow-50 via-orange-50 to-amber-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-yellow-200 flex items-center justify-center">
<BadgeDisplay impactScore={calculateImpactScore()} size="small" />
</div>
</div>
{/* Badge Progress Section */}
<div className="mt-6 bg-gradient-to-r from-blue-50 via-purple-50 to-pink-50 rounded-xl p-8 shadow-md border border-purple-200">
<div className="flex items-center justify-center mb-2">
<svg className="w-6 h-6 text-purple-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<h3 className="text-xl font-bold text-gray-900 text-center">Your Achievement Badge</h3>
</div>
<p className="text-center text-sm text-gray-600 mb-6">Keep contributing to unlock higher badges!</p>
<BadgeDisplay impactScore={calculateImpactScore()} showProgress={true} size="large" />
</div>
</div>

117
src/utils/badges.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Badge system utilities for user achievements
*/
export type BadgeLevel = 'Beginner' | 'Intermediate' | 'Advanced' | 'Expert' | 'Master';
export interface Badge {
level: BadgeLevel;
minScore: number;
maxScore: number | null;
image: string;
color: string;
description: string;
}
export const BADGES: Record<BadgeLevel, Badge> = {
Beginner: {
level: 'Beginner',
minScore: 0,
maxScore: 49,
image: '/Beginner.png',
color: 'bg-gray-100 text-gray-800',
description: 'Starting your environmental journey',
},
Intermediate: {
level: 'Intermediate',
minScore: 50,
maxScore: 99,
image: '/Intermediate.png',
color: 'bg-blue-100 text-blue-800',
description: 'Making a real difference',
},
Advanced: {
level: 'Advanced',
minScore: 100,
maxScore: 149,
image: '/Advanced.png',
color: 'bg-purple-100 text-purple-800',
description: 'Dedicated environmental champion',
},
Expert: {
level: 'Expert',
minScore: 150,
maxScore: 199,
image: '/Expert.png',
color: 'bg-orange-100 text-orange-800',
description: 'Leading the cleanup movement',
},
Master: {
level: 'Master',
minScore: 200,
maxScore: null,
image: '/Master.png',
color: 'bg-yellow-100 text-yellow-800',
description: 'Elite environmental guardian',
},
};
/**
* Get badge level based on impact score
*/
export function getBadgeForScore(score: number): Badge {
if (score >= 200) return BADGES.Master;
if (score >= 150) return BADGES.Expert;
if (score >= 100) return BADGES.Advanced;
if (score >= 50) return BADGES.Intermediate;
return BADGES.Beginner;
}
/**
* Get next badge level and progress
*/
export function getNextBadgeProgress(score: number): {
currentBadge: Badge;
nextBadge: Badge | null;
progress: number;
pointsToNext: number;
} {
const currentBadge = getBadgeForScore(score);
// If at Master level, no next badge
if (currentBadge.level === 'Master') {
return {
currentBadge,
nextBadge: null,
progress: 100,
pointsToNext: 0,
};
}
// Find next badge
const badgeLevels: BadgeLevel[] = ['Beginner', 'Intermediate', 'Advanced', 'Expert', 'Master'];
const currentIndex = badgeLevels.indexOf(currentBadge.level);
const nextBadgeLevel = badgeLevels[currentIndex + 1];
if (!nextBadgeLevel) {
return {
currentBadge,
nextBadge: null,
progress: 100,
pointsToNext: 0,
};
}
const nextBadge = BADGES[nextBadgeLevel];
// Calculate progress
const pointsInCurrentLevel = score - currentBadge.minScore;
const pointsNeededForNext = nextBadge.minScore - currentBadge.minScore;
const progress = Math.min(100, (pointsInCurrentLevel / pointsNeededForNext) * 100);
const pointsToNext = nextBadge.minScore - score;
return {
currentBadge,
nextBadge,
progress,
pointsToNext,
};
}