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:
@@ -117,7 +117,7 @@
|
||||
|
||||
## User Profile and Engagement
|
||||
|
||||
- [ ] 15. Build user profile page
|
||||
- [x] 15. Build user profile page
|
||||
- Display user statistics (reports, events attended)
|
||||
- Calculate and show impact score
|
||||
- Create personal contribution map
|
||||
|
||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.4.1"
|
||||
},
|
||||
@@ -1088,6 +1089,17 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz",
|
||||
@@ -3667,6 +3679,20 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.4.1"
|
||||
},
|
||||
|
||||
13
src/App.tsx
13
src/App.tsx
@@ -22,7 +22,8 @@ import {
|
||||
PhotoUploadDemo,
|
||||
LocationSelectorDemo,
|
||||
ReportDetailDemo,
|
||||
WhyCleanPage
|
||||
WhyCleanPage,
|
||||
UserProfilePage
|
||||
} from './pages';
|
||||
|
||||
// Services
|
||||
@@ -129,6 +130,16 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile/:userId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<UserProfilePage />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Demo routes for testing */}
|
||||
<Route
|
||||
|
||||
@@ -14,6 +14,12 @@ export function UserProfile() {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleViewProfile = () => {
|
||||
if (user?.id) {
|
||||
navigate(`/profile/${user.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -30,7 +36,10 @@ export function UserProfile() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={handleViewProfile}
|
||||
className="flex items-center space-x-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{user.imageUrl && (
|
||||
<img
|
||||
src={user.imageUrl}
|
||||
@@ -38,7 +47,7 @@ export function UserProfile() {
|
||||
className="h-10 w-10 rounded-full object-cover border-2 border-gray-200"
|
||||
/>
|
||||
)}
|
||||
<div className="hidden md:block">
|
||||
<div className="hidden md:block text-left">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{user.fullName || user.username || 'User'}
|
||||
</p>
|
||||
@@ -46,7 +55,7 @@ export function UserProfile() {
|
||||
{user.primaryEmailAddress?.emailAddress}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
|
||||
146
src/pages/USER_PROFILE_IMPLEMENTATION.md
Normal file
146
src/pages/USER_PROFILE_IMPLEMENTATION.md
Normal 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
|
||||
461
src/pages/UserProfilePage.tsx
Normal file
461
src/pages/UserProfilePage.tsx
Normal 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='© <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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -3,6 +3,8 @@ export { apiClient } from './api';
|
||||
export { reportService } from './reportService';
|
||||
export { analyticsService } from './analyticsService';
|
||||
export { eventService } from './eventService';
|
||||
export { userService } from './userService';
|
||||
export type { ApiError } from './api';
|
||||
export type { CreateReportPayload, UploadPhotoResponse } from './reportService';
|
||||
export type { CreateEventDTO, UpdateEventDTO, EventFilters, EventRegistration } from './eventService';
|
||||
export type { UserProfile, UserActivity, UpdateProfileDTO } from './userService';
|
||||
|
||||
66
src/services/userService.ts
Normal file
66
src/services/userService.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// User service for profile and user-related API calls
|
||||
import { apiClient } from './api';
|
||||
import type { User, Report, CleanupEvent } from '@/types';
|
||||
|
||||
export interface UserProfile extends User {
|
||||
reportCount: number;
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
export 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;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateProfileDTO {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
notificationEmail?: boolean;
|
||||
notificationInApp?: boolean;
|
||||
}
|
||||
|
||||
export const userService = {
|
||||
/**
|
||||
* Get user profile by ID
|
||||
*/
|
||||
async getProfile(userId: string): Promise<UserProfile> {
|
||||
return apiClient.get<UserProfile>(`/users/${userId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
async updateProfile(userId: string, data: UpdateProfileDTO): Promise<User> {
|
||||
return apiClient.patch<User>(`/users/${userId}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user's reports
|
||||
*/
|
||||
async getUserReports(userId: string): Promise<Report[]> {
|
||||
return apiClient.get<Report[]>(`/users/${userId}/reports`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user's events (created and attended)
|
||||
*/
|
||||
async getUserEvents(userId: string): Promise<CleanupEvent[]> {
|
||||
return apiClient.get<CleanupEvent[]>(`/users/${userId}/events`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user's activity timeline
|
||||
*/
|
||||
async getUserActivity(userId: string): Promise<UserActivity[]> {
|
||||
return apiClient.get<UserActivity[]>(`/users/${userId}/activity`);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user