feat(S38): cards personalizáveis como itens do catálogo (Closes #1304)

This commit is contained in:
Francisco Solla 2026-09-24 02:28:45 -03:00
parent 02ca466469
commit da3d9bf338
19 changed files with 851 additions and 98 deletions

View file

@ -0,0 +1 @@
**S38 — Cards personalizáveis como itens do catálogo (2026-09-24):** os seis modelos de card (`CARD_MODELS`) deixam de aparecer como um convite único e viram **itens sintéticos** do board de `/conteudos`, misturados às peças, sem seção/cabeçalho próprio: `CardCatalogItem` (união discriminada `ContentCatalogItem = ContentPiecePublicItem | CardCatalogItem`, guard `isCardCatalogItem`) com `art` (`previewSrc ?? assetSrc`; placeholder neutro tracejado nos modelos de foto, cuja master é só a moldura com janela transparente), `href` para o estúdio com o modelo escolhido (`/cards?model=<id>`, deep link já validado em `/cards`) e `searchText` = nome + apelidos + mini-descrição. A copy/apelidos nascem no dono (`CardModel.description`/`aliases` obrigatórios: "santinho" → time-de-voce + minha-colinha; "foto de perfil" → perfil-quadrado + perfil-retangular; "colinha" → minha-colinha; "estadual" → time-do-estadual) e o `publishedCount` continua contando **peças**: `contentCatalogItems()` só anexa os cards quando a Central tem peça publicada (estado vazio honesto intacto, sem board de cards). O pipeline único do S28 (facetas/filtro/busca) passou a ver a união: `?tipo=card` rende o chip "Tipo · Card" e traz os seis (peça publicada de tipo card segue peça comum); Cidade/Região/Tema/Instituição excluem os cards (não são territoriais) e a busca por apelido funciona sem JS, por querystring. O board ganhou `ContentCatalogCardItem` (link único — alvo grande no celular —, arte `object-cover object-top`, tag "Card", chips de apelido, CTA "Escolher este modelo →" no desktop / "Escolher →" no mobile, selo NOVO no modelo mais recente) no bucket large e o tile `ContentPieceCardInvite`/`showCardInvite` foi **deletado**. O clique alimenta o mecanismo anônimo do C213/S32 como **abertura de card** (`{ type:'abertura', subjectType:'card', cardModelId }`, 3ª variante estrita da rota, sem picker; `sendCardOpeningEvent` fail-soft no mesmo transporte `sendBeacon` → `fetch keepalive`), nunca como peça, e os contadores de download não são inflados; sem migration (o select de `contentEvent` já aceitava `abertura`/`card`) e o label admin "Abertura da peça" virou "Abertura" (o `subjectType` desambigua). Sem collection, migration, PII, `Consent` novo, rota nova ou mudança no estúdio (foto 100% no aparelho); o manifest de e2e do catálogo passou a acordar com `cardModels`/`schemas/contentEvent`. Verificação: unit (`contentPieceCatalog` com builder/busca por apelido/faceta/geo/arte/guarda de Central vazia, `cardModels` com os literais de copy, `contentEvents` com o beacon de abertura), int (`contentPiece`: cards anexados com `publishedCount` de peças, busca "santinho", geo exclui, `tipo=card`; `contentEvents`: abertura grava sem `variant`, modelo inventado e `stateDeputySlug` na abertura recusam sem gravar), e2e (`frontendConteudos`: board misto sem tile, buscas por apelido, `tipo=card`, geo, clique → `/cards?model=minha-colinha` com o beacon de abertura e o diálogo do estúdio, kill switch sem cards), `pnpm gate:fast`, `pnpm test:int`, knip/cycles e os e2e afetados locais. Crítica final do `designer` (trigger c): pendente.

View file

@ -0,0 +1,160 @@
# Impl: S38 — Central de Conteúdos — cards personalizáveis como itens do catálogo
Status: aprovado
Atualizado em: 2026-09-24
Issue: #1304
Intenção: docs/plans/central-conteudos-cards-no-catalogo.md
Appetite restante: herdado (~1–1,5 dia eng). Distribuição: Fase 1 ~0,4 · Fase 2 ~0,45 · Fase 3 ~0,15 · Fase 4 ~0,1. Corte nomeado: a **Fase 3 (analytics da abertura)** é a primeira a sair se a janela apertar — a aceitação de produto não a exige (guardrail diz "pode", não "deve"). Design UI (gate): `docs/plans/central-conteudos-cards-no-catalogo-ui-design.html`.
## Leitura da intenção
- **Outcome:** os 6 modelos de card viram itens próprios do catálogo, **misturados aos demais, sem seção**, encontráveis pelo nome, pelos apelidos ("santinho" → `time-de-voce` + `minha-colinha`, "foto de perfil" → `perfil-quadrado` + `perfil-retangular`, "colinha" → `minha-colinha`, "estadual" → `time-do-estadual`) e pelo filtro Tipo "Card", cada um levando a `/cards?model=<id>` com o modelo já escolhido; o tile único morre.
- **O que NÃO negociar:** card não é peça (sem linha no banco, sem mídia, sem publicação/kill switch, sem `id`); sem seção/cabeçalho próprio de cards; sem faceta geográfica para card (com Cidade/Região/Instituição ativos, somem); busca/filtros funcionam sem JS; estúdio `/cards` intocado (foto 100% no aparelho, sem PII/`Consent` novo); catálogo sem peça publicada mantém o estado vazio honesto com o caminho para `/cards`; nenhum segundo analytics (só o mecanismo anônimo do C213/S32, se (c) aceita); URL pública do catálogo e shape `ContentPiecePublicItem` intactos.
- **O que reavaliar (confirmado no código):**
- "a injeção acontece no loader" — **confirmado**: `loadContentPieceCatalogSearch` (`src/utilities/content/contentPieceThemeSearch.ts:62-111`) é o ponto único onde `publishedCount`/`facets`/`items` nascem, depois do tema.
- "facetas derivam só dos itens" — **confirmado** (`contentPieceCatalog.ts:155-185`): sem card alimentando `tipos`, `?tipo=card` não renderiza o chip "Tipo" e o filtro ativo cairia no valor cru (`:200`).
- "(c) é extensão aditiva pequena" — **confirmado**: `'abertura'` e `'card'` já existem no vocabulário (`contentEvents.ts:19-49`) e no select da collection (`ContentEvent.ts:52-71`) ⇒ **zero migration**; falta só a variante estrita na schema, o branch da rota (hoje o branch de card exigiria picker e devolveria 400 em `route.ts:99-107`) e o sender.
- "reusar `CardModelTile`" — **rejeitado**: exige `fontFamily` do estúdio (brexterBold só em `/cards`, `page.tsx:35`) e desenharia "SEU NOME" sobre o master; o artefato (cena 01) mostra a arte base, então o item porta a arte real por `kind` (ver D5).
- "arte real por modelo basta" — **corrigido**: `perfil-quadrado`/`perfil-retangular` não têm `previewSrc` e seu `assetSrc` é a moldura oficial com janela transparente (`photo-square-frame.png`, `photo-portrait-frame.png`); o artefato (cena 03) mostra placeholder neutro com forma tracejada — a regra de arte é por `kind` (ver D5).
## Abordagem recomendada
```mermaid
flowchart TB
DB[(contentPiece publicado)] --> TP[toContentPiecePublicItem]
CM[CARD_MODELS + description/aliases] --> CC[cardCatalogItems - puro]
TP --> GATE{"publishedCount > 0"}
CC --> GATE
GATE -->|não| EMPTY["estado vazio honesto - link /cards"]
GATE -->|sim| ITEMS["items - peças + 6 cards"]
ITEMS --> FACETS["contentPieceCatalogFacets - Tipo ganha card; geo não"]
FACETS --> FILTER["filterContentPieceCatalogItems - q e facetas"]
FILTER --> BOARD["ContentPieceCatalog - split large/compact"]
BOARD -->|peça| PC[ContentPieceCard]
BOARD -->|card| CI["ContentCatalogCardItem - 1 link"]
CI -->|href| STUDIO["/cards?model=id"]
CI -.->|onClick| BEACON[sendCardOpeningEvent]
BEACON -.-> ROUTE["POST /api/content-events - subjectType card"]
```
**Opções consideradas (forma geral):** A) união discriminada de itens sintéticos (`ContentCatalogItem = ContentPiecePublicItem | CardCatalogItem`), injeção no loader único e componente próprio no board | B) cards no shape de peça com campos fake e reuso do `ContentPieceCard` | C) galeria/seção própria de cards (ou rota nova).
**Recomendação:** A — atende "misturado, sem seção" pelo mesmo pipeline de facetas/busca (S28), não cria linha/migration (guardrail) e mantém `ContentPiecePublicItem` e todos os seus consumidores intactos.
**Rejeitadas:** B porque forçaria branches no card de peça (Baixar/Compartilhar/detalhe/`playingId`/`contentPieceMediaKind`, que classificaria card como `image`) e campos fake violariam "não é peça"; C porque o gate cortou a seção e a intenção veda rota/segunda galeria.
### Decisões de engenharia
#### D1 — Modelagem do item sintético
**Opções:** A) união discriminada com marcador só no card (`itemKind: 'card'`, guard `isCardCatalogItem`), sem `id` numérico | B) discriminador `kind: 'piece' | 'card'` nos dois lados da união (altera `ContentPiecePublicItem`) | C) um único shape de peça com campos sintéticos/fake (`id` numérico, `slug`, `file: null`, `cityLabel: null`, `type: 'card'`).
**Recomendação:** A — zero ripple em `ContentPiecePublicItem` (página da peça, share sheet, `ContentPieceCard`, fixtures e pins de peça não mudam); o card não tem `id`/`slug`/`file`, então o board não consegue tratá-lo como peça por acidente (`playingId: number`, `sharingItem` e as ações de `ContentPieceCard.tsx:136-172` ficam peça-only); o narrowing aparece em 3 pontos (filtro, facetas, board) via `isCardCatalogItem`.
**Rejeitadas:** B porque o discriminador obrigatório toca `toContentPiecePublicItem`, `ContentPieceDetail`, `ContentPieceShareSheet` e dezenas de fixtures sem eliminar branch algum; C porque `id` numérico colide com keys/`playingId`, `contentPieceMediaKind` classificaria o card como `image` e as ações de arquivo apareceriam — o rabbit hole "já que virou item, vira peça".
#### D2 — Dono dos apelidos e mini-descrições
**Opções:** A) estender `CardModel` (dono) com `description: string` e `aliases: readonly string[]` obrigatórios, preenchendo os 6 em `CARD_MODELS` | B) módulo novo `src/lib/contentCatalogCards.ts` com mapa paralelo id→copy | C) campos opcionais com fallback no builder.
**Recomendação:** A — a copy é dado do modelo (nasce/morre com ele); obrigatório faz o typecheck exigir os 6 completos (aceite: "nome, explicação curta e arte") e o unit test pina os literais; a extensão é aditiva: nenhum consumidor atual (`CardModelGallery`, `CardModelTile`, `CardComposer`, `cardRender`) lê os campos e `tests/unit/cardModels.unit.spec.ts:14-191` segue verde.
**Rejeitadas:** B é gêmeo de catálogo (duas listas para o mesmo id, contra "edit the owner, don't twin"); C deixaria um 7º modelo entrar sem descrição/apelido em silêncio.
Literais do design (verbatim):
| modelo | `label` (já existe) | `description` (nova) | `aliases` (novos) | arte do item |
| ------------------- | ------------------- | --------------------------------- | --------------------- | ------------------------------- |
| `eu-sou-solla` | Card com seu nome | Coloque seu nome no card oficial. | `nome` | real (`assetSrc`) |
| `perfil-quadrado` | Moldura quadrada | Foto de perfil quadrada. | `foto de perfil` | placeholder neutro (kind photo) |
| `perfil-retangular` | Moldura vertical | Foto vertical para stories. | `foto de perfil` | placeholder neutro (kind photo) |
| `time-de-voce` | Time de você | Entre para o time com sua foto. | `santinho` | real (`previewSrc`) |
| `time-do-estadual` | Time do estadual | Escolha o estadual e personalize. | `estadual` | real (`previewSrc`) |
| `minha-colinha` | Minha colinha | Monte sua cola de votação. | `santinho`, `colinha` | real (`previewSrc`) |
#### D3 — Onde a injeção acontece e como as facetas veem `card`
**Opções:** A) funções puras em `contentPieceCatalog.ts` (`cardCatalogItems()` + spread no loader) chamadas por `loadContentPieceCatalogSearch` entre o tema e `facets`/`filter` | B) montar itens/facetas na página `(catalog)/page.tsx` | C) devolver cards da leitura `contentPieceReads.ts`, junto das linhas.
**Recomendação:** A — o loader é o dono do pipeline e o único ponto testado por int; a injeção é condicionada a `publishedItems.length > 0` e **`publishedCount` continua contando peças** (`publishedItems.length`), então o early-return de `page.tsx:96-106` e a expansão de tema (gate `:87`) nunca são acionados por card; as facetas recebem `ContentCatalogItem[]` e o branch de card adiciona **só** `'card'` ao conjunto de `tipos` (`contentPieceCatalog.ts:155-185`), nunca cidade/região/instituição/tema — por isso `?tipo=card` rende o chip "Tipo · Card" (`:200`) e a faceta geográfica exclui cards sozinha no filtro (`:252-263`); a ordem é determinística (peças, depois cards) — não há sinal de ranking para inventar.
**Rejeitadas:** B duplicaria facetas/filtro na página e mataria o ponto único já pinado por int (`tests/int/contentPiece.int.spec.ts:948-1059`); C contamina o read cacheado (afetaria `hasPublishedContentPieces`, a metadata e a página da peça) e flerta com "linha no banco", que a intenção proíbe.
**Nota (barata de reverter):** cards entram na mesma regra OR do modo tema (q literal sempre; termos expandidos sem badge de tema). Se produto achar card fora de contexto no `mode=tema`, o único ponto a restringir é o branch de card no filtro — gatilho de revisitação registrado.
#### D4 — Analytics da abertura (questão (c))
**Opções:** A) incluir a abertura de card (`type: 'abertura'`, `subjectType: 'card'`, `subjectId = model.id`) como 3ª variante estrita do `POST /api/content-events` + sender fire-and-forget no clique | B) deferir (Não escopo com gatilho) | C) contar como peça (slug sintético).
**Recomendação:** A — custo real medido: **zero migration** (o select já aceita `abertura`/`card`); a schema ganha ~10 linhas (`{ type:'abertura', subjectType:'card', cardModelId }`, sem `stateDeputySlug` — o visitante ainda não escolheu estadual); a rota ganha um branch de abertura que valida só o modelo (sem picker — hoje `route.ts:99-107` devolveria 400) e reusa `recordContentEvent`; o sender é gêmeo estrutural de `sendCardDownloadEvent` sobre o mesmo `postContentEventBody` fail-soft (`contentEvents.ts:80-129`); o contador da home filtra `type === 'download'` (`cardDownloadCounts.ts:24,48`), então abertura **não** infla "Downloads". O label admin `contentEventTypeLabels.abertura` ("Abertura da peça") passa a "Abertura" — o mesmo tipo agora serve peça e card, e o `subjectType` já desambigua a coluna. O pin que hoje recusa `abertura` de card (`tests/int/contentEvents.int.spec.ts:456-459`) é atualizado no mesmo PR — o contrato mudou por decisão desta entrega.
**Rejeitadas:** B porque a extensão é pequena e a intenção recomenda (c)=A; C porque inventaria assunto de peça para um card ("nunca como peça") e poluiria os contadores de circulação.
#### D5 — Componente do item no board
**Opções:** A) `ContentCatalogCardItem` próprio, no bucket "large" (3-col no desktop) com responsivo mobile de linha 128px, o item inteiro um único `<a href="/cards?model=<id>">` e a CTA como affordance visual | B) reusar `ContentPieceCard` com branches por tipo de item | C) bucket "compact" (2-col de linhas no desktop).
**Recomendação:** A — card não tem Baixar/Compartilhar/detalhe/`themeMatch`; o bucket large é o card cheio de 3 colunas do artefato (cenas 01/03) — o compact renderiza linhas horizontais em 2 colunas, fora do desenho; o `<a>` único resolve as cenas em que o artefato omite a CTA (cena 02, 2º phone; cenas 04–05) e é alvo de tap no mobile, funcionando sem JS; a CTA visível ("Escolher este modelo →" no desktop, "Escolher →" no mobile) é o affordance. Reusa `CONTENT_PIECE_CARD`/`CONTENT_PIECE_TAG`/`CONTENT_PIECE_OUTLINE_BUTTON`/`CONTENT_PIECE_FOCUS` (`contentPieceClasses.ts:1-39`), selo `badge` (pill sobre a arte, como o `CardModelTile.tsx:71-75`) e `data-card-model={modelId}` para e2e. Split: `isCardCatalogItem(item)` → `largeItems`; key `card:<modelId>` para card, `item.id` para peça.
**Regra de arte (por `kind`, fiel ao artefato):** `photo` (quadrada/vertical) → arte neutra clara com a forma tracejada central do artefato (cena 03; o `assetSrc` é moldura com janela transparente, não uma arte preenchida) — sem `next/image`; os demais `kind` → `next/image` com `previewSrc ?? assetSrc` (arte real existente). O header da página (contador/"Resultados do filtro aplicado") fica como está: o artefato ilustra contexto e o aceite não pede contador — copy fora do escopo.
**Rejeitadas:** B porque os branches de card vazariam no card de peça (tag, ações, mídia) sem ganho; C porque diverge do artefato no desktop e enfraquece o CTA; `CardModelTile` no catálogo porque exige `fontFamily`/canvas de nome do estúdio; moldura real sobre slot neutro porque o artefato aprovado desenha placeholder neutro.
#### D6 — Pins de teste (o que morre, o que vira)
**Opções:** A) atualizar os pins que quebram e adicionar pins novos de busca/faceta/geo/deep-link/evento | B) deletar os pins do tile e confiar só no e2e | C) criar suíte paralela de catálogo.
**Recomendação:** A — pins que mudam: `tests/e2e/frontendConteudos.e2e.spec.ts:196` (invite 1 → 6 cards `[data-card-model]`), `:257` (filtro `tipo=video` → 0 cards), `:495` (kill switch → 0 cards além de 0 peças), `tests/int/contentPiece.int.spec.ts:948-1059` (filtrar `isCardCatalogItem` antes de comparar ids de peça; cards nas asserções de `tipo=card`), `tests/unit/contentPieceCatalog.unit.spec.ts:188-215` (mesmo narrowing) e `tests/int/contentEvents.int.spec.ts:456-459` (abertura de card: 400 → 204). Novos: unit do builder/filtro/facetas/apelidos e do schema/beacon; int da rota + do loader; e2e do item (busca "santinho"/"foto de perfil", `tipo=card`, geo somem, clique → `/cards?model=...` com o composer aberto e beacon de abertura).
**Rejeitadas:** B perde a prova barata de domínio (o CI de PR roda e2e `selected` e o `verify` do deploy é caro); C é cerimônia — a suíte do catálogo já é a dona.
### Componentes / mudanças
- **`CardModel`** (`src/lib/cardModels.ts:42-74`): `description: string` e `aliases: readonly string[]` obrigatórios (doc: copy/apelidos do item no catálogo); `CARD_MODELS` (`:76-148`) preenchido com a tabela do D2; pin novo em `tests/unit/cardModels.unit.spec.ts`.
- **`CardCatalogItem` / `ContentCatalogItem` / `isCardCatalogItem`** (`src/lib/contentPieceCatalog.ts`, junto de `ContentPiecePublicItem:291-320`): `{ itemKind:'card'; modelId: CardModelId; title; description; aliases; badge: string|null; kind: CardModelKind; artSrc: string | null; href; searchText }` — `searchText` cru (`[label, ...aliases, description].join(' ')`), normalizado no match como na peça; união `ContentPiecePublicItem | CardCatalogItem`.
- **`cardCatalogItems()`** (novo, puro, mesmo módulo): mapeia `CARD_MODELS`; `artSrc = kind === 'photo' ? null : (previewSrc ?? assetSrc)`; `href = /cards?model=<id>` (deep link já validado em `src/app/(frontend)/(home)/cards/page.tsx:26-27`).
- **`filterContentPieceCatalogItems`** (`:245-269`): passa a `readonly ContentCatalogItem[]`; branch de card: `params.tipo && params.tipo !== 'card'` → fora; qualquer `cidade|regiao|tema|instituicao` → fora; `q`/termos via `normalizeForSearch(item.searchText).includes(term)`.
- **`contentPieceCatalogFacets`** (`:155-185`): branch de card adiciona só `'card'` a `tipos` (sem geo/tema); header do módulo atualizado.
- **`loadContentPieceCatalogSearch`** (`src/utilities/content/contentPieceThemeSearch.ts:78-110`): `catalogItems = publishedItems.length > 0 ? [...publicItems, ...cardCatalogItems()] : publicItems`; `publishedCount: publishedItems.length` (doc: peças, não cards); `facets`/`items` sobre a união.
- **`ContentPieceCatalog`** (`src/components/conteudos/ContentPieceCatalog.tsx:18-72`): `items: readonly ContentCatalogItem[]`; remove `showCardInvite`; card entra em `largeItems`; render `<ContentCatalogCardItem>` para card e `<ContentPieceCard>` para peça; keys por `card:<modelId>`/`item.id`.
- **`ContentCatalogCardItem`** (novo, `src/components/conteudos/`): `<a>` único, responsivo `grid grid-cols-[128px_1fr] sm:block`, arte real (`next/image`, `previewSrc ?? assetSrc`) ou placeholder neutro tracejado para `kind === 'photo'`, selo `badge`, tag `Card` (`contentPieceTypeLabels.card`), título, `description`, chips de `aliases`, CTA, `data-card-model`, `onClick` → `sendCardOpeningEvent(modelId)`.
- **`ContentPieceCardInvite`** (`:16-44`): deletado; `page.tsx:108,152-162` perde `hasActiveFilters`/`showCardInvite` (o estado vazio e o "Fazer meu card" de `ContentPieceStates.tsx:28` ficam).
- **Analytics (Fase 3):** `src/lib/schemas/contentEvent.ts:34-44` (3ª variante estrita), `src/app/(frontend)/api/content-events/route.ts:93-116` (branch de abertura sem picker), `src/lib/contentEvents.ts:29-34,111-129` (+`sendCardOpeningEvent`; label `abertura` → "Abertura").
- **Manifest e2e** (`scripts/lib/e2e-affected-manifest.mjs:153-180`): + `'src/lib/cardModels.ts'` e `'src/lib/schemas/contentEvent.ts'` na entrada `frontendConteudos` (o `src/lib/schemas` genérico só acorda `frontend`/campaign\*; `src/lib/card` já acorda `frontend`).
- **Migration:** nenhuma — nenhuma collection/field muda; `abertura`/`card` já são opções válidas de `contentEvent`.
- **Access / Consent:** nenhum access novo e nenhuma escrita nova além do evento anônimo já existente (rota same-origin + rate-limited + bypass justificado em `contentEventWrite.ts:42-55`); sem PII, sem `Consent` (foto segue no aparelho; `cards/page.tsx` e `CardComposer` intocados).
- **UI:** Impeccable B — port do artefato aprovado (`central-conteudos-cards-no-catalogo-ui-design.html`); classes/shells existentes; sem token novo; sem seção/faixa de cards.
### Dados → forma
Não aplicável — a intenção decidiu que o item não apresenta número/contador (é roteador para o estúdio); nenhuma tabela/gráfico. O único dado produzido é o evento anônimo de abertura, lido depois pelo mecanismo existente do C213/S32 (nenhuma superfície de leitura nova nesta entrega).
## Fases verificáveis
1. **Fase 1 — puro + loader (tracer, ~0,4 dia):** campos em `cardModels.ts`; união/builder/guard no `contentPieceCatalog.ts`; branches de filtro/facetas; injeção no loader com `publishedCount` preservado. Unit novo (`contentPieceCatalog` + `cardModels`) e pins unit/int ajustados. Prova: `pnpm test:unit` e `pnpm test:int` verdes; o loader devolve peças + 6 cards em `?tipo=card` e 0 cards com geo ativa.
2. **Fase 2 — board/UI + tile morto (~0,45 dia):** `ContentCatalogCardItem`; split/key no `ContentPieceCatalog`; remoção de `showCardInvite`/`ContentPieceCardInvite`; limpeza da página; e2e do catálogo atualizado (invite→cards, kill switch, `tipo=card`, geo, buscas por apelido, clique → `/cards?model=...`). Prova: `pnpm test:e2e tests/e2e/frontendConteudos.e2e.spec.ts` (ou `pnpm test:e2e:affected`) verde; sem regressão visual pelo port fiel do artefato.
3. **Fase 3 — analytics da abertura (~0,15 dia, cortável):** variante estrita na schema; branch de abertura na rota; `sendCardOpeningEvent` + `onClick`; label admin; atualização do pin de recusa; unit/int/e2e do beacon; linha do manifest. Prova: `pnpm test:int` + beacon observado no e2e do clique.
4. **Fase 4 — gates (~0,1 dia):** `pnpm gate:fast`; `pnpm test:int`; e2e afetados locais (`pnpm test:e2e:affected`); `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`; entrada `docs/changelog/2026-09-24-s38.md` (additions-only); `pnpm push`; PR ready com CI verde.
## Rabbit holes / Não escopo (engenharia)
- **Card virar peça:** collection/migration/mídia/publicação/kill switch — item sintético; se a comunicação quiser copy editável sem deploy, vira peça publicada (gatilho herdado do D6 do S27), fora deste item.
- **Baixar/gerar card no catálogo:** canvas/ffmpeg/servidor, foto fora do aparelho — fora; o item só linka para `/cards`.
- **SEO/página/OG por card**, rota nova, sitemap, segunda galeria — fora.
- **`CardModelTile`/`fontFamily`/brexterBold no catálogo** e canvas de nome no item — fora (imagem estática).
- **Facetas próprias de card** (tema/cidade por card) e temas declarados — fora (v1 sem tema).
- **Ranking/relevância/ordenação editorial** dos cards — fora (append determinístico, sem sinal de relevância).
- **Contador de resultados / copy nova do header do board** — fora; o artefato ilustra contexto e o aceite não pede contador.
- **Dashboard/segundo analytics** das aberturas — fora; a Fase 3 só grava no mecanismo existente.
- **Restringir cards a `q` literal no modo tema** — não nesta entrega; o branch de card é o ponto único de revisão (nota no D3).
## Riscos e mitigação
- **`publishedCount` perder a semântica de peças** (alguém "simplificar" para `catalogItems.length` mataria o estado vazio e o guardrail). Mitigação: pin int (zero peças ⇒ `publishedCount === 0` e nenhum card em `items`) + e2e do kill switch.
- **Cobertura/manifest e2e:** `src/lib/schemas/contentEvent.ts` é área de risco (`E2E_RISK_PREFIXES`) e o entry genérico só acorda `frontend`; adicionar ao entry do catálogo no mesmo PR e rodar `pnpm test:e2e:affected` (wakes `frontendConteudos` + `frontend`) antes do push.
- **E2E serial com janela global:** `frontendConteudos` roda `serial` e assume zero/um publicado por teste; manter as novas asserções dentro dos testes que já controlam `unpublishEveryPiece`, sem novo teste que mute publicação global.
- **Ruído do alias "nome"** (`eu-sou-solla`): a busca é `includes` normalizado (S28), então "nome" surfará peças com a palavra — o card aparece junto; sem mudança de contrato de busca; se virar problema, o ajuste é no `searchText` do card (aditivo).
- **Conflito com #1277** (`escala-dry-pos-s30`, `components/cards/CardComposer.tsx`/`StateDeputySelect.tsx`): aqui não tocamos `components/cards` (só `cardModels.ts` dados); sem sobreposição; rebase se lá também tocar `cardModels.ts`.
- **Regressão visual do board** (card no bucket large com responsivo pode alterar o ritmo do mobile): port fiel do artefato; se o markup desviar da estrutura visual, aciona a crítica do `designer` (gate B).
- **Acessibilidade do link único:** nome acessível concatena tag+título+descrição+apelidos+CTA; sem interativo aninhado (apelidos são spans) e foco no card inteiro via `CONTENT_PIECE_FOCUS`; testes usam regex no título. Se o gate reprovar, o fallback é título como segundo link além da CTA — decisão cosmética reversível.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: 6 modelos como itens próprios, sem seção; "santinho" → 2 itens; "foto de perfil" → 2; "colinha" → 1; "estadual" → 1; Tipo "Card" → 6 (+ peças de tipo card, se publicadas, com comportamento normal); geo ativa → 0 cards; item → `/cards?model=<id>`; tile morto; estado vazio intacto.
- [ ] Invariantes AGENTS/engineering-standards: sem collection/migration/Consent/PII; `ContentPiecePublicItem` e URLs do catálogo intactos; copy pt-BR/identificadores EN; dead code (`ContentPieceCardInvite`, `showCardInvite`) removido no mesmo PR; sem `as never`.
- [ ] Testes de domínio: unit (builder/filtro/facetas/apelidos/schema/beacon), int (loader/facetas/rota), e2e (catálogo + clique); pins existentes atualizados; `pnpm gate:fast` + `pnpm test:int` + e2e afetados verdes.
- [ ] **PARADA OBRIGATÓRIA: nenhuma.** Auditoria: sem Consent/LGPD, sem migração de schema, sem mudança de contrato de URL público (o deep link `/cards?model=<id>` já existe e é reusado), sem mudança de shape público de dados, sem tocar produção/DB de prod/merge sem CI. A única ampliação de contrato é aditiva (3ª variante estrita do evento, anônima), com o pin S32 atualizado no mesmo PR.
## Self-score (decision-quality, gate ≥4)
1. **Decisões caras com rejeitadas — 5/5:** D1–D6 no formato Opções/Recomendação/Rejeitadas; a modelagem do item e a ampliação do evento (as duas caras de reverter) têm rejeitadas explícitas e mecanismo de reversão nomeado.
2. **Cabe no appetite — 4/5:** sem migration, sem rota, sem collection; ~1–1,5 dia com a Fase 3 nomeada como corte primeiro se estourar.
3. **Rabbit holes nomeados — 5/5:** os cinco de produto (peça, download, SEO, tema/cidade, medir pessoa) e os de engenharia (shape fake, canvas, facetas próprias, ranking, segundo analytics, contador de header) com corte explícito.
4. **Depth check / reuso — 5/5:** edita os donos (`contentPieceCatalog` para filtro/facetas, `cardModels` para modelos, `loadContentPieceCatalogSearch` como ponto único, `contentEvents`/rota para o evento), reusa classes de `contentPieceClasses` e o deep link existente; nenhum gêmeo, nenhum pass-through.
5. **Intenção preservada — 5/5:** o outcome não foi reescrito; a engenharia só escolheu a forma (união discriminada, injeção no loader, item próprio) e manteve todos os guardrails — inclusive "sem linha no banco" (injeção só na memória do request).
Média ≈ 4,8/5 — acima do gate.

View file

@ -159,9 +159,11 @@ export const E2E_AFFECTED_MANIFEST = [
// on-demand play, the private-media public route, the vote share sheet and
// the kill-switch contract over the C211 listing tag. C213 adds the
// anonymous beacon endpoint, its vocabulary and the event collection the
// public page (and the public download) write to.
// S39 — the home sample is wired only by the home page and the pure
// selection module; the section components live under `components/conteudos`.
// public page (and the public download) write to. S38 adds the card models
// as catalogue items (the copy/aliases live in the model catalog) and the
// card-opening variant of the beacon schema. S39 adds the home sample,
// wired by the home page and the pure selection module (the section
// components live under `components/conteudos`).
prefixes: [
'src/app/(frontend)/conteudos',
'src/app/(frontend)/(home)',
@ -171,6 +173,8 @@ export const E2E_AFFECTED_MANIFEST = [
'src/lib/contentPieceHomeSelection.ts',
'src/lib/contentPieceShare',
'src/lib/contentEvents.ts',
'src/lib/cardModels.ts',
'src/lib/schemas/contentEvent.ts',
'src/utilities/content/contentPieceReads.ts',
// S27-FOLLOWUP-DRY — the owner of the page's og:image fallback.
'src/utilities/ogImageReads.ts',

View file

@ -96,6 +96,17 @@ export const POST = async (request: Request): Promise<NextResponse> => {
const model = getCardModel(parsed.data.cardModelId)
if (!model) return silentResponse(400)
// S38 — the card opening: the catalogue item routed the visitor to the
// studio; there is no state deputy yet (the picker lives in the studio).
if (parsed.data.type === 'abertura') {
await recordContentEvent({
type: parsed.data.type,
subjectType: 'card',
subjectId: model.id,
})
return silentResponse(204)
}
const requestedDeputySlug = parsed.data.stateDeputySlug
let variant: string | null = null
if (model.stateDeputyPicker === true) {

View file

@ -105,8 +105,6 @@ export default async function ConteudosPage({
)
}
const hasActiveFilters = activeFilters.length > 0
return (
<>
<ContentPiecePageHeader />
@ -150,11 +148,7 @@ export default async function ConteudosPage({
<div className="mt-5">
{items.length > 0 ? (
<ContentPieceCatalog
items={items}
showCardInvite={!hasActiveFilters}
themeMode={themeMode}
/>
<ContentPieceCatalog items={items} themeMode={themeMode} />
) : themeMode && !themeUnavailable ? (
<ContentPieceThemeNoResults params={params} />
) : (

View file

@ -0,0 +1,107 @@
import Image from 'next/image'
import Link from 'next/link'
import { sendCardOpeningEvent } from '@/lib/contentEvents'
import type { CardCatalogItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import {
CONTENT_PIECE_CARD,
CONTENT_PIECE_FOCUS,
CONTENT_PIECE_OUTLINE_BUTTON,
CONTENT_PIECE_TAG,
} from './contentPieceClasses'
const CARD_TAG_LABEL = 'Card'
/**
* S38 — the art of a model item (artefato: cenas 01–03): the model's real file
* cropped from the top (the banners and the name line live there), or the
* neutral dashed placeholder of the photo models, whose master is only the
* frame with a transparent photo window. The `NOVO` badge of the newest model
* sits on the art.
*/
const CardItemArt = ({ item }: { item: CardCatalogItem }) => (
<span
className={cn(
'relative grid min-h-40 place-items-center overflow-hidden sm:aspect-video sm:min-h-0',
item.art.kind === 'placeholder'
? 'bg-[linear-gradient(145deg,#eee9e5,#fff)]'
: 'bg-[linear-gradient(145deg,#184e92,#0876ac)]',
)}
>
{item.art.kind === 'placeholder' ? (
<span
aria-hidden="true"
className={cn(
'relative grid place-items-center border-2 border-dashed border-[#a21c1c]/40 text-3xl text-[#a21c1c]',
item.art.shape === 'square' ? 'size-20 rounded-full' : 'h-24 w-16 rounded-lg',
)}
>
◎
</span>
) : (
<Image
src={item.art.src}
alt=""
fill
sizes="(min-width: 1024px) 360px, (min-width: 640px) 45vw, 128px"
className="object-cover object-top"
/>
)}
{item.badge ? (
<span className="absolute top-2 right-2 rounded-full bg-white px-2 py-1 text-[9px] font-black text-[#184e92]">
{item.badge}
</span>
) : null}
</span>
)
/**
* S38 — one card model as an item of the board (artefato: cenas 01–05). The
* whole item is a single link to the studio with the model chosen — a bigger
* tap target on the phone and no second selection contract. It is NOT a piece:
* no download, no share, no detail page; the item is an invite to `/cards`.
* The click feeds the anonymous opening event of the model (C213/S32
* mechanism), never a piece event.
*/
export const ContentCatalogCardItem = ({ item }: { item: CardCatalogItem }) => (
<Link
href={item.href}
data-card-model={item.modelId}
onClick={() => sendCardOpeningEvent(item.modelId)}
className={cn(
CONTENT_PIECE_CARD,
'grid grid-cols-[128px_1fr] text-black no-underline sm:block',
item.badge ? 'border-2 border-(--pt-red)' : null,
CONTENT_PIECE_FOCUS,
)}
>
<CardItemArt item={item} />
<div className="p-4">
<span className={CONTENT_PIECE_TAG}>{CARD_TAG_LABEL}</span>
<h3 className="mt-2 font-[family-name:var(--font-exo2)] text-sm font-extrabold sm:text-base">
{item.title}
</h3>
<p className="mt-1 text-xs text-(--campaign-muted)">{item.description}</p>
{item.aliases.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{item.aliases.map((alias) => (
<span
key={alias}
className="inline-flex rounded-full border border-black/10 bg-white px-[7px] py-[3px] text-[10px] font-bold text-(--campaign-muted)"
>
{alias}
</span>
))}
</div>
) : null}
<span className={cn(CONTENT_PIECE_OUTLINE_BUTTON, 'mt-4 hidden w-full sm:inline-flex')}>
Escolher este modelo →
</span>
<span className="mt-3 block text-xs font-bold text-(--pt-red) sm:hidden">Escolher →</span>
</div>
</Link>
)

View file

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

View file

@ -2,35 +2,45 @@
import { useState } from 'react'
import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import {
contentPieceMediaKind,
isCardCatalogItem,
type ContentCatalogItem,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import { ContentCatalogCardItem } from './ContentCatalogCardItem'
import { ContentPieceCard } from './ContentPieceCard'
import { ContentPieceCardInvite } from './ContentPieceCardInvite'
import { ContentPieceShareSheet } from './ContentPieceShareSheet'
/**
* S27 — the catalogue board (artefato: cena 01): full cards for video, audio
* and link pieces, compact rows for photo/text, the card invite when nothing
* is filtered. One piece plays at a time (switching unmounts the previous
* element) and the share sheet is owned here, so every card opens the same
* vote message.
* and link pieces, compact rows for photo/text, one piece plays at a time
* (switching unmounts the previous element) and the share sheet is owned here,
* so every card opens the same vote message.
*
* S38 — the six card models are items of the same board: they take the full-card
* bucket and the whole item is one link to the studio (`/cards?model=<id>`); the
* old single invite tile is gone and no section groups them.
*/
export const ContentPieceCatalog = ({
items,
showCardInvite,
themeMode,
}: {
items: readonly ContentPiecePublicItem[]
showCardInvite: boolean
items: readonly ContentCatalogItem[]
/** S28 — the board is in the theme mode: cards state their provenance. */
themeMode: boolean
}) => {
const [playingId, setPlayingId] = useState<number | null>(null)
const [sharingItem, setSharingItem] = useState<ContentPiecePublicItem | null>(null)
const largeItems: ContentPiecePublicItem[] = []
const largeItems: ContentCatalogItem[] = []
const compactItems: ContentPiecePublicItem[] = []
for (const item of items) {
if (isCardCatalogItem(item)) {
largeItems.push(item)
continue
}
const kind = contentPieceMediaKind(item)
if (kind === null || kind === 'video' || kind === 'audio') largeItems.push(item)
else compactItems.push(item)
@ -47,12 +57,15 @@ export const ContentPieceCatalog = ({
return (
<>
{largeItems.length > 0 || showCardInvite ? (
{largeItems.length > 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{largeItems.map((item) => (
<ContentPieceCard key={item.id} {...cardProps(item)} />
))}
{showCardInvite ? <ContentPieceCardInvite /> : null}
{largeItems.map((item) =>
isCardCatalogItem(item) ? (
<ContentCatalogCardItem key={`card:${item.modelId}`} item={item} />
) : (
<ContentPieceCard key={item.id} {...cardProps(item)} />
),
)}
</div>
) : null}

View file

@ -43,6 +43,17 @@ export type CardModel = {
id: CardModelId
kind: CardModelKind
label: string
/**
* S38 — the one-line explanation of the model shown by its catalogue item on
* `/conteudos`, under the name. Copy of the model, never of the studio.
*/
description: string
/**
* S38 — the popular nicknames the public catalogue search must find the model
* by ("santinho", "foto de perfil", "colinha", "estadual"). Search data of the
* item, never rendered as the model name.
*/
aliases: readonly string[]
assetSrc: string
width: number
height: number
@ -73,6 +84,8 @@ export const CARD_MODELS: readonly CardModel[] = [
id: 'eu-sou-solla',
kind: 'name',
label: 'Card com seu nome',
description: 'Coloque seu nome no card oficial.',
aliases: ['nome'],
assetSrc: '/cards/name-card-base.jpg',
width: 1080,
height: 1440,
@ -81,6 +94,8 @@ export const CARD_MODELS: readonly CardModel[] = [
id: 'perfil-quadrado',
kind: 'photo',
label: 'Moldura quadrada',
description: 'Foto de perfil quadrada.',
aliases: ['foto de perfil'],
assetSrc: '/cards/photo-square-frame.png',
width: 1000,
height: 1000,
@ -90,6 +105,8 @@ export const CARD_MODELS: readonly CardModel[] = [
id: 'perfil-retangular',
kind: 'photo',
label: 'Moldura vertical',
description: 'Foto vertical para stories.',
aliases: ['foto de perfil'],
assetSrc: '/cards/photo-portrait-frame.png',
width: 1000,
height: 1440,
@ -99,6 +116,8 @@ export const CARD_MODELS: readonly CardModel[] = [
id: 'time-de-voce',
kind: 'team',
label: 'Time de você',
description: 'Entre para o time com sua foto.',
aliases: ['santinho'],
assetSrc: '/cards/team-card-base.png',
overlaySrc: '/cards/team-card-front.png',
previewSrc: '/cards/team-card-example.jpg',
@ -111,6 +130,8 @@ export const CARD_MODELS: readonly CardModel[] = [
id: 'time-do-estadual',
kind: 'team',
label: 'Time do estadual',
description: 'Escolha o estadual e personalize.',
aliases: ['estadual'],
// S30 — the defaults are the illustrative pair (JULIO, the deputy of the
// approved example art); the composer swaps them for the selected entry of
// `stateDeputyCatalog`. `previewSrc` is the exact file approved by the
@ -128,6 +149,8 @@ export const CARD_MODELS: readonly CardModel[] = [
id: 'minha-colinha',
kind: 'colinha',
label: 'Minha colinha',
description: 'Monte sua cola de votação.',
aliases: ['santinho', 'colinha'],
// S34 — the approved art delivered by the human is the base of the preview
// and the download (drawn whole at 1.2×); the only drawn element is the
// estadual row overlay. The gallery tile shows the same art via `assetSrc`.

View file

@ -27,7 +27,7 @@ export type ContentEventType = (typeof CONTENT_EVENT_TYPES)[number]
/** Admin/REST labels only — the public surfaces own their own copy. */
export const contentEventTypeLabels: Record<ContentEventType, string> = {
abertura: 'Abertura da peça',
abertura: 'Abertura',
download: 'Download',
compartilhar_whatsapp: 'Clique em compartilhar (WhatsApp)',
compartilhar_link: 'Clique em compartilhar (link)',
@ -127,3 +127,25 @@ export const sendCardDownloadEvent = (
postContentEventBody(JSON.stringify(body))
}
type CardOpeningBeaconBody = {
type: 'abertura'
subjectType: 'card'
cardModelId: CardModelId
}
/**
* S38 — one anonymous opening event of a card model: the visitor clicked the
* catalogue item and was routed to the studio with the model chosen. Same
* transport and same fail-soft contract as the piece beacon: it never throws
* and never delays the navigation.
*/
export const sendCardOpeningEvent = (modelId: CardModelId): void => {
const body: CardOpeningBeaconBody = {
type: 'abertura',
subjectType: 'card',
cardModelId: modelId,
}
postContentEventBody(JSON.stringify(body))
}

View file

@ -8,7 +8,14 @@
* The staff list contract (`src/utilities/content/contentPieceListUrl.ts`) is
* deliberately NOT reused: its vocabulary (status, processing, page) is
* internal and the public surface has its own facets with a single value each.
*
* S38 — the six personalized-card models are synthetic items of the same board
* (`CardCatalogItem`): no row, no file, no publication — each item is an invite
* that routes the visitor to `/cards?model=<id>`. They share the facet/search
* pipeline (Tipo gains `card`; the geographic facets never do) and the honest
* empty state.
*/
import { CARD_MODELS, type CardModel, type CardModelId } from '@/lib/cardModels'
import {
CONTENT_PIECE_TYPES,
contentPieceIsPublic,
@ -151,9 +158,13 @@ const sortedOptions = (options: Map<string, string>): ContentPieceCatalogFacetOp
* Facet options are derived from the published items alone: a filter that
* cannot match anything never renders. Tipo/Tema keep the persisted enum
* order; the label facets are alphabetical by their pt-BR label.
*
* S38 — a card item feeds ONLY the `tipo` facet (`card`): the models are not
* territorial and declare no theme, so Cidade/Região/Instituição/Tema never
* grow from them.
*/
export const contentPieceCatalogFacets = (
items: readonly ContentPiecePublicItem[],
items: readonly ContentCatalogItem[],
): ContentPieceCatalogFacets => {
const types = new Set<string>()
const topics = new Set<string>()
@ -162,6 +173,10 @@ export const contentPieceCatalogFacets = (
const institutions = new Map<string, string>()
for (const item of items) {
if (isCardCatalogItem(item)) {
types.add('card')
continue
}
types.add(item.type)
for (const topic of item.topics) topics.add(topic)
if (item.cityLabel) cities.set(slugify(item.cityLabel), item.cityLabel)
@ -241,15 +256,30 @@ const normalizedSearchTerms = (query: string, themeTerms: readonly string[]): st
return terms
}
/** All active facets combine (AND); the term matches the denormalized haystack. */
/**
* All active facets combine (AND); the term matches the denormalized haystack.
*
* S38 — a card item matches the Tipo facet only as `card` and never matches a
* geographic/theme facet (the models are not territorial and declare no theme);
* the term search runs over its `searchText` (name + aliases + description)
* exactly like a piece's.
*/
export const filterContentPieceCatalogItems = (
items: readonly ContentPiecePublicItem[],
items: readonly ContentCatalogItem[],
params: ContentPieceCatalogParams,
themeTerms: readonly string[] = [],
): ContentPiecePublicItem[] => {
): ContentCatalogItem[] => {
const searchTerms = normalizedSearchTerms(params.q, themeTerms)
return items.filter((item) => {
if (isCardCatalogItem(item)) {
if (params.tipo && params.tipo !== 'card') return false
if (params.cidade || params.regiao || params.tema || params.instituicao) return false
if (searchTerms.length === 0) return true
const haystack = normalizeForSearch(item.searchText)
return searchTerms.some((term) => haystack.includes(term))
}
if (
(params.tipo && item.type !== params.tipo) ||
(params.cidade && (item.cityLabel === null || slugify(item.cityLabel) !== params.cidade)) ||
@ -325,6 +355,79 @@ type ContentPiecePublicFile = {
mimeType: string | null
downloadFilename: string
}
/**
* S38 — the studio deep link of one model. `/cards` already validates the id
* and pre-selects the model (`isCardModelId`), so the item never carries a
* second selection contract.
*/
const cardModelCatalogHref = (modelId: CardModelId): string => `/cards?model=${modelId}`
/**
* S38 — the art of a card item: the model's real file, or the neutral
* placeholder of the photo models (their master is the official frame with a
* transparent photo window, never a filled example).
*/
export type CardCatalogArt =
| { kind: 'image'; src: string }
| { kind: 'placeholder'; shape: 'square' | 'portrait' }
/**
* S38 — one personalized-card model as a catalogue item. Synthetic by
* contract: no row, no file, no publication — `itemKind` marks the card side
* of the board union and the guard below is the only narrowing point.
*/
export type CardCatalogItem = {
itemKind: 'card'
modelId: CardModelId
title: string
description: string
aliases: readonly string[]
badge: string | null
art: CardCatalogArt
href: string
/** Raw haystack (name + aliases + description); normalized at match time. */
searchText: string
}
/** One item of the public board: a published piece or a synthetic card model. */
export type ContentCatalogItem = ContentPiecePublicItem | CardCatalogItem
export const isCardCatalogItem = (item: ContentCatalogItem): item is CardCatalogItem =>
'itemKind' in item
const cardModelArt = (model: CardModel): CardCatalogArt =>
model.kind === 'photo'
? { kind: 'placeholder', shape: model.width === model.height ? 'square' : 'portrait' }
: { kind: 'image', src: model.previewSrc ?? model.assetSrc }
/**
* S38 — the six models as catalogue items, in the committed catalogue order.
* Nothing is written and nothing is published: the item is an invite to the
* studio, never a piece to download or share.
*/
export const cardCatalogItems = (): CardCatalogItem[] =>
CARD_MODELS.map((model) => ({
itemKind: 'card',
modelId: model.id,
title: model.label,
description: model.description,
aliases: model.aliases,
badge: model.badge ?? null,
art: cardModelArt(model),
href: cardModelCatalogHref(model.id),
searchText: [model.label, ...model.aliases, model.description].join(' '),
}))
/**
* S38 — the items of the public board: the published pieces plus the synthetic
* card models. The guardrail lives here: the cards only ride along a non-empty
* Central — with nothing published, the board is the honest empty state (which
* carries the studio path), never a grid of cards.
*/
export const contentCatalogItems = (
items: readonly ContentPiecePublicItem[],
cards: readonly CardCatalogItem[],
): ContentCatalogItem[] => (items.length > 0 ? [...items, ...cards] : [...items])
export type ContentPieceMediaKind = 'video' | 'audio' | 'image' | 'text' | 'other'

View file

@ -4,9 +4,9 @@ import { isCardModelId, type CardModelId } from '@/lib/cardModels'
import { CONTENT_EVENT_TYPES } from '@/lib/contentEvents'
/**
* C213/S32 — the wire contract of the public `POST /api/content-events`.
* C213/S32/S38 — the wire contract of the public `POST /api/content-events`.
*
* Two additive variants, exactly one subject per body (strict objects: an
* Three additive variants, exactly one subject per body (strict objects: an
* extra key is a malformed body, never a silently ignored one):
* - a published piece (`{ type, pieceSlug }`): the client never names the
* internal subject id — the route resolves the slug against the published
@ -14,7 +14,10 @@ import { CONTENT_EVENT_TYPES } from '@/lib/contentEvents'
* - a personalized card download (`{ type: 'download', subjectType: 'card',
* cardModelId, stateDeputySlug? }`): the client names the public catalog keys
* and the route validates both against the committed catalogs (the card model
* and the state deputy), so an arbitrary id or slug writes nothing.
* and the state deputy), so an arbitrary id or slug writes nothing;
* - a card opening (`{ type: 'abertura', subjectType: 'card', cardModelId }`,
* S38): the catalogue item routed the visitor to the studio; the visitor has
* not chosen a state deputy yet, so there is no `stateDeputySlug` here.
*
* A malformed body is refused before any query (and the beacon ignores every
* response anyway).
@ -38,7 +41,14 @@ const cardDownloadEventRequestSchema = z.strictObject({
stateDeputySlug: publicSlug.optional(),
})
const cardOpeningEventRequestSchema = z.strictObject({
type: z.literal('abertura'),
subjectType: z.literal('card'),
cardModelId: z.custom<CardModelId>(isCardModelId),
})
export const contentEventRequestSchema = z.union([
pieceEventRequestSchema,
cardDownloadEventRequestSchema,
cardOpeningEventRequestSchema,
])

View file

@ -1,12 +1,15 @@
import 'server-only'
import {
cardCatalogItems,
contentCatalogItems,
contentPieceCatalogActiveFilters,
contentPieceCatalogFacets,
contentPieceThemeTerms,
filterContentPieceCatalogItems,
parseContentPieceCatalogParams,
toContentPiecePublicItem,
type ContentCatalogItem,
type ContentPieceCatalogActiveFilter,
type ContentPieceCatalogFacets,
type ContentPieceCatalogParams,
@ -32,12 +35,20 @@ import { headers } from 'next/headers'
* something published, and always through the anonymous guard (limit +
* eligibility + cache): the caller renders the discreet notice whenever it
* resolves `null`.
*
* S38 — the same loader appends the six synthetic card items to the board (and
* only there): facets and filter below see one union, so Tipo "Card" and the
* nickname search work without a second pipeline.
*/
export type ContentPieceCatalogSearchData = {
/** Public pieces on the Central before filters: zero renders the honest empty state. */
/**
* Public pieces on the Central before filters: zero renders the honest empty
* state. S38 — cards never count here (they only ride along a non-empty
* Central), so the guardrail of the empty state is this number alone.
*/
publishedCount: number
params: ContentPieceCatalogParams
items: ContentPiecePublicItem[]
items: ContentCatalogItem[]
facets: ContentPieceCatalogFacets
activeFilters: ContentPieceCatalogActiveFilter[]
/** The actor asked for the theme mode (always with a query): the badges state the provenance. */
@ -96,12 +107,17 @@ export const loadContentPieceCatalogSearch = async ({
}
const publicItems = themeTerms.length > 0 ? toPublicItems(records, themeTerms) : publishedItems
const facets = contentPieceCatalogFacets(publicItems)
// S38 — the six card models are synthetic items of the same board, appended
// only while the Central has published pieces (the guard lives in the pure
// builder): an empty Central keeps the honest empty state, never a board of
// cards.
const catalogItems = contentCatalogItems(publicItems, cardCatalogItems())
const facets = contentPieceCatalogFacets(catalogItems)
return {
publishedCount: publicItems.length,
publishedCount: publishedItems.length,
params,
items: filterContentPieceCatalogItems(publicItems, params, themeTerms),
items: filterContentPieceCatalogItems(catalogItems, params, themeTerms),
facets,
activeFilters: contentPieceCatalogActiveFilters(params, facets),
themeMode: themeRequested,

View file

@ -221,7 +221,19 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
).toBeVisible()
await expect(page.getByText('Central de Conteúdos').first()).toBeVisible()
await expect(page.locator('article[data-content-piece]')).toHaveCount(2)
await expect(page.locator('article[data-content-piece-invite]')).toHaveCount(1)
// S38 — the six models are items of the same board: no invite tile and no
// section of their own (the card item and the pieces share one grid).
await expect(page.locator('[data-content-piece-invite]')).toHaveCount(0)
await expect(page.locator('[data-card-model]')).toHaveCount(6)
const board = page.locator('[data-card-model]').first().locator('xpath=..')
await expect(board.locator('article[data-content-piece]')).toHaveCount(2)
await expect(page.locator('[data-card-model="time-de-voce"]')).toHaveAttribute(
'href',
'/cards?model=time-de-voce',
)
await expect(
page.locator('[data-card-model="minha-colinha"]').getByText('santinho'),
).toBeVisible()
// The video mounts no media element and fetches nothing before the play
// (the photo preview is the only media the catalogue loads on its own).
@ -281,8 +293,8 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await waitForSettledPage(page)
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
await expect(page.getByRole('link', { name: videoTitle, exact: true })).toBeVisible()
// A filtered board never shows the card invite.
await expect(page.locator('article[data-content-piece-invite]')).toHaveCount(0)
// A filtered board never shows the card models that the facet excludes.
await expect(page.locator('[data-card-model]')).toHaveCount(0)
await page.goto(`/conteudos?q=${marker}`)
await expect(page.locator('article[data-content-piece]')).toHaveCount(2)
@ -308,6 +320,58 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await expect(page.getByRole('link', { name: 'Voltar à Central' })).toBeVisible()
})
test('lists the card models by nickname, filters by Tipo Card and routes to the studio (S38)', async ({
page,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
await unpublishEveryPiece(request, headers)
await createPiece(request, headers, {
title: `Peça do catálogo ${uniqueMarker()}`,
type: 'video',
})
// The nickname search finds the models that answer to it (no JS: querystring).
await page.goto('/conteudos?q=santinho')
await waitForSettledPage(page)
await expect(page.locator('[data-card-model]')).toHaveCount(2)
await expect(page.locator('[data-card-model="time-de-voce"]')).toBeVisible()
await expect(page.locator('[data-card-model="minha-colinha"]')).toBeVisible()
await page.goto('/conteudos?q=foto+de+perfil')
await waitForSettledPage(page)
await expect(page.locator('[data-card-model]')).toHaveCount(2)
await expect(page.locator('[data-card-model="perfil-quadrado"]')).toBeVisible()
await expect(page.locator('[data-card-model="perfil-retangular"]')).toBeVisible()
// Tipo Card brings the six models and the published piece leaves the board.
await page.goto('/conteudos?tipo=card')
await waitForSettledPage(page)
await expect(page.locator('[data-card-model]')).toHaveCount(6)
await expect(page.locator('article[data-content-piece]')).toHaveCount(0)
// A geographic facet removes the models: they are not territorial.
await page.goto('/conteudos?cidade=salvador')
await waitForSettledPage(page)
await expect(page.locator('[data-card-model]')).toHaveCount(0)
// The item routes to the studio with the model chosen and counts the opening
// through the anonymous beacon (never a piece event).
const openingBeacon = page.waitForRequest((candidate) => {
if (!candidate.url().includes('/api/content-events')) return false
try {
const body = candidate.postDataJSON() as { type?: string; cardModelId?: string } | null
return body?.type === 'abertura' && body?.cardModelId === 'minha-colinha'
} catch {
return false
}
})
await page.locator('[data-card-model="minha-colinha"]').click()
await expect(page).toHaveURL(/\/cards\?model=minha-colinha$/)
await openingBeacon
await expect(page.getByRole('dialog')).toBeVisible()
})
test('offers the theme mode and degrades honestly without the provider key', async ({
page,
request,
@ -512,6 +576,7 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await waitForSettledPage(page)
await expect(page.getByRole('link', { name: 'Conteúdos' })).toBeVisible()
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
await expect(page.locator('[data-card-model]')).toHaveCount(6)
await setPieceStatus(request, headers, piece.id, 'rascunho')
@ -521,6 +586,8 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
page.getByRole('heading', { name: 'As primeiras peças estão a caminho' }),
).toBeVisible()
await expect(page.locator('article[data-content-piece]')).toHaveCount(0)
// S38 — an empty Central is the honest empty state, never a board of cards.
await expect(page.locator('[data-card-model]')).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Conteúdos' })).toHaveCount(0)
await expect
.poll(async () =>

View file

@ -452,10 +452,16 @@ describe('content events (C213)', () => {
})
).status,
).toBe(400)
// The card only counts the download in v1.
// S38 — the opening never carries a state deputy (strict body).
expect(
(await postEvent({ type: 'abertura', subjectType: 'card', cardModelId: 'eu-sou-solla' }))
.status,
(
await postEvent({
type: 'abertura',
subjectType: 'card',
cardModelId: 'minha-colinha',
stateDeputySlug: 'julio',
})
).status,
).toBe(400)
// The piece contract is untouched by the card variant.
expect((await postEvent({ type: 'abertura', pieceSlug: 'peca-qualquer' })).status).toBe(204)
@ -465,6 +471,38 @@ describe('content events (C213)', () => {
).toHaveLength(0)
})
it('records a card opening with the model id and no state deputy (S38)', async () => {
const accepted = await postEvent({
type: 'abertura',
subjectType: 'card',
cardModelId: 'time-de-voce',
})
expect(accepted.status).toBe(204)
expect(accepted.headers.get('Cache-Control')).toBe('no-store')
const rows = await eventRowsOf(['time-de-voce'])
expect(rows.docs).toHaveLength(1)
expect(rows.docs[0]).toMatchObject({
type: 'abertura',
subjectType: 'card',
subjectId: 'time-de-voce',
variant: null,
})
})
it('refuses an unknown model id on the opening too (S38)', async () => {
expect(
(
await postEvent({
type: 'abertura',
subjectType: 'card',
cardModelId: 'modelo-inventado',
})
).status,
).toBe(400)
expect((await eventRowsOf(['modelo-inventado'])).docs).toHaveLength(0)
})
it('aggregates the card counters through the same reader, apart from the pieces', async () => {
await payload.create({
collection: 'contentEvent',

View file

@ -38,6 +38,11 @@ import {
} from '@/app/(campaign)/campanha/actions/contentPieces'
import { GET as getPublicPieceMedia } from '@/app/(frontend)/conteudos/[slug]/midia/route'
import type { ContentPieceCuratedField } from '@/lib/contentPiece'
import {
isCardCatalogItem,
type ContentCatalogItem,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
@ -252,6 +257,13 @@ const withTempDir = async <T>(run: (tempDir: string) => Promise<T>): Promise<T>
}
}
/**
* S38 — the loader returns pieces plus the synthetic card items; the piece
* assertions narrow here (the card side has no id/themeMatch by contract).
*/
const pieceRows = (rows: readonly ContentCatalogItem[]): ContentPiecePublicItem[] =>
rows.filter((row): row is ContentPiecePublicItem => !isCardCatalogItem(row))
describe('content pieces (C211)', () => {
let communicator: CampaignUser
let coordinator: CampaignUser
@ -1345,7 +1357,7 @@ describe('content pieces (C211)', () => {
expect(expansionCalls).toEqual(['defesa do SUS'])
expect(data.themeUnavailable).toBe(false)
expect(data.themeApplied).toBe(true)
const item = data.items.find((row) => row.id === match.piece.id)
const item = pieceRows(data.items).find((row) => row.id === match.piece.id)
expect(item?.themeMatch?.term).toBe(term)
expect(item?.themeMatch?.evidence?.quoted).toBe(true)
expect(item?.themeMatch?.evidence?.parts.some((part) => part.highlighted)).toBe(true)
@ -1363,7 +1375,7 @@ describe('content pieces (C211)', () => {
expect(data.themeUnavailable).toBe(true)
expect(data.themeApplied).toBe(false)
const item = data.items.find((row) => row.id === match.piece.id)
const item = pieceRows(data.items).find((row) => row.id === match.piece.id)
expect(item).toBeTruthy()
expect(item?.themeMatch).toBeNull()
})
@ -1380,8 +1392,8 @@ describe('content pieces (C211)', () => {
expect(data.themeUnavailable).toBe(false)
expect(data.themeApplied).toBe(false)
expect(data.items.map((row) => row.id)).toEqual([match.piece.id])
expect(data.items.every((row) => row.themeMatch === null)).toBe(true)
expect(pieceRows(data.items).map((row) => row.id)).toEqual([match.piece.id])
expect(pieceRows(data.items).every((row) => row.themeMatch === null)).toBe(true)
})
it('never expands without navigation signals (S28)', async () => {
@ -1419,7 +1431,7 @@ describe('content pieces (C211)', () => {
expect(called).toBe(false)
expect(data.params.mode).toBeNull()
expect(data.themeUnavailable).toBe(false)
expect(data.items.map((row) => row.id)).toEqual([match.piece.id])
expect(pieceRows(data.items).map((row) => row.id)).toEqual([match.piece.id])
})
it('never surfaces a draft in the theme search (S28)', async () => {
@ -1441,8 +1453,51 @@ describe('content pieces (C211)', () => {
expandTheme: async () => ({ terms: [term] }),
})
const ids = data.items.map((row) => row.id)
const ids = pieceRows(data.items).map((row) => row.id)
expect(ids).toContain(published.piece.id)
expect(ids).not.toContain(draft.piece.id)
})
it('appends the six card items to a non-empty Central (S38)', async () => {
const marker = `cards-${Date.now()}`
const match = await createPiece({ title: `Peça ${marker}`, status: 'publicado' })
const data = await loadContentPieceCatalogSearch({ rawSearchParams: {} })
// `publishedCount` still counts pieces only: with no filters the board is
// every published piece plus the six cards — never cards alone.
const pieces = pieceRows(data.items)
expect(data.publishedCount).toBe(pieces.length)
expect(pieces.map((row) => row.id)).toContain(match.piece.id)
const cards = data.items.filter(isCardCatalogItem)
expect(cards.map((card) => card.modelId)).toEqual([
'eu-sou-solla',
'perfil-quadrado',
'perfil-retangular',
'time-de-voce',
'time-do-estadual',
'minha-colinha',
])
expect(cards.every((card) => card.href.startsWith('/cards?model='))).toBe(true)
expect(data.facets.tipo).toContainEqual({ value: 'card', label: 'Card' })
})
it('finds the card models by nickname and keeps them out of the geographic facets (S38)', async () => {
const marker = `cards-busca-${Date.now()}`
await createPiece({ title: `Peça ${marker}`, status: 'publicado' })
const santinho = await loadContentPieceCatalogSearch({ rawSearchParams: { q: 'santinho' } })
expect(santinho.items.filter(isCardCatalogItem).map((card) => card.modelId)).toEqual([
'time-de-voce',
'minha-colinha',
])
const geo = await loadContentPieceCatalogSearch({
rawSearchParams: { q: 'santinho', cidade: 'salvador' },
})
expect(geo.items.filter(isCardCatalogItem)).toEqual([])
const byType = await loadContentPieceCatalogSearch({ rawSearchParams: { tipo: 'card' } })
expect(byType.items.filter(isCardCatalogItem)).toHaveLength(6)
})
})

View file

@ -23,6 +23,43 @@ describe('card model catalog (S13/S15)', () => {
expect(new Set(CARD_MODELS.map((model) => model.id)).size).toBe(CARD_MODELS.length)
})
it('pins the S38 catalogue copy: description and search nicknames per model', () => {
expect(
CARD_MODELS.map((model) => ({
id: model.id,
description: model.description,
aliases: [...model.aliases],
})),
).toEqual([
{ id: 'eu-sou-solla', description: 'Coloque seu nome no card oficial.', aliases: ['nome'] },
{
id: 'perfil-quadrado',
description: 'Foto de perfil quadrada.',
aliases: ['foto de perfil'],
},
{
id: 'perfil-retangular',
description: 'Foto vertical para stories.',
aliases: ['foto de perfil'],
},
{
id: 'time-de-voce',
description: 'Entre para o time com sua foto.',
aliases: ['santinho'],
},
{
id: 'time-do-estadual',
description: 'Escolha o estadual e personalize.',
aliases: ['estadual'],
},
{
id: 'minha-colinha',
description: 'Monte sua cola de votação.',
aliases: ['santinho', 'colinha'],
},
])
})
it('serves every master from the local /cards asset folder', () => {
for (const model of CARD_MODELS) {
expect(model.assetSrc.startsWith('/cards/')).toBe(true)

View file

@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
CONTENT_EVENT_ENDPOINT,
sendCardDownloadEvent,
sendCardOpeningEvent,
sendContentPieceEvent,
} from '@/lib/contentEvents'
@ -146,3 +147,34 @@ describe('sendCardDownloadEvent', () => {
expect(() => sendCardDownloadEvent('time-do-estadual', 'julio')).not.toThrow()
})
})
describe('sendCardOpeningEvent (S38)', () => {
it('carries the model id only, as the opening of a card', async () => {
const sendBeacon = vi.fn().mockReturnValue(true)
vi.stubGlobal('navigator', { sendBeacon })
sendCardOpeningEvent('time-de-voce')
expect(sendBeacon).toHaveBeenCalledTimes(1)
const [endpoint, blob] = sendBeacon.mock.calls[0]!
expect(endpoint).toBe(CONTENT_EVENT_ENDPOINT)
await expect((blob as Blob).text()).resolves.toBe(
JSON.stringify({ type: 'abertura', subjectType: 'card', cardModelId: 'time-de-voce' }),
)
})
it('never throws when the beacon and the fetch both fail', async () => {
vi.stubGlobal('navigator', {
sendBeacon: vi.fn().mockImplementation(() => {
throw new Error('beacon exploded')
}),
})
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
expect(() => sendCardOpeningEvent('minha-colinha')).not.toThrow()
await Promise.resolve()
vi.stubGlobal('fetch', undefined)
expect(() => sendCardOpeningEvent('minha-colinha')).not.toThrow()
})
})

View file

@ -4,6 +4,8 @@ import { describe, expect, it } from 'vitest'
import {
buildContentPieceCatalogHref,
cardCatalogItems,
contentCatalogItems,
contentPieceCatalogActiveFilters,
contentPieceCatalogFacets,
contentPieceDownloadFilename,
@ -14,8 +16,11 @@ import {
contentPieceThemeMatch,
contentPieceThemeTerms,
filterContentPieceCatalogItems,
isCardCatalogItem,
parseContentPieceCatalogParams,
toContentPiecePublicItem,
type CardCatalogItem,
type ContentCatalogItem,
type ContentPiecePublicItem,
type ContentPiecePublicSource,
} from '@/lib/contentPieceCatalog'
@ -44,6 +49,12 @@ const item = (patch: Partial<ContentPiecePublicSource> = {}): ContentPiecePublic
return resolved
}
const pieceRows = (rows: readonly ContentCatalogItem[]): ContentPiecePublicItem[] =>
rows.filter((row): row is ContentPiecePublicItem => !isCardCatalogItem(row))
const cardRows = (rows: readonly ContentCatalogItem[]): CardCatalogItem[] =>
rows.filter((row): row is CardCatalogItem => isCardCatalogItem(row))
describe('content piece catalog params', () => {
it('parses one valid value per facet and drops unknown values', () => {
expect(
@ -185,7 +196,9 @@ describe('content piece catalog filtering', () => {
]
const idsFor = (raw: Record<string, string>) =>
filterContentPieceCatalogItems(items, parseContentPieceCatalogParams(raw)).map((row) => row.id)
pieceRows(filterContentPieceCatalogItems(items, parseContentPieceCatalogParams(raw))).map(
(row) => row.id,
)
it('combines facets and the accent-insensitive term', () => {
expect(idsFor({})).toEqual([1, 2, 3])
@ -200,9 +213,9 @@ describe('content piece catalog filtering', () => {
it('ORs the literal query with the expanded theme terms, facets still AND', () => {
const idsForTerms = (raw: Record<string, string>, terms: string[]) =>
filterContentPieceCatalogItems(items, parseContentPieceCatalogParams(raw), terms).map(
(row) => row.id,
)
pieceRows(
filterContentPieceCatalogItems(items, parseContentPieceCatalogParams(raw), terms),
).map((row) => row.id)
// No terms is byte-identical to the literal search.
expect(idsForTerms({ q: 'escala' }, [])).toEqual([1])
@ -213,6 +226,97 @@ describe('content piece catalog filtering', () => {
expect(idsForTerms({ q: 'zzz', tipo: 'video' }, ['card'])).toEqual([])
expect(idsForTerms({ q: 'zzz' }, ['ESCALA'])).toEqual([1])
})
it('mixes the card items into the same facet and order (S38)', () => {
const rows = filterContentPieceCatalogItems(
[...items, ...cardCatalogItems()],
parseContentPieceCatalogParams({ tipo: 'card' }),
)
expect(rows.map((row) => (isCardCatalogItem(row) ? row.modelId : row.slug))).toEqual([
'card-feira',
...cardCatalogItems().map((card) => card.modelId),
])
expect(contentPieceCatalogFacets([...items, ...cardCatalogItems()]).tipo).toEqual([
{ value: 'video', label: 'Vídeo' },
{ value: 'card', label: 'Card' },
])
})
})
describe('card models as catalogue items (S38)', () => {
const cards = cardCatalogItems()
const modelIdsFor = (raw: Record<string, string>) =>
cardRows(filterContentPieceCatalogItems(cards, parseContentPieceCatalogParams(raw))).map(
(card) => card.modelId,
)
it('builds one item per model with the studio deep link and the model copy', () => {
expect(cards.map((card) => card.modelId)).toEqual([
'eu-sou-solla',
'perfil-quadrado',
'perfil-retangular',
'time-de-voce',
'time-do-estadual',
'minha-colinha',
])
expect(cards.every((card) => card.href === `/cards?model=${card.modelId}`)).toBe(true)
expect(cards.find((card) => card.modelId === 'minha-colinha')?.badge).toBe('NOVO')
expect(cards.every((card) => card.badge === null || card.modelId === 'minha-colinha')).toBe(
true,
)
})
it('carries the real art, or the neutral placeholder of the photo models', () => {
const artOf = (modelId: string) => cards.find((card) => card.modelId === modelId)?.art
expect(artOf('time-de-voce')).toEqual({ kind: 'image', src: '/cards/team-card-example.jpg' })
expect(artOf('eu-sou-solla')).toEqual({ kind: 'image', src: '/cards/name-card-base.jpg' })
expect(artOf('perfil-quadrado')).toEqual({ kind: 'placeholder', shape: 'square' })
expect(artOf('perfil-retangular')).toEqual({ kind: 'placeholder', shape: 'portrait' })
})
it('finds the models by nickname, by name and by description', () => {
expect(modelIdsFor({ q: 'santinho' })).toEqual(['time-de-voce', 'minha-colinha'])
expect(modelIdsFor({ q: 'foto de perfil' })).toEqual(['perfil-quadrado', 'perfil-retangular'])
expect(modelIdsFor({ q: 'colinha' })).toEqual(['minha-colinha'])
expect(modelIdsFor({ q: 'estadual' })).toEqual(['time-do-estadual'])
expect(modelIdsFor({ q: 'Moldura quadrada' })).toEqual(['perfil-quadrado'])
expect(modelIdsFor({ q: 'cola de votação' })).toEqual(['minha-colinha'])
})
it('matches the Tipo facet only as card and never a geographic/theme facet', () => {
expect(modelIdsFor({ tipo: 'card' })).toHaveLength(6)
expect(modelIdsFor({ tipo: 'video' })).toEqual([])
expect(modelIdsFor({ cidade: 'salvador' })).toEqual([])
expect(modelIdsFor({ regiao: 'metropolitano-de-salvador' })).toEqual([])
expect(modelIdsFor({ instituicao: 'camara-dos-deputados' })).toEqual([])
expect(modelIdsFor({ tema: 'saude' })).toEqual([])
})
it('feeds only the Tipo facet, never the geographic ones', () => {
const facets = contentPieceCatalogFacets(cards)
expect(facets.tipo).toEqual([{ value: 'card', label: 'Card' }])
expect(facets.cidade).toEqual([])
expect(facets.regiao).toEqual([])
expect(facets.instituicao).toEqual([])
expect(facets.tema).toEqual([])
})
it('appends the cards only to a non-empty Central', () => {
expect(contentCatalogItems([], cards)).toEqual([])
expect(contentCatalogItems([item()], cards)).toHaveLength(7)
expect(contentCatalogItems([item()], cards).map((row) => isCardCatalogItem(row))).toEqual([
false,
true,
true,
true,
true,
true,
true,
])
})
})
describe('content piece theme terms and provenance (S28)', () => {