S1 — Seção de conteúdos na home de campanha — Artigos #29

Merged
fsolla merged 4 commits from S1-secao-de-conteudos-na-home-de-campanha-artigos into main 2026-08-18 05:07:27 +00:00
13 changed files with 784 additions and 1 deletions

View file

@ -4,6 +4,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta "há X · categoria" (relativa < 48h, data absoluta além — nunca mente congelada na ISR); card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts` (home estática revalidada pelo mecanismo `posts` existente). Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 em dev e prod (`E2E_PROD=1`, `.next-e2e`), suíte completa prod 169-170 passed com flakes ambientais de campanha (máquina compartilhada; testes diferentes a cada run, nenhum no código tocado); criação via REST com hooks reais; polling positivo do estado da home pós-revalidate (ISR serve stale uma vez sob carga) e navegação para about:blank antes do cleanup (prefetch de links apagados); lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) corrigidos; o `revalidate = 300` proposto contra o congelamento da label relativa foi TESTADO e REVERTIDO — no Next 15.4 em prod ele trava o bust por tag (`revalidateTag('posts')` deixava de regenerar a home; medido em probe manual) — a label relativa ganhou threshold de 48h com fallback absoluto; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção).
**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta relativa "há X · categoria"; card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts`; a home ganhou `revalidate = 300` para a label relativa não congelar na ISR. Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 (empty-state + full-state desktop/mobile, fail-closed com tag oculta, click-through, teclado/setas, swipe, dots; criação via REST com hooks reais; cleanup convergindo a cache de dev), lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) e o congelamento da data relativa na ISR corrigidos; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção).
**PUB5** — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com `aria-label` acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em `font-black` (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (`campaignHero.unit`, `campaignHome.unit`, `frontend.e2e`). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (`E2E_PROD=1`, `.next-e2e`), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). `/simplify` 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate.
**Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte.

View file

@ -0,0 +1 @@
**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta "há X · categoria" (relativa < 48h, data absoluta além — nunca mente congelada na ISR); card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts` (home estática revalidada pelo mecanismo `posts` existente). Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 em dev e prod (`E2E_PROD=1`, `.next-e2e`), suíte completa prod 169-170 passed com flakes ambientais de campanha (máquina compartilhada; testes diferentes a cada run, nenhum no código tocado); criação via REST com hooks reais; polling positivo do estado da home pós-revalidate (ISR serve stale uma vez sob carga) e navegação para about:blank antes do cleanup (prefetch de links apagados); lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) corrigidos; o `revalidate = 300` proposto contra o congelamento da label relativa foi TESTADO e REVERTIDO — no Next 15.4 em prod ele trava o bust por tag (`revalidateTag('posts')` deixava de regenerar a home; medido em probe manual) — a label relativa ganhou threshold de 48h com fallback absoluto; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção).

View file

@ -0,0 +1,106 @@
# Impl: S1 — Seção de conteúdos na home de campanha — Artigos
Status: aprovado
Atualizado em: 2026-08-17
Issue: #17
Intenção: docs/plans/secao-conteudos-home-artigos.md
Appetite restante: ~1 dia eng (herdado)
## Leitura da intenção
- **Outcome:** a home pública de campanha ganha, na 3ª posição (entre a prova social e "Por que essa eleição importa"), uma seção "Acompanhe de perto" com bento de 5 artigos (1 grande + 4 pequenos) no desktop e carrossel de 1 por tela no mobile (~390px), lendo o `post` collection com a visibilidade pública existente; sem artigos visíveis, a seção inteira é ocultada (Prova → Problema intacto).
- **O que NÃO negociar:** zero mudança de schema/migration/Consent; `isPostVisible` fail-closed (nada de bypass do `hidden` eleitoral); cache público = tag `posts` existente; o card inteiro é link para a rota canônica (`getPostCanonicalPath`), mesma aba; bento fixo sem paginação ("Ver artigos →" → `/artigos`); sem zona morta no vazio.
- **O que reavaliar:** a hipótese "estilos no bloco `[data-theme='campaign-site']` de styles.css" está certa, mas as seções atuais (`campaign-problem`/`campaign-flags`) usam altura fixa + posicionamento absoluto com geometria clampada do Penpot — o grid de conteúdo é alto e variável, então a seção nova usa flow normal com os mesmos tokens tipográficos/inset; e o carrossel mobile é navegação manual 1/tela com dots, job diferente do `CampaignCarousel` (auto-advance de destaques) — ver Decisão 2.
## Abordagem recomendada
```mermaid
flowchart LR
H[page.tsx<br/>home] --> S[CampaignContentSection<br/>server]
S --> V[getVisiblePosts<br/>tag posts]
V --> SL[slice 5<br/>first post = destaque]
SL --> B[Bento desktop<br/>md+ grid 4 col]
SL --> C[CampaignContentCarousel<br/>client · <md]
B --> CARD[Card Artigo<br/>badge tipo · capa · título · meta]
C --> CARD
S -.0 visíveis.-> HIDE[(não renderiza<br/>Prova → Problema)]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — uma seção server (`CampaignContentSection`) que lê `getVisiblePosts()`, fatia 5 e renderiza o bento desktop, com um client component pequeno (`CampaignContentCarousel`) só para o modo mobile (estado de scroll/índice/dots); helpers puros novos em `src/utilities/posts.ts` (dono de formatação de post).
**Rejeitadas:** B) estender `CampaignCarousel` com uma 3ª variant `content` — o contrato dele (variants `problem`/`flags`, auto-advance 4s, chips, geometria clampada pinada por e2e de swipe/geometria) não tem dots nem "1 de 5"; enfiar o modo manual 1/tela no mesmo módulo torna-o raso e arrisca os dois use-cases pinados; C) tudo client — o bento desktop não precisa de JS e ganharia re-render client.
### Componentes / mudanças
- **`CampaignContentSection`** (`src/components/CampaignContentSection.tsx`, server): dono da seção — eyebrow "Acompanhe de perto", título "A caminhada, em tempo real", subtítulo, link "Ver artigos →" (`/artigos`), bento desktop `hidden md:grid`, carrossel mobile `md:hidden`. `data-home-section="contents"` (convenção `proof`/`problem`/`flags`). Renderiza `null` quando `posts.length === 0`.
- **`CampaignContentCarousel`** (`src/components/CampaignContentCarousel.tsx`, client): snap-x mandatário 1/tela + dots-botões (`aria-current`) + contador "N de 5 · deslize para ver os próximos", `aria-roledescription="carrossel"`, `data-carousel="contents"`, respeita `prefers-reduced-motion`; padrões copiados do `CampaignCarousel` (scrollToItem/syncActiveItem, `scrollbar-hide`).
- **Card** (`CampaignArticleCard`, server-friendly, dentro do section file): badge de tipo (Decisão 3), capa `coverImage` (next/image fill, fallback sem imagem), título, meta "há X · categoria" (Decisão 4); `Link` para `getPostCanonicalPath(post)`, mesma aba. Variação `featured` (grande) só no bento.
- **`formatRelativePostDate`** (`src/utilities/posts.ts`): "hoje", "há 1 dia", "há 5 dias" via `Intl.RelativeTimeFormat('pt-BR')`; novo record `POST_TYPE_BADGE_LABELS` singular (`Notícia|Campanha|Artigo|Evento`).
- **`src/app/(frontend)/(home)/page.tsx`**: insere `<CampaignContentSection />` entre `campaign-proof` e `campaign-problem`; a página passa a depender da tag `posts` (ISR — comportamento já desenhado para consumidores de post; `afterChange` do Post/Tag já revalida).
- **`src/app/(frontend)/styles.css`**: classes da seção no bloco `[data-theme='campaign-site']` (tokens `--pt-red`, `--campaign-surface`, `--campaign-line`, `--campaign-content-inset`; tipografia `campaign-section-*`); flow normal, sem altura fixa.
- **Migration:** sem migration. **Access/Consent:** nenhum — leitura pública via helpers existentes.
- **UI:** Impeccable B — seção nova na home pública com wireframe aprovado; shape → craft → critique → polish no navegador.
### Dados → forma (se aplicável)
- Forma: meta = "há X · categoria" (data relativa + categoria do post) — dados já exibidos no site, nenhum contador inventado (restrição da intenção). Rejeitadas: data longa absoluta (`formatPostDate`) no card — o rascunho aprovado mostra recência, e "prova de atividade" pede relativa; contadores/visualizações — proibido pela intenção.
## Decisões de engenharia
1. **Carrossel mobile novo vs estender `CampaignCarousel`.**
Opções: A) `CampaignContentCarousel` próprio | B) variant `content` no `CampaignCarousel`.
Recomendação: A — jobs diferentes (auto-advance de destaques vs navegação manual 1/tela com indicador); o contrato atual é pinado por Penpot + e2e. Não é twin: é o mesmo padrão visual-mechanics, encapsulado por job.
Alternativas rejeitadas: B porque muda contrato pinado e adiciona dots/counter/aria-current a um módulo que não os expõe.
2. **Badge do card.**
Opções: A) label do tipo do post (Notícia/Campanha/Artigo/Evento) | B) "Artigo" fixo (literal do aceite).
Recomendação: A — a intenção decidiu "os N mais recentes visíveis, qualquer tipo"; com o feed misto, um badge "Artigo" sobre uma notícia seria desonesto. O aceite foi escrito assumindo artigos; o badge de tipo cumpre o mesmo papel do badge de fonte do wireframe.
Alternativas rejeitadas: B porque engana no feed misto (decisão A da intenção).
→ **Confirmar no gate** (divergência de leitura do aceite).
3. **Ocultação com 1–4 posts.**
Opções: A) ocultar só com zero visíveis; bento/carrossel se adaptam | B) exigir os 5 para renderizar.
Recomendação: A — zero é o fail-closed do aceite; com 1–4, mostrar o que existe (grid auto preenche; "1 de N" no carrossel).
Alternativas rejeitadas: B porque esconderia conteúdo real da campanha no meio de funil.
4. **Meta relativa vs absoluta.**
Opções: A) relativa ("há 1 dia · categoria") | B) `formatPostDate` longa.
Recomendação: A — rascunho aprovado mostra relativa; gatilho de revisitação: trocar para absoluta é uma linha se a assessoria pedir.
Alternativas rejeitadas: B porque foge do rascunho e da intenção de "atividade".
## Fases verificáveis
1. **Tracer (server + helpers):** `formatRelativePostDate` + `POST_TYPE_BADGE_LABELS` em `posts.ts` com unit tests; `CampaignContentSection` com bento desktop + `page.tsx` + CSS mínimo; e2e empty-state (seção ausente no estado default da suite) e full-state (cria posts via `getPayload` → revalida → asserts desktop). Gates parciais: tsc + unit.
2. **UI mobile:** `CampaignContentCarousel` (dots, contador, swipe) + CSS responsivo; e2e mobile (1/tela, swipe, "1 de 5", click-through para a página do artigo, post com tag `hidden` ausente — fail-closed); cleanup + revalidação no afterAll.
3. **Gates finais:** `pnpm gate:fast` (tsc, lint, format:check, knip, check:cycles), `pnpm test`, `pnpm build` local, e2e completo.
## Rabbit holes / Não escopo (engenharia)
- Não mexer na API de `getVisiblePosts` (limite/param) para otimizar 5 de ~39 docs — custo irrelevante, outros consumidores dependem do contrato.
- Não criar hooks de scroll-snap compartilhados ("DRY < 3 call sites").
- Não animar entrada da seção / parallax / prefetch de imagens (não escopo do wireframe).
- Sem "Ver todos" além do link `/artigos`; sem paginação; sem curadoria manual.
- YouTube/Instagram (S2/S3), share WhatsApp (S4): fora.
- Não tocar `CampaignCarousel`, `PostCard` (editorial), nem as geometrias clampadas das seções existentes.
## Ajustes pós-review (simplify, 2026-08-18)
- **A11y do carrossel:** foco + setas no `<ol>` (ArrowLeft/Right navegam), `inert` + `aria-hidden` nos slides inativos (saem do tab order/árvore a11y), `aria-live="polite"` no contador, dots com contraste ≥3:1 (`--campaign-muted`), `tabIndex` só com >1 item, `motion-reduce` corta o zoom da capa.
- **Label relativa vs ISR (testado e revertido):** a primeira resposta ao congelamento da label ("há 5 minutos" gravado no HTML estático) foi `export const revalidate = 300` na home — **medido em prod (Next 15.4): com `revalidate` temporal, `revalidateTag('posts')` deixa de regenerar a home (probe manual: `/artigos` convergia, a home ficava stale para sempre)** — o bust por tag é o contrato documentado do site, então o `revalidate` foi REMOVIDO e a solução virou threshold na formatação: relativa < 48h (drift limitado), data absoluta além (nunca mente congelada).
- **Fidelidade ao rascunho:** `gap-2` nos cards pequenos (gap-3 no featured), `mt-1` no header.
- **Spec e2e:** secret espelhado da env; polling POSITIVO do HTML da home até convergir pós-revalidate (ISR serve stale uma vez sob carga; DOM carregado não refresca); `about:blank` antes do cleanup (prefetch prod dos links apagados geraria 404 no guard); classe morta `.campaign-contents` removida; lock programático 700 ms (paridade com o irmão).
- **Decisões registradas (refusais):** (a) `formatRelativePostDate` não reusa `formatRelativeAge` (contratos divergentes: `numeric: 'auto'` → "anteontem" vs o "há 2 dias" do rascunho; `round` vs `floor`; fallback absoluto; futuro → "agora"; entrada string de post) — a função é pequena e os consumidores existentes do `formatRelativeAge` (bell/sinal/município) não podem mudar de output; (b) gutter da seção usa `px-5/8/10` + `max-w-[1160px]` em vez do `--campaign-content-inset` dos irmãos (layout flow do rascunho manda — o bento wide não cabe em inset de 180px); (c) "Ver artigos →" → `/artigos` lista só notícias com o feed misto — corte sancionado na intenção (rabbit hole "feed infinito"), link por tipo fica como opção futura.
## Riscos e mitigação
- **Home vira ISR (tag `posts`) em produção:** o build bota o estado atual; `afterChange` do Post/Tag já revalida; escrita direta (seed) segue o runbook de `POST /api/revalidate`. No e2e prod-mode, o teste full-state faz `POST /api/revalidate?tag=posts` antes do primeiro `goto` (REVALIDATE_SECRET novo no webServer env do playwright) e o empty-state roda antes — ordem no arquivo garante estado.
- **ISR serve stale uma vez após revalidate:** asserts usam `expect` com timeout/poll em vez de assert imediato.
- **Dois carrosséis na home (problem + contents):** seletores próprios (`data-carousel="contents"`), swipe e2e aponta ao track certo.
- **Seção em flow normal entre duas de altura fixa:** nenhum e2e existente mede posição absoluta de `problem` (verificado: os testes de geometria medem hero+proof e gaps relativos) — a inserção não quebra pinagem.
- **Falha do `getPayload` no spec (DB errado):** mesmo caminho do `campaignE2EFixtures` (config importado + `assertTestDatabase` já guarda o processo playwright).
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (bento 1+4, carrossel 1/tela, fail-closed vazio, links canônicos)
- [x] Invariantes AGENTS/engineering-standards (sem migration/Consent/access; leitura via helpers com tag `posts`; identificadores em inglês, copy pt-BR)
- [x] Testes de domínio previstos: unit (`formatRelativePostDate`, labels) + e2e (empty-state, full-state desktop+mobile, fail-closed `hidden`, click-through, cleanup)

View file

@ -159,6 +159,9 @@ export default defineConfig({
NEXT_DIST_DIR: process.env.NEXT_DIST_DIR ?? '.next-e2e',
PAYLOAD_SECRET: process.env.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production',
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL ?? baseURL,
// Lets e2e specs bust the `posts` tag after direct REST deletes (cleanup),
// mirroring the documented post-seed runbook against the deployed site.
REVALIDATE_SECRET: process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret',
},
},
})

View file

@ -1,4 +1,5 @@
import { CampaignCarousel, type CampaignCarouselItem } from '@/components/CampaignCarousel'
import { CampaignContentSection } from '@/components/CampaignContentSection'
import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignHero } from '@/components/CampaignHero'
@ -101,6 +102,8 @@ export default async function HomePage() {
</ul>
</section>
<CampaignContentSection />
<section
aria-labelledby="problem-title"
data-home-section="problem"

View file

@ -430,6 +430,15 @@ body,
gap: clamp(10px, calc(0.63vw + 7.51px), 14px);
}
/* The content board carousel is not full-bleed: it lives inside the section
gutter, so the shared track inset (--campaign-carousel-inset) must not
re-pad it or its slides stop filling the viewport. */
[data-theme='campaign-site'] [data-carousel='contents'] [data-carousel-track] {
padding-left: 0;
padding-right: 0;
scroll-padding-left: 0;
}
[data-theme='campaign-site'] [data-carousel-chips] {
gap: clamp(6px, calc(0.16vw + 5.37px), 7px);
}

View file

@ -0,0 +1,56 @@
import Image from 'next/image'
import Link from 'next/link'
export type CampaignArticleCardData = {
id: string | number
href: string
title: string
badgeLabel: string
meta: string
coverUrl?: string
coverAlt?: string
subtitle?: string
}
type CampaignArticleCardProps = {
card: CampaignArticleCardData
featured?: boolean
}
/** Presentational article card for the campaign home content bento/carousel. */
export const CampaignArticleCard = ({ card, featured = false }: CampaignArticleCardProps) => (
<Link
href={card.href}
className={`group flex h-full flex-col rounded-xl border border-(--campaign-line) bg-white p-3 shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) ${
featured ? 'gap-3' : 'gap-2'
}`}
>
<div className="relative aspect-video w-full shrink-0 overflow-hidden rounded-lg bg-(--campaign-band)">
{card.coverUrl ? (
<Image
src={card.coverUrl}
alt={card.coverAlt ?? card.title}
fill
sizes={featured ? '(min-width: 768px) 44vw, 92vw' : '(min-width: 768px) 22vw, 92vw'}
className="object-cover transition-transform duration-300 group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
/>
) : null}
<span className="absolute top-2 left-2 rounded-full bg-(--pt-red-dark) px-2 py-0.5 text-[10px] font-bold tracking-wider text-white uppercase">
{card.badgeLabel}
</span>
</div>
<div className="flex flex-1 flex-col gap-1">
<h3
className={`leading-snug font-bold text-(--campaign-ink) group-hover:underline ${
featured ? 'text-base' : 'text-sm'
}`}
>
{card.title}
</h3>
{featured && card.subtitle ? (
<p className="text-xs leading-relaxed text-(--campaign-muted)">{card.subtitle}</p>
) : null}
<span className="mt-auto text-xs text-(--campaign-muted)">{card.meta}</span>
</div>
</Link>
)

View file

