From 0a8b0774d39c434b5819aaced63aaaccd56bc4ec Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 1 Oct 2026 16:08:03 -0300 Subject: [PATCH] =?UTF-8?q?feat(S44):=20home=20divulga=20a=20Plen=C3=A1ria?= =?UTF-8?q?=20da=20Vit=C3=B3ria=20(agenda=20pr=C3=A9-live,=20embed=20ao=20?= =?UTF-8?q?vivo=20e=20kill=20switch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS-public.md | 4 + docs/changelog/2026-10-01-s44.md | 1 + .../secao-home-plenaria-da-vitoria-impl.md | 141 ++++++++++ scripts/lib/e2e-affected-manifest.mjs | 3 + src/app/(frontend)/(home)/page.tsx | 13 +- .../shareLink/ShareLinkAgendaMenu.tsx | 26 +- .../shareLink/ShareLinkAnnouncement.tsx | 46 +--- .../shareLink/ShareLinkHomeSection.tsx | 259 ++++++++++++++++++ src/components/shareLink/menuControls.ts | 19 +- .../shareLink/useShareLinkLiveTarget.ts | 64 +++++ src/lib/campaignTime.ts | 36 ++- src/lib/shareLink.ts | 55 ++++ src/lib/shareLinkHomeSection.ts | 82 ++++++ src/utilities/shareLinkReads.ts | 36 +++ src/utilities/socialFeed/excludedItems.ts | 5 +- tests/e2e/frontend.e2e.spec.ts | 57 ++-- tests/e2e/frontendConteudos.e2e.spec.ts | 29 +- tests/e2e/frontendShareLink.e2e.spec.ts | 195 +++++++++++++ tests/helpers/homeIsr.ts | 57 ++++ tests/int/shareLink.int.spec.ts | 83 ++++++ tests/unit/campaignHome.unit.spec.tsx | 47 ++++ tests/unit/shareLink.unit.spec.ts | 43 +++ tests/unit/shareLinkHomeSection.unit.spec.ts | 120 ++++++++ tests/unit/socialFeedExclusions.unit.spec.ts | 6 +- 24 files changed, 1302 insertions(+), 125 deletions(-) create mode 100644 docs/changelog/2026-10-01-s44.md create mode 100644 docs/plans/secao-home-plenaria-da-vitoria-impl.md create mode 100644 src/components/shareLink/ShareLinkHomeSection.tsx create mode 100644 src/components/shareLink/useShareLinkLiveTarget.ts create mode 100644 src/lib/shareLinkHomeSection.ts create mode 100644 tests/helpers/homeIsr.ts create mode 100644 tests/unit/shareLinkHomeSection.unit.spec.ts diff --git a/AGENTS-public.md b/AGENTS-public.md index 4c51c70f..f575bf1b 100644 --- a/AGENTS-public.md +++ b/AGENTS-public.md @@ -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`. diff --git a/docs/changelog/2026-10-01-s44.md b/docs/changelog/2026-10-01-s44.md new file mode 100644 index 00000000..04988313 --- /dev/null +++ b/docs/changelog/2026-10-01-s44.md @@ -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`. diff --git a/docs/plans/secao-home-plenaria-da-vitoria-impl.md b/docs/plans/secao-home-plenaria-da-vitoria-impl.md new file mode 100644 index 00000000..c712c870 --- /dev/null +++ b/docs/plans/secao-home-plenaria-da-vitoria-impl.md @@ -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')
read cacheado + canonical + buildView"] + gate{"published e agora < fim?
(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)
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/`, `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: `