mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(S38): cards personalizáveis como itens do catálogo (Closes #1304)
This commit is contained in:
parent
02ca466469
commit
da3d9bf338
19 changed files with 851 additions and 98 deletions
1
docs/changelog/2026-09-24-s38.md
Normal file
1
docs/changelog/2026-09-24-s38.md
Normal 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.
|
||||
160
docs/plans/central-conteudos-cards-no-catalogo-impl.md
Normal file
160
docs/plans/central-conteudos-cards-no-catalogo-impl.md
Normal 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.
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
|
|||
107
src/components/conteudos/ContentCatalogCardItem.tsx
Normal file
107
src/components/conteudos/ContentCatalogCardItem.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () =>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue