Skip to content

Commit 51b2b91

Browse files
Mimah97cursoragent
andcommitted
feat(nav): add mobile bottom tab bar (InsurNiffy#671)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f5ce37b commit 51b2b91

3 files changed

Lines changed: 142 additions & 1 deletion

File tree

frontend/src/app/layout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Toaster } from "@/components/ui/toaster";
1111
import { WalletProvider, NetworkMismatchModal } from "@/features/wallet";
1212
import { inter, ibmPlexMono } from "@/lib/fonts";
1313
import { QueryProvider } from "@/lib/query";
14+
import { BottomTabBar } from "@/components/nav/BottomTabBar";
1415
import { NetworkBanner } from "@/components/ui/network-banner";
1516

1617
export const viewport: Viewport = {
@@ -109,7 +110,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
109110
}}
110111
/>
111112
</head>
112-
<body className="font-sans antialiased">
113+
<body className="font-sans antialiased pb-16 md:pb-0">
113114
<ThemeProvider defaultTheme="system" storageKey="niffyinsur-theme">
114115
<QueryProvider>
115116
<WalletProvider>
@@ -121,6 +122,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
121122
<CookieConsentBanner />
122123
<NetworkMismatchModal />
123124
<Toaster />
125+
<BottomTabBar />
124126
</WalletProvider>
125127
</QueryProvider>
126128
</ThemeProvider>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use client'
2+
3+
import Link from 'next/link'
4+
import { usePathname } from 'next/navigation'
5+
import { FileText, Home, Scale, Wallet } from 'lucide-react'
6+
7+
import { cn } from '@/lib/utils'
8+
9+
const TABS = [
10+
{ href: '/', label: 'Home', icon: Home, match: (path: string) => path === '/' },
11+
{
12+
href: '/policies',
13+
label: 'Policies',
14+
icon: FileText,
15+
match: (path: string) => path.startsWith('/policies'),
16+
},
17+
{
18+
href: '/claims',
19+
label: 'Claims',
20+
icon: Scale,
21+
match: (path: string) => path.startsWith('/claims'),
22+
},
23+
{
24+
href: '/settings',
25+
label: 'Wallet',
26+
icon: Wallet,
27+
match: (path: string) => path.startsWith('/settings'),
28+
},
29+
] as const
30+
31+
export function BottomTabBar() {
32+
const pathname = usePathname()
33+
34+
return (
35+
<nav
36+
aria-label="Mobile navigation"
37+
className="fixed bottom-0 inset-x-0 z-40 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 md:hidden pb-[env(safe-area-inset-bottom)]"
38+
>
39+
<ul className="flex items-stretch justify-around">
40+
{TABS.map(({ href, label, icon: Icon, match }) => {
41+
const active = match(pathname)
42+
return (
43+
<li key={href} className="flex-1">
44+
<Link
45+
href={href}
46+
className={cn(
47+
'flex flex-col items-center justify-center gap-0.5 py-2.5 text-xs font-medium min-h-[48px] transition-colors',
48+
active
49+
? 'text-primary'
50+
: 'text-muted-foreground hover:text-foreground',
51+
)}
52+
aria-current={active ? 'page' : undefined}
53+
>
54+
<Icon size={20} aria-hidden />
55+
<span>{label}</span>
56+
</Link>
57+
</li>
58+
)
59+
})}
60+
</ul>
61+
</nav>
62+
)
63+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import React from 'react'
6+
import { render, screen } from '@testing-library/react'
7+
8+
import { BottomTabBar } from '../BottomTabBar'
9+
10+
const mockPathname = jest.fn(() => '/')
11+
12+
jest.mock('next/navigation', () => ({
13+
usePathname: () => mockPathname(),
14+
}))
15+
16+
jest.mock('next/link', () => {
17+
return function MockLink({
18+
href,
19+
children,
20+
...props
21+
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) {
22+
return (
23+
<a href={href} {...props}>
24+
{children}
25+
</a>
26+
)
27+
}
28+
})
29+
30+
function setViewportMobile() {
31+
Object.defineProperty(window, 'matchMedia', {
32+
writable: true,
33+
value: jest.fn().mockImplementation((query: string) => ({
34+
matches: query.includes('max-width') && query.includes('767px'),
35+
media: query,
36+
onchange: null,
37+
addListener: jest.fn(),
38+
removeListener: jest.fn(),
39+
addEventListener: jest.fn(),
40+
removeEventListener: jest.fn(),
41+
dispatchEvent: jest.fn(),
42+
})),
43+
})
44+
}
45+
46+
describe('BottomTabBar', () => {
47+
beforeEach(() => {
48+
mockPathname.mockReturnValue('/')
49+
setViewportMobile()
50+
})
51+
52+
it('is visible on mobile viewport and hidden on desktop via responsive classes', () => {
53+
const { container } = render(<BottomTabBar />)
54+
const nav = container.querySelector('nav')
55+
expect(nav).toHaveClass('md:hidden')
56+
expect(nav).toBeInTheDocument()
57+
})
58+
59+
it('highlights the active tab based on the current route', () => {
60+
mockPathname.mockReturnValue('/claims')
61+
render(<BottomTabBar />)
62+
63+
const claimsLink = screen.getByRole('link', { name: /claims/i })
64+
expect(claimsLink).toHaveAttribute('aria-current', 'page')
65+
expect(screen.getByRole('link', { name: /home/i })).not.toHaveAttribute('aria-current')
66+
})
67+
68+
it('tab links navigate to the correct routes', () => {
69+
render(<BottomTabBar />)
70+
71+
expect(screen.getByRole('link', { name: /home/i })).toHaveAttribute('href', '/')
72+
expect(screen.getByRole('link', { name: /policies/i })).toHaveAttribute('href', '/policies')
73+
expect(screen.getByRole('link', { name: /claims/i })).toHaveAttribute('href', '/claims')
74+
expect(screen.getByRole('link', { name: /wallet/i })).toHaveAttribute('href', '/settings')
75+
})
76+
})

0 commit comments

Comments
 (0)