Add Layout components for web application

Implemented responsive layout system with:
- Header: Top navigation with profile menu and mobile hamburger
- Sidebar: Desktop-only navigation sidebar (lg and above)
- Breadcrumbs: Auto-generated navigation breadcrumbs
- Layout: Main wrapper component with configurable options

Features:
- Responsive design (mobile, tablet, desktop)
- Active route highlighting
- User profile integration via auth store
- Click-outside dropdown closing
- Comprehensive test coverage (49 tests passing)

Updated (main) layout to use new Layout component system.
Updated dashboard page to work with new layout structure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-31 18:20:13 -08:00
parent 4b60a92777
commit 1628501e75
11 changed files with 1303 additions and 4 deletions

View File

@ -1,11 +1,22 @@
'use client'; 'use client';
/**
* Dashboard Page
*
* Main dashboard showing overview of beneficiaries and sensor status.
*/
export default function DashboardPage() { export default function DashboardPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div>
<div className="text-center"> <div className="mb-6">
<h1 className="text-4xl font-bold text-gray-900">Dashboard</h1> <h1 className="text-3xl font-bold text-slate-900">Dashboard</h1>
<p className="mt-2 text-gray-600">Coming soon...</p> <p className="mt-2 text-slate-600">
Monitor your loved ones and manage their health sensors
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center">
<p className="text-slate-600">Dashboard content coming soon...</p>
</div> </div>
</div> </div>
); );

16
web/app/(main)/layout.tsx Normal file
View File

@ -0,0 +1,16 @@
'use client';
import { ReactNode } from 'react';
import { Layout } from '@/components/Layout';
/**
* Main App Layout
*
* Wraps all authenticated pages with the Layout component.
* Provides consistent navigation, header, and sidebar.
*
* This layout applies to all routes under (main) route group.
*/
export default function MainLayout({ children }: { children: ReactNode }) {
return <Layout>{children}</Layout>;
}

View File

@ -0,0 +1,144 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useMemo } from 'react';
/**
* Breadcrumb item type
*/
interface BreadcrumbItem {
label: string;
href?: string;
}
/**
* Route label mappings
* Maps route segments to human-readable labels
*/
const routeLabels: Record<string, string> = {
dashboard: 'Dashboard',
beneficiaries: 'Loved Ones',
sensors: 'Sensors',
settings: 'Settings',
profile: 'Profile',
'add-sensor': 'Add Sensor',
subscription: 'Subscription',
equipment: 'Equipment',
share: 'Share Access',
};
/**
* Breadcrumbs Component
*
* Displays navigation breadcrumbs based on current route.
* Automatically generates breadcrumb trail from pathname.
*
* Features:
* - Auto-generated from URL path
* - Clickable navigation links
* - Current page highlighted
* - Responsive (hidden on mobile)
*
* @example
* ```tsx
* <Breadcrumbs />
* ```
*
* Route examples:
* - /dashboard -> Dashboard
* - /beneficiaries/123 -> Loved Ones / John Doe
* - /beneficiaries/123/sensors -> Loved Ones / John Doe / Sensors
*/
export function Breadcrumbs() {
const pathname = usePathname();
const breadcrumbs = useMemo(() => {
// Don't show breadcrumbs on auth pages
if (pathname.startsWith('/login') || pathname.startsWith('/verify-otp')) {
return [];
}
const segments = pathname.split('/').filter(Boolean);
const items: BreadcrumbItem[] = [];
// Build breadcrumb trail
let currentPath = '';
segments.forEach((segment, index) => {
currentPath += `/${segment}`;
// Check if segment is a dynamic ID (numeric)
const isId = /^\d+$/.test(segment);
if (isId) {
// For IDs, use a placeholder or fetch the actual name
// In production, you would fetch the beneficiary name here
items.push({
label: `#${segment}`,
href: currentPath,
});
} else {
// Use mapped label or capitalize segment
const label = routeLabels[segment] || segment.charAt(0).toUpperCase() + segment.slice(1);
items.push({
label,
href: currentPath,
});
}
});
return items;
}, [pathname]);
// Don't render if no breadcrumbs
if (breadcrumbs.length === 0) {
return null;
}
return (
<nav aria-label="Breadcrumb" className="hidden md:block">
<ol className="flex items-center gap-2 text-sm">
{/* Home icon */}
<li>
<Link
href="/dashboard"
className="text-slate-500 hover:text-slate-700"
aria-label="Home"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
</Link>
</li>
{/* Breadcrumb items */}
{breadcrumbs.map((item, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<li key={item.href} className="flex items-center gap-2">
{/* Separator */}
<svg className="h-4 w-4 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
{/* Link or current page */}
{isLast ? (
<span className="font-medium text-slate-900" aria-current="page">
{item.label}
</span>
) : (
<Link
href={item.href || '#'}
className="text-slate-500 hover:text-slate-700"
>
{item.label}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@ -0,0 +1,238 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/authStore';
/**
* Navigation items for the header
*/
const navItems = [
{ href: '/dashboard', label: 'Dashboard' },
{ href: '/beneficiaries', label: 'Loved Ones' },
];
/**
* Header Component
*
* Responsive header with navigation and profile menu.
* - Desktop: Full navigation with profile dropdown
* - Mobile: Hamburger menu with slide-in navigation
*
* @example
* ```tsx
* <Header />
* ```
*/
export function Header() {
const pathname = usePathname();
const { user, logout } = useAuthStore();
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const profileMenuRef = useRef<HTMLDivElement>(null);
// Close profile menu when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) {
setIsProfileMenuOpen(false);
}
}
if (isProfileMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isProfileMenuOpen]);
// Close mobile menu when route changes
useEffect(() => {
setIsMobileMenuOpen(false);
}, [pathname]);
const handleLogout = async () => {
setIsProfileMenuOpen(false);
await logout();
};
const userInitials = user?.firstName && user?.lastName
? `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
: user?.email?.[0]?.toUpperCase() || '?';
const userName = user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.email || 'User';
return (
<header className="sticky top-0 z-50 w-full border-b border-slate-200 bg-white/80 backdrop-blur-md">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
{/* Logo and brand */}
<div className="flex items-center gap-8">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-blue-500">
<span className="text-lg font-bold text-white">W</span>
</div>
<span className="hidden text-xl font-semibold text-slate-900 sm:block">
WellNuo
</span>
</Link>
{/* Desktop navigation */}
<nav className="hidden md:flex md:gap-6">
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
className={`relative px-3 py-2 text-sm font-medium transition-colors ${
isActive
? 'text-blue-600'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{item.label}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600" />
)}
</Link>
);
})}
</nav>
</div>
{/* Right side: Profile menu */}
<div className="flex items-center gap-4">
{/* Desktop profile dropdown */}
<div className="relative hidden md:block" ref={profileMenuRef}>
<button
onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)}
className="flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-slate-50"
aria-expanded={isProfileMenuOpen}
aria-haspopup="true"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-sm font-semibold text-blue-700">
{userInitials}
</div>
<span className="hidden text-sm font-medium text-slate-700 lg:block">
{userName}
</span>
<svg
className={`h-4 w-4 text-slate-400 transition-transform ${
isProfileMenuOpen ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Profile dropdown menu */}
{isProfileMenuOpen && (
<div className="absolute right-0 mt-2 w-56 origin-top-right rounded-lg border border-slate-200 bg-white shadow-lg ring-1 ring-black ring-opacity-5">
<div className="p-3">
<div className="mb-1 text-sm font-medium text-slate-900">{userName}</div>
{user?.email && (
<div className="text-xs text-slate-500">{user.email}</div>
)}
</div>
<div className="border-t border-slate-100">
<Link
href="/profile"
className="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50"
onClick={() => setIsProfileMenuOpen(false)}
>
Profile Settings
</Link>
<button
onClick={handleLogout}
className="block w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50"
>
Sign Out
</button>
</div>
</div>
)}
</div>
{/* Mobile menu button */}
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="md:hidden rounded-lg p-2 text-slate-600 hover:bg-slate-50"
aria-label="Toggle menu"
aria-expanded={isMobileMenuOpen}
>
{isMobileMenuOpen ? (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
</div>
</div>
{/* Mobile menu */}
{isMobileMenuOpen && (
<div className="border-t border-slate-200 bg-white md:hidden">
<div className="space-y-1 px-4 pb-3 pt-2">
{/* User info */}
<div className="mb-4 flex items-center gap-3 rounded-lg bg-slate-50 p-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 text-sm font-semibold text-blue-700">
{userInitials}
</div>
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium text-slate-900">{userName}</div>
{user?.email && (
<div className="truncate text-xs text-slate-500">{user.email}</div>
)}
</div>
</div>
{/* Navigation links */}
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
className={`block rounded-lg px-3 py-2 text-base font-medium ${
isActive
? 'bg-blue-50 text-blue-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
{item.label}
</Link>
);
})}
{/* Profile link */}
<Link
href="/profile"
className="block rounded-lg px-3 py-2 text-base font-medium text-slate-700 hover:bg-slate-50"
>
Profile Settings
</Link>
{/* Sign out */}
<button
onClick={handleLogout}
className="block w-full rounded-lg px-3 py-2 text-left text-base font-medium text-red-600 hover:bg-red-50"
>
Sign Out
</button>
</div>
</div>
)}
</header>
);
}

