-
Dashboard
-
Coming soon...
+
+
+
Dashboard
+
+ Monitor your loved ones and manage their health sensors
+
+
+
+
+
Dashboard content coming soon...
);
diff --git a/web/app/(main)/layout.tsx b/web/app/(main)/layout.tsx
new file mode 100644
index 0000000..ee94199
--- /dev/null
+++ b/web/app/(main)/layout.tsx
@@ -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
{children};
+}
diff --git a/web/components/Layout/Breadcrumbs.tsx b/web/components/Layout/Breadcrumbs.tsx
new file mode 100644
index 0000000..fa639bd
--- /dev/null
+++ b/web/components/Layout/Breadcrumbs.tsx
@@ -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
= {
+ 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
+ *
+ * ```
+ *
+ * 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 (
+
+ );
+}
diff --git a/web/components/Layout/Header.tsx b/web/components/Layout/Header.tsx
new file mode 100644
index 0000000..7f60ef4
--- /dev/null
+++ b/web/components/Layout/Header.tsx
@@ -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
+ *
+ * ```
+ */
+export function Header() {
+ const pathname = usePathname();
+ const { user, logout } = useAuthStore();
+ const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+ const profileMenuRef = useRef(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 (
+
+
+
+ {/* Logo and brand */}
+
+
+
+ W
+
+
+ WellNuo
+
+
+
+ {/* Desktop navigation */}
+
+
+
+ {/* Right side: Profile menu */}
+
+ {/* Desktop profile dropdown */}
+
+
+
+ {/* Profile dropdown menu */}
+ {isProfileMenuOpen && (
+
+
+
{userName}
+ {user?.email && (
+
{user.email}
+ )}
+
+
+ setIsProfileMenuOpen(false)}
+ >
+ Profile Settings
+
+
+
+
+ )}
+
+
+ {/* Mobile menu button */}
+
+
+
+
+
+ {/* Mobile menu */}
+ {isMobileMenuOpen && (
+
+
+ {/* User info */}
+
+
+ {userInitials}
+
+
+
{userName}
+ {user?.email && (
+
{user.email}
+ )}
+
+
+
+ {/* Navigation links */}
+ {navItems.map((item) => {
+ const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
+ return (
+
+ {item.label}
+
+ );
+ })}
+
+ {/* Profile link */}
+
+ Profile Settings
+
+
+ {/* Sign out */}
+
+
+
+ )}
+
+ );
+}
diff --git a/web/components/Layout/Layout.tsx b/web/components/Layout/Layout.tsx
new file mode 100644
index 0000000..0980b9c
--- /dev/null
+++ b/web/components/Layout/Layout.tsx
@@ -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)
+ *
+ * Dashboard
+ * Content here
+ *
+ *
+ * // Custom layout without sidebar
+ *
+ * Profile
+ *
+ *
+ * // Full-width layout
+ *
+ * Full width content
+ *
+ * ```
+ */
+export function Layout({
+ children,
+ showSidebar = true,
+ showHeader = true,
+ showBreadcrumbs = true,
+ maxWidth = '7xl',
+ title,
+}: LayoutProps) {
+ const maxWidthClass = maxWidthClasses[maxWidth];
+
+ return (
+
+ {/* Sidebar - Desktop only */}
+ {showSidebar &&
}
+
+ {/* Main content area */}
+
+ {/* Header */}
+ {showHeader &&
}
+
+ {/* Page content */}
+
+
+ {/* Breadcrumbs */}
+ {showBreadcrumbs && (
+
+
+
+ )}
+
+ {/* Page title (if provided) */}
+ {title && (
+
+
{title}
+
+ )}
+
+ {/* Page content */}
+ {children}
+
+
+
+
+ );
+}
diff --git a/web/components/Layout/Sidebar.tsx b/web/components/Layout/Sidebar.tsx
new file mode 100644
index 0000000..b95b5dc
--- /dev/null
+++ b/web/components/Layout/Sidebar.tsx
@@ -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: (
+
+ ),
+ },
+ {
+ href: '/beneficiaries',
+ label: 'Loved Ones',
+ icon: (
+
+ ),
+ },
+ {
+ href: '/sensors',
+ label: 'Sensors',
+ icon: (
+
+ ),
+ },
+ {
+ href: '/settings',
+ label: 'Settings',
+ icon: (
+
+ ),
+ },
+];
+
+/**
+ * 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
+ *
+ * ```
+ */
+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 (
+
+ );
+}
diff --git a/web/components/Layout/__tests__/Breadcrumbs.test.tsx b/web/components/Layout/__tests__/Breadcrumbs.test.tsx
new file mode 100644
index 0000000..cc20eaf
--- /dev/null
+++ b/web/components/Layout/__tests__/Breadcrumbs.test.tsx
@@ -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();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('does not render on verify-otp page', () => {
+ (usePathname as jest.Mock).mockReturnValue('/verify-otp');
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders home icon link', () => {
+ (usePathname as jest.Mock).mockReturnValue('/dashboard');
+ render();
+
+ const homeLink = screen.getByLabelText('Home');
+ expect(homeLink).toHaveAttribute('href', '/dashboard');
+ });
+
+ it('renders single breadcrumb for dashboard', () => {
+ (usePathname as jest.Mock).mockReturnValue('/dashboard');
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText('Loved Ones')).toBeInTheDocument();
+ expect(screen.getByText('Subscription')).toBeInTheDocument();
+ });
+
+ it('capitalizes unknown segments', () => {
+ (usePathname as jest.Mock).mockReturnValue('/unknown-route');
+ render();
+
+ expect(screen.getByText('Unknown-route')).toBeInTheDocument();
+ });
+
+ it('marks last item as current page', () => {
+ (usePathname as jest.Mock).mockReturnValue('/beneficiaries/123/sensors');
+ render();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText('Settings')).toBeInTheDocument();
+ });
+
+ it('handles profile route', () => {
+ (usePathname as jest.Mock).mockReturnValue('/profile');
+ render();
+
+ expect(screen.getByText('Profile')).toBeInTheDocument();
+ });
+});
diff --git a/web/components/Layout/__tests__/Header.test.tsx b/web/components/Layout/__tests__/Header.test.tsx
new file mode 100644
index 0000000..a33d539
--- /dev/null
+++ b/web/components/Layout/__tests__/Header.test.tsx
@@ -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();
+ expect(screen.getByText('WellNuo')).toBeInTheDocument();
+ });
+
+ it('renders navigation items', () => {
+ render();
+ expect(screen.getAllByText('Dashboard')).toHaveLength(1);
+ expect(screen.getAllByText('Loved Ones')).toHaveLength(1);
+ });
+
+ it('highlights active navigation item', () => {
+ render();
+ const dashboardLink = screen.getAllByText('Dashboard')[0].closest('a');
+ expect(dashboardLink).toHaveClass('text-blue-600');
+ });
+
+ it('displays user initials in profile button', () => {
+ render();
+ // Desktop profile button shows initials
+ expect(screen.getByText('JD')).toBeInTheDocument();
+ });
+
+ it('displays user full name on desktop', () => {
+ render();
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
+ });
+
+ it('opens profile dropdown when clicked', async () => {
+ render();
+
+ // 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();
+
+ // 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();
+
+ 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();
+ // 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();
+ expect(screen.getByText('?')).toBeInTheDocument();
+ });
+
+ it('closes dropdown when clicking outside', async () => {
+ render();
+
+ // 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();
+ });
+ });
+});
diff --git a/web/components/Layout/__tests__/Layout.test.tsx b/web/components/Layout/__tests__/Layout.test.tsx
new file mode 100644
index 0000000..0446a39
--- /dev/null
+++ b/web/components/Layout/__tests__/Layout.test.tsx
@@ -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: () => Header
,
+}));
+
+jest.mock('../Sidebar', () => ({
+ Sidebar: () => Sidebar
,
+}));
+
+jest.mock('../Breadcrumbs', () => ({
+ Breadcrumbs: () => Breadcrumbs
,
+}));
+
+// 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(
+
+ Test Content
+
+ );
+
+ expect(screen.getByText('Test Content')).toBeInTheDocument();
+ });
+
+ it('renders header by default', () => {
+ render(
+
+ Content
+
+ );
+
+ expect(screen.getByTestId('header')).toBeInTheDocument();
+ });
+
+ it('renders sidebar by default', () => {
+ render(
+
+ Content
+
+ );
+
+ expect(screen.getByTestId('sidebar')).toBeInTheDocument();
+ });
+
+ it('renders breadcrumbs by default', () => {
+ render(
+
+ Content
+
+ );
+
+ expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
+ });
+
+ it('hides header when showHeader is false', () => {
+ render(
+
+ Content
+
+ );
+
+ expect(screen.queryByTestId('header')).not.toBeInTheDocument();
+ });
+
+ it('hides sidebar when showSidebar is false', () => {
+ render(
+
+ Content
+
+ );
+
+ expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
+ });
+
+ it('hides breadcrumbs when showBreadcrumbs is false', () => {
+ render(
+
+ Content
+
+ );
+
+ expect(screen.queryByTestId('breadcrumbs')).not.toBeInTheDocument();
+ });
+
+ it('renders page title when provided', () => {
+ render(
+
+ Content
+
+ );
+
+ expect(screen.getByText('Dashboard')).toBeInTheDocument();
+ });
+
+ it('applies correct max-width class (7xl by default)', () => {
+ const { container } = render(
+
+ Content
+
+ );
+
+ const main = container.querySelector('main div');
+ expect(main).toHaveClass('max-w-7xl');
+ });
+
+ it('applies custom max-width class', () => {
+ const { container } = render(
+
+ Content
+
+ );
+
+ const main = container.querySelector('main div');
+ expect(main).toHaveClass('max-w-4xl');
+ });
+
+ it('applies full-width when maxWidth is full', () => {
+ const { container } = render(
+
+ Content
+
+ );
+
+ 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(
+
+ Content
+
+ );
+
+ const mainWrapper = container.querySelector('.lg\\:pl-64');
+ expect(mainWrapper).toBeInTheDocument();
+ });
+
+ it('does not apply sidebar padding when sidebar is hidden', () => {
+ const { container } = render(
+
+ Content
+
+ );
+
+ const mainWrapper = container.querySelector('.lg\\:pl-64');
+ expect(mainWrapper).not.toBeInTheDocument();
+ });
+
+ it('has proper background color', () => {
+ const { container } = render(
+
+ Content
+
+ );
+
+ const wrapper = container.firstChild;
+ expect(wrapper).toHaveClass('bg-slate-50');
+ });
+
+ it('renders with all features enabled', () => {
+ render(
+
+ Content
+
+ );
+
+ 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(
+
+ Content
+
+ );
+
+ expect(screen.queryByTestId('header')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('breadcrumbs')).not.toBeInTheDocument();
+ expect(screen.getByText('Content')).toBeInTheDocument();
+ });
+});
diff --git a/web/components/Layout/__tests__/Sidebar.test.tsx b/web/components/Layout/__tests__/Sidebar.test.tsx
new file mode 100644
index 0000000..f273730
--- /dev/null
+++ b/web/components/Layout/__tests__/Sidebar.test.tsx
@@ -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();
+ expect(screen.getByText('WellNuo')).toBeInTheDocument();
+ });
+
+ it('renders all navigation items', () => {
+ render();
+ 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();
+ 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();
+
+ const beneficiariesLink = screen.getByText('Loved Ones').closest('a');
+ expect(beneficiariesLink).toHaveClass('bg-blue-50', 'text-blue-700');
+ });
+
+ it('displays user profile with initials', () => {
+ render();
+ expect(screen.getByText('JS')).toBeInTheDocument();
+ });
+
+ it('displays user full name', () => {
+ render();
+ expect(screen.getByText('Jane Smith')).toBeInTheDocument();
+ });
+
+ it('displays user email', () => {
+ render();
+ expect(screen.getByText('test@example.com')).toBeInTheDocument();
+ });
+
+ it('has a link to profile page', () => {
+ render();
+ const profileLink = screen.getByText('Jane Smith').closest('a');
+ expect(profileLink).toHaveAttribute('href', '/profile');
+ });
+
+ it('renders sign out button', () => {
+ render();
+ 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();
+ expect(screen.getAllByText('user@example.com')).toHaveLength(2);
+ });
+});
diff --git a/web/components/Layout/index.ts b/web/components/Layout/index.ts
new file mode 100644
index 0000000..1146e84
--- /dev/null
+++ b/web/components/Layout/index.ts
@@ -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 (
+ *
+ * Content here
+ *
+ * );
+ * }
+ * ```
+ */
+
+export { Layout } from './Layout';
+export { Header } from './Header';
+export { Sidebar } from './Sidebar';
+export { Breadcrumbs } from './Breadcrumbs';