mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(S38): alterna peças e cards no board e certifica o design (Closes #1304)
This commit is contained in:
parent
6ba48cb84b
commit
7f893e749a
5 changed files with 57 additions and 4 deletions
|
|
@ -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=<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.
|
||||
**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" — "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`).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null)
|
||||
const [sharingItem, setSharingItem] = useState<ContentPiecePublicItem | null>(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,
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue