feat(S45): cards verticais no catálogo, na home e na página da peça

This commit is contained in:
Francisco Solla 2026-10-01 18:50:26 -03:00
parent fcc47bb111
commit 4ac13dc351
14 changed files with 326 additions and 78 deletions

View file

@ -1,23 +1,28 @@
/**
* S27 — the loading state of the Central (artefato: cena 05): the skeleton
* shape of the board, so a slow connection sees the page it is about to get.
* S45 — the vertical board shape (4 columns from `lg`; the dominant 9:16 video
* slot, an approximation of the mixed board) with the media edge-to-edge like
* the real card.
*/
export default function ConteudosLoading() {
return (
<div className="mx-auto w-full max-w-6xl px-5 py-10 sm:px-8">
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band)" />
<div className="mt-9 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2].map((index) => (
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-9 grid items-start gap-5 sm:grid-cols-2 lg:grid-cols-4">
{[0, 1, 2, 3].map((index) => (
<div
key={index}
className="overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white p-4"
className="overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white"
>
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band)" />
<div className="mt-4 h-5 w-3/4 animate-pulse rounded bg-(--campaign-band)" />
<div className="mt-3 h-4 w-full animate-pulse rounded bg-(--campaign-band)" />
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="h-11 animate-pulse rounded bg-(--campaign-band)" />
<div className="h-11 animate-pulse rounded bg-(--campaign-band)" />
<div className="aspect-[9/16] animate-pulse bg-(--campaign-band) motion-reduce:animate-none" />
<div className="p-4">
<div className="h-5 w-3/4 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-3 h-4 w-full animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="h-11 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="h-11 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
</div>
</div>
</div>
))}

View file

@ -3,7 +3,7 @@ import Link from 'next/link'
import { sendCardOpeningEvent } from '@/lib/contentEvents'
import { contentPieceTypeLabels } from '@/lib/contentPiece'
import type { CardCatalogItem } from '@/lib/contentPieceCatalog'
import { CONTENT_PIECE_ASPECT_CLASS, type CardCatalogItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import {
@ -15,7 +15,7 @@ import {
/**
* 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
* whole in the 3:4 vertical slot (S45 — the art is never cropped), or the
* neutral dashed placeholder of the photo models, whose master is only the
* frame with a transparent photo window. The `NOVO` badge of the newest model
* sits on the art.
@ -23,7 +23,8 @@ import {
const CardItemArt = ({ item }: { item: CardCatalogItem }) => (
<span
className={cn(
'relative grid min-h-40 place-items-center overflow-hidden sm:aspect-video sm:min-h-0',
'relative grid place-items-center overflow-hidden',
CONTENT_PIECE_ASPECT_CLASS.model,
item.art.kind === 'placeholder'
? 'bg-[linear-gradient(145deg,#eee9e5,#fff)]'
: 'bg-[linear-gradient(145deg,#184e92,#0876ac)]',
@ -44,8 +45,8 @@ const CardItemArt = ({ item }: { item: CardCatalogItem }) => (
src={item.art.src}
alt=""
fill
sizes="(min-width: 1024px) 360px, (min-width: 640px) 45vw, 128px"
className="object-cover object-top"
sizes="(min-width: 1024px) 260px, (min-width: 640px) 45vw, 112px"
className="object-contain"
/>
)}
@ -72,7 +73,7 @@ export const ContentCatalogCardItem = ({ item }: { item: CardCatalogItem }) => (
onClick={() => sendCardOpeningEvent(item.modelId)}
className={cn(
CONTENT_PIECE_CARD,
'grid grid-cols-[128px_1fr] text-black no-underline sm:block',
'grid grid-cols-[112px_minmax(0,1fr)] text-black no-underline sm:block',
item.badge ? 'border-2 border-[#e4102f]' : null,
CONTENT_PIECE_FOCUS,
)}

View file

@ -1,6 +1,11 @@
import Link from 'next/link'
import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import {
CONTENT_PIECE_ASPECT_CLASS,
contentPieceBoardBucket,
contentPieceMediaKind,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
@ -84,8 +89,9 @@ const CardThemeEvidence = ({ item }: { item: ContentPiecePublicItem }) => {
}
/**
* S27/S28 — one catalogue card (artefato: cenas 01/02/05–07): video/audio/card
* and link pieces take the full card with the asset on top; a photo/text is the
* S27/S28 — one catalogue card (artefato: cenas 01/02/05–07): video takes the
* full card with the 9:16 frame on top, audio/link take the wide row (the
* media column at the left, no framing to preserve), and a photo/text is the
* compact horizontal row. Actions work from the card itself — download never
* needs the piece page.
*/
@ -97,23 +103,25 @@ export const ContentPieceCard = ({
onShare,
themeMode,
}: ContentPieceCardProps) => {
const kind = contentPieceMediaKind(item)
const isCompact = kind === 'image' || kind === 'text' || kind === 'other'
const bucket = contentPieceBoardBucket(contentPieceMediaKind(item))
if (isCompact) {
if (bucket === 'compact') {
return (
<article
data-content-piece={item.slug}
data-state={playing ? 'playing' : 'stopped'}
className={cn(CONTENT_PIECE_CARD, 'flex items-center gap-4 p-3')}
>
{/* S45 — a fixed 96×120 (4:5) box: the compact row is a flex child, and
`aspect-ratio` on a flex item is the WebKit #265243 trap; the fixed
box keeps the whole photo, never a crop. */}
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
variant="thumb"
className="h-24 w-32 shrink-0 rounded-lg"
className="h-[120px] w-24 shrink-0 rounded-lg"
/>
<div className="min-w-0 flex-1">
<CardTag item={item} themeMode={themeMode} />
@ -121,14 +129,49 @@ export const ContentPieceCard = ({
<ContentPieceCardMeta item={item} />
<CardThemeEvidence item={item} />
<div className="mt-3 flex flex-wrap gap-2">
<ContentPieceDownloadLink item={item} className="min-h-9 px-3" />
<ContentPieceShareButton item={item} onShare={onShare} className="min-h-9 px-3" />
<ContentPieceDownloadLink item={item} className="min-h-11 px-3" />
<ContentPieceShareButton item={item} onShare={onShare} className="min-h-11 px-3" />
</div>
</div>
</article>
)
}
const body = (
<div className="min-w-0 p-4">
<CardTag item={item} themeMode={themeMode} />
<ContentPieceCardTitle item={item} className="text-base" />
<ContentPieceCardMeta item={item} />
<CardThemeEvidence item={item} />
<div className={cn('mt-4 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
<ContentPieceDownloadLink item={item} />
<ContentPieceShareButton item={item} onShare={onShare} />
</div>
<Link
href={item.publicPath}
className="mt-3 block rounded-sm text-center text-xs font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(24_78_146/72%)] sm:hidden"
>
Ver detalhes da peça →
</Link>
</div>
)
if (bucket === 'wide') {
return (
<article
data-content-piece={item.slug}
data-state={playing ? 'playing' : 'stopped'}
className={cn(
CONTENT_PIECE_CARD,
'grid min-h-[156px] grid-cols-[112px_minmax(0,1fr)] sm:min-h-[170px] sm:grid-cols-[190px_minmax(0,1fr)]',
)}
>
<ContentPieceMedia item={item} playing={playing} onToggle={onToggle} onEnded={onEnded} />
{body}
</article>
)
}
return (
<article
data-content-piece={item.slug}
@ -140,24 +183,9 @@ export const ContentPieceCard = ({
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
className="aspect-video"
className={CONTENT_PIECE_ASPECT_CLASS.video}
/>
<div className="p-4">
<CardTag item={item} themeMode={themeMode} />
<ContentPieceCardTitle item={item} className="text-base" />
<ContentPieceCardMeta item={item} />
<CardThemeEvidence item={item} />
<div className={cn('mt-4 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
<ContentPieceDownloadLink item={item} />
<ContentPieceShareButton item={item} onShare={onShare} />
</div>
<Link
href={item.publicPath}
className="mt-3 block rounded-sm text-center text-xs font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-red) sm:hidden"
>
Ver detalhes da peça →
</Link>
</div>
{body}
</article>
)
}

View file

@ -3,6 +3,7 @@
import { useState } from 'react'
import {
contentPieceBoardBucket,
contentPieceMediaKind,
interleaveCatalogItems,
isCardCatalogItem,
@ -16,14 +17,15 @@ import { ContentPieceCard } from './ContentPieceCard'
import { ContentPieceShareSheet } from './ContentPieceShareSheet'
/**
* S27 — the catalogue board (artefato: cena 01): full cards for video, audio
* and link pieces, compact rows for photo/text, one piece plays at a time
* (switching unmounts the previous element) and the share sheet is owned here,
* so every card opens the same vote message.
* S27 — the catalogue board (artefato: cena 01): the vertical board (video and
* the S38 models) runs four columns from `lg`, audio/link keep a wide two-column
* grid (no framing to preserve) and photo/text keep the compact scan row. One
* piece plays at a time (switching unmounts the previous element) and the share
* sheet is owned here, so every card opens the same vote message.
*
* S38 — the six card models are items of the same board: they take the full-card
* bucket and the whole item is one link to the studio (`/cards?model=<id>`); the
* old single invite tile is gone and no section groups them.
* S38 — the six card models are items of the same board: they take the vertical
* board bucket and the whole item is one link to the studio (`/cards?model=<id>`);
* the old single invite tile is gone and no section groups them.
*/
export const ContentPieceCatalog = ({
items,
@ -36,7 +38,8 @@ export const ContentPieceCatalog = ({
const [playingId, setPlayingId] = useState<number | null>(null)
const [sharingItem, setSharingItem] = useState<ContentPiecePublicItem | null>(null)
const largePieces: ContentPiecePublicItem[] = []
const videoPieces: ContentPiecePublicItem[] = []
const widePieces: ContentPiecePublicItem[] = []
const compactItems: ContentPiecePublicItem[] = []
const cards: CardCatalogItem[] = []
for (const item of items) {
@ -44,13 +47,14 @@ export const ContentPieceCatalog = ({
cards.push(item)
continue
}
const kind = contentPieceMediaKind(item)
if (kind === null || kind === 'video' || kind === 'audio') largePieces.push(item)
const bucket = contentPieceBoardBucket(contentPieceMediaKind(item))
if (bucket === 'vertical') videoPieces.push(item)
else if (bucket === 'wide') widePieces.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)
// S38 — the models alternate with the video pieces (never a block of their
// own); the wide and compact rows keep the S27 board below.
const verticalItems = interleaveCatalogItems(videoPieces, cards)
const cardProps = (item: ContentPiecePublicItem) => ({
item,
@ -63,9 +67,9 @@ export const ContentPieceCatalog = ({
return (
<>
{largeItems.length > 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{largeItems.map((item) =>
{verticalItems.length > 0 ? (
<div className="grid items-start gap-5 sm:grid-cols-2 lg:grid-cols-4">
{verticalItems.map((item) =>
isCardCatalogItem(item) ? (
<ContentCatalogCardItem key={`card:${item.modelId}`} item={item} />
) : (
@ -75,8 +79,16 @@ export const ContentPieceCatalog = ({
</div>
) : null}
{widePieces.length > 0 ? (
<div className="mt-6 grid items-start gap-5 lg:grid-cols-2">
{widePieces.map((item) => (
<ContentPieceCard key={item.id} {...cardProps(item)} />
))}
</div>
) : null}
{compactItems.length > 0 ? (
<div className="mt-6 grid gap-6 sm:grid-cols-2">
<div className="mt-6 grid items-start gap-5 sm:grid-cols-2">
{compactItems.map((item) => (
<ContentPieceCard key={item.id} {...cardProps(item)} />
))}

View file

@ -3,7 +3,11 @@
import { useEffect, useRef, useState } from 'react'
import { sendContentPieceEvent } from '@/lib/contentEvents'
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import {
contentPieceAspectClass,
contentPieceMediaKind,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
@ -50,14 +54,17 @@ export const ContentPieceDetail = ({ item }: { item: ContentPiecePublicItem }) =
return (
<>
<div className="grid gap-8 lg:grid-cols-[1.2fr_.8fr] lg:gap-10">
{/* S45 — the piece page keeps the card's promise: the 360px media column
(artefato cena 05) and the frame of the type — Reel 9:16, photo 4:5,
wide kinds 16:9 — with the media whole, never cropped. */}
<div className="grid items-start gap-8 lg:grid-cols-[360px_minmax(0,1fr)] lg:gap-10">
<ContentPieceMedia
item={item}
playing={playing}
onToggle={() => setPlaying((current) => !current)}
onEnded={() => setPlaying(false)}
variant="detail"
className="aspect-video"
className={contentPieceAspectClass(contentPieceMediaKind(item)) ?? 'aspect-video'}
/>
<div>

View file

@ -241,7 +241,9 @@ export const ContentPieceHomeBoard = ({
<div
className={cn(
'mt-6',
isSingle ? 'lg:mt-0' : 'grid gap-4 sm:grid-cols-2 sm:gap-6 lg:mt-9 lg:grid-cols-3',
isSingle
? 'lg:mt-0'
: 'grid items-start gap-4 sm:grid-cols-2 sm:gap-6 lg:mt-9 lg:grid-cols-3',
)}
>
{selection.map(({ item, match }) => (

View file

@ -1,6 +1,10 @@
import Link from 'next/link'
import { buildContentPieceCatalogHref } from '@/lib/contentPieceCatalog'
import {
buildContentPieceCatalogHref,
contentPieceAspectClass,
contentPieceMediaKind,
} from '@/lib/contentPieceCatalog'
import type { ContentPieceHomeItem, ContentPieceHomeMatch } from '@/lib/contentPieceHomeSelection'
import { slugify } from '@/lib/slug'
import { cn } from '@/lib/utils'
@ -67,6 +71,10 @@ export const ContentPieceHomeCard = ({
}) => {
const typeHref = item.isLink ? null : buildContentPieceCatalogHref({ tipo: item.type })
const matchFacetHref = matchHref(item, match)
// S45 — the thumb keeps the form of the type (photo 4:5, Reel 9:16); the
// wide kinds (audio/link/text) keep a short fixed column on the phone and the
// wide block from `sm` up, where the card becomes a full block.
const aspectClass = contentPieceAspectClass(contentPieceMediaKind(item))
return (
<article
@ -83,7 +91,7 @@ export const ContentPieceHomeCard = ({
// the card becomes a full block and the media slot goes back to the
// catalogue treatment.
variant="home-thumb"
className="aspect-video self-start"
className={cn('self-start', aspectClass ?? 'max-sm:h-[156px] sm:aspect-video')}
/>
<div className="min-w-0 p-4">
<div className="flex flex-wrap gap-1.5">

View file

@ -50,7 +50,12 @@ const PlayButton = ({
aria-label={label}
className={cn(
'absolute grid place-items-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink) shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900] motion-reduce:transition-none',
compact ? 'size-11 sm:size-16' : 'size-14 sm:size-16',
// C243 — public light-surface focus: the yellow signal plus the dark blue
// outer separator, so the ring separates from the yellow button itself.
'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(24_78_146/72%)]',
// S45 — the approved play (artefato cena 07): 56px on the card, 44px on
// the compact thumb (44 → 56 from `sm`, where that card becomes a block).
compact ? 'size-11 sm:size-14' : 'size-14',
)}
>
<PlayIcon />
@ -141,6 +146,7 @@ export const ContentPieceMedia = ({
if (kind === null) {
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-[linear-gradient(145deg,#fff4f5,#eef4fb)]',
@ -163,7 +169,9 @@ export const ContentPieceMedia = ({
className={cn(
'mt-2 font-[family-name:var(--font-exo2)] font-black',
item.origin === 'instagram' ? 'text-[#e4102f]' : 'text-[#184e92]',
variant === 'detail' ? 'text-4xl' : 'text-2xl',
// S45 — the wide row of the catalogue and the home thumb carry a
// narrow media column on the phone; from `sm` the full scale returns.
variant === 'detail' ? 'text-4xl' : 'text-base sm:text-2xl',
)}
>
{item.originLabel}
@ -180,14 +188,17 @@ export const ContentPieceMedia = ({
if (kind === 'image') {
return (
<div className={cn(base, variant === 'detail' && 'rounded-2xl border border-black/10')}>
<div
data-content-piece-media=""
className={cn(base, variant === 'detail' && 'rounded-2xl border border-black/10')}
>
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
<img
src={item.file?.path}
alt={item.title}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
className="h-full w-full object-contain"
/>
</div>
)
@ -196,6 +207,7 @@ export const ContentPieceMedia = ({
if (kind === 'text') {
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-(--campaign-cream) px-3 text-center',
@ -218,6 +230,7 @@ export const ContentPieceMedia = ({
if (playing) {
return (
<div
data-content-piece-media=""
className={cn(
base,
'bg-[#180a09]',
@ -252,6 +265,7 @@ export const ContentPieceMedia = ({
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-[linear-gradient(135deg,#e5e2df,#f7f5f3)]',
@ -274,7 +288,7 @@ export const ContentPieceMedia = ({
// the browser's broken-image glyph over the slot for a moment, and
// the design's honest state is the neutral surface, never a glyph.
className={cn(
'absolute inset-0 h-full w-full object-cover transition-opacity duration-200',
'absolute inset-0 h-full w-full object-contain transition-opacity duration-200 motion-reduce:transition-none',
frameState !== 'ready' && 'opacity-0',
)}
/>
@ -304,6 +318,7 @@ export const ContentPieceMedia = ({
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-[linear-gradient(135deg,#e5e2df,#f7f5f3)] px-3 text-center',

View file

@ -72,6 +72,6 @@ export const CONTENT_PIECE_LOCAL_TAG_LINK = cn(
/** S42 — the card's `Ver esta peça →` under the share button (artefato `.piece-link`). */
export const CONTENT_PIECE_PIECE_LINK = cn(
'mt-2 flex min-h-9 w-full items-center justify-center rounded-sm text-xs font-extrabold text-(--pt-red) underline-offset-4 hover:underline',
'mt-2 flex min-h-11 w-full items-center justify-center rounded-sm text-xs font-extrabold text-(--pt-red) underline-offset-4 hover:underline sm:min-h-9',
CONTENT_PIECE_FOCUS,
)

View file

@ -560,6 +560,44 @@ export const contentPieceMediaKind = (
return 'other'
}
/**
* S45 — the frame each form breathes in (artefato cena 07): a Reel keeps 9:16,
* a feed photo keeps 4:5 and the S38 model art keeps 3:4. The media itself
* never crops (`object-contain`), so a genuinely horizontal piece lands whole
* in the neutral letterbox of its slot; audio, link, text and other have no
* framing to preserve and stay wide (no class).
*/
export const CONTENT_PIECE_ASPECT_CLASS = {
video: 'aspect-[9/16]',
image: 'aspect-[4/5]',
model: 'aspect-[3/4]',
} as const
/**
* The slot class of a piece by its media kind; `undefined` means the host keeps
* its wide form (audio, link, text, other, unknown).
*/
export const contentPieceAspectClass = (kind: ContentPieceMediaKind | null): string | undefined => {
if (kind === 'video' || kind === 'image') return CONTENT_PIECE_ASPECT_CLASS[kind]
return undefined
}
export type ContentPieceBoardBucket = 'vertical' | 'wide' | 'compact'
/**
* S45 — the bucket of a piece on the board (artefato cena 01): video and the
* S38 models share the vertical board, audio/link keep the wide row and
* image/text/other keep the compact scan row. One rule for the card and the
* catalogue grids.
*/
export const contentPieceBoardBucket = (
kind: ContentPieceMediaKind | null,
): ContentPieceBoardBucket => {
if (kind === 'video') return 'vertical'
if (kind === 'audio' || kind === null) return 'wide'
return 'compact'
}
const DOWNLOAD_PREFIX = 'jorge-solla-1313'
const DOWNLOAD_FALLBACK_EXTENSION = 'bin'

View file

@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { APIRequestContext, Page } from '@playwright/test'
@ -46,9 +47,15 @@ const createdLeadershipIds: number[] = []
const uniqueMarker = () => randomUUID().slice(0, 8)
const mediaFor = (type: 'video' | 'foto' | 'texto') => {
const mediaFor = (type: 'video' | 'foto' | 'audio' | 'texto') => {
if (type === 'video') return { mimetype: 'video/mp4', buffer: TEST_MP4, extension: 'mp4' }
if (type === 'foto') return { mimetype: 'image/png', buffer: TEST_PNG, extension: 'png' }
if (type === 'audio')
return {
mimetype: 'audio/mpeg',
buffer: readFileSync(join(process.cwd(), 'tests/fixtures/jingle-tone.mp3')),
extension: 'mp3',
}
return {
mimetype: 'text/plain',
buffer: Buffer.from('Peça voto pra Solla 1313.'),
@ -92,7 +99,8 @@ const createPiece = async (
publicFigures?: string[]
},
): Promise<{ id: number; slug: string }> => {
const mediaKind = data.type === 'video' || data.type === 'foto' ? data.type : 'texto'
const mediaKind =
data.type === 'video' || data.type === 'foto' || data.type === 'audio' ? data.type : 'texto'
const mediaSpec = mediaFor(mediaKind)
const media =
data.withMedia === false
@ -279,8 +287,10 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
const headers = await adminHeaders(request, BASE_URL)
const videoTitle = `Fim da escala ${uniqueMarker()}`
const photoTitle = `Solla no SUS ${uniqueMarker()}`
const audioTitle = `Áudio da Central ${uniqueMarker()}`
const video = await createPiece(request, headers, { title: videoTitle, type: 'video' })
const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' })
const audio = await createPiece(request, headers, { title: audioTitle, type: 'audio' })
const media = mediaRequests(page)
const frames = frameRequests(page)
@ -293,12 +303,13 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
page.getByRole('heading', { name: 'Uma mensagem sua pode conquistar mais um voto.' }),
).toBeVisible()
await expect(page.getByText('Central de Conteúdos').first()).toBeVisible()
await expect(page.locator('article[data-content-piece]')).toHaveCount(2)
await expect(page.locator('article[data-content-piece]')).toHaveCount(3)
// 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.
// S45 — the video shares the vertical board with the models (the photo is a
// compact row below and the audio keeps the wide row): the models are items
// of the board, not a section.
const board = page.locator('[data-card-model]').first().locator('xpath=..')
await expect(board.locator('article[data-content-piece]')).toHaveCount(1)
await expect(board.locator('[data-card-model]')).toHaveCount(6)
@ -331,8 +342,29 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
.toBeGreaterThan(0)
await expect(page.getByRole('button', { name: `Reproduzir ${videoTitle}` })).toBeVisible()
// S45 — every piece appears in the form of its type: the Reel keeps the
// 9:16 slot (the still whole, `contain`), the photo keeps the 96×120 (4:5)
// compact thumb, the audio keeps the wide row and the model art keeps 3:4.
const mediaSlot = (slug: string) =>
page.locator(`article[data-content-piece="${slug}"] [data-content-piece-media]`)
const videoSlot = await mediaSlot(video.slug).boundingBox()
expect(videoSlot!.height / videoSlot!.width).toBeCloseTo(16 / 9, 2)
await expect(still).toHaveCSS('object-fit', 'contain')
const photoSlot = await mediaSlot(photo.slug).boundingBox()
expect(photoSlot!.width).toBeCloseTo(96, 0)
expect(photoSlot!.height).toBeCloseTo(120, 0)
await expect(mediaSlot(photo.slug).locator('img')).toHaveCSS('object-fit', 'contain')
const audioSlot = await mediaSlot(audio.slug).boundingBox()
expect(audioSlot!.width).toBeCloseTo(190, 0)
expect(audioSlot!.height).toBeGreaterThanOrEqual(156)
const modelSlot = await page
.locator('[data-card-model="time-de-voce"] > span')
.first()
.boundingBox()
expect(modelSlot!.height / modelSlot!.width).toBeCloseTo(4 / 3, 2)
// Play mounts only the tapped piece and fetches only its media. The still
// leaves the slot: the player takes the same 16:9 box (cena 03).
// leaves the slot: the player takes the same 9:16 box (cena 06).
await page.getByRole('button', { name: `Reproduzir ${videoTitle}` }).click()
await expect(page.locator('video')).toHaveCount(1)
await expect.poll(() => media.filter((url) => url.includes(video.slug)).length).toBe(1)
@ -368,6 +400,17 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await sheet.getByRole('button', { name: 'Fechar', exact: true }).click()
await expect(sheet).toHaveCount(0)
// S45 — the vertical board never overflows the phone (cena 02).
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/conteudos')
await waitForSettledPage(page)
await expect(page.locator('article[data-content-piece]')).toHaveCount(3)
const overflow = await page.evaluate(() => {
const container = document.querySelector<HTMLElement>('[data-theme="campaign-site"]')
return container ? container.scrollWidth - container.clientWidth : 0
})
expect(overflow).toBeLessThanOrEqual(1)
})
test('filters by facet and term and shows the honest no-results state', async ({
@ -378,8 +421,9 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await unpublishEveryPiece(request, headers)
const marker = uniqueMarker()
const videoTitle = `Debate na rádio ${marker}`
const photoTitle = `Card do giro ${marker}`
const video = await createPiece(request, headers, { title: videoTitle, type: 'video' })
await createPiece(request, headers, { title: `Card do giro ${marker}`, type: 'foto' })
const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' })
await page.goto(`/conteudos?tipo=video`)
await waitForSettledPage(page)
@ -410,6 +454,19 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await expect(page).toHaveURL(new RegExp(`/conteudos/${video.slug}$`))
await expect(page.getByRole('heading', { name: videoTitle })).toBeVisible()
await expect(page.getByRole('link', { name: 'Voltar à Central' })).toBeVisible()
// S45 — the piece page keeps the card's promise: the 9:16 stage in the
// 360px media column (cena 05), never wider than the text.
const stage = await page.locator('[data-content-piece-media]').boundingBox()
expect(stage!.width).toBeCloseTo(360, 0)
expect(stage!.height / stage!.width).toBeCloseTo(16 / 9, 2)
// The photo keeps the 4:5 stage on its own page.
await page.goto(`/conteudos/${photo.slug}`)
await expect(page.getByRole('heading', { name: photoTitle })).toBeVisible()
const photoStage = await page.locator('[data-content-piece-media]').boundingBox()
expect(photoStage!.width).toBeCloseTo(360, 0)
expect(photoStage!.height / photoStage!.width).toBeCloseTo(5 / 4, 2)
})
test('lists the card models by nickname, filters by Tipo Card and routes to the studio (S38)', async ({
@ -756,6 +813,13 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await expect(section.getByText('Mais recente', { exact: true })).toHaveCount(2)
await expect(section.getByRole('link', { name: 'Mais recente' })).toHaveCount(0)
// S45 — the 124px phone thumb keeps the form of the type: photo 4:5, Reel 9:16.
const photoBox = await photoCard.locator('[data-content-piece-media]').boundingBox()
expect(photoBox!.width).toBeCloseTo(124, 0)
expect(photoBox!.height / photoBox!.width).toBeCloseTo(5 / 4, 2)
const videoBox = await videoCard.locator('[data-content-piece-media]').boundingBox()
expect(videoBox!.height / videoBox!.width).toBeCloseTo(16 / 9, 2)
// S42 — the card shares through the same S27 sheet, without leaving the home.
await section.getByRole('button', { name: `Compartilhar ${photoTitle}` }).click()
const homeSheet = page.getByRole('dialog', { name: 'Compartilhar peça' })

View file

@ -5,7 +5,10 @@ import { describe, expect, it } from 'vitest'
import {
buildContentPieceCatalogHref,
cardCatalogItems,
CONTENT_PIECE_ASPECT_CLASS,
contentCatalogItems,
contentPieceAspectClass,
contentPieceBoardBucket,
contentPieceCatalogActiveFilters,
contentPieceCatalogFacets,
contentPieceDownloadFilename,
@ -525,6 +528,26 @@ describe('content piece public view model', () => {
expect(contentPieceMediaKind(item({ media: null, sourceUrl: 'https://youtu.be/X' }))).toBeNull()
})
it('maps the frame of the type, leaving the wide kinds without a class (S45)', () => {
expect(contentPieceAspectClass('video')).toBe('aspect-[9/16]')
expect(contentPieceAspectClass('image')).toBe('aspect-[4/5]')
// The S38 model art is not a media kind: its host reads the table directly.
expect(CONTENT_PIECE_ASPECT_CLASS.model).toBe('aspect-[3/4]')
// Audio, link, text and other have no framing to preserve.
for (const kind of ['audio', 'text', 'other', null] as const) {
expect(contentPieceAspectClass(kind)).toBeUndefined()
}
})
it('buckets the board by type: vertical, wide or compact (S45)', () => {
expect(contentPieceBoardBucket('video')).toBe('vertical')
expect(contentPieceBoardBucket('audio')).toBe('wide')
expect(contentPieceBoardBucket(null)).toBe('wide')
for (const kind of ['image', 'text', 'other'] as const) {
expect(contentPieceBoardBucket(kind)).toBe('compact')
}
})
it('leads the metadata line with who appears in the piece (S37)', () => {
expect(item({ leaderNames: ['Maria Silva'] }).metaLabel).toBe(
'Maria Silva · Economia e Trabalho · Salvador',

View file

@ -256,6 +256,47 @@ describe('ContentPieceHomeBoard', () => {
await waitFor(() => expect(document.querySelector('video')).toBeNull())
})
it('keeps the frame of the type on the sample card (S45)', async () => {
readPermission.mockResolvedValue('denied')
const photo = item(1)
const video = item(2, {
title: 'Vídeo da home',
type: 'video',
typeLabel: 'Vídeo',
file: {
id: 102,
path: '/conteudos/peca-2/midia',
mimeType: 'video/mp4',
downloadFilename: 'peca-2.mp4',
},
})
const audio = item(3, {
title: 'Áudio da home',
type: 'audio',
typeLabel: 'Áudio',
file: {
id: 103,
path: '/conteudos/peca-3/midia',
mimeType: 'audio/mpeg',
downloadFilename: 'peca-3.mp3',
},
})
renderBoard([photo, video, audio])
await waitFor(() => expect(readPermission).toHaveBeenCalled())
const slot = (slug: string) =>
document.querySelector<HTMLElement>(
`article[data-content-piece="${slug}"] [data-content-piece-media]`,
)
// Photo keeps 4:5 and the Reel keeps 9:16; the wide kinds stay short on the
// phone and take the wide block from `sm` up.
expect(slot('peca-1')?.className).toContain('aspect-[4/5]')
expect(slot('peca-2')?.className).toContain('aspect-[9/16]')
expect(slot('peca-3')?.className).toContain('max-sm:h-[156px]')
})
it('keeps the recent selection when the position fails, without an error or a retry', async () => {
readPermission.mockResolvedValue('granted')
readPosition.mockResolvedValue({ ok: false, reason: 'timeout' })

View file

@ -72,6 +72,8 @@ describe('ContentPieceMedia — the video still before the play (C226)', () => {
renderSlot({})
expect(frameImage()).not.toBeNull()
// S45 — the still shows whole in the slot, never cropped.
expect(frameImage()?.className).toContain('object-contain')
expect(document.querySelector('video')).toBeNull()
expect(document.querySelector('audio')).toBeNull()
// S27: the element that would fetch the file only exists after the tap.
@ -246,5 +248,7 @@ describe('ContentPieceMedia — the video still before the play (C226)', () => {
expect(document.querySelectorAll('img')).toHaveLength(1)
expect(document.querySelector('img')?.getAttribute('src')).toBe('/conteudos/peca-7/midia')
// S45 — the photo shows whole in its 4:5 slot, never cropped.
expect(document.querySelector('img')?.className).toContain('object-contain')
})
})