From 8be2ce57094e447519e0c35295ee384e901e4fa9 Mon Sep 17 00:00:00 2001 From: teslims2 <38410456+teslims2@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:16:22 +0000 Subject: [PATCH] feat(dashboard): add dashboard page with user summary, progress, token balance, and credentials --- apps/frontend/src/__tests__/mocks/handlers.ts | 26 +++ .../__tests__/pages/DashboardPage.test.tsx | 46 ++++ .../src/app/[locale]/dashboard/page.tsx | 3 + apps/frontend/src/app/dashboard/page.tsx | 209 ++++++++++++++++++ apps/frontend/src/components/Navbar.tsx | 34 +-- 5 files changed, 291 insertions(+), 27 deletions(-) create mode 100644 apps/frontend/src/__tests__/pages/DashboardPage.test.tsx create mode 100644 apps/frontend/src/app/[locale]/dashboard/page.tsx create mode 100644 apps/frontend/src/app/dashboard/page.tsx diff --git a/apps/frontend/src/__tests__/mocks/handlers.ts b/apps/frontend/src/__tests__/mocks/handlers.ts index a334bbd..4345df8 100644 --- a/apps/frontend/src/__tests__/mocks/handlers.ts +++ b/apps/frontend/src/__tests__/mocks/handlers.ts @@ -24,6 +24,32 @@ export const handlers = [ avatarUrl: '', bio: '', createdAt: '2024-01-01T00:00:00.000Z', + stellarPublicKey: 'GABC...', }), ), + + http.get(`${BASE}/users/user-1/token-balance`, () => + HttpResponse.json({ balance: 850 }), + ), + + http.get(`${BASE}/users/user-1/progress`, () => + HttpResponse.json([ + { id: 'progress-1', userId: 'user-1', courseId: '1', progressPct: 45 }, + { id: 'progress-2', userId: 'user-1', courseId: '2', progressPct: 100 }, + ]), + ), + + http.get(`${BASE}/credentials/user-1`, () => + HttpResponse.json([ + { id: 'cred-123', userId: 'user-1', courseId: '2', issuedAt: '2026-03-28T15:00:00.000Z', course: { id: '2', title: 'Soroban Smart Contracts' } }, + ]), + ), + + http.get(`${BASE}/courses/1`, () => + HttpResponse.json({ id: '1', title: 'Intro to Stellar Blockchain' }), + ), + + http.get(`${BASE}/courses/2`, () => + HttpResponse.json({ id: '2', title: 'Soroban Smart Contracts' }), + ), ]; diff --git a/apps/frontend/src/__tests__/pages/DashboardPage.test.tsx b/apps/frontend/src/__tests__/pages/DashboardPage.test.tsx new file mode 100644 index 0000000..9144814 --- /dev/null +++ b/apps/frontend/src/__tests__/pages/DashboardPage.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { server } from '../mocks/server'; + +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/dashboard' })); +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: any) => {children}, +})); + +vi.mock('@/lib/auth-context', () => ({ + useAuth: () => ({ + state: { + user: { id: 'user-1', username: 'testuser', email: 'test@example.com' }, + token: 'fake-token', + isLoading: false, + }, + dispatch: vi.fn(), + }), +})); + +import DashboardPage from '@/app/dashboard/page'; + +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('DashboardPage', () => { + it('renders welcome section and data from API', async () => { + render(); + + expect(screen.getByText(/Welcome back/i)).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByText(/BST Token Balance/i)).toBeInTheDocument()); + expect(screen.getByText(/850 BST/i)).toBeInTheDocument(); + + expect(screen.getByText('Intro to Stellar Blockchain')).toBeInTheDocument(); + expect(screen.getByText('Soroban Smart Contracts')).toBeInTheDocument(); + + expect(screen.getByText(/Recent Credentials/i)).toBeInTheDocument(); + expect(screen.getByText('Soroban Smart Contracts')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/[locale]/dashboard/page.tsx b/apps/frontend/src/app/[locale]/dashboard/page.tsx new file mode 100644 index 0000000..33869db --- /dev/null +++ b/apps/frontend/src/app/[locale]/dashboard/page.tsx @@ -0,0 +1,3 @@ +import DashboardPage from '@/app/dashboard/page'; + +export default DashboardPage; diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..d857d8b --- /dev/null +++ b/apps/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useAuth } from '@/lib/auth-context'; +import api from '@/lib/api'; +import { ProtectedRoute } from '@/components/ProtectedRoute'; + +interface UserData { + id: string; + username: string; + email: string; +} + +interface ProgressRecord { + id: string; + courseId: string; + progressPct: number; +} + +interface CredentialRecord { + id: string; + courseId: string; + issuedAt: string; + course?: { id: string; title: string }; +} + +interface CourseData { + id: string; + title: string; +} + +function SkeletonItem({ width = 'w-full', height = 'h-6' }: { width?: string; height?: string }) { + return
; +} + +export default function DashboardPage() { + const { state } = useAuth(); + const [user, setUser] = useState(state.user ? { id: state.user.id, username: state.user.username, email: state.user.email } : null); + const [tokenBalance, setTokenBalance] = useState(null); + const [progress, setProgress] = useState([]); + const [courses, setCourses] = useState>({}); + const [credentials, setCredentials] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadDashboard() { + try { + if (!state.token && !state.isLoading) { + // ProtectedRoute will handle redirect; do not fetch yet. + return; + } + + let currentUser = user; + if (!currentUser) { + const { data } = await api.get('/users/me'); + currentUser = { id: data.id, username: data.username, email: data.email }; + setUser(currentUser); + } + + if (!currentUser?.id) { + throw new Error('User information is missing.'); + } + + const [balanceRes, progressRes, credRes] = await Promise.all([ + api.get(`/users/${currentUser.id}/token-balance`), + api.get(`/users/${currentUser.id}/progress`), + api.get(`/credentials/${currentUser.id}`), + ]); + + setTokenBalance(Number(balanceRes.data.balance ?? 0)); + + const progressRecords: ProgressRecord[] = (progressRes.data ?? []).map((p: any) => ({ + id: p.id, + courseId: p.courseId, + progressPct: p.progressPct ?? 0, + })); + + setProgress(progressRecords); + + const credentialsList: CredentialRecord[] = (credRes.data ?? []).map((c: any) => ({ + id: c.id, + courseId: c.courseId, + issuedAt: c.issuedAt ?? c.createdAt ?? '', + course: c.course ? { id: c.course.id, title: c.course.title } : undefined, + })); + + setCredentials(credentialsList.sort((a, b) => Number(new Date(b.issuedAt)) - Number(new Date(a.issuedAt)))); + + const courseIds = Array.from(new Set(progressRecords.map((p) => p.courseId))); + const courseMap: Record = {}; + + await Promise.all( + courseIds.map(async (courseId) => { + try { + const { data } = await api.get(`/courses/${courseId}`); + const course = data?.data ?? data; + if (course) { + courseMap[course.id] = { id: course.id, title: course.title }; + } + } catch { + // ignore missing course details + } + }), + ); + + setCourses(courseMap); + } catch (err) { + setError('Unable to load dashboard information. Please refresh.'); + } finally { + setIsLoading(false); + } + } + + if (!state.isLoading) loadDashboard(); + }, [state.isLoading, state.token, user]); + + const enrolledCourses = useMemo(() => { + return progress.map((record) => ({ + ...record, + title: courses[record.courseId]?.title ?? `Course ${record.courseId}`, + })); + }, [progress, courses]); + + const recentCredentials = useMemo(() => { + return credentials.slice(0, 5); + }, [credentials]); + + return ( + +
+
+ {isLoading ? ( +
+ + +
+ ) : ( +
+

+ Welcome back, {user?.username ?? user?.email ?? 'Student'} +

+

{user?.email}

+
+ )} +
+ + {error && ( +
+ {error} +
+ )} + +
+

BST Token Balance

+
+ {isLoading ? ( + + ) : ( +

{tokenBalance ?? 0} BST

+ )} +
+
+ +
+

Enrolled Courses

+
+ {isLoading + ? Array.from({ length: 3 }).map((_, idx) => ( +
+ +
+
+ )) + : enrolledCourses.length === 0 + ?

You have not enrolled in any courses yet.

+ : enrolledCourses.map((course) => ( +
+
+

{course.title}

+ {course.progressPct}% +
+
+
+
+
+ ))} +
+
+ +
+

Recent Credentials

+
+ {isLoading + ? Array.from({ length: 3 }).map((_, idx) => ) + : recentCredentials.length === 0 + ?

You have not earned any credentials yet.

+ : recentCredentials.map((cred) => ( +
+ {cred.course?.title ?? `Course ${cred.courseId}`} + {new Date(cred.issuedAt).toLocaleDateString()} +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/Navbar.tsx b/apps/frontend/src/components/Navbar.tsx index 51271d0..cc89de5 100644 --- a/apps/frontend/src/components/Navbar.tsx +++ b/apps/frontend/src/components/Navbar.tsx @@ -19,6 +19,13 @@ export function Navbar() { {t('brand')}
+ + Dashboard + - -import { ThemeToggle } from './ThemeToggle'; - -export function Navbar() { - return ( -