feat(S27): Central de Conteúdos pública — Peça voto pra Solla 1313

/conteudos + /conteudos/<slug>: catálogo filtrável (Tipo · Cidade · Região ·
Tema · Instituição + termo na URL canônica), play sob demanda (Vídeo/Áudio só
carregam no toque; Foto com preview leve), Baixar e Compartilhar do próprio
card com a mensagem de voto por tipo (wa.me sem destinatário, editável; "A
mídia" usa a folha nativa e cai em download onde o aparelho não suportar) e o
card personalizado como peça sintética do catálogo → /cards.

- leitura pública nova (contentPieceReads) cacheada na tag contentPieces com o
  gate where status=publicado + contentPieceIsPublic; a rota GET
  /conteudos/<slug>/midia reusa buildPrivateMediaResponse (range, disposition,
  private no-store, 404 uniforme) com download jorge-solla-1313-<slug>.<ext> e
  a allowlist inline do dono estendida (webp/gif/avif + áudios das peças)
- contrato público puro do catálogo (filtros/VM/download) + mensagens de voto
  (contentPieceShare); OG com fallback do global; conteudos entra nos slugs
  reservados; kill switch por peça vale na hora (lista, página, rodapé/selo);
  descoberta condicional no CampaignFooter e no header local
- UI portada classe-a-classe do design aprovado (cenas 01–08, com as cenas
  06–08 estendidas no trigger a); sem migration, sem Consent e sem access novo;
  contentMedia segue privada (sem segunda cópia de mídia)
- testes: 2 unit specs novos, 18 casos int (leitura pública + rota de mídia) e
  e2e frontendConteudos (5 casos); manifest e curado atualizados
- follow-up de engenharia: #1276 (docs/plans/escala-dry-pos-s27.md)

Closes #1255
This commit is contained in:
Francisco Solla 2026-09-23 10:50:19 -03:00
parent e72bfe8f8f
commit dda55adc27
43 changed files with 3582 additions and 15 deletions

View file

@ -0,0 +1 @@
**Central de Conteúdos pública — Peça voto pra Solla 1313 (S27, 2026-09-23):** a campanha ganhou `/conteudos` (chamada "Peça voto pra Solla 1313"): o eleitor vê as peças publicadas pelo C211, filtra por Tipo · Cidade · Região · Tema · Instituição e por termo (querystring canônica, funciona sem JS), vê/ouve no próprio catálogo (Vídeo e Áudio só carregam no toque; Foto com preview leve) e baixa ou compartilha dali mesmo — a mensagem de voto por tipo (Vídeo/Áudio/Foto-Texto-Card), editável, abre o WhatsApp do próprio eleitor (`wa.me` sem destinatário, nada sai sozinho); "A mídia" usa a folha nativa onde o aparelho suporta e baixa o mesmo arquivo onde não suporta. Cada peça tem página própria `/conteudos/<slug>` com OG (título + imagem; foto usa o próprio arquivo, demais caem na imagem do site) e `conteudos` entrou nos slugs reservados. O arquivo continua privado: uma rota GET `/conteudos/<slug>/midia` (gate = leitura pública cacheada na tag `contentPieces` + predicado `contentPieceIsPublic`) reusa o streaming do C193/C199 (range, disposition, `private, no-store`) com 404 uniforme para rascunho/despublicado/inexistente e download com nome legível `jorge-solla-1313-<slug>.<ext>`; a allowlist de MIME inline do dono ganhou as famílias de imagem/áudio das peças (webp/gif/avif, m4a/aac/ogg/opus/wav/webm/flac). O card personalizado aparece como peça sintética do catálogo (só sem filtro) e leva a `/cards`; a descoberta é condicional (`CampaignFooter` ganhou "Conteúdos" + selo no header local) e some junto com as peças. Kill switch por peça vale na hora (lista, página, rodapé). Sem login/cadastro/PII/rastreio, sem segunda cópia de mídia e sem disparo em massa. Testes: 2 unit specs novos (catálogo/filtro/VM + mensagens de voto), 1 e2e novo (`frontendConteudos`, 5 casos: lazy/play exclusivo, sheet com mensagem editável e fallback de download, filtros/no-results, OG da página, kill switch) e 18 casos int (leitura pública + rota de mídia); pins de `shareLink`/manifest curado atualizados. Débito registrado em #1276 (`docs/plans/escala-dry-pos-s27.md`). Design: crítica final certificada pelo tier primário (`Design tier: openai/gpt-5.6-sol`) contra o app renderizado, após uma rodada degradada que apontou e a sessão corrigir o tema do sheet no portal, `h1`/`h2` herdando o base do site, rótulo do asset, copy da peça-link e o painel do estado vazio.

View file

@ -0,0 +1,197 @@
# Impl: S27 — Central de Conteúdos pública — Peça voto pra Solla 1313
Status: aprovado
Atualizado em: 2026-09-23
Issue: #1255
Intenção: docs/plans/central-conteudos-publica.md
Design UI (gate): docs/plans/central-conteudos-publica-ui-design.html
Appetite restante: herdado (~2–3 dias eng) — catálogo filtrável + página da peça + serving público da mídia + share de voto, **sem schema novo**, reusando os donos existentes (mídia privada, tag de listing, wa.me, shells públicos). Cortados: poster/thumbnail, player próprio, busca semântica, analytics e lote.
Modo: autônomo (`--auto`) — impl plan nasce aprovado pelo agente.
## Leitura da intenção
- **Outcome:** o eleitor abre `/conteudos`, vê as peças publicadas de C211 filtráveis por Tipo · Cidade · Região · Tema · Instituição e por termo, vê/ouve no próprio catálogo (play sob demanda), baixa ou compartilha dali mesmo com a mensagem de voto (sempre editável, `wa.me` sem destinatário) e, na peça de Card, cai no `/cards`; cada peça tem página própria `/conteudos/<slug>` com preview para o link abrir bem no WhatsApp.
- **O que NÃO negociar:**
- **Kill switch por peça:** despublicar tira da lista e da própria página na hora, sem apagar arquivo; sem peça publicada, a Central mostra estado honesto e a descoberta (link do rodapé + selo do header) some.
- **LGPD/TSE:** sem login, sem cadastro, sem captura de PII e sem rastreio; `wa.me/?text=` do aparelho do eleitor, sem destinatário e sem envio pelo sistema; nada de disparo em massa; cards 100% no aparelho (a Central só convida para `/cards`); bloco eleitoral do rodapé preservado.
- **Nada de segunda cópia:** peça-link compartilha pelo link da plataforma (`sourceUrl`); peça com arquivo baixa/compartilha o MESMO objeto privado do C211 — nunca `/api/media/file` (esse proxy é só da collection `media`) e nunca re-hospedar.
- **Leve/mobile-first:** Vídeo e Áudio só carregam no toque; Foto com preview leve; sem zip/lote.
- **Literais:** `/conteudos` + `/conteudos/<slug>` (entram nos slugs reservados), `Central de Conteúdos`, `Peça voto pra Solla 1313`, filtros `Tipo` · `Cidade` · `Região` · `Tema` · `Instituição`, ações `Baixar` · `Compartilhar` (`Mensagem no WhatsApp` · `A mídia` · `Copiar link`) · `Fazer meu card`, e os três templates de mensagem de voto da intenção.
- **O que reavaliar (hipóteses do plano de intenção):**
- "cache/revalidação no padrão de `documents.ts` + allowlist em `api/revalidate`": a revalidação já está pronta no dono (`ContentPiece.afterChange` → `revalidateContentPiecesListing`, `src/collections/ContentPiece.ts:78-81`); o endpoint de revalidate é para writes diretos no DB e **não é tocado** — a leitura pública é um módulo novo cacheado na tag `contentPieces` (D1).
- "filtros/busca reusando o maquinário interno": **não** — o contrato interno (`src/utilities/content/contentPieceListUrl.ts:145-155`) é de staff e a fronteira site→interno é vedada; o contrato público é novo e puro (D3).
- "mídia baixa por atributo `download` no proxy": não existe proxy público para `contentMedia`; o serving é rota nova com o helper de I/O privado (D2). Achado do explorador que vira mudança no dono: a allowlist inline de `src/lib/privateMedia.ts:16-24` não cobre webp/gif/avif nem os áudios que o C211 aceita (m4a/ogg/opus/wav/flac) — sem isso a Foto não renderiza e o Áudio não toca.
- "`navigator.share` com arquivo": primeiro precedente do repo (hoje só texto/URL, `src/lib/homeSearchShare.ts:9-18`); sempre com fallback `<a download>`, nunca fingindo anexo.
- "OG sem thumbnail": não há poster gerado no C211; a Foto usa o próprio arquivo e os demais tipos caem na imagem do global `metadata` (D5).
## Abordagem recomendada
```mermaid
flowchart LR
PUB["C211: publicado · slug · media\n(kill switch no afterChange)"] --> TAG["revalidateTag('contentPieces')\n(documents.ts:53-54)"]
TAG --> READS["contentPieceReads (unstable_cache, tag contentPieces)\ncatálogo publicado · por slug · hasPublished"]
READS --> LIST["/conteudos\ncatálogo filtrável (q + 5 facetas)"]
READS --> PIECE["/conteudos/[slug]\npágina + OG"]
LIST -- "play sob demanda (preload=none)" --> MEDIA["/conteudos/[slug]/midia (GET)\ngate contentPieceIsPublic\nbuildPrivateMediaResponse (range)"]
PIECE --> MEDIA
LIST -- "Baixar (?download=1)" --> MEDIA
LIST -- "Compartilhar" --> SHEET["Sheet: mensagem de voto editável\nwa.me/?text · A mídia (share nativo → download) · Copiar link"]
PIECE --> SHEET
LIST -- "peça de Card" --> CARDS["/cards"]
READS -. "hasPublishedContentPieces" .-> FOOTER["CampaignFooter showConteudos\n(home · cards · jingles · Central)"]
```
**Opções consideradas (abordagem geral):** A) leitura pública cacheada por tag + rota de mídia nova reusando o helper privado + contrato público de filtro/busca puro + port classe-a-classe do design; B) página lendo direto da Local API sem cache e mídia pelo proxy do Payload; C) catálogo client-side (tudo carregado e filtrado no cliente) com mídia liberada em `contentMedia`.
**Recomendação:** **A** — mantém cada dono no seu lugar (tag do C211, I/O privado do C193/C199, `CampaignFooter` do S21), nasce fail-closed para rascunho (o `where`+`contentPieceIsPublic` são o gate) e cabe no appetite sem schema/migration.
**Rejeitadas:** **B** porque duplicaria o `where`/select/mapa em N superfícies sem cache por tag (o kill switch exigiria revalidar em todo lugar) e porque `contentMedia` é privada por contrato — liberá-la vazaria rascunho por URL; **C** porque carregar o catálogo inteiro no cliente contraria "leve/conexão ruim" e mata o filtro compartilhável por URL.
### D1 — Leitura pública das peças (onde mora; reuso de `contentPieceIsPublic`)
**Opções:** A) módulo novo `src/utilities/content/contentPieceReads.ts` (`server-only`) com `unstable_cache` na tag de listing `contentPieces` + VM público puro em `src/lib/contentPieceCatalog.ts`; B) `payload.find` direto em cada page/route; C) reusar o loader interno `src/utilities/content/contentPiecePageData.ts:92-...`.
**Recomendação:** **A** — a collection não tem read público (`canReadContentPiece`, `src/utilities/access/contentPieces.ts:16-21`); o precedente é Local API sem user + `where` de publicação + `unstable_cache` na tag de listing (`src/utilities/jingleReads.ts:21-35`, `src/utilities/shareLinkReads.ts:38-45`). O `where` filtra `status=publicado` e o predicado `contentPieceIsPublic` (`src/lib/contentPiece.ts:211-215`) derruba publicado-sem-arquivo-e-sem-link — é o contrato que o C211 reservou ao S27. A leitura **por slug** usa a **mesma tag** de listing (o hook não busta document tag, então `getCachedDocumentById` não serve; padrão `shareLinkReads.ts:33-45`). `hasPublishedContentPieces()` deriva da mesma lista resolvida (padrão `hasPublishedJingles`, `jingleReads.ts:74-80`). Local API sem `user` usa o bypass default — o gate é o `where`+predicado, com comentário explícito (mesmo precedente de `jingleReads`).
**Rejeitadas:** **B** porque repetiria o `where`/select/mapa em duas pages + rota, sem cache e com kill switch espalhado; **C** porque o loader interno exige `CampaignUser`, seleciona campos de staff (`error`, `curatedFields`, `transcript`) e inverteria a fronteira público→interno.
### D2 — Serving público da mídia (rota + headers + range)
**Opções:** A) rota GET-only `/conteudos/<slug>/midia` (`src/app/(frontend)/conteudos/[slug]/midia/route.ts`) reusando `buildPrivateMediaResponse` (`src/utilities/privateMedia/privateMediaResponse.ts:151-186`) com o gate público; B) rota sob `/api/conteudos/[slug]`; C) `contentMedia` com read público + proxy do Payload.
**Recomendação:** **A** — o objeto continua no bucket privado e a rota é a única porta, decidida por `slug` (nunca por id), com o mesmo helper de I/O do interno (range e `Content-Disposition` inclusos; nada de segundo cliente S3). 404 silencioso e uniforme para rascunho/inexistente/sem arquivo (contrato de `/corte/[id]`, `src/app/(frontend)/corte/[id]/page.tsx:38-53`).
**Headers/Cache-Control:** reusar o contrato do dono (`private, no-store`, `src/lib/privateMedia.ts:27,78`) — o kill switch precisa valer na hora e nada compartilhado pode guardar peça retirável na reta final; o range já torna o seek barato e o peso é limitado pelo `preload="none"` (D3/UI). **Gatilho:** se o campo relatar re-download doloroso, acrescentar um `cacheControl` opcional ao dono com janela curta (`public, max-age=300, must-revalidate`) — mudança localizada.
**MIME inline (mudança no dono):** estender `PRIVATE_MEDIA_INLINE_MIME_TYPES` (`src/lib/privateMedia.ts:16-24`) com `image/webp`, `image/gif`, `image/avif` (foto renderiza de verdade) e `audio/mp4`, `audio/aac`, `audio/ogg`, `audio/opus`, `audio/wav`, `audio/webm`, `audio/flac` (o C211 aceita esses formatos e hoje todos caem em `application/octet-stream` → viram download e o `<audio>` não toca). HEIC/HEIF seguem download-only (browser não renderiza) e `image/svg+xml` fica fora (XSS). `tests/unit/privateMedia.unit.spec.ts:30-36` ganha os casos.
**Download legível:** `<a href={mediaPath + '?download=1'} download={contentPieceDownloadFilename(slug, filename)}>` — o `?download=1` manda `attachment` com o nome guardado; o atributo dá o nome legível onde o browser honra (Chrome/Firefox); Safari usa o guardado (aceito).
**Rejeitadas:** **B** porque parte o contrato da Central em dois namespaces sem ganho; **C** porque `contentMedia` é privada por contrato e um rascunho vazaria por URL — mata o kill switch.
### D3 — Filtro/busca pública (querystring + pure module; o que não importar)
**Opções:** A) querystring canônica + filtragem em memória no servidor sobre o catálogo publicado já cacheado, contrato puro novo em `src/lib/contentPieceCatalog.ts` (`tipo`, `cidade`, `regiao`, `tema`, `instituicao`, `q`; um valor por faceta); B) client-state sem URL; C) reusar `src/utilities/content/contentPieceListUrl.ts`.
**Recomendação:** **A** — filtro em URL é compartilhável e funciona sem JS (form GET + links), e o catálogo publicado é pequeno (dezenas), então filtrar em memória sobre a lista cacheada evita combinações de `where` e não acopla o site ao contrato interno. `q` normaliza com `normalizeForSearch` e casa no `searchText` denormalizado do C211 (trigram no banco; em memória `includes` basta — gatilho: catálogo na casa dos milhares volta a usar `where` trigram público). Valores: `tipo`/`tema` são os enums persistidos (`CONTENT_PIECE_TYPES`, `SPEECH_TOPICS`); `cidade`/`regiao`/`instituicao` usam chave `slugify` do label (`src/lib/slug.ts`), derivada **só das peças publicadas**; faceta sem valor não aparece. Canonical da listing = `/conteudos` (sem params); `noindex` só no estado vazio (padrão `/jingles`, `src/app/(frontend)/jingles/page.tsx:62`). A peça de Card sintética (D6) só aparece no catálogo sem filtro ativo.
**Rejeitadas:** **B** porque mata o compartilhamento do filtro, o funcionamento sem JS e carrega o catálogo inteiro no cliente; **C** porque o contrato interno tem `page`/`status`/`processing` (vocabulário de staff) e a intenção veda importar o interno.
### D4 — Mensagens de voto por tipo (módulo novo)
**Opções:** A) módulo puro novo `src/lib/contentPieceShare.ts` com os três templates dos literais + link por tipo (página da peça para arquivo; `sourceUrl` para peça-link), reusando `buildWhatsAppTextShareUrl` (`src/lib/phone.ts:63-67`); B) estender `src/lib/contentShare.ts`; C) strings nos componentes.
**Recomendação:** **A** — `contentShare.ts` é o vocabulário do board da home (S4) com contrato pinado (`src/lib/contentShare.ts:11-15`); as mensagens de voto são literais do S27 e falam por **tipo de peça** (`video` | `audio` | `foto|texto|card`). O sheet usa o texto como valor inicial editável e monta o `wa.me` a cada render — o sistema nunca envia.
**Rejeitadas:** **B** porque reabre o pin do S4 e mistura dois vocabulários de mensagem; **C** porque duplica strings entre card e página e fica sem teste.
### D5 — OG da peça (sem thumbnail)
**Opções:** A) imagem real só quando a peça é `foto` com arquivo (URL absoluta de `/conteudos/<slug>/midia` no origin de deploy) e fallback para a imagem do global `metadata` nos demais tipos; B) gerar thumbnail/poster de vídeo/áudio; C) sem imagem quando não for foto.
**Recomendação:** **A** — o C211 não gera poster (fora de escopo lá) e gerar thumbnail é rabbit hole de mídia; a página abre bem no WhatsApp com `título + imagem` quando há imagem real e com `título + descrição` + imagem do site quando não há (mesmo fallback do `/corte`, `src/app/(frontend)/corte/[id]/page.tsx:82-94`, e do share-link, `src/utilities/shareLinkReads.ts:60-76`). A URL usa `resolveDeploymentOrigin` (`src/utilities/seo.ts:71-76`) porque é o único origin que serve a rota (regra do cover do `/jingles`, `src/app/(frontend)/jingles/page.tsx:36-55`).
**Rejeitadas:** **B** porque exige ffmpeg no caminho público e não está no appetite; **C** porque o link abriria sem preview mesmo havendo imagem do site.
### D6 — Card personalizado no catálogo (tile sintético vs peça catalogada)
**Opções:** A) tile sintético da Central (copy fixa do design, `Fazer meu card` → `/cards`, no mesmo grid, só sem filtro ativo); B) a peça de `type: 'card'` publicada pela assessoria é o convite; C) os dois.
**Recomendação:** **A** — o design aprovado desenha o convite como peça fixa do board ("SEU NOME + SUA FOTO", "Criado no seu aparelho.") e o caminho para `/cards` não pode depender de alguém lembrar de publicar uma linha (na reta final ele sumiria junto com o kill switch). A peça catalogada de `type: 'card'` segue como peça comum (arquivo próprio, `Baixar`/`Compartilhar`), sem comportamento especial — o tipo é rótulo editorial (`src/lib/contentPiece.ts:229-235`).
**Rejeitadas:** **B** porque acopla a descoberta do estúdio ao ciclo editorial e contraria a copy fixa do design; **C** porque duplicaria o convite quando houver peça de card publicada. Gatilho: se a comunicação quiser editar a copy do convite sem deploy, ele vira uma peça publicada (item curto).
### D7 — Descoberta (rodapé + selo; sem seção nova)
**Opções:** A) `CampaignFooter` ganha `showConteudos` + `current: 'jingles' | 'conteudos'` (link "Conteúdos" enquanto houver publicado) e a Central ganha selo no header local (`ContentPiecePageHeader`, molde `JinglePageHeader`); B) seção nova na home; C) item no `SiteHeader`.
**Recomendação:** **A** — é o contrato do S21 (descoberta condicional no rodapé + selo local, `src/components/CampaignFooter.tsx:26-33,58-68`), a intenção veda seção nova na home e o `SiteHeader` é o shell genérico do site institucional. Callers: home (`src/app/(frontend)/(home)/page.tsx:250`), cards (`src/app/(frontend)/(home)/cards/page.tsx:27,62`), jingles (`src/app/(frontend)/jingles/page.tsx:100`) e a própria Central (`current="conteudos"`).
**Rejeitadas:** **B** porque está fora do escopo explícito ("redesign do shell/nav/home"); **C** porque o header genérico serve o site inteiro.
### Componentes / mudanças
- **`src/lib/contentPieceCatalog.ts`** (novo, puro, sem `server-only`): contrato público do catálogo — `CONTENT_PIECE_CATALOG_PATH`, `contentPiecePublicPath(slug)`, `contentPieceMediaPath(slug)`, params (`tipo`, `cidade`, `regiao`, `tema`, `instituicao`, `q`), `parseContentPieceCatalogParams`, `buildContentPieceCatalogHref`, `filterContentPieceCatalogRows`, `contentPieceCatalogFacets`, `toContentPiecePublicItem` (VM serializável) e `contentPieceDownloadFilename` (molde `jingleDownloadFilename`, `src/lib/jingle.ts:40-51`). Reusa `contentPieceIsPublic`, labels, `CONTENT_PIECE_TYPES`/`SPEECH_TOPICS` de `src/lib/contentPiece.ts:24-104,211-215`, `normalizeForSearch` (`src/lib/speechSearch.ts`) e `slugify` (`src/lib/slug.ts`).
- **`src/lib/contentPieceShare.ts`** (novo, puro): `contentPieceVoteMessage(type, title, link)` com os três literais da intenção, `contentPieceShareLink(item, origin)` (página da peça para arquivo; `sourceUrl` para peça-link) e `buildContentPieceWhatsAppUrl` sobre `buildWhatsAppTextShareUrl`.
- **`src/utilities/content/contentPieceReads.ts`** (novo, `server-only`): `getPublishedContentPieceCatalog()` (find `status=publicado`, sort `-publishedAt`/`-id`, select enxuto com `searchText` + `media` depth 1, `unstable_cache` na tag `getCollectionListingTag('contentPiece')`, filtro `contentPieceIsPublic`), `getPublishedContentPieceBySlug(slug)` (mesma tag; padrão `shareLinkReads.ts:33-45`) e `hasPublishedContentPieces()`.
- **`src/app/(frontend)/conteudos/`** (novo): `layout.tsx` (`data-theme="campaign-site"` + scroll container, molde `jingles/layout.tsx:8-17`); `page.tsx` (catálogo + `generateMetadata` com canonical e noindex-vazio, molde `jingles/page.tsx:29-81`); `loading.tsx` (skeleton da cena 05); `[slug]/page.tsx` (peça + `notFound()` uniforme + metadata/OG de D5); `[slug]/not-found.tsx` (cena 05, molde `corte/not-found.tsx`); `[slug]/midia/route.ts` (GET, `dynamic = 'force-dynamic'`, gate `contentPieceIsPublic` + `buildPrivateMediaResponse` com `staticDir` de `payload.collections[CONTENT_MEDIA_SLUG]` como na rota interna `[id]/arquivo/route.ts:74-83`).
- **`src/components/conteudos/`** (novo): `ContentPiecePageHeader` (molde `JinglePageHeader.tsx:10-31`, com "Voltar à Central" na página), `ContentPieceHero` (eyebrow + headline + número 1313), `ContentPieceFilters` (chips `<details>` + form GET com hidden inputs das facetas ativas), `ContentPieceCatalog` (client: grid + play exclusivo, molde `JingleCards.tsx:85-165`), `ContentPieceCard`, `ContentPieceCardInvite` (tile de D6), `ContentPieceMedia` (vídeo/áudio `preload="none"` + overlay de play; foto inline; erro de mídia mostra "Baixe a peça"), `ContentPieceShareSheet` (textarea editável + `Abrir no WhatsApp` + `A mídia` + `Copiar link`; reusa `useCopyFeedback`/`CopyLinkButton`, `src/components/CopyLinkButton.tsx:22-46`, e o molde de popover de `ContentShareButton.tsx:44-108`), `ContentPieceActions` (`Baixar`/`Compartilhar`; peça-link sem `Baixar`/`A mídia`), `ContentPieceEmptyState`, `ContentPieceNoResults`.
- **`src/components/CampaignFooter.tsx`** (editar, `:26-33,58-68`): `showConteudos` + `current: 'jingles' | 'conteudos'`; link "Conteúdos" antes de "Jingles".
- **`src/lib/privateMedia.ts`** (editar, `:16-24`): allowlist inline de D2 + unit.
- **`src/lib/shareLink.ts`** (editar, `:9-23`): `'conteudos'` em `SHARE_LINK_RESERVED_SLUGS`.
- **Migration:** **sem migration** — nenhum campo/collection novo; `contentPiece`/`contentMedia` vieram do C211 (migration `20260923_032714_add_content_piece`) e o S27 só lê.
- **Access / Consent:** nenhum access novo; leitura pública por Local API sem `user` com `where` publicado + predicado (bypass default documentado — o gate é a query); `contentMedia` segue privada; sem `Consent` e sem PII (a Central não captura nada).
- **UI:** Impeccable C — port **classe-a-classe** do design aprovado (5 cenas), shape→craft→critique→polish sobre os shells públicos existentes; gaps de design em trigger (a), abaixo.
- **Changelog:** `docs/changelog/2026-09-23-s27.md` (uma entrada curta; nunca editar o agregado/HISTORY).
### Dados → forma (se aplicável)
N/A — a superfície não apresenta KPI, agregado, contador ou série (a intenção declara "Dados: N/A"). O `dl Tema · Local · Duração` da página é metadado de conteúdo da peça, não apresentação de dado.
## Design tier
Artefato aprovado no gate: `docs/plans/central-conteudos-publica-ui-design.html` (assets em `central-conteudos-publica-ui-design-assets/`). Superfícies cobertas:
- **Cena 01 — desktop 1280:** header da Central (logo negativo + "Central de Conteúdos"), hero (eyebrow `PEÇA VOTO PRA SOLLA 1313`, headline, número 1313), busca + CTA `Escolher uma peça e pedir voto`, chips `Tipo⌄ Cidade⌄ Região⌄ Tema⌄ Instituição⌄`, grid 3-col (vídeo/áudio/card) + 2-col (foto/texto), card com asset+play+duração, tag, título, metadados e `Baixar`/`Compartilhar`, tile de Card com `Fazer meu card`, rodapé eleitoral.
- **Cenas 02–03 — mobile 390:** antes do play (asset + play + "Toque no play para carregar só este vídeo") e depois do play (player com progresso) + **sheet de compartilhamento** (título "Peça esse voto no WhatsApp", textarea editável com a mensagem, `Abrir no WhatsApp`, `A mídia`, `Copiar link`, nota "Sem destinatário e sem envio automático…").
- **Cena 04 — desktop:** página individual (preview, tag, título, descrição, `dl` Tema/Local/Duração, `Baixar`/`Compartilhar`, "Voltar à Central").
- **Cena 05 — estados:** vazio ("As primeiras peças estão a caminho" + `Fazer meu card`), indisponível ("Esta peça não está disponível" + `Ver outras peças`), carregando (skeleton).
**Não cobertas → trigger (a) (dispatch do designer antes do markup final):**
1. **Catálogo com filtro ativo sem resultado** — o artefato cobre só o vazio global; fallback honesto na composição do vazio com "Limpar filtros" até o design chegar.
2. **Peça-link sem arquivo** (card e página) — asset placeholder por tipo e ações sem `Baixar`/`A mídia` (derivação direta das cenas cobertas; designer valida).
3. **Sheet de compartilhamento no desktop** — o artefato só mostra o 390; mesmo componente, designer valida posição/largura.
## Fases verificáveis
1. **Tracer / reads + serving — quota ~40%:** `src/lib/contentPieceCatalog.ts` + `src/lib/contentPieceShare.ts` + units; `src/utilities/content/contentPieceReads.ts`; `src/lib/privateMedia.ts` (allowlist) + unit; `src/lib/shareLink.ts` (`conteudos`) + pin; rota `[slug]/midia/route.ts`; int no `tests/int/contentPiece.int.spec.ts` (rascunho escondido, por slug, `contentPieceIsPublic`, rota 200/206/404/attachment, foto inline vs texto octet-stream) verdes antes de seguir.
2. **UI (port classe-a-classe) — quota ~45%:** `layout/page/loading/[slug]/not-found`, componentes de `src/components/conteudos/`, metadata/OG (D5), `CampaignFooter` + callers; e2e `frontendConteudos.e2e.spec.ts` + manifest + curated pin.
3. **Gates — quota ~15%:** `pnpm gate:fast` (lint/format/typecheck/knip/cycles/unit); `pnpm test:int`; e2e curado; `pnpm push` → PR `--base main` (a verificação viva é o e2e de rotas reais).
## Testes previstos
- **`tests/unit/contentPieceCatalog.unit.spec.ts`** (novo): parse (params válidos/desconhecidos/valores inválidos; um valor por faceta), href canônico (ordem e dedupe), filtro combinado (`tipo`+`cidade`+`q`; busca sem acento), facetas só do publicado, VM (arquivo vs link; `downloadFilename`; card; `contentPieceIsPublic` exclui publicado sem arquivo e sem link).
- **`tests/unit/contentPieceShare.unit.spec.ts`** (novo): os três literais com título/link; link da plataforma para peça-link vs página para peça-arquivo; `wa.me/?text=` sem destinatário (path `/`).
- **`tests/unit/privateMedia.unit.spec.ts`** (editar, `:30-36`): webp/gif/avif e os áudios inline; `image/svg+xml` → `application/octet-stream`.
- **`tests/unit/shareLink.unit.spec.ts`** (editar, `:37-53`): `'conteudos'` na lista literal ordenada (o drift test `:136-147` passa a exigir sozinho).
- **`tests/unit/e2eAffectedManifest.unit.spec.ts`** (editar, `:54-79`): `frontendConteudos` no set curado com o comentário "deliberate".
- **`tests/int/contentPiece.int.spec.ts`** (editar; já mocka `next/cache`, `:9-13`): leitura anônima continua negada por access, mas o módulo público mostra só publicado (rascunho some; despublicado some e a rota 404a; slug preservado); rota de mídia com `range` 206, `?download=1` attachment e 404 uniforme; foto inline vs texto forçado a download (molde do helper `callFileRoute`, `:149-162`).
- **`tests/e2e/frontendConteudos.e2e.spec.ts`** (novo, seeding REST admin como `frontendJingles.e2e.spec.ts:33-113`): catálogo lista publicados; zero requests a `/conteudos/*/midia` antes do play e play busca só a peça tocada; filtro e busca na URL; página da peça 200 + `og:title`/`og:image` (foto); tile de Card → `/cards`; sheet com a mensagem de voto editável e `Abrir no WhatsApp` sem destinatário; `A mídia` cai em download no headless; `Baixar` com nome legível; kill switch (some da lista, página 404, vazio 200 + `noindex`, selo e link do rodapé somem).
## Pins a atualizar (valores exatos)
- **`src/lib/shareLink.ts:9-23`** — `'conteudos'` na lista; **`tests/unit/shareLink.unit.spec.ts:37-53`** — literal ordenado com `'conteudos'`.
- **`scripts/lib/e2e-affected-manifest.mjs`** — entrada nova (junto à de jingles, `:95-118`): prefixes `src/app/(frontend)/conteudos`, `src/components/conteudos`, `src/utilities/content/contentPieceReads.ts`, `src/lib/contentPieceCatalog`, `src/lib/contentPieceShare`, `src/lib/privateMedia`, `src/components/CampaignFooter.tsx` → specs `['frontendConteudos']`; o classificador faz união, então as entradas de campanha/jingles/share-link existentes ficam.
- **`tests/unit/e2eAffectedManifest.unit.spec.ts:54-79`** — `'frontendConteudos'` no `E2E_CURATED_SPECS` (comentário: "S27 — deliberate: catálogo/página/serving público novos e a migration do C211 torna o PR high-risk").
- **`tests/unit/codebaseConventions.unit.spec.ts:202-237`** — **intocado**: a rota é GET-only, sem POST.
- **`tests/unit/codebaseConventions.unit.spec.ts:409-556`** — **intocado**: nada novo no top-level de `src/utilities/` (tudo em `src/utilities/content/`).
- **`src/lib/privateMedia.ts` + `tests/unit/privateMedia.unit.spec.ts`** — allowlist inline (D2).
## Rabbit holes / Não escopo (engenharia)
- **Poster/thumbnail de vídeo/áudio, transcode de mkv/avi, player com trechos** — preview é o arquivo; play sob demanda; erro de mídia mostra o download honesto.
- **Analytics, contadores, UTM, rastreio e consentimento do visitante** — C213; a Central não captura nada.
- **Busca semântica/embeddings** — S28; aqui `searchText` normalizado + `includes`.
- **Paginação/infinite scroll** — catálogo pequeno; renderiza tudo (gatilho: catálogo > ~200 peças).
- **Zip/lote de download, re-hospedar mídia de terceiro, embed de Instagram/YouTube, segundo catálogo/edição na Central** — vedados pela intenção.
- **`navigator.share` de arquivo no desktop** — não suportado; fallback download é o contrato, nunca fingir anexo.
- **SEO de bandeira/landing, sitemap/JSON-LD da Central** — fora de escopo (gatilho: SEO da campanha).
- **Collection/Consent/`Contact` novos** — proibidos; sem PII.
## Riscos e mitigação
- **Kill switch não refletir na hora:** todas as leituras usam a tag de listing bustada no `afterChange`; e2e despublica e faz poll da lista/página/rodapé.
- **Rascunho vazando:** nenhuma leitura pública sem `status=publicado`; `contentMedia` segue privada; rota por slug com 404 uniforme; int cobre rascunho/despublicado/inexistente.
- **Mídia pesada/repetida:** `preload="none"` + play exclusivo (precedente `JingleCards.tsx:276-290`); e2e conta requests antes do play; `private, no-store` evita cache compartilhado (gatilho de janela pública em D2).
- **`navigator.canShare`/`File` indisponível:** fallback `<a download>`; "A mídia" com estado de preparo e erro honesto; e2e cobre o fallback.
- **Busca sem acento divergente:** `normalizeForSearch` nos dois lados (C211 grava normalizado, `src/lib/contentPiece.ts:323-336`); unit cobre.
- **Slug reservado esquecido:** `conteudos` entra em `SHARE_LINK_RESERVED_SLUGS` + pin; o drift test falha sozinho.
- **MIME inline:** tipos inertes, sem SVG; unit do `privateMedia` cobre.
- **OG sem imagem em vídeo/áudio:** fallback do global `metadata`; sem imagem o link ainda abre com título/descrição (aceito, sem gerar thumbnail).
- **`depth: 1` na lista:** catálogo pequeno com select enxuto; se crescer, depth 0 + resolução de mídia por id (padrão `jingleReads.ts:45-54`).
- **PR toca `src/lib/privateMedia`, `src/lib/shareLink` e `src/utilities/content`:** e2e selecionado roda o conjunto mapeado; `gate:fast` + int local antes do push.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: `/conteudos` com peças publicadas filtráveis (Tipo · Cidade · Região · Tema · Instituição + termo) e combináveis; `/conteudos/<slug>` com preview OG; play sob demanda de Vídeo/Áudio e Foto com preview leve; `Baixar` e `Compartilhar` (`Mensagem no WhatsApp` · `A mídia` · `Copiar link`) do card e da página; peça de Card → `Fazer meu card` → `/cards`; mensagens de voto dos literais, sempre editáveis; peça-link pelo link da plataforma; kill switch com estado honesto e descoberta que some; chamada/CTA de pedido de voto.
- [ ] Guardrails: sem login/cadastro/PII/rastreio; `wa.me/?text=` sem destinatário e sem envio; sem mídia de terceiro baixada/re-hospedada; sem segunda cópia; cards 100% no aparelho; rodapé eleitoral preservado.
- [ ] Invariantes AGENTS/engineering-standards: Local API sem `user` com bypass default documentado e gate por `where`/predicado; nenhuma escrita multi-collection nova; identificadores em inglês/copy pt-BR; migrations existentes intocadas; `contentMedia` segue privada.
- [ ] Testes de domínio previstos (unit/int) onde leitura pública e serving mudam; pins de shareLink/manifest/conventions atualizados.
- [ ] `pnpm gate:fast` verde; `pnpm test:int` do spec verde; e2e `frontendConteudos` verde; `pnpm push` via GitHub.
## Decisões de engenharia
- **D1 — Leitura pública:** módulo `contentPieceReads` com `unstable_cache` na tag `contentPieces` + `contentPieceIsPublic` como gate; slug pela mesma tag. Rejeitadas: find direto por página (duplicação/sem cache) e loader interno (fronteira e campos de staff).
- **D2 — Serving:** rota GET `/conteudos/<slug>/midia` reusando `buildPrivateMediaResponse` (range/disposition) com `private, no-store` e 404 uniforme; allowlist inline do dono ganha webp/gif/avif + áudios; download `?download=1` + atributo legível. Rejeitadas: rota em `/api` (contrato partido) e `contentMedia` pública (vaza rascunho).
- **D3 — Filtro/busca:** querystring canônica (`tipo`/`cidade`/`regiao`/`tema`/`instituicao`/`q`, um valor por faceta) + filtragem em memória no servidor, contrato puro novo; canonical `/conteudos`; sem importar o interno. Rejeitadas: client-state (sem URL/JS) e contrato interno (vocabulário de staff).
- **D4 — Mensagens de voto:** módulo puro novo por tipo de peça, link por tipo (página vs `sourceUrl`), editável no sheet. Rejeitadas: estender `contentShare.ts` (pin do S4) e strings soltas.
- **D5 — OG:** foto com arquivo usa `/conteudos/<slug>/midia` no origin de deploy; demais caem na imagem do global. Rejeitadas: gerar thumbnail (fora do appetite) e sem imagem (perde preview).
- **D6 — Card no catálogo:** tile sintético com a copy do design → `/cards`, só sem filtro; peça `type: 'card'` é peça comum. Rejeitadas: peça publicada como convite (some com o kill switch) e duplicação.
- **D7 — Descoberta:** `CampaignFooter showConteudos`/`current` + selo no header local; sem seção na home e sem `SiteHeader`. Rejeitadas: seção nova (fora de escopo) e nav genérica.
- **Sem migration, sem Consent, sem access novo** — registrado, não presumido.
## Self-score
**Self-score decision-quality: 5/5.** (1) Todas as decisões caras (leitura pública, serving/cache/MIME, contrato de filtro, mensagens, OG, card, descoberta) têm opções + recomendação + rejeitadas explícitas. (2) Cabe no appetite herdado: sem schema/migration, reusa o helper de I/O privado, a tag do C211, `wa.me`, `useCopyFeedback`, `slugify` e os padrões de `/jingles` e `/corte`; o custo é o port de 5 cenas. (3) Rabbit holes nomeados (poster, transcode, analytics, semântica, paginação, zip, embed, nav). (4) Depth check: shells/helpers existentes reusados; módulos novos só onde não há dono (contrato público do catálogo, mensagens de voto, reads públicos). (5) Outcome preservado: todos os literais e o aceite de produto estão mapeados; a única interpretação é o tile de card (D6), ancorada no design aprovado e com gatilho registrado, e as mensagens seguem "assumidas — validar com a comunicação" (copy, não engenharia).

View file

@ -494,6 +494,323 @@ Estamos na reta final e o Jorge Solla 1313 precisa do seu voto. Olha esse vídeo
</article>
</div>
</section>
<section id="scene-06-no-results">
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
<span>CENA 06</span>
<h2>Filtros ativos sem resultado · desktop 1280 e mobile 390</h2>
</div>
<div class="space-y-8">
<div class="desktop frame mx-auto overflow-hidden rounded-xl bg-white">
<header class="flex h-16 items-center justify-between bg-[#ae1603] px-10">
<img
src="../../public/campaign-kit/jorge-solla-negativo.png"
class="h-9"
alt="Jorge Solla"
/><span class="text-sm font-bold text-white">Central de Conteúdos</span>
</header>
<div class="mx-auto max-w-6xl px-10 py-10">
<div class="grid grid-cols-[1fr_auto] gap-3">
<div class="field flex items-center justify-between text-[#171000]">
<span>mobilidade em Juazeiro</span>
<span class="text-lg leading-none text-[#6c615e]" aria-hidden="true">×</span>
</div>
<button class="btn primary px-6">Buscar peças</button>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="chip border-[#184e92]/35 bg-[#eef4fb] text-[#184e92]"
>Tipo · Vídeo <b class="ml-2 text-base leading-none">×</b></span
>
<span class="chip border-[#184e92]/35 bg-[#eef4fb] text-[#184e92]"
>Cidade · Juazeiro <b class="ml-2 text-base leading-none">×</b></span
>
<span class="chip">Região⌄</span><span class="chip">Tema⌄</span
><span class="chip">Instituição⌄</span>
</div>
<div
class="mt-9 rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-8 py-14 text-center"
>
<div
class="mx-auto grid size-12 place-items-center rounded-full border border-[#184e92]/20 bg-white text-xl text-[#184e92]"
aria-hidden="true"
>
⌕
</div>
<h3 class="display mt-4 text-2xl font-black">Nenhuma peça com esses filtros</h3>
<p class="mx-auto mt-2 max-w-md text-sm leading-6 text-[#6c615e]">
Tente outro termo ou limpe os filtros para ver todas as peças.
</p>
<div class="mt-4 flex flex-wrap justify-center gap-2 text-xs text-[#6c615e]">
<span class="rounded-full border border-black/10 bg-white px-3 py-1.5"
>Busca: “mobilidade em Juazeiro”</span
><span class="rounded-full border border-black/10 bg-white px-3 py-1.5"
>Vídeo</span
><span class="rounded-full border border-black/10 bg-white px-3 py-1.5"
>Juazeiro</span
>
</div>
<a class="btn primary mt-6 min-w-40" href="#">Limpar filtros</a>
</div>
</div>
</div>
<div class="phone frame mx-auto min-h-[760px] overflow-hidden bg-white">
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4">
<img
src="../../public/campaign-kit/jorge-solla-negativo.png"
class="h-7"
alt="Jorge Solla"
/><span class="text-white">☰</span>
</header>
<div class="bg-[linear-gradient(145deg,#e4102f,#184e92)] px-5 py-6 text-white">
<p class="text-xs font-bold text-[#ffeb00]">PEÇA VOTO PRA SOLLA 1313</p>
<h3 class="display mt-1 text-2xl font-black leading-tight">
Encontre a peça certa para pedir voto.
</h3>
</div>
<div class="p-4">
<div class="field flex items-center justify-between text-sm">
<span class="truncate">mobilidade em Juazeiro</span
><span class="ml-3 text-lg leading-none text-[#6c615e]">×</span>
</div>
<div class="mt-3 flex gap-2 overflow-hidden">
<span class="chip shrink-0 border-[#184e92]/35 bg-[#eef4fb] text-[#184e92]"
>Vídeo <b class="ml-2 text-base leading-none">×</b></span
><span class="chip shrink-0 border-[#184e92]/35 bg-[#eef4fb] text-[#184e92]"
>Juazeiro <b class="ml-2 text-base leading-none">×</b></span
><span class="chip shrink-0">Tema⌄</span>
</div>
<div
class="mt-5 rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-10 text-center"
>
<div
class="mx-auto grid size-11 place-items-center rounded-full border border-[#184e92]/20 bg-white text-lg text-[#184e92]"
aria-hidden="true"
>
⌕
</div>
<h3 class="display mt-4 text-xl font-black">Nenhuma peça com esses filtros</h3>
<p class="mt-2 text-sm leading-5 text-[#6c615e]">
Tente outro termo ou limpe os filtros para ver todas as peças.
</p>
<div class="mt-4 flex flex-wrap justify-center gap-1.5 text-[11px] text-[#6c615e]">
<span class="rounded-full border border-black/10 bg-white px-2.5 py-1"
>Busca: “mobilidade em Juazeiro”</span
><span class="rounded-full border border-black/10 bg-white px-2.5 py-1"
>Vídeo</span
><span class="rounded-full border border-black/10 bg-white px-2.5 py-1"
>Juazeiro</span
>
</div>
<a class="btn primary mt-6 w-full" href="#">Limpar filtros</a>
</div>
</div>
</div>
</div>
</section>
<section id="scene-07-link-piece">
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
<span>CENA 07</span>
<h2>Peça-link sem arquivo · card no catálogo e página da peça</h2>
</div>
<div class="desktop frame mx-auto overflow-hidden rounded-xl bg-white">
<header class="flex h-16 items-center justify-between bg-[#ae1603] px-10">
<img
src="../../public/campaign-kit/jorge-solla-negativo.png"
class="h-9"
alt="Jorge Solla"
/><span class="text-sm font-bold text-white">Central de Conteúdos</span>
</header>
<div class="mx-auto max-w-6xl px-10 py-10">
<p class="text-xs font-bold text-[#6c615e]">NO CATÁLOGO</p>
<div class="mt-3 grid max-w-[746px] grid-cols-2 gap-6">
<article class="card">
<div
class="relative grid aspect-video place-items-center overflow-hidden bg-[linear-gradient(145deg,#fff4f5,#eef4fb)]"
>
<div
class="absolute -right-10 -bottom-16 size-48 rounded-full border-[28px] border-[#e4102f]/10"
></div>
<div class="relative text-center">
<p class="text-[10px] font-black tracking-[.18em] text-[#6c615e]">
PEÇA NO INSTAGRAM
</p>
<p class="display mt-2 text-2xl font-black text-[#e4102f]">Instagram</p>
<p class="mt-1 text-xs text-[#6c615e]">Conteúdo hospedado na plataforma</p>
</div>
</div>
<div class="p-4">
<span class="tag">Instagram</span>
<h4 class="mt-2 text-base font-extrabold">
Solla defende mais recursos para o SUS
</h4>
<p class="mt-1 text-xs text-[#6c615e]">Saúde · Bahia</p>
<button class="btn outline mt-4 w-full">Compartilhar</button>
</div>
</article>
<article class="card">
<div
class="relative grid aspect-video place-items-center overflow-hidden bg-[linear-gradient(145deg,#eef4fb,#fff8f2)]"
>
<div
class="absolute -left-10 -top-16 size-48 rounded-full border-[28px] border-[#184e92]/10"
></div>
<div class="relative text-center">
<p class="text-[10px] font-black tracking-[.18em] text-[#6c615e]">
PEÇA NO YOUTUBE
</p>
<p class="display mt-2 text-2xl font-black text-[#184e92]">YouTube</p>
<p class="mt-1 text-xs text-[#6c615e]">Conteúdo hospedado na plataforma</p>
</div>
</div>
<div class="p-4">
<span class="tag">YouTube</span>
<h4 class="mt-2 text-base font-extrabold">
Por que o voto no 1313 faz diferença
</h4>
<p class="mt-1 text-xs text-[#6c615e]">Campanha</p>
<button class="btn outline mt-4 w-full">Compartilhar</button>
</div>
</article>
</div>
<div class="mt-10 border-t border-black/10 pt-8">
<p class="text-xs font-bold text-[#6c615e]">NA PÁGINA DA PEÇA</p>
<div class="mt-3 grid grid-cols-[1.2fr_.8fr] gap-10">
<div
class="relative grid aspect-video place-items-center overflow-hidden rounded-2xl border border-[#184e92]/15 bg-[linear-gradient(145deg,#fff4f5,#eef4fb)]"
>
<div
class="absolute -right-16 -bottom-24 size-72 rounded-full border-[42px] border-[#e4102f]/10"
></div>
<div class="relative text-center">
<p class="text-xs font-black tracking-[.2em] text-[#6c615e]">
PEÇA NO INSTAGRAM
</p>
<p class="display mt-3 text-4xl font-black text-[#e4102f]">Instagram</p>
<p class="mt-2 text-sm text-[#6c615e]">
A mídia permanece na plataforma original.
</p>
</div>
</div>
<div>
<span class="tag">Instagram</span>
<h3 class="display mt-3 text-3xl font-black">
Solla defende mais recursos para o SUS
</h3>
<p class="mt-3 text-sm leading-6 text-[#6c615e]">
Compartilhe esta publicação e peça voto pra Solla 1313 a quem você conhece.
</p>
<dl class="mt-5 space-y-2 border-y py-4 text-sm">
<div class="flex justify-between">
<dt class="text-[#6c615e]">Tema</dt>
<dd>Saúde</dd>
</div>
<div class="flex justify-between">
<dt class="text-[#6c615e]">Local</dt>
<dd>Bahia</dd>
</div>
<div class="flex justify-between">
<dt class="text-[#6c615e]">Origem</dt>
<dd>Instagram</dd>
</div>
</dl>
<button class="btn primary mt-5 w-full">Compartilhar</button>
<p class="mt-3 text-center text-xs leading-5 text-[#6c615e]">
O compartilhamento usa o link da publicação no Instagram.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="scene-08-desktop-share-sheet">
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
<span>CENA 08</span>
<h2>Compartilhamento · desktop 1280, ancorado ao card</h2>
</div>
<div
class="desktop frame relative mx-auto min-h-[720px] overflow-hidden rounded-xl bg-white"
>
<header class="flex h-16 items-center justify-between bg-[#ae1603] px-10">
<img
src="../../public/campaign-kit/jorge-solla-negativo.png"
class="h-9"
alt="Jorge Solla"
/><span class="text-sm font-bold text-white">Central de Conteúdos</span>
</header>
<div class="mx-auto max-w-6xl px-10 py-10">
<div class="grid grid-cols-3 gap-6">
<article class="card">
<div class="asset relative aspect-video">
<span>NEEDS ASSET · POSTER REAL</span
><button
class="absolute grid size-14 place-items-center rounded-full bg-[#ffe607] text-xl shadow-lg"
aria-label="Reproduzir vídeo"
>
▶
</button>
</div>
<div class="p-4">
<span class="tag">Vídeo</span>
<h4 class="mt-2 text-base font-extrabold">Fim da escala 6×1 é saúde</h4>
<p class="mt-1 text-xs text-[#6c615e]">Trabalho · Salvador</p>
<div class="mt-4 grid grid-cols-2 gap-2">
<button class="btn outline">Baixar</button
><button class="btn primary">Compartilhar</button>
</div>
</div>
</article>
<article class="card opacity-45">
<div class="asset aspect-video">NEEDS ASSET · CAPA REAL DO ÁUDIO</div>
<div class="p-4">
<span class="tag">Áudio</span>
<h4 class="mt-2 text-base font-extrabold">Mensagem para mandar no grupo</h4>
<div class="mt-4 grid grid-cols-2 gap-2">
<button class="btn outline">Baixar</button
><button class="btn outline">Compartilhar</button>
</div>
</div>
</article>
</div>
</div>
<div class="absolute inset-0 top-16 bg-black/12"></div>
<aside
class="absolute top-[285px] left-[430px] w-[430px] rounded-2xl border border-black/12 bg-white p-5 shadow-[0_20px_55px_rgb(0_0_0/22%)]"
aria-label="Compartilhar peça"
>
<div
class="absolute top-[116px] -left-2 size-4 rotate-45 border-b border-l border-black/12 bg-white"
></div>
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="display text-xl font-black">Peça esse voto no WhatsApp</h4>
<p class="mt-1 text-xs text-[#6c615e]">A mensagem é sua: revise antes de abrir.</p>
</div>
<button
class="grid size-8 shrink-0 place-items-center rounded-full border border-black/10 text-lg text-[#6c615e]"
aria-label="Fechar"
>
×
</button>
</div>
<textarea class="field mt-4 min-h-32 resize-none leading-5">
Estamos na reta final e o Jorge Solla 1313 precisa do seu voto. Olha esse vídeo: Fim da escala 6×1 é saúde — &lt;link&gt;. Peça voto pra Solla 1313 pra quem você conhece.</textarea
><button class="btn primary mt-3 w-full">Abrir no WhatsApp</button>
<div class="mt-3 grid grid-cols-2 gap-2">
<button class="btn outline">A mídia</button
><button class="btn outline">Copiar link</button>
</div>
<p class="mt-3 text-center text-[11px] leading-4 text-[#6c615e]">
Sem destinatário e sem envio automático. Se o aparelho não compartilhar o arquivo, ele
será baixado.
</p>
</aside>
</div>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,56 @@
# Escala e DRY pós-S27 (Central de Conteúdos pública)
Status: rascunho
Atualizado em: 2026-09-23
Issue: a registrar (`depends: [1255]`)
Item do roadmap: — (follow-up de engenharia da entrega S27)
Responsável: —
Appetite: ~0,5 dia eng (fill-in; uma extração + migração de call sites, sem migration)
Impeccable: A — só backend/refactor (nenhuma superfície visual muda)
## Contexto
A entrega S27 ([central-conteudos-publica.md](central-conteudos-publica.md), Issue #1255) criou a Central pública (`/conteudos` + peça + serving privado + share de voto) e a passagem `/simplify` (2 revisores, 0 achados altos) aplicou as correções da própria sessão: type honesty do VM (`file` como união, sem campos parcialmente populados), `ContentPieceActions` único para baixar/compartilhar, `resolvePrivateMediaStaticDir` no dono da mídia privada (rota pública + interna), foco/restauração do sheet, `-right-auto` inválido do blob do YouTube, partição única large/compact no catálogo, filtros computados uma vez, `ui.ts` → `contentPieceClasses.ts` e o script temporário de screenshots removido. O que sobrou é **um** débito maior que o cleanup da sessão: o fallback de imagem OG (global `metadata` → `contentMedia`/`media`) está repetido em 6–7 páginas públicas — o S27 adicionou a 5ª e a 6ª cópia (`conteudos/(catalog)/page.tsx` e `conteudos/[slug]/page.tsx`), ao lado de `jingles`, `corte/[id]` e `abaixo-assinado/[id]` (×2). Sem este item, a próxima página pública copia o bloco de novo e o dia em que o fallback mudar (ex.: formato do global, imagem por tipo) a mudança fica espalhada.
## Objetivos
- O fallback de imagem OG do frontend público vive **num dono único** server-only, com a assinatura que os call sites já usam: resolve a imagem configurada (quando houver), senão a imagem do global `metadata` (populando o id quando numérico), senão `null` — sempre absoluta contra o origin de deploy (`resolveDeploymentOrigin`), nunca contra o domínio canônico do global.
- `src/utilities/seo.ts` permanece **puro** (sem I/O): o resolver com I/O nasce ao lado dos reads de global (`src/utilities/ogImageReads.ts` ou equivalente), reusando `resolveSiteMetadata`/`toAbsoluteUrl`.
- Migrar os **2 call sites do S27** e, na sequência, os pré-existentes (`jingles/page.tsx`, `corte/[id]/page.tsx`, `abaixo-assinado/[id]/page.tsx` ×2, `[type]/[category]/[slug]/page.tsx`, `layout.tsx`) sem mudar comportamento observável (mesmas URLs de OG nos e2e atuais).
- `artigos/page.tsx` fica fora: a fonte da imagem é `home.image`, não o global.
- Guardrails: sem migration; sem mudança de comportamento visível; `pnpm gate:fast` + int + e2e das rotas públicas verdes.
## Fases
1. **F1 — dono único (maior ROI).** Criar o resolver server-only (global `metadata` + mídia + origin) e migrar os 2 call sites do S27. Prova: unit do resolver (sem imagem → `null`; global numérico → media populada; URL relativa → absoluta no deployment origin) + e2e `frontendConteudos` (og:image) verde.
2. **F2 — migrar as cópias pré-existentes.** `jingles`, `corte/[id]`, `abaixo-assinado/[id]` (×2), `[type]/[category]/[slug]` e o `layout.tsx` que hoje repetem o bloco. Prova: e2e `frontendJingles`/`campaignSpeechCut`/`frontend` verdes sem mudança de asserção.
3. **F3 — apertar a rede.** Caracterização do OG por rota (meta `og:image`) onde o e2e já cobre a página; nenhum teste novo além do unit de F1.
## Já resolvido no simplify (não reabrir)
- Type honesty do VM público: `file` é união (`{ id, path, mimeType, downloadFilename } | null`), sem `mediaId`/`mediaPath`/`mediaMimeType`/`downloadFilename` soltos.
- `ContentPieceDownloadLink`/`ContentPieceShareButton` únicos (`ContentPieceActions.tsx`), usados por card e página.
- `resolvePrivateMediaStaticDir` no dono `src/utilities/privateMedia/privateMediaResponse.ts`, usado pela rota pública e pela interna do C211.
- Sheet de compartilhamento: foco inicial no textarea, restauração do foco ao fechar, overlay fora da ordem de tabulação.
- `-right-auto`/`-bottom-auto` inválidos corrigidos (`right-auto bottom-auto`) no placeholder de peça-link.
- Partição large/compact em um único loop; filtros ativos computados uma vez na page e passados por prop.
- `ui.ts` renomeado para `contentPieceClasses.ts`; `EMPTY_CONTENT_PIECE_CATALOG_PARAMS` e `hasActiveContentPieceCatalogFilters` removidos.
- Script temporário de screenshots removido antes do commit.
## Explicitamente fora (descartes e defers com gatilho)
- **`contentPieceDownloadFilename` gêmeo de `jingleDownloadFilename`** — defer, gatilho: 3º filename de download (corte/speech) **ou** próximo toque em `src/lib/jingle.ts` (2 call sites hoje; DRY prematuro).
- **`contentPieceReads` cacheia `media` em depth 1** — defer, gatilho já registrado no impl plan do S27: catálogo na casa dos milhares **ou** troca de arquivo de `contentMedia` relatada → depth 0 + resolução por id (padrão `jingleReads`).
- **`firstValue`/`ContentPieceCatalogSearchParams` duplicando `RawSearchParams`** — defer, gatilho: 5ª cópia **ou** próximo toque em `campaignListUrl.firstValue` (mover o canônico para `src/lib/` e reexportar; reusar hoje violaria a Dependency Rule lib→utilities).
- **`buildContentPieceWhatsAppUrl` wrapper pass-through** — descartar: é a costura de vocabulário do S27 (o irmão do S4 compõe mensagem+URL), com unit test; revisitar só com um 2º tipo de share.
- **Shell `conteudos/layout.tsx` idêntico ao de jingles** — defer, gatilho: 3º shell `campaign-site` → extrair um `CampaignScrollShell`.
## Self-score (decision-quality)
1. Decisões caras com rejeitadas? **Sim** — o lote é cheap_polish; nada de access/LGPD/schema/unicidade entra (piso 4–5 não se aplica) e os descartes/defers estão nomeados com gatilho.
2. Cabe no appetite? **Sim** (~0,5 dia, uma extração + migração mecânica, sem migration).
3. Rabbit holes nomeados? **Sim** — helper com 1 call site (defer), refatorar o normalizador compartilhado (defer), mover `seo.ts` para I/O (rejeitado: o módulo é puro por contrato).
4. Depth check? **Sim** — reusa `resolveSiteMetadata`/`toAbsoluteUrl`/`getCachedDocumentById`; cria um módulo só onde há I/O real.
5. Outcome do S27 intocado? **Sim** — só estrutura interna; as URLs de OG e o comportamento observável não mudam.
**Score: 5/5.**

View file

@ -198,6 +198,16 @@ export default defineConfig({
dependencies: isProdMode ? [] : ['frontend'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
{
// S27 — the Central de Conteúdos public page (catalogue, lazy media,
// vote share, kill switch). Owns its content piece rows only, so it
// serializes behind frontend in dev (shared dev-server boot) and runs
// parallel to it in prod.
name: 'frontendConteudos',
testMatch: /frontendConteudos\.e2e\.spec\.ts/,
dependencies: isProdMode ? [] : ['frontend'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
{
name: 'admin',
testMatch: /admin\.e2e\.spec\.ts/,

View file

@ -43,6 +43,10 @@ export const E2E_CURATED_SPECS = [
// S21 — deliberate: the jingles page/player/download contract is new and the
// migration makes every PR of this delivery high-risk (curated only).
'frontendJingles',
// S27 — deliberate: the Central catalogue/piece page, the public media route
// and the vote share sheet are new contracts over the C211 collection
// (curated only).
'frontendConteudos',
]
/**
@ -110,11 +114,31 @@ export const E2E_AFFECTED_MANIFEST = [
],
specs: ['frontendJingles'],
},
{
// S27 — the public Central de Conteúdos: the facet/term catalogue, the
// on-demand play, the private-media public route, the vote share sheet and
// the kill-switch contract over the C211 listing tag.
prefixes: [
'src/app/(frontend)/conteudos',
'src/components/conteudos',
'src/lib/contentPieceCatalog',
'src/lib/contentPieceShare',
'src/utilities/content/contentPieceReads.ts',
// The tag/revalidation vocabulary owner for the content pieces listing.
'src/utilities/documents.ts',
'src/collections/ContentPiece.ts',
'src/collections/ContentMedia.ts',
// The private-media response rules the public route reuses.
'src/lib/privateMedia',
],
specs: ['frontendConteudos'],
},
{
// S21 — the campaign footer owns the conditional "Jingles" discovery link
// (it renders on the home, the cards page and `/jingles`).
// S27 — and the conditional "Conteúdos" link.
prefixes: ['src/components/CampaignFooter.tsx'],
specs: ['frontend', 'frontendJingles'],
specs: ['frontend', 'frontendJingles', 'frontendConteudos'],
},
{
// S13/S30 — the personalized-cards studio lives in shared cards components

View file

@ -17,7 +17,10 @@ import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
import { campaignJsonMutationErrorResponse } from '@/utilities/campaignJsonMutationRoute'
import { attachContentPieceMedia } from '@/utilities/content/contentPieceUpload'
import { buildPrivateMediaResponse } from '@/utilities/privateMedia/privateMediaResponse'
import {
buildPrivateMediaResponse,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { ContentPieceAttachResponse } from '../../types'
@ -71,13 +74,9 @@ export const GET = async (
.catch(() => null)
if (!piece?.media || typeof piece.media !== 'object') return notFound()
const upload = payload.collections[CONTENT_MEDIA_SLUG].config.upload
const staticDir =
upload && typeof upload === 'object' && upload.staticDir ? upload.staticDir : CONTENT_MEDIA_SLUG
return buildPrivateMediaResponse({
media: piece.media,
staticDir,
staticDir: resolvePrivateMediaStaticDir(payload, CONTENT_MEDIA_SLUG),
rangeHeader: request.headers.get('range'),
download: new URL(request.url).searchParams.get('download') === '1',
})

View file

@ -6,6 +6,7 @@ import { CampaignFooter } from '@/components/CampaignFooter'
import { CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy'
import { CardsStudio } from '@/components/cards/CardsStudio'
import { isCardModelId } from '@/lib/cardModels'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
const intro =
@ -24,7 +25,10 @@ export default async function CardsPage({ searchParams }: CardsPageProps) {
const params = await searchParams
const rawModel = Array.isArray(params.model) ? params.model[0] : params.model
const initialModelId = isCardModelId(rawModel) ? rawModel : undefined
const showJingles = await hasPublishedJingles()
const [showJingles, showConteudos] = await Promise.all([
hasPublishedJingles(),
hasPublishedContentPieces(),
])
return (
<>
@ -59,7 +63,7 @@ export default async function CardsPage({ searchParams }: CardsPageProps) {
<CardsStudio initialModelId={initialModelId} fontFamily={brexterBold.style.fontFamily} />
</section>
</main>
<CampaignFooter showJingles={showJingles} />
<CampaignFooter showJingles={showJingles} showConteudos={showConteudos} />
</>
)
}

View file

@ -7,6 +7,7 @@ import { CampaignProblemCard } from '@/components/CampaignProblemCard'
import { CampaignStorySection } from '@/components/CampaignStorySection'
import { JingleHomeSection } from '@/components/jingles/JingleHomeSection'
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getPublishedJingleItems } from '@/utilities/jingleReads'
import type { ReactNode } from 'react'
import { CampaignCardsSection } from './CampaignCardsSection'
@ -119,6 +120,8 @@ export default async function HomePage() {
// S21/S22 — one cached listing (`unstable_cache` tag `jingles`) serves the
// home sound section and the footer's discovery flag, keeping the home static.
const jingles = await getPublishedJingleItems()
// S27 — the Central's discovery flag (cached under the `contentPieces` tag).
const showConteudos = await hasPublishedContentPieces()
const homeJingles = jingles.slice(0, 3)
return (
@ -247,7 +250,7 @@ export default async function HomePage() {
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
</main>
<CampaignFooter showJingles={jingles.length > 0} />
<CampaignFooter showJingles={jingles.length > 0} showConteudos={showConteudos} />
</>
)
}

View file

@ -0,0 +1,27 @@
/**
* S27 — the loading state of the Central (artefato: cena 05): the skeleton
* shape of the board, so a slow connection sees the page it is about to get.
*/
export default function ConteudosLoading() {
return (
<div className="mx-auto w-full max-w-6xl px-5 py-10 sm:px-8">
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band)" />
<div className="mt-9 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2].map((index) => (
<div
key={index}
className="overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white p-4"
>
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band)" />
<div className="mt-4 h-5 w-3/4 animate-pulse rounded bg-(--campaign-band)" />
<div className="mt-3 h-4 w-full animate-pulse rounded bg-(--campaign-band)" />
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="h-11 animate-pulse rounded bg-(--campaign-band)" />
<div className="h-11 animate-pulse rounded bg-(--campaign-band)" />
</div>
</div>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,169 @@
import { CampaignFooter } from '@/components/CampaignFooter'
import { ContentPieceCatalog } from '@/components/conteudos/ContentPieceCatalog'
import { ContentPieceFilters } from '@/components/conteudos/ContentPieceFilters'
import { ContentPieceHero } from '@/components/conteudos/ContentPieceHero'
import { ContentPiecePageHeader } from '@/components/conteudos/ContentPiecePageHeader'
import {
ContentPieceEmptyState,
ContentPieceNoResults,
} from '@/components/conteudos/ContentPieceStates'
import {
CONTENT_PIECE_CATALOG_PATH,
contentPieceCatalogActiveFilters,
contentPieceCatalogFacets,
contentPieceMediaKind,
filterContentPieceCatalogItems,
parseContentPieceCatalogParams,
type ContentPieceCatalogSearchParams,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import type { Media } from '@/payload-types'
import { getPublishedContentPieceItems } from '@/utilities/content/contentPieceReads'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import {
absoluteSitePath,
resolveDeploymentOrigin,
resolveSiteMetadata,
toAbsoluteUrl,
} from '@/utilities/seo'
import type { Metadata } from 'next'
const CATALOG_TITLE = 'Central de Conteúdos — Peça voto pra Solla 1313'
const CATALOG_DESCRIPTION =
'Escolha uma peça oficial de Jorge Solla 1313, baixe ou compartilhe no WhatsApp com a mensagem de voto pronta. Sem cadastro e direto no celular.'
const isImagePiece = (item: ContentPiecePublicItem): boolean =>
contentPieceMediaKind(item) === 'image'
/**
* S27 — the public Central: the published pieces (C211) with the five facets,
* the term search and the share of the vote from the card itself. Indexable
* with its own title while there is content; zero published renders the honest
* empty state and hides discovery (header badge and footer link), never a 404.
* The filtered URLs canonicalize to `/conteudos`.
*/
export async function generateMetadata(): Promise<Metadata> {
const [items, globalMetadata] = await Promise.all([
getPublishedContentPieceItems(),
getCachedGlobal('metadata')(),
])
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
const canonicalUrl = absoluteSitePath(siteUrl, CONTENT_PIECE_CATALOG_PATH)
const deploymentOrigin = resolveDeploymentOrigin(siteUrl)
const firstImage = items.find(isImagePiece)
const ownImageUrl =
firstImage?.file && deploymentOrigin
? toAbsoluteUrl(firstImage.file.path, deploymentOrigin)
: undefined
let fallbackImage: Media | null = null
if (!ownImageUrl && globalMetadata.image) {
fallbackImage =
typeof globalMetadata.image === 'number'
? await getCachedDocumentById('media', String(globalMetadata.image))()
: globalMetadata.image
}
const imageUrl =
ownImageUrl ??
(fallbackImage?.url && deploymentOrigin
? toAbsoluteUrl(fallbackImage.url, deploymentOrigin)
: undefined)
const title = `${CATALOG_TITLE} | ${siteName}`
const images = imageUrl ? [{ url: imageUrl, alt: CATALOG_TITLE }] : []
return {
title,
description: CATALOG_DESCRIPTION,
...(items.length === 0 ? { robots: { index: false, follow: false } } : {}),
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'website',
locale: 'pt-BR',
...(canonicalUrl ? { url: canonicalUrl } : {}),
siteName,
title,
description: CATALOG_DESCRIPTION,
images,
},
twitter: {
card: 'summary_large_image',
title,
description: CATALOG_DESCRIPTION,
creator: twitterCreator,
images: images.map((image) => image.url),
},
}
}
export default async function ConteudosPage({
searchParams,
}: {
searchParams: Promise<ContentPieceCatalogSearchParams>
}) {
const [rawSearchParams, items, showJingles] = await Promise.all([
searchParams,
getPublishedContentPieceItems(),
hasPublishedJingles(),
])
if (items.length === 0) {
return (
<>
<ContentPiecePageHeader />
<main className="w-full bg-white">
<ContentPieceEmptyState />
</main>
<CampaignFooter showJingles={showJingles} />
</>
)
}
const params = parseContentPieceCatalogParams(rawSearchParams)
const facets = contentPieceCatalogFacets(items)
const filteredItems = filterContentPieceCatalogItems(items, params)
const activeFilters = contentPieceCatalogActiveFilters(params, facets)
const hasActiveFilters = activeFilters.length > 0
return (
<>
<ContentPiecePageHeader />
<main className="w-full bg-white">
<ContentPieceHero />
<section className="mx-auto w-full max-w-6xl px-5 py-8 sm:px-8 sm:py-10">
<ContentPieceFilters params={params} facets={facets} activeFilters={activeFilters} />
<div className="mt-9">
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black tracking-[-0.02em] sm:text-2xl">
Escolha, compartilhe e peça voto
</h2>
<p className="mt-1 text-sm text-(--campaign-muted)">
O play só carrega a mídia quando você pedir.
</p>
</div>
<span className="hidden text-sm text-(--campaign-muted) sm:inline">
Abra o título para ver detalhes
</span>
</div>
<div className="mt-5">
{filteredItems.length > 0 ? (
<ContentPieceCatalog items={filteredItems} showCardInvite={!hasActiveFilters} />
) : (
<ContentPieceNoResults activeFilters={activeFilters} />
)}
</div>
</div>
</section>
</main>
<CampaignFooter showJingles={showJingles} showConteudos current="conteudos" />
</>
)
}

View file

@ -0,0 +1,61 @@
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { CONTENT_MEDIA_SLUG } from '@/lib/contentPiece'
import { contentPieceDownloadFilename } from '@/lib/contentPieceCatalog'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import { getPublishedContentPieceBySlug } from '@/utilities/content/contentPieceReads'
import {
buildPrivateMediaResponse,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
export const dynamic = 'force-dynamic'
/**
* S27 — the only public door to a published piece's archived file. The gate is
* the cached public read (published + file/link predicate), so a draft, an
* unpublished piece, an unknown slug and a link piece without an attachment
* all answer the same silent 404 — the route never leaks which pieces exist.
* The object itself stays in the private bucket; this reuses the C193/C199
* streaming helper (range, disposition, private headers), never a second copy.
*/
const notFound = (): NextResponse =>
new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL },
})
export const GET = async (
request: Request,
{ params }: { params: Promise<{ slug: string }> },
): Promise<Response> => {
const { slug } = await params
const piece = await getPublishedContentPieceBySlug(slug)
if (!piece?.file) return notFound()
const payload = await getPayload({ config })
// Intentional admin bypass: the public gate is the cached published read
// above; the media row only supplies the fresh filename/mimeType to stream.
const media = await payload
.findByID({
collection: CONTENT_MEDIA_SLUG,
id: piece.file.id,
depth: 0,
overrideAccess: true,
})
.catch(() => null)
if (!media?.filename) return notFound()
return buildPrivateMediaResponse({
media,
staticDir: resolvePrivateMediaStaticDir(payload, CONTENT_MEDIA_SLUG),
rangeHeader: request.headers.get('range'),
download: new URL(request.url).searchParams.get('download') === '1',
// The voter downloads `jorge-solla-1313-<slug>.<ext>`, never the internal
// upload name; the object key stays `media.filename`.
dispositionFilename: contentPieceDownloadFilename(slug, media.filename),
})
}

View file

@ -0,0 +1,38 @@
import { XCircleIcon } from 'lucide-react'
import Link from 'next/link'
import { ContentPiecePageHeader } from '@/components/conteudos/ContentPiecePageHeader'
import { CONTENT_PIECE_PRIMARY_BUTTON } from '@/components/conteudos/contentPieceClasses'
import { CONTENT_PIECE_CATALOG_PATH } from '@/lib/contentPieceCatalog'
/**
* S27 — the same friendly screen for an unpublished piece and an unknown slug
* (artefato: cena 05): the link is gone and the page does not reveal whether
* the piece existed.
*/
export default function ContentPieceNotFound() {
return (
<>
<ContentPiecePageHeader backHref={CONTENT_PIECE_CATALOG_PATH} />
<main className="flex min-h-[60vh] w-full items-center justify-center px-5 py-16 sm:px-8">
<div className="max-w-lg text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-(--campaign-band) text-(--campaign-muted)">
<XCircleIcon className="size-6" aria-hidden="true" />
</span>
<h1 className="mt-5 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.02em] sm:text-3xl">
Esta peça não está disponível
</h1>
<p className="mx-auto mt-3 max-w-[48ch] text-base leading-7 text-(--campaign-muted)">
Ela pode ter sido retirada. Encontre outro material oficial e continue pedindo voto.
</p>
<Link
href={CONTENT_PIECE_CATALOG_PATH}
className={`${CONTENT_PIECE_PRIMARY_BUTTON} mt-6`}
>
Ver outras peças
</Link>
</div>
</main>
</>
)
}

View file

@ -0,0 +1,114 @@
import { CampaignFooter } from '@/components/CampaignFooter'
import { ContentPieceDetail } from '@/components/conteudos/ContentPieceDetail'
import { ContentPiecePageHeader } from '@/components/conteudos/ContentPiecePageHeader'
import { CONTENT_PIECE_CATALOG_PATH } from '@/lib/contentPieceCatalog'
import type { Media } from '@/payload-types'
import { getPublishedContentPieceBySlug } from '@/utilities/content/contentPieceReads'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import {
absoluteSitePath,
resolveDeploymentOrigin,
resolveSiteMetadata,
toAbsoluteUrl,
truncate,
} from '@/utilities/seo'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
const MAX_DESCRIPTION_LENGTH = 200
const fallbackDescription = (title: string): string =>
`Peça oficial de Jorge Solla 1313: ${title}. Baixe ou compartilhe e peça voto pra Solla 1313.`
/**
* S27 — the public page of one piece: the link opens with title and preview on
* WhatsApp, the voter can play, download and share right there. An unpublished
* piece, a draft and an unknown slug resolve to the same honest not-found
* screen — the kill switch never reveals whether the piece existed.
*/
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const item = await getPublishedContentPieceBySlug(slug)
if (!item) {
return { title: 'Esta peça não está disponível', robots: { index: false, follow: false } }
}
const globalMetadata = await getCachedGlobal('metadata')()
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
const canonicalUrl = absoluteSitePath(siteUrl, item.publicPath)
const deploymentOrigin = resolveDeploymentOrigin(siteUrl)
const ownImageUrl =
item.file?.mimeType?.startsWith('image/') && deploymentOrigin
? toAbsoluteUrl(item.file.path, deploymentOrigin)
: undefined
let fallbackImage: Media | null = null
if (!ownImageUrl && globalMetadata.image) {
fallbackImage =
typeof globalMetadata.image === 'number'
? await getCachedDocumentById('media', String(globalMetadata.image))()
: globalMetadata.image
}
const imageUrl =
ownImageUrl ??
(fallbackImage?.url && deploymentOrigin
? toAbsoluteUrl(fallbackImage.url, deploymentOrigin)
: undefined)
const title = `${item.title} | ${siteName}`
const description = truncate(
item.description ?? fallbackDescription(item.title),
MAX_DESCRIPTION_LENGTH,
)
const images = imageUrl ? [{ url: imageUrl, alt: item.title }] : []
return {
title,
description,
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: item.type === 'video' ? 'video.other' : 'article',
locale: 'pt-BR',
...(canonicalUrl ? { url: canonicalUrl } : {}),
siteName,
title,
description,
images,
},
twitter: {
card: 'summary_large_image',
title,
description,
creator: twitterCreator,
images: images.map((image) => image.url),
},
}
}
export default async function ContentPiecePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const [item, showJingles] = await Promise.all([
getPublishedContentPieceBySlug(slug),
hasPublishedJingles(),
])
if (!item) notFound()
return (
<>
<ContentPiecePageHeader backHref={CONTENT_PIECE_CATALOG_PATH} />
<main className="w-full bg-white">
<div className="mx-auto w-full max-w-5xl px-5 py-8 sm:px-8 sm:py-12">
<ContentPieceDetail item={item} />
</div>
</main>
<CampaignFooter showJingles={showJingles} showConteudos current="conteudos" />
</>
)
}

View file

@ -0,0 +1,18 @@
import React from 'react'
/**
* S27 — the Central de Conteúdos shell. The root `(frontend)` layout locks the
* document with `overflow-hidden`, so this segment owns the scroll container;
* `campaign-site` applies the campaign palette, same contract as the jingles
* page.
*/
export default function ConteudosLayout({ children }: { children: React.ReactNode }) {
return (
<div
data-theme="campaign-site"
className="h-dvh w-full overflow-y-auto bg-(--campaign-cream) text-(--campaign-ink)"
>
{children}
</div>
)
}

View file

@ -4,6 +4,7 @@ import { JingleIntro } from '@/components/jingles/JingleIntro'
import { JinglePageHeader } from '@/components/jingles/JinglePageHeader'
import { JinglePlayer } from '@/components/jingles/JinglePlayer'
import type { Media } from '@/payload-types'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { getPublishedJingleItems } from '@/utilities/jingleReads'
@ -81,7 +82,10 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function JinglesPage() {
const items = await getPublishedJingleItems()
const [items, showConteudos] = await Promise.all([
getPublishedJingleItems(),
hasPublishedContentPieces(),
])
const hasItems = items.length > 0
return (
@ -97,7 +101,7 @@ export default async function JinglesPage() {
<JingleEmptyState />
)}
</main>
<CampaignFooter showJingles={hasItems} current="jingles" />
<CampaignFooter showJingles={hasItems} showConteudos={showConteudos} current="jingles" />
</>
)
}

View file

@ -20,17 +20,21 @@ const REDES = [
* S21 — the "Jingles" discovery link only appears while something is published;
* the caller resolves the cached flag (`hasPublishedJingles`, or the page's own
* list) and passes it, so this stays a synchronous presentational leaf.
* S27 — the same contract for the "Conteúdos" link (`hasPublishedContentPieces`).
* `current` marks the page as the current one for assistive tech and the
* artefato's highlight.
*/
export const CampaignFooter = ({
showJingles = false,
showConteudos = false,
current,
}: {
showJingles?: boolean
current?: 'jingles'
showConteudos?: boolean
current?: 'jingles' | 'conteudos'
} = {}) => {
const jinglesLinkClass = cn(FOOTER_LINK, current === 'jingles' && 'font-bold text-white')
const conteudosLinkClass = cn(FOOTER_LINK, current === 'conteudos' && 'font-bold text-white')
return (
<footer className="bg-[#180a09] text-[rgb(255_248_242/75%)]">
@ -55,6 +59,17 @@ export const CampaignFooter = ({
Bandeiras
</a>
</li>
{showConteudos ? (
<li>
<Link
href="/conteudos"
aria-current={current === 'conteudos' ? 'page' : undefined}
className={conteudosLinkClass}
>
Conteúdos
</Link>
</li>
) : null}
{showJingles ? (
<li>
<Link

View file

@ -0,0 +1,47 @@
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { CONTENT_PIECE_OUTLINE_BUTTON, CONTENT_PIECE_PRIMARY_BUTTON } from './contentPieceClasses'
/**
* S27 — the two per-piece actions, single-owned (artefato: cenas 01/04/07): the
* download is the SAME public file with the legible name (a link piece has no
* file and renders no download), and share always opens the vote sheet.
*/
export const ContentPieceDownloadLink = ({
item,
className,
}: {
item: ContentPiecePublicItem
className?: string
}) =>
item.file ? (
<a
href={`${item.file.path}?download=1`}
download={item.file.downloadFilename}
aria-label={`Baixar ${item.title}`}
className={cn(CONTENT_PIECE_OUTLINE_BUTTON, className)}
>
Baixar
</a>
) : null
export const ContentPieceShareButton = ({
item,
onShare,
className,
}: {
item: ContentPiecePublicItem
onShare: (item: ContentPiecePublicItem) => void
className?: string
}) => (
<button
type="button"
onClick={() => onShare(item)}
aria-label={`Compartilhar ${item.title}`}
className={cn(CONTENT_PIECE_PRIMARY_BUTTON, className)}
>
Compartilhar
</button>
)

View file

@ -0,0 +1,110 @@
import Link from 'next/link'
import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
import { ContentPieceMedia } from './ContentPieceMedia'
import { CONTENT_PIECE_CARD, CONTENT_PIECE_TAG } from './contentPieceClasses'
type ContentPieceCardProps = {
item: ContentPiecePublicItem
playing: boolean
onToggle: () => void
onEnded: () => void
onShare: (item: ContentPiecePublicItem) => void
}
const CardTag = ({ item }: { item: ContentPiecePublicItem }) => (
<span className={CONTENT_PIECE_TAG}>{item.isLink ? item.originLabel : item.typeLabel}</span>
)
const CardTitle = ({ item, className }: { item: ContentPiecePublicItem; className?: string }) => (
<h3 className={cn('mt-2 font-[family-name:var(--font-exo2)] font-extrabold', className)}>
<Link
href={item.publicPath}
className="rounded-sm text-black underline-offset-4 hover:underline focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-red)"
>
{item.title}
</Link>
</h3>
)
const CardMeta = ({ item }: { item: ContentPiecePublicItem }) =>
item.metaLabel ? <p className="mt-1 text-xs text-(--campaign-muted)">{item.metaLabel}</p> : null
/**
* S27 — one catalogue card (artefato: cenas 01/02/07): video/audio/card and
* link pieces take the full card with the asset on top; a photo/text is the
* compact horizontal row. Actions work from the card itself — download never
* needs the piece page.
*/
export const ContentPieceCard = ({
item,
playing,
onToggle,
onEnded,
onShare,
}: ContentPieceCardProps) => {
const kind = contentPieceMediaKind(item)
const isCompact = kind === 'image' || kind === 'text' || kind === 'other'
if (isCompact) {
return (
<article
data-content-piece={item.slug}
data-state={playing ? 'playing' : 'stopped'}
className={cn(CONTENT_PIECE_CARD, 'flex items-center gap-4 p-3')}
>
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
variant="thumb"
className="h-24 w-32 shrink-0 rounded-lg"
/>
<div className="min-w-0 flex-1">
<CardTag item={item} />
<CardTitle item={item} className="text-sm" />
<CardMeta item={item} />
<div className="mt-3 flex flex-wrap gap-2">
<ContentPieceDownloadLink item={item} className="min-h-9 px-3" />
<ContentPieceShareButton item={item} onShare={onShare} className="min-h-9 px-3" />
</div>
</div>
</article>
)
}
return (
<article
data-content-piece={item.slug}
data-state={playing ? 'playing' : 'stopped'}
className={CONTENT_PIECE_CARD}
>
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
className="aspect-video"
/>
<div className="p-4">
<CardTag item={item} />
<CardTitle item={item} className="text-base" />
<CardMeta item={item} />
<div className={cn('mt-4 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
<ContentPieceDownloadLink item={item} />
<ContentPieceShareButton item={item} onShare={onShare} />
</div>
<Link
href={item.publicPath}
className="mt-3 block rounded-sm text-center text-xs font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-red) sm:hidden"
>
Ver detalhes da peça →
</Link>
</div>
</article>
)
}

View file

@ -0,0 +1,44 @@
import Image from 'next/image'
import Link from 'next/link'
import {
CONTENT_PIECE_CARD,
CONTENT_PIECE_OUTLINE_BUTTON,
CONTENT_PIECE_TAG,
} from './contentPieceClasses'
/**
* S27 — the personalized-card invite as a piece of the catalogue (artefato:
* cena 01; D6): a synthetic tile, not a published row, so the way to `/cards`
* never depends on someone remembering to publish it — and it only appears
* while no filter is active (a filtered board shows matching pieces only).
*/
export const ContentPieceCardInvite = () => (
<article
data-content-piece-invite
className={`${CONTENT_PIECE_CARD} border-[#184e92]/25 bg-(--campaign-cream)`}
>
<div className="relative grid aspect-video place-items-center bg-[linear-gradient(135deg,#e4102f,#184e92)] text-white">
<div className="text-center">
<Image
src="/campaign-kit/numero-negativo.png"
alt="1313 Deputado Federal"
width={1037}
height={595}
className="mx-auto h-16 w-auto object-contain"
/>
<p className="mt-2 text-[10px] font-black tracking-[0.14em]">SEU NOME + SUA FOTO</p>
</div>
</div>
<div className="p-4">
<span className={CONTENT_PIECE_TAG}>Card personalizado</span>
<h3 className="mt-2 font-[family-name:var(--font-exo2)] text-base font-extrabold">
Faça seu card com Solla 1313
</h3>
<p className="mt-1 text-xs text-(--campaign-muted)">Criado no seu aparelho.</p>
<Link href="/cards" className={`${CONTENT_PIECE_OUTLINE_BUTTON} mt-4 w-full`}>
Fazer meu card
</Link>
</div>
</article>
)

View file

@ -0,0 +1,68 @@
'use client'
import { useState } from 'react'
import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { ContentPieceCard } from './ContentPieceCard'
import { ContentPieceCardInvite } from './ContentPieceCardInvite'
import { ContentPieceShareSheet } from './ContentPieceShareSheet'
/**
* S27 — the catalogue board (artefato: cena 01): full cards for video, audio
* and link pieces, compact rows for photo/text, the card invite when nothing
* is filtered. One piece plays at a time (switching unmounts the previous
* element) and the share sheet is owned here, so every card opens the same
* vote message.
*/
export const ContentPieceCatalog = ({
items,
showCardInvite,
}: {
items: readonly ContentPiecePublicItem[]
showCardInvite: boolean
}) => {
const [playingId, setPlayingId] = useState<number | null>(null)
const [sharingItem, setSharingItem] = useState<ContentPiecePublicItem | null>(null)
const largeItems: ContentPiecePublicItem[] = []
const compactItems: ContentPiecePublicItem[] = []
for (const item of items) {
const kind = contentPieceMediaKind(item)
if (kind === null || kind === 'video' || kind === 'audio') largeItems.push(item)
else compactItems.push(item)
}
const cardProps = (item: ContentPiecePublicItem) => ({
item,
playing: playingId === item.id,
onToggle: () => setPlayingId((current) => (current === item.id ? null : item.id)),
onEnded: () => setPlayingId((current) => (current === item.id ? null : current)),
onShare: setSharingItem,
})
return (
<>
{largeItems.length > 0 || showCardInvite ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{largeItems.map((item) => (
<ContentPieceCard key={item.id} {...cardProps(item)} />
))}
{showCardInvite ? <ContentPieceCardInvite /> : null}
</div>
) : null}
{compactItems.length > 0 ? (
<div className="mt-6 grid gap-6 sm:grid-cols-2">
{compactItems.map((item) => (
<ContentPieceCard key={item.id} {...cardProps(item)} />
))}
</div>
) : null}
{sharingItem ? (
<ContentPieceShareSheet item={sharingItem} onClose={() => setSharingItem(null)} />
) : null}
</>
)
}

View file

@ -0,0 +1,90 @@
'use client'
import { useState } from 'react'
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
import { ContentPieceMedia } from './ContentPieceMedia'
import { ContentPieceShareSheet } from './ContentPieceShareSheet'
import { CONTENT_PIECE_TAG } from './contentPieceClasses'
type Fact = { label: string; value: string }
const originOrDurationFact = (item: ContentPiecePublicItem): Fact | null => {
if (item.isLink) return { label: 'Origem', value: item.originLabel }
if (item.durationLabel) return { label: 'Duração', value: item.durationLabel }
return null
}
/**
* S27 — the piece page body (artefato: cenas 04/07): the asset on the left
* (play on demand, photo preview, platform placeholder for a link piece), the
* title/description/facts and the same two actions of the card. The share
* sheet is the same component, so the message is identical everywhere.
*/
export const ContentPieceDetail = ({ item }: { item: ContentPiecePublicItem }) => {
const [playing, setPlaying] = useState(false)
const [sharing, setSharing] = useState(false)
const facts = [
item.topicLabels.length > 0 ? { label: 'Tema', value: item.topicLabels.join(', ') } : null,
item.cityLabel || item.regionLabel
? { label: 'Local', value: item.cityLabel ?? item.regionLabel ?? '' }
: null,
originOrDurationFact(item),
item.pieceDateLabel ? { label: 'Data', value: item.pieceDateLabel } : null,
].filter((fact): fact is Fact => fact !== null)
return (
<>
<div className="grid gap-8 lg:grid-cols-[1.2fr_.8fr] lg:gap-10">
<ContentPieceMedia
item={item}
playing={playing}
onToggle={() => setPlaying((current) => !current)}
onEnded={() => setPlaying(false)}
variant="detail"
className="aspect-video"
/>
<div>
<span className={CONTENT_PIECE_TAG}>
{item.isLink ? item.originLabel : item.typeLabel}
</span>
<h1 className="mt-3 text-left font-[family-name:var(--font-exo2)] text-3xl leading-[1.05] font-black tracking-[-0.02em] text-balance">
{item.title}
</h1>
{item.description ? (
<p className="mt-3 text-sm leading-6 text-(--campaign-muted)">{item.description}</p>
) : null}
{facts.length > 0 ? (
<dl className="mt-5 border-y border-(--campaign-line) py-4 text-sm">
{facts.map((fact) => (
<div key={fact.label} className="flex justify-between gap-4 py-1">
<dt className="text-(--campaign-muted)">{fact.label}</dt>
<dd className="text-right font-medium">{fact.value}</dd>
</div>
))}
</dl>
) : null}
<div className={cn('mt-5 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
<ContentPieceDownloadLink item={item} />
<ContentPieceShareButton item={item} onShare={() => setSharing(true)} />
</div>
{item.isLink ? (
<p className="mt-3 text-center text-xs leading-5 text-(--campaign-muted)">
O compartilhamento usa o link da publicação no {item.originLabel}.
</p>
) : null}
</div>
</div>
{sharing ? <ContentPieceShareSheet item={item} onClose={() => setSharing(false)} /> : null}
</>
)
}

View file

@ -0,0 +1,125 @@
import Link from 'next/link'
import {
CONTENT_PIECE_CATALOG_FACETS,
CONTENT_PIECE_CATALOG_PATH,
buildContentPieceCatalogHref,
contentPieceCatalogFacetLabels,
type ContentPieceCatalogActiveFilter,
type ContentPieceCatalogFacet,
type ContentPieceCatalogFacets,
type ContentPieceCatalogParams,
} from '@/lib/contentPieceCatalog'
import {
CONTENT_PIECE_ACTIVE_CHIP,
CONTENT_PIECE_CHIP,
CONTENT_PIECE_FOCUS,
CONTENT_PIECE_PRIMARY_BUTTON,
} from './contentPieceClasses'
const FIELD_CLASS =
'min-h-11 w-full rounded-[10px] border border-black/18 bg-white py-2.5 pr-10 pl-3 text-[15px] text-black placeholder:text-(--campaign-muted) focus-visible:border-(--pt-red) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red) [&::-webkit-search-cancel-button]:hidden'
const DROPDOWN_LINK =
'flex min-h-9 items-center rounded-lg px-2.5 text-sm font-medium text-black hover:bg-(--campaign-band) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)'
/**
* S27 — the catalogue filters (artefato: cenas 01/06): a GET form (works
* without JS, the URL is the state) with the search term plus one chip per
* facet — a `<details>` menu when empty, an active chip with its removal link
* when set. Facets that no published piece carries never render.
*/
export const ContentPieceFilters = ({
params,
facets,
activeFilters,
}: {
params: ContentPieceCatalogParams
facets: ContentPieceCatalogFacets
activeFilters: readonly ContentPieceCatalogActiveFilter[]
}) => {
const hasActiveFilters = activeFilters.length > 0
const termFilter = activeFilters.find((filter) => filter.facet === 'q')
const facetFilters = activeFilters.filter(
(filter): filter is typeof filter & { facet: ContentPieceCatalogFacet } => filter.facet !== 'q',
)
return (
<form action={CONTENT_PIECE_CATALOG_PATH} method="get">
{CONTENT_PIECE_CATALOG_FACETS.map((facet) =>
params[facet] ? (
<input key={facet} type="hidden" name={facet} value={params[facet] ?? ''} />
) : null,
)}
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
<div className="relative">
<input
type="search"
name="q"
defaultValue={params.q}
placeholder="Encontre uma peça por assunto, cidade ou tema…"
aria-label="Buscar peças"
className={FIELD_CLASS}
/>
{termFilter ? (
<Link
href={termFilter.removeHref}
aria-label="Limpar busca"
className={`absolute top-1/2 right-2 grid size-8 -translate-y-1/2 place-items-center rounded-full text-lg leading-none text-(--campaign-muted) hover:text-black ${CONTENT_PIECE_FOCUS}`}
>
×
</Link>
) : null}
</div>
<button type="submit" className={`${CONTENT_PIECE_PRIMARY_BUTTON} px-6`}>
{hasActiveFilters ? 'Buscar peças' : 'Escolher uma peça e pedir voto'}
</button>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{facetFilters.map((filter) => (
<Link
key={filter.facet}
href={filter.removeHref}
aria-label={`Remover filtro ${filter.label}: ${filter.value}`}
className={CONTENT_PIECE_ACTIVE_CHIP}
>
{filter.label} · {filter.value}
<span aria-hidden="true" className="text-base leading-none">
×
</span>
</Link>
))}
{CONTENT_PIECE_CATALOG_FACETS.map((facet) =>
params[facet] || facets[facet].length === 0 ? null : (
<details key={facet} className="relative">
<summary
className={`${CONTENT_PIECE_CHIP} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
>
{contentPieceCatalogFacetLabels[facet]}
<span aria-hidden="true" className="text-base leading-none">
⌄
</span>
</summary>
<ul className="absolute z-20 mt-1 max-h-72 min-w-44 list-none overflow-y-auto rounded-xl border border-(--campaign-line) bg-white p-1 shadow-[0_12px_30px_rgb(0_0_0/15%)]">
{facets[facet].map((option) => (
<li key={option.value}>
<Link
href={buildContentPieceCatalogHref({ ...params, [facet]: option.value })}
className={DROPDOWN_LINK}
>
{option.label}
</Link>
</li>
))}
</ul>
</details>
),
)}
</div>
</form>
)
}

View file

@ -0,0 +1,36 @@
import Image from 'next/image'
/**
* S27 — the Central hero (artefato: cena 01 desktop / cena 02 mobile): the
* campaign gradient band, the "Peça voto pra Solla 1313" eyebrow and the
* 1313 mark. The copy asks for the vote — one goal for the whole screen.
*/
export const ContentPieceHero = () => (
<section className="bg-[linear-gradient(120deg,#e4102f,#b60825_60%,#184e92)] text-white">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-6 px-5 py-7 sm:px-8 sm:py-12">
<div>
<p className="m-0 text-xs font-bold tracking-[0.08em] text-[#ffeb00] uppercase sm:text-sm">
Peça voto pra Solla 1313
</p>
<h1 className="mt-2 max-w-2xl text-left font-[family-name:var(--font-exo2)] text-3xl leading-[1.05] font-black tracking-[-0.02em] text-balance sm:text-4xl">
<span className="sm:hidden">Mande uma peça. Peça mais um voto.</span>
<span className="hidden sm:inline">Uma mensagem sua pode conquistar mais um voto.</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-5 text-white/85 sm:text-base sm:leading-6">
<span className="sm:hidden">Escolha alguém, compartilhe e peça o voto no 1313.</span>
<span className="hidden sm:inline">
Escolha uma peça, mande para alguém que você conhece e peça o voto no 1313.
</span>
</p>
</div>
<Image
src="/campaign-kit/numero-negativo.png"
alt="1313 Deputado Federal"
width={1037}
height={595}
priority
className="hidden h-24 w-auto object-contain sm:block"
/>
</div>
</section>
)

View file

@ -0,0 +1,196 @@
import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
/**
* S27 — the piece asset (artefato: cenas 01/02/03/07): a photo previews, a
* text shows its opening, a video/audio only mounts the media element after
* the voter taps play (`preload="none"`, nothing is fetched before the
* gesture) and a link piece shows where it lives. The owner of the play state
* is the host (catalogue keeps one at a time; the piece page keeps its own).
*/
const PlayIcon = () => (
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-6 fill-current stroke-current">
<path d="m8 5 11 7-11 7z" />
</svg>
)
const PlayButton = ({ label, onToggle }: { label: string; onToggle: () => void }) => (
<button
type="button"
onClick={onToggle}
aria-label={label}
className="absolute grid size-14 place-items-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink) shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900] motion-reduce:transition-none sm:size-16"
>
<PlayIcon />
</button>
)
const DurationBadge = ({ label }: { label: string | null }) =>
label ? (
<span className="absolute right-3 bottom-3 rounded bg-black/70 px-2 py-1 text-xs text-white">
{label}
</span>
) : null
export const ContentPieceMedia = ({
item,
playing,
onToggle,
onEnded,
variant = 'card',
className,
}: {
item: ContentPiecePublicItem
playing: boolean
onToggle: () => void
onEnded: () => void
variant?: 'card' | 'thumb' | 'detail'
className?: string
}) => {
const kind = contentPieceMediaKind(item)
const base = cn('relative overflow-hidden bg-(--campaign-band)', className)
if (kind === null) {
return (
<div
className={cn(
base,
'grid place-items-center bg-[linear-gradient(145deg,#fff4f5,#eef4fb)]',
variant === 'detail' && 'rounded-2xl border border-[#184e92]/15',
)}
>
<span
aria-hidden="true"
className={cn(
'absolute -right-10 -bottom-16 size-48 rounded-full border-[28px] border-[#e4102f]/10',
item.origin === 'youtube' &&
'right-auto bottom-auto -top-16 -left-10 border-[#184e92]/10',
)}
/>
<div className="relative px-3 text-center">
<p className="text-[10px] font-black tracking-[0.18em] text-(--campaign-muted) uppercase">
Peça no {item.originLabel}
</p>
<p
className={cn(
'mt-2 font-[family-name:var(--font-exo2)] font-black',
item.origin === 'instagram' ? 'text-[#e4102f]' : 'text-[#184e92]',
variant === 'detail' ? 'text-4xl' : 'text-2xl',
)}
>
{item.originLabel}
</p>
<p className="mt-1 text-xs text-(--campaign-muted)">
{variant === 'detail'
? 'A mídia permanece na plataforma original.'
: 'Conteúdo hospedado na plataforma'}
</p>
</div>
</div>
)
}
if (kind === 'image') {
return (
<div className={cn(base, variant === 'detail' && 'rounded-2xl border border-black/10')}>
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
<img
src={item.file?.path}
alt={item.title}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
/>
</div>
)
}
if (kind === 'text') {
return (
<div
className={cn(
base,
'grid place-items-center bg-(--campaign-cream) px-3 text-center',
variant === 'detail' && 'rounded-2xl border border-black/10',
)}
>
<p
className={cn(
'font-[family-name:var(--font-exo2)] font-black text-(--pt-red-dark)',
variant === 'detail' ? 'text-xl leading-8 sm:text-2xl' : 'text-sm leading-5',
)}
>
“{item.excerpt ?? item.title}”
</p>
</div>
)
}
if (kind === 'video' || kind === 'audio') {
if (playing) {
return (
<div
className={cn(
base,
'bg-[#180a09]',
variant === 'detail' && 'rounded-2xl border border-black/10',
)}
>
{kind === 'video' ? (
<video
controls
autoPlay
playsInline
preload="none"
src={item.file?.path}
onEnded={onEnded}
className="h-full w-full object-contain"
/>
) : (
<div className="grid h-full w-full place-items-center p-4">
<audio
controls
autoPlay
preload="none"
src={item.file?.path}
onEnded={onEnded}
className="w-full"
/>
</div>
)}
</div>
)
}
return (
<div
className={cn(
base,
'grid place-items-center bg-[linear-gradient(135deg,#e5e2df,#f7f5f3)]',
variant === 'detail' && 'rounded-2xl border border-black/10',
)}
>
<span className="absolute top-3 left-3 text-[10px] font-black tracking-[0.14em] text-(--campaign-muted) uppercase">
{item.typeLabel}
</span>
<PlayButton label={`Reproduzir ${item.title}`} onToggle={onToggle} />
<DurationBadge label={item.durationLabel} />
</div>
)
}
return (
<div
className={cn(
base,
'grid place-items-center bg-[linear-gradient(135deg,#e5e2df,#f7f5f3)] px-3 text-center',
variant === 'detail' && 'rounded-2xl border border-black/10',
)}
>
<p className="text-xs font-bold text-(--campaign-muted)">
{item.file ? 'Baixe a peça para ver o arquivo.' : 'Sem arquivo para pré-visualizar.'}
</p>
</div>
)
}

View file

@ -0,0 +1,36 @@
import Image from 'next/image'
import Link from 'next/link'
import { CONTENT_PIECE_FOCUS } from './contentPieceClasses'
/**
* S27 — the Central de Conteúdos bar (artefato: cenas 01/04/06/07/08): the
* negative logo over the campaign red and, on a piece page, the way back to
* the catalogue. Page-local, like the jingles header — not a site shell.
*/
export const ContentPiecePageHeader = ({ backHref }: { backHref?: string }) => (
<header className="bg-[#ae1603]">
<div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 px-5 sm:h-16 sm:px-8">
<Link href="/" aria-label="Início — Jorge Solla 1313" className={CONTENT_PIECE_FOCUS}>
<Image
src="/campaign-kit/jorge-solla-negativo.png"
alt="Jorge Solla"
width={1037}
height={595}
priority
className="h-7 w-auto object-contain sm:h-9"
/>
</Link>
{backHref ? (
<Link
href={backHref}
className={`rounded-sm text-sm font-bold text-white underline-offset-4 hover:underline ${CONTENT_PIECE_FOCUS}`}
>
Voltar à Central
</Link>
) : (
<span className="text-sm font-bold text-white">Central de Conteúdos</span>
)}
</div>
</header>
)

View file

@ -0,0 +1,187 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { CopyLinkButton } from '@/components/CopyLinkButton'
import { WhatsAppIcon } from '@/components/socialIcons'
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import {
buildContentPieceWhatsAppUrl,
contentPieceShareLink,
contentPieceVoteMessage,
} from '@/lib/contentPieceShare'
import { cn } from '@/lib/utils'
import {
CONTENT_PIECE_FOCUS,
CONTENT_PIECE_OUTLINE_BUTTON,
CONTENT_PIECE_PRIMARY_BUTTON,
} from './contentPieceClasses'
/**
* S27 — the share sheet (artefato: cenas 03/08): the vote message prefilled and
* EDITABLE, then `wa.me` of the sender's own WhatsApp — no recipient, nothing
* sent by the system. "A mídia" opens the native share sheet with the file
* where the device supports it and downloads the same file where it does not
* (never pretending the file was attached). The sheet portals to the body so
* no card overflow can clip it.
*/
export const ContentPieceShareSheet = ({
item,
onClose,
}: {
item: ContentPiecePublicItem
onClose: () => void
}) => {
const [mounted, setMounted] = useState(false)
const [preparing, setPreparing] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const shareLink = contentPieceShareLink(item, window.location.origin)
const [message, setMessage] = useState(() =>
contentPieceVoteMessage(item.type, item.title, shareLink),
)
useEffect(() => {
setMounted(true)
// Focus moves into the dialog on open and back to the trigger on close.
const previousFocus = document.activeElement as HTMLElement | null
textareaRef.current?.focus()
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
previousFocus?.focus?.()
}
}, [onClose])
const shareMedia = async () => {
const mediaFile = item.file
if (!mediaFile || preparing) return
setPreparing(true)
const path = mediaFile.path
try {
const response = await fetch(path)
if (!response.ok) throw new Error('fetch failed')
const blob = await response.blob()
const file = new File([blob], mediaFile.downloadFilename, {
type: blob.type || 'application/octet-stream',
})
if (typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] })) {
try {
await navigator.share({ files: [file], title: item.title })
return
} catch (error) {
// A cancelled share sheet is not a failure: never download instead.
if (error instanceof DOMException && error.name === 'AbortError') return
}
}
} catch {
// The honest fallback below is the download of the same file.
} finally {
setPreparing(false)
}
const anchor = document.createElement('a')
anchor.href = `${path}?download=1`
anchor.download = mediaFile.downloadFilename
anchor.click()
}
if (!mounted) return null
return createPortal(
// The portal lands outside the `campaign-site` layout, so the sheet carries
// its own theme: without it every campaign token resolves to nothing (the
// default `:root` foreground is near-white) and the panel fades away.
<div data-theme="campaign-site" className="fixed inset-0 z-50 text-(--campaign-ink)">
<button
type="button"
tabIndex={-1}
aria-label="Fechar compartilhamento"
onClick={onClose}
className="absolute inset-0 h-full w-full cursor-default bg-black/12"
/>
<aside
role="dialog"
aria-modal="true"
aria-label="Compartilhar peça"
className={cn(
'absolute inset-x-0 bottom-0 rounded-t-2xl border border-black/12 bg-white p-4 shadow-[0_-12px_35px_rgb(0_0_0/12%)]',
'sm:inset-x-auto sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:w-[430px] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl sm:p-5 sm:shadow-[0_20px_55px_rgb(0_0_0/22%)]',
)}
>
<div
aria-hidden="true"
className="mx-auto mb-3 h-1 w-10 rounded-full bg-stone-300 sm:hidden"
/>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black">
Peça esse voto no WhatsApp
</h2>
<p className="mt-1 text-xs text-(--campaign-muted)">
A mensagem é sua: revise antes de abrir.
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Fechar"
className={cn(
'grid size-8 shrink-0 place-items-center rounded-full border border-black/10 text-lg text-(--campaign-muted) hover:text-black',
CONTENT_PIECE_FOCUS,
)}
>
×
</button>
</div>
<label className="sr-only" htmlFor="content-piece-share-message">
Mensagem para compartilhar
</label>
<textarea
id="content-piece-share-message"
ref={textareaRef}
value={message}
onChange={(event) => setMessage(event.target.value)}
className="mt-4 min-h-32 w-full resize-none rounded-[10px] border border-black/18 bg-white p-3 text-sm leading-5 text-black focus-visible:border-(--pt-red) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)"
/>
<a
href={buildContentPieceWhatsAppUrl(message)}
target="_blank"
rel="noopener noreferrer"
className={`${CONTENT_PIECE_PRIMARY_BUTTON} mt-3 w-full`}
>
<WhatsAppIcon className="size-4" />
Abrir no WhatsApp
</a>
<div className={cn('mt-3 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
{item.file ? (
<button
type="button"
onClick={() => void shareMedia()}
disabled={preparing}
className={cn(CONTENT_PIECE_OUTLINE_BUTTON, 'disabled:opacity-70')}
>
{preparing ? 'Preparando…' : 'A mídia'}
</button>
) : null}
<CopyLinkButton url={shareLink} className={CONTENT_PIECE_OUTLINE_BUTTON} />
</div>
<p className="mt-3 text-center text-[11px] leading-4 text-(--campaign-muted)">
Sem destinatário e sem envio automático.
{item.file ? ' Se o aparelho não compartilhar o arquivo, ele será baixado.' : ''}
</p>
</aside>
</div>,
document.body,
)
}

View file

@ -0,0 +1,70 @@
import { SearchIcon } from 'lucide-react'
import Link from 'next/link'
import {
CONTENT_PIECE_CATALOG_PATH,
type ContentPieceCatalogActiveFilter,
} from '@/lib/contentPieceCatalog'
import { CONTENT_PIECE_PRIMARY_BUTTON } from './contentPieceClasses'
/**
* S27 — the honest states of the Central (artefato: cena 05/06). Zero
* published pieces keeps the page 200 with no discovery; an active filter that
* matches nothing shows the query, the filters in force and the one way out.
*/
export const ContentPieceEmptyState = () => (
<section className="bg-(--campaign-cream) px-5 py-16 sm:px-8 sm:py-20">
<div className="mx-auto max-w-lg rounded-xl bg-white p-8 text-center shadow-[0_22px_60px_rgb(39_25_22/13%)]">
<h1 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black">
As primeiras peças estão a caminho
</h1>
<p className="mt-2 text-sm leading-6 text-(--campaign-muted)">
Enquanto isso, faça seu card e peça voto pra Solla 1313.
</p>
<Link href="/cards" className={`${CONTENT_PIECE_PRIMARY_BUTTON} mt-5`}>
Fazer meu card
</Link>
</div>
</section>
)
export const ContentPieceNoResults = ({
activeFilters,
}: {
activeFilters: readonly ContentPieceCatalogActiveFilter[]
}) => (
<section className="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-10 text-center sm:px-8 sm:py-14">
<div
aria-hidden="true"
className="mx-auto grid size-11 place-items-center rounded-full border border-[#184e92]/20 bg-white text-[#184e92] sm:size-12"
>
<SearchIcon className="size-5 sm:size-6" />
</div>
<h2 className="mt-4 border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black sm:text-2xl">
Nenhuma peça com esses filtros
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">
Tente outro termo ou limpe os filtros para ver todas as peças.
</p>
{activeFilters.length > 0 ? (
<div className="mt-4 flex flex-wrap justify-center gap-1.5 text-[11px] text-(--campaign-muted) sm:text-xs">
{activeFilters.map((filter) => (
<span
key={filter.facet}
className="rounded-full border border-black/10 bg-white px-2.5 py-1 sm:px-3 sm:py-1.5"
>
{filter.facet === 'q' ? `Busca: “${filter.value}”` : filter.value}
</span>
))}
</div>
) : null}
<Link
href={CONTENT_PIECE_CATALOG_PATH}
className={`${CONTENT_PIECE_PRIMARY_BUTTON} mt-6 sm:min-w-40`}
>
Limpar filtros
</Link>
</section>
)

View file

@ -0,0 +1,35 @@
import { cn } from '@/lib/utils'
/** Shared class contracts of the Central de Conteúdos (artefato S27, cenas 01–08). */
export const CONTENT_PIECE_FOCUS =
'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-red)'
export const CONTENT_PIECE_CHIP = cn(
'inline-flex min-h-9 items-center gap-1.5 rounded-full border border-(--campaign-line) bg-white px-3 text-xs font-bold text-black',
CONTENT_PIECE_FOCUS,
)
export const CONTENT_PIECE_ACTIVE_CHIP = cn(
'inline-flex min-h-9 items-center gap-1.5 rounded-full border border-[#184e92]/35 bg-[#eef4fb] px-3 text-xs font-bold text-[#184e92]',
CONTENT_PIECE_FOCUS,
)
export const CONTENT_PIECE_TAG =
'inline-flex rounded-full bg-[#eef4fb] px-2 py-1 text-[11px] font-extrabold text-[#184e92]'
export const CONTENT_PIECE_PRIMARY_BUTTON = cn(
'inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] bg-(--pt-yellow) px-4 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-yellow-ink) no-underline',
'shadow-[0_4px_0_#cfb900] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[2px] active:shadow-[0_2px_0_#cfb900] motion-reduce:transition-none',
CONTENT_PIECE_FOCUS,
)
export const CONTENT_PIECE_OUTLINE_BUTTON = cn(
'inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] border-[1.5px] border-[rgb(162_28_28/28%)] bg-white px-4 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red) no-underline',
'transition-colors duration-150 ease-out hover:border-(--pt-red) hover:bg-[rgb(162_28_28/7%)] motion-reduce:transition-none',
CONTENT_PIECE_FOCUS,
)
export const CONTENT_PIECE_CARD = cn(
'overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white shadow-[0_8px_24px_rgb(71_19_14/7%)]',
)

View file

@ -0,0 +1,415 @@
/**
* S27 — pure contract of the public Central de Conteúdos (`/conteudos`): the
* URL vocabulary (path, filters, search), the in-memory filtering over the
* published list and the serializable view model the cards and the piece page
* render. No I/O and no `server-only`: the cached public read, the server
* components and the unit tests share this module.
*
* The staff list contract (`src/utilities/content/contentPieceListUrl.ts`) is
* deliberately NOT reused: its vocabulary (status, processing, page) is
* internal and the public surface has its own facets with a single value each.
*/
import {
CONTENT_PIECE_TYPES,
contentPieceIsPublic,
contentPieceOriginLabels,
contentPieceTopicLabel,
contentPieceTypeLabels,
isContentPieceOrigin,
isContentPieceStatus,
isContentPieceTopic,
isContentPieceType,
type ContentPieceOrigin,
type ContentPieceType,
} from '@/lib/contentPiece'
import { slugify } from '@/lib/slug'
import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock'
import { SPEECH_TOPICS, type SpeechTopic } from '@/lib/speechFacets'
import { normalizeForSearch } from '@/lib/speechSearch'
export const CONTENT_PIECE_CATALOG_PATH = '/conteudos'
export const contentPiecePublicPath = (slug: string): string =>
`${CONTENT_PIECE_CATALOG_PATH}/${slug}`
export const contentPieceMediaPath = (slug: string): string =>
`${contentPiecePublicPath(slug)}/midia`
export const CONTENT_PIECE_CATALOG_FACETS = [
'tipo',
'cidade',
'regiao',
'tema',
'instituicao',
] as const
export type ContentPieceCatalogFacet = (typeof CONTENT_PIECE_CATALOG_FACETS)[number]
export const contentPieceCatalogFacetLabels: Record<ContentPieceCatalogFacet, string> = {
tipo: 'Tipo',
cidade: 'Cidade',
regiao: 'Região',
tema: 'Tema',
instituicao: 'Instituição',
}
export type ContentPieceCatalogParams = {
tipo: ContentPieceType | null
cidade: string | null
regiao: string | null
tema: SpeechTopic | null
instituicao: string | null
q: string
}
export type ContentPieceCatalogSearchParams = Record<string, string | string[] | undefined>
const firstValue = (value: string | string[] | undefined): string =>
(Array.isArray(value) ? value[0] : value)?.trim() ?? ''
const FACET_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
/**
* One value per facet; unknown enum values and non-slug labels are dropped
* (a hand-typed `?tipo=../etc` never reaches a query or a comparison).
*/
export const parseContentPieceCatalogParams = (
raw: ContentPieceCatalogSearchParams,
): ContentPieceCatalogParams => {
const tipo = firstValue(raw.tipo)
const tema = firstValue(raw.tema)
const slugFacet = (facet: ContentPieceCatalogFacet): string | null => {
const value = firstValue(raw[facet])
return value && FACET_SLUG_PATTERN.test(value) ? value : null
}
return {
tipo: isContentPieceType(tipo) ? tipo : null,
cidade: slugFacet('cidade'),
regiao: slugFacet('regiao'),
tema: isContentPieceTopic(tema) ? tema : null,
instituicao: slugFacet('instituicao'),
q: firstValue(raw.q),
}
}
/** Canonical listing URL: fixed facet order, no empty params, no params at all when clean. */
export const buildContentPieceCatalogHref = (
params: Partial<ContentPieceCatalogParams>,
): string => {
const search = new URLSearchParams()
if (params.tipo) search.set('tipo', params.tipo)
if (params.cidade) search.set('cidade', params.cidade)
if (params.regiao) search.set('regiao', params.regiao)
if (params.tema) search.set('tema', params.tema)
if (params.instituicao) search.set('instituicao', params.instituicao)
const term = params.q?.trim()
if (term) search.set('q', term)
const query = search.toString()
return query ? `${CONTENT_PIECE_CATALOG_PATH}?${query}` : CONTENT_PIECE_CATALOG_PATH
}
type ContentPieceCatalogFacetOption = { value: string; label: string }
export type ContentPieceCatalogFacets = Record<
ContentPieceCatalogFacet,
ContentPieceCatalogFacetOption[]
>
export type ContentPieceCatalogActiveFilter = {
facet: ContentPieceCatalogFacet | 'q'
label: string
value: string
removeHref: string
}
const sortedOptions = (options: Map<string, string>): ContentPieceCatalogFacetOption[] =>
[...options.entries()]
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'))
/**
* Facet options are derived from the published items alone: a filter that
* cannot match anything never renders. Tipo/Tema keep the persisted enum
* order; the label facets are alphabetical by their pt-BR label.
*/
export const contentPieceCatalogFacets = (
items: readonly ContentPiecePublicItem[],
): ContentPieceCatalogFacets => {
const types = new Set<string>()
const topics = new Set<string>()
const cities = new Map<string, string>()
const regions = new Map<string, string>()
const institutions = new Map<string, string>()
for (const item of items) {
types.add(item.type)
for (const topic of item.topics) topics.add(topic)
if (item.cityLabel) cities.set(slugify(item.cityLabel), item.cityLabel)
if (item.regionLabel) regions.set(slugify(item.regionLabel), item.regionLabel)
if (item.institution) institutions.set(slugify(item.institution), item.institution)
}
return {
tipo: CONTENT_PIECE_TYPES.filter((type) => types.has(type)).map((type) => ({
value: type,
label: contentPieceTypeLabels[type],
})),
tema: SPEECH_TOPICS.filter((topic) => topics.has(topic.value)).map((topic) => ({
value: topic.value,
label: topic.label,
})),
cidade: sortedOptions(cities),
regiao: sortedOptions(regions),
instituicao: sortedOptions(institutions),
}
}
/** The active filters in canonical order, with the URL that removes each one. */
export const contentPieceCatalogActiveFilters = (
params: ContentPieceCatalogParams,
facets: ContentPieceCatalogFacets,
): ContentPieceCatalogActiveFilter[] => {
const filters: ContentPieceCatalogActiveFilter[] = []
for (const facet of CONTENT_PIECE_CATALOG_FACETS) {
const value = params[facet]
if (!value) continue
const option = facets[facet].find((candidate) => candidate.value === value)
filters.push({
facet,
label: contentPieceCatalogFacetLabels[facet],
value: option?.label ?? value,
removeHref: buildContentPieceCatalogHref({ ...params, [facet]: null }),
})
}
if (params.q.trim()) {
filters.push({
facet: 'q',
label: 'Busca',
value: params.q.trim(),
removeHref: buildContentPieceCatalogHref({ ...params, q: '' }),
})
}
return filters
}
/** All active facets combine (AND); the term matches the denormalized haystack. */
export const filterContentPieceCatalogItems = (
items: readonly ContentPiecePublicItem[],
params: ContentPieceCatalogParams,
): ContentPiecePublicItem[] => {
const term = normalizeForSearch(params.q)
return items.filter(
(item) =>
(!params.tipo || item.type === params.tipo) &&
(!params.cidade || (item.cityLabel !== null && slugify(item.cityLabel) === params.cidade)) &&
(!params.regiao ||
(item.regionLabel !== null && slugify(item.regionLabel) === params.regiao)) &&
(!params.tema || item.topics.includes(params.tema)) &&
(!params.instituicao ||
(item.institution !== null && slugify(item.institution) === params.instituicao)) &&
(!term || normalizeForSearch(item.searchText).includes(term)),
)
}
export type ContentPiecePublicSource = {
id: number
slug?: string | null
title?: string | null
type?: string | null
status?: string | null
origin?: string | null
topics?: string[] | null
cityLabel?: string | null
region?: string | null
institution?: string | null
description?: string | null
transcript?: string | null
sourceUrl?: string | null
durationSeconds?: number | null
pieceDate?: string | null
searchText?: string | null
media?: number | { id: number; filename?: string | null; mimeType?: string | null } | null
}
export type ContentPiecePublicItem = {
id: number
slug: string
title: string
type: ContentPieceType
typeLabel: string
origin: ContentPieceOrigin
originLabel: string
/** A link piece (Instagram/YouTube) has no archived file: it shares the platform link. */
isLink: boolean
sourceUrl: string | null
topics: SpeechTopic[]
topicLabels: string[]
cityLabel: string | null
regionLabel: string | null
institution: string | null
description: string | null
/** Opening of the text/transcript, for the `texto` card asset. */
excerpt: string | null
durationLabel: string | null
pieceDateLabel: string | null
/** `Tema · Local` (or institution/city fallbacks) — the card's metadata line. */
metaLabel: string
searchText: string
publicPath: string
/** The archived file, or null on a link piece — never partially populated. */
file: ContentPiecePublicFile | null
}
type ContentPiecePublicFile = {
id: number
path: string
mimeType: string | null
downloadFilename: string
}
export type ContentPieceMediaKind = 'video' | 'audio' | 'image' | 'text' | 'other'
/**
* How the card/preview renders the archived file: the stored MIME decides
* (video/audio play on demand, image previews, text shows the excerpt); an
* unknown MIME falls back to the editorial type, and anything else stays a
* download-only tile. A link piece has no file at all.
*/
export const contentPieceMediaKind = (
item: Pick<ContentPiecePublicItem, 'isLink' | 'file' | 'type'>,
): ContentPieceMediaKind | null => {
if (item.isLink) return null
const mime = item.file?.mimeType ?? ''
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('text/')) return 'text'
if (mime) return 'other'
if (item.type === 'video') return 'video'
if (item.type === 'audio') return 'audio'
if (item.type === 'foto' || item.type === 'card') return 'image'
if (item.type === 'texto') return 'text'
return 'other'
}
const DOWNLOAD_PREFIX = 'jorge-solla-1313'
const DOWNLOAD_FALLBACK_EXTENSION = 'bin'
/**
* Legible download name: the stored upload filename can carry accents/spaces,
* so the base comes from the public slug and the extension from the actual
* file (same contract as the jingle download name).
*/
export const contentPieceDownloadFilename = (slug: string, filename?: string | null): string => {
const base = slugify(slug) || 'peca'
const lastDot = filename?.lastIndexOf('.') ?? -1
const extension = lastDot > 0 ? filename?.slice(lastDot + 1).toLowerCase() : null
const safeExtension =
extension && /^[a-z0-9]{1,5}$/.test(extension) ? extension : DOWNLOAD_FALLBACK_EXTENSION
return `${DOWNLOAD_PREFIX}-${base}.${safeExtension}`
}
const EXCERPT_MAX_LENGTH = 140
/** First non-empty paragraph of the text/transcript, truncated for the asset tile. */
export const contentPieceTextExcerpt = (
transcript: string | null | undefined,
maxLength = EXCERPT_MAX_LENGTH,
): string | null => {
const paragraph = (transcript ?? '')
.split(/\n+/)
.map((line) => line.trim())
.find(Boolean)
if (!paragraph) return null
return paragraph.length <= maxLength
? paragraph
: `${paragraph.slice(0, maxLength - 1).trimEnd()}…`
}
const durationLabelOf = (seconds: number | null | undefined): string | null =>
typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
? formatSpeechClock(seconds)
: null
const dateLabelOf = (value: string | null | undefined): string | null =>
value && /^\d{4}-\d{2}-\d{2}/.test(value) ? formatSpeechDate(value) : null
const mediaOf = (
media: ContentPiecePublicSource['media'],
): { id: number; filename: string | null; mimeType: string | null } | null => {
if (typeof media === 'number') return { id: media, filename: null, mimeType: null }
if (media && typeof media === 'object' && typeof media.id === 'number') {
return { id: media.id, filename: media.filename ?? null, mimeType: media.mimeType ?? null }
}
return null
}
/**
* Public view of one piece, or null when it may not be shown: no slug (never
* published) or the `contentPieceIsPublic` predicate fails (published without
* file and without link). The gate lives here too, not only in the query.
*/
export const toContentPiecePublicItem = (
record: ContentPiecePublicSource,
): ContentPiecePublicItem | null => {
const slug = record.slug?.trim()
if (!slug) return null
const status = isContentPieceStatus(record.status) ? record.status : 'rascunho'
const sourceUrl = record.sourceUrl?.trim() || null
const media = mediaOf(record.media)
if (!contentPieceIsPublic({ status, hasFile: media !== null, sourceUrl })) return null
const type = isContentPieceType(record.type) ? record.type : 'foto'
const origin = isContentPieceOrigin(record.origin) ? record.origin : 'arquivo'
const topics = (record.topics ?? []).filter(isContentPieceTopic)
const topicLabels = topics.map(contentPieceTopicLabel)
const cityLabel = record.cityLabel?.trim() || null
const regionLabel = record.region?.trim() || null
const institution = record.institution?.trim() || null
const local = cityLabel ?? regionLabel
const metaLabel = [topicLabels[0] ?? institution, local].filter(Boolean).join(' · ')
const isLink = media === null && sourceUrl !== null
return {
id: record.id,
slug,
title: record.title?.trim() || `Peça ${record.id}`,
type,
typeLabel: contentPieceTypeLabels[type],
origin,
originLabel: contentPieceOriginLabels[origin],
isLink,
sourceUrl,
topics,
topicLabels,
cityLabel,
regionLabel,
institution,
description: record.description?.trim() || null,
excerpt: contentPieceTextExcerpt(record.transcript),
durationLabel: durationLabelOf(record.durationSeconds),
pieceDateLabel: dateLabelOf(record.pieceDate),
metaLabel,
searchText: record.searchText ?? '',
publicPath: contentPiecePublicPath(slug),
file: media
? {
id: media.id,
path: contentPieceMediaPath(slug),
mimeType: media.mimeType,
downloadFilename: contentPieceDownloadFilename(slug, media.filename),
}
: null,
}
}

View file

@ -0,0 +1,54 @@
/**
* S27 — pure share contract of the Central de Conteúdos: the vote message per
* piece type (the literals of the gate, always editable by the voter) and the
* link each piece shares. The system never sends anything: the sheet opens the
* sender's own WhatsApp with the text prefilled (`wa.me`, no recipient).
*
* The S4 home vocabulary (`src/lib/contentShare.ts`) stays its own owner — a
* different message family with its own pinned contract.
*/
import type { ContentPieceType } from '@/lib/contentPiece'
import { contentPiecePublicPath } from '@/lib/contentPieceCatalog'
import { buildContentShareLink } from '@/lib/contentShare'
import { buildWhatsAppTextShareUrl } from '@/lib/phone'
/** Link-piece sources share the platform URL; an archived piece shares its page. */
export type ContentPieceShareSource = {
slug: string
isLink: boolean
sourceUrl: string | null
}
/**
* Vote message per piece type (gate literals, "assumidas — validar com a
* comunicação"): the voter reviews the text in the sheet before opening
* WhatsApp, so the template is a starting point, never an automatic send.
*/
export const contentPieceVoteMessage = (
type: ContentPieceType,
title: string,
link: string,
): string => {
if (type === 'video') {
return `Estamos na reta final e o Jorge Solla 1313 precisa do seu voto. Olha esse vídeo: ${title} — ${link}. Peça voto pra Solla 1313 pra quem você conhece.`
}
if (type === 'audio') {
return `Ouça e mande pro grupo: ${title} — ${link}. Peça voto pra Solla 1313.`
}
return `Fiz/achei esse material do Solla 1313: ${title} — ${link}. Peça voto pra Solla 1313 também.`
}
/**
* The link a piece shares: the canonical platform URL for a link piece (never
* a second copy), the public piece page for an archived one. Absolute against
* the visitor's origin so the message carries a clickable URL (the S4
* fail-soft resolver is the single owner of that absolutization).
*/
export const contentPieceShareLink = (piece: ContentPieceShareSource, origin: string): string =>
piece.isLink && piece.sourceUrl
? piece.sourceUrl
: buildContentShareLink(contentPiecePublicPath(piece.slug), origin)
/** `wa.me/?text=` with the prefilled message — no recipient, no automatic send. */
export const buildContentPieceWhatsAppUrl = (message: string): string =>
buildWhatsAppTextShareUrl(message)

View file

@ -11,7 +11,11 @@
/**
* Only these may render inline; anything else is forced to download. C199 adds
* the video containers the recordings actually arrive in (MOV/MKV/WebM) — all
* inert media types, never markup executed in the campaign origin.
* inert media types, never markup executed in the campaign origin. S27 adds the
* image and audio families the content pieces accept (a published photo must
* render and an audio piece must play; both were degrading to octet-stream and
* turning into a download). HEIC/HEIF stay out on purpose (browsers do not
* render them) and `image/svg+xml` never enters (markup).
*/
const PRIVATE_MEDIA_INLINE_MIME_TYPES = [
'video/mp4',
@ -19,8 +23,18 @@ const PRIVATE_MEDIA_INLINE_MIME_TYPES = [
'video/x-matroska',
'video/webm',
'audio/mpeg',
'audio/mp4',
'audio/aac',
'audio/ogg',
'audio/opus',
'audio/wav',
'audio/webm',
'audio/flac',
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
'image/avif',
] as const
export const PRIVATE_MEDIA_FALLBACK_MIME_TYPE = 'application/octet-stream'

View file

@ -14,6 +14,7 @@ export const SHARE_LINK_RESERVED_SLUGS = [
'mandato-no-whatsapp',
'abaixo-assinado',
'jingles',
'conteudos',
'api',
'admin',
'noticia',

View file

@ -0,0 +1,84 @@
import 'server-only'
import { toContentPiecePublicItem, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { getCollectionListingTag } from '@/utilities/documents'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
/**
* S27 — public reads of the Central de Conteúdos. The collection has no public
* access (`canReadContentPiece` is campaign-only), so the anonymous read is the
* Local API without `user` — the bypass default — and the gate is the query
* plus the `contentPieceIsPublic` predicate: only `publicado` with an archived
* file or the platform link ever leaves here. Same precedent as `jingleReads`.
*
* The listing is cached under the collection listing tag (`contentPieces`,
* busted by the ContentPiece afterChange on every publish/unpublish/edit), and
* the slug read resolves from the SAME cached list: the hook does not bust a
* per-document tag, so one tag keeps the whole surface honest.
*/
const contentPiecePublicSelect = {
slug: true,
title: true,
type: true,
status: true,
origin: true,
topics: true,
cityLabel: true,
region: true,
institution: true,
description: true,
transcript: true,
sourceUrl: true,
durationSeconds: true,
pieceDate: true,
searchText: true,
media: true,
} as const
const findPublishedContentPieces = () =>
getPayload({ config: configPromise }).then((payload) =>
payload.find({
collection: 'contentPiece',
where: { status: { equals: 'publicado' } },
sort: ['-publishedAt', '-id'],
depth: 1,
limit: 0,
pagination: false,
select: contentPiecePublicSelect,
}),
)
const getCachedPublishedContentPieces = () =>
unstable_cache(findPublishedContentPieces, ['content-pieces'], {
tags: [getCollectionListingTag('contentPiece')],
})
/** Published pieces as serializable public view models, newest first. */
export const getPublishedContentPieceItems = async (): Promise<ContentPiecePublicItem[]> => {
const { docs } = await getCachedPublishedContentPieces()()
return docs
.map((piece) => toContentPiecePublicItem(piece))
.filter((item): item is ContentPiecePublicItem => item !== null)
}
/**
* One published piece by its public slug. A draft (no slug), an unpublished
* piece and an unknown slug all resolve to null — the page turns that into the
* same honest not-found screen, never revealing whether the piece existed.
*/
export const getPublishedContentPieceBySlug = async (
slug: string,
): Promise<ContentPiecePublicItem | null> =>
(await getPublishedContentPieceItems()).find((item) => item.slug === slug) ?? null
/**
* Discovery flag for the footer link and the header badge: derived from the
* SAME resolved list the page renders, so a piece that cannot render never
* advertises a link to an empty Central.
*/
export const hasPublishedContentPieces = async (): Promise<boolean> =>
(await getPublishedContentPieceItems()).length > 0

View file

@ -1,9 +1,12 @@
import 'server-only'
import type { Config } from '@/payload-types'
import { GetObjectCommand, HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { createReadStream, createWriteStream } from 'fs'
import { stat } from 'fs/promises'
import path from 'path'
import type { Payload } from 'payload'
import { getRangeRequestInfo } from 'payload/internal'
import { pipeline } from 'stream/promises'
@ -32,6 +35,21 @@ export type PrivateMediaFile = {
mimeType?: string | null
}
/**
* The local disk directory of a private upload collection (dev/test fallback
* when no S3_* is set), mirroring Payload's own file handler. Single owner for
* the authenticated and the public serving routes.
*/
export const resolvePrivateMediaStaticDir = (
payload: Payload,
collectionSlug: keyof Config['collections'],
): string => {
const upload = payload.collections[collectionSlug]?.config.upload
return upload && typeof upload === 'object' && upload.staticDir
? upload.staticDir
: collectionSlug
}
type PrivateMediaStorage = { bucket: string; client: S3Client }
let cachedStorage: PrivateMediaStorage | null | undefined
@ -147,17 +165,25 @@ const notFound = (): Response =>
/**
* Streams one artifact. A missing object answers `404`; an unsatisfiable range
* answers `416` (both with the private headers) — never a leaked S3 error.
*
* `dispositionFilename` overrides only the DOWNLOAD name (the storage key is
* always `media.filename`): the public Central serves the legible
* `jorge-solla-1313-<slug>.<ext>` name, since the browser honors the server
* `Content-Disposition` over the anchor's `download` attribute. The
* authenticated campaign route keeps the stored name (no override).
*/
export const buildPrivateMediaResponse = async ({
media,
staticDir,
rangeHeader,
download,
dispositionFilename,
}: {
media: PrivateMediaFile
staticDir: string
rangeHeader: string | null
download: boolean
dispositionFilename?: string | null
}): Promise<Response> => {
const filename = media.filename
if (!filename) return notFound()
@ -173,7 +199,7 @@ export const buildPrivateMediaResponse = async ({
headers: privateMediaHeaders({
range: opened.range,
mimeType: media.mimeType,
filename,
filename: dispositionFilename?.trim() || filename,
download,
}),
})

View file

@ -0,0 +1,369 @@
import { randomUUID } from 'node:crypto'
import type { APIRequestContext, Page } from '@playwright/test'
import { adminHeaders } from '../helpers/adminApi'
import { seedTestUser } from '../helpers/seedUser'
import { expect, test } from './fixtures/e2eTest'
/**
* S27 — the public Central de Conteúdos over real HTTP: the published pieces
* of C211 list with the five facets, the media is only fetched on the play,
* the share sheet carries the editable vote message with no recipient, the
* download name is legible and the kill switch flips the page, the discovery
* link and the piece page without a deploy.
*
* Pieces and media are seeded through the deployed REST API (admin session) so
* the server process runs the real cache hooks; a Local API call from the
* runner would throw on `revalidateTag` (same reason as the S21 spec).
*
* The tests are serial: the empty-state assertions need a global window with
* zero published pieces, and only this spec owns content piece rows.
*/
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
// 1×1 opaque PNG — enough for the photo upload.
const TEST_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
)
const TEST_MP4 = Buffer.from('fixture-content-piece-video-bytes')
type Headers = Record<string, string>
const createdPieceIds: number[] = []
const createdMediaIds: number[] = []
const uniqueMarker = () => randomUUID().slice(0, 8)
const mediaFor = (type: 'video' | 'foto' | 'texto') => {
if (type === 'video') return { mimetype: 'video/mp4', buffer: TEST_MP4, extension: 'mp4' }
if (type === 'foto') return { mimetype: 'image/png', buffer: TEST_PNG, extension: 'png' }
return {
mimetype: 'text/plain',
buffer: Buffer.from('Peça voto pra Solla 1313.'),
extension: 'txt',
}
}
const createContentMedia = async (
request: APIRequestContext,
headers: Headers,
name: string,
mimetype: string,
buffer: Buffer,
): Promise<number> => {
const response = await request.post(`${BASE_URL}/api/contentMedia`, {
headers,
multipart: {
_payload: JSON.stringify({ alt: name }),
file: { name, mimeType: mimetype, buffer },
},
})
expect(response.ok(), await response.text()).toBeTruthy()
const id = ((await response.json()) as { doc: { id: number } }).doc.id
createdMediaIds.push(id)
return id
}
const createPiece = async (
request: APIRequestContext,
headers: Headers,
data: {
title: string
type: 'video' | 'foto' | 'texto' | 'audio' | 'card'
withMedia?: boolean
origin?: 'arquivo' | 'instagram' | 'youtube'
sourceUrl?: string
},
): Promise<{ id: number; slug: string }> => {
const mediaKind = data.type === 'video' || data.type === 'foto' ? data.type : 'texto'
const mediaSpec = mediaFor(mediaKind)
const media =
data.withMedia === false
? null
: await createContentMedia(
request,
headers,
`${data.title.replace(/\s+/g, '-')}.${mediaSpec.extension}`,
mediaSpec.mimetype,
mediaSpec.buffer,
)
const response = await request.post(`${BASE_URL}/api/contentPiece`, {
headers,
data: {
title: data.title,
type: data.type,
status: 'publicado',
origin: data.origin ?? 'arquivo',
...(media ? { media } : {}),
...(data.sourceUrl ? { sourceUrl: data.sourceUrl } : {}),
},
})
expect(response.ok(), await response.text()).toBeTruthy()
const doc = ((await response.json()) as { doc: { id: number; slug: string } }).doc
createdPieceIds.push(doc.id)
return { id: doc.id, slug: doc.slug }
}
const setPieceStatus = async (
request: APIRequestContext,
headers: Headers,
id: number,
status: 'rascunho' | 'publicado',
): Promise<void> => {
const response = await request.patch(`${BASE_URL}/api/contentPiece/${id}`, {
headers,
data: { status },
})
expect(response.ok(), await response.text()).toBeTruthy()
}
/** Zero published pieces globally so the empty-state assertions stay deterministic. */
const unpublishEveryPiece = async (request: APIRequestContext, headers: Headers) => {
const listed = await request.get(`${BASE_URL}/api/contentPiece?limit=0&depth=0`, { headers })
expect(listed.ok(), await listed.text()).toBeTruthy()
const { docs } = (await listed.json()) as { docs: { id: number; status?: string | null }[] }
for (const doc of docs) {
if (doc.status === 'publicado') await setPieceStatus(request, headers, doc.id, 'rascunho')
}
}
const mediaRequests = (page: Page) => {
const requests: string[] = []
page.on('request', (request) => {
if (/\/conteudos\/.+\/midia/.test(request.url())) requests.push(request.url())
})
return requests
}
test.describe.configure({ mode: 'serial' })
test.afterAll(async ({ request }) => {
const headers = await adminHeaders(request, BASE_URL).catch(() => null)
if (!headers) return
for (const id of createdPieceIds.splice(0)) {
await request.delete(`${BASE_URL}/api/contentPiece/${id}`, { headers }).catch(() => undefined)
}
for (const id of createdMediaIds.splice(0)) {
await request.delete(`${BASE_URL}/api/contentMedia/${id}`, { headers }).catch(() => undefined)
}
})
test.describe('Frontend Central de Conteúdos (S27)', () => {
test.beforeAll(async ({ request }) => {
await seedTestUser()
const headers = await adminHeaders(request, BASE_URL)
await unpublishEveryPiece(request, headers)
})
test('lists the published pieces, keeps the media lazy and shares the vote', async ({
page,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const videoTitle = `Fim da escala ${uniqueMarker()}`
const photoTitle = `Solla no SUS ${uniqueMarker()}`
const video = await createPiece(request, headers, { title: videoTitle, type: 'video' })
await createPiece(request, headers, { title: photoTitle, type: 'foto' })
const media = mediaRequests(page)
const response = await page.goto('/conteudos')
expect(response?.status()).toBe(200)
await expect(
page.getByRole('heading', { name: 'Uma mensagem sua pode conquistar mais um voto.' }),
).toBeVisible()
await expect(page.getByText('Central de Conteúdos').first()).toBeVisible()
await expect(page.locator('article[data-content-piece]')).toHaveCount(2)
await expect(page.locator('article[data-content-piece-invite]')).toHaveCount(1)
// The video mounts no media element and fetches nothing before the play
// (the photo preview is the only media the catalogue loads on its own).
await expect(page.locator('video')).toHaveCount(0)
await expect(page.locator('audio')).toHaveCount(0)
expect(media.filter((url) => url.includes(video.slug))).toHaveLength(0)
// Play mounts only the tapped piece and fetches only its media.
await page.getByRole('button', { name: `Reproduzir ${videoTitle}` }).click()
await expect(page.locator('video')).toHaveCount(1)
await expect.poll(() => media.filter((url) => url.includes(video.slug)).length).toBe(1)
// The download carries the legible name built from the slug.
await expect(page.getByRole('link', { name: `Baixar ${videoTitle}` })).toHaveAttribute(
'download',
`jorge-solla-1313-${video.slug}.mp4`,
)
// The share sheet: editable vote message, wa.me without recipient.
await page.getByRole('button', { name: `Compartilhar ${videoTitle}` }).click()
const sheet = page.getByRole('dialog', { name: 'Compartilhar peça' })
await expect(sheet).toBeVisible()
const message = sheet.getByLabel('Mensagem para compartilhar')
await expect(message).toHaveValue(new RegExp(videoTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
const whatsApp = sheet.getByRole('link', { name: 'Abrir no WhatsApp' })
const href = await whatsApp.getAttribute('href')
expect(href).toContain('https://wa.me/?text=')
expect(new URL(href!).pathname).toBe('/')
expect(decodeURIComponent(href!)).toContain(`/conteudos/${video.slug}`)
await message.fill('Mensagem revisada pela pessoa.')
await expect(whatsApp).toHaveAttribute('href', /Mensagem\+revisada/)
// "A mídia" falls back to the download of the same file where the device
// cannot share files (headless Linux has no Web Share API).
const [downloadEvent] = await Promise.all([
page.waitForEvent('download'),
sheet.getByRole('button', { name: 'A mídia' }).click(),
])
expect(downloadEvent.suggestedFilename()).toBe(`jorge-solla-1313-${video.slug}.mp4`)
await sheet.getByRole('button', { name: 'Fechar', exact: true }).click()
await expect(sheet).toHaveCount(0)
})
test('filters by facet and term and shows the honest no-results state', async ({
page,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
await unpublishEveryPiece(request, headers)
const marker = uniqueMarker()
const videoTitle = `Debate na rádio ${marker}`
const video = await createPiece(request, headers, { title: videoTitle, type: 'video' })
await createPiece(request, headers, { title: `Card do giro ${marker}`, type: 'foto' })
await page.goto(`/conteudos?tipo=video`)
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
await expect(page.getByRole('link', { name: videoTitle, exact: true })).toBeVisible()
// A filtered board never shows the card invite.
await expect(page.locator('article[data-content-piece-invite]')).toHaveCount(0)
await page.goto(`/conteudos?q=${marker}`)
await expect(page.locator('article[data-content-piece]')).toHaveCount(2)
await page.goto(`/conteudos?tipo=video&q=${marker}`)
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
await expect(page.getByRole('link', { name: /Remover filtro Tipo: Vídeo/ })).toBeVisible()
await page.goto('/conteudos?q=zzz-nao-existe')
await expect(
page.getByRole('heading', { name: 'Nenhuma peça com esses filtros' }),
).toBeVisible()
await expect(page.getByText('Busca: “zzz-nao-existe”')).toBeVisible()
const clear = page.getByRole('link', { name: 'Limpar filtros' })
await expect(clear).toHaveAttribute('href', '/conteudos')
await clear.click()
await expect(page).toHaveURL(/\/conteudos$/)
await expect(page.locator('article[data-content-piece]')).toHaveCount(2)
// The video card link opens the piece page with the back link.
await page.getByRole('link', { name: videoTitle, exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/conteudos/${video.slug}$`))
await expect(page.getByRole('heading', { name: videoTitle })).toBeVisible()
await expect(page.getByRole('link', { name: 'Voltar à Central' })).toBeVisible()
})
test('opens the piece page with OG preview and the same actions', async ({ page, request }) => {
const headers = await adminHeaders(request, BASE_URL)
await unpublishEveryPiece(request, headers)
const photoTitle = `Solla em defesa do SUS ${uniqueMarker()}`
const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' })
const response = await page.goto(`/conteudos/${photo.slug}`)
expect(response?.status()).toBe(200)
await expect(page.getByRole('heading', { name: photoTitle })).toBeVisible()
await expect(page.getByRole('link', { name: `Baixar ${photoTitle}` })).toHaveAttribute(
'download',
`jorge-solla-1313-${photo.slug}.png`,
)
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
'content',
new RegExp(photoTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
)
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
'content',
new RegExp(`/conteudos/${photo.slug}/midia$`),
)
await page.getByRole('button', { name: `Compartilhar ${photoTitle}` }).click()
const sheet = page.getByRole('dialog', { name: 'Compartilhar peça' })
await expect(sheet.getByLabel('Mensagem para compartilhar')).toHaveValue(
/Fiz\/achei esse material do Solla 1313/,
)
})
test('shares a link piece by the platform URL with no download action', async ({
page,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
await unpublishEveryPiece(request, headers)
const linkTitle = `Post no Instagram ${uniqueMarker()}`
const shortcode = uniqueMarker().toUpperCase()
const link = await createPiece(request, headers, {
title: linkTitle,
type: 'video',
withMedia: false,
origin: 'instagram',
sourceUrl: `https://www.instagram.com/reel/${shortcode}/`,
})
await page.goto('/conteudos')
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
await expect(page.getByRole('link', { name: `Baixar ${linkTitle}` })).toHaveCount(0)
await page.getByRole('button', { name: `Compartilhar ${linkTitle}` }).click()
const sheet = page.getByRole('dialog', { name: 'Compartilhar peça' })
await expect(sheet.getByRole('button', { name: 'A mídia' })).toHaveCount(0)
const href = await sheet.getByRole('link', { name: 'Abrir no WhatsApp' }).getAttribute('href')
expect(decodeURIComponent(href!)).toContain(`https://www.instagram.com/reel/${shortcode}/`)
await page.goto(`/conteudos/${link.slug}`)
await expect(page.getByText('A mídia permanece na plataforma original.')).toBeVisible()
await expect(
page.getByText('O compartilhamento usa o link da publicação no Instagram.'),
).toBeVisible()
})
test('flips the kill switch, the discovery and the piece page without a deploy', async ({
page,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
await unpublishEveryPiece(request, headers)
const title = `Peça do kill switch ${uniqueMarker()}`
const piece = await createPiece(request, headers, { title, type: 'video' })
await page.goto('/conteudos')
await expect(page.getByRole('link', { name: 'Conteúdos' })).toBeVisible()
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
await setPieceStatus(request, headers, piece.id, 'rascunho')
await page.goto('/conteudos')
await expect(
page.getByRole('heading', { name: 'As primeiras peças estão a caminho' }),
).toBeVisible()
await expect(page.locator('article[data-content-piece]')).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Conteúdos' })).toHaveCount(0)
await expect
.poll(async () =>
(await (await request.get(`${BASE_URL}/conteudos`)).text()).includes('noindex, nofollow'),
)
.toBe(true)
// The raw HTTP status is the 404 contract and the honest copy is in the
// body (the browser is not navigated to the 404 URL: the failure guard
// fails a test on the expected resource error).
const unpublished = await request.get(`/conteudos/${piece.slug}`)
expect(unpublished.status()).toBe(404)
expect(await unpublished.text()).toContain('Esta peça não está disponível')
await setPieceStatus(request, headers, piece.id, 'publicado')
await page.goto('/conteudos')
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
await expect(page.getByRole('link', { name: 'Conteúdos' })).toBeVisible()
})
})

View file

@ -33,6 +33,7 @@ import {
setContentPiecePublishedForActor,
updateContentPieceForActor,
} from '@/app/(campaign)/campanha/actions/contentPieces'
import { GET as getPublicPieceMedia } from '@/app/(frontend)/conteudos/[slug]/midia/route'
import type { ContentPieceCuratedField } from '@/lib/contentPiece'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
@ -49,6 +50,11 @@ import {
runContentPieceJob,
} from '@/utilities/content/contentPieceJob'
import { loadContentPieceListPageData } from '@/utilities/content/contentPiecePageData'
import {
getPublishedContentPieceBySlug,
getPublishedContentPieceItems,
hasPublishedContentPieces,
} from '@/utilities/content/contentPieceReads'
import {
attachContentPieceMedia,
receiveContentPieceUpload,
@ -162,6 +168,22 @@ const callFileRoute = async ({
return GET(request, { params: Promise.resolve({ id: String(pieceId) }) })
}
const callPublicPieceMedia = async ({
slug,
range,
download,
}: {
slug: string
range?: string
download?: boolean
}): Promise<Response> =>
getPublicPieceMedia(
new Request(`http://localhost/conteudos/${slug}/midia${download ? '?download=1' : ''}`, {
headers: range ? { range } : undefined,
}),
{ params: Promise.resolve({ slug }) },
)
const transcribeOk = async () => ({
ok: true as const,
segments: [
@ -784,4 +806,131 @@ describe('content pieces (C211)', () => {
// The loader answers for the communication roles only.
await expect(loadContentPieceListPageData(payload, leader, {})).rejects.toThrow()
})
it('shows only published pieces with file or link in the public read', async () => {
const marker = `pub-${Date.now()}`
const draft = await createPiece({ title: `Rascunho ${marker}` })
const published = await createPiece({ title: `Publicada ${marker}` })
const empty = await createPiece({ title: `Vazia ${marker}`, withMedia: false })
const link = await createPiece({
title: `Link ${marker}`,
withMedia: false,
origin: 'youtube',
sourceUrl: `https://www.youtube.com/watch?v=PUB${Date.now()}`,
})
await payload.update({
collection: 'contentPiece',
id: published.piece.id,
data: { status: 'publicado' },
overrideAccess: true,
})
await payload.update({
collection: 'contentPiece',
id: empty.piece.id,
data: { status: 'publicado' },
overrideAccess: true,
})
await payload.update({
collection: 'contentPiece',
id: link.piece.id,
data: { status: 'publicado' },
overrideAccess: true,
})
const items = await getPublishedContentPieceItems()
const ids = items.map((item) => item.id)
const publishedItem = items.find((item) => item.id === published.piece.id)
expect(ids).toContain(published.piece.id)
expect(ids).toContain(link.piece.id)
expect(ids).not.toContain(draft.piece.id)
// Published but with nothing to show (no file, no link) fails closed.
expect(ids).not.toContain(empty.piece.id)
expect(publishedItem?.slug).toMatch(new RegExp(`^publicada-${marker}`))
expect(await getPublishedContentPieceBySlug(publishedItem!.slug)).toMatchObject({
id: published.piece.id,
file: { path: `/conteudos/${publishedItem!.slug}/midia` },
})
expect(await getPublishedContentPieceBySlug(`nao-existe-${marker}`)).toBeNull()
expect(await hasPublishedContentPieces()).toBe(true)
})
it('serves a published piece file publicly and 404s draft, link and unknown slugs', async () => {
const marker = `media-${Date.now()}`
const draft = await createPiece({ title: `Mídia rascunho ${marker}` })
const published = await createPiece({ title: `Mídia pública ${marker}` })
const link = await createPiece({
title: `Mídia link ${marker}`,
withMedia: false,
origin: 'instagram',
sourceUrl: `https://www.instagram.com/reel/MEDIA${Date.now()}/`,
})
await payload.update({
collection: 'contentPiece',
id: published.piece.id,
data: { status: 'publicado' },
overrideAccess: true,
})
await payload.update({
collection: 'contentPiece',
id: link.piece.id,
data: { status: 'publicado' },
overrideAccess: true,
})
const publishedSlug = (
await payload.findByID({
collection: 'contentPiece',
id: published.piece.id,
depth: 0,
overrideAccess: true,
})
).slug!
const draftSlug = (
await payload.findByID({
collection: 'contentPiece',
id: draft.piece.id,
depth: 0,
overrideAccess: true,
})
).slug
const linkSlug = (
await payload.findByID({
collection: 'contentPiece',
id: link.piece.id,
depth: 0,
overrideAccess: true,
})
).slug!
const full = await callPublicPieceMedia({ slug: publishedSlug })
expect(full.status).toBe(200)
expect(Buffer.from(await full.arrayBuffer())).toEqual(VIDEO_BYTES)
const partial = await callPublicPieceMedia({ slug: publishedSlug, range: 'bytes=0-3' })
expect(partial.status).toBe(206)
const download = await callPublicPieceMedia({ slug: publishedSlug, download: true })
expect(download.headers.get('Content-Disposition')).toContain('attachment')
// The public download carries the legible name, never the stored upload one.
expect(download.headers.get('Content-Disposition')).toContain(
`jorge-solla-1313-${publishedSlug}.mp4`,
)
// A draft has no public slug at all; a link piece has no archived file.
expect(draftSlug).toBeNull()
expect((await callPublicPieceMedia({ slug: linkSlug })).status).toBe(404)
expect((await callPublicPieceMedia({ slug: `nao-existe-${marker}` })).status).toBe(404)
// Unpublishing removes the file from the public door immediately.
await payload.update({
collection: 'contentPiece',
id: published.piece.id,
data: { status: 'rascunho' },
overrideAccess: true,
})
expect((await callPublicPieceMedia({ slug: publishedSlug })).status).toBe(404)
})
})

View file

@ -43,6 +43,13 @@ vi.mock('@/utilities/jingleReads', () => ({
getPublishedJingleItems: async () => [],
}))
// S27 — the same for the Central de Conteúdos discovery flag (cached read of
// the content pieces listing); the real listing/kill-switch behavior is
// e2e-covered (frontendConteudos.e2e.spec.ts).
vi.mock('@/utilities/content/contentPieceReads', () => ({
hasPublishedContentPieces: async () => false,
}))
// S14 — the card section renders the client studio island (next/font local
// face + matchMedia + canvas); its behavior is e2e-covered, so the unit
// skeleton mocks both the face module and the island.

View file

@ -0,0 +1,249 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
buildContentPieceCatalogHref,
contentPieceCatalogActiveFilters,
contentPieceCatalogFacets,
contentPieceDownloadFilename,
contentPieceMediaKind,
contentPieceMediaPath,
contentPiecePublicPath,
contentPieceTextExcerpt,
filterContentPieceCatalogItems,
parseContentPieceCatalogParams,
toContentPiecePublicItem,
type ContentPiecePublicItem,
type ContentPiecePublicSource,
} from '@/lib/contentPieceCatalog'
const source = (patch: Partial<ContentPiecePublicSource> = {}): ContentPiecePublicSource => ({
id: 1,
slug: 'fim-da-escala-6x1',
title: 'Fim da escala 6x1 é saúde',
type: 'video',
status: 'publicado',
origin: 'arquivo',
topics: ['economia-trabalho'],
cityLabel: 'Salvador',
region: 'Metropolitano de Salvador',
description: 'Solla explica a jornada.',
transcript: 'A redução da jornada é saúde.',
durationSeconds: 134,
searchText: 'fim da escala 6x1 e saude salvador',
media: { id: 7, filename: 'Fim da Escala.MP4' },
...patch,
})
const item = (patch: Partial<ContentPiecePublicSource> = {}): ContentPiecePublicItem => {
const resolved = toContentPiecePublicItem(source(patch))
if (!resolved) throw new Error('fixture should be public')
return resolved
}
describe('content piece catalog params', () => {
it('parses one valid value per facet and drops unknown values', () => {
expect(
parseContentPieceCatalogParams({
tipo: ['video', 'foto'],
cidade: 'salvador',
regiao: 'metropolitano-de-salvador',
tema: 'economia-trabalho',
instituicao: 'camara-dos-deputados',
q: ' escala ',
}),
).toEqual({
tipo: 'video',
cidade: 'salvador',
regiao: 'metropolitano-de-salvador',
tema: 'economia-trabalho',
instituicao: 'camara-dos-deputados',
q: 'escala',
})
expect(parseContentPieceCatalogParams({ tipo: 'inexistente', tema: 'nada' })).toEqual({
tipo: null,
cidade: null,
regiao: null,
tema: null,
instituicao: null,
q: '',
})
expect(parseContentPieceCatalogParams({ cidade: '../etc/passwd' }).cidade).toBeNull()
expect(parseContentPieceCatalogParams({}).q).toBe('')
})
it('builds the canonical href with a fixed order and no empty params', () => {
expect(buildContentPieceCatalogHref({})).toBe('/conteudos')
expect(buildContentPieceCatalogHref({ q: ' ' })).toBe('/conteudos')
expect(
buildContentPieceCatalogHref({
q: 'escala',
tema: 'economia-trabalho',
tipo: 'video',
cidade: 'salvador',
}),
).toBe('/conteudos?tipo=video&cidade=salvador&tema=economia-trabalho&q=escala')
expect(buildContentPieceCatalogHref({ tipo: 'video', tema: null, q: '' })).toBe(
'/conteudos?tipo=video',
)
})
it('lists the active filters with their removal hrefs', () => {
const items = [
item(),
item({ id: 2, slug: 'card', type: 'card', topics: ['saude'], cityLabel: null }),
]
const params = parseContentPieceCatalogParams({
tipo: 'video',
cidade: 'salvador',
q: 'escala',
})
const filters = contentPieceCatalogActiveFilters(params, contentPieceCatalogFacets(items))
expect(filters.map((filter) => `${filter.label}: ${filter.value}`)).toEqual([
'Tipo: Vídeo',
'Cidade: Salvador',
'Busca: escala',
])
expect(filters[0]?.removeHref).toBe('/conteudos?cidade=salvador&q=escala')
expect(filters[2]?.removeHref).toBe('/conteudos?tipo=video&cidade=salvador')
})
})
describe('content piece catalog facets', () => {
it('derives options from published items only, in enum/alphabetical order', () => {
const facets = contentPieceCatalogFacets([
item(),
item({
id: 2,
slug: 'card-saude',
type: 'card',
topics: ['saude'],
cityLabel: 'Feira de Santana',
region: 'Portal do Sertão',
institution: 'Câmara dos Deputados',
}),
])
expect(facets.tipo).toEqual([
{ value: 'video', label: 'Vídeo' },
{ value: 'card', label: 'Card' },
])
expect(facets.tema.map((option) => option.label)).toEqual(['Saúde', 'Economia e Trabalho'])
expect(facets.cidade.map((option) => option.label)).toEqual(['Feira de Santana', 'Salvador'])
expect(facets.regiao.map((option) => option.label)).toEqual([
'Metropolitano de Salvador',
'Portal do Sertão',
])
expect(facets.instituicao).toEqual([
{ value: 'camara-dos-deputados', label: 'Câmara dos Deputados' },
])
})
})
describe('content piece catalog filtering', () => {
const items = [
item(),
item({
id: 2,
slug: 'card-feira',
title: 'Card do giro',
type: 'card',
topics: ['saude'],
cityLabel: 'Feira de Santana',
searchText: 'card do giro saude feira de santana',
}),
item({
id: 3,
slug: 'link-yt',
origin: 'youtube',
media: null,
sourceUrl: 'https://youtu.be/X',
searchText: 'video no youtube',
}),
]
const idsFor = (raw: Record<string, string>) =>
filterContentPieceCatalogItems(items, parseContentPieceCatalogParams(raw)).map((row) => row.id)
it('combines facets and the accent-insensitive term', () => {
expect(idsFor({})).toEqual([1, 2, 3])
expect(idsFor({ tipo: 'video' })).toEqual([1, 3])
expect(idsFor({ tema: 'saude' })).toEqual([2])
expect(idsFor({ cidade: 'feira-de-santana' })).toEqual([2])
expect(idsFor({ q: 'ESCALA' })).toEqual([1])
expect(idsFor({ q: 'saúde' })).toEqual([1, 2])
expect(idsFor({ tipo: 'video', cidade: 'feira-de-santana' })).toEqual([])
expect(idsFor({ tipo: 'video', q: 'escala' })).toEqual([1])
})
})
describe('content piece public view model', () => {
it('resolves the file piece with paths, duration and metadata line', () => {
const view = item()
expect(view.isLink).toBe(false)
expect(view.typeLabel).toBe('Vídeo')
expect(view.durationLabel).toBe('02:14')
expect(view.metaLabel).toBe('Economia e Trabalho · Salvador')
expect(view.publicPath).toBe('/conteudos/fim-da-escala-6x1')
expect(view.file?.path).toBe('/conteudos/fim-da-escala-6x1/midia')
expect(view.file?.mimeType).toBeNull()
expect(view.file?.downloadFilename).toBe('jorge-solla-1313-fim-da-escala-6x1.mp4')
})
it('resolves a link piece with the platform URL and no media', () => {
const view = item({
origin: 'instagram',
media: null,
sourceUrl: 'https://www.instagram.com/reel/ABC/',
transcript: null,
})
expect(view.isLink).toBe(true)
expect(view.originLabel).toBe('Instagram')
expect(view.file).toBeNull()
})
it('resolves the media kind from the stored MIME with a type fallback', () => {
expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'video/mp4' } }))).toBe('video')
expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'audio/mpeg' } }))).toBe('audio')
expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'image/webp' } }))).toBe('image')
expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'text/plain' } }))).toBe('text')
expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'application/pdf' } }))).toBe(
'other',
)
// No MIME stored: the editorial type decides.
expect(contentPieceMediaKind(item({ media: { id: 7 } }))).toBe('video')
expect(contentPieceMediaKind(item({ type: 'card', media: { id: 7 } }))).toBe('image')
expect(contentPieceMediaKind(item({ media: null, sourceUrl: 'https://youtu.be/X' }))).toBeNull()
})
it('fails closed without a slug or without file and link', () => {
expect(toContentPiecePublicItem(source({ slug: null }))).toBeNull()
expect(toContentPiecePublicItem(source({ status: 'rascunho' }))).toBeNull()
expect(toContentPiecePublicItem(source({ media: null }))).toBeNull()
expect(toContentPiecePublicItem(source({ media: null, sourceUrl: null }))).toBeNull()
expect(
toContentPiecePublicItem(source({ media: null, sourceUrl: 'https://youtu.be/X' }))?.isLink,
).toBe(true)
})
it('keeps the text excerpt short and the download name legible', () => {
expect(contentPieceTextExcerpt('\n “Saúde é direito.” \nSegunda linha')).toBe(
'“Saúde é direito.”',
)
expect(contentPieceTextExcerpt('')).toBeNull()
expect(contentPieceTextExcerpt('a'.repeat(200))?.endsWith('…')).toBe(true)
expect(contentPieceDownloadFilename('card-feira', 'Card Final.PNG')).toBe(
'jorge-solla-1313-card-feira.png',
)
expect(contentPieceDownloadFilename('sem-extensao', null)).toBe(
'jorge-solla-1313-sem-extensao.bin',
)
expect(contentPiecePublicPath('x')).toBe('/conteudos/x')
expect(contentPieceMediaPath('x')).toBe('/conteudos/x/midia')
})
})

View file

@ -0,0 +1,69 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
buildContentPieceWhatsAppUrl,
contentPieceShareLink,
contentPieceVoteMessage,
} from '@/lib/contentPieceShare'
const ORIGIN = 'https://jorgesolla1313.com.br'
const LINK = 'https://jorgesolla1313.com.br/conteudos/fim-da-escala-6x1'
describe('content piece vote message (S27)', () => {
it('uses the video literal with title and link', () => {
expect(contentPieceVoteMessage('video', 'Fim da escala 6×1 é saúde', LINK)).toBe(
`Estamos na reta final e o Jorge Solla 1313 precisa do seu voto. Olha esse vídeo: Fim da escala 6×1 é saúde — ${LINK}. Peça voto pra Solla 1313 pra quem você conhece.`,
)
})
it('uses the audio literal', () => {
expect(contentPieceVoteMessage('audio', 'Mensagem para o grupo', LINK)).toBe(
`Ouça e mande pro grupo: Mensagem para o grupo — ${LINK}. Peça voto pra Solla 1313.`,
)
})
it('uses the material literal for foto, texto and card', () => {
const expected = `Fiz/achei esse material do Solla 1313: Card do giro — ${LINK}. Peça voto pra Solla 1313 também.`
expect(contentPieceVoteMessage('foto', 'Card do giro', LINK)).toBe(expected)
expect(contentPieceVoteMessage('texto', 'Card do giro', LINK)).toBe(expected)
expect(contentPieceVoteMessage('card', 'Card do giro', LINK)).toBe(expected)
})
})
describe('content piece share link (S27)', () => {
it('shares the public page for an archived piece', () => {
expect(
contentPieceShareLink({ slug: 'fim-da-escala-6x1', isLink: false, sourceUrl: null }, ORIGIN),
).toBe(LINK)
})
it('shares the canonical platform URL for a link piece (never a second copy)', () => {
expect(
contentPieceShareLink(
{
slug: 'post-instagram',
isLink: true,
sourceUrl: 'https://www.instagram.com/reel/ABC/',
},
ORIGIN,
),
).toBe('https://www.instagram.com/reel/ABC/')
})
it('degrades to the relative path without an origin', () => {
expect(contentPieceShareLink({ slug: 'x', isLink: false, sourceUrl: null }, '')).toBe(
'/conteudos/x',
)
})
})
describe('content piece WhatsApp url (S27)', () => {
it('prefills the sender text with no recipient and no automatic send', () => {
const url = new URL(buildContentPieceWhatsAppUrl('Peça voto pra Solla 1313'))
expect(url.origin).toBe('https://wa.me')
expect(url.pathname).toBe('/')
expect(url.searchParams.get('text')).toBe('Peça voto pra Solla 1313')
})
})

View file

@ -71,6 +71,10 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => {
// S21 — deliberate: the jingles page/player/download contract is new and
// the migration makes every PR of this delivery high-risk (curated only).
'frontendJingles',
// S27 — deliberate: the Central catalogue/piece page, the public media
// route and the vote share sheet are new contracts over the C211
// collection (curated only).
'frontendConteudos',
])
const onDisk = new Set(specNamesOnDisk())
for (const spec of E2E_CURATED_SPECS) {

View file

@ -36,6 +36,30 @@ describe('private media response rules (C193/C199)', () => {
expect(privateMediaContentType(null)).toBe(PRIVATE_MEDIA_FALLBACK_MIME_TYPE)
})
// S27 — the content pieces' photo/audio families: a published photo must
// render and an audio piece must play, so these render inline; markup
// (SVG) and formats browsers do not render (HEIC) stay download-only.
it('serves the content piece image and audio families inline', () => {
for (const mimeType of [
'image/png',
'image/webp',
'image/gif',
'image/avif',
'audio/mp4',
'audio/aac',
'audio/ogg',
'audio/opus',
'audio/wav',
'audio/webm',
'audio/flac',
]) {
expect(privateMediaContentType(mimeType), mimeType).toBe(mimeType)
}
expect(privateMediaContentType('image/heic')).toBe(PRIVATE_MEDIA_FALLBACK_MIME_TYPE)
expect(privateMediaContentType('image/svg+xml')).toBe(PRIVATE_MEDIA_FALLBACK_MIME_TYPE)
})
it('builds both filename forms and encodes non-ASCII', () => {
expect(privateMediaContentDisposition('reel.mp4', false)).toBe(
'inline; filename="reel.mp4"; filename*=UTF-8\'\'reel.mp4',

View file

@ -43,6 +43,7 @@ describe('shareLink slug', () => {
'artigos',
'campanha',
'cards',
'conteudos',
'corte',
'evento',
'jingles',