- Remove email from profile header display for privacy - Keep username and full name visible
461 lines
18 KiB
TypeScript
461 lines
18 KiB
TypeScript
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='© <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>
|
||
);
|
||
}
|