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 */}
+
+
+ {/* 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 (
+
+ )
+}
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'