@ -0,0 +1,131 @@
'use client'
import { CampaignArticleCard, type CampaignArticleCardData } from '@/components/CampaignArticleCard'
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
const PROGRAMMATIC_SCROLL_LOCK_MS = 700
type CampaignContentCarouselProps = {
ariaLabel: string
items: CampaignArticleCardData[]
}
/**
* Mobile-only carousel for the campaign home content section: one card per
* screen with snap scrolling, page dots and a "N de M" readout. Deliberately
* not a variant of `CampaignCarousel` — that module is pinned to the Penpot
* auto-advancing geometry of the problem/flags sections.
*/
export const CampaignContentCarousel = ({ ariaLabel, items }: CampaignContentCarouselProps) => {
const trackRef = useRef<HTMLOListElement>(null)
const isProgrammaticScrollRef = useRef(false)
const [activeIndex, setActiveIndex] = useState(0)
const releaseProgrammaticScroll = useCallback(() => {
isProgrammaticScrollRef.current = false
}, [])
useEffect(() => releaseProgrammaticScroll, [releaseProgrammaticScroll])
const scrollToItem = useCallback(
(index: number) => {
const track = trackRef.current
const target = track?.querySelector<HTMLElement>(`[data-carousel-index="${index}"]`)
if (!track || !target) return
isProgrammaticScrollRef.current = true
setActiveIndex(index)
track.scrollTo({ left: target.offsetLeft - track.offsetLeft })
window.setTimeout(releaseProgrammaticScroll, PROGRAMMATIC_SCROLL_LOCK_MS)
},
[releaseProgrammaticScroll],
)
const handleKeyDown = (event: KeyboardEvent<HTMLOListElement>) => {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return
event.preventDefault()
const next = activeIndex + (event.key === 'ArrowRight' ? 1 : -1)
scrollToItem(Math.max(0, Math.min(items.length - 1, next)))
}
const syncActiveItem = useCallback(() => {
const track = trackRef.current
if (!track || isProgrammaticScrollRef.current) return
const cards = Array.from(track.querySelectorAll<HTMLElement>('[data-carousel-index]'))
let nearestIndex = activeIndex
let nearestDistance = Number.POSITIVE_INFINITY
cards.forEach((card, index) => {
const distance = Math.abs(card.offsetLeft - track.offsetLeft - track.scrollLeft)
if (distance < nearestDistance) {
nearestDistance = distance
nearestIndex = index
}
})
if (nearestIndex !== activeIndex) setActiveIndex(nearestIndex)
}, [activeIndex])
return (
<div
role="region"
aria-label={ariaLabel}
aria-roledescription="carrossel"
data-carousel="contents"
onPointerDown={releaseProgrammaticScroll}
onFocusCapture={releaseProgrammaticScroll}
className="relative focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
>
<ol
ref={trackRef}
onScroll={syncActiveItem}
onKeyDown={handleKeyDown}
tabIndex={items.length > 1 ? 0 : undefined}
data-carousel-track
className="scrollbar-hide m-0 flex list-none snap-x snap-mandatory overflow-x-auto scroll-smooth p-0 motion-reduce:scroll-auto focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
>
{items.map((item, index) => (
<li
key={item.id}
data-carousel-index={index}
aria-label={`${index + 1} de ${items.length}`}
aria-current={index === activeIndex ? 'true' : undefined}
aria-hidden={index !== activeIndex || undefined}
inert={index !== activeIndex || undefined}
className="m-0 w-full shrink-0 snap-start"
>
<CampaignArticleCard card={item} />
</li>
))}
</ol>
{items.length > 1 ? (
<>
<div className="mt-3 flex items-center justify-center gap-2">
{items.map((item, index) => (
<button
key={item.id}
type="button"
aria-label={`Ir para o conteúdo ${index + 1} de ${items.length}`}
aria-current={index === activeIndex ? 'true' : undefined}
onClick={() => scrollToItem(index)}
className={`h-2 w-2 rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none ${
index === activeIndex
? 'bg-(--pt-red)'
: 'bg-(--campaign-muted) hover:bg-[#4f4744]'
}`}
/>
))}
</div>
<p
aria-live="polite"
className="mt-2 text-center text-xs font-semibold text-(--campaign-muted)"
>
{activeIndex + 1} de {items.length} · deslize para ver os próximos
</p>
</>
) : null}
</div>
)
}

View file

@ -0,0 +1,92 @@
import { CampaignArticleCard, type CampaignArticleCardData } from '@/components/CampaignArticleCard'
import { CampaignContentCarousel } from '@/components/CampaignContentCarousel'
import type { Post } from '@/payload-types'
import {
formatRelativePostDate,
getCategoryName,
getPostCanonicalPath,
getVisiblePosts,
POST_TYPE_BADGE_LABELS,
} from '@/utilities/posts'
import Link from 'next/link'
const CONTENT_SECTION_LIMIT = 5
const toCardData = (post: Post): CampaignArticleCardData | null => {
const href = getPostCanonicalPath(post)
if (!href) return null
const cover =
typeof post.coverImage === 'object' && post.coverImage !== null ? post.coverImage : null
const categoryName = getCategoryName(post)
const date = formatRelativePostDate(post.publishedDate)
return {
id: post.id,
href,
title: post.title,
badgeLabel: POST_TYPE_BADGE_LABELS[post.type],
meta: [date, categoryName].filter(Boolean).join(' · '),
...(cover?.url ? { coverUrl: cover.url, coverAlt: cover.alt ?? undefined } : {}),
...(post.subtitle ? { subtitle: post.subtitle } : {}),
}
}
/**
* Campaign home content board (S1 — articles): the 5 most recent visible posts
* as a 1+4 bento on desktop and a one-per-screen carousel on mobile. Hides
* entirely when nothing is visible (`isPostVisible` fail-closed — hidden
* electoral tags never leak).
*/
export const CampaignContentSection = async () => {
const cards = (await getVisiblePosts())
.map(toCardData)
.filter((card): card is CampaignArticleCardData => card !== null)
.slice(0, CONTENT_SECTION_LIMIT)
if (!cards.length) return null
const [featured, ...rest] = cards
return (
<section aria-labelledby="contents-title" data-home-section="contents" className="bg-white">
<div className="mx-auto w-full max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
Acompanhe de perto
</p>
<h2
id="contents-title"
className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance"
>
A caminhada, em tempo real
</h2>
<p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)">
Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.
</p>
</div>
<Link
href="/artigos"
className="text-sm font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
>
Ver artigos →
</Link>
</div>
<div className="mt-6 hidden grid-cols-4 gap-4 md:grid">
<div className="col-span-2 row-span-2">
<CampaignArticleCard card={featured} featured />
</div>
{rest.map((card) => (
<CampaignArticleCard key={card.id} card={card} />
))}
</div>
<div className="mt-5 md:hidden">
<CampaignContentCarousel ariaLabel="Artigos recentes" items={cards} />
</div>
</div>
</section>
)
}

View file

@ -17,6 +17,14 @@ export const POST_TYPE_LABELS: Record<PostType, string> = {
evento: 'Eventos',
}
/** Singular labels for per-card badges (e.g. the campaign home content bento). */
export const POST_TYPE_BADGE_LABELS: Record<PostType, string> = {
noticia: 'Notícia',
campanha: 'Campanha',
artigo: 'Artigo',
evento: 'Evento',
}
export const isPostType = (value: string): value is PostType =>
(POST_TYPES as readonly string[]).includes(value)
@ -68,6 +76,35 @@ export const formatPostDate = (date?: string | null): string | null => {
return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'long' }).format(new Date(date))
}
const relativeTimeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'always' })
/**
* Compact recency label for content cards: "agora", "há 5 minutos",
* "há 3 horas", "há 1 dia" — falling back to the long absolute date past
* 48 hours. The home is a static page revalidated only on editorial changes,
* so a relative label frozen in the HTML must stay honest: within 48 hours
* its drift is bounded, beyond that an absolute date never lies. Future dates
* (scheduled posts) read as "agora" instead of a fake countdown.
*/
export const formatRelativePostDate = (date?: string | null): string | null => {
if (!date) return null
const time = new Date(date).getTime()
if (Number.isNaN(time)) return null
const diffSeconds = Math.floor((time - Date.now()) / 1000)
if (diffSeconds > -60) return 'agora'
const absSeconds = -diffSeconds
const minutes = Math.floor(absSeconds / 60)
const hours = Math.floor(absSeconds / 3_600)
const days = Math.floor(absSeconds / 86_400)
if (hours < 1) return relativeTimeFormatter.format(-minutes, 'minute')
if (days < 1) return relativeTimeFormatter.format(-hours, 'hour')
if (days < 2) return relativeTimeFormatter.format(-days, 'day')
return formatPostDate(date)
}
const findPublishedPosts = (depth: number) =>
getPayload({ config: configPromise }).then((payload) =>
payload.find({

View file

@ -1,5 +1,6 @@
import type { Page } from '@playwright/test'
import type { APIRequestContext, Page } from '@playwright/test'
import { seedTestUser, testUser } from '../helpers/seedUser'
import { expect, test } from './fixtures/e2eTest'
const swipeLeft = async (
@ -413,3 +414,282 @@ test.describe('Frontend', () => {
)
})
})
/**
* S1 — content section on the campaign home. Runs serially: the empty-state
* test depends on the suite's default DB (no posts) and must execute before
* the full-state test creates posts. Posts/tags are created through the
* deployed REST API so the server process runs the real `afterChange` cache
* hooks (a Local API call from the runner would throw on `revalidateTag`),
* and cleanup busts the `posts` tag through the revalidate endpoint.
*/
test.describe('Campaign home content section', () => {
test.describe.configure({ mode: 'serial' })
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const revalidateSecret = process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret'
const runSuffix = Date.now()
const categoryName = `E2e Conteúdos ${runSuffix}`
const categorySlug = `e2e-conteudos-${runSuffix}`
const hiddenTagName = `E2e Oculta ${runSuffix}`
const hiddenTagSlug = `e2e-oculta-${runSuffix}`
const createdPosts: number[] = []
const createdTags: number[] = []
test.beforeAll(async () => {
await seedTestUser()
})
const adminHeaders = async (request: APIRequestContext): Promise<Record<string, string>> => {
const login = await request.post(`${baseURL}/api/users/login`, {
data: { email: testUser.email, password: testUser.password },
})
expect(login.ok()).toBeTruthy()
const { token } = await login.json()
return { cookie: `payload-token=${token}` }
}
const createTag = async (
request: APIRequestContext,
headers: Record<string, string>,
name: string,
slug: string,
hidden = false,
) => {
const response = await request.post(`${baseURL}/api/tag`, {
headers,
data: { name, slug, hidden },
})
expect(response.ok()).toBeTruthy()
const { doc } = await response.json()
createdTags.push(doc.id)
return doc
}
const createPost = async (
request: APIRequestContext,
headers: Record<string, string>,
data: { title: string; slug: string; type: string; category: number; publishedDate: string },
) => {
const response = await request.post(`${baseURL}/api/post`, {
headers,
data: { ...data, _status: 'published' },
})
expect(response.ok()).toBeTruthy()
const { doc } = await response.json()
createdPosts.push(doc.id)
return doc
}
// ISR serves the stale page while it regenerates after a revalidateTag
// (slower under the parallel suite), and a navigation that lands on it never
// refreshes its DOM. Poll the server HTML positively until it converges to
// the expected section state; the navigation that follows then always lands
// on the fresh page.
const waitForHomeSectionState = async (
request: APIRequestContext,
expected: 'present' | 'absent',
attempts = 12,
) => {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const response = await request.get(`${baseURL}/`).catch(() => undefined)
if (response?.ok()) {
const html = await response.text()
const present = html.includes('data-home-section="contents"')
if (present === (expected === 'present')) return
}
await new Promise((resolve) => setTimeout(resolve, 1_000))
}
throw new Error(
`A home não convergiu para o estado "${expected}" da seção de conteúdos após ${attempts}s.`,
)
}
test('hides the content section while no articles are visible', async ({ page, request }) => {
// The dev server persists its `posts` cache to .next-e2e/cache/fetch-cache
// between runs, so an earlier full-state run could leave it populated.
// Busting first makes the empty state deterministic on every rerun.
await request
.post(`${baseURL}/api/revalidate?tag=posts`, {
headers: { 'x-revalidate-secret': revalidateSecret },
})
.catch(() => undefined)
await waitForHomeSectionState(request, 'absent')
await page.goto('/')
await expect(page).toHaveURL(/\/$/)
await expect(page.locator('[data-home-section="contents"]')).toHaveCount(0)
await expect(page.getByText('A caminhada, em tempo real')).toHaveCount(0)
const proof = await page.locator('[data-home-section="proof"]').boundingBox()
const problem = await page.locator('[data-home-section="problem"]').boundingBox()
expect(proof).not.toBeNull()
expect(problem).not.toBeNull()
expect(problem!.y - (proof!.y + proof!.height)).toBeLessThanOrEqual(1)
})
test('renders the article bento and the one-per-screen mobile carousel', async ({
page,
request,
}) => {
await seedTestUser()
const headers = await adminHeaders(request)
const visibleTag = await createTag(request, headers, categoryName, categorySlug)
const hiddenTag = await createTag(request, headers, hiddenTagName, hiddenTagSlug, true)
const now = Date.now()
const posts = [
{
title: 'E2e Artigo em destaque',
slug: `e2e-artigo-destaque-${runSuffix}`,
type: 'artigo',
minutesAgo: 1,
},
{
title: 'E2e Notícia recente',
slug: `e2e-noticia-recente-${runSuffix}`,
type: 'noticia',
minutesAgo: 60 * 26,
},
{
title: 'E2e Campanha em ação',
slug: `e2e-campanha-${runSuffix}`,
type: 'campanha',
minutesAgo: 60 * 50,
},
{
title: 'E2e Notícia do interior',
slug: `e2e-noticia-interior-${runSuffix}`,
type: 'noticia',
minutesAgo: 60 * 74,
},
{
title: 'E2e Evento da agenda',
slug: `e2e-evento-${runSuffix}`,
type: 'evento',
minutesAgo: 60 * 98,
},
{
title: 'E2e Conteúdo oculto',
slug: `e2e-oculto-${runSuffix}`,
type: 'noticia',
minutesAgo: 60 * 122,
},
]
for (const post of posts) {
const isHidden = post.slug.includes('oculto')
await createPost(request, headers, {
title: post.title,
slug: post.slug,
type: post.type,
category: isHidden ? hiddenTag.id : visibleTag.id,
publishedDate: new Date(now - post.minutesAgo * 60_000).toISOString(),
})
}
try {
await page.setViewportSize({ width: 1280, height: 900 })
// The create hooks revalidated the `posts` tag in the server process;
// wait until the regenerated page actually shows the section.
await waitForHomeSectionState(request, 'present')
await page.goto('/')
const section = page.locator('[data-home-section="contents"]')
await expect(section).toBeVisible()
await expect(
section.getByRole('heading', { name: 'A caminhada, em tempo real' }),
).toBeVisible()
await expect(section.getByText('Acompanhe de perto')).toBeVisible()
await expect(
section.getByText(
'Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.',
),
).toBeVisible()
await expect(section.getByRole('link', { name: /Ver artigos/ })).toHaveAttribute(
'href',
'/artigos',
)
const bentoCards = section.locator(
'a[href^="/artigo/"], a[href^="/noticia/"], a[href^="/campanha/"], a[href^="/evento/"]',
)
await expect(bentoCards.filter({ visible: true })).toHaveCount(5)
for (const badge of ['Artigo', 'Notícia', 'Campanha', 'Evento']) {
await expect(section.getByText(badge, { exact: true }).first()).toBeVisible()
}
await expect(section.getByText('E2e Artigo em destaque').first()).toBeVisible()
await expect(section.getByText('E2e Conteúdo oculto')).toHaveCount(0)
await expect(section.getByText('E2e Oculta', { exact: false })).toHaveCount(0)
const featured = section.getByRole('link', { name: /E2e Artigo em destaque/ })
const featuredHref = await featured.getAttribute('href')
expect(featuredHref).toBe(`/artigo/${categorySlug}/e2e-artigo-destaque-${runSuffix}`)
const firstCard = bentoCards.first()
await expect(firstCard).toBeVisible()
await expect(firstCard.locator('h3')).toHaveText('E2e Artigo em destaque')
await expect(firstCard.locator('span').last()).toContainText(categoryName)
await featured.click()
await expect(page).toHaveURL(new RegExp(`${featuredHref}$`))
await page.goBack()
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
const carousel = page.getByRole('region', { name: 'Artigos recentes' })
await expect(carousel).toBeVisible()
await expect(carousel.getByText('E2e Conteúdo oculto')).toHaveCount(0)
const track = carousel.locator('[data-carousel-track]')
await track.scrollIntoViewIfNeeded()
const trackBox = await track.boundingBox()
if (!trackBox) throw new Error('O trilho do carrossel de conteúdo não ficou visível.')
const slides = await page.evaluate(() =>
Array.from(
document.querySelectorAll('[data-carousel="contents"] [data-carousel-index]'),
).map((slide) => slide.getBoundingClientRect().width),
)
expect(slides).toHaveLength(5)
expect(slides.every((width) => Math.abs(width - slides[0]) < 1)).toBe(true)
await swipeLeft(page, trackBox)
await expect(carousel.getByText('2 de 5 · deslize para ver os próximos')).toBeVisible()
await carousel.getByRole('button', { name: 'Ir para o conteúdo 4 de 5' }).click()
await expect(carousel.getByText('4 de 5 · deslize para ver os próximos')).toBeVisible()
// Leave the home before deleting: prod prefetches the card links, and a
// prefetch answered after the deletion would 404 (guard noise). Leaving
// the page aborts in-flight prefetches and stops new ones.
await page.goto('about:blank')
} finally {
const headers2 = await adminHeaders(request).catch(() => undefined)
if (headers2) {
for (const id of createdPosts) {
await request
.delete(`${baseURL}/api/post/${id}`, { headers: headers2 })
.catch(() => undefined)
}
for (const id of createdTags) {
await request
.delete(`${baseURL}/api/tag/${id}`, { headers: headers2 })
.catch(() => undefined)
}
await request
.post(`${baseURL}/api/revalidate?tag=posts`, {
headers: { 'x-revalidate-secret': revalidateSecret },
})
.catch(() => undefined)
// Converge the persisted dev cache back to the empty state so the
// next run starts deterministic (fail-closed on delete, too).
await waitForHomeSectionState(request, 'absent')
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto('/')
await expect(page.locator('[data-home-section="contents"]')).toHaveCount(0)
}
}
})
})

