Merge pull request #270 from fsolla/cursor/pool-i250-d89b6314-599f
B123 — Campanha — orientação na shell + aba (sem títulos grandes de seção)
This commit is contained in:
commit
7839a88a5a
57 changed files with 998 additions and 475 deletions
2
.github/workflows/ci-pr.yml
vendored
2
.github/workflows/ci-pr.yml
vendored
|
|
@ -129,7 +129,7 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm exec knip
|
||||
- run: pnpm knip
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
uses: ./.github/actions/cancel-workflow-on-failure
|
||||
|
|
|
|||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
- run: pnpm lint
|
||||
- run: pnpm format:check
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm exec knip
|
||||
- run: pnpm knip
|
||||
- run: pnpm check:cycles
|
||||
- run: pnpm test:unit
|
||||
- name: Cancel workflow on failure
|
||||
|
|
|
|||
80
docs/plans/orientacao-shell-sem-titulos-secao-impl.md
Normal file
80
docs/plans/orientacao-shell-sem-titulos-secao-impl.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Impl: Orientação na shell + aba — sem títulos grandes de seção
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-02
|
||||
Issue: #250
|
||||
Intenção: docs/plans/orientacao-shell-sem-titulos-secao.md
|
||||
Appetite restante: herdado (~1–1,5 dia eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** orientação “onde estou” sai do corpo da página e vai para chrome estável (header mobile/desktop + aba `Solla - Campanha - <Página>`); corpo começa direto no trabalho; detalhe mantém h1 da entidade; Início sem título no header.
|
||||
- **O que NÃO negociar:** wizard/auth/convite/offline intocados; h1 de entidade em detalhe; leader lockdown; chips de universo removidos (não no header).
|
||||
- **O que reavaliar:** hipótese de `CampaignListPageHeader` — não existe; B118 nunca landou. Pathname registry + context override é mais maintainable que 15 nested layouts.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph server [Server RSC]
|
||||
P[page.tsx] --> M[export metadata via campaignPageMetadata]
|
||||
P --> S[SetCampaignPageChrome em edit dinâmico]
|
||||
end
|
||||
subgraph client [Shell client]
|
||||
R[resolveCampaignPageChrome pathname+role] --> C[Provider merge override]
|
||||
C --> MB[CampaignMobileTopBar]
|
||||
C --> DH[CampaignDesktopHeader]
|
||||
end
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) nested `layout.tsx` por segmento · B) pathname registry + override context · C) só metadata sem shell
|
||||
**Recomendação:** B — um owner (`campaignPageChrome.ts`), precedente wizard/home-search, role no layout, override só em edit dinâmico.
|
||||
**Rejeitadas:** A (34 rotas, drift) · C (aceite exige shell)
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`campaignPageChrome.ts`** (`src/lib/`): vocabulário client-safe; `resolveCampaignPageChrome`; `campaignPageMetadata`
|
||||
- **`CampaignPageChromeContext.tsx`**: provider + `SetCampaignPageChrome` (override, layout effect cleanup)
|
||||
- **`CampaignPageChromeResolver.tsx`**: client; pathname + role → base chrome
|
||||
- **`CampaignDesktopHeader.tsx`**: uma linha título+subtítulo md+
|
||||
- **`CampaignMobileTopBar.tsx`**: app mode lê chrome efetivo; home vazio
|
||||
- **`(app)/layout.tsx`**: provider + resolver + desktop header; passa `role`
|
||||
- **`(campaign)/layout.tsx`**: `title.template: 'Solla - Campanha - %s'`
|
||||
- **Páginas/componentes**: remover headers de seção/create; manter h1 entidade + back em detalhe; `SetCampaignPageChrome` em edit; metadata em rotas
|
||||
- **Migration:** sem migration
|
||||
- **Access / Consent:** inalterado
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Orientação = texto estático por rota + role (quadro); sem dados de Payload no resolver.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Infra** — lib + context + shell headers + metadata template
|
||||
2. **Páginas** — strip body headers; metadata; edit bridges
|
||||
3. **Gates** — `pnpm gate:fast`; `pnpm push`
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Reintroduzir chips de escopo no header
|
||||
- Mexer em wizard chrome
|
||||
- h2 internos (ex. “Meus contatos (N)”)
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- E2E busca `heading` level 1 → atualizar para shell slot ou manter entity h1 only
|
||||
- Home search focus: chrome vazio em `/campanha` mesmo focado
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto
|
||||
- [x] Invariantes AGENTS/engineering-standards
|
||||
- [x] Testes unit mobile top bar + metadata resolver previstos
|
||||
|
||||
## Débitos deferidos (/simplify)
|
||||
|
||||
- **Registry único** (pathRules + metadata por página): defer — gatilho quando 3ª rota dinâmica exigir override; hoje catálogo + metadata explícita é legível.
|
||||
- **Resolver único no provider** (evitar dupla resolução mobile/desktop): defer — custo negligível (~28 regex); revisit se bundle crescer.
|
||||
- **Desktop header RSC** (slot bell sem client boundary): **corrigido** nesta entrega — `notificationBell` slot como mobile.
|
||||
- **Subtitle desktop truncado** em uma linha: defer — validar com usuários; mobile já usa duas linhas.
|
||||
- **Table-driven pathRules tests**: defer — cobertura smoke suficiente para B123; expandir se matcher quebrar de novo.
|
||||
|
|
@ -77,7 +77,7 @@ const main = async () => {
|
|||
console.log('\n[gate:ci] ⊘ typecheck/knip/cycles skipped (no code surface)')
|
||||
} else {
|
||||
run('typecheck', 'pnpm', ['typecheck'])
|
||||
run('knip', 'pnpm', ['exec', 'knip'])
|
||||
run('knip', 'pnpm', ['knip'])
|
||||
run('check:cycles', 'pnpm', ['check:cycles'])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { notFound, redirect } from 'next/navigation'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { setSupporterVoteIntentionFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
|
||||
import { ConsentText } from '@/components/campaign/shared/ConsentText'
|
||||
import { RemoveSupporterDataButton } from '@/components/campaign/supporter/RemoveSupporterDataButton'
|
||||
import { SupporterShareKit } from '@/components/campaign/supporter/SupporterShareKit'
|
||||
|
|
@ -13,8 +12,8 @@ import { VoteIntentionControl } from '@/components/campaign/supporter/VoteIntent
|
|||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
|
||||
import {
|
||||
loadSupporterDetailConsentData,
|
||||
loadSupporterDetailPageData,
|
||||
|
|
@ -26,6 +25,8 @@ import {
|
|||
} from '@/utilities/supporter/supporterUi'
|
||||
import { parseSupporterId } from '@/utilities/supporter/supporterViewModels'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('apoiadores')
|
||||
|
||||
type SupporterDetailPageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
|
@ -71,7 +72,6 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
|
|||
Voltar para apoiadores
|
||||
</Link>
|
||||
</Button>
|
||||
<CampaignScopeBadge>{campaignRoleLabels[user.role]}</CampaignScopeBadge>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{supporter.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{supporter.city ?? 'Cidade não informada'}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,23 @@
|
|||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
|
||||
import { SupporterImportWizard } from '@/components/campaign/supporter/SupporterImportWizard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('apoiadoresImportar')
|
||||
|
||||
export default async function ImportSupportersPage() {
|
||||
const user = await requireCampaignPageActor()
|
||||
if (!isCampaignCoordinator(user)) redirect('/campanha/apoiadores')
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Button asChild variant="ghost" className="w-fit px-0">
|
||||
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
|
||||
</Button>
|
||||
<CampaignScopeBadge>Importação · Coordenador Geral</CampaignScopeBadge>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Importar apoiadores via CSV</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Envie uma planilha, confira a prévia e confirme a importação em lote.
|
||||
</p>
|
||||
</header>
|
||||
<Button asChild variant="ghost" className="w-fit px-0">
|
||||
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
|
||||
</Button>
|
||||
|
||||
<SupporterImportWizard />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,15 @@ import { redirect } from 'next/navigation'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
|
||||
import { SupporterForm } from '@/components/campaign/supporter/SupporterForm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
|
||||
import { loadSupporterCreatePageData } from '@/utilities/supporter/supporterPageData'
|
||||
import { canAccessSupporterArea } from '@/utilities/supporter/supporterUi'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('apoiadoresNovo')
|
||||
|
||||
export default async function NewSupporterPage() {
|
||||
const [user, payload] = await Promise.all([requireCampaignPageActor(), getPayload({ config })])
|
||||
|
||||
|
|
@ -21,16 +22,9 @@ export default async function NewSupporterPage() {
|
|||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Button asChild variant="ghost" className="w-fit px-0">
|
||||
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
|
||||
</Button>
|
||||
<CampaignScopeBadge>{campaignRoleLabels[user.role]}</CampaignScopeBadge>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Novo apoiador</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Cadastre um apoiador com telefone obrigatório e consentimento LGPD.
|
||||
</p>
|
||||
</header>
|
||||
<Button asChild variant="ghost" className="w-fit px-0">
|
||||
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
|
||||
</Button>
|
||||
|
||||
<SupporterForm
|
||||
action={createSupporterFormAction}
|
||||
|
|
|
|||
|
|
@ -10,25 +10,25 @@ import { getPayload } from 'payload'
|
|||
|
||||
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { SupporterFilters } from '@/components/campaign/supporter/SupporterFilters'
|
||||
import { SupporterList } from '@/components/campaign/supporter/SupporterList'
|
||||
import { SupporterListOverview } from '@/components/campaign/supporter/SupporterListOverview'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { isCampaignCoordinator, isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
|
||||
import { loadSupportersPageData } from '@/utilities/supporter/supporterPageData'
|
||||
import {
|
||||
buildSupporterFiltersKey,
|
||||
buildSupporterListHref,
|
||||
canAccessSupporterArea,
|
||||
getSupporterScopeLabel,
|
||||
} from '@/utilities/supporter/supporterUi'
|
||||
import { toSupporterListItemViewModel } from '@/utilities/supporter/supporterViewModels'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('apoiadores')
|
||||
|
||||
type SupportersPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -80,35 +80,22 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Apoiadores</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Base nominal de apoio com intenção de voto e vínculo opcional a municípios.
|
||||
</p>
|
||||
<CampaignScopeBadge>
|
||||
{isCampaignUnrestricted(user)
|
||||
? `${campaignRoleLabels[user.role]} · todos os apoiadores`
|
||||
: getSupporterScopeLabel(result.totalDocs)}
|
||||
</CampaignScopeBadge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{isCampaignCoordinator(user) ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/apoiadores/importar">
|
||||
<FileUpIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Importar CSV
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/apoiadores/novo">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Novo
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
{isCampaignCoordinator(user) ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/apoiadores/importar">
|
||||
<FileUpIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Importar CSV
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
) : null}
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/apoiadores/novo">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Novo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<SupporterFilters
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/Adviso
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { formatBrazilianPhoneInput } from '@/lib/phone'
|
||||
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
|
||||
import { loadAdvisorDetail } from '@/utilities/advisorData'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('assessores')
|
||||
|
||||
type AdvisorDetailPageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import {
|
||||
advisorListHrefForPage,
|
||||
loadAdvisorListPageData,
|
||||
|
|
@ -26,6 +27,8 @@ import {
|
|||
updateAdvisorProfileFormAction,
|
||||
} from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('assessores')
|
||||
|
||||
type AdvisorsPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -46,14 +49,6 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Assessores</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Consulte na tabela; ative Editar para alterar campos e carteira. Nome abre a ficha;
|
||||
e-mail/celular copiam; município abre o município.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignSearchForm
|
||||
ariaLabel="Buscar assessor por nome ou e-mail"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
||||
import { updateActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
|
||||
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
|
||||
import { ActivityNotFoundError, getActivityEditPageData } from '@/utilities/activityPageData'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import {
|
||||
|
|
@ -20,6 +22,26 @@ type EditActivityPageProps = {
|
|||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
const activityEditSubtitle = 'Atualize os detalhes da atividade sem alterar o título original.'
|
||||
|
||||
export async function generateMetadata({ params }: EditActivityPageProps) {
|
||||
const { slug } = await params
|
||||
if (!slug)
|
||||
return campaignPageMetadata({ title: 'Editar atividade', subtitle: activityEditSubtitle })
|
||||
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
try {
|
||||
const view = await getActivityEditPageData(payload, user, slug)
|
||||
return campaignPageMetadata({ title: `Editar ${view.title}`, subtitle: activityEditSubtitle })
|
||||
} catch {
|
||||
return campaignPageMetadata({ title: 'Editar atividade', subtitle: activityEditSubtitle })
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EditActivityPage({ params }: EditActivityPageProps) {
|
||||
const [{ slug }, user, payload] = await Promise.all([
|
||||
params,
|
||||
|
|
@ -41,13 +63,9 @@ export default async function EditActivityPage({ params }: EditActivityPageProps
|
|||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-primary">Atividades</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Editar {view.title}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Atualize os detalhes da atividade sem alterar o título original.
|
||||
</p>
|
||||
</header>
|
||||
<SetCampaignPageChrome
|
||||
chrome={{ title: `Editar ${view.title}`, subtitle: activityEditSubtitle }}
|
||||
/>
|
||||
<ActivityForm
|
||||
action={updateActivityFormAction}
|
||||
activity={view}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { CampaignQuickActionContextBridge } from '@/components/campaign/shell/Ca
|
|||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { activityKindLabels } from '@/lib/schemas/activity'
|
||||
import { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
|
||||
|
|
@ -34,6 +35,8 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
|||
|
||||
import { ActivityOverviewTab } from './ActivityOverviewTab'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('atividades')
|
||||
|
||||
type ActivityDetailPageProps = {
|
||||
params: Promise<{ slug: string }>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import config from '@payload-config'
|
||||
import { MapPinnedIcon } from 'lucide-react'
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
|
|
@ -14,6 +13,7 @@ import { CalendarPhaseNote } from '@/components/campaign/tour/CalendarPhaseNote'
|
|||
import { TourComposerForm, type TourStopOption } from '@/components/campaign/tour/TourComposerForm'
|
||||
import { TourRegionPicker } from '@/components/campaign/tour/TourRegionPicker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { formatBahiaDayLabel } from '@/lib/campaignTime'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
|
@ -31,10 +31,7 @@ import {
|
|||
|
||||
import { createTourDraftsFormAction } from './formActions'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Planejar giro | Campanha',
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
export const metadata = campaignPageMetadataFromCatalog('atividadesGiros')
|
||||
|
||||
type TourComposerPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
|
|
@ -113,19 +110,11 @@ export default async function TourComposerPage({ searchParams }: TourComposerPag
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Planejar giro</h1>
|
||||
<p className="max-w-prose text-muted-foreground">
|
||||
Um giro por território: o ato onde o voto comprometido já está, as paradas com rede para
|
||||
receber, e uma semente de expansão para o giro não repetir só a base. A proposta é
|
||||
sugestão — você edita antes de gerar.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/atividades">Voltar para Atividades</Link>
|
||||
</Button>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{/* Shared transition: the picker navigates, the proposal dims ("Feel the action"). */}
|
||||
<CampaignListPendingBoundary>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
||||
import { createActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
|
||||
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import {
|
||||
loadMunicipalityOptions,
|
||||
|
|
@ -14,6 +15,8 @@ import {
|
|||
} from '@/utilities/campaignRelationOptions'
|
||||
import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipalityViewModels'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('atividadesNova')
|
||||
|
||||
export default async function NewActivityPage() {
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }),
|
||||
|
|
@ -29,13 +32,6 @@ export default async function NewActivityPage() {
|
|||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-primary">Atividades</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Nova atividade</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Defina a ação, quando e onde ela acontece e quem responde por ela.
|
||||
</p>
|
||||
</header>
|
||||
<ActivityForm
|
||||
action={createActivityFormAction}
|
||||
municipalityOptions={municipalityOptions}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignLis
|
|||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import type { Activity } from '@/payload-types'
|
||||
import { loadActivityListPageData } from '@/utilities/activityPageData'
|
||||
import {
|
||||
|
|
@ -27,6 +28,8 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
|||
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
|
||||
import { TOUR_COMPOSER_PATH } from '@/utilities/visit/visitPlannerUrl'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('atividades')
|
||||
|
||||
type ActivityListPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -53,32 +56,22 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Atividades</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Organize caminhadas, comícios, panfletagens e demais ações de campanha.
|
||||
</p>
|
||||
{canCreate ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={TOUR_COMPOSER_PATH}>
|
||||
<MapPinnedIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Planejar giro
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/atividades/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova atividade
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{canCreate ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* E13: the planner generates several drafts at once, so it sits next
|
||||
to "Nova atividade" rather than replacing it. */}
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={TOUR_COMPOSER_PATH}>
|
||||
<MapPinnedIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Planejar giro
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/atividades/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova atividade
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<ActivityFilters
|
||||
|
|
|
|||
|
|
@ -6,15 +6,10 @@ import {
|
|||
campaignConceptsByCategory,
|
||||
type CampaignIntelligenceConcept,
|
||||
} from '@/lib/campaignIntelligenceConcepts'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Conceitos de inteligência | Campanha',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
}
|
||||
export const metadata: Metadata = campaignPageMetadataFromCatalog('conceitos')
|
||||
|
||||
/**
|
||||
* One documented number: what it measures, how it is calculated, why it
|
||||
|
|
@ -65,14 +60,6 @@ export default async function CampaignConceptsPage() {
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Conceitos de inteligência</h1>
|
||||
<p className="max-w-prose text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_15rem] lg:items-start lg:gap-10">
|
||||
{/*
|
||||
Desktop-only index: on a phone it would push all seven sections below
|
||||
|
|
|
|||
|
|
@ -3,12 +3,15 @@ import { redirect } from 'next/navigation'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { isCampaignLeader } from '@/utilities/campaignAccess'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('contatos')
|
||||
|
||||
export default async function CampaignContatosPage() {
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])
|
||||
|
||||
|
|
@ -20,7 +23,6 @@ export default async function CampaignContatosPage() {
|
|||
|
||||
return (
|
||||
<LeaderContactsPanel
|
||||
userName={user.name}
|
||||
municipalityOptions={view.municipalityOptions}
|
||||
defaultMunicipalityId={view.defaultMunicipalityId}
|
||||
showMunicipalitySelect={view.showMunicipalitySelect}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell
|
|||
import { CampaignQuickActionContextSync } from '@/components/campaign/shell/CampaignQuickActionContextSync'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import {
|
||||
campaignDemandKindLabels,
|
||||
campaignDemandStatusLabels,
|
||||
|
|
@ -23,6 +24,8 @@ import {
|
|||
transitionDemandFormAction,
|
||||
} from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('demandas')
|
||||
|
||||
type DemandDetailPageProps = {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ import { searchDemandActivityOptions } from '@/app/(campaign)/campanha/(app)/dem
|
|||
import { DemandForm } from '@/components/campaign/demand/DemandForm'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { loadActivityRelationOptionById } from '@/utilities/activityRelationOptions'
|
||||
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
|
||||
import { createDemandFormAction } from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('demandasNova')
|
||||
|
||||
type NewDemandPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -38,19 +41,12 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<Button asChild variant="ghost" className="min-h-11 self-start">
|
||||
<Link href="/campanha/demandas">
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para demandas
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Nova demanda</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Descreva a necessidade (material, transporte, espaço, apoio…) e o município. A assessoria
|
||||
revisa e responde por aqui.
|
||||
</p>
|
||||
</header>
|
||||
<Button asChild variant="ghost" className="min-h-11 self-start">
|
||||
<Link href="/campanha/demandas">
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para demandas
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<DemandForm
|
||||
municipalityOptions={municipalityOptions}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/s
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import {
|
||||
campaignDemandKindLabels,
|
||||
campaignDemandStatusLabels,
|
||||
|
|
@ -29,6 +30,8 @@ import {
|
|||
} from '@/utilities/campaignDemandData'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('demandas')
|
||||
|
||||
type DemandsPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -100,11 +103,7 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
|
|||
])
|
||||
|
||||
const state = parseDemandListParams(rawSearchParams)
|
||||
const { rows, totalDocs, totalPages, openCount } = await loadDemandListPageData(
|
||||
payload,
|
||||
user,
|
||||
state,
|
||||
)
|
||||
const { rows, totalDocs, totalPages } = await loadDemandListPageData(payload, user, state)
|
||||
const columnVisibility = await readCampaignColumnVisibility('demandas')
|
||||
|
||||
const hrefForStatus = (status?: CampaignDemandStatus) =>
|
||||
|
|
@ -112,24 +111,14 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Demandas</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Necessidades da campanha abertas pelas lideranças, revisadas pela assessoria e — quando
|
||||
preciso — decididas pelo Coordenador Geral.
|
||||
</p>
|
||||
<Badge variant="estimate-pending" className="w-fit">
|
||||
{openCount} {openCount === 1 ? 'demanda em aberto' : 'demandas em aberto'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/demandas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova demanda
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignFilterChips
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell
|
|||
import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadStateDeputyDetail } from '@/utilities/stateDeputyData'
|
||||
import { updateStateDeputyFormAction } from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('dobradinhas')
|
||||
|
||||
type StateDeputyDetailPageProps = {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,27 +4,23 @@ import Link from 'next/link'
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { createStateDeputyFormAction } from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('dobradinhasNova')
|
||||
|
||||
export default async function NewStateDeputyPage() {
|
||||
await requireCampaignPageActor({ gate: 'staff' })
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<Button asChild variant="ghost" className="min-h-11 self-start">
|
||||
<Link href="/campanha/dobradinhas">
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para dobradinhas
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Nova dobradinha</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Cadastre deputados estaduais com quem a campanha dobra. Vincule a municípios e lideranças
|
||||
nas fichas correspondentes.
|
||||
</p>
|
||||
</header>
|
||||
<Button asChild variant="ghost" className="min-h-11 self-start">
|
||||
<Link href="/campanha/dobradinhas">
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para dobradinhas
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<StateDeputyForm formAction={createStateDeputyFormAction} />
|
||||
</CampaignPageShell>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { resolveVisibleColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
|
||||
|
|
@ -64,6 +65,8 @@ import {
|
|||
setStateDeputyMunicipalitiesFormAction,
|
||||
} from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('dobradinhas')
|
||||
|
||||
type StateDeputiesPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -223,21 +226,14 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dobradinhas</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Deputados estaduais com quem a campanha dobra — vincule lideranças e municípios direto
|
||||
na lista.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/dobradinhas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova dobradinha
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<StateDeputyFilters
|
||||
|
|
|
|||
|
|
@ -10,20 +10,17 @@ import {
|
|||
type BiometricEnrollmentOffer,
|
||||
} from '@/components/campaign/shell/BiometricEnrollmentToast'
|
||||
import { CampaignAppScrollChrome } from '@/components/campaign/shell/CampaignAppScrollChrome'
|
||||
import { CampaignDesktopHeader } from '@/components/campaign/shell/CampaignDesktopHeader'
|
||||
import { CampaignHomeSearchChromeProvider } from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
|
||||
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
|
||||
import { CampaignNotificationBellSlot } from '@/components/campaign/shell/CampaignNotificationBellSlot'
|
||||
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
|
||||
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
|
||||
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast'
|
||||
import {
|
||||
SIDEBAR_COOKIE_NAME,
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@/components/ui/Sidebar'
|
||||
import { SIDEBAR_COOKIE_NAME, SidebarInset, SidebarProvider } from '@/components/ui/Sidebar'
|
||||
import { Toaster } from '@/components/ui/Toaster'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
|
||||
|
|
@ -75,36 +72,37 @@ export default async function CampaignAppLayout({ children }: { children: React.
|
|||
<CampaignSidebarViewportDefault hasSidebarCookie={hasSidebarCookie} />
|
||||
<CampaignSidebar user={campaignUserShellView(user)} />
|
||||
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignHomeSearchChromeProvider>
|
||||
<CampaignQuickActionContextProvider>
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignMobileTopBar
|
||||
notificationBell={<CampaignNotificationBellSlot user={user} />}
|
||||
/>
|
||||
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
|
||||
<SidebarTrigger />
|
||||
<div className="ml-auto">
|
||||
<CampaignNotificationBellSlot user={user} />
|
||||
</div>
|
||||
</header>
|
||||
{/*
|
||||
<CampaignPageChromeProvider role={user.role}>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignHomeSearchChromeProvider>
|
||||
<CampaignQuickActionContextProvider>
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignMobileTopBar
|
||||
notificationBell={<CampaignNotificationBellSlot user={user} />}
|
||||
/>
|
||||
<CampaignDesktopHeader
|
||||
notificationBell={<CampaignNotificationBellSlot user={user} />}
|
||||
/>
|
||||
{/*
|
||||
Provider must wrap CampaignAppScrollChrome, not only page children:
|
||||
the quick-actions FAB overlay (B126) mounts as a sibling of
|
||||
the scrollport and renders search hits with CampaignHoverTooltip
|
||||
(priority flag). Nested only around {children} left focus→suggest
|
||||
without a provider and crashed the page (B102).
|
||||
*/}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<CampaignAppScrollChrome role={user.role}>{children}</CampaignAppScrollChrome>
|
||||
</TooltipProvider>
|
||||
<Toaster position="top-center" />
|
||||
<InstallPwaToast />
|
||||
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignQuickActionContextProvider>
|
||||
</CampaignHomeSearchChromeProvider>
|
||||
</CampaignWizardChromeProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<CampaignAppScrollChrome role={user.role}>{children}</CampaignAppScrollChrome>
|
||||
</TooltipProvider>
|
||||
<Toaster position="top-center" />
|
||||
<InstallPwaToast />
|
||||
{biometricEnrollment ? (
|
||||
<BiometricEnrollmentToast {...biometricEnrollment} />
|
||||
) : null}
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignQuickActionContextProvider>
|
||||
</CampaignHomeSearchChromeProvider>
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { CampaignQuickActionContextSync } from '@/components/campaign/shell/Camp
|
|||
import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { resolvedPortfolioEntriesById } from '@/lib/municipalityPortfolio'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import {
|
||||
|
|
@ -23,6 +24,8 @@ import { loadLeadershipDetail } from '@/utilities/leadership/leadershipData'
|
|||
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
|
||||
import { updateLeadershipInternalFormAction } from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('liderancas')
|
||||
|
||||
type LeadershipDetailPageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { getPayload } from 'payload'
|
|||
import { LeadershipForm } from '@/components/campaign/leadership/LeadershipForm'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import {
|
||||
loadMunicipalityOptions,
|
||||
|
|
@ -14,6 +15,8 @@ import {
|
|||
} from '@/utilities/campaignRelationOptions'
|
||||
import { createLeadershipFormAction } from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('liderancasNova')
|
||||
|
||||
type NewLeadershipPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -46,19 +49,12 @@ export default async function NewLeadershipPage({ searchParams }: NewLeadershipP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<Button asChild variant="ghost" className="min-h-11 self-start">
|
||||
<Link href="/campanha/liderancas">
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para lideranças
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Nova liderança</h1>
|
||||
<p className="text-muted-foreground">
|
||||
O contato é reaproveitado pelo celular quando já existe. Registre o consentimento antes de
|
||||
inserir dados reais.
|
||||
</p>
|
||||
</header>
|
||||
<Button asChild variant="ghost" className="min-h-11 self-start">
|
||||
<Link href="/campanha/liderancas">
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para lideranças
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<LeadershipForm
|
||||
municipalityOptions={municipalityOptions}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { resolveVisibleColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import {
|
||||
resolvedPortfolioEntriesById,
|
||||
|
|
@ -79,6 +80,8 @@ import {
|
|||
setLeadershipStateDeputyMembershipFormAction,
|
||||
} from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('liderancas')
|
||||
|
||||
type LeadershipsPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -381,20 +384,14 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Lideranças</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/liderancas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova liderança
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<LeadershipFilters state={state} municipalityLabelsById={municipalityLabelsById} />
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { getPayload } from 'payload'
|
|||
|
||||
import { MunicipalityAdvisorsForm } from '@/components/campaign/municipality/MunicipalityAdvisorsForm'
|
||||
import { MunicipalityStrategyForm } from '@/components/campaign/municipality/MunicipalityStrategyForm'
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
|
||||
import {
|
||||
|
|
@ -27,6 +29,29 @@ type MunicipalityEditPageProps = {
|
|||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
const municipalityEditSubtitle =
|
||||
'A geografia do município é pré-definida e não pode ser alterada. Aqui você edita metas, votos estimados, inteligência e tendência política.'
|
||||
|
||||
export async function generateMetadata({ params }: MunicipalityEditPageProps) {
|
||||
const { slug } = await params
|
||||
const payload = await getPayload({ config })
|
||||
const user = await requireCampaignPageActor({
|
||||
gate: 'staff',
|
||||
redirectTo: `/campanha/municipios/${slug}`,
|
||||
})
|
||||
|
||||
try {
|
||||
const context = await resolveAccessibleMunicipalityContext(payload, user, slug)
|
||||
const view = await getMunicipalityDetailViewModel(payload, context, user)
|
||||
return campaignPageMetadata({
|
||||
title: `Editar ${view.name}`,
|
||||
subtitle: municipalityEditSubtitle,
|
||||
})
|
||||
} catch {
|
||||
return campaignPageMetadata({ title: 'Editar município', subtitle: municipalityEditSubtitle })
|
||||
}
|
||||
}
|
||||
|
||||
export default async function MunicipalityEditPage({ params }: MunicipalityEditPageProps) {
|
||||
const { slug } = await params
|
||||
const [user, payload] = await Promise.all([
|
||||
|
|
@ -51,19 +76,15 @@ export default async function MunicipalityEditPage({ params }: MunicipalityEditP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<Button asChild variant="ghost" className="min-h-11 self-start">
|
||||
<Link href={`/campanha/municipios/${view.slug}`}>
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para {view.name}
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Editar {view.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
A geografia do município é pré-definida e não pode ser alterada. Aqui você edita metas,
|
||||
votos estimados, inteligência e tendência política.
|
||||
</p>
|
||||
</header>
|
||||
<SetCampaignPageChrome
|
||||
chrome={{ title: `Editar ${view.name}`, subtitle: municipalityEditSubtitle }}
|
||||
/>
|
||||
<Button asChild variant="ghost" className="min-h-11 self-start">
|
||||
<Link href={`/campanha/municipios/${view.slug}`}>
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para {view.name}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{user.role === 'coordinator' ? (
|
||||
<MunicipalityAdvisorsForm
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitT
|
|||
import { MunicipalityTabNav } from '@/components/campaign/municipality/MunicipalityTabNav'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import {
|
||||
resolveMunicipalityDetailTab,
|
||||
|
|
@ -33,6 +34,8 @@ import {
|
|||
UpdatesTab,
|
||||
} from './MunicipalityDetailTabs'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('municipios')
|
||||
|
||||
type MunicipalityDetailPageProps = {
|
||||
params: Promise<{ slug: string }>
|
||||
searchParams: Promise<MunicipalityDetailSearchParams>
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import { MunicipalityFilters } from '@/components/campaign/municipality/Municipa
|
|||
import { MunicipalityList } from '@/components/campaign/municipality/MunicipalityList'
|
||||
import { MunicipalityListOverview } from '@/components/campaign/municipality/MunicipalityListOverview'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import {
|
||||
isCampaignCoordinator,
|
||||
isCampaignStaff,
|
||||
|
|
@ -21,7 +21,6 @@ import {
|
|||
} from '@/utilities/campaignAccess'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { getCampaignScopeLabel } from '@/utilities/municipality/municipalityLabels'
|
||||
import {
|
||||
buildMunicipalityListVisitHref,
|
||||
buildMunicipalityListVisitLabel,
|
||||
|
|
@ -37,6 +36,8 @@ import {
|
|||
} from '@/utilities/municipality/municipalityViewModels'
|
||||
import { createMunicipalityListSignalFormAction } from './municipalityStaffFormActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('municipios')
|
||||
|
||||
type MunicipalitiesPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -59,7 +60,6 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
municipalities: listMunicipalities,
|
||||
totalDocs,
|
||||
totalPages,
|
||||
scopeTotal,
|
||||
overview,
|
||||
filterFacets,
|
||||
} = pageBundle
|
||||
|
|
@ -166,15 +166,6 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Municípios</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Os 435 municípios da campanha: um por município da Bahia — em Salvador, uma zona eleitoral
|
||||
cada.
|
||||
</p>
|
||||
<CampaignScopeBadge>{getCampaignScopeLabel(user.role, scopeTotal)}</CampaignScopeBadge>
|
||||
</header>
|
||||
|
||||
{main}
|
||||
{listVisitLabel ? (
|
||||
<RecentVisitTracker
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ import { OrganizationForm } from '@/components/campaign/organization/Organizatio
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { organizationKindLabels } from '@/lib/schemas/organization'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
|
||||
import { loadOrganizationDetail } from '@/utilities/organizationData'
|
||||
import { updateOrganizationFormAction } from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('organizacoes')
|
||||
|
||||
type OrganizationDetailPageProps = {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ import { getPayload } from 'payload'
|
|||
import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
|
||||
import { createOrganizationFormAction } from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('organizacoesNova')
|
||||
|
||||
export default async function NewOrganizationPage() {
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'staff' }),
|
||||
|
|
@ -20,19 +23,12 @@ export default async function NewOrganizationPage() {
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<Button asChild variant="ghost" className="min-h-11 self-start">
|
||||
<Link href="/campanha/organizacoes">
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para organizações
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Nova organização</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Cadastre sindicatos, associações, movimentos e afins para vincular lideranças e
|
||||
atividades.
|
||||
</p>
|
||||
</header>
|
||||
<Button asChild variant="ghost" className="min-h-11 self-start">
|
||||
<Link href="/campanha/organizacoes">
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para organizações
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<OrganizationForm
|
||||
municipalityOptions={municipalityOptions}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/s
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { organizationKindLabels } from '@/lib/schemas/organization'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
|
@ -24,6 +25,8 @@ import {
|
|||
type OrganizationRowViewModel,
|
||||
} from '@/utilities/organizationData'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('organizacoes')
|
||||
|
||||
type OrganizationsPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -74,21 +77,14 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Organizações</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Sindicatos, associações e movimentos que apoiam a campanha — com suas lideranças e
|
||||
atividades.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/organizacoes/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova organização
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignSearchForm
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/Campaig
|
|||
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { UNCOVERED_MUNICIPALITIES_LIST_HREF } from '@/lib/campaignHomeActions'
|
||||
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import { loadCampaignHomeSummary } from '@/utilities/campaignDashboardData'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
|
@ -15,6 +16,8 @@ import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearch
|
|||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata = campaignPageMetadata(null)
|
||||
|
||||
export default async function CampaignHomePage() {
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import config from '@payload-config'
|
||||
import type { Metadata } from 'next'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
|
@ -7,6 +6,7 @@ import { getPayload } from 'payload'
|
|||
import { CampaignPasskeysCard } from '@/components/campaign/auth/CampaignPasskeysCard'
|
||||
import { CampaignProfileSettings } from '@/components/campaign/auth/CampaignProfileSettings'
|
||||
import { CampaignPushNotificationsCard } from '@/components/campaign/auth/CampaignPushNotificationsCard'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
|
||||
import { getCampaignUserWithAvatar } from '@/utilities/campaignAuth'
|
||||
import { getCampaignPushConsent } from '@/utilities/campaignConsent'
|
||||
|
|
@ -15,13 +15,7 @@ import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaign
|
|||
import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony'
|
||||
import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/webauthn/campaignWebAuthnConfig'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Meu perfil | Campanha',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
}
|
||||
export const metadata = campaignPageMetadataFromCatalog('perfil')
|
||||
|
||||
type CampaignProfilePageProps = {
|
||||
searchParams: Promise<{ passwordReset?: string }>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
SuggestionSilenceStrip,
|
||||
SuggestionsPanel,
|
||||
} from '@/components/campaign/suggestion/SuggestionsPanel'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
|
@ -18,6 +19,8 @@ import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipali
|
|||
|
||||
import { resolveSuggestionFormAction } from '../suggestionFormActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('quadro')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type CampaignQuadroPageProps = {
|
||||
|
|
@ -36,7 +39,6 @@ export default async function CampaignQuadroPage({ searchParams }: CampaignQuadr
|
|||
return (
|
||||
<CampaignDashboard
|
||||
view={view}
|
||||
userName={user.name}
|
||||
mapSlot={
|
||||
<Suspense
|
||||
fallback={
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadTerritoryOverview } from '@/utilities/territory/loadTerritoryOverview'
|
||||
|
|
@ -19,6 +20,8 @@ import {
|
|||
} from '@/utilities/territory/territoryListUrl'
|
||||
import { filterTerritoryRows, sortTerritoryRows } from '@/utilities/territory/territoryOverview'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('territorios')
|
||||
|
||||
type TerritoriesPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -44,15 +47,6 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex max-w-prose flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-balance">
|
||||
Territórios de Identidade
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-pretty">
|
||||
Compare a concentração histórica e a cobertura de assessoria das regiões da Bahia. Abra um
|
||||
território para ver seus municípios.
|
||||
</p>
|
||||
</header>
|
||||
<CampaignListPendingBoundary>
|
||||
<TerritoryFilters state={state} regionOptions={regionOptions} />
|
||||
<CampaignListResults>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import '../(frontend)/styles.css'
|
|||
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Campanha',
|
||||
title: {
|
||||
default: 'Campanha',
|
||||
template: 'Solla - Campanha - %s',
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
|
|
|
|||
|
|
@ -64,13 +64,6 @@ export const CampaignProfileSettings = ({
|
|||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Meu perfil</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gerencie sua foto, sua senha e a entrada por biometria.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{passwordResetBanner ? (
|
||||
<p className="rounded-md border border-border bg-muted/50 px-4 py-3 text-sm" role="status">
|
||||
Senha redefinida com sucesso. Você já está autenticado.
|
||||
|
|
|
|||
|
|
@ -56,27 +56,16 @@ const buildZoneCityHrefs = (
|
|||
|
||||
export const CampaignDashboard = ({
|
||||
view,
|
||||
userName,
|
||||
mapSlot = null,
|
||||
suggestionsSlot = null,
|
||||
}: {
|
||||
view: StaffDashboardView
|
||||
userName: string
|
||||
/** Server-streamed map section (composition keeps this component map-agnostic). */
|
||||
mapSlot?: ReactNode
|
||||
/** Server-streamed suggestion queue (E11) — right after the KPIs: it is the "onde ajo". */
|
||||
suggestionsSlot?: ReactNode
|
||||
}) => (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Olá, {userName}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{view.role === 'advisor'
|
||||
? 'Quadro dos municípios sob sua assessoria.'
|
||||
: 'Quadro geral da campanha por município.'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{mapSlot}
|
||||
|
||||
<section
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ const dateFormatter = new Intl.DateTimeFormat('pt-BR', {
|
|||
})
|
||||
|
||||
type LeaderContactsPanelProps = {
|
||||
userName: string
|
||||
municipalityOptions: RelationOption[]
|
||||
defaultMunicipalityId: number | null
|
||||
showMunicipalitySelect: boolean
|
||||
|
|
@ -93,7 +92,6 @@ const LeaderContactsList = ({ contacts }: { contacts: LeaderContactListItem[] })
|
|||
* (`LeaderContactForm` is the client island).
|
||||
*/
|
||||
export const LeaderContactsPanel = ({
|
||||
userName,
|
||||
municipalityOptions,
|
||||
defaultMunicipalityId,
|
||||
showMunicipalitySelect,
|
||||
|
|
@ -101,13 +99,6 @@ export const LeaderContactsPanel = ({
|
|||
contacts,
|
||||
}: LeaderContactsPanelProps) => (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Olá, {userName}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Cadastre apoiadores pelo celular. Só você vê os contatos que criou aqui.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{municipalityOptions.length === 0 ? (
|
||||
<p className="rounded-xl border px-4 py-6 text-sm text-muted-foreground">
|
||||
Você ainda não está vinculada a nenhum município. Fale com a assessoria da campanha.
|
||||
|
|
|
|||
14
src/components/campaign/shell/CampaignDesktopHeader.tsx
Normal file
14
src/components/campaign/shell/CampaignDesktopHeader.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
|
||||
import { SidebarTrigger } from '@/components/ui/Sidebar'
|
||||
|
||||
export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => (
|
||||
<header className="hidden min-h-11 shrink-0 items-center gap-3 border-b border-border px-4 md:flex print:hidden">
|
||||
<SidebarTrigger />
|
||||
<CampaignPageChromeDisplay layout="desktop" className="flex-1" />
|
||||
{notificationBell ? <div className="ml-auto shrink-0">{notificationBell}</div> : null}
|
||||
</header>
|
||||
)
|
||||
|
|
@ -6,6 +6,7 @@ import type { ReactNode } from 'react'
|
|||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
|
||||
import { useCampaignHomeSearchChrome } from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
|
||||
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
|
||||
import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SidebarTrigger } from '@/components/ui/Sidebar'
|
||||
|
|
@ -148,10 +149,7 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?:
|
|||
) : (
|
||||
<SidebarTrigger className="text-primary-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 leading-tight">
|
||||
<span className="block truncate text-sm font-semibold">Jorge Solla</span>
|
||||
<span className="block truncate text-xs text-primary-foreground/80">Campanha · Bahia</span>
|
||||
</div>
|
||||
<CampaignPageChromeDisplay layout="mobile" />
|
||||
{notificationBell ? <div className="shrink-0">{notificationBell}</div> : null}
|
||||
</header>
|
||||
)
|
||||
|
|
|
|||
71
src/components/campaign/shell/CampaignPageChromeContext.tsx
Normal file
71
src/components/campaign/shell/CampaignPageChromeContext.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
|
||||
import type { CampaignPageChrome } from '@/lib/campaignPageChrome'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
|
||||
type CampaignPageChromeContextValue = {
|
||||
role: CampaignUser['role']
|
||||
override: CampaignPageChrome | null
|
||||
setOverride: Dispatch<SetStateAction<CampaignPageChrome | null>>
|
||||
}
|
||||
|
||||
const CampaignPageChromeContext = createContext<CampaignPageChromeContextValue | null>(null)
|
||||
|
||||
export const CampaignPageChromeProvider = ({
|
||||
role,
|
||||
children,
|
||||
}: {
|
||||
role: CampaignUser['role']
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const [override, setOverride] = useState<CampaignPageChrome | null>(null)
|
||||
const value = useMemo(() => ({ role, override, setOverride }), [role, override])
|
||||
|
||||
return (
|
||||
<CampaignPageChromeContext.Provider value={value}>
|
||||
{children}
|
||||
</CampaignPageChromeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useCampaignPageChromeContext = (): CampaignPageChromeContextValue => {
|
||||
const value = useContext(CampaignPageChromeContext)
|
||||
if (!value) {
|
||||
throw new Error('CampaignPageChromeProvider is required')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export const useCampaignPageChromeRole = (): CampaignUser['role'] =>
|
||||
useCampaignPageChromeContext().role
|
||||
|
||||
export const useCampaignPageChromeOverride = (): CampaignPageChrome | null =>
|
||||
useCampaignPageChromeContext().override
|
||||
|
||||
export const SetCampaignPageChrome = ({ chrome }: { chrome: CampaignPageChrome }): null => {
|
||||
const { setOverride } = useCampaignPageChromeContext()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setOverride(chrome)
|
||||
}, [setOverride, chrome])
|
||||
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
setOverride(null)
|
||||
},
|
||||
[setOverride],
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
32
src/components/campaign/shell/CampaignPageChromeDisplay.tsx
Normal file
32
src/components/campaign/shell/CampaignPageChromeDisplay.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import {
|
||||
useCampaignPageChromeOverride,
|
||||
useCampaignPageChromeRole,
|
||||
} from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageChromeText } from '@/components/campaign/shell/CampaignPageChromeText'
|
||||
import { resolveCampaignPageChrome } from '@/lib/campaignPageChrome'
|
||||
|
||||
const useEffectiveCampaignPageChrome = () => {
|
||||
const pathname = usePathname()
|
||||
const role = useCampaignPageChromeRole()
|
||||
const override = useCampaignPageChromeOverride()
|
||||
|
||||
return override ?? resolveCampaignPageChrome(pathname, role)
|
||||
}
|
||||
|
||||
export const CampaignPageChromeDisplay = ({
|
||||
layout,
|
||||
className,
|
||||
}: {
|
||||
layout: 'mobile' | 'desktop'
|
||||
className?: string
|
||||
}) => {
|
||||
const chrome = useEffectiveCampaignPageChrome()
|
||||
|
||||
if (!chrome) return null
|
||||
|
||||
return <CampaignPageChromeText chrome={chrome} layout={layout} className={className} />
|
||||
}
|
||||
35
src/components/campaign/shell/CampaignPageChromeText.tsx
Normal file
35
src/components/campaign/shell/CampaignPageChromeText.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { CampaignPageChrome } from '@/lib/campaignPageChrome'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const CampaignPageChromeText = ({
|
||||
chrome,
|
||||
layout,
|
||||
className,
|
||||
}: {
|
||||
chrome: CampaignPageChrome
|
||||
layout: 'mobile' | 'desktop'
|
||||
className?: string
|
||||
}) => {
|
||||
if (layout === 'desktop') {
|
||||
return (
|
||||
<p
|
||||
data-slot="campaign-page-chrome"
|
||||
className={cn('min-w-0 truncate text-sm font-medium text-foreground', className)}
|
||||
>
|
||||
{chrome.title}
|
||||
{chrome.subtitle ? (
|
||||
<span className="font-normal text-muted-foreground"> {chrome.subtitle}</span>
|
||||
) : null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="campaign-page-chrome" className={cn('min-w-0 flex-1 leading-tight', className)}>
|
||||
<span className="block truncate text-sm font-semibold">{chrome.title}</span>
|
||||
{chrome.subtitle ? (
|
||||
<span className="block truncate text-xs text-primary-foreground/80">{chrome.subtitle}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
305
src/lib/campaignPageChrome.ts
Normal file
305
src/lib/campaignPageChrome.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import type { Metadata } from 'next'
|
||||
|
||||
import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
|
||||
export type CampaignPageChrome = {
|
||||
title: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
type CampaignRole = CampaignUser['role']
|
||||
|
||||
const normalizePathname = (pathname: string): string => {
|
||||
if (pathname.length > 1 && pathname.endsWith('/')) {
|
||||
return pathname.slice(0, -1)
|
||||
}
|
||||
return pathname
|
||||
}
|
||||
|
||||
const quadroSubtitle = (role: CampaignRole): string =>
|
||||
role === 'advisor'
|
||||
? 'Quadro dos municípios sob sua assessoria.'
|
||||
: 'Quadro geral da campanha por município.'
|
||||
|
||||
/** Client-safe vocabulary for shell chrome and tab titles (B123). */
|
||||
export const campaignPageChromeCatalog = {
|
||||
home: null,
|
||||
quadro: {
|
||||
title: 'Quadro',
|
||||
subtitle: quadroSubtitle,
|
||||
},
|
||||
municipios: {
|
||||
title: 'Municípios',
|
||||
subtitle:
|
||||
'Os 435 municípios da campanha: um por município da Bahia — em Salvador, uma zona eleitoral cada.',
|
||||
},
|
||||
territorios: {
|
||||
title: 'Territórios de Identidade',
|
||||
subtitle:
|
||||
'Compare a concentração histórica e a cobertura de assessoria das regiões da Bahia. Abra um território para ver seus municípios.',
|
||||
},
|
||||
liderancas: {
|
||||
title: 'Lideranças',
|
||||
subtitle:
|
||||
'Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.',
|
||||
},
|
||||
liderancasNova: {
|
||||
title: 'Nova liderança',
|
||||
subtitle:
|
||||
'O contato é reaproveitado pelo celular quando já existe. Registre o consentimento antes de inserir dados reais.',
|
||||
},
|
||||
dobradinhas: {
|
||||
title: 'Dobradinhas',
|
||||
subtitle:
|
||||
'Deputados estaduais com quem a campanha dobra — vincule lideranças e municípios direto na lista.',
|
||||
},
|
||||
dobradinhasNova: {
|
||||
title: 'Nova dobradinha',
|
||||
subtitle:
|
||||
'Cadastre deputados estaduais com quem a campanha dobra. Vincule a municípios e lideranças nas fichas correspondentes.',
|
||||
},
|
||||
atividades: {
|
||||
title: 'Atividades',
|
||||
subtitle: 'Organize caminhadas, comícios, panfletagens e demais ações de campanha.',
|
||||
},
|
||||
atividadesNova: {
|
||||
title: 'Nova atividade',
|
||||
subtitle: 'Defina a ação, quando e onde ela acontece e quem responde por ela.',
|
||||
},
|
||||
atividadesGiros: {
|
||||
title: 'Planejar giro',
|
||||
subtitle:
|
||||
'Um giro por território: o ato onde o voto comprometido já está, as paradas com rede para receber, e uma semente de expansão para o giro não repetir só a base. A proposta é sugestão — você edita antes de gerar.',
|
||||
},
|
||||
demandas: {
|
||||
title: 'Demandas',
|
||||
subtitle:
|
||||
'Necessidades da campanha abertas pelas lideranças, revisadas pela assessoria e — quando preciso — decididas pelo Coordenador Geral.',
|
||||
},
|
||||
demandasNova: {
|
||||
title: 'Nova demanda',
|
||||
subtitle:
|
||||
'Descreva a necessidade (material, transporte, espaço, apoio…) e o município. A assessoria revisa e responde por aqui.',
|
||||
},
|
||||
apoiadores: {
|
||||
title: 'Apoiadores',
|
||||
subtitle: 'Base nominal de apoio com intenção de voto e vínculo opcional a municípios.',
|
||||
},
|
||||
apoiadoresNovo: {
|
||||
title: 'Novo apoiador',
|
||||
subtitle: 'Cadastre um apoiador com telefone obrigatório e consentimento LGPD.',
|
||||
},
|
||||
apoiadoresImportar: {
|
||||
title: 'Importar apoiadores via CSV',
|
||||
subtitle: 'Envie uma planilha, confira a prévia e confirme a importação em lote.',
|
||||
},
|
||||
assessores: {
|
||||
title: 'Assessores',
|
||||
subtitle:
|
||||
'Consulte na tabela; ative Editar para alterar campos e carteira. Nome abre a ficha; e-mail/celular copiam; município abre o município.',
|
||||
},
|
||||
organizacoes: {
|
||||
title: 'Organizações',
|
||||
subtitle:
|
||||
'Sindicatos, associações e movimentos que apoiam a campanha — com suas lideranças e atividades.',
|
||||
},
|
||||
organizacoesNova: {
|
||||
title: 'Nova organização',
|
||||
subtitle:
|
||||
'Cadastre sindicatos, associações, movimentos e afins para vincular lideranças e atividades.',
|
||||
},
|
||||
conceitos: {
|
||||
title: 'Conceitos de inteligência',
|
||||
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.',
|
||||
},
|
||||
contatos: {
|
||||
title: 'Contatos',
|
||||
subtitle: 'Cadastre apoiadores pelo celular. Só você vê os contatos que criou aqui.',
|
||||
},
|
||||
perfil: {
|
||||
title: 'Meu perfil',
|
||||
subtitle: 'Gerencie sua foto, sua senha e a entrada por biometria.',
|
||||
},
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
CampaignPageChrome | { title: string; subtitle: (role: CampaignRole) => string } | null
|
||||
>
|
||||
|
||||
const resolveCatalogEntry = (
|
||||
entry: (typeof campaignPageChromeCatalog)[keyof typeof campaignPageChromeCatalog],
|
||||
role: CampaignRole,
|
||||
): CampaignPageChrome | null => {
|
||||
if (!entry) return null
|
||||
const subtitle = typeof entry.subtitle === 'function' ? entry.subtitle(role) : entry.subtitle
|
||||
return { title: entry.title, subtitle }
|
||||
}
|
||||
|
||||
const sectionOnly = (title: string): CampaignPageChrome => ({ title })
|
||||
|
||||
type PathRule = {
|
||||
match: (pathname: string) => boolean
|
||||
resolve: (role: CampaignRole) => CampaignPageChrome | null
|
||||
}
|
||||
|
||||
const pathRules: PathRule[] = [
|
||||
{
|
||||
match: (pathname) => pathname === CAMPAIGN_HOME,
|
||||
resolve: () => null,
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/quadro',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.quadro, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/municipios',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.municipios, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/municipios\/[^/]+\/editar$/.test(pathname),
|
||||
resolve: () => null,
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/municipios\/[^/]+$/.test(pathname),
|
||||
resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/territorios',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.territorios, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/liderancas/nova',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.liderancasNova, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/liderancas\/[^/]+$/.test(pathname),
|
||||
resolve: () => sectionOnly(campaignPageChromeCatalog.liderancas.title),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/liderancas',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.liderancas, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/dobradinhas/nova',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.dobradinhasNova, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/dobradinhas\/[^/]+$/.test(pathname),
|
||||
resolve: () => sectionOnly(campaignPageChromeCatalog.dobradinhas.title),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/dobradinhas',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.dobradinhas, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/atividades/nova',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.atividadesNova, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/atividades/giros',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.atividadesGiros, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/atividades\/[^/]+\/editar$/.test(pathname),
|
||||
resolve: () => null,
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/atividades\/[^/]+$/.test(pathname),
|
||||
resolve: () => sectionOnly(campaignPageChromeCatalog.atividades.title),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/atividades',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.atividades, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/demandas/nova',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.demandasNova, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/demandas\/[^/]+$/.test(pathname),
|
||||
resolve: () => sectionOnly(campaignPageChromeCatalog.demandas.title),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/demandas',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.demandas, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/apoiadores/novo',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.apoiadoresNovo, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/apoiadores/importar',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.apoiadoresImportar, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/apoiadores\/[^/]+$/.test(pathname),
|
||||
resolve: () => sectionOnly(campaignPageChromeCatalog.apoiadores.title),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/apoiadores',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.apoiadores, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/assessores\/[^/]+$/.test(pathname),
|
||||
resolve: () => sectionOnly(campaignPageChromeCatalog.assessores.title),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/assessores',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.assessores, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/organizacoes/nova',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.organizacoesNova, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/organizacoes\/[^/]+$/.test(pathname),
|
||||
resolve: () => sectionOnly(campaignPageChromeCatalog.organizacoes.title),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/organizacoes',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.organizacoes, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/conceitos',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.conceitos, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === LEADER_CONTACTS_HOME,
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.contatos, role),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/perfil',
|
||||
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.perfil, role),
|
||||
},
|
||||
]
|
||||
|
||||
export const resolveCampaignPageChrome = (
|
||||
pathname: string,
|
||||
role: CampaignRole,
|
||||
): CampaignPageChrome | null => {
|
||||
const normalized = normalizePathname(pathname)
|
||||
for (const rule of pathRules) {
|
||||
if (rule.match(normalized)) {
|
||||
return rule.resolve(role)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const noIndexMetadata: Pick<Metadata, 'robots'> = {
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
/** Tab title via `title.template` on `(campaign)/layout.tsx`. */
|
||||
export const campaignPageMetadata = (chrome: CampaignPageChrome | null): Metadata => {
|
||||
if (!chrome) {
|
||||
return { title: 'Início', ...noIndexMetadata }
|
||||
}
|
||||
return { title: chrome.title, ...noIndexMetadata }
|
||||
}
|
||||
|
||||
export const campaignPageMetadataFromCatalog = (
|
||||
key: keyof typeof campaignPageChromeCatalog,
|
||||
role: CampaignRole = 'coordinator',
|
||||
): Metadata => campaignPageMetadata(resolveCatalogEntry(campaignPageChromeCatalog[key], role))
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import { AT_STANDARD_LQ } from '@/lib/territorialClassAnchors'
|
||||
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels } from '@/lib/voteEstimate'
|
||||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
import type { Municipality } from '@/payload-types'
|
||||
import { formatRatioAsPercentLabel } from '@/utilities/municipality/goalCoverage'
|
||||
import { MUNICIPALITY_COLD_SIGNAL_DAYS } from '@/utilities/municipality/municipalitySignal'
|
||||
import type {
|
||||
|
|
@ -153,19 +153,6 @@ export const formatMunicipalityConcentrationHint = (
|
|||
): string =>
|
||||
`Percentual da votação estadual do candidato neste município — não o % dos válidos locais. Colocação: posição no catálogo de ${formatElectionNumber(totalUnits)} unidades.`
|
||||
|
||||
export const getCampaignScopeLabel = (
|
||||
role: CampaignUser['role'],
|
||||
municipalityCount: number,
|
||||
): string => {
|
||||
if (role === 'advisor') {
|
||||
return `${municipalityCount} ${municipalityCount === 1 ? 'município sob sua assessoria' : 'municípios sob sua assessoria'}`
|
||||
}
|
||||
if (role === 'leader') {
|
||||
return `${municipalityCount} ${municipalityCount === 1 ? 'município em que você atua' : 'municípios em que você atua'}`
|
||||
}
|
||||
return `${municipalityCount} ${municipalityCount === 1 ? 'município' : 'municípios'}`
|
||||
}
|
||||
|
||||
export const municipalityGeographyParts = (municipality: {
|
||||
region: string
|
||||
kind: Municipality['kind']
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { campaignConceptHref } from '../../src/lib/campaignIntelligenceConcepts.js'
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
/**
|
||||
* E18 — `/campanha/conceitos` documents staff-only numbers (estimates, goals,
|
||||
|
|
@ -41,9 +41,7 @@ test.describe('Conceitos de inteligência', () => {
|
|||
await page.getByRole('link', { name: 'Como cada número é calculado' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/campanha\/conceitos$/)
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Conceitos de inteligência' }),
|
||||
).toBeVisible()
|
||||
await expect(campaignPageChrome(page, 'Conceitos de inteligência')).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Cobertura da meta' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Teto do campo (projetado)' })).toBeVisible()
|
||||
|
||||
|
|
@ -67,8 +65,6 @@ test.describe('Conceitos de inteligência', () => {
|
|||
await page.goto('/campanha/conceitos')
|
||||
|
||||
await expect(page).toHaveURL(/\/campanha\/contatos\/?$/)
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Conceitos de inteligência' }),
|
||||
).toHaveCount(0)
|
||||
await expect(campaignPageChrome(page, 'Conceitos de inteligência')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { expect, expectPostResponse, test } from './fixtures/campaignE2EFixtures.js'
|
||||
import {
|
||||
campaignPageChrome,
|
||||
expect,
|
||||
expectPostResponse,
|
||||
test,
|
||||
} from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
/**
|
||||
* `/campanha/liderancas` journeys: the B32 support-status quick edit (Popover +
|
||||
|
|
@ -25,7 +30,7 @@ test.describe('campaign leaderships list', () => {
|
|||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/liderancas?q=${encodeURIComponent(contactName)}`)
|
||||
await expect(page.getByRole('heading', { name: 'Lideranças', exact: true })).toBeVisible()
|
||||
await expect(campaignPageChrome(page, 'Lideranças')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Editar status de apoio' }).click()
|
||||
const statusPopover = page.locator('[data-slot="popover-content"]')
|
||||
|
|
@ -71,7 +76,7 @@ test.describe('campaign leaderships list', () => {
|
|||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/liderancas?q=${encodeURIComponent(contactName)}`)
|
||||
await expect(page.getByRole('heading', { name: 'Lideranças', exact: true })).toBeVisible()
|
||||
await expect(campaignPageChrome(page, 'Lideranças')).toBeVisible()
|
||||
|
||||
// The chip is still a link to the município — editing did not cost the
|
||||
// navigation the column had before.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
|
||||
} from '../helpers/testDatabaseLease.js'
|
||||
import {
|
||||
campaignPageChrome,
|
||||
checkRadixWhenHydrated,
|
||||
expect,
|
||||
expectPostResponse,
|
||||
|
|
@ -37,7 +38,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||||
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
|
||||
// E9 allocation queue: the list opens on the uncovered deficit and exposes
|
||||
// the freshness column the ordering is paired with. `exact` keeps this off
|
||||
|
|
@ -94,7 +95,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
|
||||
const advisorsTrigger = page.getByRole('button', { name: 'Editar assessores' })
|
||||
await advisorsTrigger.click()
|
||||
|
|
@ -150,7 +151,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Editar tendência política' }).click()
|
||||
const trendPopover = page.locator('[data-slot="popover-content"]')
|
||||
|
|
@ -191,7 +192,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
|
||||
// Anchored: a bare template regex on a municipality name collides with
|
||||
// prefix-shared names (Conde/Condeúba — 23/435 measured in the catalog),
|
||||
|
|
@ -298,7 +299,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
// `password` variable above does NOT unlock the leader account.
|
||||
await campaign.login(page, leaderPhone, leaderAccount.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/contatos`)
|
||||
await expect(page.getByText('Cadastre apoiadores pelo celular.')).toBeVisible()
|
||||
await expect(campaignPageChrome(page, 'Contatos')).toBeVisible()
|
||||
|
||||
// The leader redirect aborts `goto`'s load event (ERR_ABORTED); the
|
||||
// redirect itself is the assertion, and `toHaveURL` retries onto it.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
test.describe('Territórios de Identidade', () => {
|
||||
test('staff sorts, filters and opens the municipality queue for a territory', async ({
|
||||
|
|
@ -16,9 +16,7 @@ test.describe('Territórios de Identidade', () => {
|
|||
await page.getByRole('link', { name: 'Territórios', exact: true }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/campanha\/territorios$/)
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Territórios de Identidade' }),
|
||||
).toBeVisible()
|
||||
await expect(campaignPageChrome(page, 'Territórios de Identidade')).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: /Ordenar por Votos 2022/ }).click()
|
||||
await expect(page).toHaveURL(/\/campanha\/territorios\?sort=votes2022/)
|
||||
|
|
|
|||
|
|
@ -561,4 +561,7 @@ export const test = base.extend<CampaignE2ETestFixtures>({
|
|||
},
|
||||
})
|
||||
|
||||
export const campaignPageChrome = (page: Page, title: string) =>
|
||||
page.locator('[data-slot="campaign-page-chrome"]').filter({ visible: true }).getByText(title)
|
||||
|
||||
export { expect }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const pathnameState = vi.hoisted(() => ({ value: '/campanha/municipios' }))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => pathnameState.value,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
|
|
@ -14,6 +17,7 @@ import {
|
|||
type CampaignHomeSearchChromeState,
|
||||
} from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
|
||||
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
|
||||
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import {
|
||||
CampaignWizardChromeProvider,
|
||||
useSetCampaignWizardChrome,
|
||||
|
|
@ -35,18 +39,23 @@ const HomeSearchChromeProbe = ({ chrome }: { chrome: CampaignHomeSearchChromeSta
|
|||
const renderTopBar = (input: {
|
||||
wizardChrome?: CampaignWizardChromeState | null
|
||||
homeSearchChrome?: CampaignHomeSearchChromeState | null
|
||||
}) =>
|
||||
render(
|
||||
pathname?: string
|
||||
}) => {
|
||||
pathnameState.value = input.pathname ?? '/campanha/municipios'
|
||||
return render(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignHomeSearchChromeProvider>
|
||||
<WizardChromeProbe chrome={input.wizardChrome ?? null} />
|
||||
<HomeSearchChromeProbe chrome={input.homeSearchChrome ?? null} />
|
||||
<CampaignMobileTopBar />
|
||||
</CampaignHomeSearchChromeProvider>
|
||||
</CampaignWizardChromeProvider>
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignHomeSearchChromeProvider>
|
||||
<WizardChromeProbe chrome={input.wizardChrome ?? null} />
|
||||
<HomeSearchChromeProbe chrome={input.homeSearchChrome ?? null} />
|
||||
<CampaignMobileTopBar />
|
||||
</CampaignHomeSearchChromeProvider>
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('CampaignMobileTopBar', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -58,20 +67,29 @@ describe('CampaignMobileTopBar', () => {
|
|||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders app mode with sidebar trigger when chrome is null', () => {
|
||||
it('renders page chrome from pathname in app mode', () => {
|
||||
const { container } = renderTopBar({})
|
||||
|
||||
const topBar = container.querySelector('[data-slot="campaign-mobile-top-bar"]')
|
||||
expect(topBar?.getAttribute('data-mode')).toBe('app')
|
||||
expect(screen.getByText('Jorge Solla')).toBeTruthy()
|
||||
expect(screen.getByText('Municípios')).toBeTruthy()
|
||||
expect(screen.queryByRole('link', { name: /Voltar/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Fechar busca' })).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: 'Sair da ação' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders empty chrome on home', () => {
|
||||
const { container } = renderTopBar({ pathname: '/campanha' })
|
||||
|
||||
const topBar = container.querySelector('[data-slot="campaign-mobile-top-bar"]')
|
||||
expect(topBar?.getAttribute('data-mode')).toBe('app')
|
||||
expect(container.querySelector('[data-slot="campaign-page-chrome"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders app mode with Voltar when home search is focused (B106)', () => {
|
||||
const collapse = vi.fn()
|
||||
const { container } = renderTopBar({
|
||||
pathname: '/campanha',
|
||||
homeSearchChrome: { focused: true, collapse },
|
||||
})
|
||||
|
||||
|
|
@ -140,45 +158,49 @@ describe('CampaignMobileTopBar', () => {
|
|||
it('keeps wizard mode when chrome updates between steps', () => {
|
||||
const { rerender } = render(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignHomeSearchChromeProvider>
|
||||
<WizardChromeProbe
|
||||
chrome={{
|
||||
flowTitle: 'Ajustar votos',
|
||||
stepKind: 'entry',
|
||||
dismissHref: '/campanha',
|
||||
}}
|
||||
/>
|
||||
<CampaignMobileTopBar />
|
||||
</CampaignHomeSearchChromeProvider>
|
||||
</CampaignWizardChromeProvider>
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignHomeSearchChromeProvider>
|
||||
<WizardChromeProbe
|
||||
chrome={{
|
||||
flowTitle: 'Ajustar votos',
|
||||
stepKind: 'entry',
|
||||
dismissHref: '/campanha',
|
||||
}}
|
||||
/>
|
||||
<CampaignMobileTopBar />
|
||||
</CampaignHomeSearchChromeProvider>
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Jorge Solla')).toBeNull()
|
||||
expect(screen.queryByText('Municípios')).toBeNull()
|
||||
|
||||
rerender(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignHomeSearchChromeProvider>
|
||||
<WizardChromeProbe
|
||||
chrome={{
|
||||
flowTitle: 'Ajustar votos',
|
||||
municipalityLabel: 'Cairu',
|
||||
stepKind: 'continue',
|
||||
previousHref: '/campanha/acoes/atualizar-votos',
|
||||
dismissHref: '/campanha',
|
||||
}}
|
||||
/>
|
||||
<CampaignMobileTopBar />
|
||||
</CampaignHomeSearchChromeProvider>
|
||||
</CampaignWizardChromeProvider>
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignHomeSearchChromeProvider>
|
||||
<WizardChromeProbe
|
||||
chrome={{
|
||||
flowTitle: 'Ajustar votos',
|
||||
municipalityLabel: 'Cairu',
|
||||
stepKind: 'continue',
|
||||
previousHref: '/campanha/acoes/atualizar-votos',
|
||||
dismissHref: '/campanha',
|
||||
}}
|
||||
/>
|
||||
<CampaignMobileTopBar />
|
||||
</CampaignHomeSearchChromeProvider>
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
const topBar = document.querySelector('[data-slot="campaign-mobile-top-bar"]')
|
||||
expect(topBar?.getAttribute('data-mode')).toBe('wizard')
|
||||
expect(screen.queryByText('Jorge Solla')).toBeNull()
|
||||
expect(screen.queryByText('Municípios')).toBeNull()
|
||||
expect(screen.getByLabelText('Município em atualização: Cairu')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
42
tests/unit/campaignPageChrome.unit.spec.ts
Normal file
42
tests/unit/campaignPageChrome.unit.spec.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
campaignPageMetadata,
|
||||
campaignPageMetadataFromCatalog,
|
||||
resolveCampaignPageChrome,
|
||||
} from '@/lib/campaignPageChrome'
|
||||
|
||||
describe('campaignPageChrome', () => {
|
||||
it('returns null for home', () => {
|
||||
expect(resolveCampaignPageChrome('/campanha', 'coordinator')).toBeNull()
|
||||
expect(resolveCampaignPageChrome('/campanha/', 'advisor')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves section list chrome', () => {
|
||||
expect(resolveCampaignPageChrome('/campanha/municipios', 'coordinator')).toEqual({
|
||||
title: 'Municípios',
|
||||
subtitle:
|
||||
'Os 435 municípios da campanha: um por município da Bahia — em Salvador, uma zona eleitoral cada.',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves detail pages with section title only', () => {
|
||||
expect(resolveCampaignPageChrome('/campanha/municipios/cairu', 'coordinator')).toEqual({
|
||||
title: 'Municípios',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses role-based subtitle for quadro', () => {
|
||||
expect(resolveCampaignPageChrome('/campanha/quadro', 'advisor')?.subtitle).toContain(
|
||||
'sua assessoria',
|
||||
)
|
||||
expect(resolveCampaignPageChrome('/campanha/quadro', 'coordinator')?.subtitle).toContain(
|
||||
'geral',
|
||||
)
|
||||
})
|
||||
|
||||
it('builds tab metadata from catalog', () => {
|
||||
expect(campaignPageMetadataFromCatalog('contatos').title).toBe('Contatos')
|
||||
expect(campaignPageMetadata(null).title).toBe('Início')
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,7 @@ import { cleanup, render, screen, within } from '@testing-library/react'
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/campanha/acoes/atualizar-votos',
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
|
|
@ -10,6 +11,7 @@ vi.mock('next/navigation', () => ({
|
|||
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
|
||||
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
import { SidebarProvider } from '@/components/ui/Sidebar'
|
||||
import { WIZARD_MUNICIPALITY_STEP_TITLE, wizardFlowChromeAriaLabel } from '@/lib/campaignWizardCopy'
|
||||
|
|
@ -18,10 +20,12 @@ import { stubMatchMedia } from '../helpers/matchMedia'
|
|||
const renderWizardShell = (props: React.ComponentProps<typeof CampaignWizardShell>) =>
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell {...props} />
|
||||
</CampaignWizardChromeProvider>
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell {...props} />
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
|
|
@ -104,35 +108,39 @@ describe('CampaignWizardShell', () => {
|
|||
it('skips title focus when contentFocus is none', () => {
|
||||
const { rerender } = render(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Passo 1"
|
||||
isEntryStep
|
||||
previousHref="/campanha"
|
||||
contentFocus="none"
|
||||
>
|
||||
<input aria-label="Primeiro campo" />
|
||||
</CampaignWizardShell>
|
||||
</CampaignWizardChromeProvider>
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Passo 1"
|
||||
isEntryStep
|
||||
previousHref="/campanha"
|
||||
contentFocus="none"
|
||||
>
|
||||
<input aria-label="Primeiro campo" />
|
||||
</CampaignWizardShell>
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Passo 2"
|
||||
isEntryStep
|
||||
previousHref="/campanha"
|
||||
contentFocus="none"
|
||||
>
|
||||
<input aria-label="Primeiro campo" />
|
||||
</CampaignWizardShell>
|
||||
</CampaignWizardChromeProvider>
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Passo 2"
|
||||
isEntryStep
|
||||
previousHref="/campanha"
|
||||
contentFocus="none"
|
||||
>
|
||||
<input aria-label="Primeiro campo" />
|
||||
</CampaignWizardShell>
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
|
|
@ -144,33 +152,37 @@ describe('CampaignWizardShell', () => {
|
|||
it('moves focus to the step title when stepTitle changes', () => {
|
||||
const { rerender } = render(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Passo 1"
|
||||
isEntryStep
|
||||
previousHref="/campanha"
|
||||
>
|
||||
Corpo
|
||||
</CampaignWizardShell>
|
||||
</CampaignWizardChromeProvider>
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Passo 1"
|
||||
isEntryStep
|
||||
previousHref="/campanha"
|
||||
>
|
||||
Corpo
|
||||
</CampaignWizardShell>
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Passo 2"
|
||||
isEntryStep
|
||||
previousHref="/campanha"
|
||||
>
|
||||
Corpo
|
||||
</CampaignWizardShell>
|
||||
</CampaignWizardChromeProvider>
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Passo 2"
|
||||
isEntryStep
|
||||
previousHref="/campanha"
|
||||
>
|
||||
Corpo
|
||||
</CampaignWizardShell>
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const routerState = vi.hoisted(() => ({
|
|||
|
||||
vi.mock('next/navigation', async (importActual) => ({
|
||||
...(await importActual()),
|
||||
usePathname: () => '/campanha/acoes/atualizar-votos',
|
||||
useRouter: () => ({
|
||||
push: (...args: unknown[]) => routerState.push(...args),
|
||||
replace: vi.fn(),
|
||||
|
|
@ -18,6 +19,7 @@ vi.mock('next/navigation', async (importActual) => ({
|
|||
import { CampaignListPendingBoundary } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
|
||||
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
import { SidebarProvider } from '@/components/ui/Sidebar'
|
||||
import { WIZARD_STEP_PENDING_MESSAGE } from '@/lib/campaignWizardCopy'
|
||||
|
|
@ -26,21 +28,23 @@ import { stubMatchMedia } from '../helpers/matchMedia'
|
|||
const mountWizardNavigation = () =>
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Quantos votos?"
|
||||
isEntryStep={false}
|
||||
previousHref="/campanha/acoes/atualizar-votos"
|
||||
dismissHref="/campanha"
|
||||
municipalityLabel="Cairu"
|
||||
>
|
||||
Corpo do passo
|
||||
</CampaignWizardShell>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignWizardChromeProvider>
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Quantos votos?"
|
||||
isEntryStep={false}
|
||||
previousHref="/campanha/acoes/atualizar-votos"
|
||||
dismissHref="/campanha"
|
||||
municipalityLabel="Cairu"
|
||||
>
|
||||
Corpo do passo
|
||||
</CampaignWizardShell>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue