# Compartilhar página do núcleo Status: **implementado** (2026-07-18) Atualizado em: 2026-07-18 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C → C1) Responsável: — ## Como foi implementado (2026-07-18) Entregue com `ShareNucleusDialogShell` + `ShareNucleusDialog` (`src/components/campaign/`), `getNucleusShareRecipients` (`src/utilities/nucleusShareRecipients.ts`) e a Server Action `loadNucleusShareRecipients` (`src/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions.ts`). Botão no header do detalhe, template de mensagem e `wa.me` + copiar link conforme o plano. Desvios e refinamentos em relação ao texto original: - **Carregamento sob demanda, não no `Promise.all` da página.** Os destinatários são buscados por uma Server Action de leitura só quando o diálogo abre (shell lazy no padrão dos demais diálogos), evitando serializar telefones no HTML da página. - **Leituras privilegiadas atrás do gate do núcleo.** Em vez de depender do field access por linha (`overrideAccess: false`, que causaria N+1 checagens de telefone), o loader confirma primeiro o acesso do ator ao núcleo (`overrideAccess: false`) e só então lê as três seções com `overrideAccess: true`, retornando apenas `{ id, name, phone }`. - **Seção "Com liderança" restrita a `supportStatus = 'engajado'`** e omitida para o papel `lideranca` (o loader retorna lista vazia; o componente oculta seções vazias). - **`canReadCampaignUserPhone` ampliado:** telefone de usuários `geral` é legível por qualquer usuário de campanha autenticado (para contato), mantendo a regra de escopo por núcleo para os demais. - **UI: Dialog central único** (não Sheet/Drawer responsivo); destinatário sem telefone não aparece; estados de loading/erro/retry e "copiado" implementados. Testes: `tests/int/campaignNucleusShareRecipients.int.spec.ts`, `tests/int/campaignUserPhone.int.spec.ts`, `tests/unit/campaignNucleusShare.unit.spec.ts`. O restante do documento é o plano original, mantido como registro. ## Referência visual (UX Pilot) Design: [`Compartilhar-Nucleo.png`](../design-refs/latest/Compartilhar-Nucleo.png) · [`Compartilhar-Nucleo.html`](../design-refs/latest/Compartilhar-Nucleo.html) ![Compartilhar núcleo](../design-refs/latest/Compartilhar-Nucleo.png) Como usar: - **Adotar a estrutura:** bottom sheet no mobile com título "Compartilhar núcleo", subtítulo reforçando "envia apenas o link, não concede acesso" (copy que materializa a decisão travada), três seções de destinatários ("Com coordenação geral", "Com coordenador", "Com liderança" — nome + papel + botão verde "Abrir" com ícone WhatsApp) e a seção "Copiar link" com a URL canônica truncada + botão "Copiar". - **Ajuste em relação ao plano:** o plano especifica `Dialog` central (como `LeadershipInviteDialog`); o design mostra bottom sheet. Adotar o padrão responsivo já usado no painel de lideranças (Sheet/Drawer no mobile, Dialog no desktop) — a estrutura interna do design vale para ambos. - **Lembrar das regras que o design não mostra:** ocultar a seção "Com liderança" para o papel `lideranca`; ocultar destinatários sem telefone; ocultar seções vazias; estado "copiado" após o clique. - **Ajustar cores:** paleta antiga no HTML/PNG (header vermelho escuro, navy). Implementar com os tokens do tema `campaign` e componentes `src/components/ui`; avatares com foto viram iniciais. ## Contexto Hoje o detalhe do núcleo (`/campanha/nucleos/[slug]`) só tem ações de escrita (Editar, Nova liderança, Nova atualização, Arquivar). Não existe um caminho rápido para um usuário da campanha mandar o link daquele núcleo a outra pessoa do mesmo núcleo. A decisão de produto (2026-07-17) é adicionar um botão **Compartilhar** que oferece quatro destinos: coordenação geral, coordenador, liderança ou copiar link. Para os três primeiros, abre o WhatsApp via `wa.me` com o telefone do destinatário — o mesmo padrão já usado pelos convites, **sem WhatsApp Business API**. Importante: compartilhar **não concede acesso**. O link aponta para `/campanha/nucleos/[slug]`, rota autenticada que já filtra por escopo (`canReadElectoralNucleus` em `src/utilities/campaignAccess.ts`). O destinatário só vê o núcleo se tiver sessão de campanha válida e o núcleo no seu escopo. O compartilhamento é só conveniência — envia o link, não cria convite, não gera token, não toca em `Consent`. ## Objetivos - Botão de ação "Compartilhar" no header do detalhe do núcleo, ao lado das ações existentes. - Quatro destinos: coordenação geral, coordenador, liderança, copiar link. - Para os três primeiros, abrir `wa.me` com o telefone do destinatário e uma mensagem pronta (remetente + nome do núcleo + link). - Reusar o padrão `wa.me` dos convites (`buildWhatsAppUrl` em `src/utilities/phone.ts` e `buildCampaignInviteWhatsAppLink` em `src/utilities/campaignInvite.ts` como referência de texto). - Manter access control existente: nenhum dado sensível (telefone) é exposto a quem já não pudesse lê-lo. Sem server action de escrita, sem collection nova, sem `Consent`. ## Decisões travadas - **Escopo: só o detalhe do núcleo.** O botão vive no header de `src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx`. A listagem de núcleos (`NucleusCard`) fica fora deste ciclo (ver "Não escopo"). - **Compartilhar não é convite.** Não cria `campaignInvite`, não gera token, não expira, não revoga. Reusa o padrão `wa.me` apenas como canal (mesma decisão do notebook: "Convidar pelo WhatsApp" cria convite; aqui não). - **Sem WhatsApp Business API.** Mesma justificativa do roadmap linha 57: o remetente envia pelo seu próprio WhatsApp, abrindo `wa.me` em nova aba com `noopener,noreferrer` (igual ao `LeadershipInviteDialog`). - **Sem server action.** A URL `wa.me` é montada no cliente a partir do telefone do destinatário (já carregado no server) + mensagem com remetente/nome do núcleo/link. Nenhuma escrita, nenhuma transação, nenhum `req`. - **Access control por `overrideAccess: false`.** Os telefones dos destinatários vêm de queries com `user` + `overrideAccess: false`, então o field access existente (`canReadCampaignUserPhone`, `canReadContacts`) filtra naturalmente quem pode ver qual telefone. O plano não inventa regra nova. - **Link compartilhado = URL canônica do núcleo.** `${NEXT_PUBLIC_SITE_URL}/campanha/nucleos/${slug}` — reusa `getCampaignInviteBaseURL()` de `src/utilities/campaignInvite.ts` para derivar a origem (mesmo guard de produção/localhost). - **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`ShareNucleusDialog`, `getNucleusShareRecipients`), strings visíveis em pt-BR. - **`lideranca` compartilha, mas sem "Com liderança".** `lideranca` vê "Com coordenação geral", "Com coordenador" e "Copiar link". A seção "Com liderança" fica oculta para esse papel: o escopo de `canReadLeadership` para `lideranca` é `user = self`, então só retornaria a própria liderança, o que é inútil. - **`lideranca` lendo telefone de coordenador/geral.** `canReadCampaignUserPhone` já aprova leitura quando o alvo é coordenador de um núcleo acessível ao usuário, e `lideranca` tem núcleos acessíveis via liderança engajada. Mantém-se a política atual do app; se produto quiser bloquear, é mudar `canReadCampaignUserPhone`, não este plano. - **`geral` é destinatário sim.** Além de coordenador e liderança, a coordenação geral (`campaignUser.role = 'geral'`) também é destinatária — própria seção "Com coordenação geral", listando todos os usuários `geral` (name+telefone, `overrideAccess: false`). Qualquer `geral` é destinatário válido de qualquer núcleo porque vê todos. - **Loader dedicado.** `getNucleusShareRecipients` seleciona só nome+telefone de `geral`, coordenadores e lideranças do núcleo, `overrideAccess: false`, sem paginação (volume por núcleo é baixo). Evita acoplar ao estado de filtro/paginação da aba de lideranças e evita vazar telefone de quem o ator não pode ver. - **Destinatário sem telefone é ocultado.** Sem telefone não há `wa.me`; o destinatário simplesmente não aparece na lista (em vez de aparecer desabilitado). - **Núcleo arquivado também compartilha.** O link funciona para quem tem acesso; o botão permanece visível mesmo com `status = 'arquivado'`. - **UI: Dialog.** Dialog central (como `LeadershipInviteDialog`), reusando `src/components/ui/dialog` e o padrão visual do `LeadershipInviteDialog` — dá espaço para listar destinatários em mobile. - **Texto da mensagem (pt-BR).** Template fixo: `Oi {destinatário}, aqui é {remetente} da campanha do Solla. Veja o núcleo {nome}: {link}`. ## Abordagem proposta ```mermaid flowchart LR Page["NucleusDetailPage (server)
getNucleusShareRecipients
overrideAccess:false"] Header["Header de ações
botão Compartilhar"] Dialog["ShareNucleusDialog (client)
4 destinos"] Geral["Com coordenação geral
lista {name,phone}
buildWhatsAppUrl"] Coord["Com coordenador
lista {name,phone}
buildWhatsAppUrl"] Lider["Com liderança
lista {name,phone}
buildWhatsAppUrl"] Copy["Copiar link
navigator.clipboard"] Wa["wa.me
window.open noopener,noreferrer"] Recipient["Destinatário
abre /campanha/nucleos/slug
auth + escopo existente"] Page --> Header Header --> Dialog Dialog --> Geral Dialog --> Coord Dialog --> Lider Dialog --> Copy Geral --> Wa Coord --> Wa Lider --> Wa Wa --> Recipient ``` Componentes: - **`getNucleusShareRecipients`** (em `src/utilities/nucleusShareRecipients.ts`): recebe `payload`, `user`, `nucleusSlug`. Carrega o núcleo por slug (`overrideAccess: false`, `depth: 0`, `select: { coordinators: true }`) para confirmar acesso e obter IDs dos coordenadores. Depois: - Coordenação geral: `payload.find({ collection: 'campaignUser', where: { role: { equals: 'geral' } }, select: { name: true, phone: true }, overrideAccess: false, user })`. - Coordenadores: `payload.find({ collection: 'campaignUser', where: { and: [{ id: { in: coordinatorIds } }, { role: { equals: 'coordenador' } }] }, select: { name: true, phone: true }, overrideAccess: false, user })` (filtra `coordenador` para não duplicar `geral` já listado; `geral` eventualmente presente em `coordinators` aparece só na seção de coordenação geral). - Lideranças: `payload.find({ collection: 'leadership', where: { nucleus: { equals: nucleusId } }, depth: 1, select: { contact: true }, overrideAccess: false, user })` → extrai `{ id, name, phone }` do `contact` populado. - Retorna `{ general: Recipient[], coordinators: Recipient[], leaderships: Recipient[], nucleusUrl }`, onde `Recipient = { id, name, phone }`. Telefones que o ator não pode ler chegam como `null`/ausente e são filtrados pelo componente. Para `lideranca`, a query de lideranças já retorna só a própria (escopo do access), e o componente oculta a seção. - **`ShareNucleusDialog`** (client, em `src/components/campaign/`): botão "Compartilhar" (`ShareIcon`) que abre `Dialog`. Dentro, quatro seções: - **Com coordenação geral** — lista de usuários `geral` com botão "Abrir WhatsApp" cada. - **Com coordenador** — lista de coordenadores do núcleo. Oculta quando vazia. - **Com liderança** — lista de lideranças do núcleo. Oculta para `lideranca` (e quando vazia). - **Copiar link** — `navigator.clipboard.writeText(nucleusUrl)` com estado "copiado" (reusar o padrão de `copied` do `LeadershipInviteDialog`). - Cada ação de WhatsApp monta `buildWhatsAppUrl(phone, message)` com `message = "Oi {name}, aqui é {senderName} da campanha do Solla. Veja o núcleo {nucleusName}: {nucleusUrl}"` e abre com `window.open(url, '_blank', 'noopener,noreferrer')`. - Reusa ``/``/`