feat(S42): seção da Central compartilha a peça e abre /conteudos já filtrada

A seção S39 era só vitrine: quem via a peça do seu território tinha de ir
até o catálogo para compartilhar ou recortar. O card agora abre a MESMA
folha do S27 (mensagem de voto por tipo, contador C213, sem sair da home) e
as tags com faceta honesta — mais a fileira Tipo | Cidade | Região — levam
a /conteudos já filtrada pelo construtor canônico; nada filtra a amostra
local e as tags sem faceta (Mais recente, origem) continuam estáticas.

As facetas da fileira são derivadas no server (contentPieceCatalogFacets,
mesmo vocabulário do catálogo) e o lean ContentPieceHomeItem ganha só
sourceUrl, com a folha S27 estreitada ao que lê; classes novas são
home-scoped — CONTENT_PIECE_TAG e /conteudos ficam intactos. Sem migration,
Consent ou analytics novos.

Closes #1391
This commit is contained in:
Francisco Solla 2026-09-30 00:27:16 -03:00
parent 3201b7a93f
commit 862da74fbe
15 changed files with 672 additions and 99 deletions

View file

@ -0,0 +1 @@
**S42 — Home: a seção da Central compartilha a peça no card e abre `/conteudos` já filtrada (2026-09-30):** a seção S39 deixa de ser só vitrine: o card ganha `Compartilhar` (full width, artefato) que abre a **mesma** folha do S27 — mensagem de voto por tipo, `wa.me` sem destinatário e o contador anônimo C213 por peça, sem sair da home; as tags com faceta honesta (`Foto`/`Vídeo`/…, `Do seu município`, `Da sua região`) e a nova fileira `Tipo | Cidade | Região` viram links via `buildContentPieceCatalogHref` (`?tipo=|?cidade=|?regiao=`) para o catálogo já filtrado, e a tag da seção `⌖ Para seu município` faz o mesmo com o município do visitante; tags sem faceta (`Mais recente`, origem Instagram/YouTube) permanecem estáticas e **nada filtra a amostra localmente** (a fileira só monta URL — sem estado, sem resultado vazio). A fileira (`ContentPieceHomeFilterRow`) é presentacional e recebe as facetas derivadas no server por `contentPieceCatalogFacets` (mesmo vocabulário do catálogo; faceta sem opção não renderiza), com menus `<details>` que não estouram o viewport em 390/1280; o lean `ContentPieceHomeItem` ganha `sourceUrl` (a folha S27 teve a prop estreitada ao subconjunto que lê, sem tocar o catálogo) e as classes novas de tag/tag-link são home-scoped — `CONTENT_PIECE_TAG`/`CONTENT_PIECE_LOCAL_TAG` e `/conteudos` ficam byte-iguais. Sem migration, sem Consent/PII, sem analytics/localização novos; fail-closed (zero peças publicadas ⇒ seção some) intacto. Cobertura: unit novo da fileira, unit do board (folha aberta, hrefs canônicos, estáticas, amostra intacta) e pin da projeção, e2e da seção (share na home, tags, fileira aberta sem overflow e navegação para o catálogo já filtrado).

View file

@ -0,0 +1,134 @@
# Impl: S42 — Home — seção da Central de Conteúdos: compartilhar a peça no card e filtro que abre /conteudos já filtrado
Status: aprovado
Atualizado em: 2026-09-30
Issue: #1391
Intenção: docs/plans/central-conteudos-secao-home-share-filtro.md
Appetite restante: herdado — ~1–1,5 dia eng (S42), sem corte
## Leitura da intenção
- **Outcome:** o card da seção passa a compartilhar a peça pela **mesma** folha do S27 sem sair da home, e a seção ganha tags-atalho + fileira (`Tipo | Cidade | Região`) que abrem `/conteudos` já filtrado pelo construtor canônico — a seção vira atalho de circulação, nunca uma mini-Central.
- **O que NÃO negociar:** folha única do S27 (mensagem de voto por tipo, contador C213, sem mecanismo paralelo); URL canônica de `buildContentPieceCatalogHref` (`?tipo=|?cidade=|?regiao=`); tags sem faceta honesta (`Mais recente`, `Seleção recente`, origem Instagram/YouTube) estáticas; fileira **não** filtra a amostra local (sem estado de filtro, sem resultado vazio local); fail-closed (zero peças publicadas ⇒ seção some) intacto; `Ver esta peça →`/`Ver todas as peças →` seguem; sem download/analytics/localização/Consent/PII novos; `/conteudos` e demais seções intocadas; sem overflow horizontal novo em 390/1280.
- **O que reavaliar (hipóteses confirmadas por leitura direta):**
- O lean `ContentPieceHomeItem` **não carrega `sourceUrl`** (`src/lib/contentPieceHomeSelection.ts:25-43`) e a folha precisa dele para peça-link (`src/lib/contentPieceShare.ts:16-50`) — D2.
- `contentPieceCatalogFacets` lê `topics`/`institution` (`src/lib/contentPieceCatalog.ts:215-226`), então **não** roda sobre o item lean: calcula na section server, antes da projeção — D1.
- O board é o dono natural do estado de share (precedente `ContentPieceCatalog.tsx:36-37,86-88`); o card é folha e ganha só `onShare` — D5.
- `ContentPieceCatalogFacetOption` não é exportado, mas `ContentPieceCatalogFacets` é — o recorte `Pick<…, 'tipo'|'cidade'|'regiao'>` resolve sem exportar tipo novo.
## Abordagem recomendada
```mermaid
flowchart LR
PAGE["page.tsx (home)<br/>contentPieces.length > 0"] --> SEC["ContentPieceHomeSection (server)<br/>contentPieceCatalogFacets<br/>→ Pick tipo|cidade|regiao<br/>+ projeção lean (sourceUrl)"]
SEC -->|"items + facets"| BOARD["ContentPieceHomeBoard (client)<br/>playing · sharingItem · visitor"]
BOARD --> CARD["ContentPieceHomeCard<br/>tag tipo→?tipo, match→?cidade|?regiao<br/>Compartilhar + Ver esta peça"]
BOARD --> ROW["ContentPieceHomeFilterRow<br/>details Tipo|Cidade|Região"]
BOARD --> SHEET["ContentPieceShareSheet (S27)<br/>mesmo mecanismo · prop estreitada"]
CARD -->|"buildContentPieceCatalogHref"| CAT["/conteudos já filtrada"]
ROW -->|"buildContentPieceCatalogHref"| CAT
SHEET -.->|"C213 (já existente)"| EV["contentEvents"]
```
**Opções consideradas:** A) estender os donos existentes (section calcula facetas no server; board guarda `sharingItem` e renderiza a folha S27; card renderiza tags-link/Compartilhar; fileira é componente novo sem estado) | B) ilha própria da seção com filtro local sobre a amostra | C) rota intermediária com estado de filtro próprio da seção.
**Recomendação:** A — reusa o dono da URL (`buildContentPieceCatalogHref`), o dono das facetas (`contentPieceCatalogFacets`), o dono do mecanismo de share (`ContentPieceShareSheet`) e o dono das classes (`contentPieceClasses.ts`); o delta fica em 1 campo de projeção + markup portado do artefato. Cabe no appetite e mantém a amostra intocada.
**Rejeitadas:** B porque é o rabbit hole "já que tem atalho, filtra a amostra aqui mesmo" (estado local, cards escondidos, vazio local — cortado na intenção) e engordaria o board com busca/filtro que não existem no aceite; C porque cria um segundo contrato de URL/estado para o mesmo vocabulário, furando o construtor canônico.
### Decisões de engenharia (caras de reverter)
**D1 — Onde computar as facetas da fileira.**
Opções: A) na `ContentPieceHomeSection` (server), com `contentPieceCatalogFacets` canônico, passando ao board só `Pick<ContentPieceCatalogFacets, 'tipo' | 'cidade' | 'regiao'>` | B) no board (client), derivando de `ContentPieceHomeItem` | C) utility nova `contentPieceHomeFacets`.
Recomendação: A — o dono das facetas roda uma vez no server, perto da leitura cacheada, com o branch de card incluso de graça; o board recebe dados serializáveis já prontos (lista curta valor/label) e o client não carrega a derivação.
Rejeitadas: B porque o item lean não tem `topics`/`institution` — exigiria engordar o `Pick` com campos que o card não renderiza (quebrando o guardrail do S39) ou reimplementar o dono; C porque é pass-through raso de 3 facetas e vira gêmeo de `contentPieceCatalogFacets`.
**D2 — Reuso da folha S27 e resolução do `sourceUrl`.**
Opções: A) adicionar `sourceUrl` ao `Pick`/projeção de `ContentPieceHomeItem` e estreitar a prop de `ContentPieceShareSheet` ao subconjunto que ela consome (`slug`, `title`, `type`, `file`, `isLink`, `sourceUrl`) | B) manter a folha exigindo `ContentPiecePublicItem` e o board guardar o item público completo | C) folha da home própria/wrapper.
Recomendação: A — segue o precedente do módulo (`ContentPieceMedia.tsx:9-21`, `ContentPieceCardParts.tsx:15,37`): a folha declara só o que lê, o catálogo continua passando o item inteiro por assignabilidade estrutural e a home passa o lean + `sourceUrl` (URL pública de peça-link, já exposta no catálogo/página da peça; sem PII e sem o haystack `searchText`/`description`). Zero mudança visual na folha; um mecanismo só.
Rejeitadas: B porque obrigaria o board a receber/manter o `ContentPiecePublicItem` full (o haystack que a projeção S39 existe para manter fora do HTML estático e do client); C é o rabbit hole "faz um botão de share próprio do card" (dois mecanismos com mensagens divergentes).
**D3 — Componente da fileira vs inline vs reuso do catálogo.**
Opções: A) `ContentPieceHomeFilterRow` novo em `src/components/conteudos/`, presentacional e sem estado, recebendo as facetas | B) inline no `ContentPieceHomeBoard` | C) reusar `ContentPieceFilters` do catálogo.
Recomendação: A — a fileira é uma unidade com contrato próprio (caption + 3 `<details>` + menus) montada pelo board em dois contextos (multi e single, cena 01/04/05); sem estado (`<details>` nativo), reusa `contentPieceCatalogFacetLabels`, `buildContentPieceCatalogHref` e o padrão `ChevronDown` (`ContentPieceFilters.tsx:33-39,141-190`).
Rejeitadas: B porque o board já carrega geolocalização/seleção/playing/sharing e ganharia ~80 linhas de markup de facetas; C porque `ContentPieceFilters` é o form GET do catálogo (busca, modo tema, chips ativos, hidden inputs) — outro contrato, outra rota.
**D4 — Classes novas home-scoped vs alterar as compartilhadas.**
Opções: A) classes novas home-scoped em `contentPieceClasses.ts`, mantendo `CONTENT_PIECE_TAG`/`CONTENT_PIECE_LOCAL_TAG` intactas | B) alterar as compartilhadas para o spec do design | C) inline no card/fileira.
Recomendação: A — o S42 muda forma e altura (min-h-10 mobile / 36px de `sm`, contorno + chevron nas tags-link) e as classes antigas são consumidas pelo catálogo e pela página da peça, que o aceite manda não tocar; as novas ficam no dono das classes (mesmo arquivo), com prefixo home, e atendem tags do card, tag da seção e estáticas.
Rejeitadas: B porque vaza para `/conteudos`/detalhe sem intenção; C porque repetiria a mesma string em 3–4 lugares sem contrato único.
**D5 — Onde derivar o href das tags.**
Opções: A) no card, com `buildContentPieceCatalogHref`/`slugify` sobre o próprio `match`/`item` | B) no board, passando um `href` por tag ao card | C) helper local por tipo de tag.
Recomendação: A — o mapa tag→faceta é propriedade da tag que o card já possui (`MATCH_LABELS`, `item.isLink`, `item.type`) e o builder canônico é puro/client-safe (`src/lib/contentPieceCatalog.ts:131-151`); o card segue folha (ganha só `onShare`) e o board deriva apenas o href da tag da seção (`⌖`), que depende do `visitor` que ele mesmo resolve.
Rejeitadas: B porque espalha o vocabulário de tags do card para o board; C porque recria o builder (gêmeo do dono). Guarda barata: label nulo (não ocorre nos matches por construção) ⇒ tag fica estática, nunca `?cidade=undefined`.
### Componentes / mudanças
- **`ContentPieceHomeSection`** (`src/components/conteudos/ContentPieceHomeSection.tsx`): server; calcula `contentPieceCatalogFacets(items)` antes de `items.map(toContentPieceHomeItem)` e passa `items` + `Pick<ContentPieceCatalogFacets, 'tipo'|'cidade'|'regiao'>` ao board. `page.tsx` intocado (já entrega `ContentPiecePublicItem[]` e mantém o fail-closed `:257`).
- **`ContentPieceHomeItem` / `toContentPieceHomeItem`** (`src/lib/contentPieceHomeSelection.ts`): `sourceUrl: string | null` entra no `Pick` e no mapa campo-a-campo (um campo novo; o guardrail "nunca por spread" permanece).
- **`ContentPieceHomeBoard`** (`src/components/conteudos/ContentPieceHomeBoard.tsx`): ganha `facets` (props), estado `sharingItem: ContentPieceHomeItem | null` (espelha o playing), `onShare` nos cards, render de `ContentPieceShareSheet` quando setado, e a tag da seção `⌖ Para seu município` vira link `?cidade=slugify(visitor.municipalityName)` (mantendo `data-sample-tag`); `Seleção recente` continua estática.
- **`ContentPieceHomeCard`** (`src/components/conteudos/ContentPieceHomeCard.tsx`): tags — tipo link `?tipo=<enum>` quando peça não-link, `Do seu município`/`Da sua região` link `?cidade|?regiao` (D5), origem/`Mais recente` estáticas; `Compartilhar` com `CONTENT_PIECE_PRIMARY_BUTTON` e `aria-label={\`Compartilhar ${item.title}\`}` (`mt-3 sm:mt-4`, full width) antes de `Ver esta peça →`, que assume `.piece-link` (`mt-2 w-full justify-center text-xs font-extrabold`).
- **`ContentPieceHomeFilterRow`** (novo, `src/components/conteudos/ContentPieceHomeFilterRow.tsx`): presentacional sem estado; caption `Explore na Central · abre o catálogo já filtrado` (desktop) / `… abre já filtrada` (mobile); `grid grid-cols-3 gap-2`; 3 `<details name="content-home-explore">` exclusivos com `<summary>` chip e menus absolutos conforme o artefato; opções via `buildContentPieceCatalogHref({ [facet]: option.value })` (home não tem outros filtros a preservar); faceta sem opção **não renderiza** o chip.
- **`ContentPieceShareSheet`** (`src/components/conteudos/ContentPieceShareSheet.tsx`): prop estreitada para `Pick<ContentPiecePublicItem, 'slug'|'title'|'type'|'file'|'isLink'|'sourceUrl'>`; **zero mudança visual/comportamental** (mensagem, C213, portal, foco, Escape intactos). `ContentPieceCatalog` continua passando o item inteiro.
- **`contentPieceClasses.ts`** (`src/components/conteudos/contentPieceClasses.ts`): classes home-scoped novas — estáticas (`min-h-10 sm:min-h-9`, fundo `#eef4fb`/local `#fff3c4`) e link (contorno `rgb(24 78 146/30%)`, bg `#f8fbff`, hover borda currentColor/bg `#eef4fb`/underline/`translateY(-1px)`, variante local `rgb(107 81 0/28%)`/`#fff8dc`/`#fff3c4`) + `.piece-link`; `CONTENT_PIECE_TAG`/`CONTENT_PIECE_LOCAL_TAG`/`CONTENT_PIECE_FOCUS`/`CONTENT_PIECE_PRIMARY_BUTTON` intocadas/reusadas.
- **Migration:** sem migration — nenhuma collection, global, campo Payload ou índice muda.
- **Access / Consent:** sem mudança e sem chave nova — nenhuma leitura/write de PII, nenhum `Consent`; a folha segue o C213 existente.
- **UI:** Impeccable B — port classe-a-classe do artefato aprovado no gate (`…-ui-design.html`, cenas 01–06; card, fileira fechada/aberta, folha, foco, peça única); sem shape novo; shells reusados: `ContentPieceShareSheet`, `CONTENT_PIECE_PRIMARY_BUTTON`, `CONTENT_PIECE_FOCUS`, `ChevronDown`/`ChevronRight` (lucide) e o padrão `<details>/<summary>` do catálogo.
### Dados → forma (se aplicável)
N/A — nenhuma contagem/ranking/série nova; a circulação continua no contador anônimo por peça do C213, sem dado novo do visitante.
## Fases verificáveis
1. **Tracer — server + folha (~0,4 dia).** `sourceUrl` no `Pick`/projeção; facetas na section; board com `sharingItem` + `ContentPieceShareSheet` (prop estreitada) e `Compartilhar` no card. Atualizar fixture de `contentPieceHomeBoard.unit.spec.tsx` e o pin de `Object.keys` de `contentPieceHomeSelection.unit.spec.ts:84-108`. **Prova:** unit do board — clicar `Compartilhar <título>` abre o `dialog` com a mensagem do tipo e a URL da home não muda.
2. **Card tags-link + classes home (~0,3 dia).** Classes novas; tags do card (tipo/município/região link; origem/`Mais recente` estáticas); tag da seção `⌖` vira link do visitor; `Ver esta peça →` no spec `.piece-link`. **Prova:** unit com hrefs exatos (`/conteudos?tipo=foto`, `/conteudos?cidade=…`, `/conteudos?regiao=…`) e estáticas sem `<a>`.
3. **Fileira (~0,3 dia).** `ContentPieceHomeFilterRow` + posicionamento (multi: entre intro/tags e os cards, `mx-auto max-w-xl`; single: coluna esquerda, `max-w-md`, caption alinhada à esquerda no desktop, antes do CTA — cenas 01/04/05). **Prova:** unit da fileira (details/opções com href canônico; chip ausente quando a faceta é vazia).
4. **Testes da seção (~0,3 dia).** Unit do board para fileira/tags/estáticas e e2e em `tests/e2e/frontendConteudos.e2e.spec.ts` (estender o teste `:732` ou novo): abrir `Compartilhar` na home → folha visível, home atrás; tag → `/conteudos?cidade=…`; abrir a fileira → link de opção com href canônico; overflow 390 (com menu aberto) ≤1 e 1280 sem overflow.
5. **Gates.** `pnpm gate:fast` (lint/format/typecheck/knip/cycles/unit/int/build) + e2e afetado (`frontendConteudos`, seleção pelo manifesto); push via `pnpm push` → PR. Sem migration a aplicar.
## Rabbit holes / Não escopo (engenharia)
- **Filtro local na seção** (estado que esconde cards, busca, resultado vazio local) — cortado na intenção; a fileira só monta URL.
- **Segundo mecanismo de share** (hook/contexto/botão próprio do card com mensagem própria) — a folha S27 é o único; o estado é local do board (1 call site, sem `useContentPieceShare` novo).
- **Download/analytics/localização/Consent novos** — nada disso; C213 existente é o único evento.
- **Tocar `/conteudos`, catálogo, página da peça ou S3** (`ContentPieceFilters`, `ContentPieceCatalog`, `ContentPieceCard`, `CampaignContentSection`) — fora do aceite.
- **Alterar `CONTENT_PIECE_TAG`/`LOCAL_TAG`** — classes novas home-scoped; as compartilhadas ficam byte-iguais.
- **Engordar o lean item além de `sourceUrl`** (trazer `topics`/`description`/`searchText`) — proibido pelo guardrail S39.
- **Editar `scripts/lib/e2e-affected-manifest.mjs`** — `(home)`, `components/conteudos` e `contentPieceHomeSelection` já mapeiam para `frontendConteudos`.
- **Facetas Tema/Instituição/Liderança na fileira v1** — vocabulário v1 é `Tipo|Cidade|Região`; tema fica no catálogo (decisão de gate).
- **Mexer na geolocalização/visitor ou persistir qualquer coisa** — a seção segue efêmera.
- **Refatorar o board para server slots / extrair componentes "por pureza"** — sem volatilidade real.
### Débitos triados (pós-simplify)
- **Fileira estática viajando no bundle client** (poderia ser slot do server) — defer; gatilho: a fileira ganhar dado dinâmico/estado, ou perf audit acusar peso do bundle da home.
- **Esqueleto `<details>/summary/menu` reimplementado** em `ContentPieceHomeFilterRow` vs `ContentPieceFilters` — defer; gatilho: 3º consumidor do esqueleto ou convergência dos contratos (links puros vs form GET).
- **`buildContentPieceCatalogHref({ cidade: slugify(...) })` em board e card** (2 call sites) — defer; gatilho: 3º call site.
- Registrar/discartar: nenhum achado caro (sem access/LGPD/schema/hot path); nenhuma Issue nova.
## Riscos e mitigação
- **SSR/hidratação da folha a partir da home:** `ContentPieceShareSheet` lê `window.location.origin` no render (`:41`), mas só monta quando `sharingItem !== null`; o estado nasce null e só um clique o seta — o HTML estático e o primeiro render do client não incluem a folha (mesmo padrão do catálogo). Não mover a leitura para o corpo do board.
- **Overflow 390 com menus absolutos:** `w-[min(220px,calc(100vw-72px))]`, 2º chip centralizado (`left-1/2 -translate-x-1/2`), último alinhado à direita; o teste de overflow da seção (hoje `:763-767`) deve rodar também com um menu aberto; 1280 sem novo overflow.
- **Alvo de toque:** chips/opções `min-h-10` (40px) no mobile; tags `min-h-10` mobile / 36px de `sm` — conforme o artefato; não encolher por densidade.
- **Alinhamento estáticas×links:** mesmas alturas/padding/box nas tags para a fileira do card não pular entre combinações (tipo-link + recente estática) — classes home-scoped garantem o mesmo contrato.
- **Faceta vazia:** chip sem opções não renderiza (mesma regra do catálogo `ContentPieceFilters.tsx:142`); com amostra pequena a fileira pode ficar com menos chips, nunca com menu vazio; a caption permanece.
- **`sourceUrl` novo no HTML estático:** URL pública de peça-link já exposta no catálogo/página; sem PII e sem haystack — o pin de campos do unit de seleção é atualizado no mesmo commit para registrar a intenção.
- **Tipo `card`:** peça editorial `card` linka `?tipo=card` (valor canônico do vocabulário), sem rota especial de modelos — não confundir com os itens sintéticos do S38.
- **Unit do board com portal:** a folha monta portal e importa `contentEvents`/`CopyLinkButton`; manter os mocks existentes (`next/link`) e mockar só se o ambiente reclamar — sem tocar a folha por testabilidade.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: `Compartilhar` no card abre a folha S27 (mensagem por tipo, C213) sem sair da home; tags com faceta + fileira `Tipo|Cidade|Região` abrem `/conteudos` via `buildContentPieceCatalogHref`; estáticas permanecem; `Ver esta peça →`/`Ver todas as peças →` intactos; fail-closed; sem overflow 390/1280.
- [ ] Invariantes AGENTS/engineering-standards: Dependency Rule (lib pura → components), `import type` client←server, copy pt-BR/identificadores em inglês, sem Consent/PII/migration, classes compartilhadas intocadas, "edit the owner, don't twin" (nenhum gêmeo de facetas/URL/share).
- [ ] Testes de domínio previstos: unit `contentPieceHomeBoard` (folha, hrefs, estáticas, fileira) + pin de `contentPieceHomeSelection` (`sourceUrl`) + unit novo da fileira; e2e da seção no `frontendConteudos`; nenhum write path/access muda (sem int).
## Self-score (decision-quality)
1. Decisões caras com rejeitadas explícitas — 5/5 (D1–D5 no formato Opções/Recomendação/Rejeitadas).
2. Abordagem cabe no appetite da intenção — 5/5 (tracer 0,4 + card 0,3 + fileira 0,3 + testes 0,3 ≈ 1,3 dia, dentro de ~1–1,5).
3. Rabbit holes nomeados — 5/5 (filtro local, share paralelo, download/analytics, tocar catálogo, engordar o lean item, manifesto e2e).
4. Depth check: reusa shells/helpers existentes — 5/5 (`buildContentPieceCatalogHref`, `contentPieceCatalogFacets`, folha S27, classes no dono, `<details>` do catálogo); nenhum pass-through novo além da fileira, que tem contrato próprio.
5. Intenção (aceite de produto) permanece satisfeita — 5/5 (nada reescreveu o outcome; a engenharia só escolheu onde computar/renderizar).
Média: 5/5 — ≥4/5.

View file

@ -27,13 +27,13 @@ export const ContentPieceDownloadLink = ({
</a>
) : null
export const ContentPieceShareButton = ({
export const ContentPieceShareButton = <Item extends { title: string }>({
item,
onShare,
className,
}: {
item: ContentPiecePublicItem
onShare: (item: ContentPiecePublicItem) => void
item: Item
onShare: (item: Item) => void
className?: string
}) => (
<button

View file

@ -1,3 +1,4 @@
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
@ -12,6 +13,11 @@ import { CONTENT_PIECE_FOCUS } from './contentPieceClasses'
* link, the focus ring and the meta contract).
*/
/** S42 — the chevron of a filtrable tag (home card and section tag). */
export const ContentPieceTagChevron = () => (
<ChevronRight aria-hidden="true" strokeWidth={2.5} className="size-3 shrink-0" />
)
type ContentPieceCardTitleItem = Pick<ContentPiecePublicItem, 'title' | 'publicPath'>
export const ContentPieceCardTitle = ({

View file

@ -8,12 +8,14 @@ import {
loadMunicipalityZoneGeometryModule,
loadTerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import { buildContentPieceCatalogHref } from '@/lib/contentPieceCatalog'
import {
resolveContentPieceHomeVisitor,
selectContentPieceHomeItems,
type ContentPieceHomeItem,
type ContentPieceHomeVisitor,
} from '@/lib/contentPieceHomeSelection'
import { slugify } from '@/lib/slug'
import { cn } from '@/lib/utils'
import {
COARSE_ACCURACY_M,
@ -22,12 +24,15 @@ import {
type GeolocationPermissionState,
} from '@/utilities/campaignGeolocation'
import { ContentPieceTagChevron } from './ContentPieceCardParts'
import { ContentPieceHomeCard } from './ContentPieceHomeCard'
import { ContentPieceHomeFilterRow, type ContentPieceHomeFacets } from './ContentPieceHomeFilterRow'
import { ContentPieceShareSheet } from './ContentPieceShareSheet'
import {
CONTENT_PIECE_FOCUS,
CONTENT_PIECE_LOCAL_TAG,
CONTENT_PIECE_HOME_TAG,
CONTENT_PIECE_LOCAL_TAG_LINK,
CONTENT_PIECE_PRIMARY_BUTTON,
CONTENT_PIECE_TAG,
} from './contentPieceClasses'
/**
@ -40,6 +45,10 @@ import {
*
* No auto-prompt (unlike the staff B14 card): a surprise dialog on the home
* burns trust, and the recent selection is already useful without it.
*
* S42 — the board also owns the one share sheet of the section (same S27
* mechanism as the catalogue) and the explore row that opens `/conteudos`
* already filtered; the row never filters the sample.
*/
const COPY = {
@ -62,12 +71,19 @@ const LOCATE_BUTTON = cn(
CONTENT_PIECE_FOCUS,
)
export const ContentPieceHomeBoard = ({ items }: { items: readonly ContentPieceHomeItem[] }) => {
export const ContentPieceHomeBoard = ({
items,
facets,
}: {
items: readonly ContentPieceHomeItem[]
facets: ContentPieceHomeFacets
}) => {
const [permission, setPermission] = useState<GeolocationPermissionState | null>(null)
const [visitor, setVisitor] = useState<ContentPieceHomeVisitor | null>(null)
const [locating, setLocating] = useState(false)
const [attempted, setAttempted] = useState(false)
const [playingId, setPlayingId] = useState<number | null>(null)
const [sharingItem, setSharingItem] = useState<ContentPieceHomeItem | null>(null)
const autoStartedRef = useRef(false)
const locate = useCallback(async () => {
@ -139,6 +155,9 @@ export const ContentPieceHomeBoard = ({ items }: { items: readonly ContentPieceH
// tap does not drop the keyboard focus; after the attempt it is gone.
(!attempted || locating)
const copy = isSingle ? COPY.single : hasMunicipalityMatch ? COPY.local : COPY.recent
const municipalityHref = visitor?.municipalityName
? buildContentPieceCatalogHref({ cidade: slugify(visitor.municipalityName) })
: null
return (
<>
@ -180,24 +199,35 @@ export const ContentPieceHomeBoard = ({ items }: { items: readonly ContentPieceH
isSingle ? 'justify-center lg:justify-start' : 'justify-center',
)}
>
<span
data-sample-tag={hasMunicipalityMatch ? 'municipality' : 'recent'}
className={hasMunicipalityMatch ? CONTENT_PIECE_LOCAL_TAG : CONTENT_PIECE_TAG}
>
{hasMunicipalityMatch ? (
<>
<span aria-hidden="true">⌖</span> Para seu município
</>
) : (
'Seleção recente'
)}
</span>
{hasMunicipalityMatch && municipalityHref ? (
// S42 — the sampled territory tag is itself a shortcut to the
// catalogue filtered by the visitor's município (artefato cena 01).
<Link
href={municipalityHref}
data-sample-tag="municipality"
className={CONTENT_PIECE_LOCAL_TAG_LINK}
>
<span aria-hidden="true">⌖</span> Para seu município
<ContentPieceTagChevron />
</Link>
) : (
<span data-sample-tag="recent" className={CONTENT_PIECE_HOME_TAG}>
Seleção recente
</span>
)}
{hasMunicipalityMatch ? (
<span className="hidden text-[11px] text-(--campaign-muted) sm:inline">
Localização usada só nesta visita
</span>
) : null}
</div>
<ContentPieceHomeFilterRow
facets={facets}
className={cn(
'mt-5 w-full',
isSingle ? 'mx-auto max-w-md lg:mx-0' : 'mx-auto max-w-xl sm:mt-6',
)}
/>
{isSingle ? (
<Link
href="/conteudos"
@ -222,6 +252,7 @@ export const ContentPieceHomeBoard = ({ items }: { items: readonly ContentPieceH
playing={playingId === item.id}
onToggle={() => setPlayingId((current) => (current === item.id ? null : item.id))}
onEnded={() => setPlayingId((current) => (current === item.id ? null : current))}
onShare={setSharingItem}
/>
))}
</div>
@ -274,6 +305,10 @@ export const ContentPieceHomeBoard = ({ items }: { items: readonly ContentPieceH
)}
</div>
)}
{sharingItem ? (
<ContentPieceShareSheet item={sharingItem} onClose={() => setSharingItem(null)} />
) : null}
</>
)
}

View file

@ -1,15 +1,24 @@
import Link from 'next/link'
import { buildContentPieceCatalogHref } from '@/lib/contentPieceCatalog'
import type { ContentPieceHomeItem, ContentPieceHomeMatch } from '@/lib/contentPieceHomeSelection'
import { slugify } from '@/lib/slug'
import { cn } from '@/lib/utils'
import { ContentPieceCardMeta, ContentPieceCardTitle } from './ContentPieceCardParts'
import { ContentPieceShareButton } from './ContentPieceActions'
import {
ContentPieceCardMeta,
ContentPieceCardTitle,
ContentPieceTagChevron,
} from './ContentPieceCardParts'
import { ContentPieceMedia } from './ContentPieceMedia'
import {
CONTENT_PIECE_CARD,
CONTENT_PIECE_FOCUS,
CONTENT_PIECE_LOCAL_TAG,
CONTENT_PIECE_TAG,
CONTENT_PIECE_HOME_LOCAL_TAG,
CONTENT_PIECE_HOME_TAG,
CONTENT_PIECE_LOCAL_TAG_LINK,
CONTENT_PIECE_PIECE_LINK,
CONTENT_PIECE_TAG_LINK,
} from './contentPieceClasses'
const MATCH_LABELS: Record<ContentPieceHomeMatch, string> = {
@ -19,11 +28,27 @@ const MATCH_LABELS: Record<ContentPieceHomeMatch, string> = {
}
/**
* S39 — the home sample card (artefato: cenas 01/02/05): a lighter sibling of
* the catalogue card — no download/share actions, the piece page is the
* handoff. Horizontal on the phone (`[124px | 1fr]`, cena 02) and vertical from
* `sm` on; the asset is the shared `ContentPieceMedia`, so video/audio only
* mount the media after the tap.
* S42 — the canonical facet of a tag, or null when the tag has no honest one
* (origin and "Mais recente" stay static). The match only exists with its
* label, so the null guard is a contract, never a rendered case.
*/
const matchHref = (item: ContentPieceHomeItem, match: ContentPieceHomeMatch): string | null => {
if (match === 'municipality' && item.cityLabel) {
return buildContentPieceCatalogHref({ cidade: slugify(item.cityLabel) })
}
if (match === 'region' && item.regionLabel) {
return buildContentPieceCatalogHref({ regiao: slugify(item.regionLabel) })
}
return null
}
/**
* S39/S42 — the home sample card (artefato: cenas 01/02/05): a lighter sibling
* of the catalogue card. Horizontal on the phone (`[124px | 1fr]`, cena 02) and
* vertical from `sm` on; the asset is the shared `ContentPieceMedia`, so
* video/audio only mount the media after the tap. S42 gives it the direct
* shortcut: `Compartilhar` opens the section's own share sheet (the S27 one)
* and the honest tags open `/conteudos` already filtered.
*/
export const ContentPieceHomeCard = ({
item,
@ -31,47 +56,67 @@ export const ContentPieceHomeCard = ({
playing,
onToggle,
onEnded,
onShare,
}: {
item: ContentPieceHomeItem
match: ContentPieceHomeMatch
playing: boolean
onToggle: () => void
onEnded: () => void
}) => (
<article
data-content-piece={item.slug}
className={cn(CONTENT_PIECE_CARD, 'grid grid-cols-[124px_1fr] sm:block')}
>
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
// C226 — below `sm` this card's media slot is the 124px thumb of the
// design's cena 05 (the compact play/duration, no marker); from `sm` up
// the card becomes a full block and the media slot goes back to the
// catalogue treatment.
variant="home-thumb"
className="aspect-video self-start"
/>
<div className="p-4">
<div className="flex flex-wrap gap-1">
<span className={CONTENT_PIECE_TAG}>{item.isLink ? item.originLabel : item.typeLabel}</span>
<span className={match === 'recent' ? CONTENT_PIECE_TAG : CONTENT_PIECE_LOCAL_TAG}>
{MATCH_LABELS[match]}
</span>
onShare: (item: ContentPieceHomeItem) => void
}) => {
const typeHref = item.isLink ? null : buildContentPieceCatalogHref({ tipo: item.type })
const matchFacetHref = matchHref(item, match)
return (
<article
data-content-piece={item.slug}
className={cn(CONTENT_PIECE_CARD, 'grid grid-cols-[124px_1fr] sm:block')}
>
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
// C226 — below `sm` this card's media slot is the 124px thumb of the
// design's cena 05 (the compact play/duration, no marker); from `sm` up
// the card becomes a full block and the media slot goes back to the
// catalogue treatment.
variant="home-thumb"
className="aspect-video self-start"
/>
<div className="p-4">
<div className="flex flex-wrap gap-1.5">
{typeHref ? (
<Link href={typeHref} className={CONTENT_PIECE_TAG_LINK}>
{item.typeLabel}
<ContentPieceTagChevron />
</Link>
) : (
<span className={CONTENT_PIECE_HOME_TAG}>
{item.isLink ? item.originLabel : item.typeLabel}
</span>
)}
{matchFacetHref ? (
<Link href={matchFacetHref} className={CONTENT_PIECE_LOCAL_TAG_LINK}>
<span className="truncate">{MATCH_LABELS[match]}</span>
<ContentPieceTagChevron />
</Link>
) : (
<span
className={match === 'recent' ? CONTENT_PIECE_HOME_TAG : CONTENT_PIECE_HOME_LOCAL_TAG}
>
{MATCH_LABELS[match]}
</span>
)}
</div>
<ContentPieceCardTitle item={item} className="text-sm sm:text-base" />
<ContentPieceCardMeta item={item} className="text-[11px] sm:text-xs" />
<ContentPieceShareButton item={item} onShare={onShare} className="mt-3 w-full sm:mt-4" />
<Link href={item.publicPath} className={CONTENT_PIECE_PIECE_LINK}>
Ver esta peça →
</Link>
</div>
<ContentPieceCardTitle item={item} className="text-sm sm:text-base" />
<ContentPieceCardMeta item={item} className="text-[11px] sm:text-xs" />
<Link
href={item.publicPath}
className={cn(
'mt-3 inline-block text-xs font-bold text-(--pt-red) underline-offset-4 hover:underline',
CONTENT_PIECE_FOCUS,
)}
>
Ver esta peça →
</Link>
</div>
</article>
)
</article>
)
}

View file

@ -0,0 +1,100 @@
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { isContentPieceType } from '@/lib/contentPiece'
import {
buildContentPieceCatalogHref,
contentPieceCatalogFacetLabels,
type ContentPieceCatalogFacets,
} from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { CONTENT_PIECE_FOCUS } from './contentPieceClasses'
/**
* S42 — the facets of the home section's explore row (artefato: cenas 01/04/06).
* The server derives them from the published pieces with the catalogue owner
* (`contentPieceCatalogFacets`), so the vocabulary is the same and a facet with
* no option never renders. The row is pure navigation: no local state, nothing
* filters the sample.
*/
const HOME_FACETS = ['tipo', 'cidade', 'regiao'] as const
type ContentPieceHomeFacet = (typeof HOME_FACETS)[number]
export type ContentPieceHomeFacets = Pick<ContentPieceCatalogFacets, ContentPieceHomeFacet>
const CHIP = cn(
'flex min-h-10 w-full cursor-pointer list-none items-center justify-between gap-1.5 rounded-full border border-(--campaign-line) bg-white px-3 py-[7px] text-xs font-extrabold text-[#171000]',
'transition-[border-color,background-color,color] duration-150 ease-out hover:border-[rgb(24_78_146/35%)] hover:bg-[#eef4fb] hover:text-[#184e92] group-open:border-[rgb(24_78_146/35%)] group-open:bg-[#eef4fb] group-open:text-[#184e92] motion-reduce:transition-none [&::-webkit-details-marker]:hidden',
CONTENT_PIECE_FOCUS,
)
const MENU =
'absolute top-[calc(100%+8px)] left-0 z-20 max-h-72 w-[min(220px,calc(100vw-72px))] list-none overflow-y-auto rounded-xl border border-(--campaign-line) bg-white p-1.5 shadow-[0_16px_40px_rgb(39_25_22/16%)]'
const OPTION = cn(
'flex min-h-10 items-center justify-between gap-2 rounded-lg px-2.5 py-2 text-xs font-bold text-[#171000] no-underline hover:bg-[#eef4fb] hover:text-[#184e92]',
CONTENT_PIECE_FOCUS,
)
/** The canonical href of one option — each facet maps to its own URL parameter. */
const optionHref = (facet: ContentPieceHomeFacet, value: string): string => {
if (facet === 'tipo') {
return buildContentPieceCatalogHref({ tipo: isContentPieceType(value) ? value : null })
}
if (facet === 'cidade') return buildContentPieceCatalogHref({ cidade: value })
return buildContentPieceCatalogHref({ regiao: value })
}
export const ContentPieceHomeFilterRow = ({
facets,
className,
}: {
facets: ContentPieceHomeFacets
className?: string
}) => {
const available = HOME_FACETS.filter((facet) => facets[facet].length > 0)
if (available.length === 0) return null
return (
<div className={className}>
<p className="mb-2 text-[11px] font-bold text-(--campaign-muted)">
<span className="sm:hidden">Explore na Central · abre já filtrada</span>
<span className="hidden sm:inline">Explore na Central · abre o catálogo já filtrado</span>
</p>
<div className="grid grid-cols-3 gap-2">
{available.map((facet, index) => (
<details key={facet} name="content-piece-home-facet" className="group relative min-w-0">
<summary className={CHIP}>
<span className="truncate">{contentPieceCatalogFacetLabels[facet]}</span>
<ChevronDown
aria-hidden="true"
strokeWidth={2.5}
className="size-3.5 shrink-0 transition-transform group-open:rotate-180 motion-reduce:transition-none"
/>
</summary>
<ul
className={cn(
MENU,
// Artefato: the middle chip centers its menu, the last one
// anchors right so no menu bleeds past the section.
index === 1 && available.length > 2 && 'left-1/2 -translate-x-1/2',
index > 0 && index === available.length - 1 && 'right-0 left-auto',
)}
>
{facets[facet].map((option) => (
<li key={option.value}>
<Link href={optionHref(facet, option.value)} className={OPTION}>
<span className="truncate">{option.label}</span>
<span aria-hidden="true">→</span>
</Link>
</li>
))}
</ul>
</details>
))}
</div>
</div>
)
}

View file

@ -1,4 +1,4 @@
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { contentPieceCatalogFacets, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { toContentPieceHomeItem } from '@/lib/contentPieceHomeSelection'
import { ContentPieceHomeBoard } from './ContentPieceHomeBoard'
@ -8,27 +8,40 @@ import { ContentPieceHomeBoard } from './ContentPieceHomeBoard'
* 01–06). The page resolves the cached listing once and only renders this when
* something is published (fail-closed, same kill switch as the footer link);
* the lean projection keeps the search haystack out of the static HTML.
*
* S42 — the explore row's facets are derived here, before the projection, from
* the same catalogue owner the catalogue page uses (the lean item keeps only
* what the card renders).
*/
export const ContentPieceHomeSection = ({
items,
}: {
items: readonly ContentPiecePublicItem[]
}) => (
<section
aria-labelledby="content-pieces-title"
data-home-section="content-pieces"
className="relative overflow-hidden border-b border-(--campaign-line) bg-white"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 -right-20 size-64 rounded-full bg-(--pt-yellow)/35"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-28 -left-24 size-64 rounded-full bg-[#e4102f]/8"
/>
<div className="relative mx-auto w-full max-w-[1160px] px-5 py-10 sm:px-8 lg:px-10 lg:py-16">
<ContentPieceHomeBoard items={items.map(toContentPieceHomeItem)} />
</div>
</section>
)
}) => {
const facets = contentPieceCatalogFacets(items)
return (
<section
aria-labelledby="content-pieces-title"
data-home-section="content-pieces"
className="relative overflow-hidden border-b border-(--campaign-line) bg-white"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 -right-20 size-64 rounded-full bg-(--pt-yellow)/35"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-28 -left-24 size-64 rounded-full bg-[#e4102f]/8"
/>
<div className="relative mx-auto w-full max-w-[1160px] px-5 py-10 sm:px-8 lg:px-10 lg:py-16">
<ContentPieceHomeBoard
items={items.map(toContentPieceHomeItem)}
// Only the row's three facets cross to the client island; the other
// option lists (tema/instituição/liderança) stay on the server.
facets={{ tipo: facets.tipo, cidade: facets.cidade, regiao: facets.regiao }}
/>
</div>
</section>
)
}

View file

@ -20,6 +20,17 @@ import {
CONTENT_PIECE_PRIMARY_BUTTON,
} from './contentPieceClasses'
/**
* S42 — what the sheet actually reads: the catalogue passes the full public
* item and the home section passes its lean projection (the same structural
* precedent as `ContentPieceMedia`), so the one share mechanism serves both
* surfaces without the home carrying the search haystack.
*/
type ContentPieceShareSheetItem = Pick<
ContentPiecePublicItem,
'slug' | 'title' | 'type' | 'file' | 'isLink' | 'sourceUrl'
>
/**
* 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
@ -32,7 +43,7 @@ export const ContentPieceShareSheet = ({
item,
onClose,
}: {
item: ContentPiecePublicItem
item: ContentPieceShareSheetItem
onClose: () => void
}) => {
const [mounted, setMounted] = useState(false)

View file

@ -19,7 +19,7 @@ export const CONTENT_PIECE_TAG =
'inline-flex rounded-full bg-[#eef4fb] px-2 py-1 text-[11px] font-extrabold text-[#184e92]'
/** S39 — the provenance tag of a piece sampled from the visitor's territory. */
export const CONTENT_PIECE_LOCAL_TAG = cn(CONTENT_PIECE_TAG, 'bg-[#fff3c4] text-[#6b5100]')
const CONTENT_PIECE_LOCAL_TAG = cn(CONTENT_PIECE_TAG, 'bg-[#fff3c4] text-[#6b5100]')
/** S28 — the neutral badge of a theme-mode result that only matched the query. */
export const CONTENT_PIECE_EXACT_TAG =
@ -40,3 +40,38 @@ export const CONTENT_PIECE_OUTLINE_BUTTON = cn(
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%)]',
)
/**
* S42 — the home section's tags (artefato: cenas 01–06): 40px targets on
* touch and 36px from `sm` up, in the static (filled) and link (outline +
* chevron) variants. The catalogue and the piece page keep the S27 tags
* (`CONTENT_PIECE_TAG`/`CONTENT_PIECE_LOCAL_TAG`) untouched.
*/
export const CONTENT_PIECE_HOME_TAG = cn(
CONTENT_PIECE_TAG,
'min-h-10 items-center px-2.5 py-1.5 sm:min-h-9',
)
export const CONTENT_PIECE_HOME_LOCAL_TAG = cn(
CONTENT_PIECE_LOCAL_TAG,
'min-h-10 items-center px-2.5 py-1.5 sm:min-h-9',
)
export const CONTENT_PIECE_TAG_LINK = cn(
CONTENT_PIECE_TAG,
'min-h-10 max-w-full items-center gap-1 border border-[rgb(24_78_146/30%)] bg-[#f8fbff] px-2.5 py-1.5 no-underline sm:min-h-9',
'transition-[border-color,background-color,transform] duration-150 ease-out hover:-translate-y-px hover:border-current hover:bg-[#eef4fb] hover:underline hover:underline-offset-[3px] motion-reduce:transition-none',
CONTENT_PIECE_FOCUS,
)
/** S42 — the local (territory) variant of the filtrable tag. */
export const CONTENT_PIECE_LOCAL_TAG_LINK = cn(
CONTENT_PIECE_TAG_LINK,
'border-[rgb(107_81_0/28%)] bg-[#fff8dc] text-[#6b5100] hover:bg-[#fff3c4]',
)
/** S42 — the card's `Ver esta peça →` under the share button (artefato `.piece-link`). */
export const CONTENT_PIECE_PIECE_LINK = cn(
'mt-2 flex min-h-9 w-full items-center justify-center rounded-sm text-xs font-extrabold text-(--pt-red) underline-offset-4 hover:underline',
CONTENT_PIECE_FOCUS,
)

View file

@ -32,6 +32,7 @@ export type ContentPieceHomeItem = Pick<
| 'origin'
| 'originLabel'
| 'isLink'
| 'sourceUrl'
| 'cityLabel'
| 'regionLabel'
| 'excerpt'
@ -52,6 +53,7 @@ export const toContentPieceHomeItem = (item: ContentPiecePublicItem): ContentPie
origin: item.origin,
originLabel: item.originLabel,
isLink: item.isLink,
sourceUrl: item.sourceUrl,
cityLabel: item.cityLabel,
regionLabel: item.regionLabel,
excerpt: item.excerpt,

View file

@ -735,8 +735,10 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
}) => {
const headers = await adminHeaders(request, BASE_URL)
await unpublishEveryPiece(request, headers)
await createPiece(request, headers, { title: `Peça da home ${uniqueMarker()}`, type: 'foto' })
await createPiece(request, headers, { title: `Vídeo da home ${uniqueMarker()}`, type: 'video' })
const photoTitle = `Peça da home ${uniqueMarker()}`
const videoTitle = `Vídeo da home ${uniqueMarker()}`
await createPiece(request, headers, { title: photoTitle, type: 'foto' })
await createPiece(request, headers, { title: videoTitle, type: 'video' })
await waitForHomeSection(request, 'present')
@ -759,7 +761,43 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await expect(section.locator('article[data-content-piece]')).toHaveCount(2)
// Nothing plays on its own: the video mounts no media element before the tap.
await expect(section.locator('video, audio')).toHaveCount(0)
// Cena 02 — the sample never overflows the phone.
// S42 — the honest tags open the catalogue already filtered and the others
// stay static; nothing filters the sample locally.
const photoCard = section.locator(`article[data-content-piece]`, { hasText: photoTitle })
const videoCard = section.locator(`article[data-content-piece]`, { hasText: videoTitle })
await expect(photoCard.getByRole('link', { name: 'Foto', exact: true })).toHaveAttribute(
'href',
'/conteudos?tipo=foto',
)
await expect(videoCard.getByRole('link', { name: 'Vídeo', exact: true })).toHaveAttribute(
'href',
'/conteudos?tipo=video',
)
await expect(section.getByText('Mais recente', { exact: true })).toHaveCount(2)
await expect(section.getByRole('link', { name: 'Mais recente' })).toHaveCount(0)
// S42 — the card shares through the same S27 sheet, without leaving the home.
await section.getByRole('button', { name: `Compartilhar ${photoTitle}` }).click()
const homeSheet = page.getByRole('dialog', { name: 'Compartilhar peça' })
await expect(homeSheet).toBeVisible()
await expect(homeSheet.getByLabel('Mensagem para compartilhar')).toHaveValue(
new RegExp(photoTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
)
await page.keyboard.press('Escape')
await expect(homeSheet).toHaveCount(0)
await expect(section.getByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeVisible()
// S42 — the explore row only carries the facets the sample has (no
// município in these pieces) and its options point at the filtered URL.
const tipoSummary = section.locator('summary').filter({ hasText: 'Tipo' })
await expect(section.locator('summary')).toHaveCount(1)
await tipoSummary.click()
await expect(
section.locator('details[open]').getByRole('link', { name: 'Vídeo' }),
).toHaveAttribute('href', '/conteudos?tipo=video')
// Cena 02 — the sample (with the open menu) never overflows the phone.
const overflow = await page.evaluate(() => {
const container = document.querySelector<HTMLElement>('[data-theme="campaign-site"]')
return container ? container.scrollWidth - container.clientWidth : 0
@ -811,6 +849,24 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
// Exact: the sr-only live region announces "Peças do seu município".
await expect(section.getByText('Do seu município', { exact: true })).toBeVisible()
await expect(section.getByRole('link', { name: title })).toBeVisible()
// S42 — the territory tags are shortcuts to the catalogue on the same facet.
const card = section.locator('article[data-content-piece]', { hasText: title })
await expect(card.getByRole('link', { name: 'Do seu município', exact: true })).toHaveAttribute(
'href',
'/conteudos?cidade=feira-de-santana',
)
await expect(card.getByRole('link', { name: 'Foto', exact: true })).toHaveAttribute(
'href',
'/conteudos?tipo=foto',
)
await section.getByRole('link', { name: /Para seu município/ }).click()
await page.waitForURL(/\/conteudos\?cidade=feira-de-santana$/)
await waitForSettledPage(page)
// The catalogue opens already filtered by the same canonical URL.
await expect(page.getByRole('link', { name: /Remover filtro Cidade/ })).toBeVisible()
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
await expect(page.getByRole('link', { name: title, exact: true })).toBeVisible()
})
test('filters by who appears in the piece and removes a name when unpublished (S37)', async ({
page,

View file

@ -1,7 +1,8 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ContentPieceHomeBoard } from '@/components/conteudos/ContentPieceHomeBoard'
import type { ContentPieceHomeFacets } from '@/components/conteudos/ContentPieceHomeFilterRow'
import {
loadMunicipalityGeometryModule,
loadMunicipalityZoneGeometryModule,
@ -56,6 +57,7 @@ const item = (id: number, patch: Partial<ContentPieceHomeItem> = {}): ContentPie
origin: 'arquivo',
originLabel: 'Arquivo',
isLink: false,
sourceUrl: null,
cityLabel: null,
regionLabel: null,
excerpt: null,
@ -138,6 +140,19 @@ const localPiece = item(1, { cityLabel: 'Feira de Santana', regionLabel: 'Portal
const otherPiece = item(2, { cityLabel: 'Ilhéus', regionLabel: 'Litoral Sul' })
const items = [localPiece, otherPiece]
/** S42 — the server hands the explore row the catalogue vocabulary of the sample. */
const facets: ContentPieceHomeFacets = {
tipo: [
{ value: 'video', label: 'Vídeo' },
{ value: 'foto', label: 'Foto' },
],
cidade: [{ value: 'feira-de-santana', label: 'Feira de Santana' }],
regiao: [{ value: 'portal-do-sertao', label: 'Portal do Sertão' }],
}
const renderBoard = (boardItems: ContentPieceHomeItem[] = items, boardFacets = facets) =>
render(<ContentPieceHomeBoard items={boardItems} facets={boardFacets} />)
const locateButton = () => screen.queryByRole('button', { name: /Usar minha localização/ })
/** The section tag is split by the decorative glyph, so the hook is the attribute. */
@ -158,7 +173,7 @@ describe('ContentPieceHomeBoard', () => {
it('renders the recent selection and the affordance while permission is undecided', async () => {
readPermission.mockResolvedValue('prompt')
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(locateButton()).not.toBeNull())
expect(screen.getByText('Seleção recente')).toBeDefined()
@ -175,7 +190,7 @@ describe('ContentPieceHomeBoard', () => {
it('hides the affordance when the browser already refused the permission', async () => {
readPermission.mockResolvedValue('denied')
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(readPermission).toHaveBeenCalled())
expect(locateButton()).toBeNull()
@ -185,7 +200,7 @@ describe('ContentPieceHomeBoard', () => {
it('upgrades to the visitor territory without a dialog when permission is granted', async () => {
readPermission.mockResolvedValue('granted')
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(sampleTag()).toBe('municipality'))
expect(screen.getByText('Do seu município')).toBeDefined()
@ -196,7 +211,7 @@ describe('ContentPieceHomeBoard', () => {
it('upgrades to the visitor territory when the affordance is tapped', async () => {
readPermission.mockResolvedValue('prompt')
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(locateButton()).not.toBeNull())
fireEvent.click(locateButton()!)
@ -208,7 +223,7 @@ describe('ContentPieceHomeBoard', () => {
it('offers the affordance when the browser cannot tell the permission (Safari)', async () => {
readPermission.mockResolvedValue('unknown')
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(locateButton()).not.toBeNull())
expect(readPosition).not.toHaveBeenCalled()
@ -228,7 +243,7 @@ describe('ContentPieceHomeBoard', () => {
},
})
render(<ContentPieceHomeBoard items={[video]} />)
renderBoard([video])
await waitFor(() => expect(readPermission).toHaveBeenCalled())
expect(document.querySelector('video')).toBeNull()
@ -245,7 +260,7 @@ describe('ContentPieceHomeBoard', () => {
readPermission.mockResolvedValue('granted')
readPosition.mockResolvedValue({ ok: false, reason: 'timeout' })
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(readPosition).toHaveBeenCalledTimes(1))
expect(screen.getByText('Seleção recente')).toBeDefined()
@ -257,7 +272,7 @@ describe('ContentPieceHomeBoard', () => {
readPermission.mockResolvedValue('granted')
readPosition.mockResolvedValue({ ok: true, fix: { ...feiraPoint, accuracyM: 50_000 } })
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
// The coarse fix keeps the region sample, never the município claim.
await waitFor(() => expect(screen.getByText('Da sua região')).toBeDefined())
@ -269,7 +284,7 @@ describe('ContentPieceHomeBoard', () => {
it('does not offer the affordance for a single published piece', async () => {
readPermission.mockResolvedValue('prompt')
render(<ContentPieceHomeBoard items={[localPiece]} />)
renderBoard([localPiece])
await waitFor(() => expect(readPermission).toHaveBeenCalled())
expect(locateButton()).toBeNull()
@ -280,4 +295,66 @@ describe('ContentPieceHomeBoard', () => {
expect(screen.queryByText(/Nenhum card personalizável/)).toBeNull()
expect(screen.queryByText(/A localização não é guardada/)).toBeNull()
})
it('opens the S27 share sheet from the card without leaving the home (S42)', async () => {
readPermission.mockResolvedValue('denied')
renderBoard()
await waitFor(() => expect(readPermission).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Compartilhar Peça 1' }))
const sheet = screen.getByRole('dialog', { name: 'Compartilhar peça' })
expect(sheet).toBeDefined()
const message = within(sheet).getByLabelText(
'Mensagem para compartilhar',
) as HTMLTextAreaElement
expect(message.value).toContain('Peça 1')
// The section stays mounted behind the sheet — the visitor never leaves the home.
expect(screen.getByText('Seleção recente')).toBeDefined()
})
it('links the honest tags to the catalogue and keeps "Mais recente" static (S42)', async () => {
readPermission.mockResolvedValue('granted')
renderBoard()
await waitFor(() => expect(sampleTag()).toBe('municipality'))
const localCard = document.querySelector('article[data-content-piece="peca-1"]') as HTMLElement
const otherCard = document.querySelector('article[data-content-piece="peca-2"]') as HTMLElement
expect(within(localCard).getByRole('link', { name: 'Foto' }).getAttribute('href')).toBe(
'/conteudos?tipo=foto',
)
expect(
within(localCard).getByRole('link', { name: 'Do seu município' }).getAttribute('href'),
).toBe('/conteudos?cidade=feira-de-santana')
expect(within(otherCard).queryByRole('link', { name: 'Mais recente' })).toBeNull()
// The section tag is the shortcut of the visitor's município.
expect(screen.getByRole('link', { name: /Para seu município/ }).getAttribute('href')).toBe(
'/conteudos?cidade=feira-de-santana',
)
})
it('renders the explore row with canonical facet links and never filters the sample (S42)', async () => {
readPermission.mockResolvedValue('denied')
renderBoard()
await waitFor(() => expect(readPermission).toHaveBeenCalled())
// Both breakpoint captions render (one hidden by CSS); the row itself has
// one chip per non-empty facet.
expect(screen.getAllByText(/Explore na Central/)).toHaveLength(2)
// The board hands the server facets to the row; every option href is the
// row's own contract (pinned in `contentPieceHomeFilterRow.unit.spec.tsx`).
const tipoDetails = screen.getByText('Tipo').closest('details') as HTMLElement
fireEvent.click(within(tipoDetails).getByText('Tipo'))
expect(within(tipoDetails).getByRole('link', { name: 'Vídeo' }).getAttribute('href')).toBe(
'/conteudos?tipo=video',
)
// The row is pure navigation: the sample stays intact.
expect(document.querySelectorAll('article[data-content-piece]')).toHaveLength(2)
})
})

View file

@ -0,0 +1,54 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ContentPieceHomeFilterRow } from '@/components/conteudos/ContentPieceHomeFilterRow'
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
}))
const facets = {
tipo: [
{ value: 'video', label: 'Vídeo' },
{ value: 'foto', label: 'Foto' },
],
cidade: [{ value: 'salvador', label: 'Salvador' }],
regiao: [],
}
describe('ContentPieceHomeFilterRow', () => {
it('renders only the facets with options and links each option to the canonical filtered URL', () => {
render(<ContentPieceHomeFilterRow facets={facets} />)
expect(screen.getByText('Tipo')).toBeDefined()
expect(screen.getByText('Cidade')).toBeDefined()
// A facet no published piece carries never renders — no empty menu.
expect(screen.queryByText('Região')).toBeNull()
const tipo = screen.getByText('Tipo').closest('details') as HTMLElement
fireEvent.click(within(tipo).getByText('Tipo'))
expect(within(tipo).getByRole('link', { name: 'Vídeo' }).getAttribute('href')).toBe(
'/conteudos?tipo=video',
)
const cidade = screen.getByText('Cidade').closest('details') as HTMLElement
fireEvent.click(within(cidade).getByText('Cidade'))
expect(
within(cidade)
.getByRole('link', { name: /Salvador/ })
.getAttribute('href'),
).toBe('/conteudos?cidade=salvador')
})
it('renders nothing without any option', () => {
const { container } = render(
<ContentPieceHomeFilterRow facets={{ tipo: [], cidade: [], regiao: [] }} />,
)
expect(container.firstChild).toBeNull()
})
})

View file

@ -98,6 +98,7 @@ describe('content piece home selection', () => {
'publicPath',
'regionLabel',
'slug',
'sourceUrl',
'title',
'type',
'typeLabel',
@ -139,6 +140,9 @@ describe('content piece home selection', () => {
expect(item.isLink).toBe(true)
expect(item.file).toBeNull()
// S42 — the share sheet reads it from the lean item, so the projection
// must carry the platform URL of a link piece.
expect(item.sourceUrl).toBe('https://www.instagram.com/p/abc/')
})
})