Files
ocean-cleanup/src/pages/UserProfilePage.tsx
Paul Bailey 426c4c7d7e fix: remove email display from user profile page
- Remove email from profile header display for privacy
- Keep username and full name visible
2025-11-16 21:41:35 +00:00

461 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
)}
</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>
);
}