Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/frontend/src/__tests__/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export const handlers = [
avatarUrl: '',
bio: '',
createdAt: '2024-01-01T00:00:00.000Z',
stellarPublicKey: 'GABC...',
}),
})
),

Expand All @@ -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' }),
),
];
46 changes: 46 additions & 0 deletions apps/frontend/src/__tests__/pages/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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' }));

Check failure on line 5 in apps/frontend/src/__tests__/pages/DashboardPage.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend - Install, Build, Lint

Replace `·useRouter:·()·=>·({·push:·vi.fn()·}),·usePathname:·()·=>·'/dashboard'·` with `⏎··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) => <a href={href} {...props}>{children}</a>,

Check failure on line 11 in apps/frontend/src/__tests__/pages/DashboardPage.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend - Install, Build, Lint

Replace `<a·href={href}·{...props}>{children}</a>` with `(⏎····<a·href={href}·{...props}>⏎······{children}⏎····</a>⏎··)`

Check warning on line 11 in apps/frontend/src/__tests__/pages/DashboardPage.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend - Install, Build, Lint

Unexpected any. Specify a different type
}));

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(<DashboardPage />);

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();
});
});
3 changes: 3 additions & 0 deletions apps/frontend/src/app/[locale]/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import DashboardPage from '@/app/dashboard/page';

export default DashboardPage;
209 changes: 209 additions & 0 deletions apps/frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className={`bg-gray-200 dark:bg-gray-700 rounded ${width} ${height} animate-pulse`} />;
}

export default function DashboardPage() {
const { state } = useAuth();
const [user, setUser] = useState<UserData | null>(state.user ? { id: state.user.id, username: state.user.username, email: state.user.email } : null);
const [tokenBalance, setTokenBalance] = useState<number | null>(null);
const [progress, setProgress] = useState<ProgressRecord[]>([]);
const [courses, setCourses] = useState<Record<string, CourseData>>({});
const [credentials, setCredentials] = useState<CredentialRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, CourseData> = {};

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 (
<ProtectedRoute>
<main className="max-w-5xl mx-auto p-8 space-y-8">
<section>
{isLoading ? (
<div className="space-y-2">
<SkeletonItem width="w-48" height="h-8" />
<SkeletonItem width="w-64" height="h-5" />
</div>
) : (
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
Welcome back, {user?.username ?? user?.email ?? 'Student'}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">{user?.email}</p>
</div>
)}
</section>

{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700 dark:border-red-700 dark:bg-red-900/20">
{error}
</div>
)}

<section>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">BST Token Balance</h2>
<div className="mt-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
{isLoading ? (
<SkeletonItem width="w-32" height="h-7" />
) : (
<p className="text-3xl font-bold text-green-600 dark:text-green-400">{tokenBalance ?? 0} BST</p>
)}
</div>
</section>

<section>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Enrolled Courses</h2>
<div className="mt-3 space-y-4">
{isLoading
? Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="space-y-2">
<SkeletonItem width="w-2/5" height="h-5" />
<div className="h-3 w-full rounded bg-gray-200 dark:bg-gray-700" />
</div>
))
: enrolledCourses.length === 0
? <p className="text-gray-500 dark:text-gray-400">You have not enrolled in any courses yet.</p>
: enrolledCourses.map((course) => (
<div key={course.id} className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-gray-800 dark:text-gray-100">{course.title}</h3>
<span className="text-sm text-gray-500 dark:text-gray-400">{course.progressPct}%</span>
</div>
<div className="mt-2 h-3 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div className="h-full rounded-full bg-blue-500" style={{ width: `${course.progressPct}%` }} />
</div>
</div>
))}
</div>
</section>

<section>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Recent Credentials</h2>
<div className="mt-3 space-y-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
{isLoading
? Array.from({ length: 3 }).map((_, idx) => <SkeletonItem key={idx} width="w-full" height="h-6" />)
: recentCredentials.length === 0
? <p className="text-gray-500 dark:text-gray-400">You have not earned any credentials yet.</p>
: recentCredentials.map((cred) => (
<div key={cred.id} className="flex justify-between items-center text-sm text-gray-700 dark:text-gray-300">
<span>{cred.course?.title ?? `Course ${cred.courseId}`}</span>
<span>{new Date(cred.issuedAt).toLocaleDateString()}</span>
</div>
))}
</div>
</section>
</main>
</ProtectedRoute>
);
}
7 changes: 7 additions & 0 deletions apps/frontend/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ export function Navbar() {
{t('brand')}
</Link>
<div className="flex items-center gap-4">
<Link
href="/dashboard"
aria-current={isActive('/dashboard') ? 'page' : undefined}
className="text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Dashboard
</Link>
<Link
href="/courses"
aria-current={isActive('/courses') ? 'page' : undefined}
Expand Down
Loading