Merge pull request 'S4 — Compartilhar conteúdos da home no WhatsApp' (#86) from S4-compartilhar-conteudos-da-home-no-whatsapp into main
This commit is contained in:
commit
fbfbfb1329
11 changed files with 557 additions and 23 deletions
|
|
@ -10,7 +10,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**OPS67** — o cliente `scripts/lib/forgejo-api.mjs` ganhou **retry com backoff** nos fetchs: um `fetch failed` transitório (rede/DNS/reset — o sintoma que matava o run do safety net `agent-pr-ready-automerge.yml` no meio da espera de checks) agora retenta em **qualquer método**, e a leitura de body interrompida no meio (mesma classe de falha de rede) retenta **em GET**; 5xx do Cloudflare (502/503/504) retenta **só em GET** (protege o poll de 45 min do `waitForChecks` sem arriscar duplicar efeitos colaterais de escrita — body do 5xx cancelado antes do retry para liberar o socket); 4xx e body-drop em escrita continuam falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, `console.warn` pt-BR em cada retry (`tentativa N/4`), validação `retries >= 0`/`backoffMs >= 0`. Opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 12 specs novas em `forgejoApi.unit.spec.ts` (rede GET/POST → sucesso, body-drop GET retenta + POST fail-closed, exaustão de rede/5xx, `retries: 0`, backoff exponencial [100, 200, 400], jitter ±20% com `Math.random` pinado, 4xx sem retry, 5xx-GET retry/exaustão, 5xx-POST sem retry) + pin no spec 403 existente (1 fetch por invocação). Verificação: unit 2403 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: `docs/plans/ops67-forgejo-api-retry-backoff-impl.md` (aprovado no gate humano; refinamentos do /simplify absorvidos na execução).
|
||||
|
||||
**OPS67** — o cliente `scripts/lib/forgejo-api.mjs` ganhou **retry com backoff** nos fetchs: um `fetch failed` transitório (rede/DNS/reset — o sintoma que matava o run do safety net `agent-pr-ready-automerge.yml` no meio da espera de checks) agora retenta em **qualquer método**; 5xx do Cloudflare (502/503/504) retenta **só em GET** (protege o poll de 45 min do `waitForChecks` sem arriscar duplicar efeitos colaterais de escrita); 4xx continua falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, `console.warn` pt-BR em cada retry (`tentativa N/4`). Opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 6 specs novas em `forgejoApi.unit.spec.ts` (rede transitória→sucesso, rede persistente→throw após N, 4xx sem retry, 5xx-GET retry, 5xx-POST sem retry, backoff exponencial [100, 200, 400] + pin do 403 existente com calls=1). Verificação: unit 2397 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: `docs/plans/ops67-forgejo-api-retry-backoff-impl.md` (aprovado no gate humano).
|
||||
**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`.
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-08-19-s4.md
Normal file
1
docs/changelog/2026-08-19-s4.md
Normal 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).
|
||||
107
docs/plans/compartilhar-conteudos-home-whatsapp-impl.md
Normal file
107
docs/plans/compartilhar-conteudos-home-whatsapp-impl.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# 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)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
109
src/components/ContentShareButton.tsx
Normal file
109
src/components/ContentShareButton.tsx
Normal 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
48
src/lib/contentShare.ts
Normal 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))
|
||||
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
98
tests/unit/contentShare.unit.spec.ts
Normal file
98
tests/unit/contentShare.unit.spec.ts
Normal 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=/,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue