feat(S29): link com destino trocável e página de anúncio da atividade

- shareLink ganha modo (direct|announcement), pool de destinos com "Destino no
  ar" por linha e dados do evento; destination escalar sai via migration com
  backfill lossless (legado vira direct com destino no ar)
- página de anúncio pública portada classe-a-classe, header campaign-site
  extraído (CampaignPageHeader) e ativação/troca sem reload por poll da rota
  /api/share-link/<slug>/live (fail-closed, no-store)
- agenda Google (action=TEMPLATE) + .ics por rota sob o slug; compartilhar o
  link curto (kind event); S19 intacto e 404 uniforme preservado

Related #1268
This commit is contained in:
Francisco Solla 2026-09-23 03:59:41 -03:00
parent dda55adc27
commit b8051bb7e0
35 changed files with 19297 additions and 142 deletions

View file

@ -0,0 +1 @@
**Link de compartilhamento com destino trocável e página de anúncio da atividade (S29, Issue #1268):** o `shareLink` do S19 ganha **modo por link** (`direct` padrão — links existentes intactos — | `announcement`), um **pool de destinos pré-cadastrados** (rótulo + URL, com o flag "Destino no ar" na própria linha; nenhum marcado = pré-transmissão) e os **dados do evento** (Início, Fim opcional com duração padrão de 2h, Local), visíveis no admin só no modo anúncio. A migration `20260923_054145_add_share_link_announcement` faz o backfill sem perda (cada link legado vira `direct` com o destino antigo pré-cadastrado e no ar) e o down documenta a perda do estado de anúncio. No `[type]`, `direct` sem destino no ar falha fechado no mesmo 404; `announcement` sem destino no ar serve a **página de anúncio** portada classe-a-classe das cenas 01–04 (`docs/plans/link-compartilhamento-destino-trocavel-anuncio-ui-design.html`, cena 05 estendida pelo designer para o checkbox nativo; no admin, `row` nativo para Rótulo/URL/checkbox e Início/Fim, seção "Dados do evento" em `collapsible` e cabeçalho de destino com `RowLabel` read-only mostrando `01 · Google Meet` + pill "no ar" na linha ativa) — cabeçalho campaign-site extraído para `CampaignPageHeader` (compartilhado com `/jingles`), título/descrição/imagem, data em pt-BR no fuso da Bahia, "Entrar" desativado com o aviso "A transmissão ainda não começou.", agenda (Google Agenda preenchida + `/slug/evento.ics` servido por route handler com 404 sem data — builders puros em `lib/calendarEvent` sobre os primitivos extraídos para `lib/ical`) e compartilhar o próprio link curto (kind `event` do `contentShare`). Quando a equipe marca um destino, o link volta a levar direto ao destino sem interstício (S19 intacto) e quem já está na página ativa/troca o "Entrar" sozinho: poll de 30s por `GET /api/share-link/[slug]/live` (`no-store`, fail-closed, só público) — **route handler em vez de server action** porque a resposta de action re-renderiza a árvore da rota e, com o link no ar, aplicaria o redirect no cliente (medido em e2e). Sem Consent/PII, `noindex` mantido, sem hardcode de 03/10; unit (resolvers/ICS/janela/view model), int (hook cross-field, loader publicado, live target) e e2e (página, `.ics`, ativação/troca sem reload, regressão S19) verdes.

View file

@ -0,0 +1,113 @@
# Impl: Link de compartilhamento com destino trocável e página de anúncio da atividade
Status: aprovado
Atualizado em: 2026-09-23
Issue: #1268
Intenção: docs/plans/link-compartilhamento-destino-trocavel-anuncio.md
Appetite restante: herdado (~2–3 dias eng) — cortes explícitos desta fatia: sem componente admin custom (o "Destino no ar" vira checkbox nativo por linha), sem ETag/304 no `.ics`, sem SSE, sem agenda pública/analytics/UTM, sem PII/Consent novo.
## Leitura da intenção
- **Outcome:** qualquer `jorgesolla1313.com.br/<slug>` pode virar página de anúncio (título, imagem, data/hora Bahia, local, "Entrar" desativado, agenda, compartilhar) configurada pelo painel; a equipe troca o destino "no ar" sem digitar URL; com destino no ar o link volta a levar direto (S19), e quem já está na página vê o botão ativar em ~1 min sem recarregar. Nada hardcoded para 03/10.
- **O que NÃO negociar:** contrato de URL inalterado (1 segmento na raiz); comportamento dos links S19 existentes idêntico; fail-closed (despublicado → 404 uniforme, noindex, destino só `http`/`https`, modo direto sem destino no ar recusado no formulário); sem Consent/PII (não há pessoa); sem analytics/UTM; virada é decisão humana; corpo da página não é CMS; fuso `America/Bahia`, duração padrão 2h.
- **O que reavaliar (e fica registrado):**
- O `select` "Destino no ar" da cena 05 do design não é nativo no Payload (`SelectField.options` é estático; `filterOptions` só reduz no servidor). **Trigger (b) resolvido com o `designer` (tier primário, certificado):** a cena 05 foi estendida para checkbox "Destino no ar" por linha do array (linha marcada em destaque; nenhuma = pré-transmissão; apenas um no ar) — o storage é o flag `live` na própria linha, sem id órfão. O artefato é a fonte de verdade do port.
- A URL do arquivo `.ics` não está fixada pela intenção; decidida em D2 como `/<slug>/evento.ics` (subpath do próprio slug, rota estática irmã de `[category]`).
- A copy do estado desativado é proposta; o design manda ("A transmissão ainda não começou." / "A transmissão começou.").
## Abordagem recomendada
```mermaid
flowchart LR
A["GET /&lt;slug&gt;"] --> B{"publicado?"}
B -- "não" --> N["404 uniforme (mesmo not-found)"]
B -- "sim" --> C{"mode"}
C -- "direct" --> D{"destino no ar?"}
D -- "não (estado inválido)" --> N
D -- "sim" --> R["ShareLinkRedirect S19 intacto (meta refresh + script)"]
C -- "announcement" --> E{"destino no ar?"}
E -- "sim" --> R
E -- "não" --> P["ShareLinkAnnouncement (noindex)"]
P -. "poll 30s, tab visível" .-> S["rota fail-closed lê o DB"]
S -. "live" .-> L["Entrar ativa + rótulo do destino, sem reload"]
L -. "troca de destino no painel" .-> S
G["GET /&lt;slug&gt;/evento.ics"] --> I["route handler: 404 sem Início; senão text/calendar"]
```
**Opções consideradas (decisões caras):**
- **D1 — como escolher o "no ar":** A) `select` + `liveDestinationId` (id da linha) com componente admin custom (`useField`/`useAllFormFields`); B) collection nova `shareLinkDestination` + relationships; C) checkbox `live` por linha do array `destinations`. **Recomendação: C** — decidido pelo `designer` no trigger (b) e certificado na cena 05 estendida: nativo (o repo só tem 2 componentes de campo admin, ambos para algo sem equivalente nativo), sem id órfão (duplicar documento/copiar/remover linha não quebra nada — o flag viaja com a linha), e entrega a mesma ação do dia: marcar um + salvar; nenhum = pré-transmissão. **Rejeitadas:** A — armazenar id de linha cria classe de bug (duplicate/copy/delete) e acopla o form state interno do Payload, com custo de componente + e2e admin; B — collection nova (access, grupo, tela, migration) para um pool que pertence a um único link, overkill; ambas rejeitadas por robustez/appetite, não por impossibilidade.
- **D2 — URL do `.ics`:** A) `src/app/(frontend)/[type]/evento.ics/route.ts` → `/<slug>/evento.ics` (segmento estático irmão de `[category]`; estático vence o dinâmico, sem colisão com a listing); B) `/api/share-link/<slug>/evento.ics`; C) Blob gerado no cliente. **Recomendação: A** — fica no namespace do slug, com `Content-Type`/`Content-Disposition` no servidor e 404 fail-closed para link sem data; o manifest já cobre `src/app/(frontend)/[type]`. **Rejeitadas:** B — sai do namespace e não ganha nada (fica como fallback se o Next recusar o sibling estático); C — sem URL real, sem header de download para Apple/Outlook/desktop e a garantia de "nunca arquivo vazio" viraria só do cliente.
- **D3 — ativação sem reload:** A) rota pública fail-closed (`GET /api/share-link/<slug>/live`, `no-store`) + poll de 30s por `fetch` (imediato ao montar/voltar à aba visível, pausa em hidden); B) SSE/WebSocket; C) server action pública + poll (molde `getSignatureCount`); D) `router.refresh()`. **Recomendação: A** — o padrão do site é polling; a rota devolve só `{ target }` e não acopla a resposta a RSC. **Rejeitadas:** B — infra inexistente, overkill para ~1 min; C — **medido em e2e**: a resposta de server action faz o Next re-renderizar a árvore da rota e, com o link já no ar, o refresh aplica o redirect no cliente e tira o visitante da página em vez de ativar o botão (além do Fast Refresh de compilação em dev); D — idem C por construção (o refresh puxaria o redirect). A rota mantém a página cacheada; quem chega depois é resolvido no servidor.
- **D4 — compartilhar:** A) menu local `ShareLinkShareMenu` reusando `src/lib/contentShare.ts` + `useCopyFeedback` + `Popover`/`WhatsAppIcon`; B) generalizar `ContentShareButton` com variante de trigger. **Recomendação: A** — o `ContentShareButton` é o botão-ícone de canto do card (1 call site na home) e o design pede um botão rotulado com chevron e popover próprio; a lógica compartilhada (mensagem/link/feedback) já está em lib, então a duplicação é só apresentação. **Rejeitada:** B — mexer no controle da home cujo e2e (`frontend`) **não** roda no PR de alto risco (migration ⇒ curated only), risco sem rede de browser por 2 call sites.
- **D5 — coluna `destination`:** A) dropar depois do backfill (pool é a fonte única; modo direto = destino no ar do pool); B) manter como fonte do modo direto. **Recomendação: A** — a cena 05 não tem campo escalar de destino e duas fontes para a mesma URL é dívida; backfill lossless no up, down lossy por política (precedente `20260811_111828_add_contact_phones_array.ts`). **Rejeitada:** B — exige `admin.condition` extra, `NOT NULL` condicional e deixa o "direct exige no-ar" com duas formas.
- **D6 — header da página:** A) extrair `CampaignPageHeader` de `JinglePageHeader` (brand + badge opcional) e usar nos dois; B) duplicar. **Recomendação: A** — 30 linhas movidas, cobertas pelo e2e curado `frontendJingles` (afirma o badge "Jingles oficiais"); duplicar seria o gêmeo que a doutrina proíbe.
**Recomendação (resumo):** D1-C (certificado pelo designer) · D2-A · D3-A · D4-A · D5-A · D6-A — schema e URL públicos resolvidos agora; sem abstração nova (as libs puras têm ≥1 dono real e testes), sem custom admin.
### Componentes / mudanças
- **`ShareLink` (`src/collections/ShareLink.ts`)** — dono do cadastro. Campos novos (ordem do design): `mode` (`select`, required, defaultValue `'direct'`, label "Modo do link", options "Levar direto ao destino"|"Página de anúncio"); `destinations` (`array`, label "Destinos", `admin.description` "Pré-cadastre os lugares para trocar sem digitar URL no dia. Marque 'Destino no ar' em um único destino; nenhum marcado = pré-transmissão.") com `label` (text, required, "Rótulo"), `url` (text, required, "URL do destino", trim + `validateDestination` existente reusando `SHARE_LINK_DESTINATION_INVALID_MESSAGE`) e `live` (checkbox, "Destino no ar", defaultValue false); `startsAt`/`endsAt` (`date`, "Início"/"Fim", opcionais, `admin.date.pickerAppearance: 'dayAndTime'`, description "Horário da Bahia."/"Opcional. Sem fim, a agenda usa duração padrão de 2 horas.") e `location` (text, "Local", opcional, ex.: "Online"), os três com `admin.condition: (_, siblingData) => resolveShareLinkMode(siblingData?.mode) === 'announcement'` (precedente `AllocationDecision.ts:136`). `destination` **sai**. Hook `beforeValidate` `validateShareLinkConfiguration` (molde `Activity.ts:109-151`, merge `{...originalDoc, ...data}`): >1 `live` → APIError `SHARE_LINK_LIVE_DUPLICATE_MESSAGE`; `direct` sem live → `SHARE_LINK_DIRECT_WITHOUT_LIVE_MESSAGE`; `startsAt`/`endsAt` com fim ≤ início → `SHARE_LINK_EVENT_END_BEFORE_START_MESSAGE` (mesma frase do Activity). `defaultColumns: ['title','slug','mode','published']` e `admin.description` atualizada para os dois modos. Access inalterado (`publishedOrPanelAccess`/`canManagePublishedContent`); docstring S19 atualizada.
- **`src/lib/shareLink.ts`** — dono do contrato puro. `resolveShareLinkMode(value)` (null/desconhecido → `'direct'`), `resolveLiveShareLinkDestination(destinations)` (primeira linha `live` com URL `http`/`https` válida; tipo estrutural, sem importar Payload), `shareLinkIcsPath(slug) => '/<slug>/evento.ics'`, mensagens novas. Nada de I/O.
- **`src/lib/ical.ts` (novo)** — extração dos primitivos `escapeICalText` e `formatICalDate` hoje privados em `src/utilities/calendarFeed.ts:22-26`; `calendarFeed.ts` passa a importar daqui (comportamento pinado por `tests/unit/calendarFeed.unit.spec.ts`). Evita duas implementações de escape.
- **`src/lib/calendarEvent.ts` (novo)** — builders puros de evento: `DEFAULT_EVENT_DURATION_MS` (2h), `resolveCalendarEventWindow(startsAt, endsAt)` (fallback 2h; `endsAt` inválido/≤ início cai no default), `buildGoogleCalendarEventUrl({title, details, location, startsAt, endsAt})` (`calendar.google.com/calendar/render?action=TEMPLATE`, datas UTC `YYYYMMDDTHHMMSSZ`, `URLSearchParams`) e `buildCalendarEventIcs({uid, title, description, location, startsAt, endsAt, updatedAt})` (VCALENDAR de um VEVENT, CRLF, `UID:<slug>@teqo.jorgesolla.com.br`, null sem `startsAt`).
- **`src/lib/campaignTime.ts`** — dono do fuso; novo `formatBahiaEventDateLabel(iso)` → "Sábado, 3 de outubro · 19h" (minutos só quando ≠ 00), ao lado dos formatadores existentes (`formatBahiaDateTimeLabel` etc.).
- **`src/lib/shareLinkAnnouncement.ts` (novo)** — view model serializável para o client (molde `src/lib/jingle.ts`): `ShareLinkAnnouncementView` (`slug`, `title`, `description`, `imageUrl`, `imageAlt`, `eventLabel`, `startsAt`, `endsAt`, `location`) e `buildShareLinkAnnouncementView({ link, imageUrl })`.
- **`src/utilities/shareLinkReads.ts`** — dono das leituras. Novo `loadPublishedShareLinkLiveTarget(slug)`: `payload.find` **sem cache**, `where {slug, published:true}`, depth 0, comentário de justificativa do bypass (exigido pelo `codebaseConventions`), retorno `{ href, label } | null` via `resolveLiveShareLinkDestination`. O loader cacheado (`getCachedPublishedShareLinkBySlug`, tag `shareLinks`) continua sendo o das páginas.
- **`src/app/(frontend)/api/share-link/[slug]/live/route.ts` (novo)** — `GET` fina fail-closed (D3-A): valida `isValidShareLinkSlug`, delega para `loadPublishedShareLinkLiveTarget` com `try/catch` → `{ target: null }` (nunca 5xx nem campo privado), `Cache-Control: no-store` e `dynamic = 'force-dynamic'`.
- **`src/app/(frontend)/[type]/page.tsx`** — dono do slug de 1 segmento. `loadPublishedShareLink` passa a devolver `{ link, live }`; `generateMetadata` inalterado no conteúdo (OG card via `resolveShareLinkOgImageUrl`, noindex, `{}` sem link); no `Page`: `live` → `<ShareLinkRedirect destination={live.href} />` (S19 intacto); announcement sem live → `<ShareLinkAnnouncement view={...} />` com a **URL relativa** do media proxy para renderizar (o absoluto do deployment origin fica só no card OG — mesmo-origem evita depender de `remotePatterns`); direct sem live → `notFound()`.
- **`src/app/(frontend)/[type]/evento.ics/route.ts` (novo)** — `GET` com `params.type` = slug; loader cacheado depth 0; 404 (sem `startsAt`, draft ou slug desconhecido); `Content-Type: text/calendar; charset=utf-8`, `Content-Disposition: attachment; filename="<slug>.ics"`, `Cache-Control: public, no-cache`; corpo por `buildCalendarEventIcs`.
- **`src/components/CampaignPageHeader.tsx` (novo, extraído)** — brand + `badge?: ReactNode` (o atual `JinglePageHeader` generalizado); `jingles/page.tsx` passa `badge="Jingles oficiais"`; a página de anúncio passa `badge="Ao vivo"` (só no estado live). `src/components/jingles/JinglePageHeader.tsx` sai.
- **`src/components/shareLink/ShareLinkAnnouncement.tsx` (novo, client)** — corpo da página (port classe-a-classe do design): wrapper `data-theme="campaign-site"` + `min-h-full bg-(--campaign-cream)` dentro do scroll do `[type]/layout.tsx`; header, card (imagem opcional sem caixa vazia, eyebrow "Transmissão"→"Ao vivo agora", título, descrição, `dl` com `view.eventLabel` + "Horário da Bahia" e local, chip "Destino no ar" quando live), CTA "Entrar" (`<button disabled aria-disabled>` → `<a href>`), aviso, menus, `CampaignFooter`. Estado `live: {href,label}|null`; efeito: poll imediato + `setInterval` 30s com pausa em `hidden` e checagem ao voltar (molde `SignatureCounter.tsx:63-106`); para de atualizar só ao desmontar (troca Meet→YouTube atualiza o href).
- **`src/components/shareLink/ShareLinkAgendaMenu.tsx` (novo, client)** — botão rotulado + `Popover` (design cena 04): "Google Agenda" (`buildGoogleCalendarEventUrl`, nova aba `noopener noreferrer`) e "Baixar arquivo .ics" (`shareLinkIcsPath(slug)`); **não renderiza sem `startsAt`** (nunca arquivo vazio).
- **`src/components/shareLink/ShareLinkShareMenu.tsx` (novo, client)** — botão rotulado + `Popover`: "Compartilhar no WhatsApp" (`buildContentShareWhatsAppUrl('event', title, link)`) e "Copiar link" (`useCopyFeedback` + `buildContentShareLink`); compartilha o link curto com o título.
- **`src/lib/contentShare.ts`** — `ContentShareKind` ganha `'event'` com prefixo `'Participe: '` (mensagem = `Participe: {title} — {link}`); `Record<ContentShareKind,…>` garante exaustividade no typecheck.
- **Migration:** `pnpm migrate:create add_share_link_announcement` (gera `share_link_destinations` com `id varchar PK`, `_order`, `_parent_id`, `label`, `url`, `live`; colunas `mode` enum + `starts_at`/`ends_at`/`location`; drop de `destination`) e **edição à mão**: `INSERT INTO share_link_destinations (id,_parent_id,_order,label,url,live) SELECT gen_random_uuid()::text, id, 0, 'Destino', destination, true FROM share_link WHERE destination IS NOT NULL` **antes** do `DROP COLUMN destination`; `mode` com `DEFAULT 'direct'`/backfill para as linhas antigas (modo null = direct é só defesa). Down lossy por política: restaura `destination` da linha live (fallback `_order 0`) e derruba tabela/colunas. Rodar `pnpm migrate` local e `pnpm generate:types`.
- **Access / Consent:** inalterados — `publishedOrPanelAccess` no read (o `where` publicado é a barreira anônima), `canManagePublishedContent` na escrita; sem Consent/PII novo, sem join com Contact; sem transação multi-collection (escritas são de um doc).
- **UI (Impeccable D):** superfícies = página de anúncio (desktop 1280 e mobile 390, pré e no-ar), menus de agenda/compartilhar e cadastro admin. Port **classe-a-classe** do HTML aprovado (`docs/plans/...-ui-design.html`, cenas 01–05, cena 05 estendida pelo designer no trigger b) usando os tokens de `styles.css:168-196`; ciclo shape→craft→critique→polish na fase 2, com a crítica final do `designer` (tier primário) **CERTIFICADA**. Admin é formulário nativo Payload (sem navegação paralela): "Destino no ar" é checkbox por linha certificado na cena 05; a densidade da cena usa `row` nativo (Rótulo 30% / URL 45% / checkbox 25%, Slug+Modo 50/50, Início+Fim 50/50), a seção "Dados do evento" é um `collapsible` nativo aberto com helper, e o cabeçalho de cada destino é um `RowLabel` read-only (`src/components/admin/ShareLinkDestinationRowLabel.tsx`, `useRowLabel`) que mostra `01 · Google Meet` + pill "no ar" na linha ativa — o container do array é chrome nativo sem hook de estilo por linha, adaptação aceita na crítica.
### Dados → forma
Não se aplica — a intenção não apresenta dado agregado (sem contagem/analytics); a página mostra horário/local do evento.
## Fases verificáveis
1. **Tracer / schema+server (quota ~1 dia):** libs puras (`shareLink` resolvers/mensagens/`shareLinkIcsPath`, `ical` extraído, `calendarEvent`, `campaignTime` formatter) + unit; migration/collection/hook + `generate:types` + `pnpm migrate` local; `loadPublishedShareLinkLiveTarget` + rota `live` + branch do `[type]/page.tsx` (anúncio ainda sem estilo) + rota `.ics`; atualizar seeds de int/e2e para o shape novo e ver o **S19 redirect/404/kill-switch verde** (regressão). Critério: link direct continua idêntico; anúncio sem live responde 200 sem interstício; `.ics` responde 200/404 certo.
2. **UI (quota ~1 dia):** `ShareLinkAnnouncement` + `CampaignPageHeader` extraído + menus, port classe-a-classe; labels/descriptions/condition do admin; unit dos contratos puros; screenshots 1280/390 contra as cenas (crítica final do designer); a ativação sem reload é coberta pelo e2e; `frontendJingles` verde (header extraído).
3. **Gates (quota ~0.5 dia):** e2e novos em `frontendShareLink`; entradas do manifest; `pnpm gate:fast` (lint+typecheck+unit) + `pnpm test:int` + e2e selecionado (lembrar: PR com migration é alto risco ⇒ curated, que inclui `frontendShareLink`/`frontendJingles`); entrada em `docs/changelog/2026-09-23-s29-link-anuncio.md`; `pnpm push` (PR pronta).
## Rabbit holes / Não escopo (engenharia)
- **Não criar rota raiz nova** (`evento.ics` é subpath sob `[type]`; a raiz é o contrato do slug) nem entrada em `SHARE_LINK_RESERVED_SLUGS` — o drift test varre só segmentos estáticos da raiz de `(frontend)`.
- **Não manter `destination` em paralelo** ao pool (duas fontes) nem usar `push`/edição manual de schema: schema só por migration.
- **Não depender de `revalidateTag` para a ativação**: a página segue cacheada e o poll lê o DB; quem chega depois resolve no servidor.
- **Não mexer no 404 uniforme**: sem `[type]/not-found.tsx`; anúncio sem live é 200; link ausente/rascunho é o not-found padrão do Next.
- **Não trocar a origem da imagem OG** (`resolveShareLinkOgImageUrl` = deployment origin); não usar a URL canônica do global.
- **Não expor `activity` da campanha** (PUB3), nem richText/CMS da página, nem embed/chat/RSVP, nem agendamento automático/expiração, nem analytics/UTM/QR/múltiplos links, nem picker de data customizado em Bahia no admin (fase futura se o time pedir), nem ETag/304 no `.ics`.
- **Não criar arquivo novo em `src/utilities/` top-level** (o `codebaseConventions` pina a lista): a leitura fresca entra em `shareLinkReads.ts`.
## Riscos e mitigação
- **Sibling estático `[type]/evento.ics` recusado pelo Next** → fallback imediato para `/api/share-link/[slug]/evento.ics` (só muda o href do menu; contrato do slug intacto). Verificar no build da fase 1.
- **Shape REST do `shareLink` muda** (some `destination`, entram `destinations`/`mode`): consumidores são só o admin e os testes (int/e2e atualizados); nenhum integrador externo conhecido.
- **Migration irreversível na prática** (drop de `destination`): backfill antes do drop, down lossy documentado no próprio arquivo (precedente `contact_phones`).
- **Poll + cache**: ativação nunca depende do cache; chegada pós-live depende do contrato de tag já provado pelo e2e de kill-switch do S19. Falha de poll é tick perdido (silencioso), e a rota nunca devolve 5xx nem campo privado.
- **Fuso do admin**: o picker nativo mostra o fuso do browser; a página sempre formata em Bahia (descrição "Horário da Bahia."). Se o time pedir lock, é follow-up.
- **Manifest drift**: entradas novas — `src/components/shareLink`, `src/components/CampaignPageHeader.tsx` (→ também `frontendJingles`), `src/app/(frontend)/api/share-link` e `src/lib/calendarEvent.ts`/`src/lib/ical.ts` (esta também em `campaignAgendaFeed`), `src/lib/contentShare` (→ `frontend`); `src/components/CampaignFooter.tsx`, `src/collections/ShareLink.ts`, `src/utilities/shareLinkReads.ts`, `src/app/(frontend)/[type]` e `src/lib/shareLink*` já estão mapeados.
- **Sem risco de PII/Consent** (não há pessoa) e sem novo write multi-collection.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (modo por link; pool com rótulo; direct sem no-ar recusado; anúncio com data/local; Entrar desativado→ativo ~1 min; agenda Google+.ics com data e sem data esconde o botão; compartilha o link curto; despublicado 404; nada hardcoded para 03/10)
- [ ] Invariantes AGENTS/engineering-standards (URL pública intacta; S19 sem mudança de comportamento; fail-closed/noindex; sem Consent/PII; schema só por migration `push:false`; `overrideAccess` documentado; lib→utilities→components→app; pt-BR em copy, inglês em identificador)
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — obrigatórios: unit `shareLink` (modo/live/`shareLinkIcsPath`), unit `calendarEvent` (Google/ICS/2h), unit `shareLinkAnnouncement` (view + ilha de ativação jsdom), unit `contentShare` (`event`); int `shareLink` (hook cross-field com mensagens, loader publicado, live target fail-closed). Discricionários: admin e2e do checkbox; ETag do `.ics`. E2E obrigatório estendendo `frontendShareLink`: página de anúncio 200/noindex/sem refresh, `.ics` 200/404, redirect pós-live, e a regressão S19 (redirect/meta/no-JS/404/republicação) — o spec já é do conjunto curado.
## Self-score (decision-quality)
1. **Decisões caras com rejeitadas:** 5 — D1 (A/B/C), D2 (A/B/C), D3 (A/B/C), D4 (A/B), D5 (A/B), D6 (A/B), todas com recomendação e porquê.
2. **Cabe no appetite:** 4 — ~2–3 dias com tracer cedo e cortes explícitos (sem custom admin/SSE/ETag); o teto é a extração do header + rota `.ics` + 3 libs puras, todos pequenos e testáveis.
3. **Rabbit holes nomeados:** 5 — rota raiz/reserved slug, duas fontes de destino, ativação via cache, 404 uniforme, origem OG, agenda/CMS/analytics, lista pinada de utilities.
4. **Reusa shells/helpers:** 5 — `shareLinkReads`, `calendarFeed`/primitivos iCal, `campaignTime`, `CampaignFooter`, padrão de polling, `contentShare`/`useCopyFeedback`/`Popover`, `CampaignPageHeader`; nenhuma abstração nova com <3 call sites.
5. **Intenção satisfeita:** 5 — todos os bullets do aceite mapeados em componentes/fases/testes, incluindo a questão em aberto aprovada (B: link volta a levar direto).
Média 4.8/5 — plano aprovado no modo `--auto`.