View file

@ -15,6 +15,14 @@ vi.mock('next/image', () => ({
),
}))
// The content section is an async server component that reads posts through
// the Payload DB; the unit env has no database and an async child suspends the
// whole tree under testing-library. Its empty/full behavior is covered by e2e
// (frontend.e2e.spec.ts), so the home skeleton tests render it away.
vi.mock('@/components/CampaignContentSection', () => ({
CampaignContentSection: () => null,
}))
afterEach(cleanup)
describe('Campaign home', () => {

View file

@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { formatRelativePostDate, POST_TYPE_BADGE_LABELS } from '@/utilities/posts'
describe('formatRelativePostDate', () => {
afterEach(() => {
vi.useRealTimers()
})
it('reads missing or invalid dates as null', () => {
expect(formatRelativePostDate(undefined)).toBeNull()
expect(formatRelativePostDate(null)).toBeNull()
expect(formatRelativePostDate('not-a-date')).toBeNull()
})
it('reads anything up to a minute ago as "agora"', () => {
vi.setSystemTime(new Date('2026-08-17T12:00:00Z'))
expect(formatRelativePostDate('2026-08-17T12:00:00Z')).toBe('agora')
expect(formatRelativePostDate('2026-08-17T11:59:59Z')).toBe('agora')
expect(formatRelativePostDate('2026-08-17T12:00:30Z')).toBe('agora')
})
it('formats minutes, hours and days in Portuguese with auto pluralization', () => {
vi.setSystemTime(new Date('2026-08-17T12:00:00Z'))
expect(formatRelativePostDate('2026-08-17T11:55:00Z')).toBe('há 5 minutos')
expect(formatRelativePostDate('2026-08-17T10:30:00Z')).toBe('há 1 hora')
expect(formatRelativePostDate('2026-08-17T08:00:00Z')).toBe('há 4 horas')
expect(formatRelativePostDate('2026-08-16T12:00:00Z')).toBe('há 1 dia')
})
it('floors partial units so 1h59m reads as "há 1 hora"', () => {
vi.setSystemTime(new Date('2026-08-17T12:00:00Z'))
expect(formatRelativePostDate('2026-08-17T10:01:00Z')).toBe('há 1 hora')
})
it('falls back to the long absolute date past 48 hours', () => {
vi.setSystemTime(new Date('2026-08-17T12:00:00Z'))
expect(formatRelativePostDate('2026-08-16T12:00:00Z')).toBe('há 1 dia')
expect(formatRelativePostDate('2026-08-15T12:00:00Z')).toBe('15 de agosto de 2026')
expect(formatRelativePostDate('2026-07-17T12:00:00Z')).toBe('17 de julho de 2026')
})
})
describe('POST_TYPE_BADGE_LABELS', () => {
it('spells the four types in the singular for card badges', () => {
expect(POST_TYPE_BADGE_LABELS).toEqual({
noticia: 'Notícia',
campanha: 'Campanha',
artigo: 'Artigo',
evento: 'Evento',
})
})
})