diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 2db9105..ca36d70 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -81,4 +81,71 @@ router.patch('/me', authenticate, updateProfileValidation, updateProfile); // PATCH /api/users/me/password - Change password router.patch('/me/password', authenticate, changePasswordValidation, changePassword); +// GET /api/users/:username - Get public user profile by username +router.get('/:username', authenticate, async (req, res, next) => { + try { + const { username } = req.params; + + const user = await prisma.user.findUnique({ + where: { username }, + select: { + id: true, + username: true, + firstName: true, + lastName: true, + wsdcId: true, + youtubeUrl: true, + instagramUrl: true, + facebookUrl: true, + tiktokUrl: true, + country: true, + city: true, + avatar: true, + createdAt: true, + _count: { + select: { + matchesAsUser1: true, + matchesAsUser2: true, + ratingsReceived: true, + }, + }, + }, + }); + + if (!user) { + return res.status(404).json({ + success: false, + error: 'User not found', + }); + } + + // Calculate total matches + const totalMatches = user._count.matchesAsUser1 + user._count.matchesAsUser2; + + // Calculate average rating + const ratings = await prisma.rating.findMany({ + where: { ratedId: user.id }, + select: { score: true }, + }); + + const averageRating = ratings.length > 0 + ? ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length + : 0; + + res.json({ + success: true, + data: { + ...user, + stats: { + matchesCount: totalMatches, + ratingsCount: user._count.ratingsReceived, + rating: averageRating.toFixed(1), + }, + }, + }); + } catch (error) { + next(error); + } +}); + module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 93a5e18..89b7771 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,7 @@ import MatchChatPage from './pages/MatchChatPage'; import RatePartnerPage from './pages/RatePartnerPage'; import HistoryPage from './pages/HistoryPage'; import ProfilePage from './pages/ProfilePage'; +import PublicProfilePage from './pages/PublicProfilePage'; import VerificationBanner from './components/common/VerificationBanner'; // Protected Route Component with Verification Banner @@ -132,9 +133,18 @@ function App() { } /> + {/* Public Profile - must be before default redirect */} + + + + } + /> + {/* Default redirect */} } /> - } /> diff --git a/frontend/src/pages/PublicProfilePage.jsx b/frontend/src/pages/PublicProfilePage.jsx new file mode 100644 index 0000000..402fb8d --- /dev/null +++ b/frontend/src/pages/PublicProfilePage.jsx @@ -0,0 +1,211 @@ +import { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { authAPI } from '../services/api'; +import Layout from '../components/layout/Layout'; +import { User, MapPin, Globe, Hash, Youtube, Instagram, Facebook, Award, Users, Star, Calendar, Loader2, AlertCircle } from 'lucide-react'; + +const PublicProfilePage = () => { + const { username } = useParams(); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const fetchProfile = async () => { + try { + setLoading(true); + const data = await authAPI.getUserByUsername(username); + setProfile(data); + } catch (err) { + setError(err.data?.error || 'Failed to load profile'); + } finally { + setLoading(false); + } + }; + + fetchProfile(); + }, [username]); + + if (loading) { + return ( + +
+
+ +

Loading profile...

+
+
+
+ ); + } + + if (error) { + return ( + +
+
+
+ +

User Not Found

+

{error}

+ + Back to Events + +
+
+
+
+ ); + } + + return ( + +
+
+ {/* Profile Header */} +
+
+ {profile.username} +
+

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

+

@{profile.username}

+ + {/* Location */} + {(profile.city || profile.country) && ( +
+ {profile.city && ( +
+ + {profile.city} +
+ )} + {profile.country && ( +
+ + {profile.country} +
+ )} +
+ )} + + {/* Stats */} +
+
+ + {profile.stats.matchesCount} + Matches +
+
+ + {profile.stats.rating} + Rating +
+
+ + {profile.stats.ratingsCount} + Reviews +
+
+ + {/* Member since */} +
+ + Member since {new Date(profile.createdAt).toLocaleDateString()} +
+
+
+
+ + {/* WSDC ID */} + {profile.wsdcId && ( +
+

+ + WSDC Profile +

+
+ WSDC ID: + + {profile.wsdcId} + +
+
+ )} + + {/* Social Media Links */} + {(profile.youtubeUrl || profile.instagramUrl || profile.facebookUrl || profile.tiktokUrl) && ( +
+

Social Media

+
+ {profile.youtubeUrl && ( + + + YouTube + + )} + {profile.instagramUrl && ( + + + Instagram + + )} + {profile.facebookUrl && ( + + + Facebook + + )} + {profile.tiktokUrl && ( + + + + + TikTok + + )} +
+
+ )} +
+
+
+ ); +}; + +export default PublicProfilePage; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 6f03549..3b022a3 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -160,6 +160,11 @@ export const authAPI = { return data; }, + async getUserByUsername(username) { + const data = await fetchAPI(`/users/${username}`); + return data.data; + }, + logout() { localStorage.removeItem('token'); localStorage.removeItem('user');