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:
parent
b838d19447
commit
bd06018bde
12 changed files with 538 additions and 8 deletions
|
|
@ -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).
|
||||
|
|
|
|||
94
docs/plans/barra-navegacao-inferior-mobile-impl.md
Normal file
94
docs/plans/barra-navegacao-inferior-mobile-impl.md
Normal 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)
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
31
src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx
Normal file
31
src/app/(campaign)/campanha/(app)/atualizacoes/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
172
src/components/campaign/shell/CampaignBottomNav.tsx
Normal file
172
src/components/campaign/shell/CampaignBottomNav.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
117
tests/e2e/campaignBottomNav.e2e.spec.ts
Normal file
117
tests/e2e/campaignBottomNav.e2e.spec.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue