-
-
-
+
);
}
diff --git a/src/app/profile/components/ProfileTabs.tsx b/src/app/profile/components/ProfileTabs.tsx
index 29970d06..e835d692 100644
--- a/src/app/profile/components/ProfileTabs.tsx
+++ b/src/app/profile/components/ProfileTabs.tsx
@@ -2,7 +2,7 @@
import dynamic from 'next/dynamic';
import { memo, useCallback, useState } from 'react';
-import type { ProfileTabId } from '../profile-data';
+import type { ProfileTabId, ProfileUser } from '../profile-data';
import { profileTabs } from '../profile-data';
import ProfileInfoPanel from './ProfileInfoPanel';
import ProfilePanelSkeleton from './ProfilePanelSkeleton';
@@ -58,7 +58,11 @@ const ProfileTabButton = memo(function ProfileTabButton({
);
});
-export default function ProfileTabs() {
+interface ProfileTabsProps {
+ initialUser?: ProfileUser;
+}
+
+export default function ProfileTabs({ initialUser }: ProfileTabsProps = {}) {
const [activeTab, setActiveTab] = useState
('profile');
const handleTabChange = useCallback((tabId: ProfileTabId) => {
@@ -78,7 +82,7 @@ export default function ProfileTabs() {
))}
- {activeTab === 'profile' &&
}
+ {activeTab === 'profile' &&
}
{activeTab === 'settings' &&
}
{activeTab === 'achievements' &&
}
{activeTab === 'support' &&
}
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
index f3e0251a..afcd7507 100644
--- a/src/app/profile/page.tsx
+++ b/src/app/profile/page.tsx
@@ -1,9 +1,10 @@
import type { Metadata } from 'next';
import ProfileHeader from './components/ProfileHeader';
import ProfileTabs from './components/ProfileTabs';
-import { profileUser } from './profile-data';
+import { getAuthenticatedUserProfile } from '@/lib/auth/userProfile';
export async function generateMetadata(): Promise
{
+ const profileUser = await getAuthenticatedUserProfile();
return {
title: `${profileUser.name} | TeachLink`,
description: `View the profile of ${profileUser.name} on TeachLink.`,
@@ -22,13 +23,15 @@ export async function generateMetadata(): Promise {
};
}
-export default function Profile() {
+export default async function Profile() {
+ const profileUser = await getAuthenticatedUserProfile();
+
return (
);
diff --git a/src/hooks/__tests__/useUserProfile.test.tsx b/src/hooks/__tests__/useUserProfile.test.tsx
new file mode 100644
index 00000000..3a7e5e28
--- /dev/null
+++ b/src/hooks/__tests__/useUserProfile.test.tsx
@@ -0,0 +1,87 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { useUserProfile } from '../useUserProfile';
+
+describe('useUserProfile Hook', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('uses initialUser if provided without fetching', () => {
+ const customUser = {
+ initials: 'AS',
+ name: 'Alice Smith',
+ email: 'alice@example.com',
+ bio: 'Developer & Instructor',
+ learningGoal: 'monthly-course',
+ dailyLearningTime: '1-hour',
+ avatarUrl: '/avatars/alice.png',
+ };
+
+ const { result } = renderHook(() => useUserProfile(customUser));
+
+ expect(result.current.user.name).toBe('Alice Smith');
+ expect(result.current.user.email).toBe('alice@example.com');
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('fetches profile data from API if no initialUser is provided', async () => {
+ const mockProfile = {
+ initials: 'JD',
+ name: 'Jane Doe',
+ email: 'jane@example.com',
+ bio: 'Web3 developer',
+ learningGoal: 'smart-contracts',
+ dailyLearningTime: '30-minutes',
+ avatarUrl: '/avatars/jane.png',
+ };
+
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, data: mockProfile }),
+ });
+
+ const { result } = renderHook(() => useUserProfile());
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.user.name).toBe('Jane Doe');
+ expect(result.current.user.email).toBe('jane@example.com');
+ });
+
+ it('calls PUT API when updateProfile is invoked', async () => {
+ const initialUser = {
+ initials: 'JD',
+ name: 'John Doe',
+ email: 'john@example.com',
+ bio: 'Initial bio',
+ learningGoal: 'monthly-course',
+ dailyLearningTime: '30-minutes',
+ avatarUrl: '/avatars/default.png',
+ };
+
+ const updatedUser = {
+ ...initialUser,
+ name: 'John Smith',
+ bio: 'Updated bio',
+ };
+
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true, data: updatedUser }),
+ });
+
+ const { result } = renderHook(() => useUserProfile(initialUser));
+
+ let success = false;
+ await act(async () => {
+ success = await result.current.updateProfile({ name: 'John Smith', bio: 'Updated bio' });
+ });
+
+ expect(success).toBe(true);
+ expect(result.current.user.name).toBe('John Smith');
+ expect(result.current.user.bio).toBe('Updated bio');
+ });
+});
diff --git a/src/hooks/useUserProfile.ts b/src/hooks/useUserProfile.ts
new file mode 100644
index 00000000..e11cdf7d
--- /dev/null
+++ b/src/hooks/useUserProfile.ts
@@ -0,0 +1,90 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import type { ProfileUser } from '@/app/profile/profile-data';
+import { profileUser as defaultProfileUser } from '@/app/profile/profile-data';
+import { useToast } from '@/context/ToastContext';
+
+export function useUserProfile(initialUser?: ProfileUser) {
+ const [user, setUser] = useState(initialUser ?? defaultProfileUser);
+ const [isLoading, setIsLoading] = useState(!initialUser);
+ const [error, setError] = useState(null);
+
+ let successFn: ((msg: string) => void) | undefined;
+ let toastErrorFn: ((msg: string) => void) | undefined;
+
+ try {
+ const toastContext = useToast();
+ successFn = toastContext.success;
+ toastErrorFn = toastContext.error;
+ } catch {
+ // Fallback if ToastProvider is not present in test environment
+ }
+
+ const fetchProfile = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const res = await fetch('/api/user/profile');
+ if (!res.ok) {
+ throw new Error(`Failed to fetch profile: ${res.statusText}`);
+ }
+ const result = await res.json();
+ if (result.success && result.data) {
+ setUser(result.data);
+ }
+ } catch (err) {
+ const errorObj = err instanceof Error ? err : new Error(String(err));
+ setError(errorObj);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!initialUser) {
+ fetchProfile();
+ }
+ }, [initialUser, fetchProfile]);
+
+ const updateProfile = useCallback(
+ async (updatedData: Partial): Promise => {
+ setIsLoading(true);
+ try {
+ const res = await fetch('/api/user/profile', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updatedData),
+ });
+
+ if (!res.ok) {
+ throw new Error('Failed to update profile');
+ }
+
+ const result = await res.json();
+ if (result.success && result.data) {
+ setUser(result.data);
+ if (successFn) successFn('Profile updated successfully!');
+ return true;
+ } else {
+ throw new Error(result.message || 'Failed to update profile');
+ }
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Error updating profile';
+ if (toastErrorFn) toastErrorFn(msg);
+ return false;
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [successFn, toastErrorFn],
+ );
+
+ return {
+ user,
+ isLoading,
+ error,
+ refetch: fetchProfile,
+ updateProfile,
+ };
+}
diff --git a/src/lib/auth/userProfile.ts b/src/lib/auth/userProfile.ts
new file mode 100644
index 00000000..770dd101
--- /dev/null
+++ b/src/lib/auth/userProfile.ts
@@ -0,0 +1,90 @@
+import { cookies, headers } from 'next/headers';
+import { verifyToken } from './jwt';
+import { findUserByEmail } from '@/lib/db/pool';
+import type { ProfileUser } from '@/app/profile/profile-data';
+import { profileUser as defaultProfileUser } from '@/app/profile/profile-data';
+
+export function getInitials(name: string): string {
+ if (!name || !name.trim()) return 'U';
+ const parts = name.trim().split(/\s+/);
+ if (parts.length === 1) {
+ return parts[0].substring(0, 2).toUpperCase();
+ }
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+}
+
+/**
+ * Format email or username into a human-readable Full Name
+ * e.g. "john.doe@example.com" -> "John Doe"
+ */
+export function formatNameFromEmail(email: string): string {
+ if (!email || !email.includes('@')) return 'Authenticated User';
+ const username = email.split('@')[0];
+ const parts = username.split(/[._-]/);
+ return parts
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
+ .join(' ');
+}
+
+/**
+ * Server-side helper to fetch or construct the profile of the current authenticated user.
+ */
+export async function getAuthenticatedUserProfile(
+ explicitToken?: string | null,
+): Promise {
+ let token = explicitToken;
+
+ if (!token) {
+ try {
+ const headerStore = await headers();
+ const authHeader = headerStore.get('authorization');
+ if (authHeader && authHeader.startsWith('Bearer ')) {
+ token = authHeader.substring(7);
+ }
+ if (!token) {
+ const cookieStore = await cookies();
+ token =
+ cookieStore.get('Authorization')?.value ??
+ cookieStore.get('auth-token')?.value ??
+ cookieStore.get('token')?.value;
+ }
+ } catch {
+ // Called in context where next/headers is not available
+ }
+ }
+
+ if (!token) {
+ return defaultProfileUser;
+ }
+
+ const payload = await verifyToken(token);
+ if (!payload) {
+ return defaultProfileUser;
+ }
+
+ const email = payload.email || (payload.sub.includes('@') ? payload.sub : `${payload.sub}@example.com`);
+
+ // Try to lookup user record from database pool if available
+ let dbUser = null;
+ try {
+ if (email) {
+ dbUser = await findUserByEmail(email);
+ }
+ } catch {
+ // Database query error or pool uninitialized; proceed with token payload
+ }
+
+ const name = dbUser?.id ? formatNameFromEmail(email) : (email ? formatNameFromEmail(email) : 'Authenticated User');
+ const initials = getInitials(name);
+
+ return {
+ initials,
+ name,
+ email,
+ bio: defaultProfileUser.bio,
+ learningGoal: defaultProfileUser.learningGoal,
+ dailyLearningTime: defaultProfileUser.dailyLearningTime,
+ avatarUrl: defaultProfileUser.avatarUrl,
+ };
+}