feat: implement user profile page with statistics, contribution map, and activity timeline

- Add userService for profile and activity API calls
- Create UserProfilePage with tabbed interface (overview, reports, events, activity)
- Display user statistics (reports count, events count, impact score)
- Implement personal contribution map showing all user report locations
- Add activity timeline with different activity types
- Enable profile editing for own profile (first name, last name, username)
- Update UserProfile component to link to profile page
- Add /profile/:userId route to App
- Install react-leaflet for map integration
- Requirements: 6.3, 6.4, 6.5
This commit is contained in:
2025-11-16 21:13:26 +00:00
parent 5cbdb8f284
commit f02f23e185
10 changed files with 728 additions and 5 deletions

View File

@@ -0,0 +1,146 @@
# User Profile Page Implementation
## Overview
The User Profile Page displays comprehensive user statistics, contributions, and activity history. It provides both public profile viewing and personal profile editing capabilities.
## Features Implemented
### 1. User Statistics Display
- **Reports Submitted**: Total count of litter reports submitted by the user
- **Events Attended**: Total count of cleanup events the user has participated in
- **Impact Score**: Calculated score based on user contributions (reports × 10 + events × 20)
### 2. Profile Information
- User avatar, name, username, and email
- Editable profile fields (first name, last name, username) for own profile
- Edit/Save/Cancel functionality for profile updates
### 3. Personal Contribution Map
- Interactive Leaflet map showing all report locations submitted by the user
- Markers for each report with popup details (litter type, date)
- Automatically centers on user's first report location
### 4. Activity Timeline
- Chronological list of all user activities
- Activity types:
- Report submissions
- Event creation
- Event attendance
- Event completion
- Each activity shows title, description, location, and timestamp
- Visual icons for different activity types
### 5. Tabbed Interface
Four tabs for organizing profile content:
- **Overview**: Statistics, contribution map, and recent activity
- **Reports**: Grid view of all submitted reports with photos
- **Events**: List of all events (created and attended) with details
- **Activity**: Full activity timeline
## Routes
- `/profile/:userId` - View user profile (protected route)
## Components
### UserProfilePage (`src/pages/UserProfilePage.tsx`)
Main profile page component with:
- Profile header with avatar and statistics
- Tabbed navigation
- Contribution map
- Reports grid
- Events list
- Activity timeline
- Profile editing functionality
### UserProfile (`src/components/auth/UserProfile.tsx`)
Updated header component that now links to the user's profile page when clicked.
## Services
### userService (`src/services/userService.ts`)
API service for user-related operations:
- `getProfile(userId)` - Fetch user profile with statistics
- `updateProfile(userId, data)` - Update user profile information
- `getUserReports(userId)` - Get all reports by user
- `getUserEvents(userId)` - Get all events (created and attended)
- `getUserActivity(userId)` - Get activity timeline
## Types
### UserProfile
```typescript
interface UserProfile extends User {
reportCount: number;
eventCount: number;
}
```
### UserActivity
```typescript
interface UserActivity {
id: string;
type: 'report' | 'event_created' | 'event_attended' | 'event_completed';
timestamp: Date;
title: string;
description?: string;
location?: {
latitude: number;
longitude: number;
locationName?: string;
};
}
```
### UpdateProfileDTO
```typescript
interface UpdateProfileDTO {
firstName?: string;
lastName?: string;
username?: string;
notificationEmail?: boolean;
notificationInApp?: boolean;
}
```
## Backend Requirements
The following API endpoints need to be implemented on the backend:
1. `GET /api/users/:id` - Get user profile with statistics
2. `PATCH /api/users/:id` - Update user profile
3. `GET /api/users/:id/reports` - Get user's reports
4. `GET /api/users/:id/events` - Get user's events
5. `GET /api/users/:id/activity` - Get user's activity timeline
## Impact Score Calculation
The impact score is calculated as:
```
Impact Score = (Number of Reports × 10) + (Number of Events × 20)
```
This gives more weight to event participation, encouraging community engagement.
## Permissions
- Any authenticated user can view any profile
- Only the profile owner can edit their own profile
- Edit button only appears when viewing own profile
## Mobile Responsiveness
- Responsive grid layouts for statistics and reports
- Collapsible navigation for mobile devices
- Touch-friendly map controls
- Optimized for screens from 320px to desktop
## Future Enhancements
- Achievement badges based on milestones
- Leaderboard integration
- Social features (follow users, share profiles)
- Export personal data
- Profile privacy settings
- Custom profile themes

View File

