diff --git a/apps/frontend/src/__tests__/mocks/handlers.ts b/apps/frontend/src/__tests__/mocks/handlers.ts
index 81b4113..eee874c 100644
--- a/apps/frontend/src/__tests__/mocks/handlers.ts
+++ b/apps/frontend/src/__tests__/mocks/handlers.ts
@@ -36,6 +36,8 @@ export const handlers = [
avatarUrl: '',
bio: '',
createdAt: '2024-01-01T00:00:00.000Z',
+ stellarPublicKey: 'GABC...',
+ }),
})
),
@@ -50,4 +52,29 @@ export const handlers = [
},
])
),
+
+ 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 42152b7..59d04c5 100644
--- a/apps/frontend/src/components/Navbar.tsx
+++ b/apps/frontend/src/components/Navbar.tsx
@@ -22,6 +22,13 @@ export function Navbar() {
{t('brand')}
+
+ Dashboard
+