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:
119
BADGE_SYSTEM_IMPLEMENTATION.md
Normal file
119
BADGE_SYSTEM_IMPLEMENTATION.md
Normal 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
|
||||||
69
src/components/auth/BadgeDisplay.tsx
Normal file
69
src/components/auth/BadgeDisplay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@ export { UserProfile } from './UserProfile';
|
|||||||
export { SignInPage } from './SignInPage';
|
export { SignInPage } from './SignInPage';
|
||||||
export { SignUpPage } from './SignUpPage';
|
export { SignUpPage } from './SignUpPage';
|
||||||
export { NotificationSettings } from './NotificationSettings';
|
export { NotificationSettings } from './NotificationSettings';
|
||||||
|
export { BadgeDisplay } from './BadgeDisplay';
|
||||||
export type { NotificationPreferences, AreaOfInterest } from './NotificationSettings';
|
export type { NotificationPreferences, AreaOfInterest } from './NotificationSettings';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useClerk } from '@clerk/clerk-react';
|
|||||||
import { userService, type UserProfile, type UserActivity } from '@/services/userService';
|
import { userService, type UserProfile, type UserActivity } from '@/services/userService';
|
||||||
import type { Report, CleanupEvent } from '@/types';
|
import type { Report, CleanupEvent } from '@/types';
|
||||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
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 'leaflet/dist/leaflet.css';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
@@ -288,20 +288,69 @@ export function UserProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistics */}
|
{/* Statistics and Badge */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6">
|
||||||
<div className="bg-blue-50 rounded-lg p-4">
|
{/* Reports Submitted */}
|
||||||
<p className="text-sm text-gray-600">Reports Submitted</p>
|
<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">
|
||||||
<p className="text-3xl font-bold text-blue-600">{profile.reportCount}</p>
|
<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>
|
||||||
<div className="bg-green-50 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-gray-600">Events Attended</p>
|
{/* Events Attended */}
|
||||||
<p className="text-3xl font-bold text-green-600">{profile.eventCount}</p>
|
<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>
|
||||||
<div className="bg-purple-50 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-gray-600">Impact Score</p>
|
{/* Impact Score */}
|
||||||
<p className="text-3xl font-bold text-purple-600">{calculateImpactScore()}</p>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
117
src/utils/badges.ts
Normal file
117
src/utils/badges.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user