mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(S39): seção da Central de Conteúdos na home (Closes #1305)
This commit is contained in:
parent
9069d23d88
commit
6d57031eb4
22 changed files with 1645 additions and 99 deletions
1
docs/changelog/2026-09-24-s39.md
Normal file
1
docs/changelog/2026-09-24-s39.md
Normal file
|
|
@ -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`.
|
||||
135
docs/plans/central-conteudos-secao-home-impl.md
Normal file
135
docs/plans/central-conteudos-secao-home-impl.md
Normal file
|
|
@ -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()<br/>1 leitura · tag contentPieces"]
|
||||
gate{"pool.length > 0?"}
|
||||
sec["ContentPieceHomeSection<br/>toContentPieceHomeItem → pool lean"]
|
||||
absent["seção ausente"]
|
||||
footer["showConteudos → rodapé"]
|
||||
end
|
||||
subgraph client["Client · ilha (só na seção)"]
|
||||
board["ContentPieceHomeBoard<br/>visitor + playingId"]
|
||||
perm["readGeolocationPermissionState<br/>(sem dialog)"]
|
||||
btn["⌖ Usar minha localização"]
|
||||
gps["requestCurrentPosition + malhas lazy"]
|
||||
resolve["resolveContentPieceHomeVisitor"]
|
||||
sel["selectContentPieceHomeItems<br/>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<ContentPiecePublicItem, 'id' | 'slug' | 'title' | 'type' | 'typeLabel' | 'origin' | 'originLabel' | 'isLink' | 'cityLabel' | 'regionLabel' | 'excerpt' | 'durationLabel' | 'metaLabel' | 'publicPath' | 'file'>` 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 `<section aria-labelledby="content-pieces-title" data-home-section="content-pieces" className="relative overflow-hidden border-b border-(--campaign-line) bg-white">` 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`) + `<ContentPieceHomeBoard items={pool} />`.
|
||||
- **`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 ? <ContentPieceHomeSection items={contentPieces} /> : 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 `<noscript>` duplicando os cards), o HTML estático deixaria de conter as peças (kill switch/e2e) e conexão ruim mostraria caixas cinzas em vez de conteúdo útil.
|
||||
3. **Payload server→client — projeção lean `ContentPieceHomeItem`.** Recomendação: **A** (Pick + `toContentPieceHomeItem` no novo módulo `src/lib/`). Rejeitadas: **B** item público completo (levaria `searchText` — haystack normalizado com `transcript`/`description`, `contentPieceCatalog.ts:549` — e `description` para o HTML de toda a home) e **C** shape duplicado à mão (duas fontes de verdade). `ContentPieceMedia` aceita o `Pick` estrutural, sem duplicar tipos.
|
||||
4. **Seleção — módulo puro novo `src/lib/contentPieceHomeSelection.ts`.** `selectContentPieceHomeItems(items, visitor, limit = 3)`: input já vem mais recente primeiro (`contentPieceReads.ts:50`); uma passada estável (município → região → recentes), dedup por `id`, anotação `match` por item; `visitor === null` → os `limit` primeiros como `'recent'`. Rejeitadas: inchar `contentPieceCatalog.ts` (561 linhas, dono do catálogo público) e ordenar dentro do componente (lógica não testável fora do React).
|
||||
5. **Resolução ponto→visitante — B) função pura nova sobre as malhas, sem catálogo.** Recomendação: **B**: contenção na ordem **zona → município → território**: zona contém → nome da zona (`Salvador — ZE 3`, igual ao catálogo); senão município contém → nome do município; território contém → `region`; fora da malha municipal → `null`. Nomes das features são os mesmos do catálogo (pinado em `bahiaGeometries.unit.spec.ts:129,157,188` e `:150-170`), então não é preciso importar `municipalityCatalog` (435 entradas) no bundle do cliente. `coarse: true` (fix com `accuracyM > COARSE_ACCURACY_M`, decisão da ilha — `campaignGeolocation.ts:25`) mantém só a região. Rejeitadas: **A** `resolveNearbyMunicipality` com `accessible = municipalityCatalog` (semântica de carteira não se aplica — não há link para abrir — e `findNearestInScope` varreria 435 centroides quando o ponto está fora, `municipalityProximity.ts:215-234`) e **C** por IP (sem geo-IP; ver D1).
|
||||
6. **Matching visitante↔peça.** `slugify(item.cityLabel) === slugify(visitor.municipalityName)` → `'municipality'`; senão `slugify(item.regionLabel) === slugify(visitor.region)` → `'region'`; senão `'recent'` (`slugify` de `src/lib/slug.ts`, a mesma linguagem do filtro do catálogo em `contentPieceCatalog.ts:255`). Salvador: visitante em ZE casa o nome exato `Salvador — ZE N`; sem zona (ponto na cidade fora dos polígonos), `municipalityName` é `Salvador` e a peça cai na região. **Contrato binário da tag de seção (designer, trigger b):** `⌖ Para seu município` **só** com match de município; seleção apenas regional (ou sem match) usa `Seleção recente` e a copy de recentes — a tag de seção nunca mente (os cards continuam com a tag de procedência individual `Da sua região`).
|
||||
7. **Botão `⌖ Usar minha localização`.** Aparece só quando `visitor === null && selection.length > 1 && !attempted && permission ∈ {prompt, unknown}`; some em `denied`, `granted` (auto), `locating` e após a primeira tentativa; falha (`timeout`/`unavailable`) mantém recentes, **sem erro, sem retry automático e sem nada persistido** — inclusive **não** reusar o `sessionStorage` do B14 (`campaignGeolocation.ts:48-65`); `denied` do navegador vira `permission = 'denied'`. Sem auto-prompt no carregamento (`useNearestMunicipalitySlug.ts:60-72` fica de fora justamente por isso).
|
||||
8. **Gating/kill switch/leitura única.** A home passa a usar `getPublishedContentPieceItems()` **uma vez** (`page.tsx:124`) e deriva `showConteudos` (rodapé) + pool; zero itens → a seção não é renderizada no server. `hasPublishedContentPieces` continua sendo o dono do flag em `cards/page.tsx:30` e `jingles/page.tsx:64` (não mexer). Kill switch: mesma tag `contentPieces` (bust no `afterChange` do `ContentPiece`, `ContentPiece.ts:227`) — despublicar reflete sem deploy.
|
||||
9. **`data-home-section`.** Valor **`content-pieces`** (curto, distinto de `contents` do S3 em `CampaignContentSection.tsx:126`), posicionado entre `cards` e `newsletter`. O pin de ordem do spec da home (`frontend.e2e.spec.ts:1778-1780`) passa a ser agnóstico da seção condicional (`sound < cards < newsletter`); a posição exata com peça publicada é pinada no spec dono das linhas (D10).
|
||||
10. **Testes.** Unit primeiro: `tests/unit/contentPieceHomeSelection.unit.spec.ts` (projeção sem `searchText`/`description`; seleção com fixtures — município/região/recentes, dedup, ordem estável, `visitor: null`, Salvador ZE e sem zona; resolução com quadrados sintéticos no padrão de `municipalityProximity.unit.spec.ts:15-62`, incluindo `coarse` e fora da malha) e `tests/unit/contentPieceHomeBoard.unit.spec.tsx` (renderiza recente, botão visível com `prompt`, ausente com `denied`, e troca para local com `granted` + fix mockado — mocks de `campaignGeolocation` e `bahiaGeometries`, no molde de `jingleHomeSection.unit.spec.tsx`). **Int: nenhum** (sem fronteira Payload/DB nova; a leitura pública já é pinada em `tests/int/contentPiece.int.spec.ts:811`). **E2e: estender `tests/e2e/frontendConteudos.e2e.spec.ts`** (serial, dono das linhas, helpers de seed/limpeza em `:124-167`; o projeto já serializa atrás de `frontend` em dev) — spec nova duplicaria a infra de seed e criaria dois donos das mesmas linhas. Dois testes novos: (a) home + kill switch (1 peça → cena 05; 2 peças → recente + botão + CTA + posição `cards < content-pieces < newsletter` + overflow 390 ≤ 1; despublicar → seção ausente por poll do HTML do servidor); (b) localizado (`grantPermissions(['geolocation'])` + `setGeolocation` num ponto interno de Feira de Santana calculado pela malha, no padrão de `campaignNearestMunicipality.e2e.spec.ts:67-82,149-150`, com a peça seedada com `municipality` → `⌖ Para seu município` + `Do seu município`). Manifesto: acrescentar `src/lib/contentPieceHomeSelection.ts` e `src/app/(frontend)/(home)` ao entry do S27 (`e2e-affected-manifest.mjs:153-180`) — a página é a única fiação da seção e um diff só nela precisa acordar `frontendConteudos`; o entry genérico `src/app/(frontend)` (`:81-83`) continua acordando `frontend`. **E2e local afetado (OPS72):** `frontend` + `frontendConteudos` (`pnpm test:e2e:affected` ou `pnpm test:e2e --no-deps --project=frontend` / `--project=frontendConteudos`).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — lógica pura + fiação mínima (~1 dia).** `src/lib/contentPieceHomeSelection.ts` + `tests/unit/contentPieceHomeSelection.unit.spec.ts`; `page.tsx` com a leitura única e a seção condicional renderizando a seleção recente + CTA (server, sem ilha ainda). Verificação: `pnpm gate:fast` verde; kill switch visível no HTML (com peça → seção; sem peça → ausente).
|
||||
2. **UI — ilha + card + classes (~1–1,5 dia).** `ContentPieceHomeBoard`, `ContentPieceHomeCard`, `CONTENT_PIECE_LOCAL_TAG`, retype do `ContentPieceMedia`, estados do gate (localizado/recente/uma peça/botão) e `tests/unit/contentPieceHomeBoard.unit.spec.tsx`. Verificação: `pnpm gate:fast`; inspeção 390/1280 contra as cenas 01–06 (sem overflow; mídia só no toque).
|
||||
3. **Gates + e2e (~0,5 dia).** Estender `frontendConteudos.e2e.spec.ts`, atualizar `frontend.e2e.spec.ts:1778-1780`, ajustar o manifesto. Verificação: `pnpm test:e2e --no-deps --project=frontend` e `--project=frontendConteudos`; `pnpm gate:fast`; push via `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Geo-IP/`headers()`/rota de API; cache ou seleção por visitante no servidor; persistir a localização (nem `sessionStorage`); auto-prompt; reusar `resolveNearbyMunicipality`, `useNearestMunicipalitySlug` ou `NearestMunicipalityCard` (semântica de carteira/auto-prompt).
|
||||
- Migration, collection, global, Consent; tocar rodapé, selo do S27, `SiteHeader`, `/conteudos`, `CampaignContentSection` (S3), `CampaignCardsSection`, newsletter ou `#44 PUB2`.
|
||||
- Filtros/busca/download/share na seção (mini-Central); autoplay/preview animado; hardcode de peças para nunca esvaziar; cards sintéticos do estúdio como amostra (a garantia é a origem do pool: `ContentPiecePublicItem[]`).
|
||||
- Spec e2e nova/segunda infra de seed; cap/virtualização do pool; personalizar o resto da home.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Pool no HTML cresce com o catálogo:** projeção lean (sem `searchText`/`description`); gatilho de revisitação — se a lista publicada passar de algumas centenas, reavaliar (fatiar no server exigiria nova decisão, não fazer agora).
|
||||
- **Concorrência e2e (CI prod mode):** `frontendConteudos` publica peças enquanto o spec da home roda em paralelo → o pin de ordem do `frontend` fica agnóstico da seção condicional; a posição exata é pinada no spec dono das linhas.
|
||||
- **Kill switch/ISR:** a seção depende da tag `contentPieces` (mesmo contrato do rodapé); o e2e converge por poll do HTML do servidor antes de navegar (padrão `frontend.e2e.spec.ts:763-800`).
|
||||
- **Fix grosso errando o município:** `coarse` (> `COARSE_ACCURACY_M`) mantém só a região — nunca "Do seu município" com confiança falsa.
|
||||
- **Retype do `ContentPieceMedia`:** Pick estrutural mantém o item completo atribuível; catálogo e página de peça seguem cobertos pelos e2e existentes do S27.
|
||||
- **Hidratação:** render inicial sempre com `visitor = null` (recentes); a resolução só roda em efeito no cliente → sem mismatch SSR/cliente.
|
||||
- **Safari (`permissions` sem descritor):** `unknown` → botão explícito, sem prompt surpresa.
|
||||
|
||||
## Débitos da revisão (simplify) — explicitamente fora / adiado com gatilho
|
||||
|
||||
- **Já resolvido no simplify (não reabrir):** teste do clique da affordance + `unknown` do Safari; `interiorPointOf` único em `tests/helpers/featureBounds.ts`; título/meta do card em `ContentPieceCardParts.tsx`; `aria-hidden` do `⌖`; guards unificados; foco mantido + anúncio do desfecho; fixtures/typing dos testes; pin de ordem no spec dono; casos unit da projeção/limite/peça-link.
|
||||
- **Adiado com gatilho — D2:** consolidar a regra label↔faceta (`slugify`) num helper do catálogo quando houver **3º consumidor** ou quando um label mudar (ex.: `Salvador — ZE N`) e exigir fix em 2 lugares.
|
||||
- **Adiado com gatilho — D3:** extrair o poll do HTML ISR (`waitForHomeSection`/`waitForHomeHTML`) para `tests/helpers/` quando um **3º spec** precisar dele ou quando o orçamento/padrão do poll tiver de mudar em 2+ cópias.
|
||||
- **Descartado — D4:** manter a metade ponto→visitante no módulo da home (o primitivo `findContainingFeature` é reusado do dono da proximidade; ordem zona→município→território, `coarse` e vocabulário do item são contrato da seção, não semântica de carteira). Revisitar só com 2º consumidor de ponto→label.
|
||||
- **D1** (pool no HTML) segue no §Riscos com o gatilho de "algumas centenas de peças publicadas".
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: até 3 peças (município → região → recentes); sem localização/fora da Bahia → recentes; zero peças → seção ausente no server; CTA → `/conteudos`; kill switch sem deploy (tag `contentPieces`); nada guardado/logado/cookiado; cards personalizáveis nunca como amostra; demais seções/rodapé/selo/`/conteudos` intocados; mobile 390 sem overflow; mídia só no toque.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: `src/lib` sem importar `@/utilities/**` (a ilha calcula `coarse` com `COARSE_ACCURACY_M`); client importa de server só `import type`; copy pt-BR / identificadores em inglês; sem migration; sem Consent novo; sem PII.
|
||||
- [ ] Testes de domínio previstos: unit da projeção/seleção/resolução + unit do board; e2e estendido (home/kill switch + localizado); int intocado (nenhuma fronteira Payload/DB nova).
|
||||
- [ ] Gates: `pnpm gate:fast`; e2e afetado (`frontend`, `frontendConteudos`); `pnpm push`.
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
4,5/5.
|
||||
|
||||
1. **Decisões caras com rejeitadas (5/5):** mecanismo, estado inicial, payload, módulo de seleção, resolução e ownership do e2e têm Opções + Recomendação + rejeitadas explícitas (D1–D5, D10).
|
||||
2. **Cabe no appetite herdado (4,5/5):** sem schema/API; 3 componentes novos + 1 módulo puro + 2 specs unit + extensão do e2e; o custo concentra-se na UI da seção e no e2e, dentro dos ~2–3 dias.
|
||||
3. **Rabbit holes nomeados (5/5):** geo-IP, persistência, auto-prompt, mini-Central, cap do pool, spec e2e nova, cards sintéticos.
|
||||
4. **Depth check (4,5/5):** reusa `ContentPieceMedia`/`contentPieceClasses`/cache+tag/`featureContainsPoint`/loaders lazy/`readGeolocationPermissionState`/infra e2e; o único módulo novo é a lógica pura da home (a projeção evita duplicar tipos).
|
||||
5. **Intenção preservada (5/5):** outcome intacto; as reavaliações (mecanismo client e degradação para recentes sem IP) são as que a própria intenção delegou/antecipou.
|
||||
|
|
@ -119,10 +119,6 @@
|
|||
text-transform: uppercase;
|
||||
color: #a21c1c;
|
||||
}
|
||||
.skeleton {
|
||||
background: linear-gradient(100deg, #eee 20%, #ddd 38%, #eee 56%);
|
||||
background-size: 220% 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="board min-h-screen text-black antialiased">
|
||||
|
|
@ -183,7 +179,9 @@
|
|||
<div class="flex flex-wrap gap-1">
|
||||
<span class="tag">Foto</span><span class="tag local">Do seu município</span>
|
||||
</div>
|
||||
<h3 class="mt-2 font-extrabold">Título da peça local</h3>
|
||||
<h3 class="mt-2 font-extrabold">
|
||||
<a href="#detalhe-da-peca">Título da peça local</a>
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-[#6c615e]">Seu município · Tema</p>
|
||||
<a class="mt-4 inline-block text-xs font-bold text-[#a21c1c]"
|
||||
>Ver esta peça →</a
|
||||
|
|
@ -205,7 +203,9 @@
|
|||
<div class="flex flex-wrap gap-1">
|
||||
<span class="tag">Vídeo</span><span class="tag local">Da sua região</span>
|
||||
</div>
|
||||
<h3 class="mt-2 font-extrabold">Título da peça regional</h3>
|
||||
<h3 class="mt-2 font-extrabold">
|
||||
<a href="#detalhe-da-peca">Título da peça regional</a>
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-[#6c615e]">Sua região · sem autoplay</p>
|
||||
<a class="mt-4 inline-block text-xs font-bold text-[#a21c1c]"
|
||||
>Ver esta peça →</a
|
||||
|
|
@ -224,7 +224,9 @@
|
|||
<div class="flex flex-wrap gap-1">
|
||||
<span class="tag">Áudio</span><span class="tag">Mais recente</span>
|
||||
</div>
|
||||
<h3 class="mt-2 font-extrabold">Mensagem recente para compartilhar</h3>
|
||||
<h3 class="mt-2 font-extrabold">
|
||||
<a href="#detalhe-da-peca">Mensagem recente para compartilhar</a>
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-[#6c615e]">Completa a seleção local</p>
|
||||
<a class="mt-4 inline-block text-xs font-bold text-[#a21c1c]"
|
||||
>Ver esta peça →</a
|
||||
|
|
@ -277,15 +279,20 @@
|
|||
</div>
|
||||
<div class="relative mt-6 space-y-4">
|
||||
<article class="preview grid grid-cols-[124px_1fr]">
|
||||
<div class="asset min-h-36">NEEDS ASSET<br />FOTO REAL</div>
|
||||
<div class="asset aspect-video self-start">NEEDS ASSET<br />FOTO REAL</div>
|
||||
<div class="p-4 text-left">
|
||||
<span class="tag local">Do seu município</span>
|
||||
<h3 class="mt-2 text-sm font-extrabold">Título da peça local</h3>
|
||||
<a class="mt-3 block text-xs font-bold text-[#a21c1c]">Ver peça →</a>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="tag">Foto</span><span class="tag local">Do seu município</span>
|
||||
</div>
|
||||
<h3 class="mt-2 text-sm font-extrabold">
|
||||
<a href="#detalhe-da-peca">Título da peça local</a>
|
||||
</h3>
|
||||
<p class="mt-1 text-[11px] text-[#6c615e]">Meta da peça</p>
|
||||
<a class="mt-3 block text-xs font-bold text-[#a21c1c]">Ver esta peça →</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="preview grid grid-cols-[124px_1fr]">
|
||||
<div class="asset relative min-h-36">
|
||||
<div class="asset relative aspect-video self-start">
|
||||
<span>NEEDS ASSET<br />POSTER REAL</span
|
||||
><span
|
||||
class="absolute grid size-10 place-items-center rounded-full bg-[#ffe607] text-black"
|
||||
|
|
@ -293,18 +300,27 @@
|
|||
>
|
||||
</div>
|
||||
<div class="p-4 text-left">
|
||||
<span class="tag local">Da sua região</span>
|
||||
<h3 class="mt-2 text-sm font-extrabold">Título da peça regional</h3>
|
||||
<p class="mt-1 text-[11px] text-[#6c615e]">Só carrega no toque</p>
|
||||
<a class="mt-3 block text-xs font-bold text-[#a21c1c]">Ver peça →</a>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="tag">Vídeo</span><span class="tag local">Da sua região</span>
|
||||
</div>
|
||||
<h3 class="mt-2 text-sm font-extrabold">
|
||||
<a href="#detalhe-da-peca">Título da peça regional</a>
|
||||
</h3>
|
||||
<p class="mt-1 text-[11px] text-[#6c615e]">Meta da peça</p>
|
||||
<a class="mt-3 block text-xs font-bold text-[#a21c1c]">Ver esta peça →</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="preview grid grid-cols-[124px_1fr]">
|
||||
<div class="asset min-h-36">NEEDS ASSET<br />CAPA REAL</div>
|
||||
<div class="asset aspect-video self-start">NEEDS ASSET<br />CAPA REAL</div>
|
||||
<div class="p-4 text-left">
|
||||
<span class="tag">Mais recente</span>
|
||||
<h3 class="mt-2 text-sm font-extrabold">Mensagem recente</h3>
|
||||
<a class="mt-3 block text-xs font-bold text-[#a21c1c]">Ver peça →</a>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="tag">Áudio</span><span class="tag">Mais recente</span>
|
||||
</div>
|
||||
<h3 class="mt-2 text-sm font-extrabold">
|
||||
<a href="#detalhe-da-peca">Mensagem recente</a>
|
||||
</h3>
|
||||
<p class="mt-1 text-[11px] text-[#6c615e]">Meta da peça</p>
|
||||
<a class="mt-3 block text-xs font-bold text-[#a21c1c]">Ver esta peça →</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -404,36 +420,39 @@
|
|||
<section>
|
||||
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
|
||||
<span>CENA 04</span>
|
||||
<h2>Resolução da localização · transitória, não bloqueia e termina em recentes</h2>
|
||||
<h2>Recentes já no HTML → troca silenciosa para o território quando houver match</h2>
|
||||
</div>
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<article class="frame rounded-xl bg-white p-6">
|
||||
<span class="rounded-full bg-[#fff3c4] px-2 py-1 text-[10px] font-black text-[#6b5100]"
|
||||
>INSTANTE INICIAL</span
|
||||
>HTML INICIAL · CONTEÚDO ÚTIL</span
|
||||
>
|
||||
<h3 class="display mt-3 text-xl font-black">Peça voto pra Solla 1313</h3>
|
||||
<p class="mt-2 text-sm leading-5 text-[#6c615e]">
|
||||
A home já está utilizável. Só esta amostra define a melhor seleção.
|
||||
A seção nasce completa com as peças recentes. Sem JavaScript ou em conexão ruim, este
|
||||
já é o estado final utilizável.
|
||||
</p>
|
||||
<div class="mt-5 grid grid-cols-3 gap-2">
|
||||
<div class="skeleton aspect-video rounded-lg"></div>
|
||||
<div class="skeleton aspect-video rounded-lg"></div>
|
||||
<div class="skeleton aspect-video rounded-lg"></div>
|
||||
<span class="tag mt-4">Seleção recente</span>
|
||||
<div class="mt-4 grid grid-cols-3 gap-2">
|
||||
<div class="asset aspect-video rounded-lg">PREVIEW</div>
|
||||
<div class="asset aspect-video rounded-lg">PREVIEW</div>
|
||||
<div class="asset aspect-video rounded-lg">PREVIEW</div>
|
||||
</div>
|
||||
<p class="mt-4 text-xs font-bold text-[#6c615e]">
|
||||
Sem modal, sem overlay e sem prompt automático.
|
||||
Sem skeleton, modal, overlay ou prompt automático.
|
||||
</p>
|
||||
</article>
|
||||
<article class="frame rounded-xl bg-white p-6">
|
||||
<span class="rounded-full bg-green-100 px-2 py-1 text-[10px] font-black text-green-800"
|
||||
>LOCALIZAÇÃO ACESSÍVEL</span
|
||||
>MATCH LOCAL ENCONTRADO</span
|
||||
>
|
||||
<h3 class="display mt-3 text-xl font-black">Seleção do seu território</h3>
|
||||
<p class="mt-2 text-sm leading-5 text-[#6c615e]">
|
||||
GPS já acessível; senão, recorte aproximado por IP. A interface entrega o mesmo
|
||||
resultado: município → região → recentes.
|
||||
Com a permissão já concedida ou após o toque em “Usar minha localização”, a amostra
|
||||
troca em silêncio: município → região → recentes.
|
||||
</p>
|
||||
<div class="mt-5 flex gap-2">
|
||||
<span class="tag local mt-4">⌖ Para seu município</span>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<span class="tag local">Do seu município</span
|
||||
><span class="tag local">Da sua região</span>
|
||||
</div>
|
||||
|
|
@ -443,16 +462,20 @@
|
|||
</article>
|
||||
<article class="frame rounded-xl bg-white p-6">
|
||||
<span class="rounded-full bg-[#eef4fb] px-2 py-1 text-[10px] font-black text-[#184e92]"
|
||||
>SEM SINAL ÚTIL</span
|
||||
>SEM MATCH LOCAL</span
|
||||
>
|
||||
<h3 class="display mt-3 text-xl font-black">Seleção recente</h3>
|
||||
<h3 class="display mt-3 text-xl font-black">Recentes permanecem</h3>
|
||||
<p class="mt-2 text-sm leading-5 text-[#6c615e]">
|
||||
A espera termina e as peças recentes aparecem. Nenhum erro é mostrado.
|
||||
Sem sinal, fora da Bahia ou sem permissão, nada some nem espera: a seleção recente que
|
||||
já estava visível permanece.
|
||||
</p>
|
||||
<div class="mt-5 h-24 rounded-lg border bg-[#f9faff] p-3 text-xs text-[#6c615e]">
|
||||
Preview recente disponível
|
||||
<div class="mt-5 rounded-lg border bg-[#f9faff] p-3">
|
||||
<span class="tag">Seleção recente</span>
|
||||
<p class="mt-2 text-xs text-[#6c615e]">Preview recente continua disponível</p>
|
||||
</div>
|
||||
<p class="mt-4 text-xs font-bold text-[#184e92]">Nunca há spinner eterno.</p>
|
||||
<p class="mt-4 text-xs font-bold text-[#184e92]">
|
||||
Sem erro e sem troca enganosa da tag.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -473,13 +496,19 @@
|
|||
<p class="mt-3 max-w-lg text-base leading-6 text-[#6c615e]">
|
||||
Uma peça oficial já está pronta para você compartilhar.
|
||||
</p>
|
||||
<span class="tag mt-3">Seleção recente</span>
|
||||
<a class="cta mt-6" href="/conteudos">Ver todas as peças →</a>
|
||||
</div>
|
||||
<article class="preview">
|
||||
<div class="asset aspect-video">NEEDS ASSET · PREVIEW REAL</div>
|
||||
<div class="p-4">
|
||||
<span class="tag">Foto</span>
|
||||
<h3 class="mt-2 font-extrabold">Título da única peça publicada</h3>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="tag">Foto</span><span class="tag">Mais recente</span>
|
||||
</div>
|
||||
<h3 class="mt-2 font-extrabold">
|
||||
<a href="#detalhe-da-peca">Título da única peça publicada</a>
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-[#6c615e]">Meta da peça</p>
|
||||
<a class="mt-3 inline-block text-xs font-bold text-[#a21c1c]"
|
||||
>Ver esta peça →</a
|
||||
>
|
||||
|
|
@ -499,12 +528,18 @@
|
|||
<p class="mt-3 text-sm leading-5 text-[#6c615e]">
|
||||
Uma peça oficial já está pronta.
|
||||
</p>
|
||||
<article class="preview mt-6 text-left">
|
||||
<div class="asset aspect-video">NEEDS ASSET · PREVIEW REAL</div>
|
||||
<span class="tag mt-3">Seleção recente</span>
|
||||
<article class="preview mt-6 grid grid-cols-[124px_1fr] text-left">
|
||||
<div class="asset aspect-video self-start">NEEDS ASSET<br />PREVIEW REAL</div>
|
||||
<div class="p-4">
|
||||
<span class="tag">Foto</span>
|
||||
<h3 class="mt-2 font-extrabold">Título da única peça publicada</h3>
|
||||
<a class="mt-3 block text-xs font-bold text-[#a21c1c]">Ver peça →</a>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="tag">Foto</span><span class="tag">Mais recente</span>
|
||||
</div>
|
||||
<h3 class="mt-2 text-sm font-extrabold">
|
||||
<a href="#detalhe-da-peca">Título da única peça publicada</a>
|
||||
</h3>
|
||||
<p class="mt-1 text-[11px] text-[#6c615e]">Meta da peça</p>
|
||||
<a class="mt-3 block text-xs font-bold text-[#a21c1c]">Ver esta peça →</a>
|
||||
</div>
|
||||
</article>
|
||||
<a class="cta mt-6 w-full" href="/conteudos">Ver todas as peças →</a>
|
||||
|
|
|
|||
|
|
@ -156,11 +156,15 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
// the kill-switch contract over the C211 listing tag. C213 adds the
|
||||
// anonymous beacon endpoint, its vocabulary and the event collection the
|
||||
// public page (and the public download) write to.
|
||||
// S39 — the home sample is wired only by the home page and the pure
|
||||
// selection module; the section components live under `components/conteudos`.
|
||||
prefixes: [
|
||||
'src/app/(frontend)/conteudos',
|
||||
'src/app/(frontend)/(home)',
|
||||
'src/app/(frontend)/api/content-events',
|
||||
'src/components/conteudos',
|
||||
'src/lib/contentPieceCatalog',
|
||||
'src/lib/contentPieceHomeSelection.ts',
|
||||
'src/lib/contentPieceShare',
|
||||
'src/lib/contentEvents.ts',
|
||||
'src/utilities/content/contentPieceReads.ts',
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import { CampaignFooter } from '@/components/CampaignFooter'
|
|||
import { CampaignHero } from '@/components/CampaignHero'
|
||||
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
|
||||
import { CampaignStorySection } from '@/components/CampaignStorySection'
|
||||
import { ContentPieceHomeSection } from '@/components/conteudos/ContentPieceHomeSection'
|
||||
import { JingleHomeSection } from '@/components/jingles/JingleHomeSection'
|
||||
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
|
||||
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
|
||||
import { getPublishedContentPieceItems } from '@/utilities/content/contentPieceReads'
|
||||
import { getPublishedJingleItems } from '@/utilities/jingleReads'
|
||||
import type { ReactNode } from 'react'
|
||||
import { CampaignCardsSection } from './CampaignCardsSection'
|
||||
|
|
@ -120,8 +121,9 @@ export default async function HomePage() {
|
|||
// S21/S22 — one cached listing (`unstable_cache` tag `jingles`) serves the
|
||||
// home sound section and the footer's discovery flag, keeping the home static.
|
||||
const jingles = await getPublishedJingleItems()
|
||||
// S27 — the Central's discovery flag (cached under the `contentPieces` tag).
|
||||
const showConteudos = await hasPublishedContentPieces()
|
||||
// S27/S39 — one cached listing (tag `contentPieces`) feeds both the discovery
|
||||
// flag of the footer and the home sample; zero pieces hides both (fail-closed).
|
||||
const contentPieces = await getPublishedContentPieceItems()
|
||||
const homeJingles = jingles.slice(0, 3)
|
||||
|
||||
return (
|
||||
|
|
@ -247,10 +249,11 @@ export default async function HomePage() {
|
|||
<CampaignStorySection />
|
||||
<JingleHomeSection jingles={homeJingles} showAll={jingles.length > homeJingles.length} />
|
||||
<CampaignCardsSection />
|
||||
{contentPieces.length > 0 ? <ContentPieceHomeSection items={contentPieces} /> : null}
|
||||
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
|
||||
</main>
|
||||
|
||||
<CampaignFooter showJingles={jingles.length > 0} showConteudos={showConteudos} />
|
||||
<CampaignFooter showJingles={jingles.length > 0} showConteudos={contentPieces.length > 0} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1162,3 +1162,9 @@ body,
|
|||
.campaign-sound-title {
|
||||
font-size: clamp(34px, calc(1.7vw + 18px), 40px);
|
||||
}
|
||||
|
||||
/* S39 — home "Central de Conteúdos": 30px mobile → 36px desktop (cenas 01/02),
|
||||
above the shared 22–24px token without touching it. */
|
||||
.campaign-content-pieces-title {
|
||||
font-size: clamp(30px, calc(0.94vw + 24px), 36px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/conten
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
|
||||
import { ContentPieceCardMeta, ContentPieceCardTitle } from './ContentPieceCardParts'
|
||||
import { ContentPieceMedia } from './ContentPieceMedia'
|
||||
import {
|
||||
CONTENT_PIECE_CARD,
|
||||
|
|
@ -82,20 +83,6 @@ const CardThemeEvidence = ({ item }: { item: ContentPiecePublicItem }) => {
|
|||
)
|
||||
}
|
||||
|
||||
const CardTitle = ({ item, className }: { item: ContentPiecePublicItem; className?: string }) => (
|
||||
<h3 className={cn('mt-2 font-[family-name:var(--font-exo2)] font-extrabold', className)}>
|
||||
<Link
|
||||
href={item.publicPath}
|
||||
className="rounded-sm text-black underline-offset-4 hover:underline focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-red)"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</h3>
|
||||
)
|
||||
|
||||
const CardMeta = ({ item }: { item: ContentPiecePublicItem }) =>
|
||||
item.metaLabel ? <p className="mt-1 text-xs text-(--campaign-muted)">{item.metaLabel}</p> : null
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -130,8 +117,8 @@ export const ContentPieceCard = ({
|
|||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTag item={item} themeMode={themeMode} />
|
||||
<CardTitle item={item} className="text-sm" />
|
||||
<CardMeta item={item} />
|
||||
<ContentPieceCardTitle item={item} className="text-sm" />
|
||||
<ContentPieceCardMeta item={item} />
|
||||
<CardThemeEvidence item={item} />
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<ContentPieceDownloadLink item={item} className="min-h-9 px-3" />
|
||||
|
|
@ -157,8 +144,8 @@ export const ContentPieceCard = ({
|
|||
/>
|
||||
<div className="p-4">
|
||||
<CardTag item={item} themeMode={themeMode} />
|
||||
<CardTitle item={item} className="text-base" />
|
||||
<CardMeta item={item} />
|
||||
<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} />
|
||||
|
|
|
|||
48
src/components/conteudos/ContentPieceCardParts.tsx
Normal file
48
src/components/conteudos/ContentPieceCardParts.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { CONTENT_PIECE_FOCUS } from './contentPieceClasses'
|
||||
|
||||
/**
|
||||
* S27/S39 — the title and the metadata line shared by the catalogue card and
|
||||
* the home sample card. Both take the structural subset they read, so the
|
||||
* leaner home projection renders through the same markup (one owner for the
|
||||
* link, the focus ring and the meta contract).
|
||||
*/
|
||||
|
||||
type ContentPieceCardTitleItem = Pick<ContentPiecePublicItem, 'title' | 'publicPath'>
|
||||
|
||||
export const ContentPieceCardTitle = ({
|
||||
item,
|
||||
className,
|
||||
}: {
|
||||
item: ContentPieceCardTitleItem
|
||||
className?: string
|
||||
}) => (
|
||||
<h3 className={cn('mt-2 font-[family-name:var(--font-exo2)] font-extrabold', className)}>
|
||||
<Link
|
||||
href={item.publicPath}
|
||||
className={cn(
|
||||
'rounded-sm text-black underline-offset-4 hover:underline',
|
||||
CONTENT_PIECE_FOCUS,
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</h3>
|
||||
)
|
||||
|
||||
type ContentPieceCardMetaItem = Pick<ContentPiecePublicItem, 'metaLabel'>
|
||||
|
||||
export const ContentPieceCardMeta = ({
|
||||
item,
|
||||
className,
|
||||
}: {
|
||||
item: ContentPieceCardMetaItem
|
||||
className?: string
|
||||
}) =>
|
||||
item.metaLabel ? (
|
||||
<p className={cn('mt-1 text-xs text-(--campaign-muted)', className)}>{item.metaLabel}</p>
|
||||
) : null
|
||||
279
src/components/conteudos/ContentPieceHomeBoard.tsx
Normal file
279
src/components/conteudos/ContentPieceHomeBoard.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
loadMunicipalityGeometryModule,
|
||||
loadMunicipalityZoneGeometryModule,
|
||||
loadTerritoryGeometryModule,
|
||||
} from '@/lib/bahiaGeometries'
|
||||
import {
|
||||
resolveContentPieceHomeVisitor,
|
||||
selectContentPieceHomeItems,
|
||||
type ContentPieceHomeItem,
|
||||
type ContentPieceHomeVisitor,
|
||||
} from '@/lib/contentPieceHomeSelection'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
COARSE_ACCURACY_M,
|
||||
readGeolocationPermissionState,
|
||||
requestCurrentPosition,
|
||||
type GeolocationPermissionState,
|
||||
} from '@/utilities/campaignGeolocation'
|
||||
|
||||
import { ContentPieceHomeCard } from './ContentPieceHomeCard'
|
||||
import {
|
||||
CONTENT_PIECE_FOCUS,
|
||||
CONTENT_PIECE_LOCAL_TAG,
|
||||
CONTENT_PIECE_PRIMARY_BUTTON,
|
||||
CONTENT_PIECE_TAG,
|
||||
} from './contentPieceClasses'
|
||||
|
||||
/**
|
||||
* S39 — the Central sample board (artefato: cenas 01–05). The server renders
|
||||
* the recent selection; this island only upgrades it in silence when the
|
||||
* visitor's location is already available: permission `granted` resolves
|
||||
* without a dialog on mount, anything else needs the explicit "Usar minha
|
||||
* localização" affordance. Nothing is persisted, logged or sent anywhere — the
|
||||
* fix is matched against the committed meshes in the browser and dies here.
|
||||
*
|
||||
* No auto-prompt (unlike the staff B14 card): a surprise dialog on the home
|
||||
* burns trust, and the recent selection is already useful without it.
|
||||
*/
|
||||
|
||||
const COPY = {
|
||||
local: {
|
||||
desktop: 'Comece pelo material do seu território e mande para quem você conhece.',
|
||||
mobile: 'Material do seu território para compartilhar.',
|
||||
},
|
||||
recent: {
|
||||
desktop: 'Veja as peças mais recentes e escolha uma para compartilhar.',
|
||||
mobile: 'Veja as peças mais recentes.',
|
||||
},
|
||||
single: {
|
||||
desktop: 'Uma peça oficial já está pronta para você compartilhar.',
|
||||
mobile: 'Uma peça oficial já está pronta.',
|
||||
},
|
||||
} as const
|
||||
|
||||
const LOCATE_BUTTON = cn(
|
||||
'inline-flex min-h-9 items-center gap-1.5 rounded-lg px-2 text-xs font-extrabold text-(--pt-red) underline underline-offset-4',
|
||||
CONTENT_PIECE_FOCUS,
|
||||
)
|
||||
|
||||
export const ContentPieceHomeBoard = ({ items }: { items: readonly ContentPieceHomeItem[] }) => {
|
||||
const [permission, setPermission] = useState<GeolocationPermissionState | null>(null)
|
||||
const [visitor, setVisitor] = useState<ContentPieceHomeVisitor | null>(null)
|
||||
const [locating, setLocating] = useState(false)
|
||||
const [attempted, setAttempted] = useState(false)
|
||||
const [playingId, setPlayingId] = useState<number | null>(null)
|
||||
const autoStartedRef = useRef(false)
|
||||
|
||||
const locate = useCallback(async () => {
|
||||
setLocating(true)
|
||||
setAttempted(true)
|
||||
|
||||
/*
|
||||
* The geometry chunks are shared with the staff map, so they are usually
|
||||
* cached; the dynamic imports can still fail on a weak connection, and the
|
||||
* loader memoizes the rejection — without the catch the section would keep
|
||||
* the recent selection silently, which is exactly the honest fallback.
|
||||
*/
|
||||
const loaded = await Promise.all([
|
||||
requestCurrentPosition(),
|
||||
loadMunicipalityGeometryModule(),
|
||||
]).catch(() => null)
|
||||
|
||||
if (!loaded || !loaded[0].ok) {
|
||||
setLocating(false)
|
||||
return
|
||||
}
|
||||
|
||||
const [result, municipalityGeometry] = loaded
|
||||
|
||||
const [zoneGeometry, territoryGeometry] = await Promise.all([
|
||||
loadMunicipalityZoneGeometryModule().catch(() => null),
|
||||
loadTerritoryGeometryModule().catch(() => null),
|
||||
])
|
||||
|
||||
setVisitor(
|
||||
resolveContentPieceHomeVisitor({
|
||||
point: result.fix,
|
||||
coarse: result.fix.accuracyM > COARSE_ACCURACY_M,
|
||||
municipalityGeometry,
|
||||
zoneGeometry,
|
||||
territoryGeometry,
|
||||
}),
|
||||
)
|
||||
setLocating(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
* One automatic attempt per mount (the B14 guard): a re-render must never
|
||||
* fire a second position request, and Strict Mode re-runs this effect on
|
||||
* the same instance — cancelling the first run while the ref blocks the
|
||||
* second would leave the board idle forever.
|
||||
*/
|
||||
if (autoStartedRef.current) return
|
||||
autoStartedRef.current = true
|
||||
|
||||
const start = async () => {
|
||||
const state = await readGeolocationPermissionState()
|
||||
setPermission(state)
|
||||
if (state === 'granted') void locate()
|
||||
}
|
||||
|
||||
void start()
|
||||
}, [locate])
|
||||
|
||||
const selection = selectContentPieceHomeItems(items, visitor)
|
||||
const hasMunicipalityMatch = selection.some((entry) => entry.match === 'municipality')
|
||||
const isSingle = selection.length === 1
|
||||
const showLocateButton =
|
||||
(permission === 'prompt' || permission === 'unknown') &&
|
||||
visitor === null &&
|
||||
selection.length > 1 &&
|
||||
// The button stays mounted (disabled) while the fix is in flight, so the
|
||||
// tap does not drop the keyboard focus; after the attempt it is gone.
|
||||
(!attempted || locating)
|
||||
const copy = isSingle ? COPY.single : hasMunicipalityMatch ? COPY.local : COPY.recent
|
||||
|
||||
return (
|
||||
<>
|
||||
<p aria-live="polite" className="sr-only">
|
||||
{locating
|
||||
? 'Localizando…'
|
||||
: visitor !== null
|
||||
? hasMunicipalityMatch
|
||||
? 'Peças do seu município'
|
||||
: 'Peças recentes'
|
||||
: ''}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
'relative',
|
||||
isSingle && 'lg:grid lg:grid-cols-[1fr_360px] lg:items-center lg:gap-10',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(isSingle ? 'text-center lg:text-left' : 'mx-auto max-w-2xl text-center')}
|
||||
>
|
||||
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
|
||||
Central de Conteúdos
|
||||
</p>
|
||||
<h2
|
||||
id="content-pieces-title"
|
||||
className="campaign-section-title campaign-content-pieces-title m-0 mt-2 border-0 p-0 font-black tracking-[-0.02em] text-balance"
|
||||
>
|
||||
Peça voto pra
|
||||
<br className="sm:hidden" /> Solla 1313
|
||||
</h2>
|
||||
<p className="campaign-section-copy mx-auto mt-3 max-w-xl text-(--campaign-muted) lg:mx-0">
|
||||
<span className="sm:hidden">{copy.mobile}</span>
|
||||
<span className="hidden sm:inline">{copy.desktop}</span>
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3 flex flex-wrap items-center gap-2',
|
||||
isSingle ? 'justify-center lg:justify-start' : 'justify-center',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
data-sample-tag={hasMunicipalityMatch ? 'municipality' : 'recent'}
|
||||
className={hasMunicipalityMatch ? CONTENT_PIECE_LOCAL_TAG : CONTENT_PIECE_TAG}
|
||||
>
|
||||
{hasMunicipalityMatch ? (
|
||||
<>
|
||||
<span aria-hidden="true">⌖</span> Para seu município
|
||||
</>
|
||||
) : (
|
||||
'Seleção recente'
|
||||
)}
|
||||
</span>
|
||||
{hasMunicipalityMatch ? (
|
||||
<span className="hidden text-[11px] text-(--campaign-muted) sm:inline">
|
||||
Localização usada só nesta visita
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{isSingle ? (
|
||||
<Link
|
||||
href="/conteudos"
|
||||
className={cn(CONTENT_PIECE_PRIMARY_BUTTON, 'mt-6 hidden lg:inline-flex')}
|
||||
>
|
||||
Ver todas as peças →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
{selection.map(({ item, match }) => (
|
||||
<ContentPieceHomeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
match={match}
|
||||
playing={playingId === item.id}
|
||||
onToggle={() => setPlayingId((current) => (current === item.id ? null : item.id))}
|
||||
onEnded={() => setPlayingId((current) => (current === item.id ? null : current))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isSingle ? (
|
||||
<Link
|
||||
href="/conteudos"
|
||||
className={cn(CONTENT_PIECE_PRIMARY_BUTTON, 'mt-7 w-full lg:hidden')}
|
||||
>
|
||||
Ver todas as peças →
|
||||
</Link>
|
||||
) : (
|
||||
<div className="mt-9 text-center">
|
||||
<Link href="/conteudos" className={CONTENT_PIECE_PRIMARY_BUTTON}>
|
||||
Ver todas as peças →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cena 05 — with a single published piece the section ends on the CTA. */}
|
||||
{isSingle ? null : (
|
||||
<div className="relative mt-6 text-center">
|
||||
{showLocateButton ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void locate()}
|
||||
disabled={locating}
|
||||
className={LOCATE_BUTTON}
|
||||
>
|
||||
<span aria-hidden="true">⌖</span> Usar minha localização
|
||||
</button>
|
||||
<p className="mt-1 text-[11px] leading-4 text-(--campaign-muted)">
|
||||
<span className="sm:hidden">Opcional. Sem prompt no carregamento.</span>
|
||||
<span className="hidden sm:inline">
|
||||
Opcional e secundário. A seção já funciona sem isso.
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] leading-4 text-(--campaign-muted)">
|
||||
<span className="sm:hidden">
|
||||
A localização não é guardada. Cards personalizáveis ficam na seção anterior.
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
Nenhum card personalizável entra nesta amostra.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
72
src/components/conteudos/ContentPieceHomeCard.tsx
Normal file
72
src/components/conteudos/ContentPieceHomeCard.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import type { ContentPieceHomeItem, ContentPieceHomeMatch } from '@/lib/contentPieceHomeSelection'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { ContentPieceCardMeta, ContentPieceCardTitle } from './ContentPieceCardParts'
|
||||
import { ContentPieceMedia } from './ContentPieceMedia'
|
||||
import {
|
||||
CONTENT_PIECE_CARD,
|
||||
CONTENT_PIECE_FOCUS,
|
||||
CONTENT_PIECE_LOCAL_TAG,
|
||||
CONTENT_PIECE_TAG,
|
||||
} from './contentPieceClasses'
|
||||
|
||||
const MATCH_LABELS: Record<ContentPieceHomeMatch, string> = {
|
||||
municipality: 'Do seu município',
|
||||
region: 'Da sua região',
|
||||
recent: 'Mais recente',
|
||||
}
|
||||
|
||||
/**
|
||||
* S39 — the home sample card (artefato: cenas 01/02/05): a lighter sibling of
|
||||
* the catalogue card — no download/share actions, the piece page is the
|
||||
* handoff. Horizontal on the phone (`[124px | 1fr]`, cena 02) and vertical from
|
||||
* `sm` on; the asset is the shared `ContentPieceMedia`, so video/audio only
|
||||
* mount the media after the tap.
|
||||
*/
|
||||
export const ContentPieceHomeCard = ({
|
||||
item,
|
||||
match,
|
||||
playing,
|
||||
onToggle,
|
||||
onEnded,
|
||||
}: {
|
||||
item: ContentPieceHomeItem
|
||||
match: ContentPieceHomeMatch
|
||||
playing: boolean
|
||||
onToggle: () => void
|
||||
onEnded: () => void
|
||||
}) => (
|
||||
<article
|
||||
data-content-piece={item.slug}
|
||||
className={cn(CONTENT_PIECE_CARD, 'grid grid-cols-[124px_1fr] sm:block')}
|
||||
>
|
||||
<ContentPieceMedia
|
||||
item={item}
|
||||
playing={playing}
|
||||
onToggle={onToggle}
|
||||
onEnded={onEnded}
|
||||
className="aspect-video self-start"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className={CONTENT_PIECE_TAG}>{item.isLink ? item.originLabel : item.typeLabel}</span>
|
||||
<span className={match === 'recent' ? CONTENT_PIECE_TAG : CONTENT_PIECE_LOCAL_TAG}>
|
||||
{MATCH_LABELS[match]}
|
||||
</span>
|
||||
</div>
|
||||
<ContentPieceCardTitle item={item} className="text-sm sm:text-base" />
|
||||
<ContentPieceCardMeta item={item} className="text-[11px] sm:text-xs" />
|
||||
<Link
|
||||
href={item.publicPath}
|
||||
className={cn(
|
||||
'mt-3 inline-block text-xs font-bold text-(--pt-red) underline-offset-4 hover:underline',
|
||||
CONTENT_PIECE_FOCUS,
|
||||
)}
|
||||
>
|
||||
Ver esta peça →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
34
src/components/conteudos/ContentPieceHomeSection.tsx
Normal file
34
src/components/conteudos/ContentPieceHomeSection.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
|
||||
import { toContentPieceHomeItem } from '@/lib/contentPieceHomeSelection'
|
||||
|
||||
import { ContentPieceHomeBoard } from './ContentPieceHomeBoard'
|
||||
|
||||
/**
|
||||
* S39 — the home section advertising the Central de Conteúdos (artefato: cenas
|
||||
* 01–06). The page resolves the cached listing once and only renders this when
|
||||
* something is published (fail-closed, same kill switch as the footer link);
|
||||
* the lean projection keeps the search haystack out of the static HTML.
|
||||
*/
|
||||
export const ContentPieceHomeSection = ({
|
||||
items,
|
||||
}: {
|
||||
items: readonly ContentPiecePublicItem[]
|
||||
}) => (
|
||||
<section
|
||||
aria-labelledby="content-pieces-title"
|
||||
data-home-section="content-pieces"
|
||||
className="relative overflow-hidden border-b border-(--campaign-line) bg-white"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-24 -right-20 size-64 rounded-full bg-(--pt-yellow)/35"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -bottom-28 -left-24 size-64 rounded-full bg-[#e4102f]/8"
|
||||
/>
|
||||
<div className="relative mx-auto w-full max-w-[1160px] px-5 py-10 sm:px-8 lg:px-10 lg:py-16">
|
||||
<ContentPieceHomeBoard items={items.map(toContentPieceHomeItem)} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -1,6 +1,23 @@
|
|||
import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* The media states read this subset of the public item, so the S39 home
|
||||
* projection (leaner, no search haystack) renders through the same component.
|
||||
*/
|
||||
type ContentPieceMediaItem = Pick<
|
||||
ContentPiecePublicItem,
|
||||
| 'isLink'
|
||||
| 'file'
|
||||
| 'type'
|
||||
| 'origin'
|
||||
| 'originLabel'
|
||||
| 'title'
|
||||
| 'excerpt'
|
||||
| 'durationLabel'
|
||||
| 'typeLabel'
|
||||
>
|
||||
|
||||
/**
|
||||
* S27 — the piece asset (artefato: cenas 01/02/03/07): a photo previews, a
|
||||
* text shows its opening, a video/audio only mounts the media element after
|
||||
|
|
@ -41,7 +58,7 @@ export const ContentPieceMedia = ({
|
|||
variant = 'card',
|
||||
className,
|
||||
}: {
|
||||
item: ContentPiecePublicItem
|
||||
item: ContentPieceMediaItem
|
||||
playing: boolean
|
||||
onToggle: () => void
|
||||
onEnded: () => void
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ export const CONTENT_PIECE_ACTIVE_CHIP = cn(
|
|||
export const CONTENT_PIECE_TAG =
|
||||
'inline-flex rounded-full bg-[#eef4fb] px-2 py-1 text-[11px] font-extrabold text-[#184e92]'
|
||||
|
||||
/** S39 — the provenance tag of a piece sampled from the visitor's territory. */
|
||||
export const CONTENT_PIECE_LOCAL_TAG = cn(CONTENT_PIECE_TAG, 'bg-[#fff3c4] text-[#6b5100]')
|
||||
|
||||
/** S28 — the neutral badge of a theme-mode result that only matched the query. */
|
||||
export const CONTENT_PIECE_EXACT_TAG =
|
||||
'inline-flex rounded-full border border-black/12 bg-white px-2 py-1 text-[11px] font-extrabold text-[#514945]'
|
||||
|
|
|
|||
170
src/lib/contentPieceHomeSelection.ts
Normal file
170
src/lib/contentPieceHomeSelection.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* S39 — pure contract of the home section that advertises the Central de
|
||||
* Conteúdos: the lean projection of a published piece, the visitor's location
|
||||
* labels resolved from the committed Bahia meshes and the sample selection
|
||||
* (município → região → recentes).
|
||||
*
|
||||
* No I/O and no `server-only`: the cached read, the client island and the unit
|
||||
* tests share this module. The visitor's position is only ever resolved in the
|
||||
* browser — this module never sees an IP and never persists anything.
|
||||
*/
|
||||
import type {
|
||||
MunicipalityGeometryModule,
|
||||
MunicipalityZoneGeometryModule,
|
||||
TerritoryGeometryModule,
|
||||
} from '@/lib/bahiaGeometriesTypes'
|
||||
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
|
||||
import { findContainingFeature, type GeoPoint } from '@/lib/municipalityProximity'
|
||||
import { slugify } from '@/lib/slug'
|
||||
|
||||
/**
|
||||
* What the home card actually renders — the full public item carries the
|
||||
* normalized `searchText` haystack (transcript + description) and the raw
|
||||
* `description`, which must never ride the home's static HTML to every visitor.
|
||||
*/
|
||||
export type ContentPieceHomeItem = Pick<
|
||||
ContentPiecePublicItem,
|
||||
| 'id'
|
||||
| 'slug'
|
||||
| 'title'
|
||||
| 'type'
|
||||
| 'typeLabel'
|
||||
| 'origin'
|
||||
| 'originLabel'
|
||||
| 'isLink'
|
||||
| 'cityLabel'
|
||||
| 'regionLabel'
|
||||
| 'excerpt'
|
||||
| 'durationLabel'
|
||||
| 'metaLabel'
|
||||
| 'publicPath'
|
||||
| 'file'
|
||||
>
|
||||
|
||||
/** Field-by-field on purpose: a new public-item field never leaks by spread. */
|
||||
export const toContentPieceHomeItem = (item: ContentPiecePublicItem): ContentPieceHomeItem => ({
|
||||
id: item.id,
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
typeLabel: item.typeLabel,
|
||||
origin: item.origin,
|
||||
originLabel: item.originLabel,
|
||||
isLink: item.isLink,
|
||||
cityLabel: item.cityLabel,
|
||||
regionLabel: item.regionLabel,
|
||||
excerpt: item.excerpt,
|
||||
durationLabel: item.durationLabel,
|
||||
metaLabel: item.metaLabel,
|
||||
publicPath: item.publicPath,
|
||||
file: item.file,
|
||||
})
|
||||
|
||||
/**
|
||||
* Where the visitor is, in the same vocabulary the catalogue facets use: the
|
||||
* município name (a Salvador ZE resolves to "Salvador — ZE N", the exact
|
||||
* municipality record name) and the identity territory name.
|
||||
*/
|
||||
export type ContentPieceHomeVisitor = {
|
||||
municipalityName: string | null
|
||||
region: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Point → visitor labels over the committed meshes, in containment order
|
||||
* zone → município → território: the Salvador ZE mesh is finer than the
|
||||
* municipal polygon and its feature names are the catalog names, so the zone
|
||||
* match survives; a point outside the municipal mesh is outside Bahia.
|
||||
*
|
||||
* A coarse fix (network/IP guess, `accuracyM > COARSE_ACCURACY_M`) may name a
|
||||
* neighbouring município with total confidence, so only the region is kept.
|
||||
*/
|
||||
export const resolveContentPieceHomeVisitor = ({
|
||||
point,
|
||||
coarse = false,
|
||||
municipalityGeometry,
|
||||
zoneGeometry,
|
||||
territoryGeometry,
|
||||
}: {
|
||||
point: GeoPoint
|
||||
coarse?: boolean
|
||||
municipalityGeometry: Pick<MunicipalityGeometryModule, 'features'>
|
||||
zoneGeometry?: Pick<MunicipalityZoneGeometryModule, 'features'> | null
|
||||
territoryGeometry?: Pick<TerritoryGeometryModule, 'features'> | null
|
||||
}): ContentPieceHomeVisitor | null => {
|
||||
const containingZone = zoneGeometry
|
||||
? findContainingFeature(zoneGeometry.features, point)
|
||||
: undefined
|
||||
const containingMunicipality = findContainingFeature(municipalityGeometry.features, point)
|
||||
|
||||
if (!containingZone && !containingMunicipality) return null
|
||||
|
||||
const municipalityName = coarse
|
||||
? null
|
||||
: (containingZone?.properties.name ?? containingMunicipality?.properties.name ?? null)
|
||||
const region = territoryGeometry
|
||||
? (findContainingFeature(territoryGeometry.features, point)?.properties.name ?? null)
|
||||
: null
|
||||
|
||||
if (!municipalityName && !region) return null
|
||||
|
||||
return { municipalityName, region }
|
||||
}
|
||||
|
||||
/** Where a sampled piece came from — the card's provenance tag. */
|
||||
export type ContentPieceHomeMatch = 'municipality' | 'region' | 'recent'
|
||||
|
||||
export type ContentPieceHomeSelection = {
|
||||
item: ContentPieceHomeItem
|
||||
match: ContentPieceHomeMatch
|
||||
}
|
||||
|
||||
const normalized = (value: string | null): string => (value ? slugify(value) : '')
|
||||
|
||||
/**
|
||||
* Up to `limit` pieces for the visitor, in the intention's order: the município
|
||||
* first, then the region, then the newest published pieces to fill the sample.
|
||||
* `items` already arrives newest-first (the cached read sorts by `-publishedAt`),
|
||||
* so every bucket keeps that order and the dedup is by id.
|
||||
*
|
||||
* `null` (or an out-of-Bahia) visitor is the recent selection — never an error.
|
||||
*/
|
||||
export const selectContentPieceHomeItems = (
|
||||
items: readonly ContentPieceHomeItem[],
|
||||
visitor: ContentPieceHomeVisitor | null,
|
||||
limit = 3,
|
||||
): ContentPieceHomeSelection[] => {
|
||||
const selected: ContentPieceHomeSelection[] = []
|
||||
const seen = new Set<number>()
|
||||
|
||||
const take = (item: ContentPieceHomeItem, match: ContentPieceHomeMatch): void => {
|
||||
if (selected.length >= limit || seen.has(item.id)) return
|
||||
seen.add(item.id)
|
||||
selected.push({ item, match })
|
||||
}
|
||||
|
||||
if (visitor) {
|
||||
const municipality = normalized(visitor.municipalityName)
|
||||
const region = normalized(visitor.region)
|
||||
|
||||
if (municipality) {
|
||||
for (const item of items) {
|
||||
if (selected.length >= limit) break
|
||||
if (normalized(item.cityLabel) === municipality) take(item, 'municipality')
|
||||
}
|
||||
}
|
||||
if (region) {
|
||||
for (const item of items) {
|
||||
if (selected.length >= limit) break
|
||||
if (normalized(item.regionLabel) === region) take(item, 'region')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (selected.length >= limit) break
|
||||
take(item, 'recent')
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
|
@ -190,7 +190,7 @@ export const featureCentroid = (feature: PolygonalFeature): GeoPoint => {
|
|||
* not by this scan's order: the mesh is TopoJSON-derived, so neighbours share
|
||||
* numerically identical arcs and never overlap.
|
||||
*/
|
||||
const findContainingFeature = <F extends PolygonalFeature>(
|
||||
export const findContainingFeature = <F extends PolygonalFeature>(
|
||||
features: readonly F[],
|
||||
point: GeoPoint,
|
||||
): F | undefined => features.find((feature) => featureContainsPoint(feature, point))
|
||||
|
|
|
|||
|
|
@ -10,12 +10,11 @@ import {
|
|||
} from '../../src/lib/municipalityCatalog.js'
|
||||
import {
|
||||
featureCentroid,
|
||||
featureContainsPoint,
|
||||
haversineKm,
|
||||
resolveNearbyMunicipality,
|
||||
type GeoPoint,
|
||||
} from '../../src/lib/municipalityProximity.js'
|
||||
import { featureBounds } from '../helpers/featureBounds.js'
|
||||
import { interiorPointOf } from '../helpers/featureBounds.js'
|
||||
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
|
|
@ -60,27 +59,6 @@ const visibleText = (scope: Locator, text: string) => scope.getByText(text).last
|
|||
/** Assertions belong to the card, not to "somewhere on the dashboard". */
|
||||
const geoCard = (page: Page) => page.locator('[data-slot="card"]').filter({ hasText: 'Onde estou' })
|
||||
|
||||
/**
|
||||
* Coarse grid scan for a point that is really inside the polygon — a centroid can
|
||||
* fall outside a concave município, and the fixture hands out an arbitrary one.
|
||||
*/
|
||||
const interiorPointOf = (feature: BahiaMunicipalityFeature): GeoPoint => {
|
||||
const { west, east, south, north } = featureBounds(feature)
|
||||
const steps = 24
|
||||
|
||||
for (let row = 1; row < steps; row += 1) {
|
||||
for (let column = 1; column < steps; column += 1) {
|
||||
const point = {
|
||||
lng: west + ((east - west) * column) / steps,
|
||||
lat: south + ((north - south) * row) / steps,
|
||||
}
|
||||
if (featureContainsPoint(feature, point)) return point
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No interior point found for ${feature.properties.name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Farthest and nearest other município from a given one, by centroid. The fixture
|
||||
* hands out an arbitrary município, so the two fallback positions have to be
|
||||
|
|
|
|||
|
|
@ -1777,7 +1777,15 @@ test.describe('Cards personalizados (S14)', () => {
|
|||
// conversion block (cards + newsletter) stays last.
|
||||
expect(order.indexOf('sound')).toBe(order.indexOf('story') + 1)
|
||||
expect(order.indexOf('cards')).toBe(order.indexOf('sound') + 1)
|
||||
expect(order.indexOf('cards')).toBe(order.indexOf('newsletter') - 1)
|
||||
// S39 — the Central sample is conditional (only while pieces are published,
|
||||
// and the serial frontendConteudos spec may publish them mid-run), so the
|
||||
// pin is agnostic: when present it sits between the cards and the newsletter.
|
||||
expect(order.indexOf('cards')).toBeLessThan(order.indexOf('newsletter'))
|
||||
const contentPieces = order.indexOf('content-pieces')
|
||||
if (contentPieces !== -1) {
|
||||
expect(contentPieces).toBeGreaterThan(order.indexOf('cards'))
|
||||
expect(contentPieces).toBeLessThan(order.indexOf('newsletter'))
|
||||
}
|
||||
|
||||
const section = page.locator('section#cards')
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { randomUUID } from 'node:crypto'
|
|||
|
||||
import type { APIRequestContext, Page } from '@playwright/test'
|
||||
|
||||
import { loadMunicipalityGeometryModule } from '../../src/lib/bahiaGeometries.js'
|
||||
import { getMunicipalityCatalogEntry } from '../../src/lib/municipalityCatalog.js'
|
||||
import { adminHeaders } from '../helpers/adminApi'
|
||||
import { interiorPointOf } from '../helpers/featureBounds'
|
||||
import { seedTestUser } from '../helpers/seedUser'
|
||||
import { expect, test } from './fixtures/e2eTest'
|
||||
|
||||
|
|
@ -76,6 +79,8 @@ const createPiece = async (
|
|||
withMedia?: boolean
|
||||
origin?: 'arquivo' | 'instagram' | 'youtube'
|
||||
sourceUrl?: string
|
||||
/** S39 — the home sample resolves the município name from this relation. */
|
||||
municipality?: number
|
||||
},
|
||||
): Promise<{ id: number; slug: string }> => {
|
||||
const mediaKind = data.type === 'video' || data.type === 'foto' ? data.type : 'texto'
|
||||
|
|
@ -100,6 +105,7 @@ const createPiece = async (
|
|||
origin: data.origin ?? 'arquivo',
|
||||
...(media ? { media } : {}),
|
||||
...(data.sourceUrl ? { sourceUrl: data.sourceUrl } : {}),
|
||||
...(data.municipality ? { municipality: data.municipality } : {}),
|
||||
},
|
||||
})
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
|
|
@ -149,6 +155,28 @@ const waitForSettledPage = async (page: Page) => {
|
|||
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* S39 — the home is ISR and the section is gated by the same listing tag as the
|
||||
* catalogue; poll the server HTML until the kill switch converged (the
|
||||
* navigation that follows always lands on the fresh page).
|
||||
*/
|
||||
const waitForHomeSection = async (
|
||||
request: APIRequestContext,
|
||||
expected: 'present' | 'absent',
|
||||
attempts = 12,
|
||||
) => {
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const response = await request.get(`${BASE_URL}/`).catch(() => undefined)
|
||||
if (response?.ok()) {
|
||||
const html = await response.text()
|
||||
const present = html.includes('data-home-section="content-pieces"')
|
||||
if ((expected === 'present') === present) return
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000))
|
||||
}
|
||||
throw new Error(`A seção da Central não convergiu para "${expected}" em ${attempts}s.`)
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
|
|
@ -513,4 +541,88 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
|
|||
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
|
||||
await expect(page.getByRole('link', { name: 'Conteúdos' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows the Central sample on the home and hides it with the kill switch', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
await unpublishEveryPiece(request, headers)
|
||||
await createPiece(request, headers, { title: `Peça da home ${uniqueMarker()}`, type: 'foto' })
|
||||
await createPiece(request, headers, { title: `Vídeo da home ${uniqueMarker()}`, type: 'video' })
|
||||
|
||||
await waitForHomeSection(request, 'present')
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto(`/?e2e=${Date.now()}`)
|
||||
const section = page.locator('[data-home-section="content-pieces"]')
|
||||
await expect(section).toBeVisible()
|
||||
// S39 — the sample sits between the cards and the newsletter (the home spec
|
||||
// keeps this pin agnostic because the section is conditional).
|
||||
const order = await page
|
||||
.locator('[data-home-section]')
|
||||
.evaluateAll((sections) => sections.map((s) => s.getAttribute('data-home-section')))
|
||||
expect(order.indexOf('cards')).toBeLessThan(order.indexOf('content-pieces'))
|
||||
expect(order.indexOf('content-pieces')).toBeLessThan(order.indexOf('newsletter'))
|
||||
await expect(section.getByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeVisible()
|
||||
await expect(section.getByRole('link', { name: /Ver todas as peças/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/conteudos',
|
||||
)
|
||||
await expect(section.locator('article[data-content-piece]')).toHaveCount(2)
|
||||
// Nothing plays on its own: the video mounts no media element before the tap.
|
||||
await expect(section.locator('video, audio')).toHaveCount(0)
|
||||
// Cena 02 — the sample never overflows the phone.
|
||||
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)
|
||||
|
||||
await unpublishEveryPiece(request, headers)
|
||||
await waitForHomeSection(request, 'absent')
|
||||
await page.goto(`/?e2e=${Date.now()}`)
|
||||
await expect(page.locator('[data-home-section="content-pieces"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('samples the visitor município on the home when the permission is granted', async ({
|
||||
page,
|
||||
request,
|
||||
context,
|
||||
}) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
await unpublishEveryPiece(request, headers)
|
||||
|
||||
const entry = getMunicipalityCatalogEntry('feira-de-santana')
|
||||
if (!entry || entry.kind !== 'municipio') throw new Error('Catálogo sem Feira de Santana.')
|
||||
const geometry = await loadMunicipalityGeometryModule()
|
||||
const feature = geometry.features.find(
|
||||
(candidate) => candidate.properties.codarea === entry.ibgeCode,
|
||||
)
|
||||
if (!feature) throw new Error('Malha municipal sem Feira de Santana.')
|
||||
const point = interiorPointOf(feature)
|
||||
|
||||
const listed = await request.get(
|
||||
`${BASE_URL}/api/municipality?where[slug][equals]=${entry.slug}&limit=1&depth=0`,
|
||||
{ headers },
|
||||
)
|
||||
expect(listed.ok(), await listed.text()).toBeTruthy()
|
||||
const { docs } = (await listed.json()) as { docs: { id: number }[] }
|
||||
const municipality = docs[0]
|
||||
if (!municipality) throw new Error('Município não semeado no banco de teste.')
|
||||
|
||||
const title = `Peça de Feira de Santana ${uniqueMarker()}`
|
||||
await createPiece(request, headers, { title, type: 'foto', municipality: municipality.id })
|
||||
await waitForHomeSection(request, 'present')
|
||||
|
||||
await context.grantPermissions(['geolocation'])
|
||||
await context.setGeolocation({ latitude: point.lat, longitude: point.lng })
|
||||
|
||||
await page.goto(`/?e2e=${Date.now()}`)
|
||||
const section = page.locator('[data-home-section="content-pieces"]')
|
||||
await expect(section.locator('[data-sample-tag="municipality"]')).toBeVisible()
|
||||
// Exact: the sr-only live region announces "Peças do seu município".
|
||||
await expect(section.getByText('Do seu município', { exact: true })).toBeVisible()
|
||||
await expect(section.getByRole('link', { name: title })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { PolygonalFeature } from '../../src/lib/bahiaGeometriesTypes.js'
|
||||
import { polygonRingsOf } from '../../src/lib/municipalityProximity.js'
|
||||
import {
|
||||
featureContainsPoint,
|
||||
polygonRingsOf,
|
||||
type GeoPoint,
|
||||
} from '../../src/lib/municipalityProximity.js'
|
||||
|
||||
export type FeatureBounds = {
|
||||
west: number
|
||||
|
|
@ -21,3 +25,25 @@ export const featureBounds = (feature: PolygonalFeature): FeatureBounds => {
|
|||
north: Math.max(...latitudes),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A real interior point of the feature: a centroid can fall outside a concave
|
||||
* município, so scan a coarse grid inside the bounding box (shared by the B14
|
||||
* geo spec and the S39 home sample spec).
|
||||
*/
|
||||
export const interiorPointOf = (feature: PolygonalFeature): GeoPoint => {
|
||||
const { west, east, south, north } = featureBounds(feature)
|
||||
const steps = 24
|
||||
|
||||
for (let row = 1; row < steps; row += 1) {
|
||||
for (let column = 1; column < steps; column += 1) {
|
||||
const point = {
|
||||
lng: west + ((east - west) * column) / steps,
|
||||
lat: south + ((north - south) * row) / steps,
|
||||
}
|
||||
if (featureContainsPoint(feature, point)) return point
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No interior point found in the feature bounding box.')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,18 @@ import { cleanup, render, screen } from '@testing-library/react'
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import HomePage from '@/app/(frontend)/(home)/page'
|
||||
import { toContentPiecePublicItem, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
|
||||
|
||||
const pieceItem = toContentPiecePublicItem({
|
||||
id: 1,
|
||||
slug: 'peca-da-home',
|
||||
title: 'Peça da home',
|
||||
type: 'foto',
|
||||
status: 'publicado',
|
||||
origin: 'arquivo',
|
||||
media: { id: 2, filename: 'peca.png', mimeType: 'image/png' },
|
||||
})
|
||||
if (!pieceItem) throw new Error('fixture should be public')
|
||||
|
||||
type MockImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
fill?: boolean
|
||||
|
|
@ -43,11 +55,13 @@ vi.mock('@/utilities/jingleReads', () => ({
|
|||
getPublishedJingleItems: async () => [],
|
||||
}))
|
||||
|
||||
// S27 — the same for the Central de Conteúdos discovery flag (cached read of
|
||||
// the content pieces listing); the real listing/kill-switch behavior is
|
||||
// e2e-covered (frontendConteudos.e2e.spec.ts).
|
||||
// S27/S39 — the same for the Central de Conteúdos listing (cached read that
|
||||
// feeds the discovery flag and the home sample); the real kill-switch behavior
|
||||
// is e2e-covered (frontendConteudos.e2e.spec.ts).
|
||||
const contentPieces = vi.hoisted(() => ({ items: [] as ContentPiecePublicItem[] }))
|
||||
|
||||
vi.mock('@/utilities/content/contentPieceReads', () => ({
|
||||
hasPublishedContentPieces: async () => false,
|
||||
getPublishedContentPieceItems: async () => contentPieces.items,
|
||||
}))
|
||||
|
||||
// S14 — the card section renders the client studio island (next/font local
|
||||
|
|
@ -61,7 +75,10 @@ vi.mock('@/components/cards/CardsStudio', () => ({
|
|||
CardsStudio: () => null,
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
contentPieces.items = []
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Campaign home', () => {
|
||||
it('monta as seis seções previstas e o rodapé eleitoral', async () => {
|
||||
|
|
@ -101,4 +118,23 @@ describe('Campaign home', () => {
|
|||
expect(screen.getAllByText(title)).toHaveLength(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('mostra a seção da Central com peça publicada e a esconde sem nenhuma (fail-closed)', async () => {
|
||||
contentPieces.items = [pieceItem]
|
||||
render(await HomePage())
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeTruthy()
|
||||
// The single-piece layout renders one CTA per viewport (split desktop +
|
||||
// stacked mobile); both hand off to the Central.
|
||||
const ctas = screen.getAllByRole('link', { name: /Ver todas as peças/ })
|
||||
expect(ctas.length).toBeGreaterThan(0)
|
||||
for (const cta of ctas) expect(cta.getAttribute('href')).toBe('/conteudos')
|
||||
|
||||
cleanup()
|
||||
contentPieces.items = []
|
||||
render(await HomePage())
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Ver todas as peças/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
282
tests/unit/contentPieceHomeBoard.unit.spec.tsx
Normal file
282
tests/unit/contentPieceHomeBoard.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ContentPieceHomeBoard } from '@/components/conteudos/ContentPieceHomeBoard'
|
||||
import {
|
||||
loadMunicipalityGeometryModule,
|
||||
loadMunicipalityZoneGeometryModule,
|
||||
loadTerritoryGeometryModule,
|
||||
} from '@/lib/bahiaGeometries'
|
||||
import type {
|
||||
BahiaFeature,
|
||||
BahiaMunicipalityFeature,
|
||||
BahiaTerritoryFeature,
|
||||
MunicipalityGeometryModule,
|
||||
MunicipalityZoneGeometryModule,
|
||||
TerritoryGeometryModule,
|
||||
} from '@/lib/bahiaGeometriesTypes'
|
||||
import type { ContentPieceHomeItem } from '@/lib/contentPieceHomeSelection'
|
||||
import {
|
||||
readGeolocationPermissionState,
|
||||
requestCurrentPosition,
|
||||
} from '@/utilities/campaignGeolocation'
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bahiaGeometries', () => ({
|
||||
loadMunicipalityGeometryModule: vi.fn(),
|
||||
loadMunicipalityZoneGeometryModule: vi.fn(),
|
||||
loadTerritoryGeometryModule: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utilities/campaignGeolocation', () => ({
|
||||
COARSE_ACCURACY_M: 10_000,
|
||||
readGeolocationPermissionState: vi.fn(),
|
||||
requestCurrentPosition: vi.fn(),
|
||||
}))
|
||||
|
||||
const readPermission = vi.mocked(readGeolocationPermissionState)
|
||||
const readPosition = vi.mocked(requestCurrentPosition)
|
||||
const readMunicipalityGeometry = vi.mocked(loadMunicipalityGeometryModule)
|
||||
const readZoneGeometry = vi.mocked(loadMunicipalityZoneGeometryModule)
|
||||
const readTerritoryGeometry = vi.mocked(loadTerritoryGeometryModule)
|
||||
|
||||
const item = (id: number, patch: Partial<ContentPieceHomeItem> = {}): ContentPieceHomeItem => ({
|
||||
id,
|
||||
slug: `peca-${id}`,
|
||||
title: `Peça ${id}`,
|
||||
type: 'foto',
|
||||
typeLabel: 'Foto',
|
||||
origin: 'arquivo',
|
||||
originLabel: 'Arquivo',
|
||||
isLink: false,
|
||||
cityLabel: null,
|
||||
regionLabel: null,
|
||||
excerpt: null,
|
||||
durationLabel: null,
|
||||
metaLabel: 'Tema · Local',
|
||||
publicPath: `/conteudos/peca-${id}`,
|
||||
file: {
|
||||
id: 100 + id,
|
||||
path: `/conteudos/peca-${id}/midia`,
|
||||
mimeType: 'image/png',
|
||||
downloadFilename: `peca-${id}.png`,
|
||||
},
|
||||
...patch,
|
||||
})
|
||||
|
||||
/** Axis-aligned squares over the committed-mesh contract the resolver reads. */
|
||||
const square = <Properties extends Record<string, unknown>>(
|
||||
properties: Properties,
|
||||
box: { west: number; south: number; size?: number },
|
||||
): BahiaFeature<Properties> => {
|
||||
const size = box.size ?? 0.4
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties,
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[box.west, box.south],
|
||||
[box.west + size, box.south],
|
||||
[box.west + size, box.south + size],
|
||||
[box.west, box.south + size],
|
||||
[box.west, box.south],
|
||||
],
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const feira = square(
|
||||
{ codarea: '2910800', name: 'Feira de Santana' },
|
||||
{ west: -39.2, south: -12.4 },
|
||||
)
|
||||
const portal = square({ code: '20', name: 'Portal do Sertão' }, { west: -40, south: -13, size: 3 })
|
||||
|
||||
const municipalityModule = (
|
||||
features: readonly BahiaMunicipalityFeature[],
|
||||
): MunicipalityGeometryModule => ({
|
||||
topology: {
|
||||
type: 'Topology',
|
||||
objects: { municipalities: { type: 'GeometryCollection', geometries: [] } },
|
||||
arcs: [],
|
||||
},
|
||||
features,
|
||||
getMunicipalityFeature: (codarea) =>
|
||||
features.find((feature) => feature.properties.codarea === codarea),
|
||||
})
|
||||
|
||||
const zoneModule = (): MunicipalityZoneGeometryModule => ({
|
||||
topology: {
|
||||
type: 'Topology',
|
||||
objects: { municipalityZones: { type: 'GeometryCollection', geometries: [] } },
|
||||
arcs: [],
|
||||
},
|
||||
features: [],
|
||||
})
|
||||
|
||||
const territoryModule = (features: readonly BahiaTerritoryFeature[]): TerritoryGeometryModule => ({
|
||||
topology: {
|
||||
type: 'Topology',
|
||||
objects: { territories: { type: 'GeometryCollection', geometries: [] } },
|
||||
arcs: [],
|
||||
},
|
||||
features,
|
||||
})
|
||||
|
||||
const feiraPoint = { lat: -12.2, lng: -39 }
|
||||
const localPiece = item(1, { cityLabel: 'Feira de Santana', regionLabel: 'Portal do Sertão' })
|
||||
const otherPiece = item(2, { cityLabel: 'Ilhéus', regionLabel: 'Litoral Sul' })
|
||||
const items = [localPiece, otherPiece]
|
||||
|
||||
const locateButton = () => screen.queryByRole('button', { name: /Usar minha localização/ })
|
||||
|
||||
/** The section tag is split by the decorative glyph, so the hook is the attribute. */
|
||||
const sampleTag = () =>
|
||||
document.querySelector('[data-sample-tag]')?.getAttribute('data-sample-tag') ?? null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
readMunicipalityGeometry.mockResolvedValue(municipalityModule([feira]))
|
||||
readZoneGeometry.mockResolvedValue(zoneModule())
|
||||
readTerritoryGeometry.mockResolvedValue(territoryModule([portal]))
|
||||
readPosition.mockResolvedValue({ ok: true, fix: { ...feiraPoint, accuracyM: 80 } })
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ContentPieceHomeBoard', () => {
|
||||
it('renders the recent selection and the affordance while permission is undecided', async () => {
|
||||
readPermission.mockResolvedValue('prompt')
|
||||
|
||||
render(<ContentPieceHomeBoard items={items} />)
|
||||
|
||||
await waitFor(() => expect(locateButton()).not.toBeNull())
|
||||
expect(screen.getByText('Seleção recente')).toBeDefined()
|
||||
expect(screen.getAllByText('Mais recente')).toHaveLength(items.length)
|
||||
expect(screen.getByRole('link', { name: /Ver todas as peças/ }).getAttribute('href')).toBe(
|
||||
'/conteudos',
|
||||
)
|
||||
// No surprise dialog on load and nothing stored anywhere (LGPD contract).
|
||||
expect(readPosition).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
expect(localStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('hides the affordance when the browser already refused the permission', async () => {
|
||||
readPermission.mockResolvedValue('denied')
|
||||
|
||||
render(<ContentPieceHomeBoard items={items} />)
|
||||
|
||||
await waitFor(() => expect(readPermission).toHaveBeenCalled())
|
||||
expect(locateButton()).toBeNull()
|
||||
expect(screen.getByText('Seleção recente')).toBeDefined()
|
||||
})
|
||||
|
||||
it('upgrades to the visitor territory without a dialog when permission is granted', async () => {
|
||||
readPermission.mockResolvedValue('granted')
|
||||
|
||||
render(<ContentPieceHomeBoard items={items} />)
|
||||
|
||||
await waitFor(() => expect(sampleTag()).toBe('municipality'))
|
||||
expect(screen.getByText('Do seu município')).toBeDefined()
|
||||
expect(locateButton()).toBeNull()
|
||||
expect(readPosition).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('upgrades to the visitor territory when the affordance is tapped', async () => {
|
||||
readPermission.mockResolvedValue('prompt')
|
||||
|
||||
render(<ContentPieceHomeBoard items={items} />)
|
||||
|
||||
await waitFor(() => expect(locateButton()).not.toBeNull())
|
||||
fireEvent.click(locateButton()!)
|
||||
await waitFor(() => expect(sampleTag()).toBe('municipality'))
|
||||
expect(locateButton()).toBeNull()
|
||||
expect(readPosition).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('offers the affordance when the browser cannot tell the permission (Safari)', async () => {
|
||||
readPermission.mockResolvedValue('unknown')
|
||||
|
||||
render(<ContentPieceHomeBoard items={items} />)
|
||||
|
||||
await waitFor(() => expect(locateButton()).not.toBeNull())
|
||||
expect(readPosition).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mounts the media only after the tap and unmounts it when the play ends', async () => {
|
||||
readPermission.mockResolvedValue('denied')
|
||||
const video = item(7, {
|
||||
title: 'Vídeo da home',
|
||||
type: 'video',
|
||||
typeLabel: 'Vídeo',
|
||||
file: {
|
||||
id: 107,
|
||||
path: '/conteudos/peca-7/midia',
|
||||
mimeType: 'video/mp4',
|
||||
downloadFilename: 'peca-7.mp4',
|
||||
},
|
||||
})
|
||||
|
||||
render(<ContentPieceHomeBoard items={[video]} />)
|
||||
|
||||
await waitFor(() => expect(readPermission).toHaveBeenCalled())
|
||||
expect(document.querySelector('video')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reproduzir Vídeo da home' }))
|
||||
const element = document.querySelector('video')
|
||||
expect(element).not.toBeNull()
|
||||
|
||||
fireEvent.ended(element!)
|
||||
await waitFor(() => expect(document.querySelector('video')).toBeNull())
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
render(<ContentPieceHomeBoard items={items} />)
|
||||
|
||||
await waitFor(() => expect(readPosition).toHaveBeenCalledTimes(1))
|
||||
expect(screen.getByText('Seleção recente')).toBeDefined()
|
||||
expect(locateButton()).toBeNull()
|
||||
expect(screen.queryByText(/Não foi possível/)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the recent selection for a coarse fix (no município claim)', async () => {
|
||||
readPermission.mockResolvedValue('granted')
|
||||
readPosition.mockResolvedValue({ ok: true, fix: { ...feiraPoint, accuracyM: 50_000 } })
|
||||
|
||||
render(<ContentPieceHomeBoard items={items} />)
|
||||
|
||||
// The coarse fix keeps the region sample, never the município claim.
|
||||
await waitFor(() => expect(screen.getByText('Da sua região')).toBeDefined())
|
||||
expect(readPosition).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('Seleção recente')).toBeDefined()
|
||||
expect(screen.queryByText('Do seu município')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not offer the affordance for a single published piece', async () => {
|
||||
readPermission.mockResolvedValue('prompt')
|
||||
|
||||
render(<ContentPieceHomeBoard items={[localPiece]} />)
|
||||
|
||||
await waitFor(() => expect(readPermission).toHaveBeenCalled())
|
||||
expect(locateButton()).toBeNull()
|
||||
expect(
|
||||
screen.getByText('Uma peça oficial já está pronta para você compartilhar.'),
|
||||
).toBeDefined()
|
||||
// Cena 05 — the single-piece section ends on the CTA, with no footnote.
|
||||
expect(screen.queryByText(/Nenhum card personalizável/)).toBeNull()
|
||||
expect(screen.queryByText(/A localização não é guardada/)).toBeNull()
|
||||
})
|
||||
})
|
||||
310
tests/unit/contentPieceHomeSelection.unit.spec.ts
Normal file
310
tests/unit/contentPieceHomeSelection.unit.spec.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
BahiaMunicipalityFeature,
|
||||
BahiaTerritoryFeature,
|
||||
MunicipalityZoneFeature,
|
||||
} from '@/lib/bahiaGeometriesTypes'
|
||||
import { toContentPiecePublicItem, type ContentPiecePublicSource } from '@/lib/contentPieceCatalog'
|
||||
import {
|
||||
resolveContentPieceHomeVisitor,
|
||||
selectContentPieceHomeItems,
|
||||
toContentPieceHomeItem,
|
||||
type ContentPieceHomeItem,
|
||||
} from '@/lib/contentPieceHomeSelection'
|
||||
|
||||
const source = (patch: Partial<ContentPiecePublicSource> = {}): ContentPiecePublicSource => ({
|
||||
id: 1,
|
||||
slug: 'fim-da-escala-6x1',
|
||||
title: 'Fim da escala 6x1 é saúde',
|
||||
type: 'video',
|
||||
status: 'publicado',
|
||||
origin: 'arquivo',
|
||||
topics: ['economia-trabalho'],
|
||||
cityLabel: 'Feira de Santana',
|
||||
region: 'Portal do Sertão',
|
||||
description: 'Solla explica a jornada.',
|
||||
transcript: 'A redução da jornada é saúde.',
|
||||
durationSeconds: 134,
|
||||
searchText: 'fim da escala 6x1 e saude feira de santana',
|
||||
media: { id: 7, filename: 'Fim da Escala.MP4' },
|
||||
...patch,
|
||||
})
|
||||
|
||||
const homeItem = (patch: Partial<ContentPiecePublicSource> = {}): ContentPieceHomeItem => {
|
||||
const item = toContentPiecePublicItem(source(patch))
|
||||
if (!item) throw new Error('fixture should be public')
|
||||
return toContentPieceHomeItem(item)
|
||||
}
|
||||
|
||||
/** Axis-aligned square in degrees — small enough that planar reasoning holds. */
|
||||
const square = <Properties extends Record<string, unknown>>(
|
||||
properties: Properties,
|
||||
{ west, south, size }: { west: number; south: number; size: number },
|
||||
) => ({
|
||||
type: 'Feature' as const,
|
||||
properties,
|
||||
geometry: {
|
||||
type: 'Polygon' as const,
|
||||
coordinates: [
|
||||
[
|
||||
[west, south],
|
||||
[west + size, south],
|
||||
[west + size, south + size],
|
||||
[west, south + size],
|
||||
[west, south],
|
||||
],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const municipalitySquare = (
|
||||
codarea: string,
|
||||
name: string,
|
||||
box: { west: number; south: number; size: number },
|
||||
): BahiaMunicipalityFeature => square({ codarea, name }, box)
|
||||
|
||||
const zoneSquare = (
|
||||
municipalitySlug: string,
|
||||
name: string,
|
||||
ibgeCode: string,
|
||||
box: { west: number; south: number; size: number },
|
||||
): MunicipalityZoneFeature => square({ municipalitySlug, name, ibgeCode }, box)
|
||||
|
||||
const territorySquare = (
|
||||
code: string,
|
||||
name: string,
|
||||
box: { west: number; south: number; size: number },
|
||||
): BahiaTerritoryFeature => square({ code, name }, box)
|
||||
|
||||
describe('content piece home selection', () => {
|
||||
describe('toContentPieceHomeItem', () => {
|
||||
it('projects exactly the card fields — the search haystack never rides along', () => {
|
||||
const item = homeItem()
|
||||
|
||||
expect(Object.keys(item).sort()).toEqual([
|
||||
'cityLabel',
|
||||
'durationLabel',
|
||||
'excerpt',
|
||||
'file',
|
||||
'id',
|
||||
'isLink',
|
||||
'metaLabel',
|
||||
'origin',
|
||||
'originLabel',
|
||||
'publicPath',
|
||||
'regionLabel',
|
||||
'slug',
|
||||
'title',
|
||||
'type',
|
||||
'typeLabel',
|
||||
])
|
||||
expect('searchText' in item).toBe(false)
|
||||
expect('description' in item).toBe(false)
|
||||
expect('topics' in item).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a link piece with no archived file — the platform handoff', () => {
|
||||
const item = homeItem({
|
||||
id: 9,
|
||||
slug: 'peca-link',
|
||||
origin: 'instagram',
|
||||
media: null,
|
||||
sourceUrl: 'https://www.instagram.com/p/abc/',
|
||||
})
|
||||
|
||||
expect(item.isLink).toBe(true)
|
||||
expect(item.file).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectContentPieceHomeItems', () => {
|
||||
const local = homeItem({ id: 1, slug: 'local', cityLabel: 'Feira de Santana' })
|
||||
const regional = homeItem({
|
||||
id: 2,
|
||||
slug: 'regional',
|
||||
cityLabel: 'Serrinha',
|
||||
region: 'Portal do Sertão',
|
||||
})
|
||||
const other = homeItem({ id: 3, slug: 'outro', cityLabel: 'Ilhéus', region: 'Litoral Sul' })
|
||||
const newest = homeItem({ id: 4, slug: 'recente', cityLabel: null, region: null })
|
||||
const visitor = { municipalityName: 'Feira de Santana', region: 'Portal do Sertão' }
|
||||
|
||||
it('orders município → região → recentes and annotates every match', () => {
|
||||
const selection = selectContentPieceHomeItems([other, regional, local], visitor)
|
||||
|
||||
expect(selection.map((entry) => [entry.item.slug, entry.match])).toEqual([
|
||||
['local', 'municipality'],
|
||||
['regional', 'region'],
|
||||
['outro', 'recent'],
|
||||
])
|
||||
})
|
||||
|
||||
it('never repeats a piece that matches more than one bucket', () => {
|
||||
const both = homeItem({ id: 5, slug: 'both' })
|
||||
const selection = selectContentPieceHomeItems([both, local], visitor)
|
||||
|
||||
expect(selection.map((entry) => entry.item.slug)).toEqual(['both', 'local'])
|
||||
expect(selection[0]?.match).toBe('municipality')
|
||||
})
|
||||
|
||||
it('keeps the newest-first order inside each bucket and caps at the limit', () => {
|
||||
const localNewer = homeItem({ id: 11, slug: 'local-nova' })
|
||||
const localOlder = homeItem({ id: 12, slug: 'local-antiga' })
|
||||
|
||||
const selection = selectContentPieceHomeItems([localNewer, localOlder, regional], visitor, 2)
|
||||
expect(selection.map((entry) => entry.item.slug)).toEqual(['local-nova', 'local-antiga'])
|
||||
expect(
|
||||
selectContentPieceHomeItems([localNewer, localOlder, regional], visitor, 1).map(
|
||||
(entry) => entry.item.slug,
|
||||
),
|
||||
).toEqual(['local-nova'])
|
||||
})
|
||||
|
||||
it('shows fewer pieces when the catalogue is smaller than the limit', () => {
|
||||
expect(selectContentPieceHomeItems([local], visitor)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('defaults to three pieces when the catalogue is larger than the sample', () => {
|
||||
const many = [1, 2, 3, 4, 5].map((id) => homeItem({ id, slug: `peca-${id}` }))
|
||||
|
||||
expect(selectContentPieceHomeItems(many, null)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('falls back to the newest pieces without a visitor or without any label', () => {
|
||||
const items = [newest, other, local]
|
||||
|
||||
expect(selectContentPieceHomeItems(items, null).map((entry) => entry.item.slug)).toEqual([
|
||||
'recente',
|
||||
'outro',
|
||||
'local',
|
||||
])
|
||||
expect(
|
||||
selectContentPieceHomeItems(items, { municipalityName: null, region: null }).map(
|
||||
(entry) => entry.match,
|
||||
),
|
||||
).toEqual(['recent', 'recent', 'recent'])
|
||||
})
|
||||
|
||||
it('matches a Salvador ZE by its exact name and degrades to the region without one', () => {
|
||||
const zonePiece = homeItem({
|
||||
id: 6,
|
||||
slug: 'ze',
|
||||
cityLabel: 'Salvador — ZE 3',
|
||||
region: 'Metropolitano de Salvador',
|
||||
})
|
||||
|
||||
expect(
|
||||
selectContentPieceHomeItems([zonePiece], {
|
||||
municipalityName: 'Salvador — ZE 3',
|
||||
region: 'Metropolitano de Salvador',
|
||||
})[0]?.match,
|
||||
).toBe('municipality')
|
||||
expect(
|
||||
selectContentPieceHomeItems([zonePiece], {
|
||||
municipalityName: 'Salvador',
|
||||
region: 'Metropolitano de Salvador',
|
||||
})[0]?.match,
|
||||
).toBe('region')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveContentPieceHomeVisitor', () => {
|
||||
const feira = municipalitySquare('2910800', 'Feira de Santana', {
|
||||
west: -39.2,
|
||||
south: -12.4,
|
||||
size: 0.4,
|
||||
})
|
||||
const portal = territorySquare('20', 'Portal do Sertão', {
|
||||
west: -40,
|
||||
south: -13,
|
||||
size: 3,
|
||||
})
|
||||
const point = { lat: -12.2, lng: -39 }
|
||||
|
||||
it('resolves the município name and the identity territory', () => {
|
||||
expect(
|
||||
resolveContentPieceHomeVisitor({
|
||||
point,
|
||||
municipalityGeometry: { features: [feira] },
|
||||
territoryGeometry: { features: [portal] },
|
||||
}),
|
||||
).toEqual({ municipalityName: 'Feira de Santana', region: 'Portal do Sertão' })
|
||||
})
|
||||
|
||||
it('prefers the Salvador zone name when the zone mesh contains the point', () => {
|
||||
const salvador = municipalitySquare('2927408', 'Salvador', {
|
||||
west: -38.6,
|
||||
south: -13.1,
|
||||
size: 0.6,
|
||||
})
|
||||
const ze = zoneSquare('salvador-ze-3', 'Salvador — ZE 3', '2927408', {
|
||||
west: -38.55,
|
||||
south: -13.05,
|
||||
size: 0.2,
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveContentPieceHomeVisitor({
|
||||
point: { lat: -13, lng: -38.5 },
|
||||
municipalityGeometry: { features: [salvador] },
|
||||
zoneGeometry: { features: [ze] },
|
||||
territoryGeometry: { features: [portal] },
|
||||
}),
|
||||
).toEqual({ municipalityName: 'Salvador — ZE 3', region: 'Portal do Sertão' })
|
||||
})
|
||||
|
||||
it('falls back to the whole município when no zone contains the point', () => {
|
||||
const salvador = municipalitySquare('2927408', 'Salvador', {
|
||||
west: -38.6,
|
||||
south: -13.1,
|
||||
size: 0.6,
|
||||
})
|
||||
const ze = zoneSquare('salvador-ze-3', 'Salvador — ZE 3', '2927408', {
|
||||
west: -38.55,
|
||||
south: -13.05,
|
||||
size: 0.05,
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveContentPieceHomeVisitor({
|
||||
point: { lat: -12.7, lng: -38.2 },
|
||||
municipalityGeometry: { features: [salvador] },
|
||||
zoneGeometry: { features: [ze] },
|
||||
territoryGeometry: { features: [portal] },
|
||||
}),
|
||||
).toEqual({ municipalityName: 'Salvador', region: 'Portal do Sertão' })
|
||||
})
|
||||
|
||||
it('keeps only the region on a coarse fix', () => {
|
||||
expect(
|
||||
resolveContentPieceHomeVisitor({
|
||||
point,
|
||||
coarse: true,
|
||||
municipalityGeometry: { features: [feira] },
|
||||
territoryGeometry: { features: [portal] },
|
||||
}),
|
||||
).toEqual({ municipalityName: null, region: 'Portal do Sertão' })
|
||||
})
|
||||
|
||||
it('is null outside the municipal mesh — outside Bahia or without a signal', () => {
|
||||
expect(
|
||||
resolveContentPieceHomeVisitor({
|
||||
point: { lat: -23.5, lng: -46.6 },
|
||||
municipalityGeometry: { features: [feira] },
|
||||
territoryGeometry: { features: [portal] },
|
||||
}),
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveContentPieceHomeVisitor({
|
||||
point: { lat: -23.5, lng: -46.6 },
|
||||
coarse: true,
|
||||
municipalityGeometry: { features: [feira] },
|
||||
territoryGeometry: { features: [portal] },
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue