From 6d57031eb45ac4290678ce5132dfd863baa81bb2 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 09:40:03 -0300 Subject: [PATCH] =?UTF-8?q?feat(S39):=20se=C3=A7=C3=A3o=20da=20Central=20d?= =?UTF-8?q?e=20Conte=C3=BAdos=20na=20home=20(Closes=20#1305)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-24-s39.md | 1 + .../central-conteudos-secao-home-impl.md | 135 ++++++++ ...entral-conteudos-secao-home-ui-design.html | 125 ++++--- scripts/lib/e2e-affected-manifest.mjs | 4 + src/app/(frontend)/(home)/page.tsx | 11 +- src/app/(frontend)/styles.css | 6 + src/components/conteudos/ContentPieceCard.tsx | 23 +- .../conteudos/ContentPieceCardParts.tsx | 48 +++ .../conteudos/ContentPieceHomeBoard.tsx | 279 ++++++++++++++++ .../conteudos/ContentPieceHomeCard.tsx | 72 ++++ .../conteudos/ContentPieceHomeSection.tsx | 34 ++ .../conteudos/ContentPieceMedia.tsx | 19 +- .../conteudos/contentPieceClasses.ts | 3 + src/lib/contentPieceHomeSelection.ts | 170 ++++++++++ src/lib/municipalityProximity.ts | 2 +- .../campaignNearestMunicipality.e2e.spec.ts | 24 +- tests/e2e/frontend.e2e.spec.ts | 10 +- tests/e2e/frontendConteudos.e2e.spec.ts | 112 +++++++ tests/helpers/featureBounds.ts | 28 +- tests/unit/campaignHome.unit.spec.tsx | 46 ++- .../unit/contentPieceHomeBoard.unit.spec.tsx | 282 ++++++++++++++++ .../contentPieceHomeSelection.unit.spec.ts | 310 ++++++++++++++++++ 22 files changed, 1645 insertions(+), 99 deletions(-) create mode 100644 docs/changelog/2026-09-24-s39.md create mode 100644 docs/plans/central-conteudos-secao-home-impl.md create mode 100644 src/components/conteudos/ContentPieceCardParts.tsx create mode 100644 src/components/conteudos/ContentPieceHomeBoard.tsx create mode 100644 src/components/conteudos/ContentPieceHomeCard.tsx create mode 100644 src/components/conteudos/ContentPieceHomeSection.tsx create mode 100644 src/lib/contentPieceHomeSelection.ts create mode 100644 tests/unit/contentPieceHomeBoard.unit.spec.tsx create mode 100644 tests/unit/contentPieceHomeSelection.unit.spec.ts diff --git a/docs/changelog/2026-09-24-s39.md b/docs/changelog/2026-09-24-s39.md new file mode 100644 index 00000000..7bc3e9e1 --- /dev/null +++ b/docs/changelog/2026-09-24-s39.md @@ -0,0 +1 @@ +**S39 — Home: seção divulgando a Central de Conteúdos (2026-09-24):** a home pública ganha, entre os cards personalizáveis e a newsletter, a seção que mostra até 3 peças publicadas da Central (`/conteudos`, S27) e leva para lá pelo CTA `Ver todas as peças`; a ordem é **município do visitante → região → mais recentes** e sem peça publicada a seção inteira não é renderizada (fail-closed pela mesma leitura cacheada/tag `contentPieces` do rodapé — despublicar reflete sem deploy). O servidor renderiza a seleção **recente** já no HTML estático (a seção nasce completa e útil sem JS/conexão ruim) e a ilha client (`ContentPieceHomeBoard`) só **troca em silêncio** para o território do visitante: GPS automático apenas quando a permissão já está `granted` (sem dialog surpresa, sem auto-prompt — o `sessionStorage` do B14 não é usado) e, fora disso, só pela affordance explícita `Usar minha localização`; a posição é casada no browser contra as malhas commitadas (zona → município → território; Salvador resolve a ZE exata pelo nome do catálogo) e **nada é persistido, logado ou cookiado** (nem a localização, nem o IP). O caminho por IP foi **confirmado inviável** nesta entrega (não há fonte de geo-IP no repo; `headers()` dinamizaria a home ISR) — sem GPS acessível a seleção degrada para recentes, exatamente a degradação prevista no literal da intenção; fix grosso (`accuracyM > COARSE_ACCURACY_M`) mantém só a região, nunca "Do seu município" com confiança falsa. Peças: projeção lean nova (`src/lib/contentPieceHomeSelection.ts`, sem `searchText`/`description` no HTML), seleção/ordenação pura com dedup e anotação de procedência (`Do seu município`/`Da sua região`/`Mais recente`), resolução ponto→visitante pura reusando `findContainingFeature`/loaders lazy, card leve (`ContentPieceHomeCard`) com `ContentPieceMedia` (mídia só no toque, `preload="none"`) e o título/meta extraídos para `ContentPieceCardParts` (dono único com o card do catálogo); cards personalizáveis (itens sintéticos) nunca entram como amostra (a garantia é a origem do pool); demais seções, rodapé, selo do S27, `/conteudos` e o `SiteHeader` intocados; sem migration/Consent/access, sem rastreio novo. Gate: `designer` trigger (b) aprovou o estado inicial sem skeleton (cena 04 do artefato atualizada; `Design tier: openai/gpt-5.6-sol`) — crítica final (c) registrada no PR. Verificação: `pnpm gate:fast` verde (lint + typecheck + 4513 unit, incluindo os novos specs da seleção/resolução e do board), e2e novo no `frontendConteudos` (seção na home + kill switch + ordem `cards < content-pieces < newsletter` + overflow 390 ≤ 1 + amostra do município com geolocalização mockada), e2e da home (`frontend`) inteiro verde com a seção publicada e o spec B14 (`campaignNearestMunicipality`) verde com o `interiorPointOf` agora compartilhado em `tests/helpers/featureBounds.ts`. diff --git a/docs/plans/central-conteudos-secao-home-impl.md b/docs/plans/central-conteudos-secao-home-impl.md new file mode 100644 index 00000000..9cd6e2e5 --- /dev/null +++ b/docs/plans/central-conteudos-secao-home-impl.md @@ -0,0 +1,135 @@ +# Impl: S39 — Home — seção divulgando a Central de Conteúdos + +Status: aprovado +Atualizado em: 2026-09-24 +Issue: #1305 +Intenção: docs/plans/central-conteudos-secao-home.md +Appetite restante: herdado + +## Leitura da intenção + +- **Outcome:** a home ganha uma seção da Central que mostra até 3 peças publicadas do território do visitante (município → região → recentes) e leva a `/conteudos`; sem peça publicada a seção inteira some (fail-closed), e nada do visitante é guardado, logado ou cookiado. +- **O que NÃO negociar:** zero peças → seção ausente; cards personalizáveis (`CARD_MODELS`) nunca são amostra; nada persistido/logado (localização e IP incluídos); sem autoplay/mídia pesada; CTA `Ver todas as peças` → `/conteudos`; kill switch pela tag `contentPieces`; rodapé, selo do S27, `SiteHeader`, `/conteudos`, `CampaignCardsSection` e as demais seções da home intocados; sem migration/Consent novo; copy pt-BR / identificadores em inglês. +- **O que reavaliar:** (1) a intenção cogita "senão IP no servidor" — **não existe geo-IP no repo** (só extração de IP: `contentEventRateLimit.ts:33-37`, `themeSearchGuard.ts:22`) e `headers()` dinamizaria a home ISR (alerta literal em `contentPieceThemeSearch.ts:59-76`; e2e converge no HTML estático em `frontend.e2e.spec.ts:763-800`). Sem GPS acessível/afordance, a seleção **degrada para recentes** — exatamente o que a própria intenção permite ("se só resolver país/estado, a seleção degrada para região/recentes"). (2) A hipótese "seção por visitante exige mecanismo novo" se resolve com uma ilha client sobre o pool já cacheado, sem superfície nova. + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph server["Server · estático/ISR"] + read["getPublishedContentPieceItems()
1 leitura · tag contentPieces"] + gate{"pool.length > 0?"} + sec["ContentPieceHomeSection
toContentPieceHomeItem → pool lean"] + absent["seção ausente"] + footer["showConteudos → rodapé"] + end + subgraph client["Client · ilha (só na seção)"] + board["ContentPieceHomeBoard
visitor + playingId"] + perm["readGeolocationPermissionState
(sem dialog)"] + btn["⌖ Usar minha localização"] + gps["requestCurrentPosition + malhas lazy"] + resolve["resolveContentPieceHomeVisitor"] + sel["selectContentPieceHomeItems
município → região → recentes"] + end + read --> gate + gate -- "sim" --> sec --> board + gate -- "não" --> absent + read --> footer + board --> perm + perm -- "granted" --> gps + perm -- "prompt/unknown" --> btn --> gps + perm -- "denied" --> sel + gps -- "fix na BA" --> resolve --> sel + gps -- "falha / fora da BA" --> sel + sel --> cards["até 3 cards + CTA /conteudos"] +``` + +**Opções consideradas:** A) ilha client recebendo o pool serializado do server e resolvendo a localização no cliente; B) server render por IP com `headers()`; C) rota de API de seleção chamada após resolver a localização. +**Recomendação:** A — a home é ISR/estática e o e2e converge no HTML do servidor; o pool já sai do cache `contentPieces` (`contentPieceReads.ts:45-61`), então a ilha só acrescenta a resolução (GPS no cliente, como o B14) e a seleção pura. Nenhuma superfície nova, nenhum dado do visitante no servidor. +**Rejeitadas:** B porque `headers()` opta a home pelo render dinâmico/streamado (duplica o DOM atrás do `loading.tsx` na build de produção) e não há fonte de geo-IP para resolver município — no máximo país; C porque adiciona endpoint público + rate limit + cache próprio sem ganho sobre o pool já cacheado. + +### Componentes / mudanças + +- **`src/lib/contentPieceHomeSelection.ts` (novo, puro/client-safe)** — contrato da seção: + - `ContentPieceHomeItem = Pick` e `toContentPieceHomeItem(item)` copiando **campo a campo** (campo novo do item público nunca vaza por spread); + - `ContentPieceHomeVisitor = { municipalityName: string | null; region: string | null }`; + - `resolveContentPieceHomeVisitor({ point, coarse?, municipalityGeometry, zoneGeometry?, territoryGeometry })` → `ContentPieceHomeVisitor | null` (fora da malha municipal = fora da Bahia = `null`); + - `ContentPieceHomeMatch = 'municipality' | 'region' | 'recent'`; `ContentPieceHomeSelection = { item; match }`; + - `selectContentPieceHomeItems(items, visitor, limit = 3)`. + Importa `featureContainsPoint`/`GeoPoint` de `@/lib/municipalityProximity`, tipos de `@/lib/bahiaGeometriesTypes`, `slugify` de `@/lib/slug` e o tipo do catálogo. **Não importa o catálogo de 435 entradas nem `@/utilities/**`** (regra do ESLint para `src/lib`). +- **`src/components/conteudos/ContentPieceHomeSection.tsx` (novo, server)** — recebe `items: readonly ContentPiecePublicItem[]`, projeta o pool lean e renderiza `
` com os blobs decorativos (`aria-hidden`) do gate + container `mx-auto w-full max-w-[1160px] px-5 py-8 sm:px-8 lg:px-10 lg:py-16` (padrão de `JingleHomeSection.tsx:34-38`) + ``. +- **`src/components/conteudos/ContentPieceHomeBoard.tsx` (novo, `'use client'`)** — dono de `visitor`/`permission`/`locating`/`attempted` e de `playingId` (um play por vez, precedente `ContentPieceCatalog.tsx:28`); no mount só lê a permissão (`readGeolocationPermissionState`, sem dialog) e resolve automático **apenas** com `granted`; o botão explícito chama `requestCurrentPosition` + `loadMunicipalityGeometryModule`/`loadMunicipalityZoneGeometryModule`/`loadTerritoryGeometryModule` (`bahiaGeometries.ts:25-37`, lazy) e `resolveContentPieceHomeVisitor`; renderiza eyebrow/título/copy/tag/cards/CTA/botão/footnote. +- **`src/components/conteudos/ContentPieceHomeCard.tsx` (novo)** — card leve do gate: `ContentPieceMedia` (asset) + tags + título (link para `publicPath`) + `metaLabel` + `Ver esta peça →`; `grid-cols-[124px_1fr]` no mobile, coluna no desktop (`lg:`); `data-content-piece={slug}` para e2e. +- **`src/components/conteudos/contentPieceClasses.ts`** — acrescentar `CONTENT_PIECE_LOCAL_TAG` (gate: `bg-[#fff3c4] text-[#6b5100]`, mesmo estilo dos hex já usados em `CONTENT_PIECE_TAG`); reusar `CONTENT_PIECE_TAG`, `CONTENT_PIECE_CARD`, `CONTENT_PIECE_PRIMARY_BUTTON` (CTA) e `CONTENT_PIECE_FOCUS`. +- **`src/components/conteudos/ContentPieceMedia.tsx`** — o prop `item` passa de `ContentPiecePublicItem` para o `Pick` estrutural que ele realmente usa (`isLink | file | type | origin | originLabel | title | excerpt | durationLabel | typeLabel`); o item completo continua atribuível, então catálogo/detalhe não mudam. +- **`src/app/(frontend)/(home)/page.tsx`** — `page.tsx:124` passa a chamar `getPublishedContentPieceItems()` **uma vez**; `showConteudos = contentPieces.length > 0`; renderizar `{contentPieces.length > 0 ? : null}` entre `CampaignCardsSection` (`:249`) e `CampaignNewsletterSection` (`:250`). +- **Migration:** sem migration (nenhuma collection/global muda). +- **Access / Consent:** sem escrita e sem PII — nenhum helper novo, nenhuma chave `Consent`, nada de cookie/localStorage/sessionStorage/log. +- **UI:** Impeccable C com gate aprovado (`docs/plans/central-conteudos-secao-home-ui-design.html`, cenas 01–06) — shape→craft→critique→polish contra as cenas, mobile primeiro (390) e desktop (1280). **Trigger (b) resolvido pelo `designer` em 2026-09-24** (cena 04 atualizada para "recentes no HTML → troca silenciosa", sem skeleton; contrato do card alinhado: duas tags, título linkado, `metaLabel`, `aspect-video self-start` no mobile). Crítica final (c) obrigatória antes do push (o diff muda UI). + +**Literais a portar (gate):** eyebrow `Central de Conteúdos`; título `Peça voto pra Solla 1313`; copy local `Comece pelo material do seu território e mande para quem você conhece.` (mobile: `Material do seu território para compartilhar.`); copy recente `Veja as peças mais recentes e escolha uma para compartilhar.`; copy de uma peça `Uma peça oficial já está pronta para você compartilhar.` (mobile: `Uma peça oficial já está pronta.` — cena 05); tag de seção `⌖ Para seu município` / `Seleção recente`; nota local `Localização usada só nesta visita` (some no mobile); tags de card `Do seu município` / `Da sua região` / `Mais recente` (a primeira é o `typeLabel`/`originLabel`, como em `ContentPieceCard.tsx:40`); CTA `Ver todas as peças →` → `/conteudos`; botão `⌖ Usar minha localização` (glifo decorativo `aria-hidden`, nome acessível `Usar minha localização`) com nota `Opcional. Sem prompt no carregamento.`; footnote `A localização não é guardada. Cards personalizáveis ficam na seção anterior.` + +### Dados → forma (se aplicável) + +Não aplicável — a intenção fixa **Dados: N/A** (nenhum número, contador ou ranking). A "forma" aqui é a ordenação de apresentação (município → região → recentes) e as tags de procedência; rejeitadas: contador de peças, score de relevância, percentuais — todos virariam KPI inventado numa superfície de descoberta. + +## Decisões de engenharia + +1. **Mecanismo por visitante — A) ilha client + pool do server.** Recomendação: **A**. Opções: A | B (server por IP) | C (rota de API). Rejeitadas: **B** (a home é ISR e `headers()` a tornaria dinâmica/streamada; não há geo-IP no repo e IP→município não é viável) e **C** (endpoint novo, rate limit e cache próprios para um pool que já está no cache `contentPieces`). +2. **Estado inicial — A) server renderiza a seleção recente; a ilha troca em silêncio para local.** Recomendação: **A**, **aprovada pelo `designer` (trigger b, 2026-09-24; `Design tier: openai/gpt-5.6-sol`)** — o artefato foi atualizado: a cena 04 agora é "Recentes já no HTML → troca silenciosa para o território quando houver match", sem skeleton. O estado inicial é o fallback final: sem espera, e sem JS a seção continua completa (recentes) em vez de esqueleto eterno. A troca só acontece com localização real (permissão `granted` no mount ou clique explícito). Rejeitada: **B** (shell + skeleton) porque sem JS o skeleton ficaria para sempre (exigiria `