From e7055ae33dc26ce8a5e125e7d5899873676ec1fd Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 03:22:44 -0300 Subject: [PATCH] refactor(S38): reusa o label do dono, fidelidade ao artefato e simplifica o filtro (Related #1304) --- ...entral-conteudos-cards-no-catalogo-impl.md | 8 ++--- .../conteudos/ContentCatalogCardItem.tsx | 13 ++++--- .../conteudos/ContentPieceCatalog.tsx | 2 +- src/lib/contentPieceCatalog.ts | 34 ++++++++++--------- src/utilities/content/contentEventWrite.ts | 4 +-- .../content/contentPieceThemeSearch.ts | 2 +- tests/e2e/frontendConteudos.e2e.spec.ts | 5 ++- tests/unit/contentPieceCatalog.unit.spec.ts | 6 ++-- 8 files changed, 39 insertions(+), 35 deletions(-) diff --git a/docs/plans/central-conteudos-cards-no-catalogo-impl.md b/docs/plans/central-conteudos-cards-no-catalogo-impl.md index 60947b1d..f9993bd5 100644 --- a/docs/plans/central-conteudos-cards-no-catalogo-impl.md +++ b/docs/plans/central-conteudos-cards-no-catalogo-impl.md @@ -95,11 +95,11 @@ Literais do design (verbatim): ### Componentes / mudanças - **`CardModel`** (`src/lib/cardModels.ts:42-74`): `description: string` e `aliases: readonly string[]` obrigatórios (doc: copy/apelidos do item no catálogo); `CARD_MODELS` (`:76-148`) preenchido com a tabela do D2; pin novo em `tests/unit/cardModels.unit.spec.ts`. -- **`CardCatalogItem` / `ContentCatalogItem` / `isCardCatalogItem`** (`src/lib/contentPieceCatalog.ts`, junto de `ContentPiecePublicItem:291-320`): `{ itemKind:'card'; modelId: CardModelId; title; description; aliases; badge: string|null; kind: CardModelKind; artSrc: string | null; href; searchText }` — `searchText` cru (`[label, ...aliases, description].join(' ')`), normalizado no match como na peça; união `ContentPiecePublicItem | CardCatalogItem`. -- **`cardCatalogItems()`** (novo, puro, mesmo módulo): mapeia `CARD_MODELS`; `artSrc = kind === 'photo' ? null : (previewSrc ?? assetSrc)`; `href = /cards?model=` (deep link já validado em `src/app/(frontend)/(home)/cards/page.tsx:26-27`). +- **`CardCatalogItem` / `ContentCatalogItem` / `isCardCatalogItem`** (`src/lib/contentPieceCatalog.ts`, junto de `ContentPiecePublicItem:291-320`): `{ itemKind:'card'; modelId: CardModelId; title; description; aliases; badge: string|null; art: CardCatalogArt; href; searchText }`, com `CardCatalogArt = { kind:'image'; src } | { kind:'placeholder'; shape:'square'|'portrait' }` — `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`; `art = kind === 'photo' ? placeholder(shape pela orientação do master) : image(previewSrc ?? assetSrc)`; `href = /cards?model=` (deep link já validado em `src/app/(frontend)/(home)/cards/page.tsx:26-27`). - **`filterContentPieceCatalogItems`** (`:245-269`): passa a `readonly ContentCatalogItem[]`; branch de card: `params.tipo && params.tipo !== 'card'` → fora; qualquer `cidade|regiao|tema|instituicao` → fora; `q`/termos via `normalizeForSearch(item.searchText).includes(term)`. - **`contentPieceCatalogFacets`** (`:155-185`): branch de card adiciona só `'card'` a `tipos` (sem geo/tema); header do módulo atualizado. -- **`loadContentPieceCatalogSearch`** (`src/utilities/content/contentPieceThemeSearch.ts:78-110`): `catalogItems = publishedItems.length > 0 ? [...publicItems, ...cardCatalogItems()] : publicItems`; `publishedCount: publishedItems.length` (doc: peças, não cards); `facets`/`items` sobre a união. +- **`loadContentPieceCatalogSearch`** (`src/utilities/content/contentPieceThemeSearch.ts:78-110`): `catalogItems = contentCatalogItems(publicItems, publishedItems.length, cardCatalogItems())` — o guard `publishedCount > 0` vive no builder puro; `publishedCount: publishedItems.length` (doc: peças, não cards); `facets`/`items` sobre a união. - **`ContentPieceCatalog`** (`src/components/conteudos/ContentPieceCatalog.tsx:18-72`): `items: readonly ContentCatalogItem[]`; remove `showCardInvite`; card entra em `largeItems`; render `` para card e `` para peça; keys por `card:`/`item.id`. - **`ContentCatalogCardItem`** (novo, `src/components/conteudos/`): `` único, responsivo `grid grid-cols-[128px_1fr] sm:block`, arte real (`next/image`, `previewSrc ?? assetSrc`) ou placeholder neutro tracejado para `kind === 'photo'`, selo `badge`, tag `Card` (`contentPieceTypeLabels.card`), título, `description`, chips de `aliases`, CTA, `data-card-model`, `onClick` → `sendCardOpeningEvent(modelId)`. - **`ContentPieceCardInvite`** (`:16-44`): deletado; `page.tsx:108,152-162` perde `hasActiveFilters`/`showCardInvite` (o estado vazio e o "Fazer meu card" de `ContentPieceStates.tsx:28` ficam). @@ -134,7 +134,7 @@ Não aplicável — a intenção decidiu que o item não apresenta número/conta ## 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. +- **`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). - **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). diff --git a/src/components/conteudos/ContentCatalogCardItem.tsx b/src/components/conteudos/ContentCatalogCardItem.tsx index 167753d3..49eb8092 100644 --- a/src/components/conteudos/ContentCatalogCardItem.tsx +++ b/src/components/conteudos/ContentCatalogCardItem.tsx @@ -2,6 +2,7 @@ import Image from 'next/image' import Link from 'next/link' import { sendCardOpeningEvent } from '@/lib/contentEvents' +import { contentPieceTypeLabels } from '@/lib/contentPiece' import type { CardCatalogItem } from '@/lib/contentPieceCatalog' import { cn } from '@/lib/utils' @@ -12,8 +13,6 @@ import { 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 @@ -51,7 +50,7 @@ const CardItemArt = ({ item }: { item: CardCatalogItem }) => ( )} {item.badge ? ( - + {item.badge} ) : null} @@ -74,14 +73,18 @@ export const ContentCatalogCardItem = ({ item }: { item: CardCatalogItem }) => ( className={cn( CONTENT_PIECE_CARD, 'grid grid-cols-[128px_1fr] text-black no-underline sm:block', - item.badge ? 'border-2 border-(--pt-red)' : null, + item.badge ? 'border-2 border-[#e4102f]' : null, CONTENT_PIECE_FOCUS, )} >
- {CARD_TAG_LABEL} + + {contentPieceTypeLabels.card} + {/* Artefato (cena 02): no celular o selo vive na tag, não sobre a arte. */} + {item.badge ? · {item.badge} : null} +

{item.title}

diff --git a/src/components/conteudos/ContentPieceCatalog.tsx b/src/components/conteudos/ContentPieceCatalog.tsx index c4bcd619..77fc26f6 100644 --- a/src/components/conteudos/ContentPieceCatalog.tsx +++ b/src/components/conteudos/ContentPieceCatalog.tsx @@ -28,7 +28,7 @@ export const ContentPieceCatalog = ({ themeMode, }: { items: readonly ContentCatalogItem[] - /** S28 — the board is in the theme mode: cards state their provenance. */ + /** S28 — the board is in the theme mode: the pieces state their provenance. */ themeMode: boolean }) => { const [playingId, setPlayingId] = useState(null) diff --git a/src/lib/contentPieceCatalog.ts b/src/lib/contentPieceCatalog.ts index e2d967b7..8a61e729 100644 --- a/src/lib/contentPieceCatalog.ts +++ b/src/lib/contentPieceCatalog.ts @@ -155,9 +155,9 @@ const sortedOptions = (options: Map): ContentPieceCatalogFacetOp .sort((a, b) => a.label.localeCompare(b.label, 'pt-BR')) /** - * 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. + * Facet options are derived from the board 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 @@ -275,12 +275,7 @@ export const filterContentPieceCatalogItems = ( 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 ( + } else if ( (params.tipo && item.type !== params.tipo) || (params.cidade && (item.cityLabel === null || slugify(item.cityLabel) !== params.cidade)) || (params.regiao && @@ -393,8 +388,12 @@ export type CardCatalogItem = { export type ContentCatalogItem = ContentPiecePublicItem | CardCatalogItem export const isCardCatalogItem = (item: ContentCatalogItem): item is CardCatalogItem => - 'itemKind' in item + 'itemKind' in item && item.itemKind === 'card' +/** + * S38 — the art of a model item. The placeholder shape mirrors the master's + * orientation (`perfil-quadrado` is 1000×1000, `perfil-retangular` 1000×1440). + */ const cardModelArt = (model: CardModel): CardCatalogArt => model.kind === 'photo' ? { kind: 'placeholder', shape: model.width === model.height ? 'square' : 'portrait' } @@ -419,15 +418,18 @@ export const cardCatalogItems = (): CardCatalogItem[] => })) /** - * 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. + * S38 — the items of the public board: the pieces (already annotated by the + * theme search) plus the synthetic card models. The guardrail lives here: + * `publishedCount` counts published PIECES (before any filter), so 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[], + pieces: readonly ContentPiecePublicItem[], + publishedCount: number, cards: readonly CardCatalogItem[], -): ContentCatalogItem[] => (items.length > 0 ? [...items, ...cards] : [...items]) +): ContentCatalogItem[] => (publishedCount > 0 ? [...pieces, ...cards] : [...pieces]) export type ContentPieceMediaKind = 'video' | 'audio' | 'image' | 'text' | 'other' diff --git a/src/utilities/content/contentEventWrite.ts b/src/utilities/content/contentEventWrite.ts index 828ad7d7..9135116d 100644 --- a/src/utilities/content/contentEventWrite.ts +++ b/src/utilities/content/contentEventWrite.ts @@ -18,8 +18,8 @@ import { * * The write is an intentional Local API admin bypass: the collection denies * every create through access control, and the gate is the caller's (the - * published-slug read / the served file). No visitor identifier is ever part of - * the row. + * published-slug read, the served file, or — S38 — the committed card model + * catalog for the card opening). No visitor identifier is ever part of the row. */ export type ContentEventInput = { diff --git a/src/utilities/content/contentPieceThemeSearch.ts b/src/utilities/content/contentPieceThemeSearch.ts index 1f765284..7b567b12 100644 --- a/src/utilities/content/contentPieceThemeSearch.ts +++ b/src/utilities/content/contentPieceThemeSearch.ts @@ -111,7 +111,7 @@ export const loadContentPieceCatalogSearch = async ({ // 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 catalogItems = contentCatalogItems(publicItems, publishedItems.length, cardCatalogItems()) const facets = contentPieceCatalogFacets(catalogItems) return { diff --git a/tests/e2e/frontendConteudos.e2e.spec.ts b/tests/e2e/frontendConteudos.e2e.spec.ts index 8ad7af3a..c03de67d 100644 --- a/tests/e2e/frontendConteudos.e2e.spec.ts +++ b/tests/e2e/frontendConteudos.e2e.spec.ts @@ -221,9 +221,8 @@ 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) - // 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) + // S38 — the six models are items of the same board: the card item and the + // pieces share one grid (no section of their own, no invite tile). await expect(page.locator('[data-card-model]')).toHaveCount(6) // The video piece shares the full-card grid with the models (the photo is a // compact row below): the models are items of the board, not a section. diff --git a/tests/unit/contentPieceCatalog.unit.spec.ts b/tests/unit/contentPieceCatalog.unit.spec.ts index 4f226eb4..fa4f7c9e 100644 --- a/tests/unit/contentPieceCatalog.unit.spec.ts +++ b/tests/unit/contentPieceCatalog.unit.spec.ts @@ -305,9 +305,9 @@ describe('card models as catalogue items (S38)', () => { }) 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([ + expect(contentCatalogItems([], 0, cards)).toEqual([]) + expect(contentCatalogItems([item()], 1, cards)).toHaveLength(7) + expect(contentCatalogItems([item()], 1, cards).map((row) => isCardCatalogItem(row))).toEqual([ false, true, true,