Skip to content

Commit 7b0d7e1

Browse files
feat(mobile): bottom navigation bar for mobile (aluno + dashboard) — PRD-3 (#179)
* feat(mobile): add bottom navigation bar for mobile aluno + dashboard — PRD-3 - New src/components/bottom-nav.tsx: shared mobile-only BottomNav (md:hidden), 44pt touch-target, safe-area pb, reduced-motion-safe transitions, lucide iconName string union avoids ReactNode serialization across server/client - Wire BottomNav into aluno layout (2 items) + dashboard layout via DashboardBottomNav client wrapper (usePathname + getNavItems, role-filtered; dev item stays sidebar-only) - Extract getNavItems(role) from DashboardNav into reusable selector; sidebar + bottom bar share one source of role-filtered nav truth - Remove redundant md:hidden nav block from aluno UserMenu dropdown (primary nav now bottom bar); drop unused navLinks prop from UserMenu - pb-16/pb-20 on main content reserves space above fixed bottom bar - Tests: bottom-nav.test.tsx (6), updated aluno-header.test.tsx (drop removed prop); dashboard-nav.test.tsx unchanged (14 green) Refs: PRD-3 mobile-navigation-design, builds on PRD-1 foundation (#176) Co-Authored-By: Claude <noreply@anthropic.com> * docs: update CURRENT-STATE for PRD-3 bottom nav (PR #179) Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): address 5 cubic review findings on PR #179 - Extract isNavActive() pure fn to bottom-nav.tsx, reuse in DashboardNav (P2: was duplicated isActive logic) - Export DEV_HREF from dashboard-nav, import in DashboardBottomNav wrapper (P3: dev route string duplicated) - Move DashboardBottomNav out of SidebarInset (<main>) into parent div (P3: nav landmark nested inside main — aluno layout already kept nav outside main) - Remove dead DashboardNavItem type alias (P3: exported never consumed) - Add dashboard-bottom-nav.test.tsx: 4 tests asserting role filtering (GERENTE 5 items, FINANCIAL_ROUTES excludes Financeiro+Planos for INSTRUTOR), DEV_HREF exclusion, bottom-nav-limit ≤5 (P2: no test coverage) - Add FolderKanban to dashboard-nav.test.tsx lucide mock (now imported via bottom-nav real module chain) Gates: typecheck ✅, lint ✅, 1129/1129 tests ✅. Co-Authored-By: Claude <noreply@anthropic.com> * docs(mobile): correct test count in CURRENT-STATE (1125→1129) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Emiya Kiritsugu <emiyakiritsugu3@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 222e1b3 commit 7b0d7e1

11 files changed

Lines changed: 362 additions & 63 deletions

docs/CURRENT-STATE.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,30 @@
1-
# Estado Atual (2026-07-05)
1+
# Estado Atual (2026-07-06)
2+
3+
## Mobile-First Premium Polish (v0.10.0 em andamento)
4+
5+
**Branch ativa:** `feat/009-mobile-navigation`
6+
**PRs mobile-first:** PR #176 (PRD-1 foundation, merged) → PR #179 (PRD-3 bottom nav, open).
7+
8+
### PRD-3 — Bottom Navigation (mobile) — PR #179
9+
10+
Shared `src/components/bottom-nav.tsx` (client, `md:hidden`, 44pt touch-target, safe-area pb, reduced-motion-safe). Wired into ambos os portais:
11+
12+
- **Aluno** (mobile): 2 items (Dashboard, Meus Treinos).
13+
- **Dashboard** (mobile): GERENTE 5 items, INSTRUTOR/RECEPCIONISTA 3 (FINANCIAL_ROUTES exclui Financeiro+Planos). Dev fica sidebar-only.
14+
15+
Refactor `getNavItems(role)` extraído de `DashboardNav` — single source de nav filtrada por role, compartilhada sidebar + bottom bar. `UserMenu` (aluno) perdeu `navLinks` prop + bloco `md:hidden` redundante no dropdown.
16+
17+
Gates: typecheck ✅, lint ✅ (changed files clean), prettier ✅, 1129/1129 tests ✅.
18+
19+
### PRD-1 — Mobile-First Foundation (merged #176)
20+
21+
Viewport `viewportFit: cover`, dvh swap 13 files, 44pt touch-target, safe-area, reduced-motion.
22+
23+
### Sequência mobile-first
24+
25+
PRD-1 ✅ → PRD-3 ✅ (open) → PRD-2 (brand consistency) → 4 → 5 → 6 → 7 → 8.
26+
27+
---
228

329
## v0.9.0 — AI Workout Gen Fix + Meus Treinos UX Overhaul
430

src/app/aluno/aluno-header.test.tsx

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -125,29 +125,13 @@ describe('AlunoHeader', () => {
125125

126126
describe('UserMenu (aluno-header)', () => {
127127
it('renders user name and email', () => {
128-
render(
129-
<UserMenu
130-
displayName="Ana"
131-
photoURL=""
132-
email="ana@test.com"
133-
navLinks={[]}
134-
onLogout={vi.fn()}
135-
/>
136-
);
128+
render(<UserMenu displayName="Ana" photoURL="" email="ana@test.com" onLogout={vi.fn()} />);
137129
expect(screen.getByText('Ana')).toBeTruthy();
138130
expect(screen.getByText('ana@test.com')).toBeTruthy();
139131
});
140132

141133
it('renders profile and logout options', () => {
142-
render(
143-
<UserMenu
144-
displayName="Ana"
145-
photoURL=""
146-
email="ana@test.com"
147-
navLinks={[]}
148-
onLogout={vi.fn()}
149-
/>
150-
);
134+
render(<UserMenu displayName="Ana" photoURL="" email="ana@test.com" onLogout={vi.fn()} />);
151135
expect(screen.getByText('Meu Perfil')).toBeTruthy();
152136
expect(screen.getByText('Sair')).toBeTruthy();
153137
});

src/app/aluno/aluno-header.tsx

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,11 @@ export function UserMenu({
5757
displayName,
5858
photoURL,
5959
email,
60-
navLinks,
6160
onLogout,
6261
}: Readonly<{
6362
displayName: string;
6463
photoURL: string;
6564
email?: string | null;
66-
navLinks: NavLink[];
6765
onLogout: () => void;
6866
}>) {
6967
const { t } = useI18n();
@@ -87,17 +85,6 @@ export function UserMenu({
8785
<p>{displayName}</p>
8886
<p className="text-xs font-normal text-muted-foreground">{email}</p>
8987
</DropdownMenuLabel>
90-
<div className="md:hidden">
91-
<DropdownMenuSeparator />
92-
{navLinks.map((link) => (
93-
<DropdownMenuItem key={link.href} asChild>
94-
<Link href={link.href}>
95-
{link.icon}
96-
<span>{link.label}</span>
97-
</Link>
98-
</DropdownMenuItem>
99-
))}
100-
</div>
10188
<DropdownMenuSeparator />
10289
<DropdownMenuItem disabled>
10390
<UserIcon className="mr-2 h-4 w-4" />
@@ -148,13 +135,7 @@ export function AlunoHeader({
148135
<span className="hidden text-sm text-muted-foreground md:inline-block">
149136
{t('common.welcome')}, {displayName}!
150137
</span>
151-
<UserMenu
152-
displayName={displayName}
153-
photoURL={photoURL}
154-
email={email}
155-
navLinks={navLinks}
156-
onLogout={onLogout}
157-
/>
138+
<UserMenu displayName={displayName} photoURL={photoURL} email={email} onLogout={onLogout} />
158139
</div>
159140
</header>
160141
);

src/app/aluno/layout.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { Dumbbell, LayoutDashboard, FolderKanban } from 'lucide-react';
66
import { useAuth } from '@/components/providers/auth-provider';
77
import { I18nProvider, useI18n } from '@/components/providers/i18n-provider';
88
import { AlunoHeader, type NavLink } from './aluno-header';
9+
import { BottomNav, type NavIconName } from '@/components/bottom-nav';
10+
11+
const ALUNO_ICON_BY_HREF: Record<string, NavIconName> = {
12+
'/aluno/dashboard': 'layout-dashboard',
13+
'/aluno/meus-treinos': 'folder-kanban',
14+
};
915

1016
function resolveDisplayName(meta: Record<string, unknown> | undefined, fallback: string): string {
1117
const m = meta as { full_name?: string; nomeCompleto?: string } | undefined;
@@ -80,7 +86,17 @@ function AuthenticatedLayout({
8086
email={email}
8187
onLogout={onLogout}
8288
/>
83-
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-6 lg:p-8">{children}</main>
89+
<main className="flex flex-1 flex-col gap-4 p-4 pb-16 md:gap-8 md:p-6 md:pb-6 lg:p-8 lg:pb-8">
90+
{children}
91+
</main>
92+
<BottomNav
93+
items={navLinks.map((n) => ({
94+
href: n.href,
95+
label: n.label,
96+
iconName: ALUNO_ICON_BY_HREF[n.href] ?? 'layout-dashboard',
97+
}))}
98+
activeHref={pathname}
99+
/>
84100
</div>
85101
);
86102
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import { DashboardBottomNav } from './dashboard-bottom-nav';
4+
import type { ReactNode } from 'react';
5+
6+
vi.mock('next/navigation', () => ({
7+
usePathname: vi.fn(() => '/dashboard'),
8+
}));
9+
10+
vi.mock('next/link', () => ({
11+
default: ({
12+
children,
13+
href,
14+
...rest
15+
}: {
16+
children?: ReactNode;
17+
href: string;
18+
[key: string]: unknown;
19+
}) => {
20+
const ariaCurrent = (rest['aria-current'] ?? rest.ariaCurrent) as
21+
'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' | undefined;
22+
return (
23+
<a data-testid="nav-link" href={href} aria-current={ariaCurrent}>
24+
{children}
25+
</a>
26+
);
27+
},
28+
}));
29+
30+
vi.mock('lucide-react', () => {
31+
const MockIcon = ({ 'data-testid': testId }: { 'data-testid'?: string }) => (
32+
<span data-testid={testId || 'icon'} />
33+
);
34+
return {
35+
LayoutDashboard: () => <MockIcon data-testid="icon-dashboard" />,
36+
Users: () => <MockIcon data-testid="icon-users" />,
37+
Dumbbell: () => <MockIcon data-testid="icon-dumbbell" />,
38+
DollarSign: () => <MockIcon data-testid="icon-dollar" />,
39+
FileText: () => <MockIcon data-testid="icon-file" />,
40+
FolderKanban: () => <MockIcon data-testid="icon-folder" />,
41+
FlaskConical: () => <MockIcon data-testid="icon-flask" />,
42+
};
43+
});
44+
45+
describe('DashboardBottomNav', () => {
46+
it('renders all primary destinations for GERENTE (excludes dev)', () => {
47+
render(<DashboardBottomNav role="GERENTE" />);
48+
expect(screen.getByText('Dashboard')).toBeTruthy();
49+
expect(screen.getByText('Alunos')).toBeTruthy();
50+
expect(screen.getByText('Treinos')).toBeTruthy();
51+
expect(screen.getByText('Financeiro')).toBeTruthy();
52+
expect(screen.getByText('Planos')).toBeTruthy();
53+
});
54+
55+
it('excludes Financeiro + Planos for INSTRUTOR (FINANCIAL_ROUTES filter)', () => {
56+
render(<DashboardBottomNav role="INSTRUTOR" />);
57+
expect(screen.getByText('Dashboard')).toBeTruthy();
58+
expect(screen.getByText('Alunos')).toBeTruthy();
59+
expect(screen.getByText('Treinos')).toBeTruthy();
60+
expect(screen.queryByText('Financeiro')).toBeNull();
61+
expect(screen.queryByText('Planos')).toBeNull();
62+
});
63+
64+
it('excludes dev route (DEV_HREF) — dev stays sidebar-only', () => {
65+
render(<DashboardBottomNav role="GERENTE" />);
66+
expect(screen.queryByText('Dev')).toBeNull();
67+
const links = screen.getAllByTestId('nav-link');
68+
expect(links.find((l) => l.getAttribute('href') === '/dashboard/dev')).toBeUndefined();
69+
});
70+
71+
it('limits to bottom-nav-limit ≤5 items', () => {
72+
render(<DashboardBottomNav role="GERENTE" />);
73+
const links = screen.getAllByTestId('nav-link');
74+
expect(links.length).toBeLessThanOrEqual(5);
75+
});
76+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
'use client';
2+
3+
import { usePathname } from 'next/navigation';
4+
import { BottomNav } from '@/components/bottom-nav';
5+
import { DEV_HREF, getNavItems } from '@/components/dashboard-nav';
6+
7+
export function DashboardBottomNav({ role }: Readonly<{ role: string }>) {
8+
const pathname = usePathname();
9+
// dev item stays sidebar Sheet-only (secondary nav) — exclude from bottom bar
10+
const items = getNavItems(role).filter((item) => item.href !== DEV_HREF);
11+
return <BottomNav items={items} activeHref={pathname} />;
12+
}

src/app/dashboard/layout.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
SidebarTrigger,
1111
} from '@/components/ui/sidebar';
1212
import { DashboardNav, DashboardNavBottom } from '@/components/dashboard-nav';
13+
import { DashboardBottomNav } from '@/app/dashboard/_components/dashboard-bottom-nav';
1314
import { UserMenu } from '@/app/dashboard/_components/user-menu';
1415
import { createClient } from '@/utils/supabase/server';
1516
import { Dumbbell } from 'lucide-react';
@@ -116,8 +117,12 @@ export default async function DashboardLayout({
116117

117118
<SidebarInset className="bg-background/95">
118119
<DashboardHeader displayName={displayName} email={user.email!} photoURL={photoURL} />
119-
<main className="flex-1 p-6 md:p-8 max-w-[1600px] mx-auto w-full">{children}</main>
120+
<main className="flex-1 p-6 pb-20 md:p-8 md:pb-8 max-w-[1600px] mx-auto w-full">
121+
{children}
122+
</main>
120123
</SidebarInset>
124+
{/* ponytail: nav outside <main> (SidebarInset renders <main>) — avoids nesting nav landmark inside main, matches aluno layout */}
125+
<DashboardBottomNav role={role} />
121126
</div>
122127
</SidebarProvider>
123128
);

src/components/bottom-nav.test.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import { BottomNav, type NavItem } from './bottom-nav';
4+
import type { ReactNode } from 'react';
5+
6+
vi.mock('next/link', () => ({
7+
default: ({
8+
children,
9+
href,
10+
...rest
11+
}: {
12+
children?: ReactNode;
13+
href: string;
14+
[key: string]: unknown;
15+
}) => {
16+
const ariaCurrent = (rest['aria-current'] ?? rest.ariaCurrent) as
17+
'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' | undefined;
18+
return (
19+
<a data-testid="nav-link" href={href} aria-current={ariaCurrent}>
20+
{children}
21+
</a>
22+
);
23+
},
24+
}));
25+
26+
vi.mock('lucide-react', () => {
27+
const MockIcon = ({ 'data-testid': testId }: { 'data-testid'?: string }) => (
28+
<span data-testid={testId || 'icon'} />
29+
);
30+
return {
31+
LayoutDashboard: () => <MockIcon data-testid="icon-dashboard" />,
32+
Users: () => <MockIcon data-testid="icon-users" />,
33+
Dumbbell: () => <MockIcon data-testid="icon-dumbbell" />,
34+
DollarSign: () => <MockIcon data-testid="icon-dollar" />,
35+
FileText: () => <MockIcon data-testid="icon-file" />,
36+
FolderKanban: () => <MockIcon data-testid="icon-folder" />,
37+
};
38+
});
39+
40+
const ITEMS: NavItem[] = [
41+
{ href: '/dashboard', label: 'Dashboard', iconName: 'layout-dashboard' },
42+
{ href: '/dashboard/alunos', label: 'Alunos', iconName: 'users' },
43+
];
44+
45+
describe('BottomNav', () => {
46+
it('renders all items with correct labels and hrefs', () => {
47+
render(<BottomNav items={ITEMS} activeHref="/dashboard" />);
48+
expect(screen.getByText('Dashboard')).toBeTruthy();
49+
expect(screen.getByText('Alunos')).toBeTruthy();
50+
const links = screen.getAllByTestId('nav-link');
51+
const hrefs = links.map((l) => l.getAttribute('href'));
52+
expect(hrefs).toContain('/dashboard');
53+
expect(hrefs).toContain('/dashboard/alunos');
54+
});
55+
56+
it('marks the active item with aria-current=page', () => {
57+
render(<BottomNav items={ITEMS} activeHref="/dashboard/alunos" />);
58+
const links = screen.getAllByTestId('nav-link');
59+
const active = links.find((l) => l.getAttribute('aria-current') === 'page');
60+
expect(active).toBeTruthy();
61+
expect(active?.getAttribute('href')).toBe('/dashboard/alunos');
62+
});
63+
64+
it('does not mark inactive items as current page', () => {
65+
render(<BottomNav items={ITEMS} activeHref="/dashboard/alunos" />);
66+
const links = screen.getAllByTestId('nav-link');
67+
const currents = links.filter((l) => l.getAttribute('aria-current') === 'page');
68+
expect(currents).toHaveLength(1);
69+
});
70+
71+
it('root dashboard active only on exact match', () => {
72+
const { rerender } = render(<BottomNav items={ITEMS} activeHref="/dashboard/alunos/123" />);
73+
let links = screen.getAllByTestId('nav-link');
74+
let dashboardLink = links.find((l) => l.getAttribute('href') === '/dashboard');
75+
expect(dashboardLink?.getAttribute('aria-current')).toBeNull();
76+
const alunosLink = links.find((l) => l.getAttribute('href') === '/dashboard/alunos');
77+
expect(alunosLink?.getAttribute('aria-current')).toBe('page');
78+
79+
rerender(<BottomNav items={ITEMS} activeHref="/dashboard" />);
80+
links = screen.getAllByTestId('nav-link');
81+
dashboardLink = links.find((l) => l.getAttribute('href') === '/dashboard');
82+
expect(dashboardLink?.getAttribute('aria-current')).toBe('page');
83+
});
84+
85+
it('renders container with md:hidden class', () => {
86+
const { container } = render(<BottomNav items={ITEMS} activeHref="/dashboard" />);
87+
const nav = container.querySelector('nav');
88+
expect(nav).toBeTruthy();
89+
expect(nav?.className).toContain('md:hidden');
90+
});
91+
92+
it('renders nothing when items is empty', () => {
93+
const { container } = render(<BottomNav items={[]} activeHref="/" />);
94+
const links = container.querySelectorAll('[data-testid="nav-link"]');
95+
expect(links).toHaveLength(0);
96+
});
97+
});

0 commit comments

Comments
 (0)