mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
e72bfe8f8f
commit
dda55adc27
43 changed files with 3582 additions and 15 deletions
1
docs/changelog/2026-09-23-s27.md
Normal file
1
docs/changelog/2026-09-23-s27.md
Normal 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.
|
||||
197
docs/plans/central-conteudos-publica-impl.md
Normal file
197
docs/plans/central-conteudos-publica-impl.md
Normal 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).
|
||||
|
|
@ -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 — <link>. 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>
|
||||
|
|
|
|||
56
docs/plans/escala-dry-pos-s27.md
Normal file
56
docs/plans/escala-dry-pos-s27.md
Normal 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.**
|
||||
|
|
@ -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/,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
27
src/app/(frontend)/conteudos/(catalog)/loading.tsx
Normal file
27
src/app/(frontend)/conteudos/(catalog)/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
169
src/app/(frontend)/conteudos/(catalog)/page.tsx
Normal file
169
src/app/(frontend)/conteudos/(catalog)/page.tsx
Normal 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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
61
src/app/(frontend)/conteudos/[slug]/midia/route.ts
Normal file
61
src/app/(frontend)/conteudos/[slug]/midia/route.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
38
src/app/(frontend)/conteudos/[slug]/not-found.tsx
Normal file
38
src/app/(frontend)/conteudos/[slug]/not-found.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
114
src/app/(frontend)/conteudos/[slug]/page.tsx
Normal file
114
src/app/(frontend)/conteudos/[slug]/page.tsx
Normal 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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
18
src/app/(frontend)/conteudos/layout.tsx
Normal file
18
src/app/(frontend)/conteudos/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
47
src/components/conteudos/ContentPieceActions.tsx
Normal file
47
src/components/conteudos/ContentPieceActions.tsx
Normal 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>
|
||||
)
|
||||
110
src/components/conteudos/ContentPieceCard.tsx
Normal file
110
src/components/conteudos/ContentPieceCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
src/components/conteudos/ContentPieceCardInvite.tsx
Normal file
44
src/components/conteudos/ContentPieceCardInvite.tsx
Normal 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>
|
||||
)
|
||||
68
src/components/conteudos/ContentPieceCatalog.tsx
Normal file
68
src/components/conteudos/ContentPieceCatalog.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
90
src/components/conteudos/ContentPieceDetail.tsx
Normal file
90
src/components/conteudos/ContentPieceDetail.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
125
src/components/conteudos/ContentPieceFilters.tsx
Normal file
125
src/components/conteudos/ContentPieceFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/components/conteudos/ContentPieceHero.tsx
Normal file
36
src/components/conteudos/ContentPieceHero.tsx
Normal 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>
|
||||
)
|
||||
196
src/components/conteudos/ContentPieceMedia.tsx
Normal file
196
src/components/conteudos/ContentPieceMedia.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/components/conteudos/ContentPiecePageHeader.tsx
Normal file
36
src/components/conteudos/ContentPiecePageHeader.tsx
Normal 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>
|
||||
)
|
||||
187
src/components/conteudos/ContentPieceShareSheet.tsx
Normal file
187
src/components/conteudos/ContentPieceShareSheet.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
70
src/components/conteudos/ContentPieceStates.tsx
Normal file
70
src/components/conteudos/ContentPieceStates.tsx
Normal 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>
|
||||
)
|
||||
35
src/components/conteudos/contentPieceClasses.ts
Normal file
35
src/components/conteudos/contentPieceClasses.ts
Normal 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%)]',
|
||||
)
|
||||
415
src/lib/contentPieceCatalog.ts
Normal file
415
src/lib/contentPieceCatalog.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
54
src/lib/contentPieceShare.ts
Normal file
54
src/lib/contentPieceShare.ts
Normal 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)
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const SHARE_LINK_RESERVED_SLUGS = [
|
|||
'mandato-no-whatsapp',
|
||||
'abaixo-assinado',
|
||||
'jingles',
|
||||
'conteudos',
|
||||
'api',
|
||||
'admin',
|
||||
'noticia',
|
||||
|
|
|
|||
84
src/utilities/content/contentPieceReads.ts
Normal file
84
src/utilities/content/contentPieceReads.ts
Normal 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
|
||||
|
|
@ -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,
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
369
tests/e2e/frontendConteudos.e2e.spec.ts
Normal file
369
tests/e2e/frontendConteudos.e2e.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
249
tests/unit/contentPieceCatalog.unit.spec.ts
Normal file
249
tests/unit/contentPieceCatalog.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
69
tests/unit/contentPieceShare.unit.spec.ts
Normal file
69
tests/unit/contentPieceShare.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ describe('shareLink slug', () => {
|
|||
'artigos',
|
||||
'campanha',
|
||||
'cards',
|
||||
'conteudos',
|
||||
'corte',
|
||||
'evento',
|
||||
'jingles',
|
||||
|
|
|
|||
Loading…
Reference in a new issue