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,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,
};
}