Polish campaign login auth UI after Impeccable critique.

Fix header alignment and rhythm, swap Megaphone for CampaignLogo, add progressive disclosure for first access, and extract shared auth shells with corrected individual-account copy; record closed critique items in RS+.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 19:24:12 -03:00
parent 9e73b676db
commit f5140b15a2
11 changed files with 252 additions and 111 deletions

View file

@ -0,0 +1,91 @@
---
target: /campanha/login
total_score: 26
p0_count: 0
p1_count: 2
timestamp: 2026-07-19T21-47-04Z
slug: src-app-campaign-campanha-login
---
# Critique: /campanha/login
Method: dual-agent (A: 8a8b1ae0-9a8a-45f7-907f-54afcdf62db8 · B: 3899acad-4f36-4817-91d6-4135d72d2e96)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Pending state clear; no other status chrome |
| 2 | Match System / Real World | 3 | PT-BR + e-mail/celular fit; megaphone mark generic |
| 3 | User Control and Freedom | 2 | Only “Esqueceu a senha?”; no exit to public site |
| 4 | Consistency and Standards | 2 | h1 left vs centered muted copy; leading clash |
| 5 | Error Prevention | 2 | required + inputMode help; dual identifier still easy to mistype |
| 6 | Recognition Rather Than Recall | 3 | Labels/placeholder clear |
| 7 | Flexibility and Efficiency | 3 | E-mail or phone + adaptive inputMode |
| 8 | Aesthetic and Minimalist Design | 2 | Header rhythm/alignment + always-on help prose |
| 9 | Error Recovery | 3 | FieldError + leadership recovery hint |
| 10 | Help and Documentation | 3 | Help exists; always-visible first-access competes |
| **Total** | | **26/40** | **Acceptable** |
## Anti-Patterns Verdict
**LLM:** Low–moderate slop. Not purple-SaaS; stock shadcn auth shell. Dual muted blurbs feel like copy padding. Typesetting bugs read unfinished.
**Detector CLI:** exit 0, 0 source findings on login + CampaignAuthPageShell.
**Runtime (Playwright inject):** `flat-type-hierarchy` (body; sizes 14/16/20px); `nested-cards` on CardHeader — likely false positive (shadcn Card > CardHeader).
## Overall Impression
Competent Field Desk palette discipline, phone-first form craft — undermined by a campaign-theme `h1 { text-align: left }` leak and article-style `p { leading-7 }` on auth copy. Biggest opportunity: fix header typesetting; do **not** add split-screen public posts.
## What's Working
1. Phone-first: min-h-11, enterKeyHint, identifierInputMode
2. Red as signal ≤10%
3. Auth-error recovery hint for liderança phone-only accounts
## Priority Issues
### P1 — Title misaligned with subtitle
Campaign `[data-theme='campaign'] h1 { text-align: left }` overrides CardHeader `text-center`. Title glyph start ~28px left of centered subtitle.
**Fix:** Scope left-align to app chrome, or `text-center` on auth heading.
**Command:** `/impeccable typeset` or `/impeccable layout`
### P1 — Subtitle vertical rhythm inverted
CardHeader `gap-1` (4px) between title and description; orphan `<p>` inherits base `leading-7` (28px) while CardDescription stays ~20px. Title glued to howto; second muted block breathes like article body.
**Fix:** One muted block with explicit leading-snug; bump title→desc gap; never bare `<p>` under campaign theme without leading override.
**Command:** `/impeccable layout` + `/impeccable typeset`
### P2 — Split-screen + public publications: recommend No
Login is ops gate (noindex); posts are public register. Phone users get scroll soup. Motivation ≠ news feed on unlock.
**Soft maybe:** one quiet purpose line under mark — not a publication panel.
**Command:** `/impeccable shape` only if product insists on panel (internal voice, not posts)
### P2 — Generic Megaphone mark
Not Solla / Field Desk identity.
**Command:** `/impeccable polish` (reuse campaign-logo)
### P3 — Always-on first-access paragraph
Progressive disclosure for returning users.
**Command:** `/impeccable distill` or `/impeccable clarify`
## Persona Red Flags
**Casey:** Misalignment reads broken; two gray blurbs delay thumb-to-input; split-screen would be fatal.
**Jordan:** Copy helps; visual hierarchy undercuts trust; no public-site escape.
**Lia:** Input path strong; header bugs tax one-handed use; keep error recovery; reject post panel.
**Sam:** Muted contrast scrapes AA (~4.83:1); labels/ARIA look solid.
## Minor Observations
- CardHeader grid-rows assumes 2 rows; third orphan `<p>` is API misuse
- Desktop narrow column is correct for this task
- Placeholder dual-format is heavy
## Questions to Consider
1. If the desk is the product, why does the door still look like every shadcn login?
2. Is “inspiring” one quiet line under the mark — or only after auth?
3. Would merging the two muted sentences fix more trust than any split-screen?
4. Who unlocks at 7am in a núcleo — why would they want a publication?
5. Why does auth inherit dashboard `h1` left-align rules?

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-reset de senha + foto de perfil
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19 (Fase 3 parcial entregue em `main` @ `246ca85`; absorção `capture-review-debts`)
Atualizado em: 2026-07-19 (Fase 3 parcial: polish `/campanha/login` + simplify; absorção `capture-review-debts` 2026-07-19)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (RS+, fill-in de engenharia pós-MVP)
Responsável: —
@ -11,13 +11,15 @@ O fill-in **Reset de senha self-service + foto de perfil** ([reset-senha-foto-pe
**Já resolvido no simplify (não reabrir):** `CampaignFormActionState` compartilhado; constantes de copy (`CAMPAIGN_PASSWORD_RESET_*`, `CAMPAIGN_LEADERSHIP_FORGOT_PASSWORD_MESSAGE`, `CAMPAIGN_SESSION_EXPIRED_MESSAGE`); `campaignUserShellView` / `CampaignUserShellView`; `canUpdateCampaignUserAvatar` delegando para `canUpdateCampaignUser`; `fieldError` nos forms; `FormActionStatus` no perfil; remoção de wrappers `passwordFormError`/`profileFormError`; exports internos de `resetCampaignPassword`/`changeCampaignPassword`; teste int renomeado para login com senha errada.
**Já resolvido na Fase 3 parcial (merge `main` 2026-07-19, `246ca85` — não reabrir):** `CampaignAuthPageShell` em `login` / `esqueci-senha` / `redefinir-senha` (incl. card de token inválido); `campaignAuthHeadingClassName` + copy compartilhada (`CAMPAIGN_LEADERSHIP_LOGIN_RECOVERY_HINT`, `CAMPAIGN_LEADERSHIP_PHONE_ACCESS_HINT`); login com `inputMode` dinâmico tel/email, `aria-invalid`/`aria-describedby` nos dois campos, copy de primeiro acesso, `w-full` no submit — fecha P1–P3 do critique Impeccable `/campanha/login` da sessão.
**Já resolvido na Fase 3 parcial (merge `main` 2026-07-19, `246ca85` — não reabrir):** `CampaignAuthPageShell` em `login` / `esqueci-senha` / `redefinir-senha` (incl. card de token inválido); `campaignAuthHeadingClassName` + copy compartilhada (`CAMPAIGN_LEADERSHIP_LOGIN_RECOVERY_HINT`, `CAMPAIGN_LEADERSHIP_PHONE_ACCESS_HINT`); login com `inputMode` dinâmico tel/email, `aria-invalid`/`aria-describedby` nos dois campos, `w-full` no submit.
**Já resolvido no polish + `/simplify` de `/campanha/login` (2026-07-19 — não reabrir):** critique Impeccable P1–P3 fechados (alinhamento título/subtítulo `!text-center`, ritmo `gap-2` + `leading-snug`, `CampaignLogo` no shell, `<details>` para primeiro acesso); `CampaignAuthCardHeader`, `CampaignAuthBackToLoginLink`; tokens `campaignAuthDescriptionClassName`, `campaignAuthMutedTextClassName`, `campaignAuthTextLinkClassName`; `CAMPAIGN_LOGIN_SUBTITLE` (conta individual); `fieldError` sem dupla chamada em forgot/reset. Snapshot: [`.impeccable/critique/2026-07-19T21-47-04Z__src-app-campaign-campanha-login.md`](../../.impeccable/critique/2026-07-19T21-47-04Z__src-app-campaign-campanha-login.md).
## Objetivos
- Auth hot path sem join de `media` em rotas que não exibem avatar (núcleos, apoiadores, planos, etc.).
- Upload de avatar sem segurar transação Postgres aberta durante I/O de blob/sharp.
- Shell compartilhado das páginas públicas de auth (`login`, `esqueci-senha`, `redefinir-senha`) — **parcial ✓** (`CampaignAuthPageShell` + headings/copy; ver Fase 3).
- Shell compartilhado das páginas públicas de auth (`login`, `esqueci-senha`, `redefinir-senha`) — **parcial ✓** (shell + card header + logo + critique login fechados; pendem password fields + login state — Fase 3).
- Um único módulo de validação/campos de senha reutilizado por convite, reset e perfil.
- Login alinhado ao shape `CampaignFormActionState` + `mapCampaignFormActionError` das demais actions de campanha.
- Guardrails: sem novo `Consent`; sem SMS/WhatsApp para token; comportamento de produto 1C inalterado salvo bugs encontrados.
@ -44,7 +46,7 @@ flowchart TD
RS["Reset senha + perfil MVP ✓"] --> F1
F1["Fase 1 — Auth read perf<br/>depth 0 + getCampaignUserWithAvatar<br/>sessão leve em rotas públicas"]
F1 --> F2["Fase 2 — Avatar write perf<br/>blob fora da TX + revalidate estreito"]
F2 --> F3["Fase 3 — DRY auth UI/senha<br/>shell ✓ parcial · password fields + login state"]
F2 --> F3["Fase 3 — DRY auth UI/senha<br/>shell/card ✓ parcial · password fields + login state"]
F3 --> F4["Fase 4 — DRY copy/role + actions<br/>campaignRoleOptions + getCampaignActionContext"]
F4 --> F5["Fase 5 — Opcional<br/>avatar intent único + int change password"]
```
@ -64,7 +66,7 @@ flowchart TD
### Fase 3 — DRY UI e senha
**Entregue parcialmente (2026-07-19):** `CampaignAuthPageShell` + headings/copy unificados nas três rotas públicas e no card de token inválido; polish de login do critique Impeccable (ver Contexto).
**Entregue parcialmente (2026-07-19):** `CampaignAuthPageShell`, `CampaignAuthCardHeader`, `CampaignAuthBackToLoginLink`, tokens de copy/classes em `campaignAuthCopy.ts`, `CampaignLogo` no shell, polish Impeccable `/campanha/login` (ver Contexto).
**Pendente nesta fase:**
@ -74,6 +76,7 @@ flowchart TD
### Fase 4 — DRY copy, role e action context
- Consolidar strings de liderança/convite (`CAMPAIGN_LEADERSHIP_*`, `CAMPAIGN_FIRST_ACCESS_HINT`, `CAMPAIGN_LOGIN_SUBTITLE` + mensagem server em `campaignPasswordReset.ts`) numa fonte única com variantes por contexto.
- **`campaignRoleLabels`** ↔ opções do campo `role` em `CampaignUser.ts` (fonte única `campaignRoleOptions`).
- Propagar para **`NucleusUpdateFeed.tsx`** e outros `roleLabels` locais quando esses arquivos forem tocados.
- **`password.ts` / `profile.ts`:** adotar `getCampaignActionContext()` (`src/utilities/campaignActionContext.ts`) como `supporter.ts` / `actionPlan.ts`.
@ -98,13 +101,21 @@ flowchart TD
- RBAC em `users` (admin Payload) → [roadmap](../roadmap.md) Admin Payload.
- Unificar **todo** o ciclo 1 de auth (convite + login + admin) num único mega-refactor — RS+ cobre só o fill-in entregue e vizinhos diretos.
## Explicitamente fora (critique/simplify desta sessão — não reabrir)
- Split-screen login com publicações ou painel motivacional (decisão produto 2026-07-19; critique recomendou não).
- Link de saída para o site público na tela de login (heurística 3, score 2).
- Placeholder longo dual-format no identificador; submit helper global (`Spinner` + `aria-live`); constantes de rota auth; `flat-type-hierarchy` do detector runtime no body.
- Comentário em `styles.css` para auth — workaround com tokens/componentes já suficiente.
## Referências
- [reset-senha-foto-perfil.md](reset-senha-foto-perfil.md) — MVP entregue
- [escala-dry-pos-visitados-recentemente.md](escala-dry-pos-visitados-recentemente.md) — precedente RS+/VR+
- `src/utilities/campaignAuth.ts`, `src/app/(campaign)/campanha/actions/password.ts`, `profile.ts`
- `src/components/campaign/CampaignProfileSettings.tsx`, `CampaignSidebar.tsx`, `CampaignUserAvatar.tsx`
- `src/components/campaign/CampaignAuthPageShell.tsx` — shell entregue; ver Fase 3 pendente
- `src/components/campaign/CampaignAuthPageShell.tsx`, `CampaignAuthCardHeader.tsx`, `CampaignAuthBackToLoginLink.tsx`
- `src/lib/campaignAuthCopy.ts`
- `src/app/(campaign)/campanha/login/`, `esqueci-senha/`, `redefinir-senha/`
- `src/lib/schemas/campaignPassword.ts`, `src/lib/schemas/invite.ts`
- `src/collections/CampaignUser.ts`

View file

@ -110,7 +110,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| A3/A4 Baseline TSE 2022 + Gap vs 2022 | `Baseline-Eleitoral-2022` (+ bloco "Baseline 2022" em `Lista-Nucleos-Overview`) | [baseline-eleitoral-tse.md](plans/baseline-eleitoral-tse.md) |
| A5 Insights (5 planos) | `Baseline-Eleitoral-2022` (card "Insights do território", uma linha por insight) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) (Fase 1 conversão da base + Fase 2 virada: vencedor dir. **ou** share dir. alto) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) |
| A7 Escala e DRY pós-A4 | — (sem design novo; reusa baseline A4 + Alert/Progress) | [escala-dry-pos-a4.md](plans/escala-dry-pos-a4.md) |
| B6 Escala e DRY pós-B3 | — (sem design novo; reusa mapa B3) | [escala-dry-pos-b3.md](plans/escala-dry-pos-b3.md) |
| B6 Escala e DRY pós-B3 | — (sem design novo; reusa mapa B3) | [escala-dry-pos-b3.md](plans/escala-dry-pos-b3.md) |
| A8 Perfis do eleitorado (IBGE + manuais) | — (sem design nesta leva; reusa aba Eleitorado + `NucleusIntelligenceDialog`) | [perfil-eleitorado-ibge.md](plans/perfil-eleitorado-ibge.md) |
| C2 Cadastro nominal de apoiadores | `Apoiadores-Lista`, `Apoiador-Ficha`, `Importar-CSV-Apoiadores` | [cadastro-nominal-apoiadores.md](plans/cadastro-nominal-apoiadores.md) |
| C6 Escala e DRY pós-C2 | — (sem design novo; reusa os de C2 + padrões de núcleos) | [escala-dry-pos-c2.md](plans/escala-dry-pos-c2.md) |
@ -122,12 +122,12 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| C11 Escala e DRY pós-C7 | — (sem design novo; reusa os de C3 + padrões de núcleos/C6/C9) | [escala-dry-pos-c7.md](plans/escala-dry-pos-c7.md) |
| O0+ Escala e DRY pós-Onda 0 | — (sem design novo; reusa `/privacidade` + padrão revalidate `posts`) | [escala-dry-pos-onda0.md](plans/escala-dry-pos-onda0.md) |
| VR+ Escala e DRY pós-visitados | — (sem design novo; reusa dashboard + padrões de lista de núcleos) | [escala-dry-pos-visitados-recentemente.md](plans/escala-dry-pos-visitados-recentemente.md) |
| RS+ Escala e DRY pós-reset senha/perfil | — (sem design novo; reusa login + perfil + padrões de form de campanha) | [escala-dry-pos-reset-senha-perfil.md](plans/escala-dry-pos-reset-senha-perfil.md) |
| FD+ Escala e DRY pós-Field Desk | — (sem design novo; reusa shells/strips Field Desk) | [escala-dry-pos-field-desk.md](plans/escala-dry-pos-field-desk.md) |
| FD2 UX Field Desk pós-critique | — (critique Impeccable; reusa dashboard + badges) | [field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md) |
| RS+ Escala e DRY pós-reset senha/perfil | — (sem design novo; reusa login + perfil + padrões de form de campanha) | [escala-dry-pos-reset-senha-perfil.md](plans/escala-dry-pos-reset-senha-perfil.md) |
| FD+ Escala e DRY pós-Field Desk | — (sem design novo; reusa shells/strips Field Desk) | [escala-dry-pos-field-desk.md](plans/escala-dry-pos-field-desk.md) |
| FD2 UX Field Desk pós-critique | — (critique Impeccable; reusa dashboard + badges) | [field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md) |
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
| E6 Escala e DRY pós-E1+E3 | — (sem design novo; reusa Metas 2026 + overview B1) | [escala-dry-pos-e1.md](plans/escala-dry-pos-e1.md) |
| E7 Escala e DRY pós-E2 | — (sem design novo; reusa Baseline + Insights + overview tendência/conversão) | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) (Fase 2 ✓; Fases 1, 3, 4 pendentes) |
| E7 Escala e DRY pós-E2 | — (sem design novo; reusa Baseline + Insights + overview tendência/conversão) | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) (Fase 2 ✓; Fases 1, 3, 4 pendentes) |
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
| D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) |
| C5 Operação dia D / GOTV | `Dia-D-GOTV` | sem plano ainda — o design é a primeira materialização do item (check-off de apoiadores por seção/zona, progresso "62% da base", filtro "Ainda não confirmados", estado "Sincronizado" offline-tolerante) |
@ -266,36 +266,36 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (ap
**Janela 2 — 05/08 → 16/08 (pré-propaganda): base nominal + inteligência + agenda prontas para o arranque.**
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ---------------------------------- | ------------------------------------- |
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta e mesclada em `main` 2026-07-18 — deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6, C7 |
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–5 + simplify)** | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7, C8 |
| 9 | A4 Baseline no produto + insight Gap vs 2022 **(implementado e mesclado em `main` 2026-07-18 — Fases 2–4 + simplify)** | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 |
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7, C8 |
| 11 | C7 Escala e DRY pós-C3 **(entregue 2026-07-19 — Fases 1–5; feed O(n) condicional fora de escopo)** | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8, E1+E3, E2 |
| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7, E1+E3, E2, C9 |
| 13 | C9 Escala e DRY pós-C8 (filtro unificado Payload↔SQL, `contactSearchQuery` no aggregate, loader único `/apoiadores`, forms restantes) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c8.md) | C8 (merge) | A4, C3, C7, E1+E3, E2, C10 |
| 14 | C10 Escala e DRY pós-C9 (dedup access lista, scope único de núcleos do coordenador, `errorProps` nos forms) _(cortável se a base nominal permanecer pequena; Fase 3 DRY pode entrar isolada)_ | [detalhes](plans/escala-dry-pos-c9.md) | C9 (merge) | A4, C3, C7, E1+E3, E2, C11 |
| 15 | C11 Escala e DRY pós-C7 (feed O(n) condicional, loaders do detalhe, selects por aba, hot paths mutação/form, utilities de busca) _(cortável se a agenda permanecer pequena; Fase 1 migration só com volume medido)_ | [detalhes](plans/escala-dry-pos-c7.md) | C7 (merge) | A4, C3, C10, E1+E3, E2 |
| 16 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais **(implementado e mesclado em `main` 2026-07-19 — migration `20260719_054522`; `/simplify` → E6)** | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
| 17 | E6 Escala e DRY pós-E1+E3 (aggregate SQL metas/prioridade, geografia baseline, DRY formatação) _(cortável se poucos núcleos ativos)_ | [detalhes](plans/escala-dry-pos-e1.md) | E1+E3 ✓ | E2, C10, A7, E4 |
| 18 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) **(implementado 2026-07-19; `/simplify` → E7)** | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, E6, E7, C7, C8, C9, C10, C11 |
| 19 | E7 Escala e DRY pós-E2 (trend VM único no detalhe, int `loadNucleusListElectionOverview`, tipo `VoteTrendDistribution`, helpers wipe eleitoral nos int tests) _(Fase 2 int entregue 2026-07-19; Fases 1, 3 e 4 pendentes; F4 cortável — só DX de teste)_ | [detalhes](plans/escala-dry-pos-e2.md) | E2 (merge) | E6, A7, E4, C10 |
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ---------------------------------- | ----------------------------------- |
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta e mesclada em `main` 2026-07-18 — deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6, C7 |
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–5 + simplify)** | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7, C8 |
| 9 | A4 Baseline no produto + insight Gap vs 2022 **(implementado e mesclado em `main` 2026-07-18 — Fases 2–4 + simplify)** | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 |
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7, C8 |
| 11 | C7 Escala e DRY pós-C3 **(entregue 2026-07-19 — Fases 1–5; feed O(n) condicional fora de escopo)** | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8, E1+E3, E2 |
| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7, E1+E3, E2, C9 |
| 13 | C9 Escala e DRY pós-C8 (filtro unificado Payload↔SQL, `contactSearchQuery` no aggregate, loader único `/apoiadores`, forms restantes) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c8.md) | C8 (merge) | A4, C3, C7, E1+E3, E2, C10 |
| 14 | C10 Escala e DRY pós-C9 (dedup access lista, scope único de núcleos do coordenador, `errorProps` nos forms) _(cortável se a base nominal permanecer pequena; Fase 3 DRY pode entrar isolada)_ | [detalhes](plans/escala-dry-pos-c9.md) | C9 (merge) | A4, C3, C7, E1+E3, E2, C11 |
| 15 | C11 Escala e DRY pós-C7 (feed O(n) condicional, loaders do detalhe, selects por aba, hot paths mutação/form, utilities de busca) _(cortável se a agenda permanecer pequena; Fase 1 migration só com volume medido)_ | [detalhes](plans/escala-dry-pos-c7.md) | C7 (merge) | A4, C3, C10, E1+E3, E2 |
| 16 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais **(implementado e mesclado em `main` 2026-07-19 — migration `20260719_054522`; `/simplify` → E6)** | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
| 17 | E6 Escala e DRY pós-E1+E3 (aggregate SQL metas/prioridade, geografia baseline, DRY formatação) _(cortável se poucos núcleos ativos)_ | [detalhes](plans/escala-dry-pos-e1.md) | E1+E3 ✓ | E2, C10, A7, E4 |
| 18 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) **(implementado 2026-07-19; `/simplify` → E7)** | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, E6, E7, C7, C8, C9, C10, C11 |
| 19 | E7 Escala e DRY pós-E2 (trend VM único no detalhe, int `loadNucleusListElectionOverview`, tipo `VoteTrendDistribution`, helpers wipe eleitoral nos int tests) _(Fase 2 int entregue 2026-07-19; Fases 1, 3 e 4 pendentes; F4 cortável — só DX de teste)_ | [detalhes](plans/escala-dry-pos-e2.md) | E2 (merge) | E6, A7, E4, C10 |
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------- |
| 18 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 ✓ + E3 ✓ (suave: E2 ✓ — tendência visível na revisão; E6 F1 — perf da lista) | A5, A7, A8, B3, B5, C4, D2 |
| 19 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto; alavancagem inclui Fase 2 virada por vencedor **ou** share da direita) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, A8, B3, B5, C4, E4, D1 |
| 20 | A8 Perfis do eleitorado — perfil médio IBGE (Censo) + manuais na aba Eleitorado _(cortável: manuais já existem)_ | [detalhes](plans/perfil-eleitorado-ibge.md) | A1 ✓ (suave: B2 ✓ — `codarea`) | A5, A7, B3, B5, C4, E4, D1 |
| 21 | A7 Escala e DRY pós-A4 (`cityCode` TSE, Alert `confirmed` + helpers gap + `Progress`, fetch único 2022 lista+coroplético, batch federal flip/leverage na lista) _(Fase 1 agregar federal entregue 2026-07-19; Fases 2–5 pendentes; F4 pós-B3; F5 pós-A5 via `capture-review-debts`)_ | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, A8, B3, B5, B6, C4, E4, D1 |
| 22 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, A8, B5, B6, C4, E4, D1 |
| 23 | B5 Escala e DRY pós-B2 (cache CLI + factory mun/TI; F1 lazy ✓) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 entregue com B3) | A5, A7, A8, B3, B6, C4, E4, D1 |
| 24 | B6 Escala e DRY pós-B3 (`BahiaMap` setStyle incremental) _(cortável se troca de métrica for rara)_ | [detalhes](plans/escala-dry-pos-b3.md) | B3 (suave: A7 F4 na lista) | A5, A7, A8, B5, C4, E4, D1 |
| 25 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, A8, B3, B5, B6, E4, D1 |
| 26 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
| 27 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, A7, A8, B3, B5, B6, C4, E4 |
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------ |
| 18 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 ✓ + E3 ✓ (suave: E2 ✓ — tendência visível na revisão; E6 F1 — perf da lista) | A5, A7, A8, B3, B5, C4, D2 |
| 19 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto; alavancagem inclui Fase 2 virada por vencedor **ou** share da direita) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, A8, B3, B5, C4, E4, D1 |
| 20 | A8 Perfis do eleitorado — perfil médio IBGE (Censo) + manuais na aba Eleitorado _(cortável: manuais já existem)_ | [detalhes](plans/perfil-eleitorado-ibge.md) | A1 ✓ (suave: B2 ✓ — `codarea`) | A5, A7, B3, B5, C4, E4, D1 |
| 21 | A7 Escala e DRY pós-A4 (`cityCode` TSE, Alert `confirmed` + helpers gap + `Progress`, fetch único 2022 lista+coroplético, batch federal flip/leverage na lista) _(Fase 1 agregar federal entregue 2026-07-19; Fases 2–5 pendentes; F4 pós-B3; F5 pós-A5 via `capture-review-debts`)_ | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, A8, B3, B5, B6, C4, E4, D1 |
| 22 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, A8, B5, B6, C4, E4, D1 |
| 23 | B5 Escala e DRY pós-B2 (cache CLI + factory mun/TI; F1 lazy ✓) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 entregue com B3) | A5, A7, A8, B3, B6, C4, E4, D1 |
| 24 | B6 Escala e DRY pós-B3 (`BahiaMap` setStyle incremental) _(cortável se troca de métrica for rara)_ | [detalhes](plans/escala-dry-pos-b3.md) | B3 (suave: A7 F4 na lista) | A5, A7, A8, B5, C4, E4, D1 |
| 25 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, A8, B3, B5, B6, E4, D1 |
| 26 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
| 27 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, A7, A8, B3, B5, B6, C4, E4 |
**Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.**
@ -312,7 +312,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (ap
- **Visitados recentemente** ✓ (client-side, `localStorage`; MVP entregue 2026-07-19). → [detalhes](plans/visitados-recentemente.md)
- **VR+ Escala e DRY pós-visitados recentemente** (refresh bfcache/storage, DRY tempo relativo, `CampaignLinkListRow`, shell do dashboard) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-visitados-recentemente.md)
- **Reset de senha self-service + foto de perfil** ✓ (Resend + `/campanha/perfil`; MVP entregue 2026-07-19). → [detalhes](plans/reset-senha-foto-perfil.md)
- **RS+ Escala e DRY pós-reset senha/perfil** (auth read `depth: 0`, avatar fora da TX, **`CampaignAuthPageShell` ✓ parcial**, schemas de senha unificados, login em `CampaignFormActionState`) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-reset-senha-perfil.md)
- **RS+ Escala e DRY pós-reset senha/perfil** (auth read `depth: 0`, avatar fora da TX, **auth UI parcial ✓** — shell/card/logo + critique login fechados; pendem `CampaignPasswordFields` + login em `CampaignFormActionState`) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-reset-senha-perfil.md)
- **Field Desk polish** (Impeccable quieter→polish; critique 28→32) — shells/strips/freshness/filas-first/Mais filtros/empties; em branch local 2026-07-19. → `PRODUCT.md` / `DESIGN.md`
- **FD+ Escala e DRY pós-Field Desk** (`CampaignPageShell` size, heading class, VoteGoals→MetricStrip, datas canônicas, NucleusFilters bag) — engenharia pós-polish; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-field-desk.md)
- **FD2 UX Field Desk pós-critique** (bulk na fila Sem coordenador, glossário inline, outline h2 Filas, empty coordenador+CTA, Sidebar motion cortável) — produto/UX pós-critique; _(validar prioridades com produto)_. → [detalhes](plans/field-desk-ux-pos-critique.md)

View file

@ -1,17 +1,18 @@
'use client'
import Link from 'next/link'
import { useActionState } from 'react'
import { requestCampaignPasswordResetFormAction } from '@/app/(campaign)/campanha/actions/password'
import { CampaignAuthBackToLoginLink } from '@/components/campaign/CampaignAuthBackToLoginLink'
import { CampaignAuthCardHeader } from '@/components/campaign/CampaignAuthCardHeader'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import {
CAMPAIGN_LEADERSHIP_PHONE_ACCESS_HINT,
campaignAuthHeadingClassName,
campaignAuthMutedTextClassName,
} from '@/lib/campaignAuthCopy'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
@ -21,20 +22,21 @@ export const ForgotPasswordForm = () => {
requestCampaignPasswordResetFormAction,
{} satisfies CampaignFormActionState,
)
const emailError = fieldError(state.fieldErrors, 'email')
return (
<Card>
<CardHeader className="text-center">
<h1 className={campaignAuthHeadingClassName}>Esqueceu a senha?</h1>
<CardDescription>
Informe o e-mail cadastrado na sua conta. {CAMPAIGN_LEADERSHIP_PHONE_ACCESS_HINT}
</CardDescription>
</CardHeader>
<CampaignAuthCardHeader
title="Esqueceu a senha?"
description={
<>Informe o e-mail cadastrado na sua conta. {CAMPAIGN_LEADERSHIP_PHONE_ACCESS_HINT}</>
}
/>
<CardContent>
{state.status === 'success' && state.message ? (
<p className="text-sm text-muted-foreground" role="status">
<div className={campaignAuthMutedTextClassName} role="status">
{state.message}
</p>
</div>
) : (
<form action={formAction}>
<FieldGroup>
@ -51,21 +53,12 @@ export const ForgotPasswordForm = () => {
/>
</Field>
{state.message ? <FieldError>{state.message}</FieldError> : null}
{fieldError(state.fieldErrors, 'email') ? (
<FieldError>{fieldError(state.fieldErrors, 'email')}</FieldError>
) : null}
{emailError ? <FieldError>{emailError}</FieldError> : null}
<Button type="submit" className="min-h-11 w-full" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
<span aria-live="polite">{pending ? 'Enviando...' : 'Enviar link'}</span>
</Button>
<p className="text-center text-sm">
<Link
href="/campanha/login"
className="text-primary underline-offset-4 hover:underline"
>
Voltar ao login
</Link>
</p>
<CampaignAuthBackToLoginLink />
</FieldGroup>
</form>
)}

View file

@ -4,15 +4,20 @@ import Link from 'next/link'
import { useActionState, useState } from 'react'
import { loginCampaignFormAction, type LoginResult } from '@/app/(campaign)/campanha/actions/auth'
import { CampaignAuthCardHeader } from '@/components/campaign/CampaignAuthCardHeader'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import {
CAMPAIGN_FIRST_ACCESS_HINT,
CAMPAIGN_LEADERSHIP_LOGIN_RECOVERY_HINT,
campaignAuthHeadingClassName,
CAMPAIGN_LOGIN_SUBTITLE,
campaignAuthMutedTextClassName,
campaignAuthTextLinkClassName,
} from '@/lib/campaignAuthCopy'
import { cn } from '@/lib/utils'
const LOGIN_ERROR_ID = 'login-credentials-error'
@ -35,13 +40,7 @@ export const LoginForm = () => {
return (
<Card>
<CardHeader className="text-center">
<h1 className={campaignAuthHeadingClassName}>Entrar na campanha</h1>
<CardDescription>Use o e-mail da equipe ou o celular da liderança.</CardDescription>
<p className="text-sm text-muted-foreground text-pretty">
Primeiro acesso? Peça um convite no WhatsApp ao coordenador do seu núcleo.
</p>
</CardHeader>
<CampaignAuthCardHeader title="Entrar na campanha" description={CAMPAIGN_LOGIN_SUBTITLE} />
<CardContent>
<form action={formAction}>
<FieldGroup>
@ -78,16 +77,13 @@ export const LoginForm = () => {
/>
</Field>
<div className="flex flex-col gap-2 text-sm">
<Link
href="/campanha/esqueci-senha"
className="text-primary underline-offset-4 hover:underline"
>
<Link href="/campanha/esqueci-senha" className={campaignAuthTextLinkClassName}>
Esqueceu a senha?
</Link>
{hasAuthError ? (
<p className="text-muted-foreground text-pretty">
<div className={campaignAuthMutedTextClassName}>
{CAMPAIGN_LEADERSHIP_LOGIN_RECOVERY_HINT}
</p>
</div>
) : null}
</div>
<Field>
@ -97,6 +93,19 @@ export const LoginForm = () => {
<span aria-live="polite">{pending ? 'Entrando...' : 'Entrar'}</span>
</Button>
</Field>
<details className="text-center text-sm">
<summary
className={cn(
'cursor-pointer underline-offset-4 marker:content-none hover:underline [&::-webkit-details-marker]:hidden',
'text-muted-foreground hover:text-foreground',
)}
>
Primeiro acesso?
</summary>
<div className={cn(campaignAuthMutedTextClassName, 'mt-2')}>
{CAMPAIGN_FIRST_ACCESS_HINT}
</div>
</details>
</FieldGroup>
</form>
</CardContent>

View file

@ -1,15 +1,15 @@
'use client'
import Link from 'next/link'
import { useActionState } from 'react'
import { resetCampaignPasswordFormAction } from '@/app/(campaign)/campanha/actions/password'
import { CampaignAuthBackToLoginLink } from '@/components/campaign/CampaignAuthBackToLoginLink'
import { CampaignAuthCardHeader } from '@/components/campaign/CampaignAuthCardHeader'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { campaignAuthHeadingClassName } from '@/lib/campaignAuthCopy'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
@ -18,13 +18,15 @@ export const ResetPasswordForm = ({ token }: { token: string }) => {
resetCampaignPasswordFormAction.bind(null, token),
{} satisfies CampaignFormActionState,
)
const passwordError = fieldError(state.fieldErrors, 'password')
const passwordConfirmationError = fieldError(state.fieldErrors, 'passwordConfirmation')
return (
<Card>
<CardHeader className="text-center">
<h1 className={campaignAuthHeadingClassName}>Redefinir senha</h1>
<CardDescription>Escolha uma nova senha com pelo menos 8 caracteres.</CardDescription>
</CardHeader>
<CampaignAuthCardHeader
title="Redefinir senha"
description="Escolha uma nova senha com pelo menos 8 caracteres."
/>
<CardContent>
<form action={formAction}>
<FieldGroup>
@ -53,24 +55,15 @@ export const ResetPasswordForm = ({ token }: { token: string }) => {
/>
</Field>
{state.message ? <FieldError>{state.message}</FieldError> : null}
{fieldError(state.fieldErrors, 'password') ? (
<FieldError>{fieldError(state.fieldErrors, 'password')}</FieldError>
) : null}
{fieldError(state.fieldErrors, 'passwordConfirmation') ? (
<FieldError>{fieldError(state.fieldErrors, 'passwordConfirmation')}</FieldError>
{passwordError ? <FieldError>{passwordError}</FieldError> : null}
{passwordConfirmationError ? (
<FieldError>{passwordConfirmationError}</FieldError>
) : null}
<Button type="submit" className="min-h-11 w-full" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
<span aria-live="polite">{pending ? 'Salvando...' : 'Salvar nova senha'}</span>
</Button>
<p className="text-center text-sm">
<Link
href="/campanha/login"
className="text-primary underline-offset-4 hover:underline"
>
Voltar ao login
</Link>
</p>
<CampaignAuthBackToLoginLink />
</FieldGroup>
</form>
</CardContent>

View file

@ -3,10 +3,10 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { ResetPasswordForm } from '@/app/(campaign)/campanha/redefinir-senha/ResetPasswordForm'
import { CampaignAuthCardHeader } from '@/components/campaign/CampaignAuthCardHeader'
import { CampaignAuthPageShell } from '@/components/campaign/CampaignAuthPageShell'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'
import { campaignAuthHeadingClassName } from '@/lib/campaignAuthCopy'
import { Card, CardContent } from '@/components/ui/card'
import { CAMPAIGN_PASSWORD_RESET_TOKEN_MIN_LENGTH } from '@/lib/schemas/campaignPassword'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_PASSWORD_RESET_INVALID_TOKEN_MESSAGE } from '@/utilities/campaignPasswordReset'
@ -37,10 +37,10 @@ export default async function CampaignResetPasswordPage({
return (
<CampaignAuthPageShell>
<Card>
<CardHeader className="text-center">
<h1 className={campaignAuthHeadingClassName}>Link inválido</h1>
<CardDescription>{CAMPAIGN_PASSWORD_RESET_INVALID_TOKEN_MESSAGE}</CardDescription>
</CardHeader>
<CampaignAuthCardHeader
title="Link inválido"
description={CAMPAIGN_PASSWORD_RESET_INVALID_TOKEN_MESSAGE}
/>
<CardContent className="flex flex-col gap-3">
<Button asChild className="min-h-11 w-full">
<Link href="/campanha/esqueci-senha">Solicitar novo link</Link>

View file

@ -0,0 +1,11 @@
import Link from 'next/link'
import { campaignAuthTextLinkClassName } from '@/lib/campaignAuthCopy'
export const CampaignAuthBackToLoginLink = () => (
<div className="text-center text-sm">
<Link href="/campanha/login" className={campaignAuthTextLinkClassName}>
Voltar ao login
</Link>
</div>
)

View file

@ -0,0 +1,20 @@
import type { ReactNode } from 'react'
import { CardDescription, CardHeader } from '@/components/ui/card'
import {
campaignAuthCardHeaderClassName,
campaignAuthDescriptionClassName,
campaignAuthHeadingClassName,
} from '@/lib/campaignAuthCopy'
type CampaignAuthCardHeaderProps = {
title: string
description: ReactNode
}
export const CampaignAuthCardHeader = ({ title, description }: CampaignAuthCardHeaderProps) => (
<CardHeader className={campaignAuthCardHeaderClassName}>
<h1 className={campaignAuthHeadingClassName}>{title}</h1>
<CardDescription className={campaignAuthDescriptionClassName}>{description}</CardDescription>
</CardHeader>
)

View file

@ -1,6 +1,6 @@
import { Megaphone } from 'lucide-react'
import type { ComponentProps, ReactNode } from 'react'
import { CampaignLogo } from '@/components/campaign/campaign-logo'
import { cn } from '@/lib/utils'
type CampaignAuthPageShellProps = {
@ -21,12 +21,7 @@ export const CampaignAuthPageShell = ({
{...props}
>
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex items-center gap-2 self-center font-semibold">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Megaphone className="size-4" aria-hidden="true" />
</div>
Campanha
</div>
<CampaignLogo className="mx-auto w-40 max-w-full bg-primary" />
{children}
</div>
</main>

View file

@ -6,5 +6,23 @@ export const CAMPAIGN_LEADERSHIP_PHONE_ACCESS_HINT =
export const CAMPAIGN_LEADERSHIP_LOGIN_RECOVERY_HINT =
'Conta só com celular? Peça um novo convite ao coordenador — a recuperação por e-mail não funciona sem e-mail cadastrado.'
/** Card titles on public campaign auth screens (login / forgot / reset). */
export const campaignAuthHeadingClassName = 'text-balance text-xl leading-snug font-medium'
export const CAMPAIGN_FIRST_ACCESS_HINT =
'Peça um convite no WhatsApp ao coordenador do seu núcleo.'
export const CAMPAIGN_LOGIN_SUBTITLE = 'Use o e-mail ou o celular cadastrado na sua conta.'
/**
* Card titles on public campaign auth screens (login / forgot / reset).
* `!text-center` beats `[data-theme='campaign'] h1 { text-align: left }` (app shell).
*/
export const campaignAuthHeadingClassName =
'!text-center text-balance text-xl leading-snug font-medium'
export const campaignAuthDescriptionClassName = 'leading-snug text-pretty'
export const campaignAuthMutedTextClassName =
'text-sm text-muted-foreground leading-snug text-pretty'
export const campaignAuthTextLinkClassName = 'text-primary underline-offset-4 hover:underline'
export const campaignAuthCardHeaderClassName = 'gap-2 text-center'