feat(S44): home divulga a Plenária da Vitória (agenda pré-live, embed ao vivo e kill switch)
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions

This commit is contained in:
Francisco Solla 2026-10-01 16:08:03 -03:00
parent 8d61b4183f
commit 0a8b0774d3
24 changed files with 1302 additions and 125 deletions

View file

@ -14,6 +14,10 @@ The visitor's photo never leaves their device: the browser runs the face-api net
Both sides are **fail-closed**: `selfieSearchEnabled` defaults to `false`; the two Consent keys (`busca-selfie-fotos` query consent, `busca-selfie-indice` public index notice, in `src/lib/campaignConsentKeys.ts`) are provisioned by `pnpm seed:face-consents` from the texts versioned in `scripts/lib/faceConsentTexts.mjs` — without both admin rows the page renders the closed state and the API refuses. Opt-out is self-service ("Minha presença" → face match deletes the matching descriptors, so the face stops answering); photo takedown keeps using `photoAlbum.removalChannelUrl`. Opening the surface also requires approving the archive (`pnpm archive:publish`, bulk `draft → approved`, never touching `removed`). E2E builds stub the engine with `NEXT_PUBLIC_FACE_SEARCH_STUB=1` (`window.__faceSearchStub`); the real engine is exercised by the CLI canary and manually. Runbook: `docs/ops/teqo-1313-deploy.md` §C242.
## Home Plenária section (S44)
The campaign home carries a section for the Plenária da Vitória right after the hero. It is driven by **one fixed share link** (`SHARE_LINK_HOME_SECTION_SLUG = 'plenaria-vitoria'`, `src/lib/shareLinkHomeSection.ts`) — no CMS field, no migration: the link itself remains the owner of the title, description, image, date and destination pool. `loadShareLinkHomeSection` (`src/utilities/shareLinkReads.ts`) reads it under the same cached `shareLinks` tag as the announcement page (unpublish pulls the section without a deploy) and fails closed: no published link, no valid `startsAt`, or `now >= endsAt` (else `startsAt` + 2h) → the section is absent from the HTML. While the window is open the client island (`ShareLinkHomeSection`) renders the invitation — image + the S29 agenda, **never an entry button**; once a destination is on air the poll (shared hook `useShareLinkLiveTarget`, activation-only, 30s, paused on hidden tabs) swaps the image for the official `youtube-nocookie` player (video id derived from the link's pre-registered YouTube destination) and the entry CTA takes the agenda's place, always pointing to `/plenaria-vitoria` (never the raw Meet/YouTube URL) with the label by host (`Entrar na plenária` on Meet, `Assistir no YouTube` on YouTube). The island also hides itself at the exact window close, covering open tabs over the ISR-stale HTML. No data collection, no Consent, no PII.
## Posts & Tags (news / publications)
The public news/publications system is backed by two collections (both in the `Publicações` admin group) plus the helpers in `src/utilities/posts.ts`.

View file

@ -0,0 +1 @@
**S44 — Home: seção da Plenária da Vitória (divulgação e acesso) (2026-10-01):** a home pública ganha a seção da Plenária da Vitória logo depois do hero, dirigida por **um share link fixo** (`SHARE_LINK_HOME_SECTION_SLUG = 'plenaria-vitoria'`, `src/lib/shareLinkHomeSection.ts`) — sem campo/global novo, sem migration: o link continua dono de título, descrição, imagem, data e destinos. `loadShareLinkHomeSection` (`src/utilities/shareLinkReads.ts`) lê sob a mesma tag cacheada `shareLinks` do anúncio (despublicar tira a seção sem deploy) e **falha fechado**: sem link publicado, sem `startsAt` válido ou `agora >= endsAt` (senão `startsAt` + 2h, mesma convenção do S29) → `null`, a seção não existe no HTML; a ilha também se esconde no instante exato do fim da janela (timeout único, re-arma acima do teto do `setTimeout`), cobrindo abas abertas sobre o HTML ISR defasado. A ilha `ShareLinkHomeSection` porta o artefato `docs/plans/secao-home-plenaria-da-vitoria-ui-design.html` (cenas 01–06): **pré-live** = imagem quadrada + data/hora da Bahia + `Online` + **`Adicionar à agenda`** (a agenda do S29; `ShareLinkAgendaMenu` ganhou `triggerClassName` para o gatilho amarelo do gate, com as classes novas no vocabulário de `menuControls.ts`), **sem botão de entrada**; **ao vivo** = a imagem dá lugar ao embed oficial `youtube-nocookie` (id derivado do destino YouTube pré-cadastrado do link, `resolveShareLinkYoutubeVideoId` puro com validação de id) e o CTA de entrada assume o lugar da agenda com rótulo por host (`Entrar na plenária` no Meet, `Assistir no YouTube` no YouTube, fallback `Entrar` — `resolveShareLinkLiveActionLabel`) e href **sempre `/plenaria-vitoria`** (nunca a URL crua); sem destino YouTube o bloco degrada para a imagem (e para coluna única sem imagem). O estado ao vivo já vem resolvido do read cacheado no server (sem flash de agenda para quem chega durante a transmissão) e o poll do S29 foi extraído para o hook único `useShareLinkLiveTarget` (30 s, só ativa, pausa em aba oculta), consumido pelo anúncio e pela seção sem mudança de comportamento. `formatBahiaEventDayLabel`/`formatBahiaEventTimeLabel` nascem em `lib/campaignTime.ts` (o headline do anúncio passa a compor as duas, saída intacta). O débito D3 do S39 foi resolvido (3º spec bateu o gatilho): o poll do HTML ISR virou `tests/helpers/homeIsr.ts`, com rewiring de `frontend`/`frontendConteudos`, e o manifesto ganhou `src/app/(frontend)/(home)` no entry do `frontendShareLink`. Sem Consent/PII/coleta; a página `/plenaria-vitoria`, o cadastro do link, a rota `[type]` e as demais seções ficam intocados. Provas: `pnpm gate:fast` verde (459 arquivos/5.093 testes; novos unit de `shareLinkHomeSection` (11), da extração do vídeo em `shareLink` e do gating no skeleton do `campaignHome`); int `shareLink` 28 verdes (loader novo: publicado + janela futura, expirado, sem data, despublicado e pré-live); e2e `frontendShareLink` 8/8 (describe S44 novo — seção após o hero, agenda sem entrada, troca Meet→YouTube sem reload via poll, estado ao vivo já no HTML do server pelo caminho REST, kill switch e janela fechada/reaberta — com o S29 intacto), `frontendConteudos` 12/12 e `frontend` 42/42 (helper rewiring). Design hi-fi certificado pelo `designer` (tier `openai/gpt-5.6-sol`) contra o artefato `docs/plans/secao-home-plenaria-da-vitoria-ui-design.html`, após uma rodada de ajustes (linha herdada do `h2` global, padding fora do `max-w`, margens do mobile, `(horário da Bahia)` inline no mobile e o popover da agenda fiel ao artefato: largura do gatilho no mobile, `sideOffset` 8, sombra e subtítulos de 11 px). Planos: `docs/plans/secao-home-plenaria-da-vitoria.md`/`-impl.md`.

View file

@ -0,0 +1,141 @@
# Impl: S44 — Home — seção da Plenária da Vitória (divulgação e acesso)
Status: aprovado
Atualizado em: 2026-10-01
Issue: #1408
Intenção: docs/plans/secao-home-plenaria-da-vitoria.md
Design UI (gate): docs/plans/secao-home-plenaria-da-vitoria-ui-design.html
Appetite restante: herdado (~0,5–1 dia eng) — uma seção condicional na home, sem migration.
## Leitura da intenção
- **Outcome:** a home divulga a Plenária da Vitória logo depois do hero e leva a `/plenaria-vitoria` em um toque — pré-live mostra o convite (imagem + data/hora da Bahia + `Adicionar à agenda`, sem botão de entrada); ao vivo troca a imagem pelo embed oficial do YouTube e o CTA de entrada assume (`Entrar na plenária` no Meet, `Assistir no YouTube` no YouTube); despublicada ou expirada a seção inteira some (fail-closed), sem deploy.
- **O que NÃO negociar:** o CTA nunca aponta para a URL crua do Meet/YouTube (sempre `/{slug}`); a agenda é a do S29 (`ShareLinkAgendaMenu`), sem mecanismo novo; sem player próprio (embed oficial); sem campo/global/CMS novo nesta fatia; sem coleta/RSVP/rastreio; nada do cadastro do link, da página `/plenaria-vitoria`, das seções irmãs ou do redesenho da home; mobile 390 sem overflow; um único CTA primário por estado.
- **O que reavaliar:** (1) a intenção cogita "a seção acompanha o no ar como o anúncio do S29" — o poll de 30 s está **inline** em `ShareLinkAnnouncement` (`ShareLinkAnnouncement.tsx:51-88`); copiá-lo criaria dois donos do mesmo contrato (D3). (2) A intenção não fixa o que fazer quando não há destino YouTube pré-cadastrado: sem embed, degrada para a imagem e, sem imagem, coluna única (D4). (3) O watch do e2e da home pina a ordem das seções condicionais de forma agnóstica (`frontend.e2e.spec.ts:1778-1796`) — a seção nova é mais uma condicional; a posição exata fica no spec dono (D10).
## Abordagem recomendada
```mermaid
flowchart LR
subgraph server["Server · home ISR (tag shareLinks)"]
read["loadShareLinkHomeSection('plenaria-vitoria')<br/>read cacheado + canonical + buildView"]
gate{"published e agora < fim?<br/>(endsAt | startsAt+2h)"}
absent["seção ausente no HTML"]
data["{ view, initialLive }"]
end
subgraph client["Client · ilha da seção (SSR do próprio client component)"]
sec["ShareLinkHomeSection"]
timeout["timeout único de expiração → some"]
hook["useShareLinkLiveTarget(slug, initialLive)<br/>30 s · só ativa · pausa em aba oculta"]
pre["pré-live: imagem + Adicionar à agenda"]
meet["Meet no ar: embed YouTube + 'Entrar na plenária' → /plenaria-vitoria"]
yt["YouTube no ar: embed + 'Assistir no YouTube' → /plenaria-vitoria"]
end
read --> gate
gate -- "não" --> absent
gate -- "sim" --> data --> sec
sec --> timeout
sec --> hook
hook -- null --> pre
hook -- "live.href host = meet.google.com" --> meet
hook -- "live.href host = youtube" --> yt
```
**Opções consideradas:** A) server decide a visibilidade com o read cacheado e renderiza a ilha client com `initialLive` já resolvido; B) wrapper server + ilha só do swap (server pré-live sempre); C) seção 100% client que busca tudo no browser.
**Recomendação:** **A** — a home continua estática/ISR (read sob a tag `shareLinks`, o kill switch já busta), o HTML pré-live sai no server sem JS, e o destino no ar é resolvido no mesmo read cacheado, sem flash de agenda para quem chega durante a live.
**Rejeitadas:** **B** porque em cada visita ao vivo o HTML chegaria pré-live e o refetch imediato trocaria o estado, piscando a agenda antes do embed (o visitante novo é justamente o caso da reta final); **C** porque sem JS a seção sumiria e o fail-closed deixaria de ser HTML do servidor, além de expor a leitura ao browser.
### Componentes / mudanças
- **`src/lib/shareLinkHomeSection.ts` (novo, puro/client-safe):**
- `SHARE_LINK_HOME_SECTION_SLUG = 'plenaria-vitoria'` (o slug fixo do contrato);
- `ShareLinkHomeSectionView = ShareLinkAnnouncementView & { expiresAt: number; youtubeVideoId: string | null }`;
- `buildShareLinkHomeSectionView({ link, imageUrl, canonicalUrl, nowMs })` → `ShareLinkHomeSectionView | null`: janela via `resolveCalendarEventWindow(link.startsAt, link.endsAt)`; sem `startsAt` válido ou `nowMs >= end` → `null`; delega `buildShareLinkAnnouncementView` (alt/location/eventLabel) e acrescenta `expiresAt` (epoch ms) + `youtubeVideoId`;
- `resolveShareLinkLiveActionLabel(live)` → `meet.google.com` = "Entrar na plenária"; youtube/youtu.be = "Assistir no YouTube"; fallback "Entrar" (host via `new URL`, nunca o label do cadastro);
- não importa `@/utilities/**` (regra do ESLint para `src/lib`); tipos de `@/lib/shareLink`.
- **`src/lib/shareLink.ts`:** `resolveShareLinkYoutubeVideoId(destinations)` — varre o pool pré-cadastrado, aceita `youtu.be/<id>`, `watch?v=`, `/live/`, `/embed/`, `/shorts/`, valida o id (`^[A-Za-z0-9_-]{11}$`) e devolve o primeiro | `null`; sem I/O.
- **`src/lib/campaignTime.ts`:** `formatBahiaEventDayLabel(iso)` (`sexta, 2 de outubro`) e `formatBahiaEventTimeLabel(iso)` (`18h`/`18h30`); `formatBahiaEventDateLabel` passa a compor as duas, mantendo a saída atual intacta (pin em `shareLinkAnnouncement.unit.spec.ts:26`).
- **`src/utilities/shareLinkReads.ts`:** `loadShareLinkHomeSection(slug)` — server-only: `getCachedPublishedShareLinkBySlug(slug)()` (tag `shareLinks`; draft nunca sai), imagem no proxy same-origin (`typeof link.image === 'object' ? link.image.url : null`), `resolveShareLinkCanonicalUrl(slug)` e `buildShareLinkHomeSectionView({ …, nowMs: Date.now() })`; devolve `{ view, initialLive: resolveLiveShareLinkDestination(link.destinations) } | null`.
- **`src/components/shareLink/useShareLinkLiveTarget.ts` (novo, `'use client'`):** extrai o poll inline do anúncio com assinatura `useShareLinkLiveTarget(slug, initial = null)`; mesmo contrato: refetch imediato, `setInterval` 30 s só com aba visível, `visibilitychange`, guard `cancelled`, igualdade por `href`/`label`, resposta `target: null` nunca limpa o estado. Comportamento do anúncio idêntico (`initial` null).
- **`src/components/shareLink/ShareLinkAnnouncement.tsx`:** passa a consumir o hook (`useShareLinkLiveTarget(view.slug)`), removendo o efeito local — sem mudança visual/comportamental.
- **`src/components/shareLink/ShareLinkAgendaMenu.tsx`:** prop opcional `triggerClassName` (default `SECONDARY_ACTION`) para o gatilho amarelo primário do artefato; popover, rows e `.ics` intactos.
- **`src/components/shareLink/menuControls.ts`:** classes do CTA primário da home (o `PRIMARY_ACTION` do anúncio portado como vocabulário compartilhado + `SAFE_FOCUS`); o anúncio não muda.
- **`src/components/shareLink/ShareLinkHomeSection.tsx` (novo, `'use client'`):** porta classe-a-classe a `.event-section` do artefato (cena 01–06): eyebrow com ponto (`aria-live="polite"` na troca de estado), h2, descrição, grid de metas com ícones lucide (dia · `18h` + "horário da Bahia" · "Online"), bloco de ação (pré-live: `ShareLinkAgendaMenu` com o gatilho amarelo; ao vivo: `<iframe src="https://www.youtube-nocookie.com/embed/<id>?playsinline=1&rel=0" loading="lazy" title=…>` + CTA `resolveShareLinkLiveActionLabel(live)` → `shareLinkPath(view.slug)`, sem `target="_blank"`), figura quadrada com `next/image fill` (ao vivo vira `aspect-video` com o iframe; sem imagem → sem figura), blobs decorativos `aria-hidden`, ordem mobile/desktop do artefato via grid explícito, `data-home-section="plenaria"`. Estado local: `hidden` por expiração (timeout único; no mount, se já expirado, o efeito esconde imediatamente sem mismatch de hidratação) + `live` do hook (`initialLive`).
- **`src/app/(frontend)/(home)/page.tsx`:** `loadShareLinkHomeSection(SHARE_LINK_HOME_SECTION_SLUG)` entra no `Promise.all` (`page.tsx:128`) e a seção renderiza condicional logo após `<CampaignHero />` (`page.tsx:144`), antes da prova (`data-home-section="proof"`).
- **Migration:** sem migration (nenhuma collection/global muda).
- **Access / Consent:** sem escrita, sem PII, sem Consent novo; o gate é o `published: true` do read cacheado (fail-closed).
- **UI:** Impeccable C com gate aprovado — port classe a classe das cenas 01–06, mobile (390) primeiro, inspeção em 1280; shape→craft→critique→polish; crítica final antes do push.
### Dados → forma (se aplicável)
Não aplicável — a intenção fixa **Dados: N/A** (nenhuma métrica nova). A única "forma" é a separação dia × hora no grid de metas do artefato, servida pelos dois formatadores puros (D7); rejeitados: countdown, contador, percentual ou qualquer número inventado numa superfície de convite.
## Decisões de engenharia
1. **Visibilidade/expiração — A) server decide no render; poll e timeout cobrem a borda.** Opções: A | B (TTL/revalidate próprio na home) | C (campo/global novo de destaque) | D (datas fixas no código). Recomendação: **A** — read cacheado do slug fixo (`plenaria-vitoria`), sem link publicado → `null`; janela por `resolveCalendarEventWindow` (fallback 2 h), sem `startsAt` válido ou `agora >= fim` → `null`. O client esconde no instante exato da expiração (timeout único) para abas abertas/HTML ISR defasado; o kill switch (despublicar) já busta a tag. Rejeitadas: **B** (muda o contrato de cache da home por um evento de um dia), **C** (migration por algo que dura um dia) e **D** (hardcode de datas).
2. **Estado inicial ao vivo — A) server resolve o destino do read cacheado e passa `initialLive`.** Recomendação: **A** — quem chega durante a live já recebe o embed/CTA no HTML, sem flash de agenda. O poll mantém o contrato do S29 (só ativa, 30 s, pausa em aba oculta, guard por href/label). Rejeitadas: **B** (server sempre pré-live e refetch imediato trocando — flash exatamente para o visitante novo da reta final).
3. **Poll único — A) extrair o hook `useShareLinkLiveTarget(slug, initial = null)`.** Recomendação: **A** — o anúncio (S29) e a home passam a ter um dono só do contrato de ativação; o anúncio chama sem `initial` e mantém o comportamento atual bit a bit. Rejeitadas: **B** (duplicar o poll na home — dois donos do mesmo contrato, deriva garantida).
4. **Embed — A) vídeo derivado do destino YouTube pré-cadastrado.** Recomendação: **A** — `resolveShareLinkYoutubeVideoId(destinations)` puro aceita os formatos usuais e valida o id; embed `youtube-nocookie` (convenção de `CampaignStorySection.tsx:55`), `loading="lazy"`, sem autoplay, `title` acessível. Sem destino YouTube → sem embed (cai para a imagem; sem imagem, coluna única). Rejeitadas: **B** (hardcodar o id do vídeo) e **C** (player próprio).
5. **CTA de entrada — A) rótulo pelo host do destino no ar; href sempre o path canônico.** Recomendação: **A** — `resolveShareLinkLiveActionLabel` decide Meet/YouTube/fallback pelo host; href sempre `shareLinkPath(view.slug)` (`/plenaria-vitoria`), sem `target="_blank"`. Rejeitadas: **B** (usar `live.href` direto — expõe a URL crua) e **C** (decidir pelo label do cadastro — o host é mais robusto a renomeação de rótulo).
6. **Agenda pré-live — A) reusar a `ShareLinkAgendaMenu` com `triggerClassName`.** Recomendação: **A** — o menu S29 ganha a prop opcional (default `SECONDARY_ACTION`) para o gatilho amarelo primário do gate; classes novas do CTA da home entram no vocabulário de `menuControls.ts`, reusando `SAFE_FOCUS`. Rejeitadas: **B** (duplicar o popover/menu na home) e **C** (manter o gatilho secundário branco — divergiria do gate).
7. **Formatação — A) separar dia e hora em `campaignTime.ts`.** Recomendação: **A** — novas funções puras `formatBahiaEventDayLabel` e `formatBahiaEventTimeLabel`; `formatBahiaEventDateLabel` compõe as duas sem mudar a saída atual (o anúncio do S29 segue coberto pelo unit existente). Rejeitadas: **B** (split de string do `eventLabel` — quebra frágil) e **C** (terceiro módulo de data — o dono da formatação Bahia já existe).
8. **View — A) `buildShareLinkHomeSectionView` delegando ao view model do anúncio.** Recomendação: **A** — reusa alt/location/eventLabel de `buildShareLinkAnnouncementView` e acrescenta `expiresAt` + `youtubeVideoId`; `null` quando expirado. `loadShareLinkHomeSection(slug)` em `shareLinkReads.ts` orquestra read cacheado + canonical + view (e devolve `initialLive`). Rejeitadas: **B** (shape próprio duplicando o view model) e **C** (orquestração inline no `page.tsx` — a home não deve conhecer o read/canonical).
9. **Componente — A) ilha `'use client'` SSR-ada, dona do estado.** Recomendação: **A** — porta classe a classe o `.event-section` do artefato; com JS desligado o HTML pré-live continua completo (o server renderiza o client component) e o timeout de expiração/poll vivem um lugar só. Renderizada logo após `<CampaignHero />`, condicional ao loader. Rejeitadas: **B** (server wrapper + ilha só do swap — fragmenta o dono do estado/expiração).
10. **Testes — A) unit primeiro, int na fronteira do loader, e2e no spec dono com o helper de poll extraído.** Recomendação: **A** — unit (`tests/unit/shareLinkHomeSection.unit.spec.ts`): builder visível/expirado/sem janela, extração do vídeo, rótulo do CTA e labels Bahia novos; unit do hook se barato. Int (estender `tests/int/shareLink.int.spec.ts`): `loadShareLinkHomeSection` com publicado + janela futura → view com `youtubeVideoId`; expirado → `null`; despublicado → `null`; sem `startsAt` → `null`. E2e: novo describe "S44" em `tests/e2e/frontendShareLink.e2e.spec.ts` (spec dona do shareLink; o manifesto já a acorda) — (a) cria `plenaria-vitoria` via REST com mídia e destinos Meet+YouTube, `startsAt` futuro, `mode: announcement`, published; poll do HTML converge, a seção aparece logo após o hero (ordem hero→plenaria→proof), agenda presente e sem link de entrada; flag live via SQL (sem revalidar a tag) + `visibilitychange` → iframe `youtube-nocookie` + `Entrar na plenária` href `/plenaria-vitoria` sem reload; troca para YouTube → `Assistir no YouTube`; kill switch via REST (`published: false`) → HTML converge ausente; (b) janela expirada (`startsAt` passado > 2 h) → ausente; patch `endsAt` futuro → presente (borda dos dois lados). Overflow 390 ≤ 1 px; limpeza no `afterAll` com a infra existente (`createdShareLinkIds`/`createdMediaIds`). **D3 do débito do S39** (extrair o poll do HTML ISR para `tests/helpers/`) tem o gatilho batido (3º spec): unificar `waitForHomeHTML` (`frontend.e2e.spec.ts:777`) e `waitForHomeSection` (`frontendConteudos.e2e.spec.ts:242`) num helper e rewiring os dois specs; se a extração mostrar atrito, manter local e registrar o débito reavaliado. Rejeitadas: **B** (spec e2e nova — 2ª infra de seed do mesmo contrato) e **C** (e2e com `page.clock` para a expiração client — a borda é o predicado unit-testado do builder; clock no e2e é flake).
11. **Docs — A) contrato no `AGENTS-public.md` + changelog + este impl plan.** Recomendação: **A** — `AGENTS-public.md` ganha a seção da home (slug fixo, estados, fail-closed, kill switch, sem migration/Consent); `docs/changelog/2026-10-01-s44.md`; o impl plan entra no commit.
## Fases verificáveis
1. **Tracer — puros + leitura (~0,25 dia).** `src/lib/shareLinkHomeSection.ts` (`SHARE_LINK_HOME_SECTION_SLUG`, builder, rótulo do CTA), `resolveShareLinkYoutubeVideoId` em `shareLink.ts`, os dois formatadores em `campaignTime.ts`, `loadShareLinkHomeSection` em `shareLinkReads.ts`; `tests/unit/shareLinkHomeSection.unit.spec.ts` + casos novos em `tests/int/shareLink.int.spec.ts`. Verificação: `pnpm test tests/unit/shareLinkHomeSection.unit.spec.ts tests/int/shareLink.int.spec.ts` e `pnpm gate:fast`.
2. **UI (~0,5 dia).** Hook `useShareLinkLiveTarget` + rewire do `ShareLinkAnnouncement`; `triggerClassName` na agenda; classes novas em `menuControls.ts`; `ShareLinkHomeSection`; fiação em `page.tsx`; unit do hook (se barato). Verificação: `pnpm gate:fast`; inspeção 390/1280 contra as cenas 01–06 (sem overflow; pré-live sem entrada; ao vivo sem agenda).
3. **E2e + gates (~0,25–0,5 dia).** Extrair `tests/helpers/<homePoll>.ts` e rewiring `frontend.e2e.spec.ts` + `frontendConteudos.e2e.spec.ts`; describe "S44" em `frontendShareLink.e2e.spec.ts`; manifesto — acrescentar `src/app/(frontend)/(home)` ao entry do `frontendShareLink` (mesmo racional do S39: a página é a única fiação da seção, e um diff só dela deve acordar o spec dono). Verificação: `pnpm test:e2e:affected` (ou `pnpm test:e2e --no-deps --project=frontendShareLink`, `--project=frontend`, `--project=frontendConteudos`); `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Countdown, contador, compartilhar dentro da seção, carrossel/agenda de próximos eventos, CMS de eventos (a lista de eventos é outro produto).
- Campo/global "destaque na home", migration, Consent novo, collection nova.
- CTA para URL crua do Meet/YouTube; `target="_blank"`; player próprio; autoplay com som; segundo poll/endpoint/rota; leitura live (não cacheada) no server da home.
- Tocar o cadastro do `ShareLink`, a rota `[type]`, a página `/plenaria-vitoria`, o `ShareLinkAnnouncement` além da extração do hook, as seções irmãs da home ou o `SiteHeader`/rodapé.
- Spec e2e nova, `page.clock`, screenshot/visual regression da home, dedupe do slug duplicado `plenaria-da-vitoria` (ops).
## Riscos e mitigação
- **Duplicata `plenaria-da-vitoria` (id 5) segue em produção:** fora deste item (ops); a home lê apenas o slug fixo `plenaria-vitoria`.
- **ISR/HTML defasado:** o kill switch busta a tag `shareLinks`; a expiração é coberta pelo timeout no client e o e2e converge por poll do HTML do server antes de navegar.
- **Dev Fast Refresh no primeiro compile da rota de poll:** o e2e aquece `GET /api/share-link/<slug>/live` antes do `goto` (precedente S29, `frontendShareLink.e2e.spec.ts:438-442`).
- **Concorrência em prod (4 workers):** o pin de ordem do `frontend` permanece agnóstico da seção condicional (o `frontendShareLink` pode publicar `plenaria-vitoria` em paralelo); a ordem exata hero→plenaria→proof é pinada no describe S44.
- **Slug fixo no banco de teste:** o describe S44 é serial e único dono do slug; o `beforeAll` remove resíduo de runs abortados e o `afterAll` limpa com a infra existente.
- **Sem imagem e/ou sem destino YouTube:** o componente precisa renderizar sem figura e sem embed (coluna única) sem quebrar o grid do artefato; coberto no unit do builder e na inspeção das cenas.
- **Hidratação/expiração:** estado inicial visível e efeito que esconde no mount quando `Date.now() >= expiresAt` (sem mismatch); timer único limpo no unmount; sem `page.clock`.
- **Segurança do embed:** iframe só existe com id validado (`^[A-Za-z0-9_-]{11}$`) do destino pré-cadastrado; URL nunca vem do estado no ar em texto cru.
## Débitos
- **D3 do S39 — resolvido aqui:** a extração do poll do HTML ISR para `tests/helpers/` é passo da fase 3 (o 3º spec bateu o gatilho). Se a extração mostrar atrito real, manter local e registrar o débito reavaliado no changelog.
- **Dedupe do slug duplicado em produção** — ops, fora deste item (registrado na intenção).
- **Copy do dia da semana ("sexta" vs `Intl` pt-BR "sexta-feira")** — validar no port contra o gate; se divergir, ajustar a copy no artefato, nunca a saída do formatador (o anúncio do S29 precisa continuar idêntico).
### Débitos da revisão (simplify) — explicitamente fora / adiado com gatilho
- **Já resolvido no simplify (não reabrir):** exports mortos (knip); twin do `YOUTUBE_VIDEO_ID_PATTERN` (dono único em `lib/shareLink`, importado por `utilities/socialFeed/excludedItems`); listas de hosts YouTube unificadas; CTA desktop/mobile em `LiveEntryAction`; rename `ShareLinkHomeSectionData`; type do mock no unit da home; mensagem do helper de ISR com o erro original; import do slug no e2e; e2e do estado ao vivo já no HTML do server (caminho `initialLive`); e o describe int S44 aninhado no describe dono (o `afterAll` passou a limpar as linhas — provado com duas execuções).
- **Adiado com gatilho — S2:** ao expirar, o timer esconde a seção mas o hook segue pollando (30 s) até o unmount, e `expired` não volta a `false` se `view.expiresAt` mudar num refresh RSC (hoje a home não re-renderiza a seção no cliente). Gatilho: a seção ganhar refresh dinâmico no cliente (revalidate/refresh RSC) ou o `/api/share-link/<slug>/live` passar a ter custo/rate relevante — aí parar o poll com `expired` (flag `enabled`/desmontar a ilha).
- **Descartado — S1:** `eventLabel` segue no view da home de propósito — `buildShareLinkHomeSectionView` reusa `buildShareLinkAnnouncementView` (D8); shape próprio foi rejeitado. Revisitar só se o payload ganhar um 2º campo inútil.
- **Descartado — S3:** os `cleanup()`/resets dentro do `it` isolam renders múltiplos do mesmo teste; o `afterEach` cobre a fronteira entre testes — não duplicam.
- **Descartado — S4:** `Entrar` é o fail-safe do host desconhecido; sem caso de produto. Rótulo novo só com host novo real.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: seção só enquanto publicado e não expirado; pré-live imagem + `Adicionar à agenda` sem entrada; ao vivo embed + CTA por host sempre → `/plenaria-vitoria`; troca sem reload; kill switch sem deploy; demais seções intocadas; 390 sem overflow; acessível e sem autoplay com som; sem coleta.
- [ ] Invariantes AGENTS/engineering-standards: `src/lib` sem importar `@/utilities/**`; client importa de server só `import type`; sem migration; sem Consent novo; sem PII; copy pt-BR / identificadores em inglês.
- [ ] Testes de domínio previstos: unit do builder/vídeo/CTA/labels (+ hook se barato); int do `loadShareLinkHomeSection`; e2e S44 no spec dono; helper de poll extraído e specs rewired.
- [ ] Gates: `pnpm gate:fast`; e2e afetado (`frontendShareLink`, `frontend`, `frontendConteudos`); `pnpm push`.
## Self-score decision-quality
4,5/5.
1. **Decisões caras com rejeitadas (5/5):** visibilidade/cache, estado inicial, ownership do poll, embed, CTA/href, agenda, formatação, view model, componente, testes e docs têm Opções + Recomendação + rejeitadas explícitas (D1–D11).
2. **Cabe no appetite herdado (4,5/5):** sem schema/API nova; 1 módulo puro, 1 loader, 1 hook extraído, 1 componente e extensões de specs — o custo concentra-se na UI e no e2e, dentro de ~0,5–1 dia.
3. **Rabbit holes nomeados (5/5):** countdown, CMS de eventos, campo/global/migration, URL crua, player próprio, segundo poll, spec e2e nova, `page.clock`, dedupe.
4. **Depth check (4,5/5):** reusa o read cacheado + tag `shareLinks`, o view model do anúncio, `resolveCalendarEventWindow`, a agenda S29, o vocabulário de `menuControls` e a infra e2e; extrai o poll do dono existente em vez de duplicar.
5. **Intenção preservada (5/5):** outcome intacto; as reavaliações (sem destino YouTube, helper de poll e o pin agnóstico) são delegadas/antecipadas pela própria intenção e não mudam produto.

View file

@ -113,6 +113,9 @@ export const E2E_AFFECTED_MANIFEST = [
'src/components/ShareLinkRedirect.tsx',
'src/components/shareLink',
'src/app/(frontend)/api/share-link',
// S44 — the home page is the only wiring of the Plenária section; a diff
// there alone must still wake the spec that owns the section.
'src/app/(frontend)/(home)',
],
specs: ['frontendShareLink'],
},

View file

@ -8,11 +8,14 @@ import { CampaignStorySection } from '@/components/CampaignStorySection'
import { ContentPieceHomeSection } from '@/components/conteudos/ContentPieceHomeSection'
import { SelfieSearchHomeSection } from '@/components/fotos/SelfieSearchHomeSection'
import { JingleHomeSection } from '@/components/jingles/JingleHomeSection'
import { ShareLinkHomeSection } from '@/components/shareLink/ShareLinkHomeSection'
import { SHARE_LINK_HOME_SECTION_SLUG } from '@/lib/shareLinkHomeSection'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
import { getPublishedContentPieceItems } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { getPublishedJingleItems } from '@/utilities/jingleReads'
import { loadShareLinkHomeSection } from '@/utilities/shareLinkReads'
import type { ReactNode } from 'react'
import { CampaignCardsSection } from './CampaignCardsSection'
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
@ -125,12 +128,16 @@ export default async function HomePage() {
// 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).
// C233 — the same contract for the "Fotos" discovery flag (tag `archivePhotos`).
const [pixelId, jingles, contentPieces, showFotos, album] = await Promise.all([
// S44 — the Plenária section reads one published share link under the
// `shareLinks` tag; unpublished or past the event window, the section is
// absent from the HTML (fail-closed, kill switch without a deploy).
const [pixelId, jingles, contentPieces, showFotos, album, plenaria] = await Promise.all([
getCampaignHomeMetaPixelId(),
getPublishedJingleItems(),
getPublishedContentPieceItems(),
hasPublishedArchivePhotos(),
getCachedGlobal('photoAlbum')(),
loadShareLinkHomeSection(SHARE_LINK_HOME_SECTION_SLUG),
])
const homeJingles = jingles.slice(0, 3)
// C243 — the discovery section only exists while the selfie search is open
@ -143,6 +150,10 @@ export default async function HomePage() {
<main className="w-full bg-white text-black">
<CampaignHero />
{plenaria ? (
<ShareLinkHomeSection view={plenaria.view} initialLive={plenaria.initialLive} />
) : null}
<section
aria-label="Experiência e atuação parlamentar"
data-home-section="proof"

View file

@ -16,6 +16,12 @@ type ShareLinkAgendaMenuProps = {
canonicalUrl: string | null
startsAt: string | null
endsAt: string | null
/**
* S44 — the home section's agenda is the primary action (yellow CTA), while
* the announcement page keeps the secondary outline. Default preserves the
* S29 look.
*/
triggerClassName?: string
}
/**
@ -31,6 +37,7 @@ export const ShareLinkAgendaMenu = ({
canonicalUrl,
startsAt,
endsAt,
triggerClassName = SECONDARY_ACTION,
}: ShareLinkAgendaMenuProps) => {
const [open, setOpen] = useState(false)
@ -49,13 +56,20 @@ export const ShareLinkAgendaMenu = ({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" className={SECONDARY_ACTION}>
<button type="button" className={triggerClassName}>
<CalendarPlusIcon className="size-4" aria-hidden="true" />
Adicionar à agenda
{open ? <ChevronDownIcon className="size-4" aria-hidden="true" /> : null}
</button>
</PopoverTrigger>
<PopoverContent align="center" className={MENU_POPOVER} aria-label="Opções de agenda">
<PopoverContent
align="center"
side="bottom"
sideOffset={8}
collisionPadding={12}
className={MENU_POPOVER}
aria-label="Opções de agenda"
>
<a
href={googleUrl}
target="_blank"
@ -71,7 +85,9 @@ export const ShareLinkAgendaMenu = ({
</span>
<span>
Google Agenda
<small className="block font-normal text-black/45">Abrir evento preenchido</small>
<small className="mt-0.5 block text-[11px] font-normal text-black/45">
Abrir evento preenchido
</small>
</span>
</a>
<a
@ -83,7 +99,9 @@ export const ShareLinkAgendaMenu = ({
<CalendarPlusIcon className="size-5 flex-none text-(--pt-red)" aria-hidden="true" />
<span>
Baixar arquivo .ics
<small className="block font-normal text-black/45">Apple, Outlook e desktop</small>
<small className="mt-0.5 block text-[11px] font-normal text-black/45">
Apple, Outlook e desktop
</small>
</span>
</a>
</PopoverContent>

View file

@ -2,19 +2,16 @@
import { CalendarIcon, ExternalLinkIcon, MapPinIcon, VideoIcon } from 'lucide-react'
import Image from 'next/image'
import { useEffect, useState } from 'react'
import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignPageHeader } from '@/components/CampaignPageHeader'
import { SAFE_FOCUS } from '@/components/shareLink/menuControls'
import { ShareLinkAgendaMenu } from '@/components/shareLink/ShareLinkAgendaMenu'
import { ShareLinkShareMenu } from '@/components/shareLink/ShareLinkShareMenu'
import type { ShareLinkLiveTarget } from '@/lib/shareLink'
import { useShareLinkLiveTarget } from '@/components/shareLink/useShareLinkLiveTarget'
import type { ShareLinkAnnouncementView } from '@/lib/shareLinkAnnouncement'
import { cn } from '@/lib/utils'
const POLL_INTERVAL_MS = 30_000
const PRIMARY_ACTION = cn(
'inline-flex min-h-14 w-full items-center justify-center gap-2.5 rounded-[10px] bg-(--pt-yellow) px-4',
'font-[family-name:var(--font-exo2)] text-lg font-black text-(--pt-yellow-ink) no-underline',
@ -44,49 +41,10 @@ export const ShareLinkAnnouncement = ({
showJingles?: boolean
showFotos?: boolean
}) => {
const [live, setLive] = useState<ShareLinkLiveTarget | null>(null)
const live = useShareLinkLiveTarget(view.slug)
const isLive = live !== null
const hasEventDetails = Boolean(view.eventLabel || view.location)
useEffect(() => {
let cancelled = false
const refetch = async () => {
try {
const response = await fetch(`/api/share-link/${encodeURIComponent(view.slug)}/live`, {
cache: 'no-store',
})
if (!response.ok) return
const { target } = (await response.json()) as { target: ShareLinkLiveTarget | null }
if (cancelled || !target) return
setLive((current) =>
current?.href === target.href && current?.label === target.label ? current : target,
)
} catch {
// A transient failure is a lost tick: the next poll retries.
}
}
// Read immediately: the server render may be a cached pre-broadcast page
// that is already out of date.
void refetch()
const interval = setInterval(() => {
if (document.visibilityState === 'visible') void refetch()
}, POLL_INTERVAL_MS)
const handleVisibility = () => {
if (document.visibilityState === 'visible') void refetch()
}
document.addEventListener('visibilitychange', handleVisibility)
return () => {
cancelled = true
clearInterval(interval)
document.removeEventListener('visibilitychange', handleVisibility)
}
}, [view.slug])
return (
<div data-theme="campaign-site" className="flex min-h-full flex-col bg-(--campaign-cream)">
<CampaignPageHeader badge={isLive ? 'Ao vivo' : 'Atividade online'} />

View file

@ -0,0 +1,259 @@
'use client'
import { ArrowRightIcon, CalendarIcon, ClockIcon, VideoIcon } from 'lucide-react'
import Image from 'next/image'
import { useEffect, useState } from 'react'
import { ShareLinkAgendaMenu } from '@/components/shareLink/ShareLinkAgendaMenu'
import { SHARE_LINK_HOME_PRIMARY_ACTION } from '@/components/shareLink/menuControls'
import { useShareLinkLiveTarget } from '@/components/shareLink/useShareLinkLiveTarget'
import { formatBahiaEventDayLabel, formatBahiaEventTimeLabel } from '@/lib/campaignTime'
import { shareLinkPath, type ShareLinkLiveTarget } from '@/lib/shareLink'
import {
resolveShareLinkLiveActionLabel,
type ShareLinkHomeSectionView,
} from '@/lib/shareLinkHomeSection'
import { cn } from '@/lib/utils'
const META_ITEM =
'flex min-w-0 items-center gap-[11px] py-2.5 text-sm leading-[1.2] font-extrabold text-[#211b19] lg:py-0'
const META_ICON =
'grid size-9 flex-none place-items-center rounded-[10px] bg-[rgb(162_28_28/9%)] text-(--pt-red)'
// `setTimeout` clamps delays above this (Node/browser) and fires immediately;
// a far-future `endsAt` re-arms instead of hiding the section on mount.
const MAX_TIMER_MS = 2_147_483_647
/** The single live entry action (the desktop/mobile variants differ only in placement). */
const LiveEntryAction = ({
slug,
label,
className,
}: {
slug: string
label: string
className: string
}) => (
<a href={shareLinkPath(slug)} className={cn(SHARE_LINK_HOME_PRIMARY_ACTION, className)}>
{label}
<ArrowRightIcon className="size-[19px]" strokeWidth={2.25} aria-hidden="true" />
</a>
)
/**
* S44 — the home section that announces the Plenária da Vitória (design
* artefato `secao-home-plenaria-da-vitoria-ui-design.html`, cenas 01–06).
* Before the broadcast it shows the invitation (image, Bahia date/time and the
* S29 agenda, never an entry button); once a destination is on air the image
* gives way to the official YouTube player and the entry CTA takes the agenda's
* place, always pointing to the link's canonical path. The section hides
* itself the moment the event window closes (open tabs), on top of the
* server-side fail-closed gate (unpublished or expired → absent from the HTML).
*/
export const ShareLinkHomeSection = ({
view,
initialLive,
}: {
view: ShareLinkHomeSectionView
initialLive: ShareLinkLiveTarget | null
}) => {
const live = useShareLinkLiveTarget(view.slug, initialLive)
const [expired, setExpired] = useState(false)
const isLive = live !== null
useEffect(() => {
let timer: ReturnType<typeof setTimeout>
const schedule = () => {
const remaining = view.expiresAt - Date.now()
if (remaining <= 0) {
timer = setTimeout(() => setExpired(true), 0)
return
}
timer = setTimeout(schedule, Math.min(remaining, MAX_TIMER_MS))
}
schedule()
return () => clearTimeout(timer)
}, [view.expiresAt])
if (expired) return null
const dayLabel = view.startsAt ? formatBahiaEventDayLabel(view.startsAt) : ''
const timeLabel = view.startsAt ? formatBahiaEventTimeLabel(view.startsAt) : ''
const entryLabel = live ? resolveShareLinkLiveActionLabel(live) : null
const figure =
isLive && view.youtubeVideoId ? (
<figure className="m-0 mt-6 lg:col-start-2 lg:mt-0">
<div className="relative aspect-video w-full overflow-hidden rounded-[18px] border border-black/10 bg-black shadow-[0_18px_46px_rgb(71_19_14/18%)]">
<iframe
src={`https://www.youtube-nocookie.com/embed/${view.youtubeVideoId}?playsinline=1&rel=0`}
title={`Ao vivo: ${view.title}`}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="absolute inset-0 h-full w-full border-0"
/>
</div>
</figure>
) : view.imageUrl ? (
<figure className="m-0 mt-7 lg:col-start-2 lg:mt-0">
<div className="relative aspect-square w-full overflow-hidden rounded-[18px] border border-[rgb(71_19_14/18%)] bg-[#eee9e4] shadow-[0_18px_46px_rgb(71_19_14/10%)]">
<Image
src={view.imageUrl}
alt={view.imageAlt}
fill
sizes="(min-width: 1024px) 390px, 100vw"
className="object-cover"
/>
</div>
</figure>
) : null
return (
<section
aria-labelledby="plenaria-title"
data-home-section="plenaria"
className="relative isolate overflow-hidden border-b border-(--campaign-line) bg-(--campaign-cream) px-5 py-9 sm:px-8 lg:px-16 lg:py-14"
>
<span
aria-hidden="true"
className="pointer-events-none absolute -top-[170px] -right-[108px] -z-10 size-[420px] rounded-full bg-(--pt-yellow) opacity-30"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute -bottom-[230px] -left-[190px] -z-10 size-[390px] rounded-full bg-[#184e92] opacity-[0.055]"
/>
<div
className={cn(
'mx-auto grid max-w-[1152px] gap-0 lg:items-center',
isLive
? 'lg:grid-cols-[minmax(0,1fr)_470px] lg:gap-14'
: 'lg:grid-cols-[minmax(0,1fr)_390px] lg:gap-16',
)}
>
<div className={cn(isLive ? undefined : 'lg:max-w-[610px]')}>
<p
aria-live="polite"
className={cn(
'm-0 flex items-center gap-[9px] font-[family-name:var(--font-exo2)] font-black text-(--pt-red) uppercase',
isLive
? 'text-[10px] tracking-[0.08em] lg:text-[11px]'
: 'text-[10px] tracking-[0.11em] lg:text-[12px]',
)}
>
<span
aria-hidden="true"
className={cn(
'flex-none rounded-full bg-(--pt-red) shadow-[0_0_0_5px_rgb(162_28_28/10%)]',
isLive ? 'size-2' : 'size-[9px]',
)}
/>
{isLive ? 'Ao vivo agora' : 'Encontro online'}
</p>
<h2
id="plenaria-title"
className={cn(
'border-0 pb-0 font-[family-name:var(--font-exo2)] font-black tracking-[-0.04em] text-(--campaign-ink)',
isLive
? 'mt-3 text-[34px] leading-none lg:mt-4 lg:text-[43px]'
: 'mt-3 text-[36px] leading-[0.98] lg:mt-4 lg:text-[48px]',
)}
>
{view.title}
</h2>
<p
className={cn(
'text-black/72',
isLive
? 'mt-4 text-[14px] leading-6 lg:max-w-[560px] lg:text-[17px] lg:leading-[1.55]'
: 'mt-4 text-[15px] leading-6 lg:mt-5 lg:max-w-[590px] lg:text-[18px] lg:leading-[1.55]',
)}
>
{view.description}
</p>
{isLive ? (
<>
<div className="mt-5 grid gap-1 border-y border-(--campaign-line) py-3 text-xs font-extrabold lg:hidden">
<span>
{dayLabel} · {timeLabel}
</span>
<span>
{view.location ? `horário da Bahia · ${view.location}` : 'horário da Bahia'}
</span>
</div>
<div className="mt-6 hidden flex-wrap gap-x-5 gap-y-2 border-y border-(--campaign-line) py-4 text-sm font-extrabold lg:flex">
<span>{dayLabel}</span>
<span>{timeLabel} (horário da Bahia)</span>
{view.location ? <span>{view.location}</span> : null}
</div>
{entryLabel ? (
<LiveEntryAction
slug={view.slug}
label={entryLabel}
className="mt-6 hidden lg:mt-7 lg:inline-flex"
/>
) : null}
</>
) : (
<>
<div className="mt-6 grid border-y border-(--campaign-line) py-2 lg:mt-7 lg:grid-cols-3 lg:py-4">
<div className={cn(META_ITEM, 'lg:border-r lg:border-black/10 lg:pr-4')}>
<span className={META_ICON} aria-hidden="true">
<CalendarIcon className="size-5" strokeWidth={2} />
</span>
<span>{dayLabel}</span>
</div>
<div className={cn(META_ITEM, 'lg:border-r lg:border-black/10 lg:px-4')}>
<span className={META_ICON} aria-hidden="true">
<ClockIcon className="size-5" strokeWidth={2} />
</span>
<span>
{timeLabel}
<small className="mt-0.5 hidden text-[10px] font-semibold text-black/55 lg:block">
horário da Bahia
</small>
<span className="lg:hidden"> (horário da Bahia)</span>
</span>
</div>
{view.location ? (
<div className={cn(META_ITEM, 'lg:pl-4')}>
<span className={META_ICON} aria-hidden="true">
<VideoIcon className="size-5" strokeWidth={2} />
</span>
<span>{view.location}</span>
</div>
) : null}
</div>
<div className="mt-6 w-full lg:mt-7 lg:w-[270px]">
<ShareLinkAgendaMenu
slug={view.slug}
title={view.title}
description={view.description}
location={view.location}
canonicalUrl={view.canonicalUrl}
startsAt={view.startsAt}
endsAt={view.endsAt}
triggerClassName={cn(SHARE_LINK_HOME_PRIMARY_ACTION, 'w-full')}
/>
</div>
</>
)}
</div>
{figure}
{/* Mobile keeps the artifact order (player before the entry CTA); the
desktop CTA lives inside the left column, right after the meta. */}
{entryLabel ? (
<LiveEntryAction slug={view.slug} label={entryLabel} className="mt-6 w-full lg:hidden" />
) : null}
</div>
</section>
)
}

View file

@ -15,9 +15,26 @@ export const SECONDARY_ACTION = cn(
SAFE_FOCUS,
)
/**
* S44 — the home section's single primary action (design artefato
* `secao-home-plenaria-da-vitoria-ui-design.html`, `.primary-action`): the
* yellow CTA is both the agenda trigger before the broadcast and the entry
* link while on air. The announcement page keeps its own CTA constant.
*/
export const SHARE_LINK_HOME_PRIMARY_ACTION = cn(
'inline-flex min-h-[54px] items-center justify-center gap-2.5 rounded-[10px] bg-(--pt-yellow) px-5 py-3',
'font-[family-name:var(--font-exo2)] text-[17px] leading-none font-black text-(--pt-yellow-ink) no-underline',
'shadow-[0_5px_0_#cfb900,0_10px_22px_rgb(0_0_0/12%)]',
'transition-[transform,filter] duration-[180ms] ease-out hover:brightness-[0.97] hover:-translate-y-px',
'active:translate-y-[3px] active:shadow-[0_2px_0_#cfb900,0_5px_12px_rgb(0_0_0/10%)] motion-reduce:transition-none',
'focus-visible:outline-[3px] focus-visible:outline-offset-4 focus-visible:outline-(--pt-yellow)',
'focus-visible:shadow-[0_0_0_2px_var(--pt-red-dark),0_5px_0_#cfb900,0_10px_22px_rgb(0_0_0/12%)]',
)
export const MENU_ROW = cn(
'flex min-h-11 w-full items-center gap-2.5 rounded-[7px] px-2.5 py-2 text-left text-sm font-semibold text-[#211b19] no-underline',
'transition-colors hover:bg-(--campaign-band) focus-visible:bg-(--campaign-band) focus-visible:outline-none motion-reduce:transition-none',
)
export const MENU_POPOVER = 'w-[270px] rounded-[10px] border border-black/10 p-1'
export const MENU_POPOVER =
'w-[270px] max-lg:w-(--radix-popover-trigger-width) rounded-[10px] border border-black/10 p-1 shadow-[0_14px_34px_rgb(71_19_14/15%)]'

View file

@ -0,0 +1,64 @@
'use client'
import { useEffect, useState } from 'react'
import type { ShareLinkLiveTarget } from '@/lib/shareLink'
const POLL_INTERVAL_MS = 30_000
/**
* S29/S44 — the fresh "no ar" read behind the share-link surfaces. It polls
* the route handler every 30s (paused on hidden tabs), refetches immediately on
* mount/wake, and only ever ACTIVATES: a `target: null` answer never clears the
* state, so an already-open page keeps the destination it saw (the kill switch
* is the link's `published` flag, handled by the server read). `initial` seeds
* the server-resolved state (the home section renders live in the HTML for
* whoever arrives during the broadcast).
*/
export const useShareLinkLiveTarget = (
slug: string,
initial: ShareLinkLiveTarget | null = null,
): ShareLinkLiveTarget | null => {
const [live, setLive] = useState<ShareLinkLiveTarget | null>(initial)
useEffect(() => {
let cancelled = false
const refetch = async () => {
try {
const response = await fetch(`/api/share-link/${encodeURIComponent(slug)}/live`, {
cache: 'no-store',
})
if (!response.ok) return
const { target } = (await response.json()) as { target: ShareLinkLiveTarget | null }
if (cancelled || !target) return
setLive((current) =>
current?.href === target.href && current?.label === target.label ? current : target,
)
} catch {
// A transient failure is a lost tick: the next poll retries.
}
}
// Read immediately: the server render may be a cached pre-broadcast page
// that is already out of date.
void refetch()
const interval = setInterval(() => {
if (document.visibilityState === 'visible') void refetch()
}, POLL_INTERVAL_MS)
const handleVisibility = () => {
if (document.visibilityState === 'visible') void refetch()
}
document.addEventListener('visibilitychange', handleVisibility)
return () => {
cancelled = true
clearInterval(interval)
document.removeEventListener('visibilitychange', handleVisibility)
}
}, [slug])
return live
}

View file

@ -136,18 +136,40 @@ const bahiaEventTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
})
/**
* S29 — the announcement headline date, e.g. `Sábado, 3 de outubro · 19h`.
* Minutes only when they are not `00`; the timezone is always Bahia, never
* the visitor's. Empty string for an unparsable instant (callers fall back to
* hiding the date).
* S29/S44 — the event day in Bahia civil time, e.g. `sexta-feira, 2 de
* outubro`. Lowercase on purpose: the callers decide the capitalization
* (the announcement headline capitalizes; the home meta row does not). Empty
* string for an unparsable instant (callers fall back to hiding the date).
*/
export const formatBahiaEventDateLabel = (iso: string): string => {
export const formatBahiaEventDayLabel = (iso: string): string => {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return bahiaEventDayFormatter.format(date)
}
/**
* S29/S44 — the event time in Bahia civil time, e.g. `18h` (minutes only when
* they are not `00`; `18h30`). Empty string for an unparsable instant.
*/
export const formatBahiaEventTimeLabel = (iso: string): string => {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
const day = bahiaEventDayFormatter.format(date)
const [hour, minute] = bahiaEventTimeFormatter.format(date).split(':')
const time = minute === '00' ? `${hour}h` : `${hour}h${minute}`
return minute === '00' ? `${hour}h` : `${hour}h${minute}`
}
/**
* S29 — the announcement headline date, e.g. `Sábado, 3 de outubro · 19h`.
* The timezone is always Bahia, never the visitor's. Empty string for an
* unparsable instant (callers fall back to hiding the date).
*/
export const formatBahiaEventDateLabel = (iso: string): string => {
const day = formatBahiaEventDayLabel(iso)
const time = formatBahiaEventTimeLabel(iso)
if (!day || !time) return ''
const capitalizedDay = day.charAt(0).toUpperCase() + day.slice(1)
return `${capitalizedDay} · ${time}`

View file

@ -87,6 +87,61 @@ export const resolveLiveShareLinkDestination = (
return null
}
/**
* The single owner of the YouTube id contract (11 chars), shared with the
* campaign social-feed exclusion validator (`utilities/socialFeed/excludedItems`).
*/
export const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/
/** Hosts that serve YouTube videos (embed/watch), `www.` stripped by the caller. */
export const YOUTUBE_HOSTS = ['youtube.com', 'm.youtube.com', 'youtube-nocookie.com'] as const
const resolveYoutubeVideoIdFromUrl = (value: string): string | null => {
const url = normalizeAbsoluteHttpUrl(value)
if (!url) return null
const parsed = new URL(url)
const host = parsed.hostname.replace(/^www\./, '')
if (host === 'youtu.be') {
const [id] = parsed.pathname.slice(1).split('/')
return id && YOUTUBE_VIDEO_ID_PATTERN.test(id) ? id : null
}
if (!(YOUTUBE_HOSTS as readonly string[]).includes(host)) return null
if (parsed.pathname === '/watch') {
const id = parsed.searchParams.get('v')
return id && YOUTUBE_VIDEO_ID_PATTERN.test(id) ? id : null
}
const match = parsed.pathname.match(/^\/(?:live|embed|shorts)\/([^/?#]+)/)
const id = match?.[1]
return id && YOUTUBE_VIDEO_ID_PATTERN.test(id) ? id : null
}
/**
* S44 — the official player of the home section embeds the link's
* pre-registered YouTube destination (the campaign's own live), independent of
* which destination is on air. Accepts the usual public shapes
* (`youtu.be/<id>`, `watch?v=`, `/live/`, `/embed/`, `/shorts/`); the id must
* look like a real one (11 chars) — anything else fails closed to `null` and
* the section renders without the player.
*/
export const resolveShareLinkYoutubeVideoId = (
destinations: ShareLinkDestinationLike[] | null | undefined,
): string | null => {
if (!destinations?.length) return null
for (const destination of destinations) {
const url = typeof destination?.url === 'string' ? destination.url : ''
const id = resolveYoutubeVideoIdFromUrl(url)
if (id) return id
}
return null
}
/** Public path contract: a single root segment (`jorgesolla1313.com.br/<slug>`). */
export const shareLinkPath = (slug: string): string => `/${slug}`

View file

@ -0,0 +1,82 @@
import { resolveCalendarEventWindow } from '@/lib/calendarEvent'
import {
normalizeAbsoluteHttpUrl,
resolveShareLinkYoutubeVideoId,
YOUTUBE_HOSTS,
type ShareLinkDestinationLike,
type ShareLinkLiveTarget,
} from '@/lib/shareLink'
import {
buildShareLinkAnnouncementView,
type ShareLinkAnnouncementSource,
type ShareLinkAnnouncementView,
} from '@/lib/shareLinkAnnouncement'
/**
* S44 — the pure contract of the home section that announces the Plenária da
* Vitória. The section reflects the state of one fixed share link: while the
* event window is open it renders the announcement view (image + agenda before
* the broadcast; YouTube embed + entry CTA while on air), and it disappears
* (fail-closed) once the link is unpublished or the window closes. No Payload
* import here — the server read orchestrates and hands over a serializable view.
*/
export const SHARE_LINK_HOME_SECTION_SLUG = 'plenaria-vitoria'
export type ShareLinkHomeSectionSource = ShareLinkAnnouncementSource & {
destinations?: ShareLinkDestinationLike[] | null
}
export type ShareLinkHomeSectionView = ShareLinkAnnouncementView & {
/** Epoch ms when the event window closes (`endsAt`, else `startsAt` + 2h). */
expiresAt: number
/** Video id of the link's pre-registered YouTube destination, when there is one. */
youtubeVideoId: string | null
}
/**
* The visibility window is the S29 calendar window (the same 2h fallback
* duration): no valid `startsAt` means no event window, and a closed window
* hides the section entirely — never a stale promo.
*/
export const buildShareLinkHomeSectionView = ({
link,
imageUrl,
canonicalUrl,
nowMs,
}: {
link: ShareLinkHomeSectionSource
imageUrl: string | null
canonicalUrl: string | null
nowMs: number
}): ShareLinkHomeSectionView | null => {
const window = resolveCalendarEventWindow(link.startsAt, link.endsAt)
if (!window || nowMs >= window.end.getTime()) return null
return {
...buildShareLinkAnnouncementView({ link, imageUrl, canonicalUrl }),
expiresAt: window.end.getTime(),
youtubeVideoId: resolveShareLinkYoutubeVideoId(link.destinations),
}
}
const MEET_HOST = 'meet.google.com'
/**
* S44 — the entry CTA label follows the destination on air (design gate): the
* Google Meet is "Entrar na plenária", the YouTube is "Assistir no YouTube".
* The host is the source of truth (a relabeled destination never mislabels the
* button); anything else falls back to the S29 generic "Entrar".
*/
export const resolveShareLinkLiveActionLabel = (target: ShareLinkLiveTarget): string => {
const url = normalizeAbsoluteHttpUrl(target.href)
if (!url) return 'Entrar'
const host = new URL(url).hostname.replace(/^www\./, '')
if (host === MEET_HOST) return 'Entrar na plenária'
if (host === 'youtu.be' || (YOUTUBE_HOSTS as readonly string[]).includes(host)) {
return 'Assistir no YouTube'
}
return 'Entrar'
}

View file

@ -6,6 +6,10 @@ import {
shareLinkPath,
type ShareLinkLiveTarget,
} from '@/lib/shareLink'
import {
buildShareLinkHomeSectionView,
type ShareLinkHomeSectionView,
} from '@/lib/shareLinkHomeSection'
import { getCollectionListingTag } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globalReads'
import { resolveOgImage } from '@/utilities/ogImageReads'
@ -50,6 +54,38 @@ export const getCachedPublishedShareLinkBySlug = (slug: string, depth = 1) =>
},
)
export type ShareLinkHomeSectionData = {
view: ShareLinkHomeSectionView
initialLive: ShareLinkLiveTarget | null
}
/**
* S44 — the home section read: the same cached listing read as the announcement
* page (tag `shareLinks`, so unpublishing pulls the section without a deploy),
* plus the canonical URL the agenda needs and the destination already on air
* (so a visitor arriving during the live gets the embed/CTA in the HTML). The
* event window decides visibility here — a closed window (or a missing start)
* returns `null` and the home never renders the section.
*/
export const loadShareLinkHomeSection = async (
slug: string,
): Promise<ShareLinkHomeSectionData | null> => {
const link = await getCachedPublishedShareLinkBySlug(slug, 1)()
if (!link) return null
const image = typeof link.image === 'object' ? link.image : null
const canonicalUrl = await resolveShareLinkCanonicalUrl(slug)
const view = buildShareLinkHomeSectionView({
link,
imageUrl: image?.url ?? null,
canonicalUrl: canonicalUrl ?? null,
nowMs: Date.now(),
})
if (!view) return null
return { view, initialLive: resolveLiveShareLinkDestination(link.destinations) }
}
export const resolveShareLinkCanonicalUrl = async (slug: string): Promise<string | undefined> => {
const globalMetadata = await getCachedGlobal('metadata')()
const { siteUrl } = resolveSiteMetadata(globalMetadata)

View file

@ -1,3 +1,5 @@
import { YOUTUBE_VIDEO_ID_PATTERN } from '@/lib/shareLink'
/**
* Validation for `socialFeedSettings.excludedItems[].itemId` (the campaign home
* content board's manual exclusion list). The field is an array row: Payload
@ -5,9 +7,8 @@
* validate must read the platform from `siblingData` (the previous
* `validate` read `data.platform`, which is the whole document and therefore
* always `undefined` — dead code that never validated anything in production).
* The YouTube id contract itself lives in `lib/shareLink` (single owner).
*/
export const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/
export const validateExcludedItemId = (
value: string | null | undefined,
siblingData?: { platform?: string },

View file

@ -5,6 +5,7 @@ import { COLINHA_ROW_LAYOUT } from '@/lib/cardColinha'
import { TEAM_CARD_NAME_BANNER } from '@/lib/cardModels'
import { adminHeaders } from '../helpers/adminApi'
import { waitForHomeHTML, waitForHomeSection } from '../helpers/homeIsr'
import { seedTestUser } from '../helpers/seedUser'
import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub'
import { expect, test } from './fixtures/e2eTest'
@ -768,43 +769,14 @@ test.describe('Campaign home content section', () => {
return doc
}
// ISR serves the stale page while it regenerates after a revalidateTag
// (slower under the parallel suite), and a navigation that lands on it never
// refreshes its DOM. Poll the server HTML positively until it converges to
// the expected substring state (section presence or actual content, e.g.
// video titles); the navigation that follows then always lands on the fresh
// page. 12 attempts preserves the S1 convergence budget.
const waitForHomeHTML = async (
request: APIRequestContext,
includes: string[],
excludes: string[] = [],
attempts = 12,
) => {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const response = await request.get(`${baseURL}/`).catch(() => undefined)
if (response?.ok()) {
const html = await response.text()
const allIncluded = includes.every((needle) => html.includes(needle))
const anyExcluded = excludes.some((needle) => html.includes(needle))
if (allIncluded && !anyExcluded) return
}
await new Promise((resolve) => setTimeout(resolve, 1_000))
}
throw new Error(
`A home não convergiu após ${attempts}s (includes: ${includes.join(', ')}, excludes: ${excludes.join(', ')}).`,
)
}
// S39/S44 — the ISR poll lives in `tests/helpers/homeIsr.ts` (shared by the
// specs that own home sections); this spec keeps the `contents` specialization.
const waitForHomeSectionState = async (
request: APIRequestContext,
expected: 'present' | 'absent',
attempts = 12,
) => {
if (expected === 'present') {
await waitForHomeHTML(request, ['data-home-section="contents"'], [], attempts)
} else {
await waitForHomeHTML(request, [], ['data-home-section="contents"'], attempts)
}
await waitForHomeSection(request, baseURL, 'contents', expected, attempts)
}
/**
@ -1159,6 +1131,7 @@ test.describe('Campaign home content section', () => {
try {
await waitForHomeHTML(
request,
baseURL,
['E2e Vídeo em destaque', 'E2e Artigo no mix'],
['E2e Vídeo excluído'],
)
@ -1242,6 +1215,7 @@ test.describe('Campaign home content section', () => {
// section (page never breaks, no error shown).
await waitForHomeHTML(
request,
baseURL,
['E2e Artigo fallback', 'YouTube →'],
['E2e Vídeo em destaque'],
)
@ -1278,7 +1252,7 @@ test.describe('Campaign home content section', () => {
try {
// Live fetch succeeds and persists the raw snapshot.
await waitForHomeHTML(request, ['E2e Vídeo em destaque'], ['E2e Vídeo excluído'])
await waitForHomeHTML(request, baseURL, ['E2e Vídeo em destaque'], ['E2e Vídeo excluído'])
await gotoHomeFresh(page)
await expect(page.getByRole('link', { name: /E2e Vídeo em destaque/ }).first()).toBeVisible()
await page.goto('about:blank')
@ -1298,6 +1272,7 @@ test.describe('Campaign home content section', () => {
})
await waitForHomeHTML(
request,
baseURL,
['E2e Vídeo em destaque', 'E2e Vídeo de caravana'],
['E2e Vídeo de entrevista'],
)
@ -1311,7 +1286,7 @@ test.describe('Campaign home content section', () => {
// API back: the live feed returns with the original cap.
await setYouTubeStubState(request, 'ok')
await updateSocialFeedSettings(request, headers, baseSettings)
await waitForHomeHTML(request, ['E2e Vídeo de entrevista'], ['E2e Vídeo excluído'])
await waitForHomeHTML(request, baseURL, ['E2e Vídeo de entrevista'], ['E2e Vídeo excluído'])
await gotoHomeFresh(page)
await expect(page.getByText('E2e Vídeo de entrevista').first()).toBeVisible()
await page.goto('about:blank')
@ -1364,6 +1339,7 @@ test.describe('Campaign home content section', () => {
try {
await waitForHomeHTML(
request,
baseURL,
['E2e Post do muro', 'E2e Artigo no mix IG'],
['E2e Post de grade', 'YouTube →'],
)
@ -1464,6 +1440,7 @@ test.describe('Campaign home content section', () => {
// breaks, no error shown).
await waitForHomeHTML(
request,
baseURL,
['E2e Artigo fallback IG'],
['E2e Post do muro', 'Seguir no Instagram'],
)
@ -1506,7 +1483,7 @@ test.describe('Campaign home content section', () => {
try {
// Live fetch succeeds and persists the raw snapshot (with the username).
await waitForHomeHTML(request, ['E2e Post do muro'], ['E2e Post de grade'])
await waitForHomeHTML(request, baseURL, ['E2e Post do muro'], ['E2e Post de grade'])
await gotoHomeFresh(page)
await expect(page.getByRole('link', { name: /E2e Post do muro/ }).first()).toBeVisible()
await page.goto('about:blank')
@ -1527,6 +1504,7 @@ test.describe('Campaign home content section', () => {
})
await waitForHomeHTML(
request,
baseURL,
['E2e Post do muro', 'E2e Reel da caravana', 'Seguir no Instagram'],
['Publicação no Instagram'],
)
@ -1541,7 +1519,7 @@ test.describe('Campaign home content section', () => {
// API back: the live feed returns with the original cap.
await setInstagramStubState(request, 'ok')
await updateSocialFeedSettings(request, headers, baseSettings)
await waitForHomeHTML(request, ['Publicação no Instagram'], ['E2e Post de grade'])
await waitForHomeHTML(request, baseURL, ['Publicação no Instagram'], ['E2e Post de grade'])
await gotoHomeFresh(page)
await expect(page.getByText('Publicação no Instagram').first()).toBeVisible()
await page.goto('about:blank')
@ -1595,6 +1573,7 @@ test.describe('Campaign home content section', () => {
// section lives on.
await waitForHomeHTML(
request,
baseURL,
['E2e Artigo kill switch'],
['E2e Vídeo em destaque', 'YouTube →', 'E2e Post do muro', 'Seguir no Instagram'],
)
@ -1658,7 +1637,11 @@ test.describe('Campaign home content section', () => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
try {
await waitForHomeHTML(request, [articleTitle, 'E2e Vídeo em destaque', 'E2e Post do muro'])
await waitForHomeHTML(request, baseURL, [
articleTitle,
'E2e Vídeo em destaque',
'E2e Post do muro',
])
await page.setViewportSize({ width: 1280, height: 900 })
await gotoHomeFresh(page)
const section = page.locator('[data-home-section="contents"]')

View file

@ -9,6 +9,7 @@ import { getMunicipalityCatalogEntry } from '../../src/lib/municipalityCatalog.j
import { slugify } from '../../src/lib/slug.js'
import { adminHeaders } from '../helpers/adminApi'
import { interiorPointOf } from '../helpers/featureBounds'
import { waitForHomeSection } from '../helpers/homeIsr'
import { seedTestUser } from '../helpers/seedUser'
import { expect, test } from './fixtures/e2eTest'
@ -234,28 +235,6 @@ 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' })
// C226 — the still of a video piece is asked for before the play and answers
@ -740,7 +719,7 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await createPiece(request, headers, { title: photoTitle, type: 'foto' })
await createPiece(request, headers, { title: videoTitle, type: 'video' })
await waitForHomeSection(request, 'present')
await waitForHomeSection(request, BASE_URL, 'content-pieces', 'present')
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`/?e2e=${Date.now()}`)
@ -805,7 +784,7 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
expect(overflow).toBeLessThanOrEqual(1)
await unpublishEveryPiece(request, headers)
await waitForHomeSection(request, 'absent')
await waitForHomeSection(request, BASE_URL, 'content-pieces', 'absent')
await page.goto(`/?e2e=${Date.now()}`)
await expect(page.locator('[data-home-section="content-pieces"]')).toHaveCount(0)
})
@ -838,7 +817,7 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
const title = `Peça de Feira de Santana ${uniqueMarker()}`
await createPiece(request, headers, { title, type: 'foto', municipality: municipality.id })
await waitForHomeSection(request, 'present')
await waitForHomeSection(request, BASE_URL, 'content-pieces', 'present')
await context.grantPermissions(['geolocation'])
await context.setGeolocation({ latitude: point.lat, longitude: point.lng })

View file

@ -10,7 +10,9 @@ import {
formatBahiaEventDateLabel,
parseBahiaDateTimeInput,
} from '../../src/lib/campaignTime.js'
import { SHARE_LINK_HOME_SECTION_SLUG } from '../../src/lib/shareLinkHomeSection.js'
import { adminHeaders } from '../helpers/adminApi'
import { waitForHomeHTML, waitForHomeSection } from '../helpers/homeIsr'
import { metaContent } from '../helpers/metaContent'
import { seedTestUser } from '../helpers/seedUser'
import { expect, test } from './fixtures/e2eTest'
@ -485,3 +487,196 @@ test.describe('Frontend share-link announcement (S29)', () => {
}
})
})
test.describe('Frontend home Plenária section (S44)', () => {
// The home section reads one FIXED slug (`plenaria-vitoria`), so this
// describe is the only owner of those rows and runs serially — the other
// specs keep their unique-slug fixtures untouched.
test.describe.configure({ mode: 'serial' })
const HOME_SECTION_SLUG = SHARE_LINK_HOME_SECTION_SLUG
const YOUTUBE_VIDEO_ID = '77bUgl7cvQ8'
test.beforeAll(async () => {
await seedTestUser()
})
const shareLinkIdsBySlug = async (
request: APIRequestContext,
headers: Record<string, string>,
slug: string,
): Promise<number[]> => {
const response = await request.get(
`${BASE_URL}/api/shareLink?where[slug][equals]=${encodeURIComponent(slug)}&limit=10&depth=0`,
{ headers },
)
expect(response.ok(), await response.text()).toBeTruthy()
const { docs } = (await response.json()) as { docs: Array<{ id: number }> }
return docs.map((doc) => doc.id)
}
/** Deletes any residue of the fixed slug (aborted runs) before each flow. */
const resetHomeSectionLink = async (
request: APIRequestContext,
headers: Record<string, string>,
) => {
for (const id of await shareLinkIdsBySlug(request, headers, HOME_SECTION_SLUG)) {
await request.delete(`${BASE_URL}/api/shareLink/${id}`, { headers })
const index = createdShareLinkIds.indexOf(id)
if (index >= 0) createdShareLinkIds.splice(index, 1)
}
}
const createHomeSectionLink = async (
request: APIRequestContext,
headers: Record<string, string>,
overrides: Partial<ShareLinkInput> = {},
): Promise<number> => {
const image =
overrides.image ?? (await createMedia(request, headers, 'Plenária da Vitória 1313'))
return createShareLink(request, headers, {
title: 'Plenária da Vitória',
slug: HOME_SECTION_SLUG,
destinations: [
{ label: 'Google Meet', url: 'https://meet.google.com/fyz-rurx-biv' },
{ label: 'Youtube', url: `https://www.youtube.com/live/${YOUTUBE_VIDEO_ID}` },
],
description: 'O time de Jorge Solla se encontra antes da vitória.',
published: true,
mode: 'announcement',
startsAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
location: 'Online',
image,
...overrides,
})
}
test('announces the plenária after the hero, swaps to the live player and hides on unpublish', async ({
page,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
await resetHomeSectionLink(request, headers)
const id = await createHomeSectionLink(request, headers)
// Warm the poll route before the browser mounts it (the S29 dev-mode Fast
// Refresh caveat: the first compile of the route reloads the open page).
await request.get(`${BASE_URL}/api/share-link/${HOME_SECTION_SLUG}/live`)
await waitForHomeSection(request, BASE_URL, 'plenaria', 'present')
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`/?e2e=${Date.now()}`)
const section = page.locator('[data-home-section="plenaria"]')
await expect(section).toBeVisible()
// S44 — immediately after the hero, before the proof strip.
const order = await page
.locator('[data-home-section]')
.evaluateAll((sections) => sections.map((s) => s.getAttribute('data-home-section')))
expect(order.indexOf('plenaria')).toBe(order.indexOf('hero') + 1)
expect(order.indexOf('plenaria')).toBeLessThan(order.indexOf('proof'))
// Pré-live: the invitation with the S29 agenda and no entry button.
await expect(section.getByRole('heading', { name: 'Plenária da Vitória' })).toBeVisible()
await expect(section.getByText('Encontro online')).toBeVisible()
await expect(
section.getByText('O time de Jorge Solla se encontra antes da vitória.'),
).toBeVisible()
// Mobile inlines the timezone next to the time (the desktop keeps the small).
await expect(section.getByText('(horário da Bahia)')).toBeVisible()
await expect(section.getByText('Online', { exact: true })).toBeVisible()
await expect(section.getByRole('link', { name: 'Assistir no YouTube' })).toHaveCount(0)
await expect(section.getByRole('link', { name: 'Entrar na plenária' })).toHaveCount(0)
await section.getByRole('button', { name: 'Adicionar à agenda' }).click()
// The popover is portaled to the body, outside the section subtree.
await expect(page.getByRole('link', { name: /Google Agenda/ })).toHaveAttribute(
'href',
/calendar\.google\.com/,
)
await expect(page.getByRole('link', { name: /Baixar arquivo \.ics/ })).toHaveAttribute(
'href',
`/${HOME_SECTION_SLUG}/evento.ics`,
)
await page.keyboard.press('Escape')
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)
// The team flags the Meet on air through the test database (the poll
// contract; the REST path would revalidate and race the open page).
await flagLiveDestination(id, 'Google Meet')
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')))
await expect(section.getByText('Ao vivo agora')).toBeVisible({ timeout: 20_000 })
await expect(section.getByRole('link', { name: 'Entrar na plenária' })).toHaveAttribute(
'href',
`/${HOME_SECTION_SLUG}`,
)
await expect(
section.locator(`iframe[src*="youtube-nocookie.com/embed/${YOUTUBE_VIDEO_ID}"]`),
).toBeAttached()
await expect(section.getByRole('button', { name: 'Adicionar à agenda' })).toHaveCount(0)
// The YouTube takes over: only the label changes; the href stays canonical.
await flagLiveDestination(id, 'Youtube')
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')))
await expect(section.getByRole('link', { name: 'Assistir no YouTube' })).toHaveAttribute(
'href',
`/${HOME_SECTION_SLUG}`,
)
// The same state written through the admin REST API (the collection hook
// path) lands in the server HTML: whoever arrives now gets the live state
// on first paint, without waiting for the poll.
await setShareLinkDestinations(request, headers, id, [
{ label: 'Google Meet', url: 'https://meet.google.com/fyz-rurx-biv' },
{ label: 'Youtube', url: `https://www.youtube.com/live/${YOUTUBE_VIDEO_ID}`, live: true },
])
await waitForHomeHTML(request, BASE_URL, [
'data-home-section="plenaria"',
'Assistir no YouTube',
])
await page.goto(`/?e2e=${Date.now()}`)
await expect(
page
.locator('[data-home-section="plenaria"]')
.getByRole('link', { name: 'Assistir no YouTube' }),
).toBeVisible()
// Kill switch: unpublishing the link pulls the section without a deploy.
await setShareLinkPublished(request, headers, id, false)
await waitForHomeSection(request, BASE_URL, 'plenaria', 'absent')
await page.goto(`/?e2e=${Date.now()}`)
await expect(page.locator('[data-home-section="plenaria"]')).toHaveCount(0)
})
test('hides the section when the event window is closed and brings it back when it opens', async ({
page,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
await resetHomeSectionLink(request, headers)
const id = await createHomeSectionLink(request, headers, {
startsAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
})
// startsAt + 2h already passed: the section is absent from the HTML.
await waitForHomeSection(request, BASE_URL, 'plenaria', 'absent')
await page.goto(`/?e2e=${Date.now()}`)
await expect(page.locator('[data-home-section="plenaria"]')).toHaveCount(0)
// Reopening the window (an end in the future) brings the section back.
const endsAt = new Date(Date.now() + 60 * 60 * 1000).toISOString()
const response = await request.patch(`${BASE_URL}/api/shareLink/${id}`, {
headers,
data: { endsAt },
})
expect(response.ok(), await response.text()).toBeTruthy()
await waitForHomeSection(request, BASE_URL, 'plenaria', 'present')
})
})

57
tests/helpers/homeIsr.ts Normal file
View file

@ -0,0 +1,57 @@
import type { APIRequestContext } from '@playwright/test'
/**
* S39/S44 — the shared ISR poll of the specs that own home sections. The home
* is served stale while it regenerates after a `revalidateTag` (slower under
* the parallel suite), and a navigation that lands on the stale body never
* refreshes its DOM. Poll the server HTML positively until it converges to the
* expected state; the navigation that follows then always lands on the fresh
* page. 12 attempts preserves the S1 convergence budget.
*/
export const waitForHomeHTML = async (
request: APIRequestContext,
baseUrl: string,
includes: string[],
excludes: string[] = [],
attempts = 12,
): Promise<void> => {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const response = await request.get(`${baseUrl}/`).catch(() => undefined)
if (response?.ok()) {
const html = await response.text()
const allIncluded = includes.every((needle) => html.includes(needle))
const anyExcluded = excludes.some((needle) => html.includes(needle))
if (allIncluded && !anyExcluded) return
}
await new Promise((resolve) => setTimeout(resolve, 1_000))
}
throw new Error(
`A home não convergiu após ${attempts}s (includes: ${includes.join(', ')}, excludes: ${excludes.join(', ')}).`,
)
}
/** The kill-switch/section-presence specialization of the HTML poll above. */
export const waitForHomeSection = async (
request: APIRequestContext,
baseUrl: string,
section: string,
expected: 'present' | 'absent',
attempts = 12,
): Promise<void> => {
const needle = `data-home-section="${section}"`
try {
await waitForHomeHTML(
request,
baseUrl,
expected === 'present' ? [needle] : [],
expected === 'present' ? [] : [needle],
attempts,
)
} catch (error) {
throw new Error(
`A seção "${section}" da home não convergiu para "${expected}" em ${attempts}s (${(error as Error).message}).`,
)
}
}

View file

@ -49,6 +49,7 @@ import config from '@/payload.config'
import {
getCachedPublishedShareLinkBySlug,
loadPublishedShareLinkLiveTarget,
loadShareLinkHomeSection,
resolveShareLinkCanonicalUrl,
resolveShareLinkOgImageUrl,
} from '@/utilities/shareLinkReads'
@ -450,4 +451,86 @@ describe('shareLink', () => {
'https://site.test/api/media/file/site-default.png',
)
})
describe('loadShareLinkHomeSection (S44)', () => {
const startsInAnHour = () => new Date(Date.now() + 60 * 60 * 1000).toISOString()
beforeEach(() => {
getCachedGlobalMock.mockReturnValue(async () => ({ URL: 'https://canonical.example/' }))
})
it('builds the home view with the on-air destination and the YouTube player id', async () => {
const startsAt = startsInAnHour()
await createShareLink({
title: 'Plenária da Vitória',
slug: 'home-section-s44',
description: 'Encontro online.',
mode: 'announcement',
published: true,
startsAt,
location: 'Online',
destinations: [
{ label: 'Google Meet', url: 'https://meet.google.com/abc-defg-hij', live: true },
{ label: 'Youtube', url: 'https://www.youtube.com/live/77bUgl7cvQ8' },
],
})
const section = await loadShareLinkHomeSection('home-section-s44')
expect(section?.initialLive).toEqual({
href: 'https://meet.google.com/abc-defg-hij',
label: 'Google Meet',
})
expect(section?.view.youtubeVideoId).toBe('77bUgl7cvQ8')
expect(section?.view.canonicalUrl).toBe('https://canonical.example/home-section-s44')
expect(section?.view.expiresAt).toBe(Date.parse(startsAt) + 2 * 60 * 60 * 1000)
expect(section?.view.location).toBe('Online')
})
it('fails closed for an expired window, a missing start or an unpublished link', async () => {
await createShareLink({
title: 'Expirada',
slug: 'home-section-expirada-s44',
description: 'x',
mode: 'announcement',
published: true,
startsAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
})
await createShareLink({
title: 'Sem data',
slug: 'home-section-sem-data-s44',
description: 'x',
mode: 'announcement',
published: true,
})
await createShareLink({
title: 'Rascunho',
slug: 'home-section-rascunho-s44',
description: 'x',
mode: 'announcement',
published: false,
startsAt: startsInAnHour(),
})
expect(await loadShareLinkHomeSection('home-section-expirada-s44')).toBeNull()
expect(await loadShareLinkHomeSection('home-section-sem-data-s44')).toBeNull()
expect(await loadShareLinkHomeSection('home-section-rascunho-s44')).toBeNull()
expect(await loadShareLinkHomeSection('home-section-inexistente-s44')).toBeNull()
})
it('keeps the section pre-live while no destination is on air', async () => {
await createShareLink({
title: 'Pré-live',
slug: 'home-section-pre-s44',
description: 'x',
mode: 'announcement',
published: true,
startsAt: startsInAnHour(),
destinations: [{ label: 'Google Meet', url: 'https://meet.google.com/abc-defg-hij' }],
})
const section = await loadShareLinkHomeSection('home-section-pre-s44')
expect(section?.initialLive).toBeNull()
expect(section?.view.youtubeVideoId).toBeNull()
})
})
})

View file

@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import HomePage from '@/app/(frontend)/(home)/page'
import { toContentPiecePublicItem, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import type { ShareLinkHomeSectionData } from '@/utilities/shareLinkReads'
const pieceItem = toContentPiecePublicItem({
id: 1,
@ -85,6 +86,17 @@ vi.mock('@/utilities/globalReads', () => ({
}),
}))
// S44 — the Plenária section reads the fixed share link through the Payload DB
// (cached under the shareLinks tag); the unit env has no database, and the real
// window gate/swap behavior is e2e-covered (frontendShareLink.e2e.spec.ts).
const homeShareLink = vi.hoisted(() => ({
section: null as ShareLinkHomeSectionData | null,
}))
vi.mock('@/utilities/shareLinkReads', () => ({
loadShareLinkHomeSection: async () => homeShareLink.section,
}))
// S14 — the card section renders the client studio island (next/font local
// face + matchMedia + canvas); its behavior is e2e-covered, so the unit
// skeleton mocks both the face module and the island.
@ -101,6 +113,7 @@ afterEach(() => {
homePhotoAlbum.hasPhotos = false
homePhotoAlbum.selfieSearchEnabled = false
homePhotoAlbum.published = true
homeShareLink.section = null
cleanup()
})
@ -186,4 +199,38 @@ describe('Campaign home', () => {
render(await HomePage())
expect(screen.queryByRole('heading', { name: 'Encontre você nas fotos' })).toBeNull()
})
it('mostra a seção da Plenária só com o link publicado na janela (S44, fail-closed)', async () => {
const startsAt = new Date(Date.now() + 60 * 60 * 1000).toISOString()
homeShareLink.section = {
view: {
slug: 'plenaria-vitoria',
title: 'Plenária da Vitória',
description: 'O time de Jorge Solla se encontra antes da vitória.',
imageUrl: null,
imageAlt: 'Plenária da Vitória',
eventLabel: 'Sexta-feira, 2 de outubro · 18h',
location: 'Online',
startsAt,
endsAt: null,
canonicalUrl: 'https://jorgesolla1313.com.br/plenaria-vitoria',
expiresAt: Date.parse(startsAt) + 2 * 60 * 60 * 1000,
youtubeVideoId: null,
},
initialLive: null,
}
render(await HomePage())
expect(screen.getByRole('heading', { name: 'Plenária da Vitória' })).toBeTruthy()
// Pré-live: the S29 agenda is the only action — never an entry button.
expect(screen.getByRole('button', { name: 'Adicionar à agenda' })).toBeTruthy()
expect(
screen.queryByRole('link', { name: /Entrar na plenária|Assistir no YouTube/ }),
).toBeNull()
cleanup()
homeShareLink.section = null
render(await HomePage())
expect(screen.queryByRole('heading', { name: 'Plenária da Vitória' })).toBeNull()
})
})

View file

@ -10,6 +10,7 @@ import {
normalizeShareLinkDescription,
resolveLiveShareLinkDestination,
resolveShareLinkMode,
resolveShareLinkYoutubeVideoId,
SHARE_LINK_MODES,
SHARE_LINK_RESERVED_SLUGS,
SHARE_LINK_SLUG_PATTERN,
@ -137,6 +138,48 @@ describe('resolveLiveShareLinkDestination (S29)', () => {
})
})
describe('resolveShareLinkYoutubeVideoId (S44)', () => {
it.each([
'https://www.youtube.com/live/77bUgl7cvQ8',
'https://youtube.com/live/77bUgl7cvQ8?feature=share',
'https://youtu.be/77bUgl7cvQ8',
'https://www.youtube.com/watch?v=77bUgl7cvQ8',
'https://m.youtube.com/watch?v=77bUgl7cvQ8&t=10s',
'https://www.youtube.com/embed/77bUgl7cvQ8',
'https://www.youtube-nocookie.com/embed/77bUgl7cvQ8',
'https://www.youtube.com/shorts/77bUgl7cvQ8',
])('extracts the id from %s', (url) => {
expect(resolveShareLinkYoutubeVideoId([{ label: 'YouTube', url }])).toBe('77bUgl7cvQ8')
})
it('skips invalid rows and picks the first valid YouTube destination', () => {
expect(
resolveShareLinkYoutubeVideoId([
{ label: 'Meet', url: 'https://meet.google.com/abc-defg-hij' },
{ label: 'YouTube', url: 'https://www.youtube.com/live/77bUgl7cvQ8' },
{ label: 'Outro', url: 'https://youtu.be/AAAAAAAAAAA' },
]),
).toBe('77bUgl7cvQ8')
})
it('fails closed without a valid YouTube id', () => {
expect(resolveShareLinkYoutubeVideoId(null)).toBeNull()
expect(resolveShareLinkYoutubeVideoId([])).toBeNull()
expect(
resolveShareLinkYoutubeVideoId([{ label: 'Meet', url: 'https://meet.google.com/x' }]),
).toBeNull()
expect(
resolveShareLinkYoutubeVideoId([{ label: 'Y', url: 'https://youtu.be/curto' }]),
).toBeNull()
expect(resolveShareLinkYoutubeVideoId([{ label: 'Y', url: 'javascript:alert(1)' }])).toBeNull()
expect(
resolveShareLinkYoutubeVideoId([
{ label: 'Y', url: 'https://notyoutube.com/live/77bUgl7cvQ8' },
]),
).toBeNull()
})
})
describe('shareLink ics contract (S29)', () => {
it('hangs the download off the slug namespace', () => {
expect(shareLinkIcsPath('plenaria-saude')).toBe('/plenaria-saude/evento.ics')

View file

@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import { formatBahiaEventDayLabel, formatBahiaEventTimeLabel } from '@/lib/campaignTime'
import {
buildShareLinkHomeSectionView,
resolveShareLinkLiveActionLabel,
SHARE_LINK_HOME_SECTION_SLUG,
} from '@/lib/shareLinkHomeSection'
const STARTS_AT = '2026-10-02T21:00:00.000Z'
const ENDS_AT = '2026-10-03T00:00:00.000Z'
const BEFORE_END = Date.parse('2026-10-02T22:00:00.000Z')
const baseLink = {
slug: SHARE_LINK_HOME_SECTION_SLUG,
title: 'Plenária da Vitória',
description: 'O time de Jorge Solla se encontra antes da vitória.',
startsAt: STARTS_AT,
location: 'Online',
destinations: [
{ label: 'Google Meet', url: 'https://meet.google.com/fyz-rurx-biv' },
{ label: 'Youtube', url: 'https://www.youtube.com/live/77bUgl7cvQ8' },
],
}
describe('buildShareLinkHomeSectionView (S44)', () => {
it('builds the serializable view while the event window is open', () => {
const view = buildShareLinkHomeSectionView({
link: { ...baseLink, image: { alt: 'Plenária da Vitória 1313' } },
imageUrl: '/api/media/file/plenaria.jpg',
canonicalUrl: 'https://jorgesolla1313.com.br/plenaria-vitoria',
nowMs: BEFORE_END,
})
expect(view).toEqual({
slug: 'plenaria-vitoria',
title: 'Plenária da Vitória',
description: 'O time de Jorge Solla se encontra antes da vitória.',
imageUrl: '/api/media/file/plenaria.jpg',
imageAlt: 'Plenária da Vitória 1313',
eventLabel: 'Sexta-feira, 2 de outubro · 18h',
location: 'Online',
startsAt: STARTS_AT,
endsAt: null,
canonicalUrl: 'https://jorgesolla1313.com.br/plenaria-vitoria',
expiresAt: Date.parse('2026-10-02T23:00:00.000Z'),
youtubeVideoId: '77bUgl7cvQ8',
})
})
it('honors a configured end and drops the section at the exact close', () => {
const view = buildShareLinkHomeSectionView({
link: { ...baseLink, endsAt: ENDS_AT },
imageUrl: null,
canonicalUrl: null,
nowMs: Date.parse(ENDS_AT) - 1,
})
expect(view?.expiresAt).toBe(Date.parse(ENDS_AT))
expect(
buildShareLinkHomeSectionView({
link: { ...baseLink, endsAt: ENDS_AT },
imageUrl: null,
canonicalUrl: null,
nowMs: Date.parse(ENDS_AT),
}),
).toBeNull()
})
it('fails closed without a valid event window', () => {
const args = { imageUrl: null, canonicalUrl: null, nowMs: BEFORE_END }
expect(
buildShareLinkHomeSectionView({ ...args, link: { ...baseLink, startsAt: null } }),
).toBeNull()
expect(
buildShareLinkHomeSectionView({ ...args, link: { ...baseLink, startsAt: 'lixo' } }),
).toBeNull()
expect(
buildShareLinkHomeSectionView({
...args,
link: { ...baseLink, startsAt: '2026-09-30T21:00:00.000Z' },
}),
).toBeNull()
})
it('renders without the player when there is no YouTube destination', () => {
const view = buildShareLinkHomeSectionView({
link: { ...baseLink, destinations: [{ label: 'Meet', url: 'https://meet.google.com/x' }] },
imageUrl: null,
canonicalUrl: null,
nowMs: BEFORE_END,
})
expect(view?.youtubeVideoId).toBeNull()
})
})
describe('resolveShareLinkLiveActionLabel (S44)', () => {
it.each([
['https://meet.google.com/fyz-rurx-biv', 'Entrar na plenária'],
['https://www.youtube.com/live/77bUgl7cvQ8', 'Assistir no YouTube'],
['https://youtu.be/77bUgl7cvQ8', 'Assistir no YouTube'],
['https://zoom.us/j/123', 'Entrar'],
['javascript:alert(1)', 'Entrar'],
])('labels %s', (href, label) => {
expect(resolveShareLinkLiveActionLabel({ href, label: 'Destino' })).toBe(label)
})
})
describe('Bahia event labels (S44)', () => {
it('splits the day and the time for the home meta row', () => {
expect(formatBahiaEventDayLabel(STARTS_AT)).toBe('sexta-feira, 2 de outubro')
expect(formatBahiaEventTimeLabel(STARTS_AT)).toBe('18h')
expect(formatBahiaEventTimeLabel('2026-10-02T21:30:00.000Z')).toBe('18h30')
})
it('returns an empty string for an unparsable instant', () => {
expect(formatBahiaEventDayLabel('lixo')).toBe('')
expect(formatBahiaEventTimeLabel('lixo')).toBe('')
})
})

View file

@ -1,7 +1,5 @@
import {
validateExcludedItemId,
YOUTUBE_VIDEO_ID_PATTERN,
} from '@/utilities/socialFeed/excludedItems'
import { YOUTUBE_VIDEO_ID_PATTERN } from '@/lib/shareLink'
import { validateExcludedItemId } from '@/utilities/socialFeed/excludedItems'
import { describe, expect, it } from 'vitest'
describe('validateExcludedItemId', () => {