feat(S1): seção de conteúdos na home de campanha (artigos)

Bento 1+4 no desktop e carrossel 1/tela no mobile (~390px) lendo o post
collection com isPostVisible fail-closed; seção some inteira sem posts
visíveis. Zero migration/Consent; home vira ISR com tag posts +
revalidate=300 (label relativa não congela).
This commit is contained in:
Francisco Solla 2026-08-18 00:31:12 -03:00 committed by fsolla
parent 54058da059
commit 41dbb564c6
13 changed files with 748 additions and 1 deletions

View file

@ -4,6 +4,8 @@ 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 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-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/<filename>` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; 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 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).

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.
- **ISR congela a label relativa:** `export const revalidate = 300` na home — a página re-renderiza a cada 5 min com labels frescas; a lista de posts continua cacheada sob a tag `posts` (camadas independentes).
- **Fidelidade ao rascunho:** `gap-2` nos cards pequenos (gap-3 no featured), `mt-1` no header.
- **Spec e2e:** secret espelhado da env (`process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret'`), 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 pós-30d; 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'
@ -76,6 +77,11 @@ const proofItems = [
{ value: '3º', label: 'mandato de deputado federal' },
]
// The content section shows relative recency labels ("há 5 minutos"); a
// static page would freeze them between editorial revalidations. Re-render
// every 5 minutes (posts list itself stays cached under the `posts` tag).
export const revalidate = 300
export default async function HomePage() {
return (
<>
@ -101,6 +107,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,32 @@ 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 min", "há 3 horas",
* "há 2 dias" — falling back to the long absolute date past 30 days. 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 < 30) 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,248 @@ 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
}
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 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 })
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()
} 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 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,51 @@
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-15T12:00:00Z')).toBe('há 2 dias')
})
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 30 days', () => {
vi.setSystemTime(new Date('2026-08-17T12:00:00Z'))
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',
})
})
})