View file

@ -273,6 +273,25 @@
border-radius: 4px;
background: white;
}
.payload-array-row-live {
border: 2px solid var(--p-dark);
box-shadow: 0 2px 5px rgb(0 0 0 / 9%);
}
.payload-checkbox {
display: grid;
width: 20px;
height: 20px;
flex: none;
place-items: center;
border: 1px solid var(--p-input-line);
border-radius: 3px;
background: white;
color: white;
}
.payload-checkbox-checked {
border-color: var(--p-dark);
background: var(--p-dark);
}
@media (prefers-reduced-motion: reduce) {
* {
@ -1077,7 +1096,7 @@
</div>
<button class="border-0 bg-transparent text-[#777]">•••</button>
</div>
<div class="grid grid-cols-[0.65fr_1.35fr] gap-4 p-3">
<div class="grid grid-cols-[0.55fr_1.2fr_150px] items-end gap-4 p-3">
<div>
<label class="payload-label">Rótulo</label>
<div class="payload-input">Google Meet</div>
@ -1086,11 +1105,15 @@
<label class="payload-label">URL do destino</label>
<div class="payload-input text-[#777]">https://…</div>
</div>
<label class="flex min-h-10 items-center gap-2.5 pb-0.5 font-medium">
<span class="payload-checkbox" aria-hidden="true"></span>
<span>Destino no ar</span>
</label>
</div>
</div>
<div class="payload-array-row">
<div class="payload-array-row payload-array-row-live">
<div
class="flex items-center justify-between border-b border-[#e2e2e2] bg-[#f8f8f8] px-3 py-2"
class="flex items-center justify-between border-b border-[#333] bg-[#f3f3f3] px-3 py-2"
>
<div class="flex items-center gap-2">
<svg
@ -1110,7 +1133,7 @@
</div>
<button class="border-0 bg-transparent text-[#777]">•••</button>
</div>
<div class="grid grid-cols-[0.65fr_1.35fr] gap-4 p-3">
<div class="grid grid-cols-[0.55fr_1.2fr_150px] items-end gap-4 p-3">
<div>
<label class="payload-label">Rótulo</label>
<div class="payload-input">YouTube</div>
@ -1119,35 +1142,31 @@
<label class="payload-label">URL do destino</label>
<div class="payload-input text-[#777]">https://…</div>
</div>
<label class="flex min-h-10 items-center gap-2.5 pb-0.5 font-medium">
<span
class="payload-checkbox payload-checkbox-checked"
aria-hidden="true"
><svg
viewBox="0 0 24 24"
class="size-3.5"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="m5 12 4 4L19 6" /></svg
></span>
<span>Destino no ar</span>
</label>
</div>
</div>
</div>
</div>
<div
class="payload-section rounded-[4px] border-2 border-[#333] bg-[#fbfbfb] p-4"
>
<div class="grid grid-cols-[1fr_auto] items-end gap-4">
<div>
<label class="payload-label text-[15px]">Destino no ar</label>
<div class="payload-input flex items-center justify-between bg-white">
<span>YouTube</span
><svg
viewBox="0 0 24 24"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
<p class="payload-help">
Selecione um destino pré-cadastrado. Vazio = pré-transmissão.
</p>
</div>
<button class="payload-btn h-10" type="button">Salvar</button>
</div>
<p
class="payload-help mt-3 border-l-[3px] border-[#333] bg-[#f3f3f3] px-3 py-2"
>
<b class="font-semibold text-[#333]">Ação do dia:</b> marque “Destino no ar”
na linha desejada e salve. Desmarque todos para voltar à pré-transmissão.
Apenas um destino pode ficar no ar.
</p>
</div>
<div class="payload-section">
@ -1226,7 +1245,8 @@
</div>
<button class="payload-btn mt-5 w-full" type="button">Salvar</button>
<p class="mt-3 text-center text-[11px] leading-4 text-[#777]">
No dia: escolha “Destino no ar” e salve. Não é preciso editar a URL.
No dia: marque “Destino no ar” na linha desejada e salve. Não é preciso editar a
URL.
</p>
</aside>
</div>
@ -1237,16 +1257,16 @@
class="mx-auto mt-4 grid max-w-[1280px] gap-3 text-xs leading-5 text-[#6c615e] md:grid-cols-3"
>
<p class="m-0 rounded-lg border border-black/10 bg-white/75 p-3">
<b class="text-[#211b19]">Ação do dia:</b> “Destino no ar” recebe o maior contraste
dentro do formulário; escolher + salvar resolve.
<b class="text-[#211b19]">Ação do dia:</b> o checkbox nativo “Destino no ar” viaja com
cada linha; a linha marcada recebe o maior contraste. Marcar + salvar resolve.
</p>
<p class="m-0 rounded-lg border border-black/10 bg-white/75 p-3">
<b class="text-[#211b19]">Formulário nativo:</b> chrome, densidade, campos, arrays,
raios e ações seguem Payload; nenhuma navegação paralela.
<b class="text-[#211b19]">Formulário nativo:</b> chrome, densidade, campos, array,
checkbox, raios e ações seguem Payload; não há seletor derivado nem navegação paralela.
</p>
<p class="m-0 rounded-lg border border-black/10 bg-white/75 p-3">
<b class="text-[#211b19]">Fail-closed visível:</b> vazio significa pré-transmissão no
modo anúncio; despublicado continua respondendo 404.
<b class="text-[#211b19]">Fail-closed visível:</b> nenhuma linha marcada significa
pré-transmissão no modo anúncio; despublicado continua respondendo 404.
</p>
</div>
</section>

View file

@ -82,20 +82,42 @@ export const E2E_AFFECTED_MANIFEST = [
specs: ['frontend'],
},
{
// S19 — the share-link surface: the `[type]` branch serves the OG card and
// the instant handoff, and the pure module/collection/cached read own the
// slug, destination and publish contract.
// S19/S29 — the share-link surface: the `[type]` branch serves the OG card,
// the instant handoff and the announcement page (`.ics` included), and the
// pure module/collection/cached read own the slug, mode, destination pool,
// event window and publish contract.
prefixes: [
'src/app/(frontend)/[type]',
'src/lib/shareLink',
'src/lib/calendarEvent.ts',
'src/utilities/shareLinkReads.ts',
// The tag/revalidation vocabulary owner for the shareLinks listing.
'src/utilities/documents.ts',
'src/collections/ShareLink.ts',
'src/components/ShareLinkRedirect.tsx',
'src/components/shareLink',
'src/app/(frontend)/api/share-link',
],
specs: ['frontendShareLink'],
},
{
// S29 — the campaign-site header extracted from `/jingles` is shared by the
// announcement page; a diff there must wake both public specs.
prefixes: ['src/components/CampaignPageHeader.tsx'],
specs: ['frontendShareLink', 'frontendJingles'],
},
{
// S29 — the iCal primitives extracted from the agenda feed also render the
// announcement `.ics`; both surfaces must run.
prefixes: ['src/lib/ical.ts'],
specs: ['campaignAgendaFeed', 'frontendShareLink'],
},
{
// S4/S29 — the share-message contract behind the home cards and the
// announcement share menu.
prefixes: ['src/lib/contentShare.ts'],
specs: ['frontend', 'frontendShareLink'],
},
{
// S21 — the public jingles page: ordered published cards, the lazy
// in-page player (one at a time), the slug-based download name and the

View file

@ -0,0 +1,40 @@
import { buildCalendarEventIcs } from '@/lib/calendarEvent'
import { shareLinkIcsUid } from '@/lib/shareLink'
import { getCachedPublishedShareLinkBySlug } from '@/utilities/shareLinkReads'
export const dynamicParams = true
type RouteParams = { type: string }
/**
* S29 — the `.ics` download of an announcement link, served under the slug's
* own namespace (`/<slug>/evento.ics`, a static sibling of `[category]`). It
* fails closed to 404 for a draft/unknown value or a link without a start date
* — never an empty/broken file. The `type` param name is the `[type]` folder
* contract; its value is the share-link slug.
*/
export async function GET(_request: Request, { params }: { params: Promise<RouteParams> }) {
const { type: slug } = await params
const link = await getCachedPublishedShareLinkBySlug(slug, 0)()
if (!link) return new Response(null, { status: 404 })
const body = buildCalendarEventIcs({
uid: shareLinkIcsUid(slug),
title: link.title,
description: link.description,
location: link.location,
startsAt: link.startsAt,
endsAt: link.endsAt,
updatedAt: link.updatedAt,
})
if (!body) return new Response(null, { status: 404 })
return new Response(body, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': `attachment; filename="${slug}.ics"`,
'Cache-Control': 'public, no-cache',
},
})
}

View file

@ -1,15 +1,20 @@
import { PostCard } from '@/components/PostCard'
import { ShareLinkAnnouncement } from '@/components/shareLink/ShareLinkAnnouncement'
import { ShareLinkRedirect } from '@/components/ShareLinkRedirect'
import { SiteHeader } from '@/components/SiteHeader'
import { Badge } from '@/components/ui/Badge'
import { Separator } from '@/components/ui/separator'
import {
isValidShareLinkDestination,
normalizeShareLinkDescription,
resolveLiveShareLinkDestination,
resolveShareLinkMode,
shareLinkPath,
type ShareLinkLiveTarget,
} from '@/lib/shareLink'
import { buildShareLinkAnnouncementView } from '@/lib/shareLinkAnnouncement'
import type { ShareLink } from '@/payload-types'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import { POST_TYPE_LABELS, getVisiblePosts, isPostType } from '@/utilities/posts'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
import {
@ -29,17 +34,28 @@ export async function generateStaticParams(): Promise<RouteParams[]> {
return [...types].map((type) => ({ type }))
}
type ResolvedShareLink = { link: ShareLink; live: ShareLinkLiveTarget | null }
/**
* S19 — the share-link page lives inside the existing `[type]` dynamic segment
* (Next refuses a second dynamic folder name at the same level, E337). A
* non-post-type segment is looked up as a published share link; an unknown,
* unpublished or invalid one resolves to the same 404 (never reveals whether it
* S19/S29 — the share-link page lives inside the existing `[type]` dynamic
* segment (Next refuses a second dynamic folder name at the same level, E337).
* A non-post-type segment is looked up as a published share link; an unknown
* or unpublished one resolves to the same 404 (never reveals whether it
* existed), the same uniform not-found contract as `/corte/[id]`.
*
* S29 — a `direct` link without a destination on air is an invalid state and
* fails closed to the same 404; an `announcement` link without a live target is
* the pre-broadcast state and serves the announcement page. When a destination
* is on air the S19 redirect is served to everyone, untouched.
*/
const loadPublishedShareLink = async (slug: string): Promise<ShareLink | null> => {
const loadPublishedShareLink = async (slug: string): Promise<ResolvedShareLink | null> => {
const link = await getCachedPublishedShareLinkBySlug(slug)()
if (!link || !isValidShareLinkDestination(link.destination)) return null
return link
if (!link) return null
const live = resolveLiveShareLinkDestination(link.destinations)
if (resolveShareLinkMode(link.mode) === 'direct' && !live) return null
return { link, live }
}
const resolveShareLinkMetadata = async (link: ShareLink): Promise<Metadata> => {
@ -104,20 +120,29 @@ export async function generateMetadata({
}
}
const link = await loadPublishedShareLink(type)
if (!link) return {}
const resolved = await loadPublishedShareLink(type)
if (!resolved) return {}
return resolveShareLinkMetadata(link)
return resolveShareLinkMetadata(resolved.link)
}
export default async function Page({ params }: { params: Promise<RouteParams> }) {
const { type } = await params
if (!isPostType(type)) {
const link = await loadPublishedShareLink(type)
if (!link) notFound()
const resolved = await loadPublishedShareLink(type)
if (!resolved) notFound()
return <ShareLinkRedirect destination={link.destination} />
const { link, live } = resolved
if (live) return <ShareLinkRedirect destination={live.href} />
// The rendered page uses the media proxy path (same origin); the absolute
// deployment-origin URL is only for the OG card in `resolveShareLinkMetadata`.
const image = typeof link.image === 'object' ? link.image : null
const showJingles = await hasPublishedJingles()
const view = buildShareLinkAnnouncementView({ link, imageUrl: image?.url ?? null })
return <ShareLinkAnnouncement view={view} showJingles={showJingles} />
}
const posts = (await getVisiblePosts()).filter((post) => post.type === type)

View file

@ -0,0 +1,29 @@
import { isValidShareLinkSlug } from '@/lib/shareLink'
import { loadPublishedShareLinkLiveTarget } from '@/utilities/shareLinkReads'
export const dynamic = 'force-dynamic'
type RouteParams = { slug: string }
/**
* S29 — the fresh read behind the announcement page's activation poll. It is
* deliberately a route handler (not a server action): the poll must never carry
* an RSC refresh of the route, or the open page would be re-rendered into the
* live redirect the moment the team flags a destination. Fail-closed: an
* invalid slug, an unpublished link or any read failure answers `{ target: null }`
* — never a 5xx the client would have to interpret, and never a private field.
*/
export async function GET(_request: Request, { params }: { params: Promise<RouteParams> }) {
const { slug } = await params
const target = isValidShareLinkSlug(slug)
? await loadPublishedShareLinkLiveTarget(slug).catch(() => null)
: null
return Response.json(
{ target },
{
headers: { 'Cache-Control': 'no-store' },
},
)
}

View file

@ -1,7 +1,7 @@
import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignPageHeader } from '@/components/CampaignPageHeader'
import { JingleEmptyState } from '@/components/jingles/JingleEmptyState'
import { JingleIntro } from '@/components/jingles/JingleIntro'
import { JinglePageHeader } from '@/components/jingles/JinglePageHeader'
import { JinglePlayer } from '@/components/jingles/JinglePlayer'
import type { Media } from '@/payload-types'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
@ -90,7 +90,7 @@ export default async function JinglesPage() {
return (
<>
<JinglePageHeader withBadge={hasItems} />
<CampaignPageHeader badge={hasItems ? 'Jingles oficiais' : undefined} />
<main className="w-full bg-white">
{hasItems ? (
<>

View file

@ -22,6 +22,7 @@ import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997e
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ExportListMenuItem as ExportListMenuItem_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { ShareLinkDestinationRowLabel as ShareLinkDestinationRowLabel_d200d02557aa3256362b5d666ce358d4 } from '../../../components/admin/ShareLinkDestinationRowLabel'
import { FormatField as FormatField_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { LimitField as LimitField_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { Page as Page_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
@ -64,6 +65,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/plugin-import-export/rsc#ExportListMenuItem": ExportListMenuItem_cdf7e044479f899a31f804427d568b36,
"./components/admin/ShareLinkDestinationRowLabel#ShareLinkDestinationRowLabel": ShareLinkDestinationRowLabel_d200d02557aa3256362b5d666ce358d4,
"@payloadcms/plugin-import-export/rsc#FormatField": FormatField_cdf7e044479f899a31f804427d568b36,
"@payloadcms/plugin-import-export/rsc#LimitField": LimitField_cdf7e044479f899a31f804427d568b36,
"@payloadcms/plugin-import-export/rsc#Page": Page_cdf7e044479f899a31f804427d568b36,

View file

@ -1,6 +1,10 @@
import {
SHARE_LINK_DESCRIPTION_MAX_LENGTH,
SHARE_LINK_DESTINATION_INVALID_MESSAGE,
SHARE_LINK_DIRECT_WITHOUT_LIVE_MESSAGE,
SHARE_LINK_EVENT_END_BEFORE_START_MESSAGE,
SHARE_LINK_LIVE_DUPLICATE_MESSAGE,
SHARE_LINK_MODE_OPTIONS,
SHARE_LINK_SLUG_DUPLICATE_MESSAGE,
SHARE_LINK_SLUG_INVALID_MESSAGE,
SHARE_LINK_SLUG_RESERVED_MESSAGE,
@ -8,17 +12,26 @@ import {
isReservedShareLinkSlug,
isValidShareLinkDestination,
isValidShareLinkSlug,
resolveLiveShareLinkDestination,
resolveShareLinkMode,
type ShareLinkDestinationLike,
} from '@/lib/shareLink'
import { slugify } from '@/lib/slug'
import { canManagePublishedContent, publishedOrPanelAccess } from '@/utilities/campaignAccess'
import { revalidateShareLinksListing } from '@/utilities/documents'
import type { CollectionConfig, TextFieldSingleValidation } from 'payload'
import type { TextFieldSingleValidation } from 'payload'
import { APIError, type CollectionBeforeValidateHook, type CollectionConfig } from 'payload'
/**
* S19 — share links: `jorgesolla1313.com.br/<slug>` serves our own HTML with
* the configured Open Graph card while the visitor goes straight to the
* external destination. `published` is the kill switch (unchecked → 404);
* anonymous reads fail closed through the `where` constraint.
*
* S29 — per-link mode and the pre-registered destination pool: `direct` keeps
* the S19 behavior (a live destination is required); `announcement` serves the
* announcement page until the team flags one destination as "no ar", when the
* link goes back to redirecting. Existing links (mode null) behave as `direct`.
*/
const validateSlug: TextFieldSingleValidation = async (value, { req, id }) => {
if (typeof value !== 'string' || !value) return SHARE_LINK_SLUG_INVALID_MESSAGE
@ -49,6 +62,53 @@ const validateDestination: TextFieldSingleValidation = (value) => {
return true
}
/**
* Cross-field rules of the link (same merge pattern as `Activity`): at most
* one destination on air, a `direct` link always has one on air, and the
* configured event window never ends before it starts.
*/
const validateShareLinkConfiguration: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
}) => {
if (!data) return data
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
const destinations = Array.isArray(nextData.destinations)
? (nextData.destinations as ShareLinkDestinationLike[])
: []
if (destinations.filter((destination) => destination?.live).length > 1) {
throw new APIError(SHARE_LINK_LIVE_DUPLICATE_MESSAGE, 400)
}
if (
resolveShareLinkMode(nextData.mode) === 'direct' &&
!resolveLiveShareLinkDestination(destinations)
) {
throw new APIError(SHARE_LINK_DIRECT_WITHOUT_LIVE_MESSAGE, 400)
}
const { startsAt, endsAt } = nextData
if (startsAt && endsAt) {
const start = new Date(startsAt)
const end = new Date(endsAt)
if (
!Number.isNaN(start.getTime()) &&
!Number.isNaN(end.getTime()) &&
start.getTime() >= end.getTime()
) {
throw new APIError(SHARE_LINK_EVENT_END_BEFORE_START_MESSAGE, 400)
}
}
return data
}
const announcementOnly = (_: unknown, siblingData: { mode?: string | null } | undefined) =>
resolveShareLinkMode(siblingData?.mode) === 'announcement'
export const ShareLink: CollectionConfig = {
slug: 'shareLink',
labels: {
@ -58,9 +118,9 @@ export const ShareLink: CollectionConfig = {
admin: {
group: 'Publicações',
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'destination', 'published'],
defaultColumns: ['title', 'slug', 'mode', 'published'],
description:
'Links curtos com miniatura personalizada para compartilhar no WhatsApp. O endereço é jorgesolla1313.com.br/<slug> e o clique leva direto ao destino.',
'Links curtos com miniatura personalizada para compartilhar no WhatsApp. O endereço é jorgesolla1313.com.br/<slug>: no modo "Levar direto ao destino" o clique leva direto ao destino no ar; no modo "Página de anúncio" o visitante vê a página do evento até alguém marcar um destino no ar.',
},
access: {
read: publishedOrPanelAccess,
@ -69,6 +129,7 @@ export const ShareLink: CollectionConfig = {
delete: canManagePublishedContent,
},
hooks: {
beforeValidate: [validateShareLinkConfiguration],
afterChange: [revalidateShareLinksListing],
afterDelete: [revalidateShareLinksListing],
},
@ -81,44 +142,105 @@ export const ShareLink: CollectionConfig = {
maxLength: SHARE_LINK_TITLE_MAX_LENGTH,
admin: {
description:
'Título do cartão no WhatsApp — o WhatsApp mostra no máximo 2 linhas (~60–90 caracteres).',
'Título do cartão no WhatsApp e da página de anúncio — o WhatsApp mostra no máximo 2 linhas (~60–90 caracteres).',
},
},
{
name: 'slug',
type: 'text',
label: 'Slug',
required: true,
unique: true,
index: true,
admin: {
description:
'Endereço curto: jorgesolla1313.com.br/<slug>. Gerado do título quando vazio; não pode repetir nem usar palavra reservada do site. O WhatsApp guarda a miniatura em cache por URL: para trocar a miniatura de um link já compartilhado, use um slug novo.',
},
hooks: {
beforeValidate: [
({ value, siblingData }) => {
if (value) return slugify(String(value))
if (siblingData?.title) return slugify(String(siblingData.title))
return value
type: 'row',
fields: [
{
name: 'slug',
type: 'text',
label: 'Slug',
required: true,
unique: true,
index: true,
admin: {
width: '50%',
description:
'Endereço curto: jorgesolla1313.com.br/<slug>. Gerado do título quando vazio; não pode repetir nem usar palavra reservada do site. O WhatsApp guarda a miniatura em cache por URL: para trocar a miniatura de um link já compartilhado, use um slug novo.',
},
],
},
validate: validateSlug,
hooks: {
beforeValidate: [
({ value, siblingData }) => {
if (value) return slugify(String(value))
if (siblingData?.title) return slugify(String(siblingData.title))
return value
},
],
},
validate: validateSlug,
},
{
name: 'mode',
type: 'select',
label: 'Modo do link',
required: true,
defaultValue: 'direct',
options: SHARE_LINK_MODE_OPTIONS,
admin: {
width: '50%',
description:
'"Levar direto ao destino" exige um destino no ar. "Página de anúncio" mostra a página do evento enquanto nenhum destino estiver no ar.',
},
},
],
},
{
name: 'destination',
type: 'text',
label: 'Destino',
required: true,
name: 'destinations',
type: 'array',
label: 'Destinos',
labels: { singular: 'Destino', plural: 'Destinos' },
admin: {
description:
'URL completa do destino (ex.: o link do Google Meet da plenária), começando com http:// ou https://.',
'Pré-cadastre os lugares para trocar sem digitar URL no dia. Marque "Destino no ar" em um único destino; nenhum marcado = pré-transmissão (só no modo "Página de anúncio").',
components: {
RowLabel: './components/admin/ShareLinkDestinationRowLabel#ShareLinkDestinationRowLabel',
},
},
hooks: {
beforeValidate: [({ value }) => (typeof value === 'string' ? value.trim() : value)],
},
validate: validateDestination,
fields: [
{
type: 'row',
fields: [
{
name: 'label',
type: 'text',
label: 'Rótulo',
required: true,
admin: {
width: '30%',
description: 'Como o destino aparece para a equipe e na página (ex.: Google Meet).',
},
},
{
name: 'url',
type: 'text',
label: 'URL do destino',
required: true,
admin: {
width: '45%',
description:
'URL completa do destino (ex.: o link do Google Meet da plenária), começando com http:// ou https://.',
},
hooks: {
beforeValidate: [({ value }) => (typeof value === 'string' ? value.trim() : value)],
},
validate: validateDestination,
},
{
name: 'live',
type: 'checkbox',
label: 'Destino no ar',
defaultValue: false,
admin: {
width: '25%',
description:
'Marque um único destino quando a transmissão começar. Desmarque para voltar à pré-transmissão.',
},
},
],
},
],
},
{
name: 'description',
@ -128,7 +250,7 @@ export const ShareLink: CollectionConfig = {
maxLength: SHARE_LINK_DESCRIPTION_MAX_LENGTH,
admin: {
description:
'Texto do cartão no WhatsApp: ~80 caracteres já bastam; evitar passar de ~160.',
'Texto do cartão no WhatsApp e da página de anúncio: ~80 caracteres já bastam; evitar passar de ~160.',
},
},
{
@ -141,6 +263,51 @@ export const ShareLink: CollectionConfig = {
'Imagem do cartão: 1200×630 px (proporção 1,91:1), arquivo até 600 KB, JPG ou PNG, largura mínima 300 px; evitar imagens muito largas (proporção máx. 4:1). A página do link monta a URL pública absoluta na hora de exibir o cartão. Vazio = imagem padrão do site.',
},
},
{
type: 'collapsible',
label: 'Dados do evento',
admin: {
initCollapsed: false,
description: 'Usados na página de anúncio e nas opções de agenda.',
condition: announcementOnly,
},
fields: [
{
type: 'row',
fields: [
{
name: 'startsAt',
type: 'date',
label: 'Início',
admin: {
width: '50%',
description: 'Horário da Bahia.',
date: { pickerAppearance: 'dayAndTime' },
},
},
{
name: 'endsAt',
type: 'date',
label: 'Fim',
admin: {
width: '50%',
description: 'Opcional. Sem fim, a agenda usa duração padrão de 2 horas.',
date: { pickerAppearance: 'dayAndTime' },
},
},
],
},
{
name: 'location',
type: 'text',
label: 'Local',
admin: {
description:
'Onde acontece (ex.: Online). Usado na página de anúncio e nas opções de agenda.',
},
},
],
},
{
name: 'published',
type: 'checkbox',

View file

@ -1,13 +1,15 @@
import Image from 'next/image'
import Link from 'next/link'
import type { ReactNode } from 'react'
/**
* S21 — the campaign-site bar of the jingles page (artefato: cena 01/03): the
* full positive logo linking home and the "Jingles oficiais" badge. The badge
* only shows when there is something published (empty state drops it), so this
* is page-local — not a generalized header yet.
* S21/S29 — the campaign-site bar of the public pages that live outside the
* home chrome (`/jingles`, the share-link announcement): the full positive logo
* linking home plus an optional status badge. Extracted from the jingles header
* so both pages share one chrome; the badge is desktop-only by design (mobile
* keeps the logo alone).
*/
export const JinglePageHeader = ({ withBadge }: { withBadge: boolean }) => (
export const CampaignPageHeader = ({ badge }: { badge?: ReactNode }) => (
<header className="border-b border-black/10 bg-(--campaign-cream)">
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-3 sm:h-[92px] sm:px-8 sm:py-0">
<Link
@ -24,9 +26,9 @@ export const JinglePageHeader = ({ withBadge }: { withBadge: boolean }) => (
className="h-[66px] w-auto object-contain sm:h-[74px]"
/>
</Link>
{withBadge ? (
{badge ? (
<span className="hidden rounded-full bg-(--pt-red) px-4 py-2 font-[family-name:var(--font-exo2)] text-xs font-black tracking-[0.08em] text-white uppercase sm:inline-block">
Jingles oficiais
{badge}
</span>
) : null}
</div>

View file

@ -0,0 +1,42 @@
'use client'
import { useRowLabel } from '@payloadcms/ui'
type ShareLinkDestinationRow = { label?: string | null; live?: boolean | null }
/**
* S29 — array row header of the destination pool (design artefato cena 05):
* shows the row number, the registered label (`01 · Google Meet`) and a "no ar"
* marker on the destination that is on air, so the team finds it without
* opening every row. Read-only: it only reads the row data from the form
* context, never writes it. The row container itself is native Payload chrome
* (no per-row styling hook), so the marker lives in the header.
*/
export const ShareLinkDestinationRowLabel = () => {
const { data, rowNumber } = useRowLabel<ShareLinkDestinationRow>()
const name = data?.label?.trim() || 'Destino'
const index = String((rowNumber ?? 0) + 1).padStart(2, '0')
return (
<span style={{ alignItems: 'center', display: 'inline-flex', gap: 8 }}>
{`${index} · ${name}`}
{data?.live ? (
<span
style={{
background: '#333',
borderRadius: 999,
color: '#fff',
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.04em',
lineHeight: 1.6,
padding: '1px 8px',
textTransform: 'uppercase',
}}
>
no ar
</span>
) : null}
</span>
)
}

View file

@ -0,0 +1,83 @@
'use client'
import { CalendarPlusIcon, ChevronDownIcon } from 'lucide-react'
import { useState } from 'react'
import { MENU_POPOVER, MENU_ROW, SECONDARY_ACTION } from '@/components/shareLink/menuControls'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { buildGoogleCalendarEventUrl } from '@/lib/calendarEvent'
import { shareLinkIcsPath } from '@/lib/shareLink'
type ShareLinkAgendaMenuProps = {
slug: string
title: string
description: string
location: string | null
startsAt: string | null
endsAt: string | null
}
/**
* S29 — "Adicionar à agenda" (design cena 04): Google Agenda (a prefilled
* `action=TEMPLATE` URL) and the server-generated `.ics` download. Without a
* start date the whole control disappears — never an empty/disabled option.
*/
export const ShareLinkAgendaMenu = ({
slug,
title,
description,
location,
startsAt,
endsAt,
}: ShareLinkAgendaMenuProps) => {
const [open, setOpen] = useState(false)
const googleUrl = startsAt
? buildGoogleCalendarEventUrl({ title, description, location, startsAt, endsAt })
: null
if (!googleUrl) return null
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" className={SECONDARY_ACTION}>
<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">
<a
href={googleUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className={MENU_ROW}
>
<span
aria-hidden="true"
className="grid size-7 flex-none place-items-center rounded bg-[#4285f4] text-xs font-bold text-white"
>
G
</span>
<span>
Google Agenda
<small className="block font-normal text-black/45">Abrir evento preenchido</small>
</span>
</a>
<a
href={shareLinkIcsPath(slug)}
download
onClick={() => setOpen(false)}
className={MENU_ROW}
>
<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>
</span>
</a>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,237 @@
'use client'
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 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',
'shadow-[0_5px_0_#cfb900,0_10px_22px_rgb(0_0_0/12%)]',
)
const PRIMARY_ACTION_DISABLED = cn(
PRIMARY_ACTION,
'cursor-not-allowed bg-[#e7e2d5] text-[rgb(0_0_0/48%)] shadow-[0_3px_0_#cfc9bb]',
)
/**
* S29 — the public announcement page of a share link (design artefato cenas
* 01–03): honest pre-broadcast state with a disabled "Entrar", date/location in
* Bahia time, agenda and share options, and the live swap — the poll reads the
* fresh "no ar" target every 30s (paused on hidden tabs) and flips the CTA
* without a reload, so the place of the button never changes for whoever left
* the page open. The link itself keeps redirecting after a reload; this page
* only exists while nothing is on air.
*/
export const ShareLinkAnnouncement = ({
view,
showJingles = false,
}: {
view: ShareLinkAnnouncementView
showJingles?: boolean
}) => {
const [live, setLive] = useState<ShareLinkLiveTarget | null>(null)
const isLive = live !== null
const hasImage = Boolean(view.imageUrl)
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'} />
<main className="relative flex-1 overflow-hidden bg-[radial-gradient(circle_at_88%_15%,rgb(255_230_7/30%)_0_120px,transparent_121px),radial-gradient(circle_at_4%_84%,rgb(24_78_146/8%)_0_170px,transparent_171px)] px-4 py-5 sm:px-8 sm:py-12">
<span
aria-hidden="true"
className="pointer-events-none absolute right-[-40px] bottom-[-76px] h-[260px] w-[350px] bg-[url('/campaign-kit/pattern-shapes.png')] bg-[center_15%] bg-[length:350px_auto] bg-no-repeat opacity-[0.055]"
/>
<article
className={cn(
'relative z-1 mx-auto grid max-w-6xl overflow-hidden rounded-[18px] border border-(--campaign-line) bg-white',
'shadow-[0_18px_50px_rgb(71_19_14/10%)]',
hasImage && 'lg:grid-cols-[0.95fr_1.05fr]',
)}
>
{hasImage ? (
<div className="relative aspect-[1.91/1] w-full overflow-hidden bg-[#184e92] lg:aspect-auto lg:min-h-[535px]">
<Image
src={view.imageUrl as string}
alt={view.imageAlt}
fill
priority
sizes="(min-width: 1024px) 50vw, 100vw"
className="object-cover"
/>
</div>
) : null}
<div className="flex flex-col p-5 sm:p-10">
<p className="m-0 flex items-center gap-2 text-[10px] font-bold tracking-[0.1em] text-(--pt-red) uppercase sm:text-xs sm:tracking-[0.09em]">
{isLive ? (
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-(--pt-red) opacity-60 motion-reduce:animate-none" />
<span className="relative inline-flex size-2 rounded-full bg-(--pt-red)" />
</span>
) : (
<span className="size-1.5 rounded-full bg-(--pt-red) sm:size-2" />
)}
{isLive ? 'Ao vivo agora' : 'Transmissão'}
</p>
<h1 className="mt-2 text-left font-[family-name:var(--font-exo2)] text-[31px] leading-none font-black tracking-[-0.035em] sm:mt-4 sm:text-[44px] sm:leading-[0.98] sm:tracking-[-0.04em]">
{view.title}
</h1>
{isLive ? (
<p className="mt-3 text-sm leading-6 text-black/60">A transmissão começou.</p>
) : (
<p className="mt-3 max-w-xl text-sm leading-5 text-black/65 sm:mt-5 sm:text-base sm:leading-7">
{view.description}
</p>
)}
{!isLive && hasEventDetails ? (
<dl className="mt-4 grid gap-3 border-y border-black/10 py-4 text-sm sm:mt-7 sm:gap-4 sm:py-5">
{view.eventLabel ? (
<div className="flex items-start gap-3">
<CalendarIcon
className="mt-0.5 size-5 flex-none text-(--pt-red)"
strokeWidth={2}
aria-hidden="true"
/>
<div>
<dt className="font-bold text-black">{view.eventLabel}</dt>
<dd className="mt-0.5 text-[11px] text-black/50 sm:text-xs">
Horário da Bahia
</dd>
</div>
</div>
) : null}
{view.location ? (
<div className="flex items-center gap-3">
<MapPinIcon
className="size-5 flex-none text-(--pt-red)"
strokeWidth={2}
aria-hidden="true"
/>
<dt className="font-bold text-black">{view.location}</dt>
</div>
) : null}
</dl>
) : null}
<div className="mt-auto pt-5 sm:pt-7" aria-live="polite">
{isLive && live ? (
<div className="mb-3 flex flex-wrap items-center justify-center gap-2 text-xs font-bold text-black/55">
<span>Destino no ar</span>
<span className="rounded-full bg-(--campaign-band) px-2.5 py-1 text-black">
{live.label || 'Destino'}
</span>
</div>
) : null}
{isLive && live ? (
<a
href={live.href}
target="_blank"
rel="noopener noreferrer"
className={cn(PRIMARY_ACTION, SAFE_FOCUS)}
>
Entrar
<ExternalLinkIcon className="size-5" strokeWidth={2} aria-hidden="true" />
</a>
) : (
<button
type="button"
disabled
aria-disabled="true"
className={PRIMARY_ACTION_DISABLED}
>
<VideoIcon className="size-5" strokeWidth={2} aria-hidden="true" />
Entrar
</button>
)}
{!isLive ? (
<p className="mt-3 text-center text-xs font-medium text-black/55 sm:text-sm">
A transmissão ainda não começou.
</p>
) : null}
<div className="mt-4 grid gap-2 sm:mt-5 sm:grid-cols-2 sm:gap-3">
{view.startsAt ? (
<ShareLinkAgendaMenu
slug={view.slug}
title={view.title}
description={view.description}
location={view.location}
startsAt={view.startsAt}
endsAt={view.endsAt}
/>
) : null}
<ShareLinkShareMenu
slug={view.slug}
title={view.title}
className={view.startsAt ? undefined : 'sm:col-start-2'}
/>
</div>
</div>
</div>
</article>
</main>
<CampaignFooter showJingles={showJingles} />
</div>
)
}

View file

@ -0,0 +1,78 @@
'use client'
import { CheckIcon, ChevronDownIcon, CopyIcon, Share2Icon } from 'lucide-react'
import { useState } from 'react'
import { MENU_POPOVER, MENU_ROW, SECONDARY_ACTION } from '@/components/shareLink/menuControls'
import { WhatsAppIcon } from '@/components/socialIcons'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { buildContentShareLink, buildContentShareWhatsAppUrl } from '@/lib/contentShare'
import { copyFeedbackLabels, copyFeedbackLiveMessages, useCopyFeedback } from '@/lib/copyFeedback'
import { shareLinkPath } from '@/lib/shareLink'
import { cn } from '@/lib/utils'
type ShareLinkShareMenuProps = {
slug: string
title: string
className?: string
}
/**
* S29 — "Compartilhar" (design cena 04): shares the short link itself (never
* the destination URL) with the activity title — WhatsApp with a pre-filled
* message, or copy to the clipboard with the standard feedback contract. The
* short link keeps working even after the destination is swapped.
*/
export const ShareLinkShareMenu = ({ slug, title, className }: ShareLinkShareMenuProps) => {
const [open, setOpen] = useState(false)
const [origin] = useState(() => (typeof window === 'undefined' ? '' : window.location.origin))
const { feedback, copy } = useCopyFeedback()
const link = buildContentShareLink(shareLinkPath(slug), origin || shareLinkPath(slug))
const whatsAppUrl = buildContentShareWhatsAppUrl('event', title, link)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" className={cn(SECONDARY_ACTION, className)}>
<Share2Icon className="size-4" aria-hidden="true" />
Compartilhar
{open ? <ChevronDownIcon className="size-4" aria-hidden="true" /> : null}
</button>
</PopoverTrigger>
<PopoverContent
align="center"
className={MENU_POPOVER}
aria-label="Opções de compartilhamento"
>
<a
href={whatsAppUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className={MENU_ROW}
>
<WhatsAppIcon className="size-5 flex-none text-[#25D366]" />
Compartilhar no WhatsApp
</a>
<button
type="button"
onClick={() => {
void copy(link)
}}
className={MENU_ROW}
>
{feedback === 'copied' ? (
<CheckIcon className="size-5 flex-none text-green-600" aria-hidden="true" />
) : (
<CopyIcon className="size-5 flex-none" aria-hidden="true" />
)}
{copyFeedbackLabels[feedback]}
</button>
<span aria-live="polite" className="sr-only">
{copyFeedbackLiveMessages[feedback]}
</span>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,23 @@
import { cn } from '@/lib/utils'
/**
* S29 — the shared control vocabulary of the announcement page (design artefato
* cenas 01–04). Kept in one module so the CTA, the two menus and the popover
* rows never drift apart.
*/
export const SAFE_FOCUS =
'focus-visible:shadow-[0_0_0_2px_var(--pt-red-dark)] focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow)'
export const SECONDARY_ACTION = cn(
'inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] border-[1.5px] border-[rgb(162_28_28/28%)] bg-white px-3',
'font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red)',
'transition-[background-color,border-color] duration-150 ease-out hover:border-(--pt-red) hover:bg-[rgb(162_28_28/7%)] motion-reduce:transition-none',
SAFE_FOCUS,
)
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'

94
src/lib/calendarEvent.ts Normal file
View file

@ -0,0 +1,94 @@
import { escapeICalText, formatICalDate } from '@/lib/ical'
/**
* S29 — pure builders for the calendar options of an announcement link
* (Google Calendar template URL + downloadable `.ics`). No I/O, no Payload:
* the page and the route handler only feed the configured event data.
*/
export const DEFAULT_EVENT_DURATION_MS = 2 * 60 * 60 * 1000
export type CalendarEventInput = {
title: string
description?: string | null
location?: string | null
startsAt?: string | null
endsAt?: string | null
}
const formatICSInstant = (date: Date): string => formatICalDate(date.toISOString())
/**
* The event window: no `startsAt` (or an invalid one) means "no event" — the
* calendar options hide; an absent/invalid `endsAt` or one that does not move
* forward falls back to the standard 2-hour duration. Never a broken/empty
* file: callers get `null` and drop the option entirely.
*/
export const resolveCalendarEventWindow = (
startsAt?: string | null,
endsAt?: string | null,
): { start: Date; end: Date } | null => {
if (!startsAt) return null
const start = new Date(startsAt)
if (Number.isNaN(start.getTime())) return null
const end = endsAt ? new Date(endsAt) : null
if (!end || Number.isNaN(end.getTime()) || end.getTime() <= start.getTime()) {
return { start, end: new Date(start.getTime() + DEFAULT_EVENT_DURATION_MS) }
}
return { start, end }
}
/** `https://calendar.google.com/calendar/render?action=TEMPLATE…` or null without a valid start. */
export const buildGoogleCalendarEventUrl = (event: CalendarEventInput): string | null => {
const window = resolveCalendarEventWindow(event.startsAt, event.endsAt)
if (!window) return null
const params = new URLSearchParams({
action: 'TEMPLATE',
text: event.title,
dates: `${formatICSInstant(window.start)}/${formatICSInstant(window.end)}`,
})
if (event.description) params.set('details', event.description)
if (event.location) params.set('location', event.location)
return `https://calendar.google.com/calendar/render?${params.toString()}`
}
/**
* A single-event VCALENDAR (CRLF, RFC 5545) for the `.ics` download; null
* without a valid start so the route can answer 404 instead of an empty file.
*/
export const buildCalendarEventIcs = ({
uid,
updatedAt,
...event
}: CalendarEventInput & { uid: string; updatedAt?: string | null }): string | null => {
const window = resolveCalendarEventWindow(event.startsAt, event.endsAt)
if (!window) return null
const updated = updatedAt ? new Date(updatedAt) : null
const dtstamp = !updated || Number.isNaN(updated.getTime()) ? window.start : updated
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Teqo//Link de compartilhamento//PT',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${escapeICalText(uid)}`,
`DTSTAMP:${formatICSInstant(dtstamp)}`,
`DTSTART:${formatICSInstant(window.start)}`,
`DTEND:${formatICSInstant(window.end)}`,
`SUMMARY:${escapeICalText(event.title)}`,
]
if (event.description) lines.push(`DESCRIPTION:${escapeICalText(event.description)}`)
if (event.location) lines.push(`LOCATION:${escapeICalText(event.location)}`)
lines.push('END:VEVENT', 'END:VCALENDAR')
return lines.join('\r\n')
}

View file

@ -115,6 +115,38 @@ const bahiaDateTimeDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
export const formatBahiaDateTimeLabel = (iso: string): string =>
bahiaDateTimeDisplayFormatter.format(new Date(iso)).replace(', ', ' às ')
const bahiaEventDayFormatter = new Intl.DateTimeFormat('pt-BR', {
timeZone: BAHIA_TIME_ZONE,
weekday: 'long',
day: 'numeric',
month: 'long',
})
const bahiaEventTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
timeZone: BAHIA_TIME_ZONE,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
})
/**
* 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).
*/
export const formatBahiaEventDateLabel = (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}`
const capitalizedDay = day.charAt(0).toUpperCase() + day.slice(1)
return `${capitalizedDay} · ${time}`
}
const bahiaDateDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
timeZone: BAHIA_TIME_ZONE,
day: '2-digit',

View file

@ -1,17 +1,21 @@
import { buildWhatsAppTextShareUrl } from '@/lib/phone'
/** Content source of a home content card — drives the share message template. */
export type ContentShareKind = 'article' | 'video' | 'instagram'
/**
* Content source of a home content card — drives the share message template.
* S29 adds `event`: the public announcement page shares its own short link.
*/
export type ContentShareKind = 'article' | 'video' | 'instagram' | 'event'
/**
* Message opener per source (S4 acceptance, decision B): article shares "isso",
* YouTube "esse vídeo", Instagram "esse post". The rest of the message is
* always "{title} — {link}".
* YouTube "esse vídeo", Instagram "esse post"; S29 event invites ("Participe").
* The rest of the message is always "{title} — {link}".
*/
export const CONTENT_SHARE_PREFIXES: Record<ContentShareKind, string> = {
article: 'Olha isso do Solla: ',
video: 'Olha esse vídeo do Solla: ',
instagram: 'Olha esse post do Solla: ',
event: 'Participe: ',
}
/** Pre-configured share message: opener + title + absolute link. */

12
src/lib/ical.ts Normal file
View file

@ -0,0 +1,12 @@
/**
* Shared iCal primitives (RFC 5545). They live in `lib/` because two owners
* need the exact same escaping/formatting: the campaign agenda feed
* (`utilities/calendarFeed.ts`) and the public share-link `.ics`
* (`lib/calendarEvent.ts`). One implementation, pinned by unit tests.
*/
export const escapeICalText = (value: string): string =>
value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;')
export const formatICalDate = (isoString: string): string =>
isoString.replace(/[-:]/g, '').replace(/\.\d{3}/, '')

View file

@ -33,10 +33,72 @@ export const SHARE_LINK_SLUG_DUPLICATE_MESSAGE =
'Já existe um link de compartilhamento com este slug.'
export const SHARE_LINK_DESTINATION_INVALID_MESSAGE =
'Informe uma URL completa começando com http:// ou https://.'
export const SHARE_LINK_LIVE_DUPLICATE_MESSAGE =
'Apenas um destino pode ficar no ar. Desmarque os outros para continuar.'
export const SHARE_LINK_DIRECT_WITHOUT_LIVE_MESSAGE =
'No modo "Levar direto ao destino", marque um destino no ar. Para deixar o link em pré-transmissão, use o modo "Página de anúncio".'
export const SHARE_LINK_EVENT_END_BEFORE_START_MESSAGE =
'O horário de término deve ser posterior ao de início.'
/**
* S29 — per-link mode. `direct` is the S19 behavior (the click goes straight to
* the destination); `announcement` serves the announcement page while no
* destination is on air. A legacy/null value means `direct`: existing links
* never change behavior.
*/
export const SHARE_LINK_MODES = ['direct', 'announcement'] as const
export type ShareLinkMode = (typeof SHARE_LINK_MODES)[number]
export const SHARE_LINK_MODE_OPTIONS: { label: string; value: ShareLinkMode }[] = [
{ label: 'Levar direto ao destino', value: 'direct' },
{ label: 'Página de anúncio', value: 'announcement' },
]
export const resolveShareLinkMode = (value: string | null | undefined): ShareLinkMode =>
value === 'announcement' ? 'announcement' : 'direct'
/** Structural shape of one pre-registered destination (Payload array row). */
export type ShareLinkDestinationLike = {
label?: string | null
url?: string | null
live?: boolean | null
}
export type ShareLinkLiveTarget = { href: string; label: string }
/**
* The destination currently on air: the first row flagged `live` whose URL is
* a valid `http`/`https` one. Fail-closed — a malformed row never leaks a
* destination and the link behaves as pre-broadcast.
*/
export const resolveLiveShareLinkDestination = (
destinations: ShareLinkDestinationLike[] | null | undefined,
): ShareLinkLiveTarget | null => {
if (!destinations?.length) return null
for (const destination of destinations) {
if (!destination?.live) continue
const url = typeof destination.url === 'string' ? destination.url.trim() : ''
if (!isValidShareLinkDestination(url)) continue
return { href: url, label: (destination.label ?? '').trim() }
}
return null
}
/** Public path contract: a single root segment (`jorgesolla1313.com.br/<slug>`). */
export const shareLinkPath = (slug: string): string => `/${slug}`
/**
* S29 — the `.ics` download of an announcement link. It hangs off the slug's
* own namespace (`/<slug>/evento.ics`) as a static sibling of the `[category]`
* listing route, so the 1-segment share-link contract stays untouched.
*/
export const shareLinkIcsPath = (slug: string): string => `/${slug}/evento.ics`
/** Deterministic UID of the announcement event — one event per link. */
export const shareLinkIcsUid = (slug: string): string => `${slug}@teqo.jorgesolla.com.br`
export const isValidShareLinkSlug = (value: string): boolean => SHARE_LINK_SLUG_PATTERN.test(value)
export const isReservedShareLinkSlug = (value: string): boolean =>

View file

@ -0,0 +1,61 @@
import { formatBahiaEventDateLabel } from '@/lib/campaignTime'
/**
* S29 — pure view model of the public announcement page. The server read
* resolves the OG image and hands the client a serializable shape; the labels
* (Bahia date, trimmed location) are computed here, client-safe, with no
* Payload import.
*/
type ShareLinkAnnouncementMedia = {
alt?: string | null
}
export type ShareLinkAnnouncementSource = {
slug: string
title: string
description: string
image?: ShareLinkAnnouncementMedia | number | null
startsAt?: string | null
endsAt?: string | null
location?: string | null
}
export type ShareLinkAnnouncementView = {
slug: string
title: string
description: string
imageUrl: string | null
imageAlt: string
eventLabel: string | null
location: string | null
startsAt: string | null
endsAt: string | null
}
const isMedia = (
value: ShareLinkAnnouncementSource['image'],
): value is ShareLinkAnnouncementMedia => typeof value === 'object' && value !== null
export const buildShareLinkAnnouncementView = ({
link,
imageUrl,
}: {
link: ShareLinkAnnouncementSource
imageUrl: string | null
}): ShareLinkAnnouncementView => {
const mediaAlt = isMedia(link.image) ? link.image.alt?.trim() : null
const location = link.location?.trim()
return {
slug: link.slug,
title: link.title,
description: link.description,
imageUrl,
imageAlt: mediaAlt || link.title,
eventLabel: link.startsAt ? formatBahiaEventDateLabel(link.startsAt) || null : null,
location: location || null,
startsAt: link.startsAt ?? null,
endsAt: link.endsAt ?? null,
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_share_link_mode" AS ENUM('direct', 'announcement');
CREATE TABLE "share_link_destinations" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"label" varchar NOT NULL,
"url" varchar NOT NULL,
"live" boolean DEFAULT false
);
ALTER TABLE "share_link" ADD COLUMN "mode" "enum_share_link_mode" DEFAULT 'direct' NOT NULL;
ALTER TABLE "share_link" ADD COLUMN "starts_at" timestamp(3) with time zone;
ALTER TABLE "share_link" ADD COLUMN "ends_at" timestamp(3) with time zone;
ALTER TABLE "share_link" ADD COLUMN "location" varchar;
ALTER TABLE "share_link_destinations" ADD CONSTRAINT "share_link_destinations_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."share_link"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "share_link_destinations_order_idx" ON "share_link_destinations" USING btree ("_order");
CREATE INDEX "share_link_destinations_parent_id_idx" ON "share_link_destinations" USING btree ("_parent_id");
-- S29 backfill (hand-written): every legacy link becomes a direct link with
-- its old destination pre-registered and on air — lossless before the drop.
INSERT INTO "share_link_destinations" ("_order", "_parent_id", "id", "label", "url", "live")
SELECT 0, "id", gen_random_uuid()::text, 'Destino', "destination", true
FROM "share_link"
WHERE "destination" IS NOT NULL AND "destination" <> '';
ALTER TABLE "share_link" DROP COLUMN "destination";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
// Lossy by policy (precedent: contact_phones): the pool has no single scalar
// equivalent, so the live destination (fallback: first row) comes back as the
// old destination column; announcement-only links lose the pre-broadcast state.
await db.execute(sql`
ALTER TABLE "share_link" ADD COLUMN "destination" varchar;
UPDATE "share_link" AS link
SET "destination" = COALESCE(
(
SELECT destination."url"
FROM "share_link_destinations" AS destination
WHERE destination."_parent_id" = link."id"
ORDER BY destination."live" DESC NULLS LAST, destination."_order" ASC
LIMIT 1
),
''
);
ALTER TABLE "share_link" ALTER COLUMN "destination" SET NOT NULL;
DROP TABLE "share_link_destinations" CASCADE;
ALTER TABLE "share_link" DROP COLUMN "mode";
ALTER TABLE "share_link" DROP COLUMN "starts_at";
ALTER TABLE "share_link" DROP COLUMN "ends_at";
ALTER TABLE "share_link" DROP COLUMN "location";
DROP TYPE "public"."enum_share_link_mode";`)
}

View file

@ -79,6 +79,7 @@ import * as migration_20260919_032729_add_recording_speakers from './20260919_03
import * as migration_20260919_155232_add_share_link from './20260919_155232_add_share_link';
import * as migration_20260920_031320_add_jingle from './20260920_031320_add_jingle';
import * as migration_20260923_032714_add_content_piece from './20260923_032714_add_content_piece';
import * as migration_20260923_054145_add_share_link_announcement from './20260923_054145_add_share_link_announcement';
export const migrations = [
{
@ -486,4 +487,9 @@ export const migrations = [
down: migration_20260923_032714_add_content_piece.down,
name: '20260923_032714_add_content_piece'
},
{
up: migration_20260923_054145_add_share_link_announcement.up,
down: migration_20260923_054145_add_share_link_announcement.down,
name: '20260923_054145_add_share_link_announcement'
},
];

View file

@ -1654,7 +1654,7 @@ export interface Tag {
createdAt: string;
}
/**
* Links curtos com miniatura personalizada para compartilhar no WhatsApp. O endereço é jorgesolla1313.com.br/<slug> e o clique leva direto ao destino.
* Links curtos com miniatura personalizada para compartilhar no WhatsApp. O endereço é jorgesolla1313.com.br/<slug>: no modo "Levar direto ao destino" o clique leva direto ao destino no ar; no modo "Página de anúncio" o visitante vê a página do evento até alguém marcar um destino no ar.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "shareLink".
@ -1662,7 +1662,7 @@ export interface Tag {
export interface ShareLink {
id: number;
/**
* Título do cartão no WhatsApp — o WhatsApp mostra no máximo 2 linhas (~60–90 caracteres).
* Título do cartão no WhatsApp e da página de anúncio — o WhatsApp mostra no máximo 2 linhas (~60–90 caracteres).
*/
title: string;
/**
@ -1670,17 +1670,49 @@ export interface ShareLink {
*/
slug: string;
/**
* URL completa do destino (ex.: o link do Google Meet da plenária), começando com http:// ou https://.
* "Levar direto ao destino" exige um destino no ar. "Página de anúncio" mostra a página do evento enquanto nenhum destino estiver no ar.
*/
destination: string;
mode: 'direct' | 'announcement';
/**
* Texto do cartão no WhatsApp: ~80 caracteres já bastam; evitar passar de ~160.
* Pré-cadastre os lugares para trocar sem digitar URL no dia. Marque "Destino no ar" em um único destino; nenhum marcado = pré-transmissão (só no modo "Página de anúncio").
*/
destinations?:
| {
/**
* Como o destino aparece para a equipe e na página (ex.: Google Meet).
*/
label: string;
/**
* URL completa do destino (ex.: o link do Google Meet da plenária), começando com http:// ou https://.
*/
url: string;
/**
* Marque um único destino quando a transmissão começar. Desmarque para voltar à pré-transmissão.
*/
live?: boolean | null;
id?: string | null;
}[]
| null;
/**
* Texto do cartão no WhatsApp e da página de anúncio: ~80 caracteres já bastam; evitar passar de ~160.
*/
description: string;
/**
* Imagem do cartão: 1200×630 px (proporção 1,91:1), arquivo até 600 KB, JPG ou PNG, largura mínima 300 px; evitar imagens muito largas (proporção máx. 4:1). A página do link monta a URL pública absoluta na hora de exibir o cartão. Vazio = imagem padrão do site.
*/
image?: (number | null) | Media;
/**
* Horário da Bahia.
*/
startsAt?: string | null;
/**
* Opcional. Sem fim, a agenda usa duração padrão de 2 horas.
*/
endsAt?: string | null;
/**
* Onde acontece (ex.: Online). Usado na página de anúncio e nas opções de agenda.
*/
location?: string | null;
/**
* Desmarcado, o link responde 404 (kill switch).
*/
@ -3007,9 +3039,20 @@ export interface TagSelect<T extends boolean = true> {
export interface ShareLinkSelect<T extends boolean = true> {
title?: T;
slug?: T;
destination?: T;
mode?: T;
destinations?:
| T
| {
label?: T;
url?: T;
live?: T;
id?: T;
};
description?: T;
image?: T;
startsAt?: T;
endsAt?: T;
location?: T;
published?: T;
updatedAt?: T;
createdAt?: T;

View file

@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
import { buildActivityDescriptionParts } from '@/lib/activityDescription'
import { escapeICalText, formatICalDate } from '@/lib/ical'
import type { Activity, CalendarFeed } from '@/payload-types'
import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared'
import type { Payload, Where } from 'payload'
@ -19,12 +20,6 @@ const FEED_LOOKAHEAD_DAYS = 365
const FEED_CACHE_CONTROL = 'public, no-cache'
const FEED_PUBLISHED_TTL = 'PT1H'
const escapeICalText = (value: string): string =>
value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;')
const formatICalDate = (isoString: string): string =>
isoString.replace(/[-:]/g, '').replace(/\.\d{3}/, '')
const buildActivityDescription = (activity: Activity, municipalityName?: string): string =>
// iCal escapes newlines as literal `\n` (backslash + n).
buildActivityDescriptionParts(activity, municipalityName).join('\\n')

View file

@ -1,5 +1,6 @@
import 'server-only'
import { resolveLiveShareLinkDestination, type ShareLinkLiveTarget } from '@/lib/shareLink'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCollectionListingTag } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globalReads'
@ -44,6 +45,32 @@ export const getCachedPublishedShareLinkBySlug = (slug: string, depth = 1) =>
},
)
/**
* S29 — the fresh read behind the announcement page's activation poll. It must
* NOT be cached: the whole point is seeing the "no ar" flag flip without a
* reload. The anonymous contract is the same as the cached loader — the
* `published: true` filter is the barrier, so a draft never resolves here.
*/
export const loadPublishedShareLinkLiveTarget = async (
slug: string,
): Promise<ShareLinkLiveTarget | null> => {
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'shareLink',
where: { slug: { equals: slug }, published: { equals: true } },
depth: 0,
limit: 1,
// Deliberate access bypass: this is an unauthenticated public read, and the
// `published: true` where clause above is the fail-closed gate.
overrideAccess: true,
})
const link = result.docs[0]
if (!link) return null
return resolveLiveShareLinkDestination(link.destinations)
}
const mediaOf = (value: Media | number | null | undefined): Media | null =>
typeof value === 'object' && value !== null ? value : null

View file

@ -2,7 +2,14 @@ import { randomUUID } from 'node:crypto'
import type { APIRequestContext } from '@playwright/test'
import { request as playwrightRequest } from '@playwright/test'
import { Client } from 'pg'
import {
formatBahiaCivilDate,
formatBahiaEventDateLabel,
parseBahiaDateTimeInput,
} from '../../src/lib/campaignTime.js'
import { formatICalDate } from '../../src/lib/ical.js'
import { adminHeaders } from '../helpers/adminApi'
import { seedTestUser } from '../helpers/seedUser'
import { expect, test } from './fixtures/e2eTest'
@ -14,12 +21,28 @@ import { expect, test } from './fixtures/e2eTest'
* refresh when JS is off). Unpublished and unknown slugs answer the same 404,
* and the kill switch flips without a deploy (404 → 200 after republishing).
*
* S29 — the announcement mode: while no destination is on air the link serves
* the event page (disabled "Entrar", Bahia date, `.ics`); flagging a destination
* live turns the link back into the S19 redirect, and the already-open page
* activates/swaps the button through the fresh poll without a reload.
*
* Links and media are seeded through the deployed REST API (admin session) so
* the server process runs the real cache hooks; a Local API call from the runner
* would throw on `revalidateTag` (same reason as the speech-cut spec).
*/
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const isProdMode = Boolean(process.env.CI) || process.env.E2E_PROD === '1'
/**
* The announcement fixture starts today at 19:00 in Bahia civil time (B196: an
* e2e fixture date never hardcodes a literal — a fixed instant expires).
*/
const ANNOUNCEMENT_STARTS_AT = (() => {
const startsAt = parseBahiaDateTimeInput(`${formatBahiaCivilDate(new Date())}T19:00`)
if (!startsAt) throw new Error('Falha ao montar a data da fixture de anúncio.')
return startsAt
})()
// 1×1 opaque PNG — enough for the upload (no dimensions are enforced on purpose).
const TEST_PNG = Buffer.from(
@ -30,6 +53,20 @@ const TEST_PNG = Buffer.from(
const createdShareLinkIds: number[] = []
const createdMediaIds: number[] = []
type DestinationInput = { label: string; url: string; live?: boolean }
type ShareLinkInput = {
title: string
slug: string
destinations: DestinationInput[]
description: string
published: boolean
mode?: 'direct' | 'announcement'
startsAt?: string
location?: string
image?: number
}
/** Reads a meta tag by one attribute and returns its `content` (attribute-order tolerant). */
const metaContent = (html: string, attribute: string, value: string): string | null => {
for (const tag of html.match(/<meta\b[^>]*>/g) ?? []) {
@ -65,14 +102,7 @@ const createMedia = async (
const createShareLink = async (
request: APIRequestContext,
headers: Record<string, string>,
data: {
title: string
slug: string
destination: string
description: string
published: boolean
image?: number
},
data: ShareLinkInput,
): Promise<number> => {
const response = await request.post(`${BASE_URL}/api/shareLink`, { headers, data })
expect(response.ok(), await response.text()).toBeTruthy()
@ -94,6 +124,43 @@ const setShareLinkPublished = async (
expect(response.ok(), await response.text()).toBeTruthy()
}
/** Replaces the destination pool — the "swap the live destination" admin action. */
const setShareLinkDestinations = async (
request: APIRequestContext,
headers: Record<string, string>,
id: number,
destinations: DestinationInput[],
): Promise<void> => {
const response = await request.patch(`${BASE_URL}/api/shareLink/${id}`, {
headers,
data: { destinations },
})
expect(response.ok(), await response.text()).toBeTruthy()
}
/**
* Flags one pre-registered destination as on air through the test database.
* This is deliberately NOT the REST PATCH: the collection hook revalidates the
* `shareLinks` tag, and the dev server answers a revalidation with a route
* refresh (HMR router-cache invalidation) that races the very assertion this
* test makes — the open page would be re-rendered into the live redirect. The
* direct write isolates the poll contract, which is what this test owns; the
* REST write path is covered by the redirect assertion at the end. The URL is
* the isolated test database (`assertTestDatabase` in playwright.config).
*/
const flagLiveDestination = async (linkId: number, label: string): Promise<void> => {
const client = new Client({ connectionString: process.env.DATABASE_URL })
await client.connect()
try {
await client.query(
'update share_link_destinations set live = (label = $2) where _parent_id = $1',
[linkId, label],
)
} finally {
await client.end()
}
}
test.afterAll(async ({ request }) => {
const headers = await adminHeaders(request, BASE_URL).catch(() => null)
if (!headers) return
@ -124,7 +191,7 @@ test.describe('Frontend share links (S19)', () => {
await createShareLink(request, headers, {
title,
slug,
destination,
destinations: [{ label: 'Google Meet', url: destination, live: true }],
description,
published: true,
image: mediaId,
@ -173,7 +240,9 @@ test.describe('Frontend share links (S19)', () => {
await createShareLink(request, headers, {
title: 'Rascunho que não deve aparecer',
slug,
destination: 'https://meet.google.com/abc-defg-hij',
destinations: [
{ label: 'Google Meet', url: 'https://meet.google.com/abc-defg-hij', live: true },
],
description: 'Ainda não publicado.',
published: false,
})
@ -204,7 +273,7 @@ test.describe('Frontend share links (S19)', () => {
const id = await createShareLink(request, headers, {
title: 'Plenária com kill switch',
slug,
destination: `${BASE_URL}/artigos`,
destinations: [{ label: 'Google Meet', url: `${BASE_URL}/artigos`, live: true }],
description: 'Publicado, despublicado e republicado.',
published: true,
})
@ -223,3 +292,173 @@ test.describe('Frontend share links (S19)', () => {
}
})
})
test.describe('Frontend share-link announcement (S29)', () => {
test.beforeAll(async () => {
await seedTestUser()
})
const createAnnouncementLink = async (
request: APIRequestContext,
headers: Record<string, string>,
overrides: Partial<ShareLinkInput> = {},
): Promise<{ id: number; slug: string }> => {
const slug = overrides.slug ?? `anuncio-${randomUUID().slice(0, 8)}`
const id = await createShareLink(request, headers, {
title: 'Plenária da saúde',
destinations: [
{ label: 'Google Meet', url: `${BASE_URL}/artigos` },
{ label: 'YouTube', url: `${BASE_URL}/jingles` },
],
description: 'Encontro online da campanha.',
published: true,
mode: 'announcement',
startsAt: ANNOUNCEMENT_STARTS_AT,
location: 'Online',
...overrides,
slug,
})
return { id, slug }
}
test('serves the announcement page (disabled Entrar, Bahia date, .ics) pre-broadcast', async ({
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const { slug } = await createAnnouncementLink(request, headers)
const anonymous = await playwrightRequest.newContext({ baseURL: BASE_URL })
try {
const response = await anonymous.get(`/${slug}`)
expect(response.status()).toBe(200)
const html = await response.text()
// The OG card is the same as S19, always noindex, and no interstice.
expect(metaContent(html, 'property', 'og:title')).toBe('Plenária da saúde')
expect(metaContent(html, 'name', 'robots')).toBe('noindex, nofollow')
expect(metaContent(html, 'http-equiv', 'refresh')).toBeNull()
expect(html).not.toContain('window.location.replace')
// Honest pre-broadcast state: visible but disabled, with the warning.
expect(html).toContain('A transmissão ainda não começou.')
expect(html).toMatch(/<button[^>]*disabled[^>]*>[\s\S]*?Entrar/)
expect(html).toContain(formatBahiaEventDateLabel(ANNOUNCEMENT_STARTS_AT))
expect(html).toContain('Horário da Bahia')
expect(html).toContain('Online')
const ics = await anonymous.get(`/${slug}/evento.ics`)
expect(ics.status()).toBe(200)
expect(ics.headers()['content-type']).toContain('text/calendar')
expect(ics.headers()['content-disposition']).toContain(`filename="${slug}.ics"`)
const body = await ics.text()
expect(body).toContain('BEGIN:VEVENT')
expect(body).toContain(`DTSTART:${formatICalDate(ANNOUNCEMENT_STARTS_AT)}`)
expect(body).toContain(`UID:${slug}@teqo.jorgesolla.com.br`)
} finally {
await anonymous.dispose()
}
})
test('fails closed: no .ics without a start date, no page for a direct link without a live target', async ({
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const { slug: noDateSlug } = await createAnnouncementLink(request, headers, {
slug: `sem-data-${randomUUID().slice(0, 8)}`,
startsAt: undefined,
})
// A `direct` link without a live destination cannot even be created.
const invalid = await request.post(`${BASE_URL}/api/shareLink`, {
headers,
data: {
title: 'Direto inválido',
slug: `direto-invalido-${randomUUID().slice(0, 8)}`,
destinations: [{ label: 'Meet', url: 'https://meet.google.com/abc' }],
description: 'x',
published: true,
mode: 'direct',
},
})
expect(invalid.ok()).toBe(false)
const anonymous = await playwrightRequest.newContext({ baseURL: BASE_URL })
try {
const ics = await anonymous.get(`/${noDateSlug}/evento.ics`)
expect(ics.status()).toBe(404)
const unknownIcs = await anonymous.get(`/nao-existe-${randomUUID().slice(0, 8)}/evento.ics`)
expect(unknownIcs.status()).toBe(404)
// The announcement page itself still answers 200 without a date.
expect((await anonymous.get(`/${noDateSlug}`)).status()).toBe(200)
} finally {
await anonymous.dispose()
}
})
test('activates the Entrar button and swaps the destination without a reload', async ({
page,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const first = `${BASE_URL}/artigos`
const second = `${BASE_URL}/jingles`
const { id, slug } = await createAnnouncementLink(request, headers, {
slug: `troca-${randomUUID().slice(0, 8)}`,
destinations: [
{ label: 'Google Meet', url: first },
{ label: 'YouTube', url: second },
],
})
// Warm the poll route before the browser mounts it: in dev the first
// compile of a route triggers a Fast Refresh that reloads the open page —
// mid-assertion, and after the flag is flipped that reload would serve the
// live redirect. In CI's prod build this GET is a cheap warm-up.
await request.get(`${BASE_URL}/api/share-link/${slug}/live`)
await page.goto(`/${slug}`)
await expect(page.getByRole('button', { name: 'Entrar' })).toBeDisabled()
await expect(page.getByText('A transmissão ainda não começou.')).toBeVisible()
// Dev-only settle: the first browser mount compiles the route chunks and
// Next pushes a Fast Refresh (a same-URL reload) right after — if the flag
// flips before that reload lands, the reload serves the live redirect and
// preempts the activation assertion. The prod build (CI, E2E_PROD) has no
// compile and needs no wait.
if (!isProdMode) await page.waitForTimeout(6_000)
// The team flags the Meet as live; the open page refetches on wake.
await flagLiveDestination(id, 'Google Meet')
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')))
// Dev-mode cold compile of the poll route can blow the 10s default; in
// CI's prod build the response is immediate.
await expect(page.getByText('Destino no ar')).toBeVisible({ timeout: 20_000 })
await expect(page.getByRole('link', { name: 'Entrar' })).toHaveAttribute('href', first)
await expect(page.getByRole('article').getByText('Google Meet')).toBeVisible()
await expect(page.getByText('A transmissão começou.')).toBeVisible()
// The Meet fills up: swapping the live destination updates the open page.
await flagLiveDestination(id, 'YouTube')
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')))
await expect(page.getByRole('link', { name: 'Entrar' })).toHaveAttribute('href', second)
await expect(page.getByRole('article').getByText('YouTube')).toBeVisible()
// Whoever arrives later goes straight to the destination on air — the same
// state written through the admin REST API (the collection hook path).
await setShareLinkDestinations(request, headers, id, [
{ label: 'Google Meet', url: first },
{ label: 'YouTube', url: second, live: true },
])
const anonymous = await playwrightRequest.newContext({ baseURL: BASE_URL })
try {
const response = await anonymous.get(`/${slug}`)
expect(response.status()).toBe(200)
expect(metaContent(await response.text(), 'http-equiv', 'refresh')).toBe(`0;url=${second}`)
} finally {
await anonymous.dispose()
}
})
})

View file

@ -5,6 +5,10 @@
* validation (http/https only), slugify fallback, the fail-closed `published`
* switch for anonymous reads, the slug-keyed public loader and the listing cache
* tag busted by afterChange/afterDelete.
*
* S29 — per-link mode, the destination pool with the "no ar" flag, the
* cross-field rules (one live at most; direct requires one), the fresh live
* target read behind the announcement poll and the event window validation.
*/
import type { Payload } from 'payload'
import { getPayload } from 'payload'
@ -33,13 +37,18 @@ vi.mock('@/utilities/globalReads', () => ({
import {
SHARE_LINK_DESTINATION_INVALID_MESSAGE,
SHARE_LINK_DIRECT_WITHOUT_LIVE_MESSAGE,
SHARE_LINK_EVENT_END_BEFORE_START_MESSAGE,
SHARE_LINK_LIVE_DUPLICATE_MESSAGE,
SHARE_LINK_SLUG_DUPLICATE_MESSAGE,
SHARE_LINK_SLUG_INVALID_MESSAGE,
SHARE_LINK_SLUG_RESERVED_MESSAGE,
resolveLiveShareLinkDestination,
} from '@/lib/shareLink'
import config from '@/payload.config'
import {
getCachedPublishedShareLinkBySlug,
loadPublishedShareLinkLiveTarget,
resolveShareLinkOgImageUrl,
} from '@/utilities/shareLinkReads'
@ -52,17 +61,40 @@ let payload: Payload
const createdIds = new Set<number>()
const createdMediaIds = new Set<number>()
type ShareLinkDestinationInput = { label: string; url: string; live?: boolean | null }
type ShareLinkInput = {
title: string
slug: string
destination: string
destinations?: ShareLinkDestinationInput[]
description: string
mode?: 'direct' | 'announcement'
startsAt?: string
endsAt?: string
location?: string
image?: number
published?: boolean
}
/** The S19-equivalent default: a `direct` link with its destination on air. */
const LIVE_DESTINATION: ShareLinkDestinationInput = {
label: 'Google Meet',
url: 'https://meet.google.com/abc-defg-hij',
live: true,
}
const withDefaults = (data: ShareLinkInput) => {
const { mode, destinations, ...rest } = data
return {
destinations: destinations ?? [LIVE_DESTINATION],
mode: mode ?? 'direct',
...rest,
}
}
const createShareLink = async (data: ShareLinkInput) => {
const doc = await payload.create({ collection: 'shareLink', data })
const doc = await payload.create({ collection: 'shareLink', data: withDefaults(data) })
createdIds.add(doc.id)
return doc
}
@ -82,7 +114,7 @@ const findAnonymous = (slug: string) =>
*/
const createErrorMessages = async (data: ShareLinkInput): Promise<string[]> => {
try {
const doc = await payload.create({ collection: 'shareLink', data })
const doc = await payload.create({ collection: 'shareLink', data: withDefaults(data) })
createdIds.add(doc.id)
} catch (error) {
const entries =
@ -119,11 +151,12 @@ describe('shareLink', () => {
const doc = await createShareLink({
title: 'Plenária da saúde',
slug: 'plenaria-saude',
destination: 'https://meet.google.com/abc-defg-hij',
description: 'Participe da plenária sobre saúde.',
})
expect(doc.published).toBe(false)
expect(doc.mode).toBe('direct')
expect(doc.destinations?.[0]?.live).toBe(true)
expect(revalidateTagMock).toHaveBeenCalledWith('shareLinks')
})
@ -131,29 +164,29 @@ describe('shareLink', () => {
const doc = await createShareLink({
title: 'Plenária da Saúde em Feira',
slug: '',
destination: 'https://meet.google.com/abc-defg-hij',
description: 'Participe.',
})
expect(doc.slug).toBe('plenaria-da-saude-em-feira')
})
it('trims the destination', async () => {
it('trims the destination URL of a pool row', async () => {
const doc = await createShareLink({
title: 'Reunião',
slug: 'reuniao-trim',
destination: ' https://meet.google.com/abc-defg-hij ',
description: 'Participe.',
destinations: [
{ label: 'Meet', url: ' https://meet.google.com/abc-defg-hij ', live: true },
],
})
expect(doc.destination).toBe('https://meet.google.com/abc-defg-hij')
expect(doc.destinations?.[0]?.url).toBe('https://meet.google.com/abc-defg-hij')
})
it('rejects a reserved slug with a clear message', async () => {
const messages = await createErrorMessages({
title: 'Corte',
slug: 'corte',
destination: 'https://meet.google.com/abc',
description: 'x',
})
@ -169,7 +202,6 @@ describe('shareLink', () => {
const doc = await createShareLink({
title: 'Plenária',
slug: input,
destination: 'https://meet.google.com/abc',
description: 'x',
})
@ -180,7 +212,6 @@ describe('shareLink', () => {
const messages = await createErrorMessages({
title: 'Plenária',
slug: '---',
destination: 'https://meet.google.com/abc',
description: 'x',
})
@ -191,14 +222,12 @@ describe('shareLink', () => {
await createShareLink({
title: 'Primeira',
slug: 'slug-unico-s19',
destination: 'https://meet.google.com/abc',
description: 'x',
})
const messages = await createErrorMessages({
title: 'Segunda',
slug: 'slug-unico-s19',
destination: 'https://meet.google.com/def',
description: 'y',
})
@ -208,22 +237,109 @@ describe('shareLink', () => {
it.each(['meet.google.com/abc', 'ftp://example.com/x', 'javascript:alert(1)'])(
'rejects the invalid destination %s',
async (destination) => {
// Announcement mode isolates the field validation from the cross-field
// rule (a live row with an invalid URL would fail the direct check first).
const messages = await createErrorMessages({
title: 'Plenária',
slug: `destino-invalido-${Buffer.from(destination).toString('hex').slice(0, 12)}`,
destination,
description: 'x',
mode: 'announcement',
destinations: [{ label: 'Destino', url: destination }],
})
expect(messages).toContain(SHARE_LINK_DESTINATION_INVALID_MESSAGE)
},
)
it('rejects more than one destination on air', async () => {
await expect(
createShareLink({
title: 'Duas no ar',
slug: 'duas-no-ar-s29',
description: 'x',
destinations: [
{ label: 'Meet', url: 'https://meet.google.com/abc' },
{ label: 'YouTube', url: 'https://youtube.com/live/abc', live: true },
{ label: 'Outro', url: 'https://example.com/outro', live: true },
],
}),
).rejects.toThrow(SHARE_LINK_LIVE_DUPLICATE_MESSAGE)
})
it('rejects a direct link without a destination on air', async () => {
await expect(
createShareLink({
title: 'Direto sem no ar',
slug: 'direto-sem-no-ar-s29',
description: 'x',
mode: 'direct',
destinations: [{ label: 'Meet', url: 'https://meet.google.com/abc' }],
}),
).rejects.toThrow(SHARE_LINK_DIRECT_WITHOUT_LIVE_MESSAGE)
})
it('accepts an announcement link with no destination on air', async () => {
const doc = await createShareLink({
title: 'Pré-transmissão',
slug: 'pre-transmissao-s29',
description: 'x',
mode: 'announcement',
destinations: [{ label: 'Meet', url: 'https://meet.google.com/abc' }],
})
expect(doc.mode).toBe('announcement')
expect(resolveLiveShareLinkDestination(doc.destinations)).toBeNull()
})
it('rejects an event window that ends before it starts', async () => {
await expect(
createShareLink({
title: 'Janela inválida',
slug: 'janela-invalida-s29',
description: 'x',
mode: 'announcement',
destinations: [],
startsAt: '2026-10-03T22:00:00.000Z',
endsAt: '2026-10-03T21:00:00.000Z',
}),
).rejects.toThrow(SHARE_LINK_EVENT_END_BEFORE_START_MESSAGE)
})
it('resolves the live target only for a published link', async () => {
const doc = await createShareLink({
title: 'No ar',
slug: 'no-ar-s29',
description: 'x',
published: true,
})
expect(await loadPublishedShareLinkLiveTarget('no-ar-s29')).toEqual({
href: 'https://meet.google.com/abc-defg-hij',
label: 'Google Meet',
})
await payload.update({ collection: 'shareLink', id: doc.id, data: { published: false } })
expect(await loadPublishedShareLinkLiveTarget('no-ar-s29')).toBeNull()
expect(await loadPublishedShareLinkLiveTarget('nao-existe-s29')).toBeNull()
})
it('resolves no live target while the announcement is pre-broadcast', async () => {
await createShareLink({
title: 'Pré',
slug: 'pre-s29',
description: 'x',
published: true,
mode: 'announcement',
destinations: [{ label: 'YouTube', url: 'https://youtube.com/live/x' }],
})
expect(await loadPublishedShareLinkLiveTarget('pre-s29')).toBeNull()
})
it('hides drafts from anonymous reads and exposes them once published', async () => {
const draft = await createShareLink({
title: 'Rascunho',
slug: 'rascunho-s19',
destination: 'https://meet.google.com/abc',
description: 'x',
})
@ -241,7 +357,6 @@ describe('shareLink', () => {
const doc = await createShareLink({
title: 'Ciclo',
slug: 'ciclo-s19',
destination: 'https://meet.google.com/abc',
description: 'x',
})
@ -273,7 +388,6 @@ describe('shareLink', () => {
const withImage = await createShareLink({
title: 'Com imagem',
slug: 'imagem-configurada-s19',
destination: 'https://meet.google.com/abc',
description: 'x',
image: media.id,
})
@ -291,7 +405,6 @@ describe('shareLink', () => {
const withoutImage = await createShareLink({
title: 'Sem imagem',
slug: 'imagem-fallback-s19',
destination: 'https://meet.google.com/abc',
description: 'x',
})
// No site default configured: no image at all — never a relative/broken one.

View file

@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import {
buildCalendarEventIcs,
buildGoogleCalendarEventUrl,
DEFAULT_EVENT_DURATION_MS,
resolveCalendarEventWindow,
} from '@/lib/calendarEvent'
const STARTS_AT = '2026-10-03T22:00:00.000Z'
const ENDS_AT = '2026-10-04T00:00:00.000Z'
describe('resolveCalendarEventWindow', () => {
it('returns null without a start, so the calendar options hide', () => {
expect(resolveCalendarEventWindow(null, ENDS_AT)).toBeNull()
expect(resolveCalendarEventWindow('', ENDS_AT)).toBeNull()
expect(resolveCalendarEventWindow('lixo', ENDS_AT)).toBeNull()
})
it('falls back to the 2-hour default without an end', () => {
const window = resolveCalendarEventWindow(STARTS_AT, null)
expect(window?.end.getTime()).toBe(new Date(STARTS_AT).getTime() + DEFAULT_EVENT_DURATION_MS)
})
it('falls back when the end is invalid or does not move forward', () => {
const startMs = new Date(STARTS_AT).getTime()
expect(resolveCalendarEventWindow(STARTS_AT, 'lixo')?.end.getTime()).toBe(
startMs + DEFAULT_EVENT_DURATION_MS,
)
expect(resolveCalendarEventWindow(STARTS_AT, STARTS_AT)?.end.getTime()).toBe(
startMs + DEFAULT_EVENT_DURATION_MS,
)
expect(resolveCalendarEventWindow(STARTS_AT, '2026-10-03T21:00:00.000Z')?.end.getTime()).toBe(
startMs + DEFAULT_EVENT_DURATION_MS,
)
})
it('keeps an explicit end that moves forward', () => {
expect(resolveCalendarEventWindow(STARTS_AT, ENDS_AT)?.end.toISOString()).toBe(ENDS_AT)
})
})
describe('buildGoogleCalendarEventUrl', () => {
it('builds an action=TEMPLATE URL with UTC dates, details and location', () => {
const url = buildGoogleCalendarEventUrl({
title: 'Plenária da saúde',
description: 'Encontro online da campanha.',
location: 'Online',
startsAt: STARTS_AT,
endsAt: ENDS_AT,
})
expect(url).not.toBeNull()
const parsed = new URL(url as string)
expect(`${parsed.origin}${parsed.pathname}`).toBe('https://calendar.google.com/calendar/render')
expect(parsed.searchParams.get('action')).toBe('TEMPLATE')
expect(parsed.searchParams.get('text')).toBe('Plenária da saúde')
expect(parsed.searchParams.get('dates')).toBe('20261003T220000Z/20261004T000000Z')
expect(parsed.searchParams.get('details')).toBe('Encontro online da campanha.')
expect(parsed.searchParams.get('location')).toBe('Online')
})
it('uses the 2-hour default when the end is missing', () => {
const url = buildGoogleCalendarEventUrl({ title: 'Plenária', startsAt: STARTS_AT })
const parsed = new URL(url as string)
expect(parsed.searchParams.get('dates')).toBe('20261003T220000Z/20261004T000000Z')
expect(parsed.searchParams.get('details')).toBeNull()
expect(parsed.searchParams.get('location')).toBeNull()
})
it('returns null without a valid start', () => {
expect(buildGoogleCalendarEventUrl({ title: 'Plenária' })).toBeNull()
expect(buildGoogleCalendarEventUrl({ title: 'Plenária', startsAt: 'lixo' })).toBeNull()
})
})
describe('buildCalendarEventIcs', () => {
it('builds a single VEVENT with CRLF and escaped fields', () => {
const ics = buildCalendarEventIcs({
uid: 'plenaria@teqo.jorgesolla.com.br',
title: 'Plenária, saúde',
description: 'Linha 1\nLinha 2',
location: 'Online; sala 1',
startsAt: STARTS_AT,
endsAt: ENDS_AT,
updatedAt: '2026-09-23T12:00:00.000Z',
})
expect(ics).not.toBeNull()
const body = ics as string
expect(body.startsWith('BEGIN:VCALENDAR\r\n')).toBe(true)
expect(body.endsWith('END:VCALENDAR')).toBe(true)
expect(body).toContain('UID:plenaria@teqo.jorgesolla.com.br\r\n')
expect(body).toContain('DTSTAMP:20260923T120000Z\r\n')
expect(body).toContain('DTSTART:20261003T220000Z\r\n')
expect(body).toContain('DTEND:20261004T000000Z\r\n')
expect(body).toContain('SUMMARY:Plenária\\, saúde\r\n')
expect(body).toContain('DESCRIPTION:Linha 1\\nLinha 2\r\n')
expect(body).toContain('LOCATION:Online\\; sala 1\r\n')
})
it('omits optional lines and falls back to the start for DTSTAMP', () => {
const body = buildCalendarEventIcs({
uid: 'x@teqo.jorgesolla.com.br',
title: 'Plenária',
startsAt: STARTS_AT,
updatedAt: 'lixo',
}) as string
expect(body).toContain('DTSTAMP:20261003T220000Z\r\n')
expect(body).not.toContain('DESCRIPTION:')
expect(body).not.toContain('LOCATION:')
})
it('returns null without a valid start — never an empty file', () => {
expect(buildCalendarEventIcs({ uid: 'x@teqo', title: 'Plenária' })).toBeNull()
expect(buildCalendarEventIcs({ uid: 'x@teqo', title: 'Plenária', startsAt: 'lixo' })).toBeNull()
})
})

View file

@ -6,6 +6,7 @@ import {
floorToMinuteStep,
formatBahiaCivilDate,
formatBahiaCivilDateTimeLabel,
formatBahiaEventDateLabel,
hourOptions,
minuteOptionsForStep,
subtractBahiaCivilDays,
@ -74,3 +75,16 @@ describe('Bahia civil datetime picker helpers (C97)', () => {
expect(floorToMinuteStep('lixo')).toBe('lixo')
})
})
describe('Bahia event date label (S29)', () => {
it('labels the announcement headline in Bahia time, minutes only when needed', () => {
expect(formatBahiaEventDateLabel('2026-10-03T22:00:00.000Z')).toBe('Sábado, 3 de outubro · 19h')
expect(formatBahiaEventDateLabel('2026-10-03T22:30:00.000Z')).toBe(
'Sábado, 3 de outubro · 19h30',
)
})
it('returns an empty string for an unparsable instant', () => {
expect(formatBahiaEventDateLabel('lixo')).toBe('')
})
})

View file

@ -27,11 +27,18 @@ describe('buildContentShareMessage', () => {
).toBe('Olha esse post do Solla: Legenda do post — https://www.instagram.com/p/xyz/')
})
it('invites with the event opener on the announcement link', () => {
expect(
buildContentShareMessage('event', 'Plenária da saúde', 'https://site.com/plenaria'),
).toBe('Participe: Plenária da saúde — https://site.com/plenaria')
})
it('keeps one opener per kind, matching the locked product decision', () => {
expect(CONTENT_SHARE_PREFIXES).toEqual({
article: 'Olha isso do Solla: ',
video: 'Olha esse vídeo do Solla: ',
instagram: 'Olha esse post do Solla: ',
event: 'Participe: ',
})
})
})
@ -88,7 +95,7 @@ describe('buildContentShareWhatsAppUrl', () => {
})
it('keeps every kind producing the same wa.me shape', () => {
const kinds: ContentShareKind[] = ['article', 'video', 'instagram']
const kinds: ContentShareKind[] = ['article', 'video', 'instagram', 'event']
for (const kind of kinds) {
expect(buildContentShareWhatsAppUrl(kind, 'T', 'https://site.com/x')).toMatch(
/^https:\/\/wa\.me\/\?text=/,

View file

@ -7,8 +7,13 @@ import {
isValidShareLinkDestination,
isValidShareLinkSlug,
normalizeShareLinkDescription,
resolveLiveShareLinkDestination,
resolveShareLinkMode,
SHARE_LINK_MODES,
SHARE_LINK_RESERVED_SLUGS,
SHARE_LINK_SLUG_PATTERN,
shareLinkIcsPath,
shareLinkIcsUid,
shareLinkPath,
} from '@/lib/shareLink'
@ -84,6 +89,53 @@ describe('shareLink destination', () => {
})
})
describe('shareLink mode (S29)', () => {
it('treats a legacy/null value as direct and lists both modes', () => {
expect(SHARE_LINK_MODES).toEqual(['direct', 'announcement'])
expect(resolveShareLinkMode(null)).toBe('direct')
expect(resolveShareLinkMode(undefined)).toBe('direct')
expect(resolveShareLinkMode('direct')).toBe('direct')
expect(resolveShareLinkMode('announcement')).toBe('announcement')
expect(resolveShareLinkMode('outro')).toBe('direct')
})
})
describe('resolveLiveShareLinkDestination (S29)', () => {
it('returns the first live row with a valid http/https URL', () => {
expect(
resolveLiveShareLinkDestination([
{ label: 'Meet', url: 'https://meet.google.com/abc', live: false },
{ label: 'YouTube', url: 'https://youtube.com/live/x', live: true },
{ label: 'Outro', url: 'https://example.com/outro', live: true },
]),
).toEqual({ href: 'https://youtube.com/live/x', label: 'YouTube' })
})
it('fails closed on empty, unflagged or invalid rows', () => {
expect(resolveLiveShareLinkDestination(null)).toBeNull()
expect(resolveLiveShareLinkDestination([])).toBeNull()
expect(resolveLiveShareLinkDestination([{ label: 'Meet', url: 'https://x.com' }])).toBeNull()
expect(
resolveLiveShareLinkDestination([{ label: 'X', url: 'javascript:alert(1)', live: true }]),
).toBeNull()
expect(resolveLiveShareLinkDestination([{ live: true, url: null }])).toBeNull()
})
it('trims the URL and tolerates a missing label', () => {
expect(resolveLiveShareLinkDestination([{ url: ' https://x.com/a ', live: true }])).toEqual({
href: 'https://x.com/a',
label: '',
})
})
})
describe('shareLink ics contract (S29)', () => {
it('hangs the download off the slug namespace', () => {
expect(shareLinkIcsPath('plenaria-saude')).toBe('/plenaria-saude/evento.ics')
expect(shareLinkIcsUid('plenaria-saude')).toBe('plenaria-saude@teqo.jorgesolla.com.br')
})
})
describe('normalizeShareLinkDescription', () => {
it('collapses newlines and repeated spaces so the meta tag stays single-line', () => {
expect(normalizeShareLinkDescription(' Venha para a\n\nplenária de saúde ')).toBe(

View file

@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { buildShareLinkAnnouncementView } from '@/lib/shareLinkAnnouncement'
describe('buildShareLinkAnnouncementView', () => {
it('formats the Bahia event label and trims the location', () => {
const view = buildShareLinkAnnouncementView({
link: {
slug: 'plenaria-saude',
title: 'Plenária da saúde',
description: 'Encontro online da campanha.',
startsAt: '2026-10-03T22:00:00.000Z',
endsAt: '2026-10-04T00:00:00.000Z',
location: ' Online ',
},
imageUrl: '/api/media/file/plenaria.jpg',
})
expect(view).toEqual({
slug: 'plenaria-saude',
title: 'Plenária da saúde',
description: 'Encontro online da campanha.',
imageUrl: '/api/media/file/plenaria.jpg',
imageAlt: 'Plenária da saúde',
eventLabel: 'Sábado, 3 de outubro · 19h',
location: 'Online',
startsAt: '2026-10-03T22:00:00.000Z',
endsAt: '2026-10-04T00:00:00.000Z',
})
})
it('prefers the media alt and nulls the absent event data', () => {
const view = buildShareLinkAnnouncementView({
link: {
slug: 'sem-data',
title: 'Atividade',
description: 'Descrição',
image: { alt: 'Foto do encontro' },
location: ' ',
},
imageUrl: null,
})
expect(view.imageAlt).toBe('Foto do encontro')
expect(view.imageUrl).toBeNull()
expect(view.eventLabel).toBeNull()
expect(view.location).toBeNull()
expect(view.startsAt).toBeNull()
expect(view.endsAt).toBeNull()
})
it('hides the event label when the date cannot be parsed', () => {
const view = buildShareLinkAnnouncementView({
link: {
slug: 'data-invalida',
title: 'Atividade',
description: 'Descrição',
startsAt: 'lixo',
},
imageUrl: null,
})
expect(view.eventLabel).toBeNull()
})
})