diff --git a/app/globals.css b/app/globals.css index e6040daf..e55202fb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -459,3 +459,17 @@ Accessibility: Enhanced focus indicators for keyboard navigation */ .skip-link:focus { top: 0; } + +/* Shimmer animation for loading states */ +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +.animate-shimmer { + animation: shimmer 2s infinite; +} diff --git a/app/protected/connections/page.tsx b/app/protected/connections/page.tsx new file mode 100644 index 00000000..873e4b26 --- /dev/null +++ b/app/protected/connections/page.tsx @@ -0,0 +1,95 @@ +'use client' + +import React, { useState } from 'react' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Input } from '@/components/ui/input' +import { Search, Users, UserPlus } from 'lucide-react' +import { FollowingList } from '@/components/connections/FollowingList' +import { FollowersList } from '@/components/connections/FollowersList' +import { SearchUsers } from '@/components/connections/SearchUsers' +import { ConnectionStats } from '@/components/connections/ConnectionStats' + +export default function ConnectionsPage() { + const [searchQuery, setSearchQuery] = useState('') + const [activeTab, setActiveTab] = useState('following') + + return ( +
+ {/* Header */} +
+
+
+ +

Connections

+
+ +
+
+ + {/* Main Content */} +
+
+ + + + + Following + + + + Followers + + + + Search + + + + + + + + + + + + +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-9" + /> + {searchQuery && ( + + )} +
+ +
+
+
+
+
+ ) +} diff --git a/app/protected/layout.tsx b/app/protected/layout.tsx index 7f1e335f..1a8084bf 100644 --- a/app/protected/layout.tsx +++ b/app/protected/layout.tsx @@ -123,6 +123,16 @@ const sidebarItems: SidebarGroupType[] = [ { title: "Community", items: [ + { + title: "Connections", + url: "/protected/connections", + icon: Users, + }, + { + title: "Messages", + url: "/protected/messages", + icon: MessageSquare, + }, { title: "Study Groups", url: "/protected/study-groups", @@ -188,11 +198,7 @@ const sidebarItems: SidebarGroupType[] = [ { title: "Support", items: [ - { - title: "Messages", - url: "/protected/messages", - icon: MessageSquare, - }, + { title: "Help Center", url: "/protected/help", diff --git a/components/connections/ConnectionStats.tsx b/components/connections/ConnectionStats.tsx new file mode 100644 index 00000000..a13cd30c --- /dev/null +++ b/components/connections/ConnectionStats.tsx @@ -0,0 +1,79 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { connectionService } from '@/lib/services/connectionService' +import { useAuth } from '@/lib/hooks/useAuth' +import { Card } from '@/components/ui/card' +import { Users, UserPlus } from 'lucide-react' + +interface ConnectionStatsProps { + onTabChange?: (tab: string) => void +} + +export function ConnectionStats({ onTabChange }: ConnectionStatsProps) { + const { user } = useAuth() + const [stats, setStats] = useState({ + following: 0, + followers: 0 + }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!user) return + + const loadStats = async () => { + try { + const [following, followers] = await Promise.all([ + connectionService.getFollowingCount(user.id), + connectionService.getFollowerCount(user.id) + ]) + setStats({ following, followers }) + } catch (error) { + console.error('Error loading connection stats:', error) + } finally { + setLoading(false) + } + } + + loadStats() + }, [user]) + + if (loading) { + return ( +
+ {[1, 2].map((i) => ( + +
+
+
+ + ))} +
+ ) + } + + return ( +
+ onTabChange?.('following')} + > +
+ + Following +
+

{stats.following}

+
+ onTabChange?.('followers')} + > +
+ + Followers +
+

{stats.followers}

+
+
+ ) +} diff --git a/components/connections/FollowersList.tsx b/components/connections/FollowersList.tsx new file mode 100644 index 00000000..7cfb379b --- /dev/null +++ b/components/connections/FollowersList.tsx @@ -0,0 +1,123 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { createClient } from '@/lib/supabase/client' +import { useAuth } from '@/lib/hooks/useAuth' +import { UserCard } from './UserCard' +import { Loader2, Users } from 'lucide-react' +import { connectionService } from '@/lib/services/connectionService' + +interface UserProfile { + id: string + first_name: string | null + last_name: string | null + username: string + avatar_url: string | null + bio: string | null +} + +export function FollowersList() { + const { user } = useAuth() + const [followers, setFollowers] = useState([]) + const [connectionStatuses, setConnectionStatuses] = useState>({}) + const [loading, setLoading] = useState(true) + + const loadFollowers = React.useCallback(async () => { + if (!user) return + + try { + setLoading(true) + const supabase = createClient() + + const { data, error } = await supabase + .from('user_connections') + .select(` + follower_id, + profiles:follower_id ( + id, + first_name, + last_name, + username, + avatar_url, + bio + ) + `) + .eq('following_id', user.id) + .order('created_at', { ascending: false }) + + if (error) throw error + + const users = (data || []) + .map(item => item.profiles as unknown) + .filter((profile: unknown): profile is UserProfile => + profile !== null && + typeof profile === 'object' && + 'id' in profile + ) + + setFollowers(users) + + // Load connection statuses for all followers + const statuses: Record = {} + await Promise.all( + users.map(async (profile) => { + const status = await connectionService.getConnectionStatus(profile.id) + statuses[profile.id] = status + }) + ) + setConnectionStatuses(statuses) + } catch (error) { + console.error('Error loading followers:', error) + } finally { + setLoading(false) + } + }, [user]) + + useEffect(() => { + loadFollowers() + }, [loadFollowers]) + + if (loading) { + return ( +
+ +
+ ) + } + + if (followers.length === 0) { + return ( +
+
+
+
+ +
+
+
+

No followers yet

+

+ When people follow you, they'll appear here. Keep engaging with the community! +

+
+
+
+ Share your profile to gain followers +
+
+ ) + } + + return ( +
+ {followers.map((profile) => ( + + ))} +
+ ) +} diff --git a/components/connections/FollowingList.tsx b/components/connections/FollowingList.tsx new file mode 100644 index 00000000..05b6fa5e --- /dev/null +++ b/components/connections/FollowingList.tsx @@ -0,0 +1,115 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { createClient } from '@/lib/supabase/client' +import { useAuth } from '@/lib/hooks/useAuth' +import { UserCard } from './UserCard' +import { Loader2, Users } from 'lucide-react' + +interface UserProfile { + id: string + first_name: string | null + last_name: string | null + username: string + avatar_url: string | null + bio: string | null +} + +export function FollowingList() { + const { user } = useAuth() + const [following, setFollowing] = useState([]) + const [loading, setLoading] = useState(true) + + const loadFollowing = React.useCallback(async () => { + if (!user) return + + try { + setLoading(true) + const supabase = createClient() + + const { data, error } = await supabase + .from('user_connections') + .select(` + following_id, + profiles:following_id ( + id, + first_name, + last_name, + username, + avatar_url, + bio + ) + `) + .eq('follower_id', user.id) + .order('created_at', { ascending: false }) + + if (error) throw error + + const users = (data || []) + .map(item => item.profiles as unknown) + .filter((profile: unknown): profile is UserProfile => + profile !== null && + typeof profile === 'object' && + 'id' in profile + ) + + setFollowing(users) + } catch (error) { + console.error('Error loading following:', error) + } finally { + setLoading(false) + } + }, [user]) + + useEffect(() => { + loadFollowing() + }, [loadFollowing]) + + if (loading) { + return ( +
+ +
+ ) + } + + if (following.length === 0) { + return ( +
+
+
+
+ +
+
+
+

No connections yet

+

+ Start following users to build your professional network and stay connected +

+
+
+
+ Try the Search tab to find people +
+
+ ) + } + + return ( +
+ {following.map((profile) => ( + + ))} +
+ ) +} diff --git a/components/connections/SearchUsers.tsx b/components/connections/SearchUsers.tsx new file mode 100644 index 00000000..347dbf14 --- /dev/null +++ b/components/connections/SearchUsers.tsx @@ -0,0 +1,145 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { UserCard } from './UserCard' +import { Loader2, Search } from 'lucide-react' +import { conversationService } from '@/lib/services/conversationService' +import { connectionService } from '@/lib/services/connectionService' + +interface UserProfile { + id: string + first_name: string | null + last_name: string | null + username: string + avatar_url: string | null + bio?: string | null +} + +interface SearchUsersProps { + searchQuery: string +} + +export function SearchUsers({ searchQuery }: SearchUsersProps) { + const [users, setUsers] = useState([]) + const [connectionStatuses, setConnectionStatuses] = useState>({}) + const [loading, setLoading] = useState(false) + + useEffect(() => { + const searchUsers = async () => { + if (searchQuery.trim().length < 2) { + setUsers([]) + return + } + + try { + setLoading(true) + const results = await conversationService.searchUsers(searchQuery) + setUsers(results) + + // Load connection statuses for all users + const statuses: Record = {} + await Promise.all( + results.map(async (user) => { + const status = await connectionService.getConnectionStatus(user.id) + statuses[user.id] = status + }) + ) + setConnectionStatuses(statuses) + } catch (error) { + console.error('Error searching users:', error) + } finally { + setLoading(false) + } + } + + const debounce = setTimeout(searchUsers, 300) + return () => clearTimeout(debounce) + }, [searchQuery]) + + const handleConnectionChange = async () => { + // Reload connection statuses + const statuses: Record = {} + await Promise.all( + users.map(async (user) => { + const status = await connectionService.getConnectionStatus(user.id) + statuses[user.id] = status + }) + ) + setConnectionStatuses(statuses) + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (searchQuery.trim().length < 2) { + return ( +
+
+
+
+ +
+
+
+

Discover New Connections

+

+ Search for users by name or username to expand your network +

+
+
+
+ Type at least 2 characters to start searching +
+
+ ) + } + + if (users.length === 0) { + return ( +
+
+
+ +
+
+
+

No users found

+

+ We couldn't find anyone matching "{searchQuery}" +

+
+

+ Try searching with a different name or username +

+
+ ) + } + + return ( +
+ {/* Result count header */} +
+

+ Found {users.length} {users.length === 1 ? 'user' : 'users'} +

+
+ + {/* User list */} +
+ {users.map((user) => ( + + ))} +
+
+ ) +} diff --git a/components/connections/UserCard.tsx b/components/connections/UserCard.tsx new file mode 100644 index 00000000..38f6228f --- /dev/null +++ b/components/connections/UserCard.tsx @@ -0,0 +1,218 @@ +'use client' + +import React, { useState } from 'react' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { MessageCircle, UserMinus, UserPlus, Eye, CheckCircle2, UserCheck } from 'lucide-react' +import { connectionService } from '@/lib/services/connectionService' +import { conversationService } from '@/lib/services/conversationService' +import { useRouter } from 'next/navigation' +import { UserProfileModal } from './UserProfileModal' + +interface UserCardProps { + user: { + id: string + first_name: string | null + last_name: string | null + username: string + avatar_url: string | null + bio?: string | null + } + connectionStatus?: { + isFollowing: boolean + isFollower: boolean + isMutual: boolean + } + onConnectionChange?: () => void + showMessageButton?: boolean +} + +export function UserCard({ user, connectionStatus, onConnectionChange, showMessageButton = true }: UserCardProps) { + const router = useRouter() + const [loading, setLoading] = useState(false) + const [localStatus, setLocalStatus] = useState(connectionStatus) + const [showProfileModal, setShowProfileModal] = useState(false) + + const name = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.username + const initials = `${user.first_name?.[0] || ''}${user.last_name?.[0] || ''}`.toUpperCase() || user.username[0].toUpperCase() + + const handleFollow = async () => { + try { + setLoading(true) + await connectionService.followUser(user.id) + setLocalStatus(prev => prev ? { ...prev, isFollowing: true, isMutual: prev.isFollower } : undefined) + onConnectionChange?.() + } catch (error) { + console.error('Error following user:', error) + alert(error instanceof Error ? error.message : 'Failed to follow user') + } finally { + setLoading(false) + } + } + + const handleUnfollow = async () => { + try { + setLoading(true) + await connectionService.unfollowUser(user.id) + setLocalStatus(prev => prev ? { ...prev, isFollowing: false, isMutual: false } : undefined) + onConnectionChange?.() + } catch (error) { + console.error('Error unfollowing user:', error) + alert(error instanceof Error ? error.message : 'Failed to unfollow user') + } finally { + setLoading(false) + } + } + + const handleMessage = async () => { + try { + setLoading(true) + const { canMessage, reason } = await conversationService.canMessageUser(user.id) + + if (!canMessage) { + alert(reason || 'Cannot message this user') + return + } + + const conversation = await conversationService.getOrCreateConversation(user.id) + router.push(`/protected/messages?conversation=${conversation.id}`) + } catch (error) { + console.error('Error creating conversation:', error) + alert(error instanceof Error ? error.message : 'Failed to create conversation') + } finally { + setLoading(false) + } + } + + const handleViewProfile = () => { + setShowProfileModal(true) + } + + return ( + <> +
+ {/* Clickable Avatar */} +
+ + {user.avatar_url && } + + {initials} + + +
+ +
+
+
+ {/* Clickable Name */} + +
+ + {localStatus?.isMutual && ( + + + Connected + + )} + {!localStatus?.isMutual && localStatus?.isFollowing && ( + + + Following + + )} + {!localStatus?.isMutual && localStatus?.isFollower && ( + + + Follows you + + )} +
+ {user.bio && ( +

{user.bio}

+ )} +
+ +
+ {/* View Profile Button */} + + + {showMessageButton && localStatus?.isMutual && ( + + )} + + {localStatus?.isFollowing ? ( + + ) : ( + + )} +
+
+
+
+ + {/* Profile Preview Modal */} + { + onConnectionChange?.() + // Refresh local status + setLocalStatus(prev => prev ? { ...prev } : undefined) + }} + /> + + ) +} diff --git a/components/connections/UserProfileModal.tsx b/components/connections/UserProfileModal.tsx new file mode 100644 index 00000000..7319f374 --- /dev/null +++ b/components/connections/UserProfileModal.tsx @@ -0,0 +1,350 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { + MapPin, + Briefcase, + Building, + Github, + Linkedin, + Twitter, + Loader2, + ExternalLink, + MessageCircle, + UserPlus, + UserMinus +} from 'lucide-react' +import { createClient } from '@/lib/supabase/client' +import { connectionService } from '@/lib/services/connectionService' +import { conversationService } from '@/lib/services/conversationService' +import { useRouter } from 'next/navigation' +import Link from 'next/link' + +interface UserProfileModalProps { + userId: string + open: boolean + onOpenChange: (open: boolean) => void + onConnectionChange?: () => void +} + +interface ProfileData { + id: string + first_name: string | null + last_name: string | null + username: string + avatar_url: string | null + bio: string | null + location: string | null + current_position: string | null + company: string | null + skills: string[] | null + github_url: string | null + linkedin_url: string | null + twitter_url: string | null + is_public: boolean +} + +export function UserProfileModal({ + userId, + open, + onOpenChange, + onConnectionChange +}: UserProfileModalProps) { + const router = useRouter() + const [profile, setProfile] = useState(null) + const [loading, setLoading] = useState(true) + const [actionLoading, setActionLoading] = useState(false) + const [connectionStatus, setConnectionStatus] = useState({ + isFollowing: false, + isFollower: false, + isMutual: false + }) + + const loadProfile = React.useCallback(async () => { + try { + setLoading(true) + const supabase = createClient() + const { data, error } = await supabase + .from('profiles') + .select('*') + .eq('id', userId) + .single() + + if (error) throw error + setProfile(data) + } catch (error) { + console.error('Error loading profile:', error) + } finally { + setLoading(false) + } + }, [userId]) + + const loadConnectionStatus = React.useCallback(async () => { + try { + const status = await connectionService.getConnectionStatus(userId) + setConnectionStatus(status) + } catch (error) { + console.error('Error loading connection status:', error) + } + }, [userId]) + + useEffect(() => { + if (open && userId) { + loadProfile() + loadConnectionStatus() + } + }, [open, userId, loadProfile, loadConnectionStatus]) + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && open) { + onOpenChange(false) + } + } + + if (open) { + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + } + }, [open, onOpenChange]) + + const handleFollow = async () => { + try { + setActionLoading(true) + await connectionService.followUser(userId) + await loadConnectionStatus() + onConnectionChange?.() + } catch (error) { + console.error('Error following user:', error) + alert(error instanceof Error ? error.message : 'Failed to follow user') + } finally { + setActionLoading(false) + } + } + + const handleUnfollow = async () => { + try { + setActionLoading(true) + await connectionService.unfollowUser(userId) + await loadConnectionStatus() + onConnectionChange?.() + } catch (error) { + console.error('Error unfollowing user:', error) + alert(error instanceof Error ? error.message : 'Failed to unfollow user') + } finally { + setActionLoading(false) + } + } + + const handleMessage = async () => { + try { + setActionLoading(true) + const { canMessage, reason } = await conversationService.canMessageUser(userId) + + if (!canMessage) { + alert(reason || 'Cannot message this user') + return + } + + const conversation = await conversationService.getOrCreateConversation(userId) + onOpenChange(false) + router.push(`/protected/messages?conversation=${conversation.id}`) + } catch (error) { + console.error('Error creating conversation:', error) + alert(error instanceof Error ? error.message : 'Failed to create conversation') + } finally { + setActionLoading(false) + } + } + + if (loading || !profile) { + return ( + + +
+ +
+
+
+ ) + } + + const name = `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || profile.username + const initials = `${profile.first_name?.[0] || ''}${profile.last_name?.[0] || ''}`.toUpperCase() || profile.username[0].toUpperCase() + + return ( + + + + User Profile + + + {/* Profile Header */} +
+ + {profile.avatar_url && } + + {initials} + + + +
+

{name}

+

@{profile.username}

+
+ + {/* Connection Badges */} +
+ {connectionStatus.isMutual && ( + Connected + )} + {!connectionStatus.isMutual && connectionStatus.isFollower && ( + Follows you + )} +
+ + {/* Action Buttons */} +
+ {connectionStatus.isMutual && ( + + )} + + {connectionStatus.isFollowing ? ( + + ) : ( + + )} + + +
+
+ + + + {/* Profile Details */} +
+ {/* Bio */} + {profile.bio && ( +
+

About

+

{profile.bio}

+
+ )} + + {/* Professional Info */} + {(profile.current_position || profile.company || profile.location) && ( +
+ {profile.current_position && ( +
+ + {profile.current_position} +
+ )} + {profile.company && ( +
+ + {profile.company} +
+ )} + {profile.location && ( +
+ + {profile.location} +
+ )} +
+ )} + + {/* Skills */} + {profile.skills && profile.skills.length > 0 && ( +
+

Skills

+
+ {profile.skills.map((skill, index) => ( + + {skill} + + ))} +
+
+ )} + + {/* Social Links */} + {(profile.github_url || profile.linkedin_url || profile.twitter_url) && ( +
+

Social Links

+
+ {profile.github_url && ( + + )} + {profile.linkedin_url && ( + + )} + {profile.twitter_url && ( + + )} +
+
+ )} +
+
+
+ ) +} diff --git a/components/connections/index.ts b/components/connections/index.ts new file mode 100644 index 00000000..b0d99f17 --- /dev/null +++ b/components/connections/index.ts @@ -0,0 +1,5 @@ +export { ConnectionStats } from './ConnectionStats' +export { FollowingList } from './FollowingList' +export { FollowersList } from './FollowersList' +export { SearchUsers } from './SearchUsers' +export { UserCard } from './UserCard'