diff --git a/docs/changelog/2026-09-23-s27.md b/docs/changelog/2026-09-23-s27.md new file mode 100644 index 00000000..fb6b333a --- /dev/null +++ b/docs/changelog/2026-09-23-s27.md @@ -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/` 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//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-.`; 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. diff --git a/docs/plans/central-conteudos-publica-impl.md b/docs/plans/central-conteudos-publica-impl.md new file mode 100644 index 00000000..e66f8b3c --- /dev/null +++ b/docs/plans/central-conteudos-publica-impl.md @@ -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/` 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/` (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 ``, 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//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 `` — 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//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 `
` + 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 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/` 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//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//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). diff --git a/docs/plans/central-conteudos-publica-ui-design.html b/docs/plans/central-conteudos-publica-ui-design.html index 5ef1f00d..b437d983 100644 --- a/docs/plans/central-conteudos-publica-ui-design.html +++ b/docs/plans/central-conteudos-publica-ui-design.html @@ -494,6 +494,323 @@ Estamos na reta final e o Jorge Solla 1313 precisa do seu voto. Olha esse vídeo + +
+
+ CENA 06 +

Filtros ativos sem resultado · desktop 1280 e mobile 390

+
+
+ + +
+
+ Jorge Solla☰ +
+
+

PEÇA VOTO PRA SOLLA 1313

+

+ Encontre a peça certa para pedir voto. +

+
+
+
+ mobilidade em Juazeiro× +
+
+ Vídeo ×Juazeiro ×Tema⌄ +
+
+ +

Nenhuma peça com esses filtros

+

+ Tente outro termo ou limpe os filtros para ver todas as peças. +

+
+ Busca: “mobilidade em Juazeiro”VídeoJuazeiro +
+ Limpar filtros +
+
+
+
+
+ + + +
+
+ CENA 08 +

Compartilhamento · desktop 1280, ancorado ao card

+
+
+
+ Jorge SollaCentral de Conteúdos +
+
+
+
+
+ NEEDS ASSET · POSTER REAL +
+
+ Vídeo +

Fim da escala 6×1 é saúde

+

Trabalho · Salvador

+
+ +
+
+
+
+
NEEDS ASSET · CAPA REAL DO ÁUDIO
+
+ Áudio +

Mensagem para mandar no grupo

+
+ +
+
+
+
+
+
+ +
+
diff --git a/docs/plans/escala-dry-pos-s27.md b/docs/plans/escala-dry-pos-s27.md new file mode 100644 index 00000000..16ed35ee --- /dev/null +++ b/docs/plans/escala-dry-pos-s27.md @@ -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.** diff --git a/playwright.config.ts b/playwright.config.ts index fb83f548..074be66b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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/, diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index ce6ca31b..925b58aa 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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 diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route.ts index da50d086..b8ebfda2 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route.ts +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route.ts @@ -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', }) diff --git a/src/app/(frontend)/(home)/cards/page.tsx b/src/app/(frontend)/(home)/cards/page.tsx index a15a05ce..77c3b197 100644 --- a/src/app/(frontend)/(home)/cards/page.tsx +++ b/src/app/(frontend)/(home)/cards/page.tsx @@ -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) { - + ) } diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index 57c8997f..948d75dd 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -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() { - 0} /> + 0} showConteudos={showConteudos} /> ) } diff --git a/src/app/(frontend)/conteudos/(catalog)/loading.tsx b/src/app/(frontend)/conteudos/(catalog)/loading.tsx new file mode 100644 index 00000000..d12ae731 --- /dev/null +++ b/src/app/(frontend)/conteudos/(catalog)/loading.tsx @@ -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 ( +
+
+
+ {[0, 1, 2].map((index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+ ) +} diff --git a/src/app/(frontend)/conteudos/(catalog)/page.tsx b/src/app/(frontend)/conteudos/(catalog)/page.tsx new file mode 100644 index 00000000..94451d23 --- /dev/null +++ b/src/app/(frontend)/conteudos/(catalog)/page.tsx @@ -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 { + 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 +}) { + const [rawSearchParams, items, showJingles] = await Promise.all([ + searchParams, + getPublishedContentPieceItems(), + hasPublishedJingles(), + ]) + + if (items.length === 0) { + return ( + <> + +
+ +
+ + + ) + } + + const params = parseContentPieceCatalogParams(rawSearchParams) + const facets = contentPieceCatalogFacets(items) + const filteredItems = filterContentPieceCatalogItems(items, params) + const activeFilters = contentPieceCatalogActiveFilters(params, facets) + const hasActiveFilters = activeFilters.length > 0 + + return ( + <> + +
+ + +
+ + +
+
+
+

+ Escolha, compartilhe e peça voto +

+

+ O play só carrega a mídia quando você pedir. +

+
+ + Abra o título para ver detalhes + +
+ +
+ {filteredItems.length > 0 ? ( + + ) : ( + + )} +
+
+
+
+ + + + ) +} diff --git a/src/app/(frontend)/conteudos/[slug]/midia/route.ts b/src/app/(frontend)/conteudos/[slug]/midia/route.ts new file mode 100644 index 00000000..6e5e4005 --- /dev/null +++ b/src/app/(frontend)/conteudos/[slug]/midia/route.ts @@ -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 => { + 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-.`, never the internal + // upload name; the object key stays `media.filename`. + dispositionFilename: contentPieceDownloadFilename(slug, media.filename), + }) +} diff --git a/src/app/(frontend)/conteudos/[slug]/not-found.tsx b/src/app/(frontend)/conteudos/[slug]/not-found.tsx new file mode 100644 index 00000000..dbf3c770 --- /dev/null +++ b/src/app/(frontend)/conteudos/[slug]/not-found.tsx @@ -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 ( + <> + +
+
+ + +

+ Esta peça não está disponível +

+

+ Ela pode ter sido retirada. Encontre outro material oficial e continue pedindo voto. +

+ + Ver outras peças + +
+
+ + ) +} diff --git a/src/app/(frontend)/conteudos/[slug]/page.tsx b/src/app/(frontend)/conteudos/[slug]/page.tsx new file mode 100644 index 00000000..ddc1b3fc --- /dev/null +++ b/src/app/(frontend)/conteudos/[slug]/page.tsx @@ -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 { + 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 ( + <> + +
+
+ +
+
+ + + ) +} diff --git a/src/app/(frontend)/conteudos/layout.tsx b/src/app/(frontend)/conteudos/layout.tsx new file mode 100644 index 00000000..1e48e813 --- /dev/null +++ b/src/app/(frontend)/conteudos/layout.tsx @@ -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 ( +
+ {children} +
+ ) +} diff --git a/src/app/(frontend)/jingles/page.tsx b/src/app/(frontend)/jingles/page.tsx index 8dd73e50..1f862d4e 100644 --- a/src/app/(frontend)/jingles/page.tsx +++ b/src/app/(frontend)/jingles/page.tsx @@ -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 { } 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() { )} - + ) } diff --git a/src/components/CampaignFooter.tsx b/src/components/CampaignFooter.tsx index 46b816c5..beac0473 100644 --- a/src/components/CampaignFooter.tsx +++ b/src/components/CampaignFooter.tsx @@ -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 (