feat(S4): compartilhar conteúdos da home no WhatsApp

This commit is contained in:
Francisco Solla 2026-08-19 12:29:58 -03:00
parent 9504c7ccf6
commit da0d87d49b
11 changed files with 559 additions and 22 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**S4** — botão discreto de compartilhar no WhatsApp por card da seção "Acompanhe de perto" da home (jorgesolla1313.com.br): cada card (Artigo/YouTube/Instagram, o board S1+S2+S3) ganha um botão redondo no canto superior direito (visual 28px sobre área de toque 44px, `aria-label` "Compartilhar", sem competir com o CTA do card) que abre um popover de 2 ações: **"Compartilhar no WhatsApp"** — `wa.me` sem destinatário (WhatsApp do próprio remetente; o `buildWhatsAppTextShareUrl` do S6 foi movido para `src/lib/phone.ts` — dono do wa.me, re-exportado no `homeSearchShare.ts` — e o `contentShare.ts` reusa, sem twin) com mensagem pré-configurada **por fonte** (decisão B do gate: "Olha isso do Solla: {título} — {link}" / "Olha esse vídeo…" / "Olha esse post…"; link = página canônica do artigo resolvida contra `window.location.origin` no cliente, ou URL da plataforma para YT/IG; nova aba `noopener noreferrer") — e **"Copiar link"** (clipboard com "Link copiado" + `aria-live` para leitores de tela, fallback honesto em falha). Estrutura: o card inteiro é um anchor — o botão vive como **irmão** absoluto num wrapper `relative` (botão dentro do anchor seria HTML inválido); o conteúdo do popover usa `ui/Popover` (Radix, portal — escapa o `overflow-x-auto` do carrossel mobile que cliparia um menu absoluto), sem roles `menu`/`menuitem` (a semântica honesta de popover com 2 ações — Radix já emite `aria-expanded`). Zero migration/schema/Consent/escrita no servidor — montagem de URL 100% cliente, nada coletado (anti-goal da intenção). Lib pura `src/lib/contentShare.ts` (prefixos por fonte, resolução de link fail-soft contra permalink malformado, URL wa.me) + 11 units; e2e novo no describe serial da seção (cadeia 19/19 verde local): botão por card do bento, mensagem por fonte com URL absoluta, `target/rel`, copiar→clipboard real (`expect.poll`), Escape/clique-fora, mobile ≥44px + 28px visual + portal dentro do viewport, card ativo pinado por identidade. Verificação: unit 2402 + int 791 verdes, tsc/lint/format/cycles/build verdes; knip = limitação P3 pré-existente (não carrega `payload.config.ts`); CI valida. /simplify: 3 revisores paralelos absorvidos (twin do wa.me resolvido pelo owner move, máquina de feedback de copiar simplificada para effect único, foco/roles honestos, min-h-11 nos itens, `aria-live`, origin lazy sem guard morto, malformed-href fail-soft, e2e endurecido: pin de identidade do card ativo + unmount do popover antes do próximo trigger + helper de decode `+`); 1 débito deferido (dedupe do blob de 15 campos do `updateSocialFeedSettings` nos 5 testes do describe — quando os testes S2/S3 forem tocados de novo).
**S6-FOLLOWUP** — CLI Playwright 1.58.2 e filtros e2e locais: `pnpm test:e2e:affected` rodava a suíte completa silenciosamente em runs selecionados. Causa raiz (medida com o binário do repo, config real): o commander parseia posicionais e `-g`/`--list` funcionam — quem arrastava a suíte era o closure de dependências dos projects no dev mode (`setup ← campaign ← frontend ← admin` em `playwright.config.ts`): `collectProjectsAndTestFiles` filtra os arquivos por project, mas `buildProjectsClosure` repõe todos os arquivos de cada project-dependência — filtrar um spec do `frontend` puxava `campaign` (160) + `setup` (2), 177 no total com o próprio arquivo (15); `admin` puxava a suíte inteira. Em prod mode (`CI=1`) os projects não têm deps e o filtro sempre funcionou (por isso o CI selecionado estava correto). Fix: `run-e2e-affected.mjs` agora gera `test:e2e <passthrough verbatim> --no-deps -- <paths>` via helpers puros novos em `scripts/lib/playwright-e2e-args.mjs` (unit-testados, `tests/unit/playwrightE2eArgs.unit.spec.ts`) — `--no-deps` desliga o closure em runs filtrados (suíte completa mantém a cadeia OPS34 de prewarm); de quebra, o passthrough do script foi corrigido (`pnpm test:e2e:affected -- tests/x.spec.ts` descartava o path — pnpm consome o `--` e o argv nunca o continha; agora `parsePassthroughArgs` só remove um `--` líder e repassa o resto verbatim, preservando `-g grade`). Hardening: `scripts/run-e2e-affected.mjs` entrou em `HIGH_RISK_EXACT` (era o espelho local do job e2e sem gate — um PR só de runner não rodaria e2e). Docs: receitas de filtro no `playwright.config.ts` (posicional exige `--no-deps`; `--project=<família> --no-deps`; `-g`/`--list` funcionam), header do script e nota no AGENTS.md. Sem mudança de specs, CI (`--shard`) ou `gate:ci`. Verificação: unit 2343+ verdes; contagem por `--list` do binário com a forma final (18); CI do PR roda o e2e completo (o diff toca `playwright.config.ts` e `run-e2e-affected.mjs`, high-risk). Débito absorvido: hang do `pnpm test:e2e` via pnpm neste worktree (pré-existente, ambiente — binário direto funciona; não escopo). Impl plan: `docs/plans/s6-followup-playwright-filtros-cli-ignorados-impl.md`.
**S2** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (`@JorgeSollaDep`), estendendo o board da S1. Fonte automática servidor-side: global `SocialFeedSettings` nova (grupo `Configurações`, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch `enabled` ("Pausar feed"), toggle `youtubeEnabled`, chave + channel ID, `youtubeMaxItems` (1–5, default 3) e `excludedItems` (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration `20260818_111748_add_social_feed_settings` não destrutiva (editada para conter só o DDL — o `migrate:create` regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`, pinado no spec de convenções): parsers/format puros (`formatYouTubeViews` pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, `fetchImpl`/`YOUTUBE_API_BASE_URL` injetáveis), `unstable_cache` tag `social-feed` + `revalidate: 300` (TTL da entrada, não da página — o bug do S1 era page-level); **fail-closed com snapshot persistido por SQL direto** (`payload.db.drizzle`, sem hooks — `revalidateTag` dentro de `unstable_cache` LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado `CampaignArticleCard` → `CampaignContentCard` (git mv; `external?: boolean` → `<a target="_blank" rel="noopener noreferrer">`); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha `i.ytimg.com` + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (`tests/e2e/youtube-stub.mjs`, webServer do Playwright vira array, porta = dev+1000; `POST /__stub/state` alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (`E2E_PROD=1` — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. `/simplify` 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, `Promise.all` posts+feed no cache frio, comentário do sentinela 0 do merge, helper único `tests/helpers/youtubeStub.ts` para a porta do stub, `waitForHomeSectionState` fundido no `waitForHomeHTML` com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o `validate` do `itemId` lê `data.platform` que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag `social-feed` fora da convenção `global_*` (precedente election-tse/municipality-catalog), remotePattern `localhost/thumbs` incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).

View file

@ -0,0 +1 @@
**S4** — botão discreto de compartilhar no WhatsApp por card da seção "Acompanhe de perto" da home (jorgesolla1313.com.br): cada card (Artigo/YouTube/Instagram, o board S1+S2+S3) ganha um botão redondo no canto superior direito (visual 28px sobre área de toque 44px, `aria-label` "Compartilhar", sem competir com o CTA do card) que abre um popover de 2 ações: **"Compartilhar no WhatsApp"** — `wa.me` sem destinatário (WhatsApp do próprio remetente; o `buildWhatsAppTextShareUrl` do S6 foi movido para `src/lib/phone.ts` — dono do wa.me, re-exportado no `homeSearchShare.ts` — e o `contentShare.ts` reusa, sem twin) com mensagem pré-configurada **por fonte** (decisão B do gate: "Olha isso do Solla: {título} — {link}" / "Olha esse vídeo…" / "Olha esse post…"; link = página canônica do artigo resolvida contra `window.location.origin` no cliente, ou URL da plataforma para YT/IG; nova aba `noopener noreferrer") — e **"Copiar link"** (clipboard com "Link copiado" + `aria-live` para leitores de tela, fallback honesto em falha). Estrutura: o card inteiro é um anchor — o botão vive como **irmão** absoluto num wrapper `relative` (botão dentro do anchor seria HTML inválido); o conteúdo do popover usa `ui/Popover` (Radix, portal — escapa o `overflow-x-auto` do carrossel mobile que cliparia um menu absoluto), sem roles `menu`/`menuitem` (a semântica honesta de popover com 2 ações — Radix já emite `aria-expanded`). Zero migration/schema/Consent/escrita no servidor — montagem de URL 100% cliente, nada coletado (anti-goal da intenção). Lib pura `src/lib/contentShare.ts` (prefixos por fonte, resolução de link fail-soft contra permalink malformado, URL wa.me) + 11 units; e2e novo no describe serial da seção (cadeia 19/19 verde local): botão por card do bento, mensagem por fonte com URL absoluta, `target/rel`, copiar→clipboard real (`expect.poll`), Escape/clique-fora, mobile ≥44px + 28px visual + portal dentro do viewport, card ativo pinado por identidade. Verificação: unit 2402 + int 791 verdes, tsc/lint/format/cycles/build verdes; knip = limitação P3 pré-existente (não carrega `payload.config.ts`); CI valida. /simplify: 3 revisores paralelos absorvidos (twin do wa.me resolvido pelo owner move, máquina de feedback de copiar simplificada para effect único, foco/roles honestos, min-h-11 nos itens, `aria-live`, origin lazy sem guard morto, malformed-href fail-soft, e2e endurecido: pin de identidade do card ativo + unmount do popover antes do próximo trigger + helper de decode `+`); 1 débito deferido (dedupe do blob de 15 campos do `updateSocialFeedSettings` nos 5 testes do describe — quando os testes S2/S3 forem tocados de novo).

View file

@ -0,0 +1,108 @@
# Impl: Compartilhar conteúdos da home no WhatsApp (S4)
Status: aprovado
Atualizado em: 2026-08-19
Issue: #19
Intenção: docs/plans/compartilhar-conteudos-home-whatsapp.md
Appetite restante: herdado (~0,5–1 dia eng)
## Pós-entrega (2026-08-19)
Entregue e mergeado em `main`. Desvios de execução em relação a este plano:
- **`buildWhatsAppTextShareUrl` já existia** em `src/lib/homeSearchShare.ts` (S6) — em vez do builder próprio do rascunho, o `contentShare.ts` reusa o canônico, e o dono do builder foi movido para `src/lib/phone.ts` (o módulo wa.me) com re-export no `homeSearchShare.ts` (sem twin).
- **Roles `menu`/`menuitem` removidos do popover** (achado P1 do critique): Radix Popover não implementa o contrato de widget de menu (setas/roving focus); semântica honesta é popover de 2 ações com Tab — Radix emite `aria-expanded` no trigger.
- **Encoding do `text` é `+` para espaço** (URLSearchParams do builder canônico) — válido para o WhatsApp; os decodes de teste fazem `replace(/\+/g, ' ')`.
- **Débito deferido:** dedupe do blob de 15 campos do `updateSocialFeedSettings` (5 cópias no describe do e2e) — quando os testes S2/S3 forem tocados de novo.
## Leitura da intenção
- **Outcome:** todo card de conteúdo da home (Artigo/YouTube/Instagram — seção S1+S2+S3 entregue) ganha um botão discreto de compartilhar que abre `wa.me` com mensagem pronta por fonte + "Copiar link", sem competir com o CTA do card, sem coletar nada.
- **O que NÃO negociar:** mensagem por fonte (artigo/vídeo/post, decisão B travada); link = artigo → página canônica, YT/IG → URL da plataforma; nova aba com `noopener`; botão acessível (aria-label, alvo ≥44px no mobile) e sem mudar o layout do card; sem migration/schema/Consent — montagem de URL no cliente, sem escrita no servidor; mensagem honesta.
- **O que reavaliar:** a hipótese "reusar `buildWhatsAppUrl` (`src/utilities/phone.ts`)" está **errada** — (a) o módulo é `src/lib/phone.ts`, e (b) `buildWhatsAppUrl` **exige telefone brasileiro válido e lança erro sem ele**; S4 não tem destinatário — é o `wa.me/?text=` do próprio remetente (precedente exato: `PetitionSuccessDialog.tsx:39`). Também reavaliar: o card inteiro é um `<a>`/`<Link>` — um `<button>` aninhado seria HTML inválido e o clique navegaria; o botão precisa ser **irmão** do anchor.
## Abordagem recomendada
```mermaid
flowchart LR
Section["CampaignContentSection (server)<br/>mappers setam shareKind<br/>article|video|instagram"]
Card["CampaignContentCard (server)<br/>wrapper relative + anchor irmão"]
Button["ContentShareButton (client)<br/>Popover (Radix, portal)"]
Menu["menu role=menu:<br/>Compartilhar no WhatsApp / Copiar link"]
Wa["wa.me/?text=msg encodada<br/>target _blank noopener noreferrer"]
Copy["navigator.clipboard<br/>label 'Link copiado' 2s"]
Lib["contentShare.ts (puro)<br/>mensagem por fonte + URL absoluta + wa.me"]
Section --> Card
Card --> Button
Button --> Menu
Menu --> Wa
Menu --> Copy
Button -. monta .-> Lib
```
**Opções consideradas:** A | B | C (por decisão abaixo)
**Recomendação:** descrita nas decisões — irmão do anchor + `wa.me/?text=` + Popover Radix.
**Rejeitadas:** `buildWhatsAppUrl` (exige telefone; throw); botão aninhado no anchor (HTML inválido, clique navega); `navigator.share` nativo (não é o aceite — WhatsApp + copiar link); menu absoluto caseiro (reimplementa portal/foco/fora/escape que o `ui/Popover` já tem).
### Decisões de engenharia
**D1 — Estrutura do card (o botão não cabe dentro do anchor).**
Opções: A) wrapper `relative` no card + anchor irmão + botão absoluto (`z-10`, visual 28px em área de toque 44px, canto superior direito — `group` migra para o wrapper, preservando o zoom do cover e o `h-full` da cadeia bento/li) | B) botão aninhado com `preventDefault`/stopPropagation | C) âncora só no conteúdo e botão em fluxo normal.
Recomendação: **A** — siblings resolvem propagação e a11y por construção, sem `preventDefault` mágico; zero mudança de layout (absoluto). Rejeitadas: B (interactive dentro de interactive — invalid HTML, `role` confuso para leitor de tela); C (muda a hit-area/estética do card — viola "não muda o layout").
**D2 — Link absoluto (artigo é caminho relativo).**
Opções: A) montar no cliente no clique: `new URL(href, window.location.origin)` — hrefs YT/IG já absolutos passam inalterados; origem é a do visitante real (preview correto) | B) resolver `siteUrl` no server (metadata global) e passar link absoluto no card data.
Recomendação: **A** — casa com o aceite "montagem de URL no cliente"; zero dado novo no contrato do card; precedente `PetitionSuccessDialog` (`window.location.href`). Rejeitada: B (acopla a seção à leitura da global + prop extra sem necessidade).
**D3 — Mensagem por fonte (aceite travado).**
Opções: A) campo explícito `shareKind: 'article' | 'video' | 'instagram'` no `CampaignContentCardData`, setado pelos 3 mappers da seção (ponto único de renderização) | B) derivar da fonte por heurística (`badgeLabel`, forma do href).
Recomendação: **A** — `badgeLabel` é display (mudaria a msg se a label mudar); href é heurística frágil. A lógica pura fica em `src/lib/contentShare.ts` (unit-testável): prefixo por kind (`Olha isso do Solla: ` / `Olha esse vídeo do Solla: ` / `Olha esse post do Solla: `) + `{título} — {link}`.
**D4 — Menu (clipping do carrossel mobile).**
Opções: A) Popover Radix (`src/components/ui/Popover.tsx` — portal, escape, clique-fora, foco de graça) | B) menu absoluto caseiro dentro do card | C) Sheet/Drawer no mobile.
Recomendação: **A** — o track do `CampaignContentCarousel` é `overflow-x-auto`; um absoluto caseiro estouraria o box do card e criaria scrollbar vertical/clipping; o portal do Radix escapa qualquer container. Rejeitadas: B (reimplementa foco/fora/escape e arrisca clipping); C (overkill para 2 ações). Conferir no craft o token de superfície do conteúdo (`bg-popover` existe no tema público? senão `bg-white` + `border-(--campaign-line)` igual ao card).
**D5 — Feedback de copiar.**
Opções: A) swap inline do item para "Link copiado" + `CheckIcon` por 2s (padrão `LeadershipInviteButtons`/`CalendarFeedDialog`) | B) `toast` (sonner).
Recomendação: **A** — o layout público não monta `<Toaster>`; swap inline é o padrão do repo. `navigator.clipboard` com try/catch (fallback de label em falha).
### Componentes / mudanças
- **`ContentShareKind` + `buildContentShareMessage` + `buildContentShareLink` + `buildContentShareWhatsAppUrl`** (`src/lib/contentShare.ts`, novo, puro): frase por fonte, resolução `new URL(href, origin)` e `https://wa.me/?text=${encodeURIComponent(message)}` — a forma do `PetitionSuccessDialog` com `noopener noreferrer` no call site.
- **`CampaignContentCardData.shareKind`** (novo campo, obrigatório) + **`CampaignContentCard`** (server): wrapper `relative` (absoluto do botão), anchor irmão; renderiza `<ContentShareButton kind title href />`. Sem migration.
- **`CampaignContentSection`**: os 3 mappers (`toArticleCardData`/`toVideoCardData`/`toInstagramCardData`) setam `shareKind`. Único ponto — nada duplicado por plataforma.
- **`ContentShareButton`** (`src/components/ContentShareButton.tsx`, novo, client): trigger `aria-label="Compartilhar"` (`Share2Icon` — já usado no repo), visual 28px `rounded-full bg-white shadow` sobre área de toque ≥44px (`h-11 w-11`), `PopoverTrigger` + `PopoverContent` (portal) com `role="menu"`; itens: `<a href={waUrl} target="_blank" rel="noopener noreferrer">` com `WhatsAppIcon` (de `socialIcons`, usado no público) e botão "Copiar link" (`CopyIcon` → "Link copiado" + `CheckIcon`).
- **Migration:** nenhuma. **Access/Consent:** nenhum — zero escrita, zero leitura nova.
- **UI:** Impeccable B (discreto, não compete com o CTA) — shape → craft → critique → polish no padrão da seção (tokens `--campaign-*`, `--pt-red` no foco, `motion-reduce`).
## Fases verificáveis
1. **Lib pura + unit** — `src/lib/contentShare.ts` + `tests/unit/contentShare.unit.spec.ts` (frases por fonte, resolução de link relativo/absoluto, encoding do `wa.me`, round-trip `decodeURIComponent`).
2. **Card + seção** — wrapper relativo + `shareKind` nos mappers + `ContentShareButton` com Popover; verificar visual no dev (bento desktop + carrossel mobile, incl. card inerte do carrossel).
3. **E2E** — dentro do describe `Campaign home content section` (`tests/e2e/frontend.e2e.spec.ts`, serial, fixtures/stubs existentes): botão por card; menu abre; href `wa.me` correto por fonte (título + link absoluto, `target=_blank` `rel=noopener noreferrer`); copiar link grava a URL absoluta no clipboard (grant `clipboard-read/write`) e troca label; Escape/clique-fora fecha; mobile: área de toque ≥44px no card do carrossel e menu visível (portal não é clippado).
4. **Gates** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build`; e2e verde no CI.
## Rabbit holes / Não escopo (engenharia)
- **UTM/medição de clique** — fora (intenção: item futuro explícito); nada é coletado.
- **`navigator.share` nativo / outros canais** — fora; aceite é WhatsApp + copiar link.
- **Terceira ação no menu, ou menu por plataforma** — não; 2 itens fixos, um componente.
- **Generalizar o popover de compartilhar para fora da home** — não; 1 call site.
- **Tocar `CampaignContentCarousel`** — não; o botão vem do card, e o `inert` do carrossel já cobre os cards não ativos.
## Riscos e mitigação
- **HTML inválido / navegação ao compartilhar** → botão irmão do anchor (D1); zero propagação.
- **Clipping do menu no carrossel `overflow-x-auto`** → portal do Radix (D4).
- **Token `bg-popover` ausente no tema público** → conferir em `styles.css`; fallback `bg-white` + borda do card.
- **Quebra da cadeia `h-full` do bento/`li`** → wrapper `h-full` + anchor `h-full` (verificar no craft com o card featured).
- **Clipboard indisponível (http/privado)** → try/catch com label de erro honesto, sem crash.
- **Hidratação** → props de server→client são só `kind`/`title`/`href` (strings) — sem serialização nova.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (mensagens por fonte, links, `noopener`, ≥44px mobile, sem mudança de layout, sem escrita)
- [ ] Invariantes AGENTS: identificadores em inglês / copy pt-BR; sem migration; sem Consent; reuso de `ui/Popover`, `WhatsAppIcon`, padrão `copied` do repo
- [ ] Unit da lib pura + E2E no describe existente da seção (cobertura de domínio: mensagem por fonte e URL absoluta)

View file

@ -1,3 +1,5 @@
import { ContentShareButton } from '@/components/ContentShareButton'
import type { ContentShareKind } from '@/lib/contentShare'
import Image from 'next/image'
import Link from 'next/link'
@ -7,6 +9,8 @@ export type CampaignContentCardData = {
title: string
badgeLabel: string
meta: string
/** Source of the content — drives the WhatsApp share message template. */
shareKind: ContentShareKind
coverUrl?: string
coverAlt?: string
subtitle?: string
@ -22,7 +26,7 @@ type CampaignContentCardProps = {
}
const cardClassName = (featured: boolean) =>
`group flex h-full flex-col rounded-xl border border-(--campaign-line) bg-white p-3 shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) ${
`flex h-full flex-col rounded-xl border border-(--campaign-line) bg-white p-3 shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) ${
featured ? 'gap-3' : 'gap-2'
}`
@ -30,7 +34,10 @@ const cardClassName = (featured: boolean) =>
* Presentational content card for the campaign home content bento/carousel —
* one spelling for every source (article, YouTube, Instagram in S3). Internal
* targets use `Link`; external ones open on the platform in a new tab with
* `noopener` (no inline player, per the approved wireframe).
* `noopener` (no inline player, per the approved wireframe). The card body is
* one anchor; the S4 share control lives as an absolutely positioned sibling
* (a button inside the anchor would be invalid interactive nesting), on a
* wrapper that also owns the hover `group` so the cover zoom keeps working.
*/
export const CampaignContentCard = ({ card, featured = false }: CampaignContentCardProps) => {
const inner = (
@ -69,18 +76,23 @@ export const CampaignContentCard = ({ card, featured = false }: CampaignContentC
</>
)
return card.external ? (
<a
href={card.href}
target="_blank"
rel="noopener noreferrer"
className={cardClassName(featured)}
>
{inner}
</a>
) : (
<Link href={card.href} className={cardClassName(featured)}>
{inner}
</Link>
return (
<div className="group relative h-full">
{card.external ? (
<a
href={card.href}
target="_blank"
rel="noopener noreferrer"
className={cardClassName(featured)}
>
{inner}
</a>
) : (
<Link href={card.href} className={cardClassName(featured)}>
{inner}
</Link>
)}
<ContentShareButton kind={card.shareKind} title={card.title} href={card.href} />
</div>
)
}

View file

@ -48,6 +48,7 @@ const toArticleCardData = (post: Post): DatedCard | null => {
href,
title: post.title,
badgeLabel: POST_TYPE_BADGE_LABELS[post.type],
shareKind: 'article' as const,
meta: [formatRelativePostDate(post.publishedDate), categoryName].filter(Boolean).join(' · '),
...(cover?.url ? { coverUrl: cover.url, coverAlt: cover.alt ?? undefined } : {}),
...(post.subtitle ? { subtitle: post.subtitle } : {}),
@ -62,6 +63,7 @@ const toVideoCardData = (video: YouTubeVideo): DatedCard => ({
href: `https://www.youtube.com/watch?v=${video.id}`,
title: video.title,
badgeLabel: 'YouTube',
shareKind: 'video' as const,
meta: [
formatRelativePostDate(video.publishedAt),
video.viewCount != null ? `${formatYouTubeViews(video.viewCount)} visualizações` : null,
@ -80,6 +82,7 @@ const toInstagramCardData = (post: InstagramPost): DatedCard => ({
href: post.permalink,
title: post.caption ?? 'Publicação no Instagram',
badgeLabel: 'Instagram',
shareKind: 'instagram' as const,
meta: formatRelativePostDate(post.timestamp) ?? '',
coverAspect: 'square',
...(post.thumbnailUrl

View file

@ -0,0 +1,109 @@
'use client'
import { WhatsAppIcon } from '@/components/socialIcons'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import {
buildContentShareLink,
buildContentShareWhatsAppUrl,
type ContentShareKind,
} from '@/lib/contentShare'
import { CheckIcon, CopyIcon, Share2Icon } from 'lucide-react'
import { useEffect, useState } from 'react'
const COPY_FEEDBACK_RESET_MS = 2000
type CopyFeedback = 'idle' | 'copied' | 'error'
const COPY_LABELS: Record<CopyFeedback, string> = {
idle: 'Copiar link',
copied: 'Link copiado',
error: 'Não foi possível copiar',
}
const COPY_LIVE_MESSAGES: Record<CopyFeedback, string> = {
idle: '',
copied: 'Link copiado.',
error: 'Não foi possível copiar o link.',
}
type ContentShareButtonProps = {
kind: ContentShareKind
title: string
href: string
}
/**
* Discreet per-card WhatsApp share control for the home content section (S4):
* a small round button in the card corner opening a two-item popover — share
* on WhatsApp with a pre-configured message (`wa.me` of the sender's own
* WhatsApp, new tab with `noopener`) or copy the absolute link. It lives as a
* sibling of the card anchor (never inside it — interactive inside an anchor
* is invalid HTML), and the Popover portals its content so the mobile
* carousel's `overflow-x-auto` track never clips it.
*/
export const ContentShareButton = ({ kind, title, href }: ContentShareButtonProps) => {
const [open, setOpen] = useState(false)
const [copyFeedback, setCopyFeedback] = useState<CopyFeedback>('idle')
const [origin] = useState(() => (typeof window === 'undefined' ? '' : window.location.origin))
useEffect(() => {
if (copyFeedback === 'idle') return
const reset = setTimeout(() => setCopyFeedback('idle'), COPY_FEEDBACK_RESET_MS)
return () => clearTimeout(reset)
}, [copyFeedback])
const link = buildContentShareLink(href, origin || href)
const whatsAppUrl = buildContentShareWhatsAppUrl(kind, title, link)
const copyLink = async () => {
try {
await navigator.clipboard.writeText(link)
setCopyFeedback('copied')
} catch {
setCopyFeedback('error')
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Compartilhar"
className="absolute top-2 right-2 z-10 flex h-11 w-11 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-(--campaign-ink) shadow transition-colors hover:bg-(--campaign-band)">
<Share2Icon className="size-4" aria-hidden="true" />
</span>
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-52 p-1">
<a
href={whatsAppUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className="flex min-h-11 items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-(--campaign-ink) hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
>
<WhatsAppIcon className="size-4 text-[#25D366]" />
Compartilhar no WhatsApp
</a>
<button
type="button"
onClick={copyLink}
className="flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-(--campaign-ink) hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
>
{copyFeedback === 'copied' ? (
<CheckIcon className="size-4 text-green-600" aria-hidden="true" />
) : (
<CopyIcon className="size-4" aria-hidden="true" />
)}
{COPY_LABELS[copyFeedback]}
</button>
<span aria-live="polite" className="sr-only">
{COPY_LIVE_MESSAGES[copyFeedback]}
</span>
</PopoverContent>
</Popover>
)
}

48
src/lib/contentShare.ts Normal file
View file

@ -0,0 +1,48 @@
import { buildWhatsAppTextShareUrl } from '@/lib/phone'
/** Content source of a home content card — drives the share message template. */
export type ContentShareKind = 'article' | 'video' | 'instagram'
/**
* 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}".
*/
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: ',
}
/** Pre-configured share message: opener + title + absolute link. */
export const buildContentShareMessage = (
kind: ContentShareKind,
title: string,
link: string,
): string => `${CONTENT_SHARE_PREFIXES[kind]}${title} — ${link}`
/**
* Resolve the shared link to an absolute URL against the visitor's origin.
* Article paths are relative (`/noticia/saude/<slug>`); YouTube/Instagram
* hrefs are already absolute and pass through unchanged. Fail-soft on a
* malformed href (a bad feed permalink must degrade the share link, never
* crash the whole home section).
*/
export const buildContentShareLink = (href: string, origin: string): string => {
try {
return new URL(href, origin).toString()
} catch {
return href
}
}
/**
* `wa.me` URL with the pre-filled message — the sender's own WhatsApp, no
* recipient phone (reuses the `phone.ts` text-share builder). Callers must
* open it in a new tab with `noopener noreferrer`.
*/
export const buildContentShareWhatsAppUrl = (
kind: ContentShareKind,
title: string,
link: string,
): string => buildWhatsAppTextShareUrl(buildContentShareMessage(kind, title, link))

View file

@ -1,13 +1,10 @@
import { buildWhatsAppTextShareUrl } from '@/lib/phone'
/** Share payload for a home-search hit — title plus canonical detail URL. */
export const buildHomeSearchShareText = (title: string, absoluteUrl: string): string =>
`${title}\n${absoluteUrl}`
/** `wa.me` share link with pre-filled text (no recipient phone). */
export const buildWhatsAppTextShareUrl = (message: string): string => {
const url = new URL('https://wa.me/')
url.searchParams.set('text', message)
return url.toString()
}
export { buildWhatsAppTextShareUrl }
const canUseNativeWebShare = (): boolean =>
typeof window !== 'undefined' &&

View file

@ -59,6 +59,13 @@ export const buildWhatsAppUrl = (phone: string, message?: string): string => {
return url.toString()
}
/** `wa.me` share link with pre-filled text but no recipient — the sender's own WhatsApp picks the chat (PetitionSuccessDialog precedent). */
export const buildWhatsAppTextShareUrl = (message: string): string => {
const url = new URL('https://wa.me/')
url.searchParams.set('text', message)
return url.toString()
}
/** `wa.me` href for a row's phone, or `null` when it doesn't normalize — the shared guard behind every per-row WhatsApp button in the campaign lists. */
export const whatsAppHrefForPhone = (phone: string | null): string | null => {
if (!phone || !normalizeBrazilianPhone(phone)) return null

View file

@ -1,4 +1,4 @@
import type { APIRequestContext, Page } from '@playwright/test'
import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { seedTestUser, testUser } from '../helpers/seedUser'
import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub'
@ -1376,4 +1376,156 @@ test.describe('Campaign home content section', () => {
await cleanupS2Fixtures(request, headers)
}
})
test('shares every content card on WhatsApp and copies the link (S4)', async ({
page,
request,
context,
}) => {
await seedTestUser()
const headers = await adminHeaders(request)
await setYouTubeStubState(request, 'ok')
await setInstagramStubState(request, 'ok')
const shareTagSlug = `e2e-share-${runSuffix}`
const shareTag = await createTag(request, headers, `E2e Share ${runSuffix}`, shareTagSlug)
const articleTitle = 'E2e Artigo do compartilhar'
await createPost(request, headers, {
title: articleTitle,
slug: `e2e-artigo-compartilhar-${runSuffix}`,
type: 'noticia',
category: shareTag.id,
publishedDate: new Date(Date.now() - 5 * 60_000).toISOString(),
})
// Both feeds on, no exclusions: the board holds one card of each source
// (newest article featured + Instagram/YouTube from the stubs).
await updateSocialFeedSettings(request, headers, {
enabled: true,
youtubeEnabled: true,
youtubeApiKey: 'e2e-youtube-api-key',
youtubeChannelId: 'UCe2eTestChannel',
youtubeMaxItems: 3,
instagramEnabled: true,
instagramAccessToken: 'e2e-ig-token',
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [],
youtubeFeedSnapshot: null,
instagramFeedSnapshot: null,
})
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
try {
await waitForHomeHTML(request, [articleTitle, 'E2e Vídeo em destaque', 'E2e Post do muro'])
await page.setViewportSize({ width: 1280, height: 900 })
await gotoHomeFresh(page)
const section = page.locator('[data-home-section="contents"]')
await expect(section).toBeVisible()
// Every card of the 1+4 bento (CONTENT_SECTION_LIMIT = 5) carries
// exactly one discreet share button.
await expect(section.getByRole('button', { name: 'Compartilhar' })).toHaveCount(5)
const urlBeforeShare = page.url()
const waMessage = async (item: Locator) => {
const href = await item.getAttribute('href')
expect(href).toBeTruthy()
return decodeURIComponent(href!.slice('https://wa.me/?text='.length)).replace(/\+/g, ' ')
}
// Article card (featured): the popover builds the per-source message with
// the canonical absolute article link, opened in a new tab with noopener.
const articleCard = section.getByRole('link', { name: /E2e Artigo do compartilhar/ })
const articleShare = articleCard
.locator('xpath=..')
.getByRole('button', { name: 'Compartilhar' })
await articleShare.click()
await expect(page).toHaveURL(urlBeforeShare)
const waItem = page.getByRole('link', { name: 'Compartilhar no WhatsApp' })
await expect(waItem).toBeVisible()
await expect(waItem).toHaveAttribute('target', '_blank')
await expect(waItem).toHaveAttribute('rel', 'noopener noreferrer')
expect(await waMessage(waItem)).toBe(
`Olha isso do Solla: ${articleTitle} — ${baseURL}/noticia/${shareTagSlug}/e2e-artigo-compartilhar-${runSuffix}`,
)
await page.keyboard.press('Escape')
await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0)
// Copy link puts the absolute URL in the clipboard and swaps the label.
await articleShare.click()
await page.getByRole('button', { name: /Copiar link/ }).click()
await expect(page.getByRole('button', { name: 'Link copiado' })).toBeVisible()
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(`${baseURL}/noticia/${shareTagSlug}/e2e-artigo-compartilhar-${runSuffix}`)
await page.keyboard.press('Escape')
await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0)
// Outside click also closes the popover.
await articleShare.click()
await section.getByRole('heading', { name: 'A caminhada, em tempo real' }).click()
await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0)
// Video card: the platform URL is shared with the video opener.
const videoCard = section.getByRole('link', { name: /E2e Vídeo em destaque/ })
const videoShare = videoCard.locator('xpath=..').getByRole('button', { name: 'Compartilhar' })
await videoShare.click()
expect(await waMessage(page.getByRole('link', { name: 'Compartilhar no WhatsApp' }))).toBe(
'Olha esse vídeo do Solla: E2e Vídeo em destaque — https://www.youtube.com/watch?v=e2e-video-destaque-1',
)
await page.keyboard.press('Escape')
await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0)
// Instagram card: the post opener.
const instagramCard = section.getByRole('link', { name: /E2e Post do muro/ })
const instagramShare = instagramCard
.locator('xpath=..')
.getByRole('button', { name: 'Compartilhar' })
await instagramShare.click()
expect(await waMessage(page.getByRole('link', { name: 'Compartilhar no WhatsApp' }))).toBe(
'Olha esse post do Solla: E2e Post do muro — https://www.instagram.com/p/e2e-ig-muro-1/',
)
await page.keyboard.press('Escape')
// Mobile: the carousel exposes only the active card's share button, with
// a ≥44px touch target around the discreet 28px visual, and the portal
// menu stays fully inside the viewport (never clipped by the track).
await page.setViewportSize({ width: 390, height: 844 })
await gotoHomeFresh(page)
const carousel = page.getByRole('region', { name: 'Conteúdos recentes' })
await expect(carousel).toBeVisible()
// The active (featured) card is the newest item — the article seeded
// 5 min ago against the stubs' 20-min-oldest — which pins the identity
// of the single exposed share button below.
await expect(carousel.getByRole('heading', { name: articleTitle })).toBeVisible()
const mobileShare = carousel.getByRole('button', { name: 'Compartilhar' })
await expect(mobileShare).toHaveCount(1)
await mobileShare.scrollIntoViewIfNeeded()
const buttonBox = await mobileShare.boundingBox()
expect(buttonBox).not.toBeNull()
expect(buttonBox!.width).toBeGreaterThanOrEqual(44)
expect(buttonBox!.height).toBeGreaterThanOrEqual(44)
const visualBox = await mobileShare.locator('span').first().boundingBox()
expect(visualBox).not.toBeNull()
expect(Math.abs(visualBox!.width - 28)).toBeLessThanOrEqual(1)
expect(Math.abs(visualBox!.height - 28)).toBeLessThanOrEqual(1)
await mobileShare.click()
const mobileWaItem = page.getByRole('link', { name: 'Compartilhar no WhatsApp' })
await expect(mobileWaItem).toBeVisible()
const mobileWaBox = await mobileWaItem.boundingBox()
expect(mobileWaBox).not.toBeNull()
expect(mobileWaBox!.x).toBeGreaterThanOrEqual(0)
expect(mobileWaBox!.x + mobileWaBox!.width).toBeLessThanOrEqual(390)
expect(await waMessage(mobileWaItem)).toBe(
`Olha isso do Solla: ${articleTitle} — ${baseURL}/noticia/${shareTagSlug}/e2e-artigo-compartilhar-${runSuffix}`,
)
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
} finally {
await setYouTubeStubState(request, 'ok')
await setInstagramStubState(request, 'ok')
await cleanupS2Fixtures(request, headers)
}
})
})

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import {
buildContentShareLink,
buildContentShareMessage,
buildContentShareWhatsAppUrl,
CONTENT_SHARE_PREFIXES,
type ContentShareKind,
} from '@/lib/contentShare'
describe('buildContentShareMessage', () => {
it('uses the article opener for articles', () => {
expect(buildContentShareMessage('article', 'Título do artigo', 'https://site.com/artigo')).toBe(
'Olha isso do Solla: Título do artigo — https://site.com/artigo',
)
})
it('uses the video opener for YouTube cards', () => {
expect(
buildContentShareMessage('video', 'Título do vídeo', 'https://www.youtube.com/watch?v=abc'),
).toBe('Olha esse vídeo do Solla: Título do vídeo — https://www.youtube.com/watch?v=abc')
})
it('uses the post opener for Instagram cards', () => {
expect(
buildContentShareMessage('instagram', 'Legenda do post', 'https://www.instagram.com/p/xyz/'),
).toBe('Olha esse post do Solla: Legenda do post — https://www.instagram.com/p/xyz/')
})
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: ',
})
})
})
describe('buildContentShareLink', () => {
it('resolves a relative article path against the origin', () => {
expect(
buildContentShareLink('/noticia/saude/sus-pela-vida', 'https://jorgesolla1313.com.br'),
).toBe('https://jorgesolla1313.com.br/noticia/saude/sus-pela-vida')
})
it('passes absolute platform URLs through unchanged', () => {
const videoUrl = 'https://www.youtube.com/watch?v=abc123'
expect(buildContentShareLink(videoUrl, 'https://jorgesolla1313.com.br')).toBe(videoUrl)
const postUrl = 'https://www.instagram.com/p/xyz/'
expect(buildContentShareLink(postUrl, 'https://jorgesolla1313.com.br')).toBe(postUrl)
})
it('resolves a trailing-slash origin without double slashes', () => {
expect(buildContentShareLink('/artigo', 'https://site.com/')).toBe('https://site.com/artigo')
})
it('fails soft on a malformed href instead of throwing', () => {
const broken = 'https://[malformed'
expect(buildContentShareLink(broken, 'https://site.com')).toBe(broken)
})
})
/** Decode the `text` query param exactly as a URLSearchParams consumer would — `+` is space. */
const decodeWaText = (url: string): string =>
decodeURIComponent(url.slice('https://wa.me/?text='.length)).replace(/\+/g, ' ')
describe('buildContentShareWhatsAppUrl', () => {
it('builds wa.me with the encoded pre-configured message', () => {
const url = buildContentShareWhatsAppUrl(
'article',
'Título',
'https://site.com/noticia/saude/um',
)
expect(url.startsWith('https://wa.me/?text=')).toBe(true)
expect(decodeWaText(url)).toBe('Olha isso do Solla: Título — https://site.com/noticia/saude/um')
})
it('encodes accents, spaces and the em dash', () => {
const url = buildContentShareWhatsAppUrl(
'video',
'Ação em Salvador',
'https://site.com/video?x=1&y=2',
)
expect(url).not.toMatch(/[áàãéêç—& ]/)
expect(decodeWaText(url)).toBe(
'Olha esse vídeo do Solla: Ação em Salvador — https://site.com/video?x=1&y=2',
)
})
it('keeps every kind producing the same wa.me shape', () => {
const kinds: ContentShareKind[] = ['article', 'video', 'instagram']
for (const kind of kinds) {
expect(buildContentShareWhatsAppUrl(kind, 'T', 'https://site.com/x')).toMatch(
/^https:\/\/wa\.me\/\?text=/,
)
}
})
})