S1 — Seção de conteúdos na home de campanha — Artigos #29
13 changed files with 784 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-18-s1.md
Normal file
1
docs/changelog/2026-08-18-s1.md
Normal 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).
|
||||
106
docs/plans/secao-conteudos-home-artigos-impl.md
Normal file
106
docs/plans/secao-conteudos-home-artigos-impl.md
Normal 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)
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
56
src/components/CampaignArticleCard.tsx
Normal file
56
src/components/CampaignArticleCard.tsx
Normal 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>
|
||||
)
|
||||
131
src/components/CampaignContentCarousel.tsx
Normal file
131
src/components/CampaignContentCarousel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/components/CampaignContentSection.tsx
Normal file
92
src/components/CampaignContentSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
53
tests/unit/postsDateFormats.unit.spec.ts
Normal file
53
tests/unit/postsDateFormats.unit.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue