From 7f893e749a15592fd32ae4de13c8c935be1fd647 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 09:59:12 -0300 Subject: [PATCH] =?UTF-8?q?feat(S38):=20alterna=20pe=C3=A7as=20e=20cards?= =?UTF-8?q?=20no=20board=20e=20certifica=20o=20design=20(Closes=20#1304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-24-s38.md | 2 +- ...entral-conteudos-cards-no-catalogo-impl.md | 6 ++++++ .../conteudos/ContentPieceCatalog.tsx | 12 ++++++++--- src/lib/contentPieceCatalog.ts | 20 ++++++++++++++++++ tests/unit/contentPieceCatalog.unit.spec.ts | 21 +++++++++++++++++++ 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/changelog/2026-09-24-s38.md b/docs/changelog/2026-09-24-s38.md index 38befc2c..8e26ba95 100644 --- a/docs/changelog/2026-09-24-s38.md +++ b/docs/changelog/2026-09-24-s38.md @@ -1 +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. +**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" — "Card · NOVO" no celular do modelo com selo, com o pill NOVO sobre a arte só no desktop —, chips de apelido, CTA "Escolher este modelo →" no desktop / "Escolher →" no mobile) e a sequência do grid alterna peças e modelos (`interleaveCatalogItems`, `peça → card → peça`, sem bloco/seção de cards; o bucket compacto de foto/texto segue como no S27); 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/alternância, `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 (conjunto curado em modo prod, espelho do CI). Crítica final do `designer` (trigger c): reprovou a primeira passada (bloco contínuo de cards lido como seção; print mobile de build pré-fix) e certificou após os ajustes — a alternância `peça → card → peça`, o selo responsivo e as capturas de "foto de perfil"/peças compactas — em tier primário (`Design tier: gpt-5.6-sol`). diff --git a/docs/plans/central-conteudos-cards-no-catalogo-impl.md b/docs/plans/central-conteudos-cards-no-catalogo-impl.md index f9993bd5..5b5d9365 100644 --- a/docs/plans/central-conteudos-cards-no-catalogo-impl.md +++ b/docs/plans/central-conteudos-cards-no-catalogo-impl.md @@ -132,6 +132,12 @@ Não aplicável — a intenção decidiu que o item não apresenta número/conta - **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). +### Débitos deferidos (triagem do simplify, 2026-09-24) + +- **Copy do catálogo só fala em "peça"** (`ContentPieceStates.tsx:48,51` no-results; `ContentPieceFilters.tsx:68` placeholder) — o artefato se contradiz (cena 02 "peça ou card" × cenas 01/03/04 "peça"); gatilho: próxima passagem de copy/design do catálogo (S39) ou decisão do `designer` sobre a cena 02. +- **Helper `pieceRows` duplicado** em `tests/int/contentPiece.int.spec.ts` + `tests/unit/contentPieceCatalog.unit.spec.ts` — gatilho: 3ª suíte/camada precisar do narrowing de `ContentCatalogItem`. +- **Corpo da abertura declarado 2×** (tipo do cliente em `contentEvents.ts` + zod em `schemas/contentEvent.ts`), como o gêmeo do download S32 — gatilho: 4ª variante do beacon ou divergência sender↔schema pega por teste. + ## 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: unit do builder puro (zero peças ⇒ nenhum card) + pin int (`publishedCount === pieceRows(items).length` com cards no board) + e2e do kill switch (zero publicadas ⇒ 0 cards). diff --git a/src/components/conteudos/ContentPieceCatalog.tsx b/src/components/conteudos/ContentPieceCatalog.tsx index 77fc26f6..04a386e1 100644 --- a/src/components/conteudos/ContentPieceCatalog.tsx +++ b/src/components/conteudos/ContentPieceCatalog.tsx @@ -4,7 +4,9 @@ import { useState } from 'react' import { contentPieceMediaKind, + interleaveCatalogItems, isCardCatalogItem, + type CardCatalogItem, type ContentCatalogItem, type ContentPiecePublicItem, } from '@/lib/contentPieceCatalog' @@ -34,17 +36,21 @@ export const ContentPieceCatalog = ({ const [playingId, setPlayingId] = useState(null) const [sharingItem, setSharingItem] = useState(null) - const largeItems: ContentCatalogItem[] = [] + const largePieces: ContentPiecePublicItem[] = [] const compactItems: ContentPiecePublicItem[] = [] + const cards: CardCatalogItem[] = [] for (const item of items) { if (isCardCatalogItem(item)) { - largeItems.push(item) + cards.push(item) continue } const kind = contentPieceMediaKind(item) - if (kind === null || kind === 'video' || kind === 'audio') largeItems.push(item) + if (kind === null || kind === 'video' || kind === 'audio') largePieces.push(item) else compactItems.push(item) } + // S38 — the models alternate with the full-card pieces (never a block of + // their own); the compact rows keep the S27 board below. + const largeItems = interleaveCatalogItems(largePieces, cards) const cardProps = (item: ContentPiecePublicItem) => ({ item, diff --git a/src/lib/contentPieceCatalog.ts b/src/lib/contentPieceCatalog.ts index 8a61e729..ffc50f82 100644 --- a/src/lib/contentPieceCatalog.ts +++ b/src/lib/contentPieceCatalog.ts @@ -431,6 +431,26 @@ export const contentCatalogItems = ( cards: readonly CardCatalogItem[], ): ContentCatalogItem[] => (publishedCount > 0 ? [...pieces, ...cards] : [...pieces]) +/** + * S38 — the full-card sequence of the board: the pieces and the card models + * alternate (`peça → card → peça`, artefato cenas 01/02), so the models never + * read as a section of their own. The remainder of whichever side is longer + * lands at the end, in its own committed order. + */ +export const interleaveCatalogItems = ( + pieces: readonly ContentPiecePublicItem[], + cards: readonly CardCatalogItem[], +): ContentCatalogItem[] => { + const items: ContentCatalogItem[] = [] + for (let index = 0; index < Math.max(pieces.length, cards.length); index += 1) { + const piece = pieces[index] + if (piece) items.push(piece) + const card = cards[index] + if (card) items.push(card) + } + return items +} + export type ContentPieceMediaKind = 'video' | 'audio' | 'image' | 'text' | 'other' /** S28 — where the "Por que apareceu" passage came from (label of the fallback). */ diff --git a/tests/unit/contentPieceCatalog.unit.spec.ts b/tests/unit/contentPieceCatalog.unit.spec.ts index fa4f7c9e..9b63bcd2 100644 --- a/tests/unit/contentPieceCatalog.unit.spec.ts +++ b/tests/unit/contentPieceCatalog.unit.spec.ts @@ -16,6 +16,7 @@ import { contentPieceThemeMatch, contentPieceThemeTerms, filterContentPieceCatalogItems, + interleaveCatalogItems, isCardCatalogItem, parseContentPieceCatalogParams, toContentPiecePublicItem, @@ -317,6 +318,26 @@ describe('card models as catalogue items (S38)', () => { true, ]) }) + + it('alternates pieces and models, never a block of cards', () => { + const label = (row: ContentCatalogItem) => (isCardCatalogItem(row) ? row.modelId : row.id) + const first = item({ id: 1 }) + const second = item({ id: 2, slug: 'outra-peca' }) + + expect(interleaveCatalogItems([first, second], cards.slice(0, 2)).map(label)).toEqual([ + 1, + 'eu-sou-solla', + 2, + 'perfil-quadrado', + ]) + // The remainder of the longer side keeps its own committed order. + expect(interleaveCatalogItems([first], cards.slice(0, 2)).map(label)).toEqual([ + 1, + 'eu-sou-solla', + 'perfil-quadrado', + ]) + expect(interleaveCatalogItems([first, second], []).map(label)).toEqual([1, 2]) + }) }) describe('content piece theme terms and provenance (S28)', () => {