refactor(S38): reusa o label do dono, fidelidade ao artefato e simplifica o filtro (Related #1304)

This commit is contained in:
Francisco Solla 2026-09-24 03:22:44 -03:00
parent a79882bc12
commit 6ba48cb84b
8 changed files with 39 additions and 35 deletions

View file

@ -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=<id>` (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=<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.
- **`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 `<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).
@ -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).

View file

@ -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 ? (
<span className="absolute top-2 right-2 rounded-full bg-white px-2 py-1 text-[9px] font-black text-[#184e92]">
<span className="absolute top-2 right-2 hidden rounded-full bg-white px-2 py-1 text-[9px] font-black text-[#184e92] sm:block">
{item.badge}
</span>
) : 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,
)}
>
<CardItemArt item={item} />
<div className="p-4">
<span className={CONTENT_PIECE_TAG}>{CARD_TAG_LABEL}</span>
<span className={CONTENT_PIECE_TAG}>
{contentPieceTypeLabels.card}
{/* Artefato (cena 02): no celular o selo vive na tag, não sobre a arte. */}
{item.badge ? <span className="sm:hidden"> · {item.badge}</span> : null}
</span>
<h3 className="mt-2 font-[family-name:var(--font-exo2)] text-sm font-extrabold sm:text-base">
{item.title}
</h3>

View file

@ -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<number | null>(null)

View file

@ -155,9 +155,9 @@ const sortedOptions = (options: Map<string, string>): 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'

View file

@ -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 = {

View file

@ -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 {

View file

@ -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.

View file

@ -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,