From f02f23e1857ce05e4628ce01056e43ce630f7ed6 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sun, 16 Nov 2025 21:13:26 +0000 Subject: [PATCH] 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 --- .kiro/specs/beach-litter-mapping/tasks.md | 2 +- package-lock.json | 26 ++ package.json | 1 + src/App.tsx | 13 +- src/components/auth/UserProfile.tsx | 15 +- src/pages/USER_PROFILE_IMPLEMENTATION.md | 146 +++++++ src/pages/UserProfilePage.tsx | 461 ++++++++++++++++++++++ src/pages/index.ts | 1 + src/services/index.ts | 2 + src/services/userService.ts | 66 ++++ 10 files changed, 728 insertions(+), 5 deletions(-) create mode 100644 src/pages/USER_PROFILE_IMPLEMENTATION.md create mode 100644 src/pages/UserProfilePage.tsx create mode 100644 src/services/userService.ts diff --git a/.kiro/specs/beach-litter-mapping/tasks.md b/.kiro/specs/beach-litter-mapping/tasks.md index 66e8538..96917af 100644 --- a/.kiro/specs/beach-litter-mapping/tasks.md +++ b/.kiro/specs/beach-litter-mapping/tasks.md @@ -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 diff --git a/package-lock.json b/package-lock.json index aa34750..4af6b39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dd16f45..1162500 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/App.tsx b/src/App.tsx index 44224e7..b61aa40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,8 @@ import { PhotoUploadDemo, LocationSelectorDemo, ReportDetailDemo, - WhyCleanPage + WhyCleanPage, + UserProfilePage } from './pages'; // Services @@ -129,6 +130,16 @@ function App() { } /> + + + + + + } + /> {/* Demo routes for testing */} { + if (user?.id) { + navigate(`/profile/${user.id}`); + } + }; + if (!isLoaded) { return (
@@ -30,7 +36,10 @@ export function UserProfile() { return (
-
+ +
+
+ ); + } + + return ( +
+
+ {/* Profile Header */} +
+
+
+ {profile.avatarUrl && ( + {profile.username} + )} +
+ {isEditing ? ( +
+
+ setEditForm({ ...editForm, firstName: e.target.value })} + placeholder="First name" + className="px-3 py-1 border border-gray-300 rounded-md" + /> + setEditForm({ ...editForm, lastName: e.target.value })} + placeholder="Last name" + className="px-3 py-1 border border-gray-300 rounded-md" + /> +
+ setEditForm({ ...editForm, username: e.target.value })} + placeholder="Username" + className="px-3 py-1 border border-gray-300 rounded-md w-full" + /> +
+ ) : ( + <> +

+ {profile.firstName && profile.lastName + ? `${profile.firstName} ${profile.lastName}` + : profile.username} +

+

@{profile.username}

+

{profile.email}

+ + )} +
+
+ {isOwnProfile && ( +
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ + {/* Statistics */} +
+
+

Reports Submitted

+

{profile.reportCount}

+
+
+

Events Attended

+

{profile.eventCount}

+
+
+

Impact Score

+

{calculateImpactScore()}

+
+
+
+ + {/* Tabs */} +
+
+ +
+ +
+ {activeTab === 'overview' && ( +
+
+

Contribution Map

+ {reports.length > 0 && reports[0] ? ( +
+ + + {reports.map((report) => ( + + +
+

{report.litterType}

+

{formatDate(report.createdAt)}

+
+
+
+ ))} +
+
+ ) : ( +

No reports submitted yet

+ )} +
+ +
+

Recent Activity

+ {activity.length > 0 ? ( +
+ {activity.slice(0, 5).map((item) => ( +
+
+ {item.type === 'report' && ( +
+ 📍 +
+ )} + {item.type.startsWith('event') && ( +
+ 🗓️ +
+ )} +
+
+

{item.title}

+ {item.description && ( +

{item.description}

+ )} +

{formatDate(item.timestamp)}

+
+
+ ))} +
+ ) : ( +

No activity yet

+ )} +
+
+ )} + + {activeTab === 'reports' && ( +
+

Submitted Reports

+ {reports.length > 0 ? ( +
+ {reports.map((report) => ( +
+ {report.photoUrls.length > 0 && ( + Report + )} +
+

{report.litterType}

+

Quantity: {report.quantity}

+ {report.description && ( +

{report.description}

+ )} +

{formatDate(report.createdAt)}

+
+
+ ))} +
+ ) : ( +

No reports submitted yet

+ )} +
+ )} + + {activeTab === 'events' && ( +
+

Events

+ {events.length > 0 ? ( +
+ {events.map((event) => ( +
+
+
+

{event.title}

+

{event.description}

+

+ 📍 {event.locationName} +

+

+ 🗓️ {formatDate(event.scheduledDate)} +

+

+ 👥 {event.participantCount} participants +

+ {event.litterCollected && ( +

+ ♻️ {event.litterCollected} kg collected +

+ )} +
+ + {event.status} + +
+
+ ))} +
+ ) : ( +

No events yet

+ )} +
+ )} + + {activeTab === 'activity' && ( +
+

Activity Timeline

+ {activity.length > 0 ? ( +
+ {activity.map((item) => ( +
+
+
+ {item.type === 'report' && 📍} + {item.type === 'event_created' && ➕} + {item.type === 'event_attended' && ✅} + {item.type === 'event_completed' && 🎉} +
+
+
+

{item.title}

+ {item.description && ( +

{item.description}

+ )} + {item.location && ( +

+ 📍 {item.location.locationName || `${item.location.latitude?.toFixed(4)}, ${item.location.longitude?.toFixed(4)}`} +

+ )} +

{formatDate(item.timestamp)}

+
+
+ ))} +
+ ) : ( +

No activity yet

+ )} +
+ )} +
+
+
+
+ ); +} diff --git a/src/pages/index.ts b/src/pages/index.ts index 0a951cb..fa1bbf4 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -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'; diff --git a/src/services/index.ts b/src/services/index.ts index 1879d43..8ad9a2b 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -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'; diff --git a/src/services/userService.ts b/src/services/userService.ts new file mode 100644 index 0000000..65ac9b6 --- /dev/null +++ b/src/services/userService.ts @@ -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 { + return apiClient.get(`/users/${userId}`); + }, + + /** + * Update user profile + */ + async updateProfile(userId: string, data: UpdateProfileDTO): Promise { + return apiClient.patch(`/users/${userId}`, data); + }, + + /** + * Get user's reports + */ + async getUserReports(userId: string): Promise { + return apiClient.get(`/users/${userId}/reports`); + }, + + /** + * Get user's events (created and attended) + */ + async getUserEvents(userId: string): Promise { + return apiClient.get(`/users/${userId}/events`); + }, + + /** + * Get user's activity timeline + */ + async getUserActivity(userId: string): Promise { + return apiClient.get(`/users/${userId}/activity`); + }, +};