View File

@ -0,0 +1,137 @@
'use client';
import { ReactNode } from 'react';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
import { Breadcrumbs } from './Breadcrumbs';
/**
* Layout Props
*/
interface LayoutProps {
/**
* Page content
*/
children: ReactNode;
/**
* Show sidebar (desktop only)
* @default true
*/
showSidebar?: boolean;
/**
* Show header
* @default true
*/
showHeader?: boolean;
/**
* Show breadcrumbs
* @default true
*/
showBreadcrumbs?: boolean;
/**
* Maximum content width
* @default '7xl' (1280px)
*/
maxWidth?: 'full' | '7xl' | '6xl' | '5xl' | '4xl';
/**
* Custom page title (shown in breadcrumbs if provided)
*/
title?: string;
}
/**
* Max width classes mapping
*/
const maxWidthClasses = {
full: '',
'7xl': 'max-w-7xl',
'6xl': 'max-w-6xl',
'5xl': 'max-w-5xl',
'4xl': 'max-w-4xl',
};
/**
* Main Layout Component
*
* Provides consistent layout structure for authenticated pages.
*
* Layout structure:
* - Desktop (lg+): Sidebar + Header + Main content
* - Mobile/Tablet: Header + Main content
*
* Features:
* - Responsive sidebar (desktop only)
* - Sticky header
* - Breadcrumb navigation
* - Configurable max-width
* - Proper spacing and padding
*
* @example
* ```tsx
* // Default layout (all features enabled)
* <Layout>
* <h1>Dashboard</h1>
* <p>Content here</p>
* </Layout>
*
* // Custom layout without sidebar
* <Layout showSidebar={false} maxWidth="4xl">
* <h1>Profile</h1>
* </Layout>
*
* // Full-width layout
* <Layout maxWidth="full">
* <div>Full width content</div>
* </Layout>
* ```
*/
export function Layout({
children,
showSidebar = true,
showHeader = true,
showBreadcrumbs = true,
maxWidth = '7xl',
title,
}: LayoutProps) {
const maxWidthClass = maxWidthClasses[maxWidth];
return (
<div className="flex min-h-screen bg-slate-50">
{/* Sidebar - Desktop only */}
{showSidebar && <Sidebar />}
{/* Main content area */}
<div className={`flex flex-1 flex-col ${showSidebar ? 'lg:pl-64' : ''}`}>
{/* Header */}
{showHeader && <Header />}
{/* Page content */}
<main className="flex-1">
<div className={`mx-auto px-4 py-6 sm:px-6 lg:px-8 ${maxWidthClass}`}>
{/* Breadcrumbs */}
{showBreadcrumbs && (
<div className="mb-6">
<Breadcrumbs />
</div>
)}
{/* Page title (if provided) */}
{title && (
<div className="mb-6">
<h1 className="text-2xl font-semibold text-slate-900">{title}</h1>
</div>
)}
{/* Page content */}
{children}
</div>
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,145 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/authStore';
/**
* Sidebar navigation items with icons
*/
const sidebarItems = [
{
href: '/dashboard',
label: 'Dashboard',
icon: (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
href: '/beneficiaries',
label: 'Loved Ones',
icon: (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
},
{
href: '/sensors',
label: 'Sensors',
icon: (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
),
},
{
href: '/settings',
label: 'Settings',
icon: (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
/**
* Sidebar Component
*
* Desktop-only sidebar navigation.
* Hidden on mobile/tablet, shown on desktop (lg and above).
*
* Features:
* - Active state highlighting
* - Icon + label navigation
* - User profile section at bottom
* - Logout button
*
* @example
* ```tsx
* <Sidebar />
* ```
*/
export function Sidebar() {
const pathname = usePathname();
const { user, logout } = useAuthStore();
const userInitials = user?.firstName && user?.lastName
? `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
: user?.email?.[0]?.toUpperCase() || '?';
const userName = user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.email || 'User';
return (
<aside className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
{/* Sidebar container */}
<div className="flex min-h-0 flex-1 flex-col border-r border-slate-200 bg-white">
{/* Logo section */}
<div className="flex h-16 shrink-0 items-center gap-2 border-b border-slate-200 px-6">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-blue-500">
<span className="text-lg font-bold text-white">W</span>
</div>
<span className="text-xl font-semibold text-slate-900">WellNuo</span>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 px-3 py-4">
{sidebarItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
className={`group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive
? 'bg-blue-50 text-blue-700'
: 'text-slate-700 hover:bg-slate-50 hover:text-slate-900'
}`}
>
<span className={isActive ? 'text-blue-700' : 'text-slate-400 group-hover:text-slate-600'}>
{item.icon}
</span>
{item.label}
</Link>
);
})}
</nav>
{/* User profile section at bottom */}
<div className="border-t border-slate-200 p-4">
<Link
href="/profile"
className="group flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-slate-50"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 text-sm font-semibold text-blue-700">
{userInitials}
</div>
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium text-slate-900">{userName}</div>
{user?.email && (
<div className="truncate text-xs text-slate-500">{user.email}</div>
)}
</div>
</Link>
{/* Logout button */}
<button
onClick={logout}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-red-50 hover:text-red-600"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign Out
</button>
</div>
</div>
</aside>
);
}

View File

@ -0,0 +1,107 @@
import { render, screen } from '@testing-library/react';
import { Breadcrumbs } from '../Breadcrumbs';
import { usePathname } from 'next/navigation';
// Mock Next.js navigation
jest.mock('next/navigation', () => ({
usePathname: jest.fn(),
}));
describe('Breadcrumbs Component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('does not render on login page', () => {
(usePathname as jest.Mock).mockReturnValue('/login');
const { container } = render(<Breadcrumbs />);
expect(container.firstChild).toBeNull();
});
it('does not render on verify-otp page', () => {
(usePathname as jest.Mock).mockReturnValue('/verify-otp');
const { container } = render(<Breadcrumbs />);
expect(container.firstChild).toBeNull();
});
it('renders home icon link', () => {
(usePathname as jest.Mock).mockReturnValue('/dashboard');
render(<Breadcrumbs />);
const homeLink = screen.getByLabelText('Home');
expect(homeLink).toHaveAttribute('href', '/dashboard');
});
it('renders single breadcrumb for dashboard', () => {
(usePathname as jest.Mock).mockReturnValue('/dashboard');
render(<Breadcrumbs />);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Dashboard')).toHaveAttribute('aria-current', 'page');
});
it('renders breadcrumbs for nested route', () => {
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123');
render(<Breadcrumbs />);
expect(screen.getByText('Loved Ones')).toBeInTheDocument();
expect(screen.getByText('#123')).toBeInTheDocument();
});
it('renders breadcrumbs for deep nested route', () => {
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123/sensors');
render(<Breadcrumbs />);
expect(screen.getByText('Loved Ones')).toBeInTheDocument();
expect(screen.getByText('#123')).toBeInTheDocument();
expect(screen.getByText('Sensors')).toBeInTheDocument();
});
it('renders proper mapped labels', () => {
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123/subscription');
render(<Breadcrumbs />);
expect(screen.getByText('Loved Ones')).toBeInTheDocument();
expect(screen.getByText('Subscription')).toBeInTheDocument();
});
it('capitalizes unknown segments', () => {
(usePathname as jest.Mock).mockReturnValue('/unknown-route');
render(<Breadcrumbs />);
expect(screen.getByText('Unknown-route')).toBeInTheDocument();
});
it('marks last item as current page', () => {
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123/sensors');
render(<Breadcrumbs />);
const currentPage = screen.getByText('Sensors');
expect(currentPage).toHaveAttribute('aria-current', 'page');
});
it('makes intermediate items clickable', () => {
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123/sensors');
render(<Breadcrumbs />);
const lovedOnesLink = screen.getByText('Loved Ones').closest('a');
expect(lovedOnesLink).toHaveAttribute('href', '/beneficiaries');
const idLink = screen.getByText('#123').closest('a');
expect(idLink).toHaveAttribute('href', '/beneficiaries/123');
});
it('handles settings route', () => {
(usePathname as jest.Mock).mockReturnValue('/settings');
render(<Breadcrumbs />);
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('handles profile route', () => {
(usePathname as jest.Mock).mockReturnValue('/profile');
render(<Breadcrumbs />);
expect(screen.getByText('Profile')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,155 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Header } from '../Header';
import { useAuthStore } from '@/stores/authStore';
import { usePathname } from 'next/navigation';
// Mock Next.js navigation
jest.mock('next/navigation', () => ({
usePathname: jest.fn(),
}));
// Mock auth store
jest.mock('@/stores/authStore', () => ({
useAuthStore: jest.fn(),
}));
describe('Header Component', () => {
const mockLogout = jest.fn();
const mockUser = {
user_id: 1,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
};
beforeEach(() => {
jest.clearAllMocks();
(usePathname as jest.Mock).mockReturnValue('/dashboard');
(useAuthStore as unknown as jest.Mock).mockReturnValue({
user: mockUser,
logout: mockLogout,
});
});
it('renders the WellNuo logo and brand', () => {
render(<Header />);
expect(screen.getByText('WellNuo')).toBeInTheDocument();
});
it('renders navigation items', () => {
render(<Header />);
expect(screen.getAllByText('Dashboard')).toHaveLength(1);
expect(screen.getAllByText('Loved Ones')).toHaveLength(1);
});
it('highlights active navigation item', () => {
render(<Header />);
const dashboardLink = screen.getAllByText('Dashboard')[0].closest('a');
expect(dashboardLink).toHaveClass('text-blue-600');
});
it('displays user initials in profile button', () => {
render(<Header />);
// Desktop profile button shows initials
expect(screen.getByText('JD')).toBeInTheDocument();
});
it('displays user full name on desktop', () => {
render(<Header />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('opens profile dropdown when clicked', async () => {
render(<Header />);
// Get profile button (not the mobile menu button)
const buttons = screen.getAllByRole('button', { expanded: false });
const profileButton = buttons.find(btn => btn.getAttribute('aria-haspopup') === 'true');
if (!profileButton) throw new Error('Profile button not found');
fireEvent.click(profileButton);
await waitFor(() => {
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
expect(screen.getByText('Sign Out')).toBeInTheDocument();
});
});
it('calls logout when Sign Out is clicked in dropdown', async () => {
render(<Header />);
// Open dropdown
const buttons = screen.getAllByRole('button', { expanded: false });
const profileButton = buttons.find(btn => btn.getAttribute('aria-haspopup') === 'true');
if (!profileButton) throw new Error('Profile button not found');
fireEvent.click(profileButton);
// Click Sign Out in dropdown (not mobile menu)
await waitFor(() => {
const signOutButtons = screen.getAllByRole('button', { name: /sign out/i });
fireEvent.click(signOutButtons[0]);
});
expect(mockLogout).toHaveBeenCalled();
});
it('toggles mobile menu', async () => {
render(<Header />);
const menuButton = screen.getByLabelText('Toggle menu');
fireEvent.click(menuButton);
await waitFor(() => {
// Mobile menu should show navigation items
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
});
// Close menu
fireEvent.click(menuButton);
});
it('displays user email when no first/last name', () => {
(useAuthStore as unknown as jest.Mock).mockReturnValue({
user: { user_id: 1, email: 'test@example.com' },
logout: mockLogout,
});
render(<Header />);
// Email displayed in profile section
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
it('displays fallback initials when no user data', () => {
(useAuthStore as unknown as jest.Mock).mockReturnValue({
user: { user_id: 1 },
logout: mockLogout,
});
render(<Header />);
expect(screen.getByText('?')).toBeInTheDocument();
});
it('closes dropdown when clicking outside', async () => {
render(<Header />);
// Open dropdown
const buttons = screen.getAllByRole('button', { expanded: false });
const profileButton = buttons.find(btn => btn.getAttribute('aria-haspopup') === 'true');
if (!profileButton) throw new Error('Profile button not found');
fireEvent.click(profileButton);
await waitFor(() => {
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
});
// Click outside
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByText('Profile Settings')).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,221 @@
import { render, screen } from '@testing-library/react';
import { Layout } from '../Layout';
import { useAuthStore } from '@/stores/authStore';
import { usePathname } from 'next/navigation';
// Mock child components
jest.mock('../Header', () => ({
Header: () => <div data-testid="header">Header</div>,
}));
jest.mock('../Sidebar', () => ({
Sidebar: () => <div data-testid="sidebar">Sidebar</div>,
}));
jest.mock('../Breadcrumbs', () => ({
Breadcrumbs: () => <div data-testid="breadcrumbs">Breadcrumbs</div>,
}));
// Mock Next.js navigation
jest.mock('next/navigation', () => ({
usePathname: jest.fn(),
}));
// Mock auth store
jest.mock('@/stores/authStore', () => ({
useAuthStore: jest.fn(),
}));
describe('Layout Component', () => {
beforeEach(() => {
jest.clearAllMocks();
(usePathname as jest.Mock).mockReturnValue('/dashboard');
(useAuthStore as unknown as jest.Mock).mockReturnValue({
user: { user_id: 1, email: 'test@example.com' },
logout: jest.fn(),
});
});
it('renders children content', () => {
render(
<Layout>
<div>Test Content</div>
</Layout>
);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('renders header by default', () => {
render(
<Layout>
<div>Content</div>
</Layout>
);
expect(screen.getByTestId('header')).toBeInTheDocument();
});
it('renders sidebar by default', () => {
render(
<Layout>
<div>Content</div>
</Layout>
);
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
});
it('renders breadcrumbs by default', () => {
render(
<Layout>
<div>Content</div>
</Layout>
);
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
});
it('hides header when showHeader is false', () => {
render(
<Layout showHeader={false}>
<div>Content</div>
</Layout>
);
expect(screen.queryByTestId('header')).not.toBeInTheDocument();
});
it('hides sidebar when showSidebar is false', () => {
render(
<Layout showSidebar={false}>
<div>Content</div>
</Layout>
);
expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
});
it('hides breadcrumbs when showBreadcrumbs is false', () => {
render(
<Layout showBreadcrumbs={false}>
<div>Content</div>
</Layout>
);
expect(screen.queryByTestId('breadcrumbs')).not.toBeInTheDocument();
});
it('renders page title when provided', () => {
render(
<Layout title="Dashboard">
<div>Content</div>
</Layout>
);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
it('applies correct max-width class (7xl by default)', () => {
const { container } = render(
<Layout>
<div>Content</div>
</Layout>
);
const main = container.querySelector('main div');
expect(main).toHaveClass('max-w-7xl');
});
it('applies custom max-width class', () => {
const { container } = render(
<Layout maxWidth="4xl">
<div>Content</div>
</Layout>
);
const main = container.querySelector('main div');
expect(main).toHaveClass('max-w-4xl');
});
it('applies full-width when maxWidth is full', () => {
const { container } = render(
<Layout maxWidth="full">
<div>Content</div>
</Layout>
);
const main = container.querySelector('main div');
expect(main).not.toHaveClass('max-w-7xl');
expect(main).not.toHaveClass('max-w-4xl');
});
it('applies sidebar padding class to main area', () => {
const { container } = render(
<Layout showSidebar={true}>
<div>Content</div>
</Layout>
);
const mainWrapper = container.querySelector('.lg\\:pl-64');
expect(mainWrapper).toBeInTheDocument();
});
it('does not apply sidebar padding when sidebar is hidden', () => {
const { container } = render(
<Layout showSidebar={false}>
<div>Content</div>
</Layout>
);
const mainWrapper = container.querySelector('.lg\\:pl-64');
expect(mainWrapper).not.toBeInTheDocument();
});
it('has proper background color', () => {
const { container } = render(
<Layout>
<div>Content</div>
</Layout>
);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('bg-slate-50');
});
it('renders with all features enabled', () => {
render(
<Layout
showHeader={true}
showSidebar={true}
showBreadcrumbs={true}
title="Test Page"
>
<div>Content</div>
</Layout>
);
expect(screen.getByTestId('header')).toBeInTheDocument();
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
expect(screen.getByText('Test Page')).toBeInTheDocument();
expect(screen.getByText('Content')).toBeInTheDocument();
});
it('renders minimal layout without any chrome', () => {
render(
<Layout
showHeader={false}
showSidebar={false}
showBreadcrumbs={false}
>
<div>Content</div>
</Layout>
);
expect(screen.queryByTestId('header')).not.toBeInTheDocument();
expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
expect(screen.queryByTestId('breadcrumbs')).not.toBeInTheDocument();
expect(screen.getByText('Content')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,96 @@
import { render, screen } from '@testing-library/react';
import { Sidebar } from '../Sidebar';
import { useAuthStore } from '@/stores/authStore';
import { usePathname } from 'next/navigation';
// Mock Next.js navigation
jest.mock('next/navigation', () => ({
usePathname: jest.fn(),
}));
// Mock auth store
jest.mock('@/stores/authStore', () => ({
useAuthStore: jest.fn(),
}));
describe('Sidebar Component', () => {
const mockLogout = jest.fn();
const mockUser = {
user_id: 1,
email: 'test@example.com',
firstName: 'Jane',
lastName: 'Smith',
};
beforeEach(() => {
jest.clearAllMocks();
(usePathname as jest.Mock).mockReturnValue('/dashboard');
(useAuthStore as unknown as jest.Mock).mockReturnValue({
user: mockUser,
logout: mockLogout,
});
});
it('renders the WellNuo logo', () => {
render(<Sidebar />);
expect(screen.getByText('WellNuo')).toBeInTheDocument();
});
it('renders all navigation items', () => {
render(<Sidebar />);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Loved Ones')).toBeInTheDocument();
expect(screen.getByText('Sensors')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('highlights active navigation item', () => {
render(<Sidebar />);
const dashboardLink = screen.getByText('Dashboard').closest('a');
expect(dashboardLink).toHaveClass('bg-blue-50', 'text-blue-700');
});
it('highlights active item for nested routes', () => {
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123');
render(<Sidebar />);
const beneficiariesLink = screen.getByText('Loved Ones').closest('a');
expect(beneficiariesLink).toHaveClass('bg-blue-50', 'text-blue-700');
});
it('displays user profile with initials', () => {
render(<Sidebar />);
expect(screen.getByText('JS')).toBeInTheDocument();
});
it('displays user full name', () => {
render(<Sidebar />);
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
it('displays user email', () => {
render(<Sidebar />);
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
it('has a link to profile page', () => {
render(<Sidebar />);
const profileLink = screen.getByText('Jane Smith').closest('a');
expect(profileLink).toHaveAttribute('href', '/profile');
});
it('renders sign out button', () => {
render(<Sidebar />);
expect(screen.getByText('Sign Out')).toBeInTheDocument();
});
it('displays email when no first/last name', () => {
(useAuthStore as unknown as jest.Mock).mockReturnValue({
user: { user_id: 1, email: 'user@example.com' },
logout: mockLogout,
});
render(<Sidebar />);
expect(screen.getAllByText('user@example.com')).toHaveLength(2);
});
});

View File

@ -0,0 +1,29 @@
/**
* Layout Components
*
* Provides consistent layout structure for the web application.
*
* Components:
* - Layout: Main layout wrapper with sidebar, header, and breadcrumbs
* - Header: Top navigation bar with profile menu
* - Sidebar: Desktop navigation sidebar
* - Breadcrumbs: Contextual navigation breadcrumbs
*
* @example
* ```tsx
* import { Layout } from '@/components/Layout';
*
* export default function DashboardPage() {
* return (
* <Layout title="Dashboard">
* <div>Content here</div>
* </Layout>
* );
* }
* ```
*/
export { Layout } from './Layout';
export { Header } from './Header';
export { Sidebar } from './Sidebar';
export { Breadcrumbs } from './Breadcrumbs';