mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
refactor(S38): reusa o label do dono, fidelidade ao artefato e simplifica o filtro (Related #1304)
This commit is contained in:
parent
a79882bc12
commit
6ba48cb84b
8 changed files with 39 additions and 35 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue