B164: barra de navegação inferior no mobile para staff

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
This commit is contained in:
Francisco Solla 2026-08-08 11:23:03 -03:00
parent b838d19447
commit bd06018bde
12 changed files with 538 additions and 8 deletions

View file

@ -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<slot>`/`teqo_wt<slot>_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*<worktree>\_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).

View file

@ -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<string>` — `{ '/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 `<CampaignBottomNav role={user.role} />` 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)

View file

@ -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`,

View file

@ -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 (
<CampaignPageShell className="items-center py-12">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-muted">
<Inbox aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<h1 className="text-xl font-semibold">Atualizações em breve</h1>
<p className="max-w-sm text-sm text-muted-foreground">
O painel de atualizações da campanha ainda está em construção. Volte depois para conferir
as novidades.
</p>
</div>
</CampaignPageShell>
)
}

View file

@ -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}
</CampaignAppScrollChrome>
</div>
{isStaffCampaignRole(user.role) ? (
<CampaignBottomNav user={campaignUserShellView(user)} />
) : null}
</TooltipProvider>
<Toaster position="top-center" />
<InstallPwaToast />

View file

@ -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<HTMLFormElement>) => {
event.preventDefault()
if (isLoggingOut) return
setIsLoggingOut(true)
clearRecentVisits()
clearLastActedMunicipality()
clearMunicipalitySavedFilters()
await clearCampaignPwaCaches()
await logoutCampaign()
}
return (
<>
<nav
aria-label="Navegação principal"
className={cn(
'fixed inset-x-0 bottom-0 z-30 grid w-full',
'grid-cols-[repeat(5,minmax(0,1fr))]',
'border-t bg-background',
'pb-[env(safe-area-inset-bottom)]',
'md:hidden print:hidden',
)}
>
{navItems.map((item) => {
const active = item.href === '' ? overflowOpen : isCampaignNavActive(pathname, item.href)
if (item.href === '') {
return (
<button
key="Mais"
type="button"
aria-haspopup="dialog"
aria-expanded={overflowOpen}
aria-label="Mais"
onClick={() => setOverflowOpen(true)}
className={cn(
'flex flex-1 flex-col items-center justify-center gap-0.5 text-xs font-medium text-muted-foreground outline-none',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
active && 'text-primary',
)}
>
<item.icon aria-hidden="true" className="size-5 shrink-0" />
<span>{item.title}</span>
</button>
)
}
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex flex-1 flex-col items-center justify-center gap-0.5 text-xs font-medium text-muted-foreground outline-none',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
active && 'text-primary',
)}
>
<item.icon aria-hidden="true" className="size-5 shrink-0" />
<span>{item.title}</span>
</Link>
)
})}
</nav>
<Drawer
open={overflowOpen}
onOpenChange={setOverflowOpen}
showSwipeHandle
swipeDirection="down"
modal={true}
>
<DrawerContent
id="CampaignBottomNavOverflow"
className="max-h-[85dvh] border-t border-border bg-background"
>
<DrawerHeader>
<DrawerTitle>Navegação</DrawerTitle>
<DrawerDescription>Destinos da campanha</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-1 px-4 pb-0">
{overflowNav.map((item) => {
const active = isCampaignNavActive(pathname, item.href)
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? 'page' : undefined}
onClick={() => 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',
)}
>
<item.icon aria-hidden="true" className="size-5 shrink-0" />
<span>{item.title}</span>
</Link>
)
})}
</div>
<DrawerFooter>
<Link
href={CAMPAIGN_PROFILE_HOME}
onClick={() => 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',
)}
>
<UserIcon aria-hidden="true" className="size-5 shrink-0" />
<span>Perfil</span>
</Link>
<form onSubmit={handleLogout} className="w-full">
<Button
type="submit"
variant="ghost"
disabled={isLoggingOut}
className="w-full justify-start gap-3 px-3 py-2.5 text-sm font-medium text-foreground hover:bg-muted"
>
{isLoggingOut ? (
<Spinner aria-hidden="true" className="size-5" />
) : (
<LogOutIcon aria-hidden="true" className="size-5" />
)}
<span>Sair</span>
</Button>
</form>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
)
}

View file

@ -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 }) => (
<div
data-slot="campaign-content-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 md:p-6 print:h-auto print:overflow-visible print:p-0 [&::-webkit-scrollbar]:hidden [scrollbar-width:none]"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-[calc(4rem+env(safe-area-inset-bottom))] md:p-6 md:pb-6 print:h-auto print:overflow-visible print:p-0 [&::-webkit-scrollbar]:hidden [scrollbar-width:none]"
>
{children}
</div>
@ -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 (
<>
<CampaignQuickActionsFab open={open} onOpenChange={handleOpenChange} />
<CampaignQuickActionsFab
open={open}
onOpenChange={handleOpenChange}
className={
showBottomNav ? 'bottom-[calc(4rem+env(safe-area-inset-bottom))] md:bottom-4' : undefined
}
/>
<CampaignQuickActionsOverlay open={open} onOpenChange={handleOpenChange} actions={actions} />
</>
)

View file

@ -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<string> = 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)]
}

View file

@ -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),

View file

@ -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

View file

@ -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)
}
})
})

View file

@ -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)
})
})