mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(S7): cards 'Acompanhe de perto' sem raio, borda, sombra ou chip; capa de borda a borda (Closes #90)
This commit is contained in:
parent
3b33a59a9d
commit
4a3c3b709c
6 changed files with 90 additions and 45 deletions
68
docs/plans/cards-acompanhe-de-perto-polimento-impl.md
Normal file
68
docs/plans/cards-acompanhe-de-perto-polimento-impl.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Impl: Cards da seção "Acompanhe de perto": sem cantos arredondados, imagem de borda a borda, chip removido
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-19
|
||||
Issue: #90
|
||||
Intenção: docs/plans/cards-acompanhe-de-perto-polimento.md
|
||||
Appetite restante: herdado (~0,5–1 dia; na prática, horas)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** os cards da seção "Acompanhe de perto" (bento 1+4 no desktop, carrossel no mobile) passam a ter cantos retos (card e capa), capa de borda a borda, **sem borda e sem sombra no card** (decisão do humano em 2026-08-19, após o gate — estende o rascunho, que mantinha `border`/`shadow`) e nenhum chip de badge — nem tipos de post do site, nem origens externas (decisão B do gate). O texto interno (título, subtítulo, meta) continua com o respiro atual (`p-3`) em relação às bordas.
|
||||
- **O que NÃO negociar:** nada além da superfície muda — compartilhamento (S4) usável no canto, grade do bento, carrossel, links, bandas do S5 (borda/background da seção) e o fail-closed da seção (some inteira quando nada é visível). Nada de tokens de cor, capas reais, proporções ou outras seções.
|
||||
- **O que reavaliar:** a hipótese da intenção ("mover o padding para o bloco de texto e remover o badge; dados de badge tendem a ficar órfãos — o executor decide o que descartar"). Confirmado no código: `badgeLabel` vive só no card + nas três funções de mapeamento da seção + `POST_TYPE_BADGE_LABELS` (com um unit test dedicado). Decisão de engenharia: remover os órfãos por completo (tipo do card, constante e teste unitário) — manter morto seria dead code flagrável pelo knip (CI-blocking).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[CampaignContentCard.tsx<br/>superfície do card] --> B["card: -rounded-xl -p-3 -gap<br/>capa: -rounded-lg<br/>texto: +p-3<br/>badge span: removido"]
|
||||
A --> C["CampaignContentCardData<br/>-badgeLabel"]
|
||||
D[CampaignContentSection.tsx] --> E["-badgeLabel nas 3 funções<br/>-import POST_TYPE_BADGE_LABELS"]
|
||||
F[posts.ts] --> G["-POST_TYPE_BADGE_LABELS"]
|
||||
H[postsDateFormats.unit.spec.ts] --> I["-describe POST_TYPE_BADGE_LABELS"]
|
||||
J[frontend.e2e.spec.ts] --> K["badge loop: 812-814,<br/>YouTube: 952,<br/>Instagram: 1153<br/>→ ausência + raio 0"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (superfície no card único + remoção dos órfãos) | B (variante/flag de card, ex. `variant="flat"`) | C (manter `POST_TYPE_BADGE_LABELS` e o badge no DOM com CSS oculto)
|
||||
**Recomendação:** A — o card é o único componente da seção (bento e carrossel reusam `CampaignContentCard`); não há outro consumidor com cantos arredondados para preservar, então uma variante seria abstração sem dono. A remoção dos dados de badge é limpa: `badgeLabel` não é usado em mais nenhum lugar do repo.
|
||||
**Rejeitadas:** B (over-engineering: nenhum outro card usa o componente; "edit the owner, don't twin"); C (dead code + dead data path; knip derruba o CI e o teste unitário continuaria pinando um contrato morto).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`CampaignContentCard.tsx`** (`src/components/`): `cardClassName` vira constante sem `featured` (o único uso do param era o `gap-3`/`gap-2`; `featured` continua dirigindo `sizes` da imagem e o `h3`): `flex h-full flex-col bg-white transition-shadow ...` — sem `rounded-xl`, sem `p-3`, sem `gap`, **sem `border border-(--campaign-line)` e sem `shadow-sm`/`hover:shadow-md`/`transition-shadow`**. Capa: remove `rounded-lg` (mantém `overflow-hidden` — o zoom hover continua contido na própria capa). Bloco de texto: ganha `p-3` (`flex flex-1 flex-col gap-1 p-3`), igual ao draft. Badge `<span>` removido; `badgeLabel` sai de `CampaignContentCardData`. O anel de foco (`focus-visible:ring-2 ring-(--pt-red)`) do link permanece — a11y.
|
||||
- **`CampaignContentSection.tsx`** (`src/components/`): `badgeLabel` removido de `toArticleCardData`/`toVideoCardData`/`toInstagramCardData`; import `POST_TYPE_BADGE_LABELS` removido. Nada mais muda (limites, mesclagem, capas, metas).
|
||||
- **`src/utilities/posts.ts`**: deleta `POST_TYPE_BADGE_LABELS` (único consumidor era a seção; `POST_TYPE_LABELS` plural fica — usado nas listagens).
|
||||
- **`tests/unit/postsDateFormats.unit.spec.ts`**: import e describe de `POST_TYPE_BADGE_LABELS` removidos.
|
||||
- **Migration:** sem migration (nenhuma mudança de schema/coleção).
|
||||
- **Access / Consent:** nenhum.
|
||||
- **UI:** Impeccable B — encaixe em um componente existente (shape→craft→critique→polish). O botão `ContentShareButton` (S4) segue `absolute top-2 right-2` sobre a capa, agora sem moldura; o draft mantém o botão no canto — sem mudança nele.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- N/A — nenhum dado novo; apenas remoção de um campo de superfície (`badgeLabel`) do tipo de dados do card.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Superfície + órfãos** — `CampaignContentCard.tsx`, `CampaignContentSection.tsx`, `posts.ts`, unit test do badge; verificação: `pnpm typecheck` + `pnpm test:unit` (o describe removido some, o resto passa).
|
||||
2. **e2e** — `tests/e2e/frontend.e2e.spec.ts`: loop de badges (812–814) e as asserções `YouTube`/`Instagram` (952, 1153) → asserções de ausência (`toHaveCount(0)` para os quatro tipos + YouTube/Instagram nos testes de feed) e pin do novo visual no teste do bento (`border-radius: 0px` no card e na capa, `border-top-width: 0px` e `box-shadow: none` no card, no mesmo espírito dos pins CSS do S5). `toHaveCSS` com `first()` para não quebrar com o carrossel escondido no mobile.
|
||||
3. **Gates** — `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test` (unit+int). E2e roda no CI (OPS59) — local opcional com `pnpm test:e2e:affected`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não tocar `ContentShareButton`, `CampaignContentCarousel`, banda/bordas da seção (S5), `POST_TYPE_LABELS` (plural), `getVisiblePosts`/fail-closed ou capas. O rascunho HTML do gate fica intocado como artefato — a remoção de borda/sombra é decisão do humano posterior ao gate e não altera o arquivo do rascunho.
|
||||
- Não criar variante de card, não esconder badge via CSS, não renomear `h3`/metas.
|
||||
- Cuidado com o `gap` removido: o `mt-auto` da meta continua pinando a data ao rodapé do bloco; o `p-3` do bloco de texto é quem dá o respiro (draft).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **e2e que assere badge que não mapeei** — verificado por grep: só 812–814 (loop), 952 (YouTube) e 1153 (Instagram) no `frontend.e2e.spec.ts`; `waitForHomeHTML` só usa títulos/links de cabeçalho. Grep final antes do push.
|
||||
- **Raio 0 pinado pelo e2e pode flake por arredondamento** — `toHaveCSS('border-radius', '0px')` é determinístico (nenhum token de raio no card/capa).
|
||||
- **Dead code residual** — `POST_TYPE_BADGE_LABELS` e `badgeLabel` removidos do mesmo PR; knip no gate confirma.
|
||||
- **Capa sem cover (placeholder `--campaign-band`)** — continua com borda a borda e cantos retos, igual ao draft; nada quebra.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (raio 0 card+capa, capa borda a borda, texto `p-3`, nenhum chip em nenhum card, **sem borda e sem sombra no card**)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (card único como dono da superfície; S5/bandas intocadas; fail-closed da seção intacto)
|
||||
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — N/A (nenhum write path muda); e2e atualizados para o novo estado visual
|
||||
|
|
@ -7,7 +7,6 @@ export type CampaignContentCardData = {
|
|||
id: string | number
|
||||
href: string
|
||||
title: string
|
||||
badgeLabel: string
|
||||
meta: string
|
||||
/** Source of the content — drives the WhatsApp share message template. */
|
||||
shareKind: ContentShareKind
|
||||
|
|
@ -25,10 +24,8 @@ type CampaignContentCardProps = {
|
|||
featured?: boolean
|
||||
}
|
||||
|
||||
const cardClassName = (featured: boolean) =>
|
||||
`flex h-full flex-col rounded-xl border border-(--campaign-line) bg-white p-3 shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) ${
|
||||
featured ? 'gap-3' : 'gap-2'
|
||||
}`
|
||||
const cardClassName =
|
||||
'flex h-full flex-col bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red)'
|
||||
|
||||
/**
|
||||
* Presentational content card for the campaign home content bento/carousel —
|
||||
|
|
@ -43,7 +40,7 @@ export const CampaignContentCard = ({ card, featured = false }: CampaignContentC
|
|||
const inner = (
|
||||
<>
|
||||
<div
|
||||
className={`relative w-full shrink-0 overflow-hidden rounded-lg bg-(--campaign-band) ${
|
||||
className={`relative w-full shrink-0 overflow-hidden bg-(--campaign-band) ${
|
||||
card.coverAspect === 'square' ? 'aspect-square' : 'aspect-video'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -56,11 +53,8 @@ export const CampaignContentCard = ({ card, featured = false }: CampaignContentC
|
|||
className="object-cover transition-transform duration-300 group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
|
||||
/>
|
||||
) : null}
|
||||
<span className="absolute top-2 left-2 rounded-full bg-(--pt-red-dark) px-2 py-0.5 text-[10px] font-bold tracking-wider text-white uppercase">
|
||||
{card.badgeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<h3
|
||||
className={`leading-snug font-bold text-(--campaign-ink) group-hover:underline ${
|
||||
featured ? 'text-base' : 'text-sm'
|
||||
|
|
@ -79,16 +73,11 @@ export const CampaignContentCard = ({ card, featured = false }: CampaignContentC
|
|||
return (
|
||||
<div className="group relative h-full">
|
||||
{card.external ? (
|
||||
<a
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cardClassName(featured)}
|
||||
>
|
||||
<a href={card.href} target="_blank" rel="noopener noreferrer" className={cardClassName}>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={card.href} className={cardClassName(featured)}>
|
||||
<Link href={card.href} className={cardClassName}>
|
||||
{inner}
|
||||
</Link>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
getCategoryName,
|
||||
getPostCanonicalPath,
|
||||
getVisiblePosts,
|
||||
POST_TYPE_BADGE_LABELS,
|
||||
} from '@/utilities/posts'
|
||||
import { getInstagramFeed, type InstagramPost } from '@/utilities/socialFeed/instagramFeed'
|
||||
import {
|
||||
|
|
@ -47,7 +46,6 @@ const toArticleCardData = (post: Post): DatedCard | null => {
|
|||
id: post.id,
|
||||
href,
|
||||
title: post.title,
|
||||
badgeLabel: POST_TYPE_BADGE_LABELS[post.type],
|
||||
shareKind: 'article' as const,
|
||||
meta: [formatRelativePostDate(post.publishedDate), categoryName].filter(Boolean).join(' · '),
|
||||
...(cover?.url ? { coverUrl: cover.url, coverAlt: cover.alt ?? undefined } : {}),
|
||||
|
|
@ -62,7 +60,6 @@ const toVideoCardData = (video: YouTubeVideo): DatedCard => ({
|
|||
id: `yt:${video.id}`,
|
||||
href: `https://www.youtube.com/watch?v=${video.id}`,
|
||||
title: video.title,
|
||||
badgeLabel: 'YouTube',
|
||||
shareKind: 'video' as const,
|
||||
meta: [
|
||||
formatRelativePostDate(video.publishedAt),
|
||||
|
|
@ -81,7 +78,6 @@ const toInstagramCardData = (post: InstagramPost): DatedCard => ({
|
|||
id: `ig:${post.id}`,
|
||||
href: post.permalink,
|
||||
title: post.caption ?? 'Publicação no Instagram',
|
||||
badgeLabel: 'Instagram',
|
||||
shareKind: 'instagram' as const,
|
||||
meta: formatRelativePostDate(post.timestamp) ?? '',
|
||||
coverAspect: 'square',
|
||||
|
|
|
|||
|
|
@ -17,14 +17,6 @@ export const POST_TYPE_LABELS: Record<PostType, string> = {
|
|||
evento: 'Eventos',
|
||||
}
|
||||
|
||||
/** Singular labels for per-card badges (e.g. the campaign home content bento). */
|
||||
export const POST_TYPE_BADGE_LABELS: Record<PostType, string> = {
|
||||
noticia: 'Notícia',
|
||||
campanha: 'Campanha',
|
||||
artigo: 'Artigo',
|
||||
evento: 'Evento',
|
||||
}
|
||||
|
||||
export const isPostType = (value: string): value is PostType =>
|
||||
(POST_TYPES as readonly string[]).includes(value)
|
||||
|
||||
|
|
|
|||
|
|
@ -809,8 +809,10 @@ test.describe('Campaign home content section', () => {
|
|||
'a[href^="/artigo/"], a[href^="/noticia/"], a[href^="/campanha/"], a[href^="/evento/"]',
|
||||
)
|
||||
await expect(bentoCards.filter({ visible: true })).toHaveCount(5)
|
||||
for (const badge of ['Artigo', 'Notícia', 'Campanha', 'Evento']) {
|
||||
await expect(section.getByText(badge, { exact: true }).first()).toBeVisible()
|
||||
// S7 — cards are badge-free: no type chip (site or external source)
|
||||
// anywhere in the section.
|
||||
for (const badge of ['Artigo', 'Notícia', 'Campanha', 'Evento', 'YouTube', 'Instagram']) {
|
||||
await expect(section.getByText(badge, { exact: true })).toHaveCount(0)
|
||||
}
|
||||
await expect(section.getByText('E2e Artigo em destaque').first()).toBeVisible()
|
||||
await expect(section.getByText('E2e Conteúdo oculto')).toHaveCount(0)
|
||||
|
|
@ -835,6 +837,13 @@ test.describe('Campaign home content section', () => {
|
|||
await expect(section).toHaveCSS('border-top-color', 'rgba(0, 0, 0, 0.12)')
|
||||
await expect(firstCard).toHaveCSS('background-color', 'rgb(255, 255, 255)')
|
||||
|
||||
// S7 — flat editorial cards: no radius, border or shadow (the white card
|
||||
// reads against the band on shape alone), cover edge to edge.
|
||||
await expect(firstCard).toHaveCSS('border-radius', '0px')
|
||||
await expect(firstCard).toHaveCSS('border-top-width', '0px')
|
||||
await expect(firstCard).toHaveCSS('box-shadow', 'none')
|
||||
await expect(firstCard.locator('.aspect-video')).toHaveCSS('border-radius', '0px')
|
||||
|
||||
await featured.click()
|
||||
await expect(page).toHaveURL(new RegExp(`${featuredHref}$`))
|
||||
await page.goBack()
|
||||
|
|
@ -949,7 +958,8 @@ test.describe('Campaign home content section', () => {
|
|||
await expect(
|
||||
section.getByText('há 30 minutos · 12,4 mil visualizações').first(),
|
||||
).toBeVisible()
|
||||
await expect(section.getByText('YouTube', { exact: true }).first()).toBeVisible()
|
||||
// S7 — no external-source chip on YouTube cards anymore.
|
||||
await expect(section.getByText('YouTube', { exact: true })).toHaveCount(0)
|
||||
await expect(section.getByText('E2e Vídeo excluído')).toHaveCount(0)
|
||||
|
||||
await expect(section.getByRole('link', { name: /E2e Artigo no mix/ }).first()).toBeVisible()
|
||||
|
|
@ -1150,7 +1160,8 @@ test.describe('Campaign home content section', () => {
|
|||
await expect(featured).toHaveAttribute('href', 'https://www.instagram.com/p/e2e-ig-muro-1/')
|
||||
await expect(featured).toHaveAttribute('target', '_blank')
|
||||
await expect(featured).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
await expect(section.getByText('Instagram', { exact: true }).first()).toBeVisible()
|
||||
// S7 — no external-source chip on Instagram cards anymore.
|
||||
await expect(section.getByText('Instagram', { exact: true })).toHaveCount(0)
|
||||
await expect(section.getByText('há 20 minutos').first()).toBeVisible()
|
||||
await expect(section.getByText('E2e Post de grade')).toHaveCount(0)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { formatRelativePostDate, POST_TYPE_BADGE_LABELS } from '@/utilities/posts'
|
||||
import { formatRelativePostDate } from '@/utilities/posts'
|
||||
|
||||
describe('formatRelativePostDate', () => {
|
||||
afterEach(() => {
|
||||
|
|
@ -40,14 +40,3 @@ describe('formatRelativePostDate', () => {
|
|||
expect(formatRelativePostDate('2026-07-17T12:00:00Z')).toBe('17 de julho de 2026')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST_TYPE_BADGE_LABELS', () => {
|
||||
it('spells the four types in the singular for card badges', () => {
|
||||
expect(POST_TYPE_BADGE_LABELS).toEqual({
|
||||
noticia: 'Notícia',
|
||||
campanha: 'Campanha',
|
||||
artigo: 'Artigo',
|
||||
evento: 'Evento',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue