From ade4d4db1a6dc9a5cdf98ce27986564876bcc816 Mon Sep 17 00:00:00 2001 From: Dev Fashman Date: Sat, 29 Aug 2026 03:31:14 -0400 Subject: [PATCH] feat: add shared UI components for leaderboard, progress, stats and notifications Closes #253 (stats-card), #254 (course-progress-card), #255 (leaderboard-table), #256 (notification-bell). - Add reusable StatsCard with icon, value, label, theme and trend indicator; use it in the dashboard stats grid - Add CourseProgressCard with animated progress, module count, completion status and continue action; use it in the dashboard My Courses section - Add LeaderboardTable with rank medallions, current-user highlight and a compact sidebar mode - Add notification system (types, API client, hook) and NotificationBell in the header with unread badge, read/unread states, empty and error states - Fix pre-existing syntax errors in loading-skeleton, module-list, quiz-interface and layout (broken imports / unclosed tags) that broke typecheck and tests --- src/app/dashboard/page.tsx | 101 ++++----- src/app/layout.tsx | 2 - .../course/course-progress-card.tsx | 90 ++++++++ src/components/course/module-list.tsx | 2 - src/components/course/quiz-interface.tsx | 1 + src/components/layout/header.tsx | 2 + src/components/shared/leaderboard-table.tsx | 154 ++++++++++++++ src/components/shared/loading-skeleton.tsx | 1 + src/components/shared/notification-bell.tsx | 199 ++++++++++++++++++ src/components/shared/stats-card.tsx | 105 +++++++++ src/lib/api/notifications.ts | 34 +++ src/lib/hooks/use-notifications.ts | 89 ++++++++ src/lib/utils/format.ts | 18 ++ .../course/course-progress-card.test.tsx | 64 ++++++ src/tests/layout/header.test.tsx | 41 +++- src/tests/shared/leaderboard-table.test.tsx | 59 ++++++ src/tests/shared/notification-bell.test.tsx | 125 +++++++++++ src/tests/shared/stats-card.test.tsx | 68 ++++++ src/types/notification.ts | 15 ++ 19 files changed, 1097 insertions(+), 73 deletions(-) create mode 100644 src/components/course/course-progress-card.tsx create mode 100644 src/components/shared/leaderboard-table.tsx create mode 100644 src/components/shared/notification-bell.tsx create mode 100644 src/components/shared/stats-card.tsx create mode 100644 src/lib/api/notifications.ts create mode 100644 src/lib/hooks/use-notifications.ts create mode 100644 src/tests/course/course-progress-card.test.tsx create mode 100644 src/tests/shared/leaderboard-table.test.tsx create mode 100644 src/tests/shared/notification-bell.test.tsx create mode 100644 src/tests/shared/stats-card.test.tsx create mode 100644 src/types/notification.ts diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 99ab5de..19fe9bb 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -4,13 +4,14 @@ import { useRequireAuth } from "@/lib/hooks/use-require-auth"; import { useCourses, useRecommendedCourses } from "@/lib/hooks/use-courses"; import { useRewards } from "@/lib/hooks/use-rewards"; import { useCredentials } from "@/lib/hooks/use-credentials"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { CourseCard } from "@/components/course/course-card"; -import { ProgressBar } from "@/components/course/progress-bar"; +import { CourseProgressCard } from "@/components/course/course-progress-card"; import { BalanceDisplay } from "@/components/wallet/balance-display"; import { CredentialCard } from "@/components/credentials/credential-card"; import { DashboardSkeleton } from "@/components/shared/loading-skeleton"; import { EmptyState } from "@/components/shared/empty-state"; +import { StatsCard } from "@/components/shared/stats-card"; import { useCourseStore } from "@/store/course-store"; import { BookOpen, @@ -76,52 +77,30 @@ export default function DashboardPage() { {/* Stats */}
- - -
- -
-
-

{enrollments.length}

-

Enrolled Courses

-
-
-
- - -
- -
-
-

- {enrollments.filter((e) => e.progress === 100).length} -

-

Completed

-
-
-
- - -
- -
-
-

{totalBalance.toFixed(1)}

-

LEARN Tokens

-
-
-
- - -
- -
-
-

{credentials.length}

-

Credentials

-
-
-
+ + e.progress === 100).length} + color="success" + /> + +
@@ -174,21 +153,17 @@ export default function DashboardPage() { ) : (
{enrolledCourses.map(({ enrollment, course }) => ( - - -

- {course?.title || "Course"} -

- - - - -
-
+ ))}
)} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index effb2e0..290d9e7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -51,8 +51,6 @@ export const metadata: Metadata = { }, }; -import { PageTransition } from "@/components/shared/page-transition"; - export default function RootLayout({ children, }: { diff --git a/src/components/course/course-progress-card.tsx b/src/components/course/course-progress-card.tsx new file mode 100644 index 0000000..0e703a6 --- /dev/null +++ b/src/components/course/course-progress-card.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { memo } from "react"; +import Link from "next/link"; +import { cn } from "@/lib/utils/cn"; +import { Card, CardContent } from "@/components/ui/card"; +import { ProgressBar } from "@/components/course/progress-bar"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ArrowRight, CheckCircle2, Layers } from "lucide-react"; + +export interface CourseProgressCardProps { + courseId: string; + courseTitle: string; + /** 0-100 completion percentage. */ + progress: number; + completedModules?: number; + moduleCount?: number; + /** Optional href for the continue action. Defaults to the course detail page. */ + continueHref?: string; + className?: string; +} + +/** + * Dedicated card for an enrolled course on the dashboard. Shows the course + * title, an animated progress bar, module count and a quick continue action, + * plus explicit completion status. + */ +export const CourseProgressCard = memo(function CourseProgressCard({ + courseId, + courseTitle, + progress, + completedModules, + moduleCount, + continueHref, + className, +}: CourseProgressCardProps) { + const clamped = Math.min(100, Math.max(0, progress)); + const isComplete = clamped >= 100; + + return ( + + +
+

+ {courseTitle} +

+ + {isComplete ? "Completed" : "In Progress"} + +
+ + {moduleCount !== undefined && ( +

+

+ )} + + + +
+ + + +
+
+
+ ); +}); \ No newline at end of file diff --git a/src/components/course/module-list.tsx b/src/components/course/module-list.tsx index 7fbbe59..a421126 100644 --- a/src/components/course/module-list.tsx +++ b/src/components/course/module-list.tsx @@ -98,8 +98,6 @@ export function ModuleList({ aria-current={isCurrent ? "step" : undefined} className={cn( "flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50 cursor-pointer dark:hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:outline-none", - isCurrent && "bg-primary-50 border border-primary-200" - "flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50 cursor-pointer dark:hover:bg-gray-800", isCurrent && "bg-primary-50 border border-primary-200 dark:bg-primary-900/30 dark:border-primary-800" )} > diff --git a/src/components/course/quiz-interface.tsx b/src/components/course/quiz-interface.tsx index 5bdf8f1..31a2368 100644 --- a/src/components/course/quiz-interface.tsx +++ b/src/components/course/quiz-interface.tsx @@ -125,6 +125,7 @@ export function QuizInterface({ quiz, onSubmit, onRetry, className }: QuizInterf

You need {quiz.passingScore}% to pass. +

)} {!attempt.passed && onRetry && ( diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index a1b42db..b7d4ebb 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -22,6 +22,7 @@ import { import { LogOut, Settings, User } from "lucide-react"; import { Sun, Moon } from "lucide-react"; import { useTheme } from "@/components/theme/theme-provider"; +import { NotificationBell } from "@/components/shared/notification-bell"; export function Header() { const { isAuthenticated, walletAddress } = useAuthStore(); @@ -63,6 +64,7 @@ export function Header() { {/* Right side */}
+ {isAuthenticated && } {isAuthenticated && walletAddress && ( diff --git a/src/components/shared/leaderboard-table.tsx b/src/components/shared/leaderboard-table.tsx new file mode 100644 index 0000000..ba131aa --- /dev/null +++ b/src/components/shared/leaderboard-table.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { memo } from "react"; +import { cn } from "@/lib/utils/cn"; +import { + Avatar, + AvatarFallback, + AvatarImage, + getInitials, +} from "@/components/ui/avatar"; +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { formatNumber } from "@/lib/utils/format"; +import { Trophy } from "lucide-react"; + +export interface LeaderboardEntry { + id: string; + name: string; + score: number; + avatarUrl?: string; +} + +export interface LeaderboardTableProps { + entries: LeaderboardEntry[]; + /** Highlight this entry (usually the signed-in user). */ + currentUserId?: string; + /** Compact mode for sidebars — tighter cells and no avatars. */ + compact?: boolean; + className?: string; +} + +const rankStyles = [ + "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/60 dark:text-yellow-300", + "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200", + "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300", +]; + +function RankCell({ rank, compact }: { rank: number; compact: boolean }) { + if (rank > 3) { + return ( + + {rank} + + ); + } + const styles = rankStyles[rank - 1]; + return ( + + {rank} + + ); +} + +/** + * Leaderboard table showing top learners by score with their rank. Supports a + * compact mode for sidebars and a full mode for page-level displays. The + * current user's row is highlighted and flagged with a "You" badge. + */ +export const LeaderboardTable = memo(function LeaderboardTable({ + entries, + currentUserId, + compact = false, + className, +}: LeaderboardTableProps) { + return ( + + + + Rank + Name + Score + + + + {entries.map((entry, index) => { + const rank = index + 1; + const isCurrent = entry.id === currentUserId; + return ( + + + + + +
+ {!compact && ( + + {entry.avatarUrl ? ( + + ) : null} + {getInitials(entry.name)} + + )} + + {entry.name} + + {isCurrent && ( + + You + + )} +
+
+ + {formatNumber(entry.score)} + +
+ ); + })} +
+
+ ); +}); \ No newline at end of file diff --git a/src/components/shared/loading-skeleton.tsx b/src/components/shared/loading-skeleton.tsx index c91145b..2da6d8d 100644 --- a/src/components/shared/loading-skeleton.tsx +++ b/src/components/shared/loading-skeleton.tsx @@ -1,6 +1,7 @@ "use client"; import { cn } from "@/lib/utils/cn"; +import { Skeleton, SkeletonCircle, SkeletonStack, diff --git a/src/components/shared/notification-bell.tsx b/src/components/shared/notification-bell.tsx new file mode 100644 index 0000000..6f6f184 --- /dev/null +++ b/src/components/shared/notification-bell.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { + Bell, + CheckCheck, + CheckCircle2, + Gift, + Info, + Megaphone, + ShieldCheck, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useAuthStore } from "@/store/auth-store"; +import { useNotifications } from "@/lib/hooks/use-notifications"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton, SkeletonText } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils/cn"; +import type { AppNotification, NotificationType } from "@/types/notification"; +import { formatRelativeTime } from "@/lib/utils/format"; + +const typeIcons: Record = { + reward_claimed: Gift, + course_completed: CheckCircle2, + credential_minted: ShieldCheck, + announcement: Megaphone, + system: Info, +}; + +const typeStyles: Record = { + reward_claimed: "text-green-600 dark:text-green-400", + course_completed: "text-primary-600 dark:text-primary-400", + credential_minted: "text-stellar-purple", + announcement: "text-amber-600 dark:text-amber-400", + system: "text-gray-500 dark:text-gray-400", +}; + +export function NotificationBell() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const { + notifications, + unreadCount, + loading, + error, + refetch, + markAsRead, + markAllAsRead, + } = useNotifications(); + + if (!isAuthenticated) return null; + + return ( + + + + + + +
+ + Notifications + + {unreadCount > 0 && ( + + )} +
+ + + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : error && notifications.length === 0 ? ( +
+
+ ) : notifications.length === 0 ? ( +
+
+) : ( +
+ {notifications.map((notification) => { + const Icon = typeIcons[notification.type] ?? Info; + const unread = !notification.read; + return ( + markAsRead(notification.id)} + className={cn( + "cursor-pointer items-start gap-3 px-4 py-3", + unread && "bg-primary-50/60 dark:bg-primary-950/40" + )} + > + + + + + + {notification.title} + + {unread && ( + + {notification.message && ( + + {notification.message} + + )} + + + + ); + })} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/shared/stats-card.tsx b/src/components/shared/stats-card.tsx new file mode 100644 index 0000000..0716f1a --- /dev/null +++ b/src/components/shared/stats-card.tsx @@ -0,0 +1,105 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import { TrendingDown, TrendingUp, Minus } from "lucide-react"; +import type { ReactNode } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils/cn"; + +export type StatsCardColor = + | "primary" + | "success" + | "warning" + | "danger" + | "purple" + | "muted"; + +export type StatsCardTrend = "up" | "down" | "flat"; + +const colorThemes: Record< + StatsCardColor, + { iconContainer: string; icon: string } +> = { + primary: { iconContainer: "bg-primary-100", icon: "text-primary-600" }, + success: { iconContainer: "bg-green-100", icon: "text-green-600" }, + warning: { iconContainer: "bg-yellow-100", icon: "text-yellow-600" }, + danger: { iconContainer: "bg-red-100", icon: "text-red-600" }, + purple: { + iconContainer: "bg-stellar-purple/10", + icon: "text-stellar-purple", + }, + muted: { iconContainer: "bg-gray-100", icon: "text-gray-600" }, +}; + +const trendStyles: Record = { + up: { icon: TrendingUp, className: "text-green-600 dark:text-green-400" }, + down: { icon: TrendingDown, className: "text-red-600 dark:text-red-400" }, + flat: { icon: Minus, className: "text-gray-500 dark:text-gray-400" }, +}; + +export interface StatsCardProps { + icon: LucideIcon; + label: string; + value: ReactNode; + color?: StatsCardColor; + trend?: StatsCardTrend; + /** Optional label describing the trend, e.g. "12% this week". */ + trendLabel?: string; + className?: string; +} + +/** + * Reusable metric card for the dashboard and profile pages. Replaces the + * ad-hoc stat cards that were duplicated with inline styles so every metric + * renders with the same icon treatment, typography and spacing. + */ +export function StatsCard({ + icon: Icon, + label, + value, + color = "primary", + trend, + trendLabel, + className, +}: StatsCardProps) { + const theme = colorThemes[color]; + const trendMeta = trend !== undefined ? trendStyles[trend] : undefined; + + return ( + + + +
+

+ {value} +

+

{label}

+ {trendMeta && ( +

+

+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/lib/api/notifications.ts b/src/lib/api/notifications.ts new file mode 100644 index 0000000..974456f --- /dev/null +++ b/src/lib/api/notifications.ts @@ -0,0 +1,34 @@ +import { apiClient } from "./client"; +import type { AppNotification } from "@/types/notification"; + +/** + * Fetch the authenticated user's notifications, newest first. + */ +export async function getNotifications( + jwt: string +): Promise { + const response = await apiClient.get( + "/notifications", + jwt + ); + return response.data; +} + +/** + * Mark a single notification as read. + */ +export async function markNotificationAsRead( + id: string, + jwt: string +): Promise { + await apiClient.post(`/notifications/${id}/read`, {}, jwt); +} + +/** + * Mark every notification as read. + */ +export async function markAllNotificationsAsRead( + jwt: string +): Promise { + await apiClient.post("/notifications/read-all", {}, jwt); +} \ No newline at end of file diff --git a/src/lib/hooks/use-notifications.ts b/src/lib/hooks/use-notifications.ts new file mode 100644 index 0000000..9757989 --- /dev/null +++ b/src/lib/hooks/use-notifications.ts @@ -0,0 +1,89 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAuthStore } from "@/store/auth-store"; +import { + getNotifications, + markAllNotificationsAsRead, + markNotificationAsRead, +} from "@/lib/api/notifications"; +import type { AppNotification } from "@/types/notification"; + +export function useNotifications() { + const jwt = useAuthStore((s) => s.jwt); + const jwtRef = useRef(jwt); + jwtRef.current = jwt; + + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + const token = jwtRef.current; + if (!token) { + setNotifications([]); + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const items = await getNotifications(token); + setNotifications(items); + } catch (e) { + console.error("Failed to fetch notifications:", e); + setError(e instanceof Error ? e.message : "Failed to load notifications"); + } finally { + setLoading(false); + } + }, []); + + const markAsRead = useCallback( + async (id: string) => { + const token = jwtRef.current; + if (!token) return; + // Optimistically mark local state before the server round-trips. + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, read: true } : n)) + ); + try { + await markNotificationAsRead(id, token); + } catch (e) { + console.error("Failed to mark notification as read:", e); + // Revert so the unread state stays accurate if the request fails. + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, read: false } : n)) + ); + } + }, + [] + ); + + const markAllAsRead = useCallback(async () => { + const token = jwtRef.current; + if (!token) return; + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + try { + await markAllNotificationsAsRead(token); + } catch (e) { + console.error("Failed to mark all notifications as read:", e); + refetch(); + } + }, [refetch]); + + useEffect(() => { + refetch(); + }, [refetch]); + + const unreadCount = notifications.filter((n) => !n.read).length; + + return { + notifications, + unreadCount, + loading, + error, + refetch, + markAsRead, + markAllAsRead, + }; +} \ No newline at end of file diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts index b343cc4..ffd49b5 100644 --- a/src/lib/utils/format.ts +++ b/src/lib/utils/format.ts @@ -36,6 +36,24 @@ export function formatDate(dateStr: string): string { }); } +/** + * Format a timestamp as a relative time (e.g. "5m ago", "2h ago"), falling + * back to the full date for anything older than a week. + */ +export function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return ""; + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 60) return "Just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return formatDate(dateStr); +} + /** * Format minutes into a human-readable duration string. */ diff --git a/src/tests/course/course-progress-card.test.tsx b/src/tests/course/course-progress-card.test.tsx new file mode 100644 index 0000000..01c61a3 --- /dev/null +++ b/src/tests/course/course-progress-card.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { CourseProgressCard } from "@/components/course/course-progress-card"; + +describe("CourseProgressCard", () => { + const baseProps = { + courseId: "course-1", + courseTitle: "Stellar Development", + }; + + it("renders the course title and in-progress status", () => { + render(); + expect(screen.getByText("Stellar Development")).toBeInTheDocument(); + expect(screen.getByText("In Progress")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /continue learning/i }) + ).toHaveAttribute("href", "/courses/course-1"); + }); + + it("shows completed status with a review action at 100%", () => { + render(); + expect(screen.getByText("Completed")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /review course/i }) + ).toBeInTheDocument(); + }); + + it("renders the module count", () => { + render(); + expect(screen.getByText("8 modules")).toBeInTheDocument(); + }); + + it("renders completed module count when provided", () => { + render( + + ); + expect(screen.getByText("4 of 8 modules")).toBeInTheDocument(); + }); + + it("uses a custom continue href", () => { + render( + + ); + expect( + screen.getByRole("link", { name: /continue learning/i }) + ).toHaveAttribute("href", "/courses/course-1/modules/mod-2"); + }); + + it("merges custom className", () => { + const { container } = render( + + ); + expect((container.firstChild as HTMLElement).className).toContain("my-class"); + }); +}); \ No newline at end of file diff --git a/src/tests/layout/header.test.tsx b/src/tests/layout/header.test.tsx index f460a1d..04f5037 100644 --- a/src/tests/layout/header.test.tsx +++ b/src/tests/layout/header.test.tsx @@ -2,12 +2,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Header } from "@/components/layout/header"; +import { ThemeProvider } from "@/components/theme/theme-provider"; // ConnectButton makes real auth calls; stub it vi.mock("@/components/wallet/connect-button", () => ({ ConnectButton: () => , })); +// NotificationBell fetches from the API; stub it +vi.mock("@/components/shared/notification-bell", () => ({ + NotificationBell: () => null, +})); + // Stub auth store const authState = { isAuthenticated: false, @@ -18,6 +24,29 @@ vi.mock("@/store/auth-store", () => ({ useAuthStore: () => authState, })); +// jsdom has no matchMedia; ThemeProvider reads the system theme from it. +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +function renderHeader() { + return render( + +
+ + ); +} + describe("Header", () => { beforeEach(() => { authState.isAuthenticated = false; @@ -25,37 +54,37 @@ describe("Header", () => { }); it("renders the ChainLearn logo link", () => { - render(
); + renderHeader(); expect(screen.getByRole("link", { name: /chainlearn/i })).toHaveAttribute("href", "/"); }); it("renders the ConnectButton", () => { - render(
); + renderHeader(); expect(screen.getByRole("button", { name: /connect wallet/i })).toBeInTheDocument(); }); it("does not show nav links when unauthenticated", () => { - render(
); + renderHeader(); expect(screen.queryByRole("link", { name: /dashboard/i })).not.toBeInTheDocument(); }); it("shows nav links when authenticated", () => { authState.isAuthenticated = true; - render(
); + renderHeader(); expect(screen.getByRole("link", { name: /dashboard/i })).toBeInTheDocument(); }); it("shows wallet address when authenticated", () => { authState.isAuthenticated = true; authState.walletAddress = "GABCD1234EFGH5678"; - render(
); + renderHeader(); // truncateAddress shows first4...last4 = GABC...5678 expect(screen.getByText(/GABC.*5678/)).toBeInTheDocument(); }); it("toggles mobile nav on hamburger click", async () => { authState.isAuthenticated = true; - render(
); + renderHeader(); const toggle = screen.getByRole("button", { name: /toggle navigation/i }); await userEvent.click(toggle); // Mobile nav should now be visible — Dashboard link is accessible diff --git a/src/tests/shared/leaderboard-table.test.tsx b/src/tests/shared/leaderboard-table.test.tsx new file mode 100644 index 0000000..5cae811 --- /dev/null +++ b/src/tests/shared/leaderboard-table.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { LeaderboardTable } from "@/components/shared/leaderboard-table"; +import type { LeaderboardEntry } from "@/components/shared/leaderboard-table"; + +const entries: LeaderboardEntry[] = [ + { id: "a", name: "Alice", score: 1250 }, + { id: "b", name: "Bob", score: 980 }, + { id: "c", name: "Carol", score: 760 }, +]; + +describe("LeaderboardTable", () => { + it("renders a table with rank, name and score columns", () => { + render(); + expect(screen.getByRole("table", { name: /leaderboard/i })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /rank/i })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /name/i })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /score/i })).toBeInTheDocument(); + }); + + it("renders each entry with its rank and score", () => { + render(); + const rows = screen.getAllByRole("row"); + // header + 3 entries + expect(rows).toHaveLength(4); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("1,250")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + }); + + it("highlights the current user and marks them with a You badge", () => { + render(); + const bobRow = screen.getByText("Bob").closest("tr"); + expect(bobRow).not.toBeNull(); + expect(bobRow!.className).toContain("bg-primary-50"); + expect(within(bobRow!).getByText("You")).toBeInTheDocument(); + }); + + it("does not render a You badge without a current user", () => { + render(); + expect(screen.queryByText("You")).not.toBeInTheDocument(); + }); + + it("renders rank medallions for the top three", () => { + render(); + expect(screen.getByLabelText("Rank 1")).toBeInTheDocument(); + expect(screen.getByLabelText("Rank 2")).toBeInTheDocument(); + expect(screen.getByLabelText("Rank 3")).toBeInTheDocument(); + }); + + it("merges custom className", () => { + const { container } = render( + + ); + expect( + (container.querySelector("table") as HTMLElement).className + ).toContain("my-class"); + }); +}); \ No newline at end of file diff --git a/src/tests/shared/notification-bell.test.tsx b/src/tests/shared/notification-bell.test.tsx new file mode 100644 index 0000000..dad1261 --- /dev/null +++ b/src/tests/shared/notification-bell.test.tsx @@ -0,0 +1,125 @@ +import { beforeEach, describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { NotificationBell } from "@/components/shared/notification-bell"; +import type { AppNotification } from "@/types/notification"; + +const { notificationsState } = vi.hoisted(() => ({ + notificationsState: { value: [] as AppNotification[] }, +})); + +const markAsRead = vi.fn(); +const markAllAsRead = vi.fn(); + +vi.mock("@/lib/hooks/use-notifications", () => ({ + useNotifications: () => ({ + notifications: notificationsState.value, + unreadCount: notificationsState.value.filter((n) => !n.read).length, + loading: false, + error: null, + refetch: vi.fn(), + markAsRead, + markAllAsRead, + }), +})); + +vi.mock("@/store/auth-store", () => ({ + useAuthStore: (selector: (s: { isAuthenticated: boolean; jwt: string }) => unknown) => + selector({ isAuthenticated: true, jwt: "test-jwt" }), +})); + +function makeNotification( + overrides: Partial = {} +): AppNotification { + return { + id: "n1", + type: "reward_claimed", + title: "Reward claimed", + message: "You earned 10 LEARN", + read: false, + createdAt: new Date(Date.now() - 30_000).toISOString(), + ...overrides, + }; +} + +beforeEach(() => { + markAsRead.mockClear(); + markAllAsRead.mockClear(); + notificationsState.value = [ + makeNotification(), + makeNotification({ + id: "n2", + type: "credential_minted", + title: "Credential minted", + read: true, + createdAt: new Date(Date.now() - 3_600_000).toISOString(), + }), + ]; +}); + +describe("NotificationBell", () => { + it("renders a bell button with the unread count", () => { + render(); + expect( + screen.getByRole("button", { name: /notifications, 1 unread/i }) + ).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + }); + + it("hides the badge when there are no unread notifications", () => { + notificationsState.value = notificationsState.value.map((n) => ({ + ...n, + read: true, + })); + render(); + expect( + screen.getByRole("button", { name: /^notifications$/i }) + ).toBeInTheDocument(); + expect(screen.queryByText("1")).not.toBeInTheDocument(); + }); + + it("opens the panel and lists notifications with timestamps", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /notifications/i })); + expect(await screen.findByText("Reward claimed")).toBeInTheDocument(); + expect(screen.getByText(/Just now/i)).toBeInTheDocument(); + expect(screen.getByText("Credential minted")).toBeInTheDocument(); + }); + + it("marks a notification as read when clicked", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /notifications/i })); + const item = await screen.findByText("Reward claimed"); + await user.click(item); + expect(markAsRead).toHaveBeenCalledWith("n1"); + }); + + it("offers mark all read when there are unread notifications", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /notifications/i })); + await user.click( + await screen.findByRole("button", { name: /mark all read/i }) + ); + expect(markAllAsRead).toHaveBeenCalledTimes(1); + }); + + it("closes the panel on escape", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /notifications/i })); + expect(await screen.findByText("Reward claimed")).toBeInTheDocument(); + await user.keyboard("{Escape}"); + expect(screen.queryByText("Reward claimed")).not.toBeInTheDocument(); + }); + + it("shows an empty state when there are no notifications", async () => { + notificationsState.value = []; + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /notifications/i })); + expect(await screen.findByText("No notifications")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/tests/shared/stats-card.test.tsx b/src/tests/shared/stats-card.test.tsx new file mode 100644 index 0000000..2af5c65 --- /dev/null +++ b/src/tests/shared/stats-card.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { BookOpen } from "lucide-react"; +import { StatsCard } from "@/components/shared/stats-card"; + +describe("StatsCard", () => { + it("renders the value and label", () => { + render(); + expect(screen.getByText("4")).toBeInTheDocument(); + expect(screen.getByText("Enrolled Courses")).toBeInTheDocument(); + }); + + it("renders string values", () => { + render(); + expect(screen.getByText("1,250")).toBeInTheDocument(); + }); + + it("shows no trend indicator by default", () => { + render(); + expect(screen.queryByText(/trending|change/i)).not.toBeInTheDocument(); + }); + + it("renders an up trend with label", () => { + render( + + ); + expect(screen.getByText("12% this week")).toBeInTheDocument(); + }); + + it("renders a down trend with the default label", () => { + render(); + expect(screen.getByText("Trending down")).toBeInTheDocument(); + }); + + it("merges custom className", () => { + const { container } = render( + + ); + expect((container.firstChild as HTMLElement).className).toContain("my-class"); + }); + + it.each([ + ["primary", "bg-primary-100"], + ["success", "bg-green-100"], + ["warning", "bg-yellow-100"], + ["danger", "bg-red-100"], + ["purple", "bg-stellar-purple/10"], + ["muted", "bg-gray-100"], + ] as const)("color=%s applies the icon container theme", (color, cls) => { + const { container } = render( + + ); + expect((container.querySelector("[aria-hidden='true']") as HTMLElement).className).toContain( + cls + ); + }); +}); \ No newline at end of file diff --git a/src/types/notification.ts b/src/types/notification.ts new file mode 100644 index 0000000..df50ace --- /dev/null +++ b/src/types/notification.ts @@ -0,0 +1,15 @@ +export type NotificationType = + | "reward_claimed" + | "course_completed" + | "credential_minted" + | "announcement" + | "system"; + +export interface AppNotification { + id: string; + type: NotificationType; + title: string; + message?: string; + read: boolean; + createdAt: string; +} \ No newline at end of file