@@ -0,0 +1,461 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { userService, type UserProfile, type UserActivity } from '@/services/userService';
import type { Report, CleanupEvent } from '@/types';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
// Fix Leaflet default marker icon issue
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow,
});
export function UserProfilePage() {
const { userId } = useParams<{ userId: string }>();
const { user: currentUser } = useAuth();
const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [reports, setReports] = useState<Report[]>([]);
const [events, setEvents] = useState<CleanupEvent[]>([]);
const [activity, setActivity] = useState<UserActivity[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'reports' | 'events' | 'activity'>('overview');
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState({
firstName: '',
lastName: '',
username: '',
});
const isOwnProfile = currentUser?.id === userId;
useEffect(() => {
if (!userId) {
setError('User ID is required');
setLoading(false);
return;
}
loadProfileData();
}, [userId]);
const loadProfileData = async () => {
if (!userId) return;
try {
setLoading(true);
setError(null);
const [profileData, reportsData, eventsData, activityData] = await Promise.all([
userService.getProfile(userId),
userService.getUserReports(userId),
userService.getUserEvents(userId),
userService.getUserActivity(userId),
]);
setProfile(profileData);
setReports(reportsData);
setEvents(eventsData);
setActivity(activityData);
setEditForm({
firstName: profileData.firstName || '',
lastName: profileData.lastName || '',
username: profileData.username || '',
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load profile');
} finally {
setLoading(false);
}
};
const handleEditProfile = () => {
setIsEditing(true);
};
const handleCancelEdit = () => {
setIsEditing(false);
if (profile) {
setEditForm({
firstName: profile.firstName || '',
lastName: profile.lastName || '',
username: profile.username || '',
});
}
};
const handleSaveProfile = async () => {
if (!userId) return;
try {
await userService.updateProfile(userId, editForm);
setIsEditing(false);
await loadProfileData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile');
}
};
const calculateImpactScore = () => {
if (!profile) return 0;
// Impact score calculation: reports * 10 + events attended * 20
return profile.reportCount * 10 + profile.eventCount * 20;
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-IE', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading profile...</p>
</div>
</div>
);
}
if (error || !profile) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 mb-4">{error || 'Profile not found'}</p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Go Home
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Profile Header */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-4">
{profile.avatarUrl && (
<img
src={profile.avatarUrl}
alt={profile.username}
className="h-20 w-20 rounded-full object-cover border-4 border-blue-100"
/>
)}
<div>
{isEditing ? (
<div className="space-y-2">
<div className="flex space-x-2">
<input
type="text"
value={editForm.firstName}
onChange={(e) => setEditForm({ ...editForm, firstName: e.target.value })}
placeholder="First name"
className="px-3 py-1 border border-gray-300 rounded-md"
/>
<input
type="text"
value={editForm.lastName}
onChange={(e) => setEditForm({ ...editForm, lastName: e.target.value })}
placeholder="Last name"
className="px-3 py-1 border border-gray-300 rounded-md"
/>
</div>
<input
type="text"
value={editForm.username}
onChange={(e) => setEditForm({ ...editForm, username: e.target.value })}
placeholder="Username"
className="px-3 py-1 border border-gray-300 rounded-md w-full"
/>
</div>
) : (
<>
<h1 className="text-2xl font-bold text-gray-900">
{profile.firstName && profile.lastName
? `${profile.firstName} ${profile.lastName}`
: profile.username}
</h1>
<p className="text-gray-600">@{profile.username}</p>
<p className="text-sm text-gray-500">{profile.email}</p>
</>
)}
</div>
</div>
{isOwnProfile && (
<div className="flex space-x-2">
{isEditing ? (
<>
<button
onClick={handleSaveProfile}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Save
</button>
<button
onClick={handleCancelEdit}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300"
>
Cancel
</button>
</>
) : (
<button
onClick={handleEditProfile}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Edit Profile
</button>
)}
</div>
)}
</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>
</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>
</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>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow-md mb-6">
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
{(['overview', 'reports', 'events', 'activity'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-6 py-3 text-sm font-medium capitalize ${
activeTab === tab
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-600 hover:text-gray-900 hover:border-gray-300'
}`}
>
{tab}
</button>
))}
</nav>
</div>
<div className="p-6">
{activeTab === 'overview' && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold mb-4">Contribution Map</h2>
{reports.length > 0 && reports[0] ? (
<div className="h-96 rounded-lg overflow-hidden">
<MapContainer
center={[reports[0].latitude, reports[0].longitude]}
zoom={10}
style={{ height: '100%', width: '100%' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{reports.map((report) => (
<Marker
key={report.id}
position={[report.latitude, report.longitude]}
>
<Popup>
<div>
<p className="font-semibold capitalize">{report.litterType}</p>
<p className="text-sm text-gray-600">{formatDate(report.createdAt)}</p>
</div>
</Popup>
</Marker>
))}
</MapContainer>
</div>
) : (
<p className="text-gray-500">No reports submitted yet</p>
)}
</div>
<div>
<h2 className="text-xl font-semibold mb-4">Recent Activity</h2>
{activity.length > 0 ? (
<div className="space-y-3">
{activity.slice(0, 5).map((item) => (
<div key={item.id} className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
<div className="flex-shrink-0">
{item.type === 'report' && (
<div className="h-10 w-10 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600">📍</span>
</div>
)}
{item.type.startsWith('event') && (
<div className="h-10 w-10 bg-green-100 rounded-full flex items-center justify-center">
<span className="text-green-600">🗓️</span>
</div>
)}
</div>
<div className="flex-1">
<p className="font-medium text-gray-900">{item.title}</p>
{item.description && (
<p className="text-sm text-gray-600">{item.description}</p>
)}
<p className="text-xs text-gray-500 mt-1">{formatDate(item.timestamp)}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">No activity yet</p>
)}
</div>
</div>
)}
{activeTab === 'reports' && (
<div>
<h2 className="text-xl font-semibold mb-4">Submitted Reports</h2>
{reports.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{reports.map((report) => (
<div key={report.id} className="bg-gray-50 rounded-lg p-4">
{report.photoUrls.length > 0 && (
<img
src={report.photoUrls[0]}
alt="Report"
className="w-full h-48 object-cover rounded-md mb-3"
/>
)}
<div className="space-y-2">
<p className="font-semibold capitalize">{report.litterType}</p>
<p className="text-sm text-gray-600 capitalize">Quantity: {report.quantity}</p>
{report.description && (
<p className="text-sm text-gray-700">{report.description}</p>
)}
<p className="text-xs text-gray-500">{formatDate(report.createdAt)}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">No reports submitted yet</p>
)}
</div>
)}
{activeTab === 'events' && (
<div>
<h2 className="text-xl font-semibold mb-4">Events</h2>
{events.length > 0 ? (
<div className="space-y-4">
{events.map((event) => (
<div key={event.id} className="bg-gray-50 rounded-lg p-4">
<div className="flex justify-between items-start">
<div>
<h3 className="font-semibold text-lg">{event.title}</h3>
<p className="text-sm text-gray-600 mt-1">{event.description}</p>
<p className="text-sm text-gray-500 mt-2">
📍 {event.locationName}
</p>
<p className="text-sm text-gray-500">
🗓️ {formatDate(event.scheduledDate)}
</p>
<p className="text-sm text-gray-500">
👥 {event.participantCount} participants
</p>
{event.litterCollected && (
<p className="text-sm text-green-600 font-medium mt-2">
♻️ {event.litterCollected} kg collected
</p>
)}
</div>
<span
className={`px-3 py-1 rounded-full text-xs font-medium ${
event.status === 'completed'
? 'bg-green-100 text-green-800'
: event.status === 'upcoming'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{event.status}
</span>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">No events yet</p>
)}
</div>
)}
{activeTab === 'activity' && (
<div>
<h2 className="text-xl font-semibold mb-4">Activity Timeline</h2>
{activity.length > 0 ? (
<div className="space-y-4">
{activity.map((item) => (
<div key={item.id} className="flex items-start space-x-4">
<div className="flex-shrink-0">
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
{item.type === 'report' && <span className="text-xl">📍</span>}
{item.type === 'event_created' && <span className="text-xl">➕</span>}
{item.type === 'event_attended' && <span className="text-xl">✅</span>}
{item.type === 'event_completed' && <span className="text-xl">🎉</span>}
</div>
</div>
<div className="flex-1 bg-gray-50 rounded-lg p-4">
<p className="font-medium text-gray-900">{item.title}</p>
{item.description && (
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
)}
{item.location && (
<p className="text-sm text-gray-500 mt-1">
📍 {item.location.locationName || `${item.location.latitude?.toFixed(4)}, ${item.location.longitude?.toFixed(4)}`}
</p>
)}
<p className="text-xs text-gray-500 mt-2">{formatDate(item.timestamp)}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500">No activity yet</p>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -7,3 +7,4 @@ export { PhotoUploadDemo } from './PhotoUploadDemo';
export { default as LocationSelectorDemo } from './LocationSelectorDemo';
export { ReportDetailDemo } from './ReportDetailDemo';
export { WhyCleanPage } from './WhyCleanPage';
export { UserProfilePage } from './UserProfilePage';