Warming up the neural circuits...
Share
Handbook Deep dive Quick Recall Quick recall What you'll learn 7 On this page 38 Capstone: SaaS Dashboard
You've learned components, state , routing, data fetching, forms, and testing. Now it's time to put everything together into a real SaaS dashboard — the kind of project you'd build at a startup.
What You'll Build
A multi-role admin dashboard for a fictional SaaS product called "LearnDash" — an online learning platform. The dashboard has two roles:
Role Capabilities Admin Manage courses, view analytics, manage users, see revenue charts User View enrolled courses, track progress, see recommendations, manage profile
Project Architecture
src/
├── components/
│ ├── layout/ # Sidebar, Header, Shell
│ ├── dashboard/ # StatCards, Charts, ActivityFeed
│ ├── courses/ # CourseList, CourseCard, CourseForm
│ ├── users/ # UserTable, UserForm (admin only)
│ ├── auth/ # LoginForm, ProtectedRoute
│ └── ui/ # Button, Input, Modal, Dropdown (shadcn-style)
├── hooks/
│ ├── useAuth.ts # Auth context hook
│ ├── useCourses.ts # TanStack Query for courses
│ ├── useWebSocket.ts # WebSocket connection hook
│ └── useDarkMode.ts # Dark mode toggle
├── contexts/
│ ├── AuthContext.tsx # Auth state + user role
│ └── ThemeContext.tsx # Dark/light theme
├── lib/
│ ├── api.ts # Axios/fetch wrapper
│ ├── constants.ts # Routes, roles, config
│ └── utils.ts # Formatters, cn()
├── pages/
│ ├── Login.tsx
│ ├── Dashboard.tsx # Role-based redirect
│ ├── admin/
│ │ ├── Analytics.tsx # Charts + metrics
│ │ ├── Courses.tsx # CRUD table
│ │ └── Users.tsx # User management
│ └── user/
│ ├── MyCourses.tsx
│ └── Profile.tsx
└── App.tsx
Part 1: Project Setup
1.1 Create the Project
npm create vite@latest learndash-dashboard -- --template react-ts
cd learndash-dashboard
npm install
1.2 Install Dependencies
npm install react-router-dom @tanstack/react-query axios
# Charts
npm install recharts
# UI Utilities
npm install clsx tailwind-merge lucide-react
# Forms
npm install react-hook-form zod @hookform/resolvers
# Dev
npm install -D tailwindcss @tailwindcss/vite
1.3 Tailwind Setup
// vite.config.ts
import { defineConfig } from ' vite '
import react from ' @vitejs/plugin-react '
import tailwindcss from ' @tailwindcss/vite '
export default defineConfig ( {
plugins : [ react () , tailwindcss ()] ,
} )
/* src/index.css */
@ import " tailwindcss " ;
@ custom-variant dark (&:where(.dark, .dark *)) ;
@ theme {
--color-primary: # 6366f1 ;
--color-primary-foreground: # ffffff;
--color-destructive: # ef4444;
Part 2: Authentication & RBAC
2.1 Auth Context
The auth context manages the logged-in user's state, role, and provides login/logout functions. In production, this would connect to a real backend, but for the capstone we use a mock API with JWT simulation.
// src/contexts/AuthContext.tsx
import { createContext , useContext , useState , useCallback , type ReactNode } from ' react ' ;
type Role = ' admin ' | ' user ' ;
interface User {
id
2.2 Protected Route Component
// src/components/auth/ProtectedRoute.tsx
import { Navigate , Outlet } from ' react-router-dom ' ;
import { useAuth } from ' @/contexts/AuthContext ' ;
interface Props {
allowedRoles ?: ( ' admin ' | ' user ' )[] ;
2.3 Role-Based Navigation
// src/components/layout/Sidebar.tsx
import { NavLink } from ' react-router-dom ' ;
import { useAuth } from ' @/contexts/AuthContext ' ;
import {
LayoutDashboard , BookOpen , Users , BarChart3 ,
Settings , GraduationCap ,
Part 3: Data Fetching with TanStack Query
3.1 API Client Setup
// src/lib/api.ts
const BASE_URL = ' https://api.learndash.demo ' ; // Mock — replace with real API
interface ApiOptions {
method ?: ' GET ' | ' POST ' | ' PUT ' | ' DELETE ' ;
body ?: unknown
3.2 Query Hooks with Role-Based Data
// src/hooks/useCourses.ts
import { useQuery , useMutation , useQueryClient } from ' @tanstack/react-query ' ;
import { useAuth } from ' @/contexts/AuthContext ' ;
interface Course {
id : string ;
title :
Part 4: Admin Analytics Dashboard
4.1 Stat Cards
// src/components/dashboard/StatCards.tsx
import { TrendingUp , Users , DollarSign , BookOpen } from ' lucide-react ' ;
interface Stat {
label : string ;
value : string ;
change : string
4.2 Revenue Chart with Recharts
// src/components/dashboard/RevenueChart.tsx
import {
AreaChart , Area , XAxis , YAxis , CartesianGrid ,
Tooltip , ResponsiveContainer
} from ' recharts ' ;
const data = [
{ month : ' Jan '
4.3 Analytics Page (Admin)
// src/pages/admin/Analytics.tsx
import { StatCards } from ' @/components/dashboard/StatCards ' ;
import { RevenueChart } from ' @/components/dashboard/RevenueChart ' ;
import { RecentActivity } from ' @/components/dashboard/RecentActivity ' ;
import { DollarSign , Users , BookOpen
Part 5: Real-Time Notifications with WebSockets
5.1 WebSocket Hook
// src/hooks/useWebSocket.ts
import { useEffect , useRef , useState , useCallback } from ' react ' ;
type MessageHandler = ( data : unknown ) => void ;
export function useWebSocket ( url
5.2 Notification Component
// src/components/dashboard/Notifications.tsx
import { useEffect , useState } from ' react ' ;
import { useWebSocket } from ' @/hooks/useWebSocket ' ;
import { Bell , CheckCircle , AlertCircle , X } from ' lucide-react '
Part 6: Dark Mode Toggle
6.1 Theme Context
// src/contexts/ThemeContext.tsx
import { createContext , useContext , useEffect , useState , type ReactNode } from ' react ' ;
type Theme = ' light ' | ' dark ' ;
interface ThemeContextType {
theme
// src/components/ui/ThemeToggle.tsx
import { Sun , Moon } from ' lucide-react ' ;
import { useTheme } from ' @/contexts/ThemeContext ' ;
export function ThemeToggle () {
const { theme , toggleTheme } = useTheme () ;
Part 7: App Shell & Routing
// src/App.tsx
import { BrowserRouter , Routes , Route , Navigate } from ' react-router-dom ' ;
import { QueryClient , QueryClientProvider } from ' @tanstack/react-query ' ;
import { AuthProvider } from ' @/contexts/AuthContext
Part 8: Testing
8.1 Component Test (React Testing Library)
// src/components/dashboard/StatCards.test.tsx
import { render , screen } from ' @testing-library/react ' ;
import { describe , it , expect } from ' vitest ' ;
import { StatCards } from ' ./StatCards ' ;
import
8.2 Hook Test
// src/hooks/useDarkMode.test.ts
import { renderHook , act } from ' @testing-library/react ' ;
import { describe , it , expect , beforeEach } from ' vitest ' ;
// Since useTheme uses context, test the theme logic directly
describe ( '
8.3 E2E Test (Cypress)
// cypress/e2e/auth.cy.ts
describe ( ' Authentication ' , () => {
beforeEach ( () => {
cy . visit ( ' /login ' ) ;
} ) ;
it ( ' shows login form ' , () => {
cy . get
Part 9: Deployment
9.1 Deploy to Vercel
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
# Set environment variables in Vercel dashboard:
# VITE_API_URL=https://your-api.com
9.2 GitHub Actions CI/CD
# .github/workflows/ci.yml
name : CI/CD
on :
push :
branches : [ main ]
pull_request :
branches : [ main ]
jobs :
test :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses :
Part 10: Putting It All Together
Key Architecture Decisions
Decision Why TanStack Query over useEffect fetchingAutomatic caching, refetching, stale-while-revalidate, devtools Context API over ReduxSufficient for auth + theme; Redux overkill for this scale Recharts over Chart.jsBetter React integration, composable API, responsive by default React Hook Form + Zod Performant forms with minimal re-renders + schema validation WebSocket hook (not library)Lightweight, educates on the protocol, sufficient for simple notifications Lazy loading pages Code splitting reduces initial bundle, faster first paint
Folder Structure Recap
learndash-dashboard/
├── public/
├── src/
│ ├── components/ # Reusable UI pieces
│ ├── contexts/ # AuthContext, ThemeContext
│ ├── hooks/ # useAuth, useCourses, useWebSocket
│ ├── lib/ # api.ts, utils.ts, constants.ts
│ ├── pages/ # Route-level components
│ │ ├── admin/ # Admin-only pages
│ │ └── user/ # User-only pages
│ ├── App.tsx # Routes + providers
│ ├── main.tsx # Entry point
│ └── index.css # Tailwind imports + theme tokens
├── cypress/ # E2E tests
├── .github/workflows/ # CI/CD
├── vite.config.ts
├── tailwind.config.ts
├── tsconfig.json
└── package.json
Milestones
Day 1-2: Project setup, auth system, protected routes
Day 3-4: Dashboard shell, sidebar, stat cards, RBAC routing
Day 5-6: Data fetching with TanStack Query, admin courses CRUD
Day 7-8: Charts with Recharts, analytics page
Day 9: WebSocket notifications
Day 10: Dark mode toggle + theme persistence
Day 11-12: Testing (unit + integration + E2E)
Day 13: Deployment + CI/CD pipeline
Day 14: Polish, documentation, README
Build this in Minimum Viable Feature order: auth → sidebar + routing → stat cards → charts → notifications → dark mode → tests → deploy. Ship after each milestone — don't wait until everything is perfect.
Skipping error boundaries — wrap chart/notification sections in error boundaries so one failure doesn't crash the whole dashboard.
Hardcoding role checks — use the allowedRoles prop pattern from Part 2.2 so adding a third role is trivial.
Neglecting loading states — every data-dependent component should have a skeleton or spinner while data loads.
WebSocket reconnection — always handle reconnect logic; users switch networks. The hook in Part 5 connects once; add reconnection as a stretch goal.
Key Takeaways
RBAC is about routing + conditional rendering — protect routes with wrapper components, conditionally show UI based on user.role .
TanStack Query eliminates boilerplate — no manual loading/error/data state, automatic cache invalidation.
WebSockets are simpler than you think — a 40-line custom hook handles real-time notifications.
Dark mode with Tailwind — dark: prefix + classList.toggle('dark') + localStorage = complete solution.
Tests prove your code works — unit test components, integration test hooks, E2E test critical user flows.
CI/CD catches bugs before users do — automated tests on every push, auto-deploy on main.
Self-Check
vitest
@testing-library/react
@testing-library/jest-dom
jsdom
}
:
string
;
name : string ;
email : string ;
role : Role ;
avatar ?: string ;
}
interface AuthState {
user : User | null ;
token : string | null ;
isAuthenticated : boolean ;
isLoading : boolean ;
}
interface AuthContextType extends AuthState {
login : ( email : string , password : string ) => Promise < void >;
logout : () => void ;
}
const AuthContext = createContext < AuthContextType | null > ( null ) ;
// Mock users for demo
const MOCK_USERS : Record < string , User & { password : string }> = {
' admin@demo.com ' : {
id : ' 1 ' , name : ' Admin User ' , email : ' admin@demo.com ' ,
role : ' admin ' , password : ' admin123 ' ,
},
' user@demo.com ' : {
id : ' 2 ' , name : ' Jane Student ' , email : ' user@demo.com ' ,
role : ' user ' , password : ' user123 ' ,
},
};
export function AuthProvider ({ children } : { children : ReactNode }) {
const [ state , setState ] = useState < AuthState > ( {
user : null , token : null , isAuthenticated : false , isLoading : false ,
} ) ;
const login = useCallback ( async ( email : string , password : string ) => {
setState ( prev => ( { ... prev , isLoading : true } )) ;
// Simulate API call
await new Promise ( r => setTimeout ( r , 800 )) ;
const user = MOCK_USERS [ email ] ;
if ( ! user || user . password !== password ) {
setState ( prev => ( { ... prev , isLoading : false } )) ;
throw new Error ( ' Invalid credentials ' ) ;
}
const { password : _ , ... safeUser } = user ;
setState ( {
user : safeUser , token : ' mock-jwt-token ' ,
isAuthenticated : true , isLoading : false ,
} ) ;
}, []) ;
const logout = useCallback ( () => {
setState ( { user : null , token : null , isAuthenticated : false , isLoading : false } ) ;
}, []) ;
return (
< AuthContext.Provider value = {{ ... state , login , logout }} >
{ children }
</ AuthContext.Provider >
) ;
}
export function useAuth () {
const ctx = useContext ( AuthContext ) ;
if ( ! ctx ) throw new Error ( ' useAuth must be used within AuthProvider ' ) ;
return ctx ;
}
}
export function ProtectedRoute ({ allowedRoles } : Props ) {
const { isAuthenticated , user , isLoading } = useAuth () ;
if ( isLoading ) return < div className = " flex h-screen items-center justify-center " > Loading... </ div > ;
if ( ! isAuthenticated ) return < Navigate to = " /login " replace /> ;
if ( allowedRoles && user && ! allowedRoles . includes ( user . role )) {
return < Navigate to = " /dashboard " replace /> ;
}
return < Outlet /> ;
}
LogOut
} from ' lucide-react ' ;
const adminLinks = [
{ to : ' /admin/analytics ' , label : ' Analytics ' , icon : BarChart3 },
{ to : ' /admin/courses ' , label : ' Courses ' , icon : BookOpen },
{ to : ' /admin/users ' , label : ' Users ' , icon : Users },
] ;
const userLinks = [
{ to : ' /user/courses ' , label : ' My Courses ' , icon : GraduationCap },
] ;
export function Sidebar () {
const { user , logout } = useAuth () ;
const links = user ?. role === ' admin ' ? adminLinks : userLinks ;
return (
< aside className = " hidden md:flex flex-col w-64 bg-card border-r border-border h-screen sticky top-0 " >
< div className = " p-6 border-b border-border " >
< h1 className = " text-xl font-bold bg-gradient-to-r from-primary to-purple-500 bg-clip-text text-transparent " >
LearnDash
</ h1 >
< p className = " text-xs text-muted-foreground mt-1 capitalize " > { user ?. role } dashboard </ p >
</ div >
< nav className = " flex-1 p-4 space-y-1 " >
< NavLink
to = " /dashboard "
end
className = {({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
isActive ? ' bg-primary/10 text-primary ' : ' text-muted-foreground hover:bg-accent hover:text-foreground '
} `
}
>
< LayoutDashboard className = " h-4 w-4 " />
Overview
</ NavLink >
{ links . map ( ({ to , label , icon : Icon }) => (
< NavLink
key = { to }
to = { to }
className = {({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
isActive ? ' bg-primary/10 text-primary ' : ' text-muted-foreground hover:bg-accent hover:text-foreground '
} `
}
>
< Icon className = " h-4 w-4 " />
{ label }
</ NavLink >
)) }
</ nav >
< div className = " p-4 border-t border-border " >
< div className = " flex items-center gap-3 mb-3 " >
< div className = " h-8 w-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold text-sm " >
{ user ?. name . charAt ( 0 ) }
</ div >
< div className = " flex-1 min-w-0 " >
< p className = " text-sm font-medium truncate " > { user ?. name } </ p >
< p className = " text-xs text-muted-foreground truncate " > { user ?. email } </ p >
</ div >
</ div >
< button
onClick = { logout }
className = " flex items-center gap-2 w-full px-3 py-2 text-sm text-muted-foreground hover:text-destructive rounded-lg hover:bg-destructive/10 transition-colors "
>
< LogOut className = " h-4 w-4 " /> Sign Out
</ button >
</ div >
</ aside >
) ;
}
;
token ?: string ;
}
export async function apiClient < T >( endpoint : string , options : ApiOptions = {}) : Promise < T > {
const { method = ' GET ' , body , token } = options ;
const headers : Record < string , string > = { ' Content-Type ' : ' application/json ' };
if ( token ) headers [ ' Authorization ' ] = `Bearer ${ token } ` ;
const res = await fetch ( ` ${ BASE_URL }${ endpoint } ` , {
method , headers ,
body : body ? JSON . stringify ( body ) : undefined ,
} ) ;
if ( ! res . ok ) {
const error = await res . json () . catch ( () => ( { message : ' Request failed ' } )) ;
throw new Error ( error . message ) ;
}
return res . json () ;
}
string
;
description : string ;
students : number ;
revenue : number ;
status : ' published ' | ' draft ' ;
createdAt : string ;
}
// Mock data — replace with real API calls
const MOCK_COURSES : Course [] = [
{ id : ' 1 ' , title : ' React Fundamentals ' , description : ' Learn React from scratch ' , students : 245 , revenue : 12250 , status : ' published ' , createdAt : ' 2026-01-15 ' },
{ id : ' 2 ' , title : ' Advanced TypeScript ' , description : ' Master TypeScript patterns ' , students : 189 , revenue : 9450 , status : ' published ' , createdAt : ' 2026-02-01 ' },
{ id : ' 3 ' , title : ' Node.js Backend ' , description : ' Build scalable APIs ' , students : 312 , revenue : 15600 , status : ' published ' , createdAt : ' 2026-03-10 ' },
{ id : ' 4 ' , title : ' System Design ' , description : ' Design distributed systems ' , students : 98 , revenue : 4900 , status : ' draft ' , createdAt : ' 2026-04-01 ' },
] ;
// Admin: sees all courses with revenue data
export function useAdminCourses () {
const { token } = useAuth () ;
return useQuery ( {
queryKey : [ ' admin ' , ' courses ' ] ,
queryFn : async () => {
// In production: return apiClient<Course[]>('/admin/courses', { token });
await new Promise ( r => setTimeout ( r , 500 )) ;
return MOCK_COURSES ;
},
} ) ;
}
// User: sees only enrolled courses (no revenue data)
interface UserCourse {
id : string ;
title : string ;
progress : number ;
lastAccessed : string ;
}
const MOCK_USER_COURSES : UserCourse [] = [
{ id : ' 1 ' , title : ' React Fundamentals ' , progress : 78 , lastAccessed : ' 2026-05-03 ' },
{ id : ' 2 ' , title : ' Advanced TypeScript ' , progress : 42 , lastAccessed : ' 2026-05-01 ' },
] ;
export function useUserCourses () {
const { token } = useAuth () ;
return useQuery ( {
queryKey : [ ' user ' , ' courses ' ] ,
queryFn : async () => {
await new Promise ( r => setTimeout ( r , 500 )) ;
return MOCK_USER_COURSES ;
},
} ) ;
}
// Mutation for creating/editing courses (admin only)
export function useCreateCourse () {
const queryClient = useQueryClient () ;
return useMutation ( {
mutationFn : async ( course : Omit < Course , ' id ' | ' createdAt ' >) => {
await new Promise ( r => setTimeout ( r , 800 )) ;
return { ... course , id : String ( Date . now ()) , createdAt : new Date () . toISOString () };
},
onSuccess : () => {
queryClient . invalidateQueries ( { queryKey : [ ' admin ' , ' courses ' ] } ) ;
},
} ) ;
}
;
changeType : ' positive ' | ' negative ' ;
icon : React . ComponentType <{ className ?: string }>;
}
export function StatCards ({ stats } : { stats : Stat [] }) {
return (
< div className = " grid gap-4 md:grid-cols-2 lg:grid-cols-4 " >
{ stats . map ( stat => (
< div
key = { stat . label }
className = " rounded-xl border border-border bg-card p-6 hover:shadow-md transition-shadow "
>
< div className = " flex items-center justify-between " >
< span className = " text-sm font-medium text-muted-foreground " > { stat . label } </ span >
< stat.icon className = " h-4 w-4 text-muted-foreground " />
</ div >
< div className = " mt-2 " >
< span className = " text-2xl font-bold " > { stat . value } </ span >
</ div >
< div className = " mt-1 " >
< span className = { `text-xs font-medium ${
stat . changeType === ' positive ' ? ' text-green-600 dark:text-green-400 ' : ' text-red-600 dark:text-red-400 '
} ` } >
{ stat . changeType === ' positive ' ? ' ↑ ' : ' ↓ ' } { stat . change }
</ span >
< span className = " text-xs text-muted-foreground ml-1 " > vs last month </ span >
</ div >
</ div >
)) }
</ div >
) ;
}
,
revenue
:
4000
,
students
:
240
},
{ month : ' Feb ' , revenue : 5500 , students : 320 },
{ month : ' Mar ' , revenue : 4800 , students : 280 },
{ month : ' Apr ' , revenue : 7200 , students : 390 },
{ month : ' May ' , revenue : 6500 , students : 350 },
{ month : ' Jun ' , revenue : 8900 , students : 450 },
] ;
export function RevenueChart () {
return (
< div className = " rounded-xl border border-border bg-card p-6 " >
< h3 className = " text-lg font-semibold mb-1 " > Revenue Overview </ h3 >
< p className = " text-sm text-muted-foreground mb-6 " > Monthly revenue trend </ p >
< ResponsiveContainer width = " 100% " height = { 300 } >
< AreaChart data = { data } margin = {{ top : 5 , right : 5 , left : 0 , bottom : 5 }} >
< defs >
< linearGradient id = " revenueGradient " x1 = " 0 " y1 = " 0 " x2 = " 0 " y2 = " 1 " >
< stop offset = " 5% " stopColor = " var(--color-primary) " stopOpacity = { 0.3 } />
< stop offset = " 95% " stopColor = " var(--color-primary) " stopOpacity = { 0 } />
</ linearGradient >
</ defs >
< CartesianGrid strokeDasharray = " 3 3 " className = " stroke-border " />
< XAxis dataKey = " month " className = " text-xs " tick = {{ fill : ' var(--color-muted-foreground) ' }} />
< YAxis className = " text-xs " tick = {{ fill : ' var(--color-muted-foreground) ' }} />
< Tooltip
contentStyle = {{
backgroundColor : ' var(--color-card) ' ,
border : ' 1px solid var(--color-border) ' ,
borderRadius : ' 12px ' ,
boxShadow : ' 0 4px 20px rgba(0,0,0,0.1) ' ,
}}
/>
< Area
type = " monotone "
dataKey = " revenue "
stroke = " var(--color-primary) "
fill = " url(#revenueGradient) "
strokeWidth = { 2 }
/>
</ AreaChart >
</ ResponsiveContainer >
</ div >
) ;
}
,
TrendingUp
}
from
'
lucide-react
'
;
const stats = [
{ label : ' Total Revenue ' , value : ' ₹3,89,000 ' , change : ' 12.5% ' , changeType : ' positive ' as const , icon : DollarSign },
{ label : ' Active Students ' , value : ' 2,453 ' , change : ' 8.2% ' , changeType : ' positive ' as const , icon : Users },
{ label : ' Total Courses ' , value : ' 18 ' , change : ' 2 ' , changeType : ' positive ' as const , icon : BookOpen },
{ label : ' Conversion Rate ' , value : ' 4.8% ' , change : ' 0.3% ' , changeType : ' negative ' as const , icon : TrendingUp },
] ;
export default function Analytics () {
return (
< div className = " space-y-6 " >
< div >
< h2 className = " text-2xl font-bold tracking-tight " > Analytics </ h2 >
< p className = " text-muted-foreground " > Your platform performance at a glance. </ p >
</ div >
< StatCards stats = { stats } />
< div className = " grid gap-6 lg:grid-cols-3 " >
< div className = " lg:col-span-2 " >
< RevenueChart />
</ div >
< RecentActivity />
</ div >
</ div >
) ;
}
:
string
)
{
const wsRef = useRef < WebSocket | null > ( null ) ;
const [ isConnected , setIsConnected ] = useState ( false ) ;
const [ lastMessage , setLastMessage ] = useState < unknown > ( null ) ;
const handlersRef = useRef < Map < string , Set < MessageHandler >>> ( new Map ()) ;
useEffect ( () => {
const ws = new WebSocket ( url ) ;
wsRef . current = ws ;
ws . onopen = () => setIsConnected ( true ) ;
ws . onclose = () => setIsConnected ( false ) ;
ws . onerror = () => setIsConnected ( false ) ;
ws . onmessage = ( event ) => {
try {
const data = JSON . parse ( event . data ) ;
setLastMessage ( data ) ;
// Dispatch to type-specific handlers
const handlerSet = handlersRef . current . get ( data . type ) ;
handlerSet ?. forEach ( handler => handler ( data . payload )) ;
} catch {
console . warn ( ' Failed to parse WebSocket message ' ) ;
}
};
return () => {
ws . close () ;
};
}, [ url ]) ;
const subscribe = useCallback ( ( type : string , handler : MessageHandler ) => {
if ( ! handlersRef . current . has ( type )) {
handlersRef . current . set ( type , new Set ()) ;
}
handlersRef . current . get ( type ) ! . add ( handler ) ;
return () => {
handlersRef . current . get ( type ) ?. delete ( handler ) ;
};
}, []) ;
const send = useCallback ( ( data : unknown ) => {
if ( wsRef . current ?. readyState === WebSocket . OPEN ) {
wsRef . current . send ( JSON . stringify ( data )) ;
}
}, []) ;
return { isConnected , lastMessage , subscribe , send };
}
;
interface Notification {
id : string ;
title : string ;
message : string ;
type : ' success ' | ' warning ' | ' info ' ;
timestamp : string ;
read : boolean ;
}
export function Notifications () {
const [ notifications , setNotifications ] = useState < Notification [] > ([]) ;
const [ isOpen , setIsOpen ] = useState ( false ) ;
const { subscribe , isConnected } = useWebSocket ( ' wss://api.learndash.demo/ws ' ) ;
useEffect ( () => {
const unsub = subscribe ( ' notification ' , ( payload ) => {
const notif = payload as Notification ;
setNotifications ( prev => [ notif , ... prev ]) ;
} ) ;
return unsub ;
}, [ subscribe ]) ;
const unreadCount = notifications . filter ( n => ! n . read ) . length ;
return (
< div className = " relative " >
< button
onClick = {() => setIsOpen ( ! isOpen ) }
className = " relative p-2 rounded-lg hover:bg-accent transition-colors "
>
< Bell className = " h-5 w-5 " />
{ unreadCount > 0 && (
< span className = " absolute -top-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground " >
{ unreadCount }
</ span >
) }
</ button >
{ isOpen && (
< div className = " absolute right-0 top-full mt-2 w-80 rounded-xl border border-border bg-card shadow-2xl z-50 overflow-hidden " >
< div className = " flex items-center justify-between p-4 border-b border-border " >
< h3 className = " font-semibold " > Notifications </ h3 >
< span className = { `flex items-center gap-1.5 text-xs ${ isConnected ? ' text-green-500 ' : ' text-muted-foreground ' } ` } >
< span className = { `h-2 w-2 rounded-full ${ isConnected ? ' bg-green-500 ' : ' bg-gray-400 ' } ` } />
{ isConnected ? ' Live ' : ' Disconnected ' }
</ span >
</ div >
< div className = " max-h-80 overflow-y-auto " >
{ notifications . length === 0 ? (
< p className = " text-sm text-muted-foreground p-6 text-center " > No notifications yet </ p >
) : (
notifications . map ( n => (
< div
key = { n . id }
className = { `flex gap-3 p-4 border-b border-border/50 hover:bg-accent/50 transition-colors ${ n . read ? ' opacity-60 ' : '' } ` }
>
{ n . type === ' success ' ? (
< CheckCircle className = " h-5 w-5 text-green-500 shrink-0 mt-0.5 " />
) : (
< AlertCircle className = " h-5 w-5 text-amber-500 shrink-0 mt-0.5 " />
) }
< div className = " flex-1 min-w-0 " >
< p className = " text-sm font-medium " > { n . title } </ p >
< p className = " text-xs text-muted-foreground mt-0.5 " > { n . message } </ p >
< p className = " text-[10px] text-muted-foreground mt-1 " > { n . timestamp } </ p >
</ div >
</ div >
))
) }
</ div >
</ div >
) }
</ div >
) ;
}
:
Theme
;
toggleTheme : () => void ;
}
const ThemeContext = createContext < ThemeContextType | null > ( null ) ;
const STORAGE_KEY = ' learndash-theme ' ;
function getInitialTheme () : Theme {
if ( typeof window === ' undefined ' ) return ' light ' ;
const stored = localStorage . getItem ( STORAGE_KEY ) as Theme | null ;
if ( stored ) return stored ;
return window . matchMedia ( ' (prefers-color-scheme: dark) ' ) . matches ? ' dark ' : ' light ' ;
}
export function ThemeProvider ({ children } : { children : ReactNode }) {
const [ theme , setTheme ] = useState < Theme > ( getInitialTheme ) ;
useEffect ( () => {
const root = document . documentElement ;
root . classList . toggle ( ' dark ' , theme === ' dark ' ) ;
localStorage . setItem ( STORAGE_KEY , theme ) ;
}, [ theme ]) ;
const toggleTheme = () => setTheme ( prev => ( prev === ' light ' ? ' dark ' : ' light ' )) ;
return (
< ThemeContext.Provider value = {{ theme , toggleTheme }} >
{ children }
</ ThemeContext.Provider >
) ;
}
export function useTheme () {
const ctx = useContext ( ThemeContext ) ;
if ( ! ctx ) throw new Error ( ' useTheme must be used within ThemeProvider ' ) ;
return ctx ;
}
return (
< button
onClick = { toggleTheme }
className = " p-2 rounded-lg hover:bg-accent transition-colors "
aria-label = { `Switch to ${ theme === ' light ' ? ' dark ' : ' light ' } mode` }
>
{ theme === ' light ' ? (
< Moon className = " h-5 w-5 " />
) : (
< Sun className = " h-5 w-5 " />
) }
</ button >
) ;
}
'
;
import { ThemeProvider } from ' @/contexts/ThemeContext ' ;
import { ProtectedRoute } from ' @/components/auth/ProtectedRoute ' ;
import { DashboardLayout } from ' @/components/layout/DashboardLayout ' ;
import { lazy , Suspense } from ' react ' ;
// Lazy load pages for code splitting
const Login = lazy ( () => import ( ' @/pages/Login ' )) ;
const Dashboard = lazy ( () => import ( ' @/pages/Dashboard ' )) ;
const AdminAnalytics = lazy ( () => import ( ' @/pages/admin/Analytics ' )) ;
const AdminCourses = lazy ( () => import ( ' @/pages/admin/Courses ' )) ;
const AdminUsers = lazy ( () => import ( ' @/pages/admin/Users ' )) ;
const UserCourses = lazy ( () => import ( ' @/pages/user/MyCourses ' )) ;
const queryClient = new QueryClient ( {
defaultOptions : {
queries : {
staleTime : 5 * 60 * 1000 , // 5 minutes
retry : 1 ,
},
},
} ) ;
export default function App () {
return (
< QueryClientProvider client = { queryClient } >
< ThemeProvider >
< AuthProvider >
< BrowserRouter >
< Suspense fallback = {
< div className = " flex h-screen items-center justify-center " >
< div className = " h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent " />
</ div >
} >
< Routes >
< Route path = " /login " element = { < Login /> } />
< Route element = { < ProtectedRoute /> } >
< Route element = { < DashboardLayout /> } >
< Route path = " /dashboard " element = { < Dashboard /> } />
< Route element = { < ProtectedRoute allowedRoles = { [ ' admin ' ] } /> } >
< Route path = " /admin/analytics " element = { < AdminAnalytics /> } />
< Route path = " /admin/courses " element = { < AdminCourses /> } />
< Route path = " /admin/users " element = { < AdminUsers /> } />
</ Route >
< Route element = { < ProtectedRoute allowedRoles = { [ ' user ' ] } /> } >
< Route path = " /user/courses " element = { < UserCourses /> } />
</ Route >
</ Route >
</ Route >
< Route path = " * " element = { < Navigate to = " /dashboard " replace /> } />
</ Routes >
</ Suspense >
</ BrowserRouter >
</ AuthProvider >
</ ThemeProvider >
</ QueryClientProvider >
) ;
}
{
DollarSign
,
Users
}
from
'
lucide-react
'
;
const mockStats = [
{ label : ' Revenue ' , value : ' ₹50,000 ' , change : ' 10% ' , changeType : ' positive ' as const , icon : DollarSign },
{ label : ' Users ' , value : ' 1,200 ' , change : ' 3% ' , changeType : ' negative ' as const , icon : Users },
] ;
describe ( ' StatCards ' , () => {
it ( ' renders all stat cards ' , () => {
render ( < StatCards stats = { mockStats } /> ) ;
expect ( screen . getByText ( ' Revenue ' )) . toBeInTheDocument () ;
expect ( screen . getByText ( ' Users ' )) . toBeInTheDocument () ;
expect ( screen . getByText ( ' ₹50,000 ' )) . toBeInTheDocument () ;
expect ( screen . getByText ( ' 1,200 ' )) . toBeInTheDocument () ;
} ) ;
it ( ' displays positive change in green ' , () => {
render ( < StatCards stats = { mockStats } /> ) ;
const positiveChange = screen . getByText ( ' ↑ 10% ' ) ;
expect ( positiveChange ) . toHaveClass ( ' text-green-600 ' ) ;
} ) ;
it ( ' displays negative change in red ' , () => {
render ( < StatCards stats = { mockStats } /> ) ;
const negativeChange = screen . getByText ( ' ↓ 3% ' ) ;
expect ( negativeChange ) . toHaveClass ( ' text-red-600 ' ) ;
} ) ;
} ) ;
Theme persistence
'
,
()
=>
{
beforeEach ( () => {
localStorage . clear () ;
} ) ;
it ( ' defaults to light theme ' , () => {
const stored = localStorage . getItem ( ' learndash-theme ' ) ;
expect ( stored ) . toBeNull () ;
} ) ;
it ( ' persists theme to localStorage ' , () => {
localStorage . setItem ( ' learndash-theme ' , ' dark ' ) ;
expect ( localStorage . getItem ( ' learndash-theme ' )) . toBe ( ' dark ' ) ;
} ) ;
it ( ' toggles between light and dark ' , () => {
localStorage . setItem ( ' learndash-theme ' , ' light ' ) ;
const newTheme = localStorage . getItem ( ' learndash-theme ' ) === ' light ' ? ' dark ' : ' light ' ;
localStorage . setItem ( ' learndash-theme ' , newTheme ) ;
expect ( localStorage . getItem ( ' learndash-theme ' )) . toBe ( ' dark ' ) ;
} ) ;
} ) ;
(
'
input[type="email"]
'
)
.
should
(
'
exist
'
)
;
cy . get ( ' input[type="password"] ' ) . should ( ' exist ' ) ;
cy . get ( ' button[type="submit"] ' ) . should ( ' contain ' , ' Sign In ' ) ;
} ) ;
it ( ' shows error for invalid credentials ' , () => {
cy . get ( ' input[type="email"] ' ) . type ( ' wrong@demo.com ' ) ;
cy . get ( ' input[type="password"] ' ) . type ( ' wrong ' ) ;
cy . get ( ' button[type="submit"] ' ) . click () ;
cy . contains ( ' Invalid credentials ' ) . should ( ' be.visible ' ) ;
} ) ;
it ( ' redirects admin to admin dashboard ' , () => {
cy . get ( ' input[type="email"] ' ) . type ( ' admin@demo.com ' ) ;
cy . get ( ' input[type="password"] ' ) . type ( ' admin123 ' ) ;
cy . get ( ' button[type="submit"] ' ) . click () ;
cy . url () . should ( ' include ' , ' /dashboard ' ) ;
cy . contains ( ' Analytics ' ) . should ( ' exist ' ) ;
} ) ;
it ( ' redirects user to user dashboard ' , () => {
cy . get ( ' input[type="email"] ' ) . type ( ' user@demo.com ' ) ;
cy . get ( ' input[type="password"] ' ) . type ( ' user123 ' ) ;
cy . get ( ' button[type="submit"] ' ) . click () ;
cy . url () . should ( ' include ' , ' /dashboard ' ) ;
cy . contains ( ' My Courses ' ) . should ( ' exist ' ) ;
} ) ;
} ) ;
actions/setup-node@v4
with :
node-version : 20
cache : ' npm '
- run : npm ci
- run : npm run test
- run : npm run lint
- run : npm run build
deploy :
needs : test
if : github.ref == 'refs/heads/main'
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : 20
cache : ' npm '
- run : npm ci
- run : npm run build
- uses : amondnet/vercel-action@v25
with :
vercel-token : ${{ secrets.VERCEL_TOKEN }}
vercel-org-id : ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id : ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args : ' --prod '