mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
3201b7a93f
commit
862da74fbe
15 changed files with 672 additions and 99 deletions
1
docs/changelog/2026-09-30-s42.md
Normal file
1
docs/changelog/2026-09-30-s42.md
Normal 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).
|
||||
134
docs/plans/central-conteudos-secao-home-share-filtro-impl.md
Normal file
134
docs/plans/central-conteudos-secao-home-share-filtro-impl.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
100
src/components/conteudos/ContentPieceHomeFilterRow.tsx
Normal file
100
src/components/conteudos/ContentPieceHomeFilterRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
54
tests/unit/contentPieceHomeFilterRow.unit.spec.tsx
Normal file
54
tests/unit/contentPieceHomeFilterRow.unit.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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/')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue