diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 176fdf45..0a1a7698 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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. diff --git a/docs/changelog/2026-08-18-s1.md b/docs/changelog/2026-08-18-s1.md new file mode 100644 index 00000000..a1374510 --- /dev/null +++ b/docs/changelog/2026-08-18-s1.md @@ -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). diff --git a/docs/plans/secao-conteudos-home-artigos-impl.md b/docs/plans/secao-conteudos-home-artigos-impl.md new file mode 100644 index 00000000..8aeb6fa4 --- /dev/null +++ b/docs/plans/secao-conteudos-home-artigos-impl.md @@ -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
home] --> S[CampaignContentSection
server] + S --> V[getVisiblePosts
tag posts] + V --> SL[slice 5
first post = destaque] + SL --> B[Bento desktop
md+ grid 4 col] + SL --> C[CampaignContentCarousel
client · CARD[Card Artigo
badge tipo · capa · título · meta] + C --> CARD + S -.0 visíveis.-> HIDE[(não renderiza
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 `` 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 `
    ` (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) diff --git a/playwright.config.ts b/playwright.config.ts index 92bfa52c..37a02954 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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', }, }, }) diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index f8ec2bcc..a76693ac 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -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() { + +
    ( + +
    + {card.coverUrl ? ( + {card.coverAlt + ) : null} + + {card.badgeLabel} + +
    +
    +

    + {card.title} +

    + {featured && card.subtitle ? ( +

    {card.subtitle}

    + ) : null} + {card.meta} +
    + +) diff --git a/src/components/CampaignContentCarousel.tsx b/src/components/CampaignContentCarousel.tsx new file mode 100644 index 00000000..449902eb --- /dev/null +++ b/src/components/CampaignContentCarousel.tsx @@ -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(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(`[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) => { + 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('[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 ( +
    +
      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) => ( +
    1. + +
    2. + ))} +
    + + {items.length > 1 ? ( + <> +
    + {items.map((item, index) => ( +
    +

    + {activeIndex + 1} de {items.length} · deslize para ver os próximos +

    + + ) : null} +
    + ) +} diff --git a/src/components/CampaignContentSection.tsx b/src/components/CampaignContentSection.tsx new file mode 100644 index 00000000..ccc2a852 --- /dev/null +++ b/src/components/CampaignContentSection.tsx @@ -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 ( +
    +
    +
    +
    +

    + Acompanhe de perto +

    +

    + A caminhada, em tempo real +

    +

    + Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes. +

    +
    + + Ver artigos → + +
    + +
    +
    + +
    + {rest.map((card) => ( + + ))} +
    + +
    + +
    +
    +
    + ) +} diff --git a/src/utilities/posts.ts b/src/utilities/posts.ts index a69f9b0f..49307764 100644 --- a/src/utilities/posts.ts +++ b/src/utilities/posts.ts @@ -17,6 +17,14 @@ export const POST_TYPE_LABELS: Record = { evento: 'Eventos', } +/** Singular labels for per-card badges (e.g. the campaign home content bento). */ +export const POST_TYPE_BADGE_LABELS: Record = { + 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({ diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index d0455797..d220af3c 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -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> => { + 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, + 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, + 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) + } + } + }) +}) diff --git a/tests/unit/campaignHome.unit.spec.tsx b/tests/unit/campaignHome.unit.spec.tsx index 765b1c40..1b6036c5 100644 --- a/tests/unit/campaignHome.unit.spec.tsx +++ b/tests/unit/campaignHome.unit.spec.tsx @@ -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', () => { diff --git a/tests/unit/postsDateFormats.unit.spec.ts b/tests/unit/postsDateFormats.unit.spec.ts new file mode 100644 index 00000000..6f763389 --- /dev/null +++ b/tests/unit/postsDateFormats.unit.spec.ts @@ -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', + }) + }) +})