diff --git a/docs/changelog/2026-09-24-s38.md b/docs/changelog/2026-09-24-s38.md new file mode 100644 index 00000000..38befc2c --- /dev/null +++ b/docs/changelog/2026-09-24-s38.md @@ -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=`, 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. diff --git a/docs/plans/central-conteudos-cards-no-catalogo-impl.md b/docs/plans/central-conteudos-cards-no-catalogo-impl.md new file mode 100644 index 00000000..60947b1d --- /dev/null +++ b/docs/plans/central-conteudos-cards-no-catalogo-impl.md @@ -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=` 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 `` 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 `` ú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:` 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=` (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 `` para card e `` para peça; keys por `card:`/`item.id`. +- **`ContentCatalogCardItem`** (novo, `src/components/conteudos/`): `` ú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=`; 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=` 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. diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 2dd24636..30a95e05 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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', diff --git a/src/app/(frontend)/api/content-events/route.ts b/src/app/(frontend)/api/content-events/route.ts index 0b766962..8b5efe6c 100644 --- a/src/app/(frontend)/api/content-events/route.ts +++ b/src/app/(frontend)/api/content-events/route.ts @@ -96,6 +96,17 @@ export const POST = async (request: Request): Promise => { 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) { diff --git a/src/app/(frontend)/conteudos/(catalog)/page.tsx b/src/app/(frontend)/conteudos/(catalog)/page.tsx index 6030d3f9..07308c99 100644 --- a/src/app/(frontend)/conteudos/(catalog)/page.tsx +++ b/src/app/(frontend)/conteudos/(catalog)/page.tsx @@ -105,8 +105,6 @@ export default async function ConteudosPage({ ) } - const hasActiveFilters = activeFilters.length > 0 - return ( <> @@ -150,11 +148,7 @@ export default async function ConteudosPage({
{items.length > 0 ? ( - + ) : themeMode && !themeUnavailable ? ( ) : ( diff --git a/src/components/conteudos/ContentCatalogCardItem.tsx b/src/components/conteudos/ContentCatalogCardItem.tsx new file mode 100644 index 00000000..167753d3 --- /dev/null +++ b/src/components/conteudos/ContentCatalogCardItem.tsx @@ -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 }) => ( + + {item.art.kind === 'placeholder' ? ( + + ) : ( + + )} + + {item.badge ? ( + + {item.badge} + + ) : null} + +) + +/** + * 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 }) => ( + 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, + )} + > + + +
+ {CARD_TAG_LABEL} +

+ {item.title} +

+

{item.description}

+ {item.aliases.length > 0 ? ( +
+ {item.aliases.map((alias) => ( + + {alias} + + ))} +
+ ) : null} + + Escolher → +
+ +) diff --git a/src/components/conteudos/ContentPieceCardInvite.tsx b/src/components/conteudos/ContentPieceCardInvite.tsx deleted file mode 100644 index ac428a82..00000000 --- a/src/components/conteudos/ContentPieceCardInvite.tsx +++ /dev/null @@ -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 = () => ( -
-
-
- 1313 Deputado Federal -

SEU NOME + SUA FOTO

-
-
-
- Card personalizado -

- Faça seu card com Solla 1313 -

-

Criado no seu aparelho.

- - Fazer meu card - -
-
-) diff --git a/src/components/conteudos/ContentPieceCatalog.tsx b/src/components/conteudos/ContentPieceCatalog.tsx index 2d00652f..c4bcd619 100644 --- a/src/components/conteudos/ContentPieceCatalog.tsx +++ b/src/components/conteudos/ContentPieceCatalog.tsx @@ -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=`); 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(null) const [sharingItem, setSharingItem] = useState(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 ? (
- {largeItems.map((item) => ( - - ))} - {showCardInvite ? : null} + {largeItems.map((item) => + isCardCatalogItem(item) ? ( + + ) : ( + + ), + )}
) : null} diff --git a/src/lib/cardModels.ts b/src/lib/cardModels.ts index 87579e4d..d7142ad8 100644 --- a/src/lib/cardModels.ts +++ b/src/lib/cardModels.ts @@ -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`. diff --git a/src/lib/contentEvents.ts b/src/lib/contentEvents.ts index b60601e5..749cba73 100644 --- a/src/lib/contentEvents.ts +++ b/src/lib/contentEvents.ts @@ -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 = { - 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)) +} diff --git a/src/lib/contentPieceCatalog.ts b/src/lib/contentPieceCatalog.ts index 07a55d4f..e2d967b7 100644 --- a/src/lib/contentPieceCatalog.ts +++ b/src/lib/contentPieceCatalog.ts @@ -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=`. 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): 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() const topics = new Set() @@ -162,6 +173,10 @@ export const contentPieceCatalogFacets = ( const institutions = new Map() 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' diff --git a/src/lib/schemas/contentEvent.ts b/src/lib/schemas/contentEvent.ts index 79dcedf7..df92b649 100644 --- a/src/lib/schemas/contentEvent.ts +++ b/src/lib/schemas/contentEvent.ts @@ -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(isCardModelId), +}) + export const contentEventRequestSchema = z.union([ pieceEventRequestSchema, cardDownloadEventRequestSchema, + cardOpeningEventRequestSchema, ]) diff --git a/src/utilities/content/contentPieceThemeSearch.ts b/src/utilities/content/contentPieceThemeSearch.ts index 4b3a2eac..1f765284 100644 --- a/src/utilities/content/contentPieceThemeSearch.ts +++ b/src/utilities/content/contentPieceThemeSearch.ts @@ -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, diff --git a/tests/e2e/frontendConteudos.e2e.spec.ts b/tests/e2e/frontendConteudos.e2e.spec.ts index cf1e504d..375ce46c 100644 --- a/tests/e2e/frontendConteudos.e2e.spec.ts +++ b/tests/e2e/frontendConteudos.e2e.spec.ts @@ -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 () => diff --git a/tests/int/contentEvents.int.spec.ts b/tests/int/contentEvents.int.spec.ts index 1941d40b..c6643104 100644 --- a/tests/int/contentEvents.int.spec.ts +++ b/tests/int/contentEvents.int.spec.ts @@ -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', diff --git a/tests/int/contentPiece.int.spec.ts b/tests/int/contentPiece.int.spec.ts index fb944df2..b4f1c96b 100644 --- a/tests/int/contentPiece.int.spec.ts +++ b/tests/int/contentPiece.int.spec.ts @@ -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 (run: (tempDir: string) => Promise): Promise } } +/** + * 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) + }) }) diff --git a/tests/unit/cardModels.unit.spec.ts b/tests/unit/cardModels.unit.spec.ts index da4448c7..b41b7f67 100644 --- a/tests/unit/cardModels.unit.spec.ts +++ b/tests/unit/cardModels.unit.spec.ts @@ -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) diff --git a/tests/unit/contentEvents.unit.spec.ts b/tests/unit/contentEvents.unit.spec.ts index 4d86c4d0..8bc6c72c 100644 --- a/tests/unit/contentEvents.unit.spec.ts +++ b/tests/unit/contentEvents.unit.spec.ts @@ -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() + }) +}) diff --git a/tests/unit/contentPieceCatalog.unit.spec.ts b/tests/unit/contentPieceCatalog.unit.spec.ts index 16982e14..4f226eb4 100644 --- a/tests/unit/contentPieceCatalog.unit.spec.ts +++ b/tests/unit/contentPieceCatalog.unit.spec.ts @@ -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 = {}): 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) => - 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, 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) => + 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)', () => {