From bd06018bde99a2352d17403dfe8617d21afe5d1b Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 8 Aug 2026 11:23:03 -0300 Subject: [PATCH] =?UTF-8?q?B164:=20barra=20de=20navega=C3=A7=C3=A3o=20infe?= =?UTF-8?q?rior=20no=20mobile=20para=20staff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile bottom nav (staff only): 5 items (Início, Municípios, Atualizações, Agenda, Mais) with Drawer overflow for secondary destinations. Leader and desktop remain sidebar-only. FAB + scroll padding adjusted for safe-area. Resolves #400 --- docs/CHANGELOG-AGENTS.md | 2 + .../barra-navegacao-inferior-mobile-impl.md | 94 ++++++++++ scripts/lib/e2e-affected-manifest.mjs | 3 +- .../campanha/(app)/atualizacoes/page.tsx | 31 ++++ src/app/(campaign)/campanha/(app)/layout.tsx | 5 + .../campaign/shell/CampaignBottomNav.tsx | 172 ++++++++++++++++++ .../shell/CampaignQuickActionsHost.tsx | 13 +- src/components/campaign/shell/nav.ts | 44 ++++- src/lib/campaignPageChrome.ts | 9 +- src/lib/campaignPaths.ts | 2 + tests/e2e/campaignBottomNav.e2e.spec.ts | 117 ++++++++++++ tests/unit/campaignNav.unit.spec.ts | 54 +++++- 12 files changed, 538 insertions(+), 8 deletions(-) create mode 100644 docs/plans/barra-navegacao-inferior-mobile-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx create mode 100644 src/components/campaign/shell/CampaignBottomNav.tsx create mode 100644 tests/e2e/campaignBottomNav.e2e.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index fdf86331..3b46c00a 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-08):** **worktree next provisiona ambiente isolado por worktree** — agentes em paralelo não disputam mais porta 3000, o `teqo_test` compartilhado nem um segundo Postgres no 5432: `pnpm worktree next [--go] [--no-migrate]` deriva deterministicamente slot do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos) → dev server `3100+slot`, bancos `teqo_wt`/`teqo_wt_test` criados no container compartilhado com migrations aplicadas; escreve `.env.local` + `.env.test.local` (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD*SECRET/Blob/VAPID do main. `kill` dropa os bancos gerados (best-effort, WITH FORCE). Suites leem `.env.test.local` depois de `.env.test` (vitest.setup/playwright/db-doctor/gate-ci espelham o layering); `assertTestDatabase` aceita `teqo_test` ou `teqo*\_test`em localhost só (fail-closed preservado).`db:start`/`db:stop`fixam`-p teqo`(um container, qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação pura`scripts/lib/worktree-env.mjs` (unit-tested). +**Recently resolved (2026-08-08):** **B164** (Issue #400) — barra de navegação inferior fixa no mobile para staff, com 5 itens (Início, Municípios, Atualizações, Agenda, Mais) e drawer overflow "Mais" para destinos secundários (Quadro, Territórios, Organizações, Líderanças, etc.) + rodapé Perfil/Sair. Leader e desktop mantêm sidebar-only (lockdown intacto). `CampaignContentScroll` e `CampaignQuickActionsHost` ajustados para safe-area e z-index do FAB sobre a barra. `/campanha/atualizacoes` é placeholder honesto (âncora C89). Impl [`barra-navegacao-inferior-mobile-impl.md`](docs/plans/barra-navegacao-inferior-mobile-impl.md). + **Recently resolved (2026-08-07):** **C15** (Issue #390) — `/campanha/agenda` virou a entrada operacional staff com FullCalendar v7 route-local (semana/mês/lista), filtros compartilháveis por município/deputado/tag, criação pelo slot, detalhe por URL e remarcação drag/resize sob RBAC, transação e lock por atividade; `/campanha/atividades` e giros permanecem como lista/fluxos legados. Sem migration. Impl [`fullcalendar-agenda-campanha-impl.md`](plans/fullcalendar-agenda-campanha-impl.md). **Recently resolved (2026-08-06):** **B163** (Issue #391) — `/campanha/dobradinhas` agora edita Nome, Partido, E-mail e Telefone inline; `Contact` virou a fonte de identidade, dobradinha nova cria o Contact na mesma transação, e a ficha usa ID canônico com fallback/redirect de slugs antigos. A migration faz backfill idempotente, preserva nomes legados com preflight de limite, mantém a invariável de nome/telefone e o importador da projeção também reencontra dobradinhas pelo nome atual do Contact quando o alias legado diverge; os links de relações/busca apontam para ID. Intenção [`editar-campos-lista-dobradinhas.md`](plans/editar-campos-lista-dobradinhas.md); impl [`editar-campos-lista-dobradinhas-impl.md`](plans/editar-campos-lista-dobradinhas-impl.md). diff --git a/docs/plans/barra-navegacao-inferior-mobile-impl.md b/docs/plans/barra-navegacao-inferior-mobile-impl.md new file mode 100644 index 00000000..73a956a5 --- /dev/null +++ b/docs/plans/barra-navegacao-inferior-mobile-impl.md @@ -0,0 +1,94 @@ +# Impl: B164 — Barra de navegação inferior no mobile (+ drawer Mais) + +Status: aprovado +Atualizado em: 2026-08-08 +Issue: #400 +Intenção: docs/plans/barra-navegacao-inferior-mobile.md +Appetite restante: ~1 dia eng (dentro do estimado) + +## Leitura da intenção + +- **Outcome:** Staff no mobile troca entre os 4 destinos primários (Início, Municípios, Atualizações, Agenda) e o overflow (Mais) em um toque; barra fixa inferior só em mobile (< `md`), staff-only (leader lockdown intacto). +- **O que NÃO negociar:** leader não vê a barra de 5 destinos; desktop/tablet continua sidebar-only; `SidebarTrigger` mobile permanece; FAB continua; paginação/semântica do scroll global não quebra. +- **O que reavaliar:** altura real da bottom nav vs `pb` do scroll (`CampaignContentScroll`); z-index da barra vs FAB vs Drawer (overlay do wizard). + +## Abordagem recomendada + +```mermaid +flowchart TD + layout["(app)/layout.tsx"] --> topbar["CampaignMobileTopBar md:hidden"] + layout --> header["CampaignDesktopHeader md:flex hidden"] + layout --> scroll["CampaignContentScroll pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6"] + layout --> fab["CampaignQuickActionsFab — bottom ajustado via className"] + layout --> bottom["CampaignBottomNav staff fixed bottom-0 md:hidden"] + bottom --> drawer["Drawer overflow: Quadro … Perfil / Sair"] + bottom --> atualizacoes["/campanha/atualizacoes — âncora C89 (placeholder)"] + nav["nav.ts"] --> bottomnav["getCampaignBottomNav(role)"] + nav --> overflow["getCampaignOverflowNav(role)"] + nav --> bottomnav5["5 primários (Início, Municípios, Atualizações, Agenda, Mais)"] +``` + +**Opções consideradas:** A (drawer) | B (página separada) | C (reaproveitar B73) +**Recomendação:** **A — Drawer** para o overflow "Mais". — `@base-ui/react` Drawer já está no codebase (`CampaignQuickActionsOverlay` usa o mesmo padrão); consistente com swipe-down do wizard; FAB overlay já foi testado com Drawer. Nada de rota `/campanha/mais` → não precisa de página server, access gate, ou `CAMPAIGN_OVERFLOW_HREF`. +**Rejeitadas:** + +- B) Página `/campanha/mais`: navegação full-reload, menos nativo; duplicaria Perfil/Sair do sidebar. O drawer fecha em 1 toque e reusa `logoutCampaign`. +- C) Reaproveitar `CampaignBottomNav` apagado do B73: foi deletado deliberadamente ("git history é o arquivo"). Recriar novo, sem código morto. +- Bottom navigator genérico do shadcn: **não existe** nesse projeto (verificado `src/components/ui/`). Usa `Button` + CSS grid, como o original. + +### Componentes / mudanças + +- **`src/lib/campaignPaths.ts`**: adicionar `CAMPAIGN_UPDATES_HREF = '/campanha/atualizacoes'`. (Sem `CAMPAIGN_OVERFLOW_HREF` — drawer, não rota.) +- **`src/components/campaign/shell/nav.ts`**: + - Importar `BellIcon`, `EllipsisVertical` de `lucide-react`; importar `CAMPAIGN_UPDATES_HREF`. + - `bottomNavPrimaryHrefs: Set` — `{ '/campanha', '/campanha/municipios', CAMPAIGN_UPDATES_HREF, CAMPAIGN_AGENDA_HOME }` (os 4 que NÃO são Mais). + - `bottomNavStaff: CampaignNavItem[]` — 5 itens: Início, Municípios, Atualizações, Agenda, Mais. + - `getCampaignBottomNav(role)`: staff → `bottomNavStaff`; leader → `[]` (lockdown). + - `getCampaignOverflowNav(role)`: staff → `getCampaignNav(role).filter(h => !bottomNavPrimaryHrefs.has(h.href))` + `getCampaignSecondaryNav(role)` (Conceitos). Leader → `[]`. Perfil + Sair são tratados inline no drawer footer, não em `nav.ts` (como no sidebar footer). + - `isCampaignNavActive`: reconhecer `/campanha/atualizacoes` por prefixo (exatidão como `/campanha`). +- **`src/components/campaign/shell/CampaignBottomNav.tsx`** (new): `use client`, `fixed inset-x-0 bottom-0`, `md:hidden print:hidden`, `z-30` (abaixo do FAB `z-40`, acima do scroll). Contém: + - Barra: grid 5 colunas, 4 itens = `Link`, item 5 "Mais" = `button` (abre drawer), ícone + rótulo pt-BR, `aria-current` via `isCampaignNavActive`, `pb-[env(safe-area-inset-bottom)]`, `border-t`. + - Drawer: `@base-ui` Drawer (`showSwipeHandle`, `swipeDirection="down"`), overlay nav items de `getCampaignOverflowNav(role)` + footer Perfil (`CAMPAIGN_PROFILE_HOME`) + Sair (`logoutCampaign`). +- **`src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx`** (new): placeholder honesto — `CampaignPageShell` + título "Atualizações" + texto "Em breve você verá o feed de atualizações da campanha aqui." Staff-only (redireciona leader para `/campanha`). +- **`src/app/(campaign)/campanha/(app)/layout.tsx`**: renderizar `` quando `isStaffCampaignRole(user.role)` — sibling de `TooltipProvider > div`, fora do flex flow (`fixed`). +- **`src/components/campaign/shell/CampaignQuickActionsHost.tsx`**: + - `CampaignContentScroll`: `pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6` (mobile gap para a barra). + - FAB: `className={showBottomNav ? 'bottom-[calc(4rem+env(safe-area-inset-bottom))] md:bottom-4' : undefined}` — sobe quando a barra existe. + +- **Migration:** sem migration — chrome de navegação. +- **Access / Consent:** `isStaffCampaignRole` reusado; `canAccessSupporterArea` / `isUnrestrictedCampaignRole` já filtram `Apoiadores`/`Assessores` em `getCampaignNav` → herdados. Sem Consent. +- **UI:** Impeccable C — shell chrome, mobile-first, `data-theme='campaign'`, consistente com `CampaignQuickActionsOverlay` Drawer pattern. + +### Dados → forma + +N/A — chrome de navegação, sem dados de domínio. + +## Fases verificáveis + +1. **Nav (tracer)** — `nav.ts` com `getCampaignBottomNav` + `getCampaignOverflowNav`; `campaignPaths.ts` com `CAMPAIGN_UPDATES_HREF`. Unit test (`campaignNav.unit.spec.ts` estendido) validando: staff tem 5 itens; leader tem 0; overflow exclui primários + inclui Conceitos; `isCampaignNavActive` matcha `atualizacoes` por prefixo. +2. **UI** — `CampaignBottomNav.tsx` (barra + drawer overflow), `/campanha/atualizacoes` placeholder; layout renderiza barra only staff mobile; FAB sobe; scroll não esconde conteúdo; drawer reabre Perfil/Sair com role filter. +3. **Gates** — `pnpm gate:fast`; e2e smoke mobile (barra visível staff, invisível desktop/leader, navegação ativa reflete rota, FAB acima da barra, drawer overflow abre/fecha); `pnpm push -u origin HEAD` → `gh pr create --base main`. + +## Rabbit holes / Não escopo (engenharia) + +- Drawer do wizard (`CampaignQuickActionsOverlay` mobile) sobrepõe a barra: o Drawer é modal full-snap, z-index alto, não há conflito visual. +- FAB no desktop: bottom nav é `md:hidden`, então `md:bottom-4` no FAB mantém comportamento atual. +- Leader lockdown: `getCampaignBottomNav('leader')` retorna `[]`; `/campanha/atualizacoes` redireciona leader para `/campanha`. +- Atualizações: âncora honesta — **não** implementar feed/threads (→ C89). Placeholder com título + texto. +- Não reintroduzir Quadro/Territórios/Apoiadores/Assessores na barra (teto de 5, thumb zone). +- Não tocar no design-refs HTML legados. + +## Riscos e mitigação + +- **Risco:** conteúdo fica escondido atrás da barra. **Mitigação:** `pb-[calc(4rem+env(safe-area-inset-bottom))]` no scroll + teste de overflow. +- **Risco:** FAB some atrás da barra. **Mitigação:** ajuste de `bottom` via conditional className em `CampaignQuickActionsHost`. +- **Risco:** leader enxerga a barra. **Mitigação:** `isStaffCampaignRole` no layout + `getCampaignBottomNav('leader') → []`. +- **Risco:** z-index da barra sobe sobre o Drawer. **Mitigação:** `z-30` (Drawer usa z-50+). + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (5 itens, staff only, Mais = drawer overflow, sidebar intacta desktop) +- [ ] Invariantes AGENTS/engineering-standards: identificadores inglês, copy pt-BR, `md:hidden`, role filtering via `nav.ts` +- [ ] Unit: `getCampaignBottomNav` / `getCampaignOverflowNav` / `isCampaignNavActive` estendidos (5 itens staff, 0 leader, overflow exclui primários, inclui Conceitos) +- [ ] e2e smoke: barra visível mobile staff, invisível desktop/leader, navegação ativa reflete rota, FAB acima da barra, drawer overflow abre/fecha +- [ ] `pnpm gate:fast` verde (tsc + lint + knip + test + build) diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index bf0425a8..decb0a25 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -102,7 +102,7 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/lib/campaignReferenceQuickActions', 'src/lib/campaignPaths.ts', ], - specs: ['campaign-pwa', 'campaignWizardChrome', 'campaignMunicipalities'], + specs: ['campaign-pwa', 'campaignWizardChrome', 'campaignBottomNav', 'campaignMunicipalities'], }, // Domains without a dedicated e2e family still wake campaign home smoke so // the affected classifier cannot return mode=none on an unmapped domain dir. @@ -133,6 +133,7 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/lib/wizardMunicipalitySuggestMerge', `${CAMPAIGN_APP}/apoiadores`, `${CAMPAIGN_APP}/assessores`, + `${CAMPAIGN_APP}/atualizacoes`, `${CAMPAIGN_APP}/contatos`, `${CAMPAIGN_APP}/demandas`, `${CAMPAIGN_APP}/dobradinhas`, diff --git a/src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx b/src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx new file mode 100644 index 00000000..2713141d --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx @@ -0,0 +1,31 @@ +import { Inbox } from 'lucide-react' + +import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' + +export const metadata = campaignPageMetadataFromCatalog('atualizacoes') + +/** + * B164 placeholder — staff-only. The `Atualizações` nav item points here so + * the bottom nav has a destination to route to; the content is an honest sign + * that the feed is not built yet (no fake skeleton rows). + */ +export default async function CampaignUpdatesPage() { + await requireCampaignPageActor({ gate: 'staff' }) + + return ( + +
+
+
+

Atualizações em breve

+

+ O painel de atualizações da campanha ainda está em construção. Volte depois para conferir + as novidades. +

+
+
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx index 80723c64..c96b8fa6 100644 --- a/src/app/(campaign)/campanha/(app)/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -11,6 +11,7 @@ import { type BiometricEnrollmentOffer, } from '@/components/campaign/shell/BiometricEnrollmentToast' import { CampaignAppScrollChrome } from '@/components/campaign/shell/CampaignAppScrollChrome' +import { CampaignBottomNav } from '@/components/campaign/shell/CampaignBottomNav' import { CampaignDesktopHeader } from '@/components/campaign/shell/CampaignDesktopHeader' import { CampaignHomeSearchChromeProvider } from '@/components/campaign/shell/CampaignHomeSearchChromeContext' import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar' @@ -24,6 +25,7 @@ import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast' import { SIDEBAR_COOKIE_NAME, SidebarInset, SidebarProvider } from '@/components/ui/Sidebar' import { Toaster } from '@/components/ui/Toaster' import { TooltipProvider } from '@/components/ui/tooltip' +import { isStaffCampaignRole } from '@/lib/campaignRoles' import { deviceLabelFromUserAgent } from '@/lib/deviceLabel' import { getCampaignUserWithAvatar } from '@/utilities/campaignAuth' import { campaignUserShellView } from '@/utilities/campaignUserProfile' @@ -98,6 +100,9 @@ export default async function CampaignAppLayout({ children }: { children: React. {children} + {isStaffCampaignRole(user.role) ? ( + + ) : null} diff --git a/src/components/campaign/shell/CampaignBottomNav.tsx b/src/components/campaign/shell/CampaignBottomNav.tsx new file mode 100644 index 00000000..3bfd2803 --- /dev/null +++ b/src/components/campaign/shell/CampaignBottomNav.tsx @@ -0,0 +1,172 @@ +'use client' + +import { LogOutIcon, UserIcon } from 'lucide-react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { useState, type FormEvent } from 'react' + +import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth' +import { + getCampaignBottomNav, + getCampaignOverflowNav, + isCampaignNavActive, +} from '@/components/campaign/shell/nav' +import { Button } from '@/components/ui/button' +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from '@/components/ui/Drawer' +import { Spinner } from '@/components/ui/Spinner' +import { clearLastActedMunicipality } from '@/lib/campaignLastActedMunicipality' +import { CAMPAIGN_PROFILE_HOME } from '@/lib/campaignPaths' +import { cn } from '@/lib/utils' +import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient' +import { type CampaignUserShellView } from '@/utilities/campaignUserProfile' +import { clearMunicipalitySavedFilters } from '@/utilities/municipality/municipalitySavedFilters' +import { clearRecentVisits } from '@/utilities/recentVisits' + +export const CampaignBottomNav = ({ user }: { user: CampaignUserShellView }) => { + const pathname = usePathname() + const navItems = getCampaignBottomNav(user.role) + const overflowNav = getCampaignOverflowNav(user.role) + const [overflowOpen, setOverflowOpen] = useState(false) + const [isLoggingOut, setIsLoggingOut] = useState(false) + + const handleLogout = async (event: FormEvent) => { + event.preventDefault() + if (isLoggingOut) return + setIsLoggingOut(true) + clearRecentVisits() + clearLastActedMunicipality() + clearMunicipalitySavedFilters() + await clearCampaignPwaCaches() + await logoutCampaign() + } + + return ( + <> + + + + + + Navegação + Destinos da campanha + + +
+ {overflowNav.map((item) => { + const active = isCampaignNavActive(pathname, item.href) + return ( + setOverflowOpen(false)} + className={cn( + 'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium text-foreground outline-none', + 'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring', + active && 'bg-muted text-primary', + )} + > +
+ + + setOverflowOpen(false)} + className={cn( + 'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium text-foreground outline-none', + 'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring', + )} + > + +
+
+ + ) +} diff --git a/src/components/campaign/shell/CampaignQuickActionsHost.tsx b/src/components/campaign/shell/CampaignQuickActionsHost.tsx index 0437a72b..bf78f06c 100644 --- a/src/components/campaign/shell/CampaignQuickActionsHost.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsHost.tsx @@ -8,12 +8,12 @@ import { CampaignQuickActionsFab } from '@/components/campaign/shell/CampaignQui import { CampaignQuickActionsOverlay } from '@/components/campaign/shell/CampaignQuickActionsOverlay' import { shouldMountQuickActionsFab } from '@/lib/campaignQuickActionMount' import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry' -import type { CampaignRole } from '@/lib/campaignRoles' +import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles' export const CampaignContentScroll = ({ children }: { children: ReactNode }) => (
{children}
@@ -37,10 +37,17 @@ export const CampaignQuickActionsHost = ({ role }: { role: CampaignRole }) => { if (!mounted) return null const actions = resolveQuickActionsForPath(pathname, role, context) + const showBottomNav = isStaffCampaignRole(role) return ( <> - + ) diff --git a/src/components/campaign/shell/nav.ts b/src/components/campaign/shell/nav.ts index 4e34fbf1..bb5f3ad0 100644 --- a/src/components/campaign/shell/nav.ts +++ b/src/components/campaign/shell/nav.ts @@ -1,6 +1,8 @@ import { + BellIcon, BookOpenIcon, CalendarDaysIcon, + EllipsisVerticalIcon, HandshakeIcon, HomeIcon, InboxIcon, @@ -14,7 +16,11 @@ import { type LucideIcon, } from 'lucide-react' -import { CAMPAIGN_AGENDA_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' +import { + CAMPAIGN_AGENDA_HOME, + CAMPAIGN_UPDATES_HREF, + LEADER_CONTACTS_HOME, +} from '@/lib/campaignPaths' import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles' import type { CampaignUser } from '@/payload-types' import { canAccessSupporterArea } from '@/utilities/supporter/supporterUi' @@ -83,6 +89,42 @@ export const isCampaignNavActive = (pathname: string, href: string): boolean => if (href === '/campanha') { return pathname === '/campanha' || pathname === '/campanha/' } + if (href === CAMPAIGN_UPDATES_HREF) { + return pathname === CAMPAIGN_UPDATES_HREF || pathname.startsWith(`${CAMPAIGN_UPDATES_HREF}/`) + } if (href === CAMPAIGN_AGENDA_HOME && pathname.startsWith('/campanha/atividades')) return true return pathname === href || pathname.startsWith(`${href}/`) } + +// The four primary destinations shown in the bottom nav (all except "Mais"). +// Used to exclude them from the overflow drawer. +const bottomNavPrimaryHrefs: ReadonlySet = new Set([ + '/campanha', + MUNICIPALITY_NAV_HREF, + CAMPAIGN_UPDATES_HREF, + CAMPAIGN_AGENDA_HOME, +]) + +/** The five staff items in the mobile bottom nav — Início, Municípios, + * Atualizações, Agenda, Mais. Leaders get nothing (lockdown). */ +const bottomNavStaff: CampaignNavItem[] = [ + { title: 'Início', href: '/campanha', icon: HomeIcon }, + { title: 'Municípios', href: MUNICIPALITY_NAV_HREF, icon: MapPinIcon }, + { title: 'Atualizações', href: CAMPAIGN_UPDATES_HREF, icon: BellIcon }, + { title: 'Agenda', href: CAMPAIGN_AGENDA_HOME, icon: CalendarDaysIcon }, + { title: 'Mais', href: '', icon: EllipsisVerticalIcon }, +] + +/** Primary navigation for the mobile bottom bar. Staff only — leaders are + * in lockdown and use the sidebar Sheet (Meus contatos) instead. */ +export const getCampaignBottomNav = (role: CampaignUser['role']): CampaignNavItem[] => + isStaffCampaignRole(role) ? bottomNavStaff : [] + +/** Overflow destinations for the "Mais" drawer — staff nav items that are + * not in the bottom nav's four primaries, plus Conceitos. Perfil and Sair + * are rendered inline in the drawer footer (same as sidebar). */ +export const getCampaignOverflowNav = (role: CampaignUser['role']): CampaignNavItem[] => { + if (!isStaffCampaignRole(role)) return [] + const primary = getCampaignNav(role).filter((item) => !bottomNavPrimaryHrefs.has(item.href)) + return [...primary, ...getCampaignSecondaryNav(role)] +} diff --git a/src/lib/campaignPageChrome.ts b/src/lib/campaignPageChrome.ts index 146ebaa6..37074b05 100644 --- a/src/lib/campaignPageChrome.ts +++ b/src/lib/campaignPageChrome.ts @@ -1,6 +1,6 @@ import type { Metadata } from 'next' -import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' +import { CAMPAIGN_HOME, CAMPAIGN_UPDATES_HREF, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' import { normalizePathname } from '@/lib/campaignQuickActionPaths' import type { CampaignUser } from '@/payload-types' @@ -80,6 +80,9 @@ export const campaignPageChromeCatalog = { subtitle: 'O que cada número da campanha mede e como é calculado. Só o que o produto já calcula hoje — a lista cresce conforme novas análises entram.', }, + atualizacoes: { + title: 'Atualizações', + }, contatos: { title: 'Contatos', subtitle: 'Cadastre apoiadores pelo celular. Só você vê os contatos que criou aqui.', @@ -234,6 +237,10 @@ const pathRules: PathRule[] = [ match: (pathname) => pathname === '/campanha/conceitos', resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.conceitos), }, + { + match: (pathname) => pathname === CAMPAIGN_UPDATES_HREF, + resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atualizacoes), + }, { match: (pathname) => pathname === LEADER_CONTACTS_HOME, resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.contatos), diff --git a/src/lib/campaignPaths.ts b/src/lib/campaignPaths.ts index 34868c2a..96983b68 100644 --- a/src/lib/campaignPaths.ts +++ b/src/lib/campaignPaths.ts @@ -14,3 +14,5 @@ export const CAMPAIGN_AGENDA_HOME = '/campanha/agenda' as const export const LEADER_CONTACTS_HOME = '/campanha/contatos' as const export const CAMPAIGN_PROFILE_HOME = '/campanha/perfil' as const + +export const CAMPAIGN_UPDATES_HREF = '/campanha/atualizacoes' as const diff --git a/tests/e2e/campaignBottomNav.e2e.spec.ts b/tests/e2e/campaignBottomNav.e2e.spec.ts new file mode 100644 index 00000000..d78fd5d2 --- /dev/null +++ b/tests/e2e/campaignBottomNav.e2e.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from './fixtures/campaignE2EFixtures.js' + +test.use({ viewport: { width: 390, height: 844 } }) + +test.describe('Mobile bottom nav (B164)', () => { + test('staff sees five-item bottom nav', async ({ campaign, page }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Nav'), + }) + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto('/campanha') + + const bottomNav = page.locator('[aria-label="Navegação principal"]') + await expect(bottomNav).toBeVisible() + await expect(bottomNav.getByRole('link', { name: 'Início' })).toBeVisible() + await expect(bottomNav.getByRole('link', { name: 'Municípios' })).toBeVisible() + await expect(bottomNav.getByRole('link', { name: 'Atualizações' })).toBeVisible() + await expect(bottomNav.getByRole('link', { name: 'Agenda' })).toBeVisible() + await expect(bottomNav.getByRole('button', { name: 'Mais' })).toBeVisible() + }) + + test('highlights active item and navigates on tap', async ({ campaign, page }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Nav Ativa'), + }) + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto('/campanha') + + const bottomNav = page.locator('[aria-label="Navegação principal"]') + + // Início is active on /campanha + await expect(bottomNav.getByRole('link', { name: 'Início' })).toHaveAttribute( + 'aria-current', + 'page', + ) + + // Navigate to Municípios — active state moves + await bottomNav.getByRole('link', { name: 'Municípios' }).click() + await expect(page).toHaveURL(/\/campanha\/municipios\/?$/) + await expect(bottomNav.getByRole('link', { name: 'Municípios' })).toHaveAttribute( + 'aria-current', + 'page', + ) + await expect(bottomNav.getByRole('link', { name: 'Início' })).not.toHaveAttribute( + 'aria-current', + 'page', + ) + }) + + test('Mais opens overflow drawer with secondary destinations', async ({ campaign, page }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Drawer'), + }) + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto('/campanha') + + const bottomNav = page.locator('[aria-label="Navegação principal"]') + await bottomNav.getByRole('button', { name: 'Mais' }).click() + + const drawer = page.locator('[data-slot="drawer-content"]') + await expect(drawer).toBeVisible() + await expect(drawer.getByRole('link', { name: 'Quadro', exact: true })).toBeVisible() + await expect(drawer.getByRole('link', { name: 'Conceitos' })).toBeVisible() + await expect(drawer.getByRole('link', { name: 'Perfil' })).toBeVisible() + await expect(drawer.getByRole('button', { name: 'Sair' })).toBeVisible() + }) + + test('leader does not see the bottom nav', async ({ campaign, page }) => { + const { fixtures } = campaign + const phone = fixtures.phone() + const leader = await fixtures.createCampaignUser('leader', { + name: fixtures.value('Líder Nav'), + username: phone, + }) + await campaign.login(page, phone, leader.password) + await page.goto('/campanha/contatos') + + await expect(page.locator('[aria-label="Navegação principal"]')).toHaveCount(0) + }) + + test('hidden on desktop viewport', async ({ campaign, page }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Desktop'), + }) + await campaign.login(page, coordinator.email!, coordinator.password) + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto('/campanha') + + // CSS-hidden, but still in DOM + await expect(page.locator('[aria-label="Navegação principal"]')).toBeHidden() + // Sidebar trigger is visible on desktop + await expect(page.getByRole('button', { name: /abrir ou fechar/i })).toBeVisible() + }) + + test('FAB sits above the bottom nav on mobile', async ({ campaign, page }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora FAB'), + }) + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto('/campanha/municipios') + + const fab = page.getByRole('button', { name: 'Ações rápidas' }) + await expect(fab).toBeVisible() + await expect(page.locator('[aria-label="Navegação principal"]')).toBeVisible() + + const fabBox = await fab.boundingBox() + const navBox = await (await page.locator('[aria-label="Navegação principal"]')).boundingBox() + if (fabBox && navBox) { + expect(fabBox.y).toBeLessThan(navBox.y) + } + }) +}) diff --git a/tests/unit/campaignNav.unit.spec.ts b/tests/unit/campaignNav.unit.spec.ts index 00479476..1672c64a 100644 --- a/tests/unit/campaignNav.unit.spec.ts +++ b/tests/unit/campaignNav.unit.spec.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from 'vitest' -import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/shell/nav' -import { CAMPAIGN_AGENDA_HOME } from '@/lib/campaignPaths' +import { + getCampaignBottomNav, + getCampaignNav, + getCampaignOverflowNav, + isCampaignNavActive, +} from '@/components/campaign/shell/nav' +import { CAMPAIGN_AGENDA_HOME, CAMPAIGN_UPDATES_HREF } from '@/lib/campaignPaths' import { ORGANIZATIONS_LIST_PATH } from '@/lib/campaignQuickActionPaths' describe('organizations sidebar entry', () => { @@ -52,3 +57,48 @@ describe('agenda sidebar entry', () => { ) }) }) + +describe('mobile bottom nav', () => { + it('offers exactly five primary items to staff, zero to leaders', () => { + for (const role of ['coordinator', 'advisor', 'candidate'] as const) { + const items = getCampaignBottomNav(role) + expect(items).toHaveLength(5) + expect(items.map((i) => i.title)).toEqual([ + 'Início', + 'Municípios', + 'Atualizações', + 'Agenda', + 'Mais', + ]) + expect(items.map((i) => i.href)).toContain(CAMPAIGN_UPDATES_HREF) + } + expect(getCampaignBottomNav('leader')).toHaveLength(0) + }) + + it('keeps Atualizações and Mais out of the overflow drawer', () => { + const overflow = getCampaignOverflowNav('coordinator').map((i) => i.href) + expect(overflow).not.toContain(CAMPAIGN_UPDATES_HREF) + expect(overflow).not.toContain('') + // The four primaries (minus Mais) must not appear in overflow. + expect(overflow).not.toContain('/campanha') + expect(overflow).not.toContain('/campanha/municipios') + expect(overflow).not.toContain(CAMPAIGN_AGENDA_HOME) + }) + + it('overflow includes secondary nav (Conceitos)', () => { + const overflow = getCampaignOverflowNav('coordinator').map((i) => i.href) + expect(overflow).toContain('/campanha/conceitos') + }) + + it('overflow is zero for leaders', () => { + expect(getCampaignOverflowNav('leader')).toHaveLength(0) + }) + + it('matches Atualizações by prefix', () => { + expect(isCampaignNavActive(CAMPAIGN_UPDATES_HREF, CAMPAIGN_UPDATES_HREF)).toBe(true) + expect(isCampaignNavActive(`${CAMPAIGN_UPDATES_HREF}/algum-fio`, CAMPAIGN_UPDATES_HREF)).toBe( + true, + ) + expect(isCampaignNavActive('/campanha/mais', CAMPAIGN_UPDATES_HREF)).toBe(false) + }) +})