mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(S1): seção de conteúdos na home de campanha (artigos)
Bento 1+4 no desktop e carrossel 1/tela no mobile (~390px) lendo o post collection com isPostVisible fail-closed; seção some inteira sem posts visíveis. Zero migration/Consent; home vira ISR com tag posts + revalidate=300 (label relativa não congela).
This commit is contained in:
parent
54058da059
commit
41dbb564c6
13 changed files with 748 additions and 1 deletions
|
|
@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta relativa "há X · categoria"; card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts`; a home ganhou `revalidate = 300` para a label relativa não congelar na ISR. Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 (empty-state + full-state desktop/mobile, fail-closed com tag oculta, click-through, teclado/setas, swipe, dots; criação via REST com hooks reais; cleanup convergindo a cache de dev), lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) e o congelamento da data relativa na ISR corrigidos; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção).
|
||||
|
||||
**PUB5** — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com `aria-label` acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em `font-black` (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (`campaignHero.unit`, `campaignHome.unit`, `frontend.e2e`). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (`E2E_PROD=1`, `.next-e2e`), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). `/simplify` 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate.
|
||||
|
||||
**Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/<filename>` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte.
|
||||
|
|
|
|||
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 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).
|
||||
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.
|
||||
- **ISR congela a label relativa:** `export const revalidate = 300` na home — a página re-renderiza a cada 5 min com labels frescas; a lista de posts continua cacheada sob a tag `posts` (camadas independentes).
|
||||
- **Fidelidade ao rascunho:** `gap-2` nos cards pequenos (gap-3 no featured), `mt-1` no header.
|
||||
- **Spec e2e:** secret espelhado da env (`process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret'`), classe morta `.campaign-contents` removida, lock programático 700 ms (paridade com o irmão).
|
||||
- **Decisões registradas (refusais):** (a) `formatRelativePostDate` não reusa `formatRelativeAge` (contratos divergentes: `numeric: 'auto'` → "anteontem" vs o "há 2 dias" do rascunho; `round` vs `floor`; fallback absoluto pós-30d; futuro → "agora"; entrada string de post) — a função é pequena e os consumidores existentes do `formatRelativeAge` (bell/sinal/município) não podem mudar de output; (b) gutter da seção usa `px-5/8/10` + `max-w-[1160px]` em vez do `--campaign-content-inset` dos irmãos (layout flow do rascunho manda — o bento wide não cabe em inset de 180px); (c) "Ver artigos →" → `/artigos` lista só notícias com o feed misto — corte sancionado na intenção (rabbit hole "feed infinito"), link por tipo fica como opção futura.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Home vira ISR (tag `posts`) em produção:** o build bota o estado atual; `afterChange` do Post/Tag já revalida; escrita direta (seed) segue o runbook de `POST /api/revalidate`. No e2e prod-mode, o teste full-state faz `POST /api/revalidate?tag=posts` antes do primeiro `goto` (REVALIDATE_SECRET novo no webServer env do playwright) e o empty-state roda antes — ordem no arquivo garante estado.
|
||||
- **ISR serve stale uma vez após revalidate:** asserts usam `expect` com timeout/poll em vez de assert imediato.
|
||||
- **Dois carrosséis na home (problem + contents):** seletores próprios (`data-carousel="contents"`), swipe e2e aponta ao track certo.
|
||||
- **Seção em flow normal entre duas de altura fixa:** nenhum e2e existente mede posição absoluta de `problem` (verificado: os testes de geometria medem hero+proof e gaps relativos) — a inserção não quebra pinagem.
|
||||
- **Falha do `getPayload` no spec (DB errado):** mesmo caminho do `campaignE2EFixtures` (config importado + `assertTestDatabase` já guarda o processo playwright).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (bento 1+4, carrossel 1/tela, fail-closed vazio, links canônicos)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem migration/Consent/access; leitura via helpers com tag `posts`; identificadores em inglês, copy pt-BR)
|
||||
- [x] Testes de domínio previstos: unit (`formatRelativePostDate`, labels) + e2e (empty-state, full-state desktop+mobile, fail-closed `hidden`, click-through, cleanup)
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
@ -76,6 +77,11 @@ const proofItems = [
|
|||
{ value: '3º', label: 'mandato de deputado federal' },
|
||||
]
|
||||
|
||||
// The content section shows relative recency labels ("há 5 minutos"); a
|
||||
// static page would freeze them between editorial revalidations. Re-render
|
||||
// every 5 minutes (posts list itself stays cached under the `posts` tag).
|
||||
export const revalidate = 300
|
||||
|
||||
export default async function HomePage() {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -101,6 +107,8 @@ export default async function HomePage() {
|
|||
</ul>
|
||||
</section>
|
||||
|
||||
<CampaignContentSection />
|
||||
|
||||
<section
|
||||
aria-labelledby="problem-title"
|
||||
data-home-section="problem"
|
||||
|
|
|
|||
|
|
@ -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,32 @@ export const formatPostDate = (date?: string | null): string | null => {
|
|||
return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'long' }).format(new Date(date))
|
||||
}
|
||||
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'always' })
|
||||
|
||||
/**
|
||||
* Compact recency label for content cards: "agora", "há 5 min", "há 3 horas",
|
||||
* "há 2 dias" — falling back to the long absolute date past 30 days. Future
|
||||
* dates (scheduled posts) read as "agora" instead of a fake countdown.
|
||||
*/
|
||||
export const formatRelativePostDate = (date?: string | null): string | null => {
|
||||
if (!date) return null
|
||||
const time = new Date(date).getTime()
|
||||
if (Number.isNaN(time)) return null
|
||||
|
||||
const diffSeconds = Math.floor((time - Date.now()) / 1000)
|
||||
if (diffSeconds > -60) return 'agora'
|
||||
|
||||
const absSeconds = -diffSeconds
|
||||
const minutes = Math.floor(absSeconds / 60)
|
||||
const hours = Math.floor(absSeconds / 3_600)
|
||||
const days = Math.floor(absSeconds / 86_400)
|
||||
|
||||
if (hours < 1) return relativeTimeFormatter.format(-minutes, 'minute')
|
||||
if (days < 1) return relativeTimeFormatter.format(-hours, 'hour')
|
||||
if (days < 30) return relativeTimeFormatter.format(-days, 'day')
|
||||
return formatPostDate(date)
|
||||
}
|
||||
|
||||
const findPublishedPosts = (depth: number) =>
|
||||
getPayload({ config: configPromise }).then((payload) =>
|
||||
payload.find({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
import type { APIRequestContext, Page } from '@playwright/test'
|
||||
|
||||
import { seedTestUser, testUser } from '../helpers/seedUser'
|
||||
import { expect, test } from './fixtures/e2eTest'
|
||||
|
||||
const swipeLeft = async (
|
||||
|
|
@ -413,3 +414,248 @@ test.describe('Frontend', () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* S1 — content section on the campaign home. Runs serially: the empty-state
|
||||
* test depends on the suite's default DB (no posts) and must execute before
|
||||
* the full-state test creates posts. Posts/tags are created through the
|
||||
* deployed REST API so the server process runs the real `afterChange` cache
|
||||
* hooks (a Local API call from the runner would throw on `revalidateTag`),
|
||||
* and cleanup busts the `posts` tag through the revalidate endpoint.
|
||||
*/
|
||||
test.describe('Campaign home content section', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
|
||||
const revalidateSecret = process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret'
|
||||
const runSuffix = Date.now()
|
||||
const categoryName = `E2e Conteúdos ${runSuffix}`
|
||||
const categorySlug = `e2e-conteudos-${runSuffix}`
|
||||
const hiddenTagName = `E2e Oculta ${runSuffix}`
|
||||
const hiddenTagSlug = `e2e-oculta-${runSuffix}`
|
||||
const createdPosts: number[] = []
|
||||
const createdTags: number[] = []
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await seedTestUser()
|
||||
})
|
||||
|
||||
const adminHeaders = async (request: APIRequestContext): Promise<Record<string, string>> => {
|
||||
const login = await request.post(`${baseURL}/api/users/login`, {
|
||||
data: { email: testUser.email, password: testUser.password },
|
||||
})
|
||||
expect(login.ok()).toBeTruthy()
|
||||
const { token } = await login.json()
|
||||
return { cookie: `payload-token=${token}` }
|
||||
}
|
||||
|
||||
const createTag = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
name: string,
|
||||
slug: string,
|
||||
hidden = false,
|
||||
) => {
|
||||
const response = await request.post(`${baseURL}/api/tag`, {
|
||||
headers,
|
||||
data: { name, slug, hidden },
|
||||
})
|
||||
expect(response.ok()).toBeTruthy()
|
||||
const { doc } = await response.json()
|
||||
createdTags.push(doc.id)
|
||||
return doc
|
||||
}
|
||||
|
||||
const createPost = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
data: { title: string; slug: string; type: string; category: number; publishedDate: string },
|
||||
) => {
|
||||
const response = await request.post(`${baseURL}/api/post`, {
|
||||
headers,
|
||||
data: { ...data, _status: 'published' },
|
||||
})
|
||||
expect(response.ok()).toBeTruthy()
|
||||
const { doc } = await response.json()
|
||||
createdPosts.push(doc.id)
|
||||
return doc
|
||||
}
|
||||
|
||||
test('hides the content section while no articles are visible', async ({ page, request }) => {
|
||||
// The dev server persists its `posts` cache to .next-e2e/cache/fetch-cache
|
||||
// between runs, so an earlier full-state run could leave it populated.
|
||||
// Busting first makes the empty state deterministic on every rerun.
|
||||
await request
|
||||
.post(`${baseURL}/api/revalidate?tag=posts`, {
|
||||
headers: { 'x-revalidate-secret': revalidateSecret },
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
await expect(page.locator('[data-home-section="contents"]')).toHaveCount(0)
|
||||
await expect(page.getByText('A caminhada, em tempo real')).toHaveCount(0)
|
||||
|
||||
const proof = await page.locator('[data-home-section="proof"]').boundingBox()
|
||||
const problem = await page.locator('[data-home-section="problem"]').boundingBox()
|
||||
expect(proof).not.toBeNull()
|
||||
expect(problem).not.toBeNull()
|
||||
expect(problem!.y - (proof!.y + proof!.height)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('renders the article bento and the one-per-screen mobile carousel', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await seedTestUser()
|
||||
const headers = await adminHeaders(request)
|
||||
|
||||
const visibleTag = await createTag(request, headers, categoryName, categorySlug)
|
||||
const hiddenTag = await createTag(request, headers, hiddenTagName, hiddenTagSlug, true)
|
||||
|
||||
const now = Date.now()
|
||||
const posts = [
|
||||
{
|
||||
title: 'E2e Artigo em destaque',
|
||||
slug: `e2e-artigo-destaque-${runSuffix}`,
|
||||
type: 'artigo',
|
||||
minutesAgo: 1,
|
||||
},
|
||||
{
|
||||
title: 'E2e Notícia recente',
|
||||
slug: `e2e-noticia-recente-${runSuffix}`,
|
||||
type: 'noticia',
|
||||
minutesAgo: 60 * 26,
|
||||
},
|
||||
{
|
||||
title: 'E2e Campanha em ação',
|
||||
slug: `e2e-campanha-${runSuffix}`,
|
||||
type: 'campanha',
|
||||
minutesAgo: 60 * 50,
|
||||
},
|
||||
{
|
||||
title: 'E2e Notícia do interior',
|
||||
slug: `e2e-noticia-interior-${runSuffix}`,
|
||||
type: 'noticia',
|
||||
minutesAgo: 60 * 74,
|
||||
},
|
||||
{
|
||||
title: 'E2e Evento da agenda',
|
||||
slug: `e2e-evento-${runSuffix}`,
|
||||
type: 'evento',
|
||||
minutesAgo: 60 * 98,
|
||||
},
|
||||
{
|
||||
title: 'E2e Conteúdo oculto',
|
||||
slug: `e2e-oculto-${runSuffix}`,
|
||||
type: 'noticia',
|
||||
minutesAgo: 60 * 122,
|
||||
},
|
||||
]
|
||||
for (const post of posts) {
|
||||
const isHidden = post.slug.includes('oculto')
|
||||
await createPost(request, headers, {
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
type: post.type,
|
||||
category: isHidden ? hiddenTag.id : visibleTag.id,
|
||||
publishedDate: new Date(now - post.minutesAgo * 60_000).toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.goto('/')
|
||||
const section = page.locator('[data-home-section="contents"]')
|
||||
await expect(section).toBeVisible()
|
||||
|
||||
await expect(
|
||||
section.getByRole('heading', { name: 'A caminhada, em tempo real' }),
|
||||
).toBeVisible()
|
||||
await expect(section.getByText('Acompanhe de perto')).toBeVisible()
|
||||
await expect(
|
||||
section.getByText(
|
||||
'Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.',
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(section.getByRole('link', { name: /Ver artigos/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/artigos',
|
||||
)
|
||||
|
||||
const bentoCards = section.locator(
|
||||
'a[href^="/artigo/"], a[href^="/noticia/"], a[href^="/campanha/"], a[href^="/evento/"]',
|
||||
)
|
||||
await expect(bentoCards.filter({ visible: true })).toHaveCount(5)
|
||||
for (const badge of ['Artigo', 'Notícia', 'Campanha', 'Evento']) {
|
||||
await expect(section.getByText(badge, { exact: true }).first()).toBeVisible()
|
||||
}
|
||||
await expect(section.getByText('E2e Artigo em destaque').first()).toBeVisible()
|
||||
await expect(section.getByText('E2e Conteúdo oculto')).toHaveCount(0)
|
||||
await expect(section.getByText('E2e Oculta', { exact: false })).toHaveCount(0)
|
||||
|
||||
const featured = section.getByRole('link', { name: /E2e Artigo em destaque/ })
|
||||
const featuredHref = await featured.getAttribute('href')
|
||||
expect(featuredHref).toBe(`/artigo/${categorySlug}/e2e-artigo-destaque-${runSuffix}`)
|
||||
|
||||
const firstCard = bentoCards.first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
await expect(firstCard.locator('h3')).toHaveText('E2e Artigo em destaque')
|
||||
await expect(firstCard.locator('span').last()).toContainText(categoryName)
|
||||
|
||||
await featured.click()
|
||||
await expect(page).toHaveURL(new RegExp(`${featuredHref}$`))
|
||||
await page.goBack()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/')
|
||||
const carousel = page.getByRole('region', { name: 'Artigos recentes' })
|
||||
await expect(carousel).toBeVisible()
|
||||
await expect(carousel.getByText('E2e Conteúdo oculto')).toHaveCount(0)
|
||||
|
||||
const track = carousel.locator('[data-carousel-track]')
|
||||
await track.scrollIntoViewIfNeeded()
|
||||
const trackBox = await track.boundingBox()
|
||||
if (!trackBox) throw new Error('O trilho do carrossel de conteúdo não ficou visível.')
|
||||
|
||||
const slides = await page.evaluate(() =>
|
||||
Array.from(
|
||||
document.querySelectorAll('[data-carousel="contents"] [data-carousel-index]'),
|
||||
).map((slide) => slide.getBoundingClientRect().width),
|
||||
)
|
||||
expect(slides).toHaveLength(5)
|
||||
expect(slides.every((width) => Math.abs(width - slides[0]) < 1)).toBe(true)
|
||||
|
||||
await swipeLeft(page, trackBox)
|
||||
await expect(carousel.getByText('2 de 5 · deslize para ver os próximos')).toBeVisible()
|
||||
|
||||
await carousel.getByRole('button', { name: 'Ir para o conteúdo 4 de 5' }).click()
|
||||
await expect(carousel.getByText('4 de 5 · deslize para ver os próximos')).toBeVisible()
|
||||
} finally {
|
||||
const headers2 = await adminHeaders(request).catch(() => undefined)
|
||||
if (headers2) {
|
||||
for (const id of createdPosts) {
|
||||
await request
|
||||
.delete(`${baseURL}/api/post/${id}`, { headers: headers2 })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
for (const id of createdTags) {
|
||||
await request
|
||||
.delete(`${baseURL}/api/tag/${id}`, { headers: headers2 })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
await request
|
||||
.post(`${baseURL}/api/revalidate?tag=posts`, {
|
||||
headers: { 'x-revalidate-secret': revalidateSecret },
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
// Converge the persisted dev cache back to the empty state so the
|
||||
// next run starts deterministic (fail-closed on delete, too).
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.goto('/')
|
||||
await expect(page.locator('[data-home-section="contents"]')).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
51
tests/unit/postsDateFormats.unit.spec.ts
Normal file
51
tests/unit/postsDateFormats.unit.spec.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { formatRelativePostDate, POST_TYPE_BADGE_LABELS } from '@/utilities/posts'
|
||||
|
||||
describe('formatRelativePostDate', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reads missing or invalid dates as null', () => {
|
||||
expect(formatRelativePostDate(undefined)).toBeNull()
|
||||
expect(formatRelativePostDate(null)).toBeNull()
|
||||
expect(formatRelativePostDate('not-a-date')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads anything up to a minute ago as "agora"', () => {
|
||||
vi.setSystemTime(new Date('2026-08-17T12:00:00Z'))
|
||||
expect(formatRelativePostDate('2026-08-17T12:00:00Z')).toBe('agora')
|
||||
expect(formatRelativePostDate('2026-08-17T11:59:59Z')).toBe('agora')
|
||||
expect(formatRelativePostDate('2026-08-17T12:00:30Z')).toBe('agora')
|
||||
})
|
||||
|
||||
it('formats minutes, hours and days in Portuguese with auto pluralization', () => {
|
||||
vi.setSystemTime(new Date('2026-08-17T12:00:00Z'))
|
||||
expect(formatRelativePostDate('2026-08-17T11:55:00Z')).toBe('há 5 minutos')
|
||||
expect(formatRelativePostDate('2026-08-17T10:30:00Z')).toBe('há 1 hora')
|
||||
expect(formatRelativePostDate('2026-08-17T08:00:00Z')).toBe('há 4 horas')
|
||||
expect(formatRelativePostDate('2026-08-15T12:00:00Z')).toBe('há 2 dias')
|
||||
})
|
||||
|
||||
it('floors partial units so 1h59m reads as "há 1 hora"', () => {
|
||||
vi.setSystemTime(new Date('2026-08-17T12:00:00Z'))
|
||||
expect(formatRelativePostDate('2026-08-17T10:01:00Z')).toBe('há 1 hora')
|
||||
})
|
||||
|
||||
it('falls back to the long absolute date past 30 days', () => {
|
||||
vi.setSystemTime(new Date('2026-08-17T12:00:00Z'))
|
||||
expect(formatRelativePostDate('2026-07-17T12:00:00Z')).toBe('17 de julho de 2026')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST_TYPE_BADGE_LABELS', () => {
|
||||
it('spells the four types in the singular for card badges', () => {
|
||||
expect(POST_TYPE_BADGE_LABELS).toEqual({
|
||||
noticia: 'Notícia',
|
||||
campanha: 'Campanha',
|
||||
artigo: 'Artigo',
|
||||
evento: 'Evento